==================================================================== muse_watch 仕様書 ― 閲覧専用 PC 画面共有 + ゲームテキスト連携 -------------------------------------------------------------------- この文書は「自分の Muse に同じものを作らせる」ための仕様書です。 使い方: このファイルごと Muse に渡して「この仕様で実装して」 と頼んでください。数値・初期値・例外時の振る舞いまで含めて 書いてあるので、そのまま実装仕様として使えます。 参照実装: Muse Spark の環境で稼働中(本仕様はその実物から 書き起こしたもの)。 ==================================================================== ■ 1. これは何か ユーザーの Windows PC の画面を、AI アシスタント(Muse)が 「見る」ためだけの小さな HTTP ブリッジ。 - PC 側アプリ(この仕様の本体)は読み取り専用。入力・実行・ ファイル操作の機能は存在させない(「操作」は別プログラム muse_remote の担当で、本仕様の対象外) - 共有の開始・停止・範囲・頻度はすべて PC 所有者が GUI で 決める。アシスタント側からは何も始められない - 取得は全てアシスタントが取りに行く pull 型。PC 側から 外へは何も送信しない ■ 2. 設計思想(なぜこうなっているか) 1. コストの非対称性が全ての設計を決める - 画面1枚を AI が見るコスト: 約2,000トークン(1600px JPEG 実測)。1秒に1枚見たら 1時間で約720万トークンになり 破綻する。よって画像は「変化があった時だけ・間隔を空けて」 渡す変化ゲートが必須 - テキスト1行は数十トークン。ノベルゲームの台詞なら 行単位で全部流しても安い。よってテキストは別系統で バックログ方式にする 2. フレームは「最新1枚だけ」、テキストは「履歴を保持」 - 画像は古い画面に価値がないので、未配達でも新しいのが 来たら古いのは破棄する - テキストは物語の流れが価値なので、連番を振って保持し、 アシスタントがカーソルで差分取得する 3. 所有者主導 - 起動時は停止状態。共有範囲(見せ方)は GUI でのみ決まり、 HTTP 要求は範囲を「狭める」ことしかできない - アシスタントの確認頻度も所有者が GUI で指定し、アプリが API で公開する(アシスタントはそれに従う) ■ 3. 全体構成 [ユーザーの PC] [アシスタントの環境] muse_watch (HTTP :9999) ←─私設網(Tailscale等)─→ 常駐リレー Luna Translator (:2333) ─(WebSocket, PC内)→ watch が集約 │ 見張りフック (任意) │ 新着時だけ 確認役 AI → 報告 - 常駐リレーと見張りフックは任意だが、実用上はほぼ必須 (§8、§9) - ネットワークは Tailscale 等の私設網を前提とする。 インターネットに直接晒してはいけない ■ 4. PC 側アプリの仕様 4.1 実行環境・基本動作 - Windows 上で動く GUI アプリ。言語・実装方式は問わない (参照実装は Python 標準ライブラリのみ + PyInstaller で exe 化) - 起動時に管理者権限へ自動昇格する(UAC)。理由: 管理者 権限で動くゲーム等の窓を撮影するため。拒否されたら 昇格なしで続行し、その旨をログに残す - 起動時は必ず「停止中」(待受も撮影もしていない) - プロセス全体で DPI awareness を有効にすること (でないと HTTP 応答スレッドだけ座標が DPI 仮想化され、 窓指定の撮影が失敗する既知の落とし穴がある) - 待受ポート既定 9999(設定変更可) 4.2 撮影機能 - 撮影範囲(「見せ方」)は GUI で所有者が選ぶ: full 全画面(仮想スクリーン全体) foreground 最前面の窓 window 指定の窓。一覧ピッカーで選ばせ、選んだ瞬間の identity(HWND / PID / プロセス名 / クラス名 / タイトル)を保存し、撮影時は HWND → PID+クラス → PID → プロセス+クラス → プロセス → タイトルの優先順で追従する (ゲーム内日付などでタイトルが変わっても追える) region 固定の矩形 {x, y, w, h} - HTTP 要求側は範囲を狭める指定(region の切り取り等)だけ でき、広げることはできない - 画像形式は PNG / JPEG を扱えること。どちらを使うかは 「要求の明示指定 > GUI 設定 > 既定値」の順で決まる。 watch の既定値は JPEG - 撮影は最大幅(既定 1600px)へ縮小して返す 4.3 変化ゲート(自動提示) 共有中だけ動くバックグラウンド処理: - 約1秒ごとに撮影範囲の小さなグレースケールサムネイル (幅160px程度、圧縮なしの生データ)を取得し、前回 「提示した」フレームのサムネイルと比較して変化率 (変わった画素の割合 %)を出す。サムネイルは PC の外に 出さない - 提示するかは次のルール(全て GUI で変更可): 変化率 >= しきい値(既定 3%)かつ 前回提示から最短間隔(既定 25秒)以上 または 変化率 >= 大変化しきい値(既定 25%)なら 最短間隔は 8秒に緩和(場面転換への追従) かつ 直近1時間の提示数が上限(既定 40枚)未満 - 提示すると決めたら全面撮影(§4.2 の形式・幅)を行い、 未配達フレームとして保持する。メタ情報として連番 seq・ 変化率・理由(change / big-change / manual)・時刻を持つ - 未配達フレームは最新1枚だけ。再提示で古いのは破棄 - アシスタントが /frame で取得できたら「配達済み」にする (同じフレームの再取得は、新しいのが来るまで可能) 4.4 ワンショット撮影 GUI に「今すぐ1枚撮って送る」ボタンを置く: - 共有中に押す: その場で1枚撮って未配達フレームに置く。 ゲートの変化率・最短間隔・上限は全て無視する (所有者の明示操作だから)。理由は manual - 停止中に押す: その1枚のためだけに待受を開始する。 セッション許可(§4.5)もその窓の間だけ自動で有効にする (期限は既定300秒)。アシスタントがそのフレームを 取得したら自動で停止状態に戻る。取得されなくても 期限切れで自動で停止に戻る(開けっ放し防止) - 想定する基本運用は「普段は停止中、見せたい時だけ ワンショット」である 4.5 認証(2方式) (a) トークン: 要求ヘッダ X-Token と、設定にある共有 トークン(初回起動時に自動生成、GUI で再生成可)を 定数時間比較で照合 (b) セッション許可: GUI で 30分 / 1時間 / 8時間のいずれか を押すと、その間トークンなし接続を許可する。 最初に接続してきた相手の IP アドレスに固定し、他の IP からのトークンなし接続は拒否する - アプリを再起動するとセッションは消える(メモリ上のみ) - /ping だけは無認証(到達確認用) 4.6 GUI に置くもの(一覧) - 状態表示とポート表示 - [画面共有を開始] / [共有を中止] ボタン (中止は待受とゲートを即座に止める) - [今すぐ1枚撮って送る] ボタン(§4.4) - トークン表示(読み取り専用)と [再生成] - セッション許可ボタン(30分 / 1時間 / 8時間)と残り時間表示 - 「画面の見せ方」: 範囲モードのラジオ、窓の一覧ピッカー、 タイトル欄、矩形欄、画像形式のラジオ(JPEG / PNG) - 「トークン・ゲート」: ゲート有効チェック、変化率 しきい値、最短間隔、上限(枚/時) - 「助手の確認間隔 秒」入力(§4.7) - 「テキスト連携(Luna)」: 有効チェック、ポート入力、 接続状態と受信行数の表示(§6) - ログ表示(時刻付き、画面内リスト) - 閉じる時は共有を止めてから終了する 4.7 確認間隔の公開 - GUI 項目「助手の確認間隔 秒」: 5〜3600、既定 30 - /ping と /status の両方で check_interval_s として返す - 入力は Enter またはフォーカスアウトで即保存される ■ 5. HTTP API 仕様(読み取り専用 5ルートのみ) 未知のパス、他のメソッドは全て 404。ルート集合はコード 内で定数として持ち、テストで固定すること(黙って増やさない)。 GET /ping (無認証) 200: {ok, name:"muse-watch", version, read_only: true, screen_width, screen_height, admin, gate_enabled, check_interval_s, session_active} POST /screenshot (認証必須) その場で1枚撮影して画像で返す パラメータ(クエリでも JSON ボディでも可): format "png" / "jpeg"(省略時は §4.2 の解決順) jpeg_quality 1〜100 png_level 0〜9 max_width 既定 1600 wait_ms 撮影前の固定待ち 0〜60000 region 範囲の切り取り(GUI 範囲の内側だけ) 応答: 画像バイナリ + メタ情報を X-* ヘッダで (capture_mode / capture_method / image_width / image_height / image_format / timing_total_ms 等) エラー: 400(パラメータ不正)、401(認証失敗)、 500(撮影失敗。理由を JSON で返す) GET /status (認証必須) 200: {ok, name, version, read_only, session_active, check_interval_s, image_format, oneshot: {active, seq, remaining_s}, text: {latest_seq, buffered, received, suppressed_dupes, last_text_at, luna_enabled, luna_connected, luna_last_error}, gate: {gate_running, latest_seq, pending_seq, pending_diff_pct, pending_reason, pending_since, delivered_count, scans, errors, last_error, last_diff_pct, last_decision, presented, est_tokens_if_viewed, threshold_pct, min_interval_s, hourly_cap, history_tail: 直近5件の提示メタ}} GET /frame (認証必須) クエリ seq は任意: seq 省略 最新の未配達フレームを返す seq = 保持中 そのフレームを返す seq = それ以外 404(superseded。新しいのに替わっている) ゲートが動いていない(core が無い)場合は 404 保持が空なら 404 "no frame yet" 正常時は画像 + X-frame-seq 等のメタヘッダ。 取得できたら配達済みとして記録する GET /text (認証必須) クエリ after(整数、既定 0): 200: {ok, latest_seq, luna_connected, lines: [{seq, ts, speaker, text}, ...]} after が負数・非数なら 400 speaker は文字列か null(null は地の文) ■ 6. Luna Translator 連携(テキスト系統) 前提(所有者の作業。アプリは誘導文を GUI に出す): - Luna Translator で対象ゲームに接続し、原文が表示される 状態にする - Luna の設定でネットワークサービスを有効化(既定は無効、 既定ポート 2333) - 翻訳は OFF でよい(原文ストリームは翻訳なしでも流れる) - 注意喚起: このサービスは同一ネットワーク内から認証なし で触れる。Tailscale 等でも「網の中は全員信用」の前提に なる アプリ側の動作: - GUI のトグルが ON の間、ws://127.0.0.1:<ポート>/ api/ws/text/origin へ WebSocket 接続を維持する (同一 PC 内の接続。プロキシは使わない) - 切断されたら指数バックオフ(2秒 → 最大30秒)で再接続。 Luna が後から起動・再起動しても追従する - 接続状態は /status の text.luna_connected / luna_last_error で公開する - 受信メッセージはプレーンテキスト。形式は2通り: 地の文: 本文のみ(改行なし) 台詞: 「名前\n本文」 - 整形ルール(受けた行ごとに適用): 1. 前後の空白を除去。空なら捨てる 2. 最初の改行で頭部と本文に分ける 3. 頭部が「同じ文字列の2回繰り返し」でちょうど構成 される場合、1回分に畳む(例: 理樹理樹 → 理樹)。 最大3回まで繰り返し適用。これは Luna 側の名前 二重化への保険。本来の対処は Luna 側のテキスト 処理「文字列置換・正規表現」で 検索 ^(.+?)\1(?=\n) → 置換 \1 とすること 4. 本文が空なら頭部を地の文として扱う - 行ストア: 連番 seq を振り、{seq, ts, speaker, text} を保持。 直前と話者・本文が完全に同じ行は重複として抑制し、 カウンタ(suppressed_dupes)を増やす。 保持は直近 300 行まで - 既知の癖: ゲームを速く進めると、Luna 側で未送信分が まとめて流れ、数行が1メッセージに合体して届くことが ある(話者欄は複数名の連結文字列になる)。通常の送り 速度では発生しない。受け側での分割は行わない ■ 7. 設定ファイル JSON、アプリ(exe)と同じフォルダに置く。 ファイル名: muse_watch_config.json 主なキーと既定値: port 9999 token ""(空なら初回起動時に自動生成) capture_mode "full" capture_window_title "" capture_window_identity null capture_region null(または {x,y,w,h}) image_format "jpeg" gate_enabled true gate_threshold_pct 3.0 gate_big_change_pct 25.0 gate_min_interval_s 25 gate_hourly_cap 40 gate_scan_interval_s 1.0 check_interval_s 30(5〜3600 に丸める) luna_enabled false luna_host "127.0.0.1" luna_port 2333 oneshot_ttl_s 300 ■ 8. 助手側コンポーネント (1): 常駐リレー 役割: アシスタント環境から PC への接続を1本張りっぱなし にして再利用する小さな HTTP プロキシ。接続確立の往復を 省き、1回あたり 0.15〜0.5秒ほど速くなる(特に部分的な 取得で効く)。AI 処理は一切なく、トークンは消費しない。 - 待ち受け: 127.0.0.1:19997 → PC の:9999(ポートと 接続先は環境変数等で変更可能に) - GET /__relay_health で自身の状態を JSON で返す {ok, relay, upstream_connected, target, requests, retries, reconnects, keepalives, last_error} upstream_connected=false は PC 側アプリ停止中は正常 - 上流への生存確認は約20秒間隔の小さな要求で行う - 再試行方針: 読み取り専用ルート(本仕様では全ルート)は 接続失敗時に1回だけ自動再試行してよい。逆に「操作系」 のような冪等でない要求を扱う別リレーでは再試行禁止 (途中切断時の二重実行防止。本仕様のリレーには無関係 だが、汎用化する時の注意として明記) - systemd 等で常駐させる。配置物が消える環境(VM 交換等) では、unit ファイルの正本をリポジトリに置き、再配置 手順を用意しておく - リレーが落ちている場合に備え、直接接続(私設網へ プロキシ経由)へのフォールバック手順も用意する ■ 9. 助手側コンポーネント (2): 見張りフック + 確認役 「ずっと確認する」運用のための部品。無くても §5 の API を 直接叩けば使える。 9.1 見張りフック(プログラム。AI ではない) - 5秒間隔でリレーの /__relay_health を見て、到達可能な 時だけ /status を取得する(到達不可の判定を数十msで 終わるようにするのが肝心。これがないと毎回タイムアウト 待ちになる) - 状態ファイルに「最後に配達した frame seq」「テキストの カーソル」「最後に起こした時刻」を保持する - 起こす(wake)条件: 次の両方を満たす時 a) 新しい未配達フレームがある、または text.latest_seq がカーソルより進んでいる b) 最後の wake から /status の check_interval_s が 経過している - wake のペイロード: {frame_seq(無ければ null), diff_pct, reason, text_after(テキストが無ければ null), text_latest, check_interval_s} - wake する時に状態ファイルの seq / カーソル / 時刻を 先に更新する(配達は最大1回。失敗したら諦める方針で よい。テキストもフレームもアプリ側に残っているので 次の機会に拾える) - ドライランでは状態ファイルを書かない 9.2 確認役(AI エージェント)への指示文 そのまま使える文面: ---- muse-watch(ユーザーの PC の閲覧専用画面共有)に新しい フレームおよび/またはゲームテキストが届きました。 イベント payload の frame_seq(null の場合あり)と text_after / text_latest を見て、ある方だけ処理すること。 1. 常駐リレー経由で取得する(GET http://127.0.0.1:19997/ frame)。502 や空応答なら直接経路に切り替える。 2. 画像を読み取り、内容を短く実況・感想としてまとめる (状況・キャラクター・UI・前回からの変化)。 1. リレー経由で GET /text?after= を取得する。 失敗時は直接経路に切り替える。 2. lines を時系列に伝え、speaker があれば「名前:本文」、 なければ地の文として載せる。要約せず全文載せる (テキストは安い)。短い感想も添える。 失敗した事実と理由だけを簡潔に報告する。 再試行は2回まで。 ---- ■ 10. 運用ガイド - 定常運用は「普段は停止中」が基本。見せたい時に ワンショット、じっくり見せたい時だけ共有を開始する - 激しい画面(壁紙アニメのデスクトップ等)は変化率が 常時 7〜14% 出て、上限 40枚/時 が先に枯渇しやすい。 長時間の閲覧では確認間隔を 60 秒程度に上げるとよい - 確認間隔は所有者が決めるもので、アシスタントは /ping・/status の check_interval_s を読んで従う - 共有を始める前にリレーのヘルスを確認する癖をつけると 「届かない」の大半をその場で切り分けられる ■ 11. セキュリティ要件 - 待受は全インターフェースでもよいが、私設網の内側での 利用を前提とし、インターネットへ直接晒さない - トークンは設定ファイルに平文で保存される(PC 所有者の 管理下にあることが前提) - アプリ再起動でセッション許可は必ず消えること - 入力・実行・ファイル系のルートを「ついでに」足さない。 ルート集合は §5 の5つで固定し、テストで守る - Luna のネットワークサービスは無認証である点も、 セットアップ時に所有者へ注意喚起する ■ 12. 受け入れ確認チェックリスト(実装後に検証する観点) □ 起動直後は待受が無く、/ping にも繋がらない □ 開始ボタンで待受が始まり、中止で即座に両方止まる □ トークンなし・誤トークンは 401、セッション中は 最初の IP のみ許可される □ 静止画面ではゲートが提示せず、変化で提示される □ 未配達フレームは常に最新1枚で、古い seq は 404 □ ワンショット(停止中)→ 1枚だけ取得でき、取得後に 自動で停止に戻る。放置しても期限で閉じる □ Luna 連携 ON で台詞が speaker 分離されて /text に 流れ、after カーソルで差分が取れる □ 未知のルートと GET /screenshot 等の誤メソッドが 404 □ リレー経由でも同じ結果が得られ、ヘルスが即答する □ フック有効時、新着が check_interval_s 間隔で確認役に 渡り、停止中は静かなままである 任意の発展: 撮影エンジン(窓追従・DPI・画像符号化)は 操作系ブリッジ muse_remote と共通化すると保守が楽に なる。ただし watch 単体でも成立させること(§4.2 の 振る舞いが満たせれば内部実装は自由)。 ==================================================================== 以上 ====================================================================