1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

音声 AI 秘書「AIあまね」の追加機能の作り方: ゴミの日(第 n 曜日の計算・二重に知らせない記録・時計を進めるテスト)

1
Posted at

AI あまね の公式アカウントです(中の人は、あまね を作っている個人開発者です)。

AI あまね は、自分の PC で動く音声 AI エージェントです。返事を考える頭には Claude Code を使っていて、Claude の有料プラン(Pro 以上)の利用枠を使います(2026 年 10 月時点)。MIT ライセンスで公開しています。

あまね には、plugins フォルダにフォルダを 1 つ入れるだけで機能を足せる、追加機能(プラグイン)の仕組みがあります。この記事では、見本として作った追加機能「ゴミの日」の作りを書きます。

  • 「燃えるゴミは毎週月曜と木曜」と声で言うと、収集日のルールを登録する
  • 前の晩と朝に「明日は「燃えるゴミ」の日です。」のように声で知らせる
  • 同じ日に、同じ回の知らせを二度は言わない
  • 起動し直しても、登録と「もう知らせたか」を覚えている

入れ方と話しかけ方は、使い方の記事(最後にリンク)に書いたので、ここでは作りの話だけをします。コードは要所だけを実物のまま載せます。全文は最後のリンクの ZIP にあります。

全体の形

ファイルは、本体とのつなぎの plugin.js と、中身の gomi-core.js(ルールの読み方・日付の計算・知らせる時刻の見張り・保存)に分けました。

本体の流れは、公開版の docs/plugins.md のとおりです。頭が返事の中に <act do="gomi.set" name="燃えるゴミ" rule="毎週月・木"/> のようなタグを書くと、画面がそれをサーバーに送り、サーバーは引数を params の決まりで確かめてから run を呼びます。

動作 種類 すること
gomi.set command 種類と収集日のルールを登録する(同じ名前は上書き)
gomi.remove command 登録を消す
gomi.list query 登録と、知らせる時刻を答える
gomi.next query 今日・明日・明後日のゴミか、その種類の次の収集日を答える
gomi.settings command 知らせる時刻(前の晩・朝)とオン/オフを変える

query は、run が返した文を頭が言葉にして話します。command は結果が会話ログに出るだけです。

時計を差し替えられるようにする

docs/plugins.md にも「時間の計算や外部との通信を plugin.js から分けると、node --test で確かめやすくなります」とあります。中身を作る関数は、今の時刻(now)と 1 分ごとの見張り(setTimer / clearTimer)を外から渡せる形にしました。

gomi/gomi-core.js
function createGomi({ store, notify, now = Date.now, setTimer = setInterval, clearTimer = clearInterval, log = console }) {

テストでは保存先をメモリーにして、時計を clock.t という数にします。

test/gomi.test.js
// 偽物の時計(t を書き換えて時間を進める)
function setup({ store = memoryStore(), start = at(2026, 10, 13, 12, 0), rules = [] } = {}) {
  const clock = { t: start };
  const said = [];
  const gomi = createGomi({ store, notify: (text) => said.push(text), now: () => clock.t, log: { warn() {} } });
  gomi.load();
  for (const r of rules) gomi.set(r);
  return { gomi, clock, said, store };
}

at(年, 月, 日, 時, 分) は、その日時(PC の時刻)のミリ秒を返す小さな関数です。clock.t を書き換えて tick()(時刻を見て、知らせる回なら知らせる関数)を呼ぶと、知らせた文が said にたまります。

test/gomi.test.js
  clock.t = at(2026, 10, 13, 20, 0);
  assert.deepEqual(gomi.tick(), ["evening"]);
  assert.deepEqual(said, ["明日は「びん・缶」の日です。"]);

この記事の挙動は、どれもこの形のテストで確かめています。

1. ルールは 2 つの形だけ受け取る

追加機能が受け取るルールは、毎週月・木(毎週 月・木)と 第2・4水(第 2・第 4 水)の 2 つの形だけにしました。「第2と第4水曜」のような話し言葉をこの形に直すのは、頭に任せます(prompt に、直し方の例を書いています)。

形は正規表現 1 つです。

gomi/gomi-core.js
const DAY = "[月火水木金土日]";
const RULE_PATTERN = `毎週${DAY}(?:・${DAY}){0,6}|第[1-5](?:・[1-5]){0,4}${DAY}`;
const RULE_RE = new RegExp(`^(?:${RULE_PATTERN})$`, "u");

この RULE_PATTERN を、plugin.js の params にもそのまま渡しています。

gomi/plugin.js
        rule: { type: "string", max: 20, pattern: RULE_PATTERN, desc: "毎週月・木 か 第2・4水" },

本体は run を呼ぶ前に、pattern を値の全体に当てて確かめ、合わなければ「rule の形が違います」のような理由で断ります。隔週のような決まり方は、頭が書いてきてもここで断ります。

受け取ったルールは、中身の側でもう一度読みます。

gomi/gomi-core.js
function parseRule(text) {
  const s = String(text ?? "").trim();
  if (!RULE_RE.test(s)) throw new Error("ルールは「毎週月・木」か「第2・4水」の形で書いてください");
  if (s.startsWith("毎週")) return { kind: "weekly", days: uniqSorted([...s.slice(2)].filter((c) => c !== "・").map(dayIndex)) };
  const m = s.match(/^第([1-5・]+)(.)$/u);
  return { kind: "nth", weeks: uniqSorted(m[1].split("・").map(Number)), day: dayIndex(m[2]) };
}

dayIndex は曜日の文字を Date#getDay() の番号(日曜が 0)にする関数、uniqSorted は重なりを除いて小さい順に並べる関数です。形が違えば Error になるので、本体を通らないとき(テストや、保存したファイルを読み込むとき)も同じ決まりで確かめられます。保存の前には、同じ意味なら同じ文字にそろえます(毎週木・月・月 は 毎週月・木 になります)。

ほかの引数も params で絞っています。

  • name(種類の名前)は、知らせの文にそのまま入るので、空白・句読点・記号なしの 20 字まで(「びん・缶」の「・」と英数字は可)。本体は文字列から改行・制御文字と []<> を取り除いてから渡すので、[お知らせ]<燃える> は お知らせ燃える になります(テストで確かめています)
  • day は enum で、今日・明日・明後日のほかは断る
  • 時刻は 20:00 の形(全角の : も受けて、中身で 07:00 の形にそろえる)。オン/オフは type: "boolean" で、本体が文字列の "false" を false に直して渡す

名前で選ぶとき(消す・次の収集日)は、名前がそのまま合うものを先に探し、無ければ名前の一部が合うものを探します(言われた言葉が 2 字以上のときだけ)。2 つ当たったら「「ゴミ」に当たるものが2つあります(燃えるゴミ・燃えないゴミ)。どれを消すか聞いてください」という Error を投げます。Error の message は、本体が理由として頭に渡します。

2. 第 n 曜日を数える

gomi/gomi-core.js
// その月の何回目の曜日か(1 日〜7 日 → 1、8 日〜14 日 → 2 …)
const nthOfMonth = (date) => Math.ceil(date.getDate() / 7);

「第 n 〇曜」は、その月の n 回目の〇曜日として数えます。曜日が合い、この数がルールの n のどれかなら、その日に当たります。1 回目は必ず 1〜7 日、2 回目は 8〜14 日のどこかにあるので、日付を 7 で割って切り上げれば何回目かが分かります(29〜31 日は 5 回目)。

テストは 2026 年 10 月で確かめています。第2・4水 は 10/14 と 10/28 に当たり、10/7(第 1 水曜)には当たりません。月が変わった 11/4 は第 1 水曜に戻ります。第5木 は 10/29 に当たり、11 月(第 4 木曜の 11/26 まで)には当たりません。

「次の燃えるゴミはいつ?」には、今日から 1 日ずつ進めて最初に当たる日を答えます。第 5 月曜のように何か月もあく収集日もあるので、探すのは 366 日先までです。

3. 月・年をまたぐ日は Date に任せる

前の晩の知らせは「明日」を見るので、月末や大みそかの晩には、次の月・次の年の日付が要ります。

gomi/gomi-core.js
// 日付から n 日あとの Date(夏時間のない日本向け。月またぎ・年またぎは Date に任せる)
const addDays = (date, n) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + n);

new Date(年, 月, 日 + n) は、日がその月の日数を超えると次の月・次の年に繰り上げます。時刻は 0 時になるので、日付だけを比べられます。

「もう知らせたか」の記録に使う 2026-10-28 の形の文字も、PC の時刻(ローカル)の年・月・日から作っています。toISOString() は UTC なので、日本時間の 9 時より前は、前の日の日付になってしまいます。

テストでは、12/31 の 20 時に、翌日(2027 年 1 月 1 日・金曜)の「毎週金曜」の燃えるゴミを知らせることと、10/31 の 20 時に、11/1(第 1 日曜)の古紙を知らせることを確かめています。

4. 1 分ごとに時計を見て、3 時間すぎた回は言わない

知らせる時刻に setTimeout で予約する作りにはせず、1 分ごとに時計を見ます。起動したときも、すぐに 1 回見ます。

gomi/gomi-core.js
const CHECK_MS = 60 * 1000;          // 時刻を見る間隔(PC のスリープからの復帰にも追いつく)
const LATE_MIN = 180;                // 知らせる時刻をこれ以上(分)過ぎていたら、その回は知らせない
                                     // (昼に起動したのに「今日は〜」と言わないため)

見回りの中身が tick です。

gomi/gomi-core.js
  // 時刻を見て、知らせる回になっていれば知らせる。知らせた回を返す(テスト用)
  //  同じ日には 1 回だけ(last に残す)。LATE_MIN 以上過ぎた回・該当が無い日は知らせない
  function tick() {
    const t = new Date(now());
    const nowMin = t.getHours() * 60 + t.getMinutes();
    const slots = [
      { key: "evening", on: settings.eveningOn, at: settings.eveningAt, offset: 1, head: "明日は" },
      { key: "morning", on: settings.morningOn, at: settings.morningAt, offset: 0, head: "今日は" },
    ];
    const done = [];
    for (const s of slots) {
      const diff = nowMin - minutesOf(s.at);
      if (!s.on || diff < 0 || diff >= LATE_MIN || last[s.key] === dayKey(t)) continue;
      const names = trashOn(rules, addDays(t, s.offset));
      if (!names.length) continue;
      last = { ...last, [s.key]: dayKey(t) };
      save();   // 先に残す(知らせの途中で止まっても、起動し直したあとに二重にならない)
      try { notify(`${s.head}${names.map((n) => `「${n}」`).join("、")}の日です。`); } catch (e) { log.warn("[gomi] 知らせられません:", e.message); }
      done.push(s.key);
    }
    return done;
  }

前の晩(はじめは 20:00)は明日のゴミを、朝(はじめは 7:00)は今日のゴミを知らせます。次のときは知らせません。

  • その回がオフ・まだ時刻になっていない
  • 時刻を 3 時間(LATE_MIN = 180 分)以上過ぎている
  • 今日はもう、その回を知らせた(last に今日の日付がある)
  • その日に出すゴミが無い

3 時間で切るのは、昼に PC をつけたときに、朝の「今日は「燃えるゴミ」の日です。」を言わないためです。テストでは、22:00 に起動すれば 20:00 の回を知らせ、23:30 なら知らせないことを確かめています。

5. 「もう知らせた」は、知らせる前に保存する

last は、前の晩と朝の回ごとに、最後に知らせた日を持ちます。ルール・設定といっしょに、本体の ctx.store で data/plugins/gomi.json に保存します。

tick では、知らせる前に last を書き換えて保存しています。知らせの途中で止まって起動し直しても、同じ回を二度言いません。その代わり、知らせに失敗した回は出し直しません(テストで確かめています)。二度言うより、言いそびれるほうを取った形です。保存に失敗したときは黒いウィンドウに出すだけで、動いている間は知らせを続けます。

起動し直しのテストは、同じ保存先から 2 つ目を作って行います。20:05 に知らせたあと、20:30 に起動し直しても知らせず、翌日の 20:00 にはまた知らせます。

読み込むときは、保存したファイルを信じすぎないようにしています。形が正しくないルールや名前、同じ名前の 2 つ目、21 個目から後ろは捨て、時刻やオン/オフがおかしければ既定値に戻します。

6. plugin.js で本体とつなぐ

gomi/plugin.js
    const g = createGomi({
      store: ctx.store,
      log: { warn: ctx.warn },
      notify: (text) => ctx.notify({ text, chime: "notice" }),
    });
    g.load();
    g.start();
    gomi = g;       // 読み込みと開始が済んでから使えるようにする(失敗したら「準備ができていません」のまま)

start で、本体の部品(ctx.store・ctx.warn・ctx.notify)を中身に渡します。gomi に入れて動作から使えるようにするのは、読み込みと開始が済んでからです。その前や、start に失敗したあとに動作が呼ばれたら、「ゴミの日の準備ができていません」という Error を投げます。

  • requires(使う本体の仕組み)は空です。docs/plugins.md の表にある仕組み(confirm など)は使っていません
  • remove には confirm(画面のボタンで確かめてから実行する仕組み)を付けていません。消えるのは登録 1 件で、声でもう一度登録できます

テストの数と、できないこと

テストは、中身のテストが 29 件と、本体の仕組みに plugin.js を読み込ませるテスト(読み込み・引数の検査・「頼む → 登録 → 答える → 保存」の通し)が 3 件です(記事を書いた時点)。公開版の本体の仕組みでも 3 件とも通ることを確かめています。テストのファイルは ZIP に入れていません。

できないことも書いておきます。

  • 祝日・年末年始の収集日の変更は扱いません(聞かれたら、自治体の案内を見てもらうよう頭に伝えています)
  • ルールは「毎週〇曜」と「第 n 〇曜」の 2 つの形だけです
  • 声は聞き間違えます。登録したら「登録しているゴミの日を教えて」で確かめてもらうよう、README に書いています
  • 知らせは、PC で あまね が動いている間だけです

おわりに

決まった時刻に知らせる追加機能は、時計を外から渡せるようにしておくと、何月何日の何時でもテストで再現できました。受け取る形を決めること、知らせる前に記録することと合わせて、ほかの「見張って知らせる」追加機能にも使える作りだと考えています。

この記事は、中の人が生成 AI(Claude)を使って下書きし、載せたコードは公開版の本体で動くことを確かめてから出しています。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?