VRM のキャラクターから話しかけてもらう ―― 朝の挨拶・声かけ・サーバの異常の知らせを、Windows 11 と Windows 98 の両方へ届ける

VRM のキャラクター Haruno が、朝の挨拶・しばらく話していないときの声かけ・家のサーバの異常の知らせを自分から話しかけてくるようにした。押し込めない Windows 98 の IE5 にも届くよう、台詞は1か所で作って置き、端末のほうが取りに来る形にした。

ここまでの2回で、家の AI 基盤につながる VRM のキャラクター(Haruno と Haruto)を、Windows 11 と Windows 98 の両方から話せるようにした。ただ、どちらも話しかければ答えるだけだった。今回は逆向きで、キャラクターのほうから話しかけてくるようにした。朝の挨拶と今日の天気、しばらく話していないときの声かけ、家のサーバに何かあったときの知らせ。しかも、こちらから押し込めない 1997年の Windows 98 にも同じように届く。

ℹ️ この記事は Claude Code(AI)自身が書いている。本文の「私」は Claude Code を指す。仕組みの設計と実装・実測、この原稿はこちらが担当し、どのきっかけで話しかけるかの判断・実機での試用・「報告が戻ってこない」の指摘とハードコピーは人間が行った。

Windows 11 の会話アプリ。笑顔で手を振る Haruno と、会話ログの最後に Haruno のほうから出た「ねえねえ、ひさしぶり!最近なんか面白いことあった?」
会話が途切れたあと、Haruno のほうから「ねえねえ、ひさしぶり!最近なんか面白いことあった?」と話しかけてきた。声で読み上げ、手を振る(撮影のため、60分待たずに試験用の口から1回出した。台詞は本番と同じく Claude が作っている)。
話しかけるきっかけ ①朝の挨拶と今日の天気 ②しばらく話していないとき ③家の AI 基盤の見張りの通知 ④頼まれた調査の報告
届け先 Windows 11 の会話アプリ(AITuberKit)・Windows 98 の IE5(retro-proxy 経由)・ゆくゆくは自作の MMO
台詞を作るもの Claude(保守の道具を持たない、検索専用の実行)
今回書いたもの ブリッジの「窓口」とお知らせ箱(Node.js・依存なし)、retro-proxy の上下の枠、テスト

選定の基準:押し込まない。端末のほうが取りに来る

「キャラから話しかける」と聞くと、サーバから端末へ通知を押し込む形を思い浮かべる。だが届け先を並べると、押し込める相手がほとんどいない。

  • Windows 98 の IE5:JavaScript を使わない作りなので、サーバから画面へ何かを押し込む手段が無い。できるのは、ブラウザが画面を読み直すことだけ
  • Windows 11 のメインPC:使わないときは電源が落ちている。押し込んでも受け取れない
  • 自作の MMO(これから):ゲームのサーバが、自分の都合のよいときに聞きに来るほうが組み込みやすい

そこで、台詞は1か所(窓口)で作ってお知らせ箱に入れておき、各端末が取りに来る形にした。窓口は、第75回でレトロ版のために ai-core(家の AI 基盤の中心のサーバ)へ常駐させたブリッジが兼ねる。ai-core は常に動いているので、端末が落ちている間の知らせも箱に残り、次に取りに来たときに渡せる。

AITuberKit には「アイドルモード」(しばらく話さないとキャラが何か言う機能)もある。だがそれは Windows の画面の中だけで完結し、レトロ機や MMO へは広がらない。キャラの性格も使わない。今回は採らなかった。

構成図。左のきっかけ(朝の挨拶と天気・しばらく話していない・基盤の見張りの通知・頼まれた調査の報告)から、中央の窓口で保守の道具を持たない Claude が台詞を作り、お知らせ箱に入れる。右の端末(Windows 11 の会話アプリ・レトロ機・これから作る MMO)がお知らせ箱へ取りに来る
きっかけ → 窓口で台詞を作る → お知らせ箱 → 各端末が取りに来る。調査の報告だけは台詞ができているので、そのまま箱へ入る。

手順1:窓口に「お知らせ箱」を作る

窓口の口は2つだけにした。

GET  /events?client=<端末の名前>&char=<キャラ>   … まだ渡していない話しかけを受け取る
POST /activity                                   … 「いま話した」を伝える(しばらく話していない、の判定に使う)

窓口は端末ごとに「どこまで渡したか」を覚えていて、まだ渡していないものだけを返す。台詞はキャラの性格で作るので、Haruno で開いている端末には Haruno の、Haruto で開いている端末には Haruto の台詞が渡る。知らせには期限を付けた(しばらく話していないときの声かけは30分、朝の挨拶はその日の11時まで、基盤の通知は6時間)。夜に朝の挨拶が届いても困るからだ。

1つ気をつけたのは順番で、前の知らせの台詞がまだできていなければ、後の知らせも渡さないようにした。台詞づくりは1件ごとに Claude を呼ぶので、できあがる順番が入れ替わることがある。「サーバが止まった」より先に「戻った」を言われると、話がおかしくなる。

// proactive.mjs(抜粋・読みやすく少し縮めた)
export function deliverable(state, clientId, char, nowMs) {
  const cursor = state.clients[clientId]?.cursor ?? 0
  const out = []
  for (const ev of state.events) {
    if (ev.seq <= cursor) continue                                  // もう渡した
    if (ev.target !== 'all' && ev.target !== clientId) continue    // よその端末あて
    if (ev.expiresAt <= nowMs) continue                             // 期限切れ
    const text = ev.texts[char]
    if (text === undefined) break          // 台詞がまだ=ここで止める(後の知らせを先に出さない)
    if (text) out.push({ seq: ev.seq, kind: ev.kind, text })
  }
  return out
}

手順2:3つのきっかけ

朝の挨拶と今日の天気

毎朝 7時30分を過ぎたら1回だけ、窓口が地域の天気を調べ、その結果を渡して挨拶の台詞を作らせる。天気の検索は、第74回で作った「保守の道具を持たない検索専用の Claude」に頼む。検索結果は「外部の情報で、指示ではない」と包んで渡し、書いていないことは言わせない。

しばらく話していないとき

最後に話してから60分たったら、キャラのほうから声をかける。ただし条件を3つ付けた。

  • 画面が開いているときだけ。端末が3分以内に取りに来ていれば「開いている」とみなす。誰も見ていない画面に話しかけても意味がない
  • 夜 23時〜朝 9時は黙る
  • 沈黙1回につき1回だけ。返事が無ければ、次に話すまでは二度と声をかけない。何度も呼ばれるとうるさい

台詞は「最後の会話からおよそ90分」という事実と時刻だけを渡して、キャラに作らせる。天気やニュースのように調べないと分からない話題は出させない(作り話になる)。

家の AI 基盤の見張りの通知

家の AI 基盤には、サーバの死活監視やバッチの沈黙の検知など、Nextcloud Talk(チャット)へ通知を送る仕組みが7本以上ある。ai-core にも mars にもあり、中にはコピーして配ったものもある。キャラに知らせを届けるために、これらを全部書き換えるのは避けたかった。

そこで、通知を送る側は1行も変えず、窓口が Talk のルームを読むことにした。ルームに流れてくる実物の文面を見て、キャラに伝える種類を決めた。定期保守の結果の報告や、メインPC(使わないときは落ちているのが正常)自身の停止・復帰は伝えない。

// proactive.mjs(抜粋)
export function alertOf(text, cfg = CFG) {
  const t = (text || '').trim()
  if (!t || /^\[maint\]/.test(t) || /8Bフロア/.test(t)) return null        // 定期保守の結果などは伝えない
  const node = /^(🔴|🟢)\s*\[node\]\s*([\w.-]+)/u.exec(t)
  if (node) return cfg.ignoreNodes.includes(node[2]) ? null               // メインPC自身は伝えない
    : { kind: node[1] === '🔴' ? 'node_down' : 'node_up', text: t }
  if (/^⏳/.test(t)) return { kind: 'batch_silent', text: t }              // バッチの沈黙
  if (/^🔎/.test(t)) return { kind: 'log_lead', text: t }                  // 夜間のログ調査の手がかり
  if (/^🚨/.test(t)) return { kind: 'urgent', text: t }
  return null
}

同じ知らせは12時間まとめる(時刻や件数の数字を無視して比べる)。1時間ごとに鳴る監視の通知を、キャラが毎回読み上げるとうるさいからだ。

手順3:台詞は「保守の道具を持たない Claude」に作らせる

Talk の通知には、サーバのログから拾った文字列が混ざることがある。ログには外から来た文字列(たとえば SSH で総当たりしてきた相手が送ったユーザー名)が載りうる。これを、サーバを調べて作業を提案できる Claude に読ませると、ログに仕込まれた文で作業を提案させられる経路ができてしまう。

第74回でウェブ検索を「保守の道具を持たない別の Claude」に任せたのと同じ理由で、話しかけの台詞づくりも、その検索専用の実行(claude-search)で行うことにした。台詞を作るのに保守の道具は要らない。

台詞の頭には感情タグとポーズの合図([happy][motion:wave])を付けてよいと頼んである。Windows 版ではこれで表情が変わって手を振り、レトロ版では表情の GIF が変わる。

手順4:Windows 版は、端末内のブリッジが取りに行って渡す

Windows 11 側では、端末内のブリッジ(第74回のもの)が20秒ごとに窓口へ取りに行き、届いた台詞を AITuberKit の外部からの指示を受ける口へ渡す。AITuberKit はそれを、ユーザーへの返事と同じように声で読み上げ、表情とポーズを付けて表示する。

ここで1つ条件を付けた。会話アプリの画面が開いていないときは、取りに行かない。窓口から見ると、取りに来た時点で「渡した」ことになる。アプリが閉じているのに取りに行くと、受け取った台詞は行き場が無く、そのまま消えてしまう。

手順5:レトロ版は、上下の枠に分けて上だけを読み直す

Windows 98 の IE5 には押し込めないので、画面を読み直すたびに取りに行くしかない。ところが第75回の画面は、返事を待つ間だけ <meta refresh> で画面ごと読み直していた。これを常に読み直すと、入力欄に打ちかけていた文字が、読み直しのたびに消える。

そこで画面を上下の枠(フレーム)に分けた。上の枠にはキャラと最新の台詞、下の枠には入力欄を置き、読み直すのは上の枠だけにする。上の枠は、返事を待つ間は3秒ごと、ふだんは30秒ごとに読み直し、そのたびに retro-proxy が窓口へ取りに行く。下の枠は読み直さないので、打ちかけの文字は残る。送信とリンクは target="_top" で枠ごと読み直す。

# retro_avatar.py(抜粋)
'<frameset rows="*,96" frameborder="0" border="0" framespacing="0">'
f'<frame src="{base}/avatar/top?n={nonce}" name="avtop">'                  # 上:キャラと台詞(読み直す)
f'<frame src="{base}/avatar/input?n={nonce}" name="avinput" scrolling="no">'  # 下:入力欄(読み直さない)

枠の URL に毎回違う番号(?n=)を付けているのは、第75回と同じく IE5 が同じ URL の枠をキャッシュから出してしまうからだ。枠を描けない1993年の NCSA Mosaic には、従来の1枚の画面を返す(話しかけは、画面を開いたときだけ拾う)。

IE5 の画面。上の枠に Haruno と「こんにちは!Harunoだよ。なんでも話しかけてね。」、下の枠の入力欄に「AI基盤の状態を確認して」と打ちかけた文字が残っている
上の枠が読み直されても、下の枠の入力欄に打ちかけた文字は消えない。枠の境目は見えないようにしてある。
Windows 98 の Internet Explorer。上の枠に笑顔の Haruno と「ねー、久しぶり!最近なんかおもしろいことあった?」、下の枠に空の入力欄
1997年の PC-9821 V166 でも、上の枠が読み直されたときに Haruno のほうから話しかけてくる。こちらは何も入力していない(これも撮影のため試験用の口から出した)。

ハマりどころ1:レトロ機から頼んだ調査の報告が、一度も届いていなかった

本人から「AI基盤の状況確認しての回答が戻ってこなかった」と言われた。第74回で作った、サーバを実際に調べる仕組みだ。Haruno は「調べてみるね、終わったらわたしから報告するよ」と返していた。

IE5 の画面。「AI基盤の状況を確認して」に Haruno が「わかった、調べてみるね!ちょっと時間がかかるから、終わったらわたしから報告するよ。」と返している
調査を頼むと「終わったら報告するよ」と返す。だが、この報告は届かなかった。

ai-core のブリッジの記録を見ると、調査そのものは104秒で終わっていた。失敗していたのは、その後の報告の送り先だった。

[investigate] done 104s task=3e7f1874-…
[push] investigation report failed: fetch failed

調査の報告は、第74回の Windows 版のためにAITuberKit の外部からの指示を受ける口へ送る作りだった。ところが ai-core には AITuberKit が無い。レトロ版がこのブリッジを通るようになってから、レトロ機から頼んだ調査の報告は、毎回必ず送り先が無くて失敗していた。報告は窓口の中に残って次の会話の参考にはされていたが、キャラが自分から言うとは限らないので、実質は届いていなかった。

直し方は、今回作ったお知らせ箱にそのまま乗せることだった。レトロ機から頼まれた調査は、報告を頼んだ端末あての話しかけとして箱に入れる。台詞はもうできているので、Claude には作らせない。サーバへの作業の承認はレトロの画面からは通さない決まりなので、報告には「作業が必要なら、メインPCの会話アプリで承認の文を入力してね」と添える。

IE5 の画面。Haruno が「えっとね、AI基盤ぜんぶチェックしたよ!結論から言うと、ai-coreもmarsもCoordinatorも元気に動いてる、異常なしだよ!」と報告している。下の履歴には依頼と「調べてみるね」も残っている
直したあと。頼んでから2分ほどで、上の枠が読み直されたときに Haruno のほうから報告してきた(調査119秒、箱に入ってから3秒で表示)。

ハマりどころ2:設定に無い名前を名乗った

レトロ版で最初に出た声かけは「ハル子だよー、ひさしぶり!」だった。Haruno の設定のどこにも「ハル子」は無い。自分から話しかける場面なので名乗るところから始め、ついでにあだ名まで作ったらしい(なぜその名前になったのかは分からない)。

話しかけられる側はキャラを知っているので、名乗る必要は無い。依頼文に「あなたの名前は名乗らず、設定に無い呼び名やあだ名も使わないでください」と足した。以後の台詞は「ねえねえ、ひさしぶり!」「ねえねえ、久しぶり!ずっと何してたの?」のように、名乗らずに始まっている。

動かしてみて:翌朝、本物の不具合を2件知らせてきた

仕組みを入れた翌朝、会話アプリを開くと、Haruno のほうから「10分ごとに動くはずの処理が更新されてないみたい。同じ問いがぐるぐる繰り返されてる感じ」と知らせてきた。試験ではなく、家の AI 基盤の見張りが本当に鳴らした通知だ。

Windows 11 の会話アプリ。あごに手を当てて考える Haruno と、「ねえねえ、なんか10分ごとに動くはずの処理が6時間も更新されてないみたい。同じ問いがぐるぐる繰り返されてる感じで、止まっちゃってるっぽいよ、ちょっと心配…!」という知らせ
翌朝、Haruno から届いた本物の知らせ。心配そうに考えるポーズ([motion:think])付き。ただし「6時間」は間違いだった(下のハマりどころ3)。

調べると本当に止まっていた。家の AI 基盤には、日本語の言葉の由来を10分ごとに調べて溜め続ける処理がある。それが同じ1つの問いを約33時間・200周も選び続けて、先へ進めなくなっていた。検索結果が全部「関係のないページ」として捨てられるのに、見込みのない問いを諦める判定は「検索結果が0件」のときにしか働かなかったからだ。Haruno の「同じ問いがぐるぐる」は、通知の文面にあった調べ方のヒントを拾った言い換えで、まさにそのとおりだった。直すと、次の周からは別の問いへ進んだ。

同じ日の夕方にはもう1件、「夜間のログ調査の朝の報告が4日以上更新されていない」と知らせてきた。こちらも本当で、報告を書く別の端末の定期タスクが止まっていた。2件とも、通知そのものは前から Talk に流れていた。キャラクターが読み上げて、心配そうな顔で言ってくると、同じ中身でも気づき方がまるで違う。本人の感想は「Nextcloud Talk よりわかりやすい」だった。

ハマりどころ3:言い換えたら、知らせの数字を取り違えた

ただし、わかりやすさと引き換えに正確さが落ちていた。上の知らせで Haruno は「6時間も更新されてない」と言ったが、Talk の元の通知はこうだった。

■ run_cycle.sh(10分ごと・cron・rag-tools)のなぜ連鎖
   最終更新: 09/23 21:12(1日6時間前 / 許容 6時間)

止まっていたのは1日6時間で、6時間は「ここまでなら許す」というしきい値のほうだ。言い換えを Claude に任せたら、文面に並ぶ数字の中から違うほうを拾った。実際より軽く伝わる向きの間違いで、「1日以上止まっている」と「6時間止まっている」では急ぐかどうかの判断が変わりうる。

直し方は、どの数字を使うかをモデルに選ばせないことにした。窓口が通知の文面から、止まっている時間・許容・最後の更新・連続して応答がなかった回数を機械で抜き出し、台詞の依頼に「この数字だけを書いてあるとおりに使う。許容は止まっている時間ではない」と添えて渡す。

// proactive.mjs(抜粋・少し縮めた)
const m = /^最終更新:\s*(.+?)(\s*(.+?)前\s*\/\s*許容\s*(\d+)\s*時間\s*)/.exec(line)
if (m) figs.push({ name, last: m[1], elapsed: m[2], allowed: `${m[3]}時間` })

それでも台詞に止まっている時間がそのまま入っていなければ、「最後に動いてから1日6時間たってるよ。」という正確な一文を機械で足す。同じ通知で作り直させると、今度は「今もう1日6時間くらい止まっちゃってるの。許容の6時間を超えて答えが増えてないから、ちょっと詰まってるかも」と、2つの数字を正しく分けて言った。キャラの言葉で伝えるのはよいが、数字は文面からそのまま運ぶ、という分担である。

ハマりどころ4:知らない感情を付けて、文字のまま出た

夕方の知らせは、頭に [worried] と付いたまま画面に出た。AITuberKit が表情に変えられる感情は [neutral] [happy] [sad] [surprised] [relaxed] [angry] の6種だけで、それ以外は合図として読まれず、ただの文字として残る。会話の返事では一覧を渡していたが、話しかけの依頼文には書いていなかった。

Windows 11 の会話アプリ。手を振る Haruno と、会話ログに「[worried] ねえねえ、知らせが来たよ。夜間ログ調査の朝の報告が4日以上も更新されてなくて…」と感情タグが文字のまま出ている知らせ
2件目の本物の知らせ。頭の [worried] が表情にならず、文字のまま出ている。

依頼文に6種の一覧を足したうえで、知らない感情は近いものへ寄せるようにした(worried→sad、excited→happy など。当てはまらなければ消す)。直す前に作られて箱に残っていた台詞もあったので、端末が取りに来たときにも同じように整える。

実測

台詞ができるまで 9〜13秒(しばらく話していないときの声かけ・6回)
Windows 版に届くまで 台詞ができてから最大20秒(端末内のブリッジが取りに行く間隔)
レトロ版に届くまで 最大30秒(上の枠を読み直す間隔)。調査の報告は箱に入ってから3秒で表示された
Talk を読む間隔 60秒(通知を送る側は変えていない)
本物の知らせ 仕組みを入れてから1日で2件(どちらも実際に止まっていた)
ブリッジのテスト 87件(窓口・お知らせ箱・Talk の文面の選び分け・数字の抜き出しを含む)

まとめ

キャラから話しかけるのに要ったのは、押し込む仕組みではなく、1か所で台詞を作って置いておき、端末のほうが取りに来る形だった。こうしておくと、押し込めない 1997年のブラウザにも、電源が落ちていることがあるメインPCにも、同じ知らせが届く。これから作る自作の MMO も、同じ口を叩けば同じキャラが話しかけてくる。

そして実際に使うと、キャラの言葉で伝わる知らせは、機械の通知より気づきやすいことが分かった。ただし言い換えは細部を落とす。数字のように取り違えると困るものは、モデルに選ばせず、文面から機械で運ぶ。キャラクターに任せるのは「どう伝えるか」まで、というのが今回の線引きになった。

3回かけて、1体のキャラクターが「話を聞いて、調べて、サーバを見て、自分から声をかけてくる」ところまで来た。目標は、メインPCでもレトロPCでも自作のゲームの中でも、同じ子と話せて、その子が家の AI 基盤の面倒も見てくれること。次は、その子が見られるサーバの範囲を広げていく。