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)を外から渡せる形にしました。
function createGomi({ store, notify, now = Date.now, setTimer = setInterval, clearTimer = clearInterval, log = console }) {
テストでは保存先をメモリーにして、時計を clock.t という数にします。
// 偽物の時計(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 にたまります。
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 つです。
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 にもそのまま渡しています。
rule: { type: "string", max: 20, pattern: RULE_PATTERN, desc: "毎週月・木 か 第2・4水" },
本体は run を呼ぶ前に、pattern を値の全体に当てて確かめ、合わなければ「rule の形が違います」のような理由で断ります。隔週のような決まり方は、頭が書いてきてもここで断ります。
受け取ったルールは、中身の側でもう一度読みます。
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 曜日を数える
// その月の何回目の曜日か(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 に任せる
前の晩の知らせは「明日」を見るので、月末や大みそかの晩には、次の月・次の年の日付が要ります。
// 日付から 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 回見ます。
const CHECK_MS = 60 * 1000; // 時刻を見る間隔(PC のスリープからの復帰にも追いつく)
const LATE_MIN = 180; // 知らせる時刻をこれ以上(分)過ぎていたら、その回は知らせない
// (昼に起動したのに「今日は〜」と言わないため)
見回りの中身が tick です。
// 時刻を見て、知らせる回になっていれば知らせる。知らせた回を返す(テスト用)
// 同じ日には 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 で本体とつなぐ
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 で あまね が動いている間だけです
おわりに
決まった時刻に知らせる追加機能は、時計を外から渡せるようにしておくと、何月何日の何時でもテストで再現できました。受け取る形を決めること、知らせる前に記録することと合わせて、ほかの「見張って知らせる」追加機能にも使える作りだと考えています。
- GitHub: https://github.com/ai-amane/ai-amane
- ゴミの日の ZIP(plugin.js・gomi-core.js・README.md): https://github.com/ai-amane/ai-amane/raw/main/samples/plugins/gomi.zip
- 入れ方と話しかけ方(使い方の記事): https://note.com/aiamane_official/n/n596080eb9cf8
この記事は、中の人が生成 AI(Claude)を使って下書きし、載せたコードは公開版の本体で動くことを確かめてから出しています。