meeting-copilot
会議に同席して、進行と前提を見張るモニタ一式
English abstract — Two machines. The child (a Windows laptop, in the meeting) captures two physical audio channels —
T= your microphone,G= a WASAPI loopback of the default speaker — and streams both to the parent over TCP with a shared token. The parent transcribes each channel separately (speaker identity comes from the channel, never from diarization), then runs three layers over the transcript: a rule-only warden (copilot.py) that advances the agenda and fires boundary alarms with no LLM at all; a premise watcher (premise_watch.py) that classifies every guest utterance against a ledger of pre-agreed facts as contradiction / already-known / new; and an answerer (answerer.py) for off-script questions. A teleprompter (viewer2.py) serves two different pages — a prompt screen only you see on your phone, and a stage window you actually screen-share. Everything case-specific lives in config files; the code carries no customer data. Written mainly in Japanese; the structure is language-independent.
0. 全体像 — これは2台構成である
一番よくある取り違えが「1台で完結する道具だと思う」こと。必ず2台要る。
┌─ 子機 (Windows ノートPC・会議に持っていく機体) ────────────┐
│ │
│ マイク ────────────► agent_mic.py ──┐ ch "T" │
│ │ │
│ 既定スピーカーの │ │
│ ループバック ─────► agent_loop.py ──┤ ch "G" │
│ │ │
│ 音を録って送るだけ。STTもAPIキーも持たない │
└──────────────────────────────────────────┼──────────────────┘
│
TCP + 合言葉(token) │ 16kHz mono ×2本
プライベート網(tailscale等)を想定
│
┌─ 親機 (Linux / WSL・自宅や事務所に置きっぱなし) ───────────┼───┐
│ ▼ │
│ receiver.py … 音を受けて STT へ流し transcript.jsonl へ追記 │
│ │ │
│ ├──► copilot.py … ルールだけで段の進行・境界警報 │
│ │ ├──► premise_watch.py … 相手の発話×事実台帳 │
│ │ └──► answerer.py … 台本外の質問に回答 │
│ │ │
│ └──► viewer2.py … カンペ画面 + 舞台画面を配信 │
└─────────────────────────────────────────────────────────────────┘
│ │
│ カンペ画面(自分だけ見る) │ 舞台画面(相手に画面共有する)
▼ ▼
スマホ / 携帯ディスプレイ 名前付き別窓 meetlive_stage
🔴 イヤホン/イヤモニが必須な理由は、この構成に直結している
話者の分け方が物理チャンネルだからである(receiver.py の CH_SPEAKER = {"T": "host", "G": "guest"})。
STT の話者分離(diarization)には一切頼っていない。安いし速いし確実——ただし1つだけ前提がある。
子機のスピーカーで相手の声を鳴らすと、ch
G(相手の声)として送っている音を chT(自分のマイク)が拾い直し、同じ声が2チャンネルに二重計上される。 こうなると相手の発話がspeaker="host"として記録される。
そして host / guest の区別は、この道具の土台になっている:
| 何が壊れるか | どこで効いているか |
|---|---|
| 段の進行が勝手に進む | 段の検知キーワードは host の発話だけに当てる (copilot.auto_step) |
| 前提監視が黙る/誤爆する | 前提監視を撃つのは guest の発話のときだけ (copilot.feed) |
| 呼びかけが効かない/暴発する | 呼びかけ語の検知は host の発話だけ (receiver.Writer.emit) |
| 台本外の質問への回答が暴走する | answerer を呼ぶのは guest の質問のときだけ |
つまりイヤホンを忘れると、モニタの機能がほぼ全部おかしくなる。
receiver.py --xtalk-gate は「T の音量より G の音量が1.5倍大きければ T を捨てる」という
保険だが、解決ではない。イヤホンを挿すこと。
1. 4つの層 — 何がどこまでやるか
| 層 | ファイル | LLM | いつ動くか |
|---|---|---|---|
| 逐語化 | receiver.py + stt.py |
使わない | 音が来るたび |
| 番人(第1層) | copilot.py |
使わない | 逐語1行ごと。ルールと文字列照合だけ |
| 前提監視 | premise_watch.py |
量産呼び出し | 相手の発話ごとに毎回 |
| 回答(第2層) | answerer.py |
一発呼び出し | 台本外の質問のときだけ(30秒に1回まで) |
| 返し役 | responder.py |
一発呼び出し | 相手の発話がひと区切りするたび |
| 表示 | viewer2.py |
使わない | 長ポーリングで配信 |
| 事後検証 | action_log.py |
使わない | 会議のあと |
なぜ第1層に LLM を置かないか: 会議中は「速い・落ちない・同じ入力に同じ出力」が 何より効く。段の進行と約束の境界(金額・期限・責任)の検知は、キーワード照合で足りる。 LLM を挟むと、その分だけ遅れ、その分だけ落ちる。
answerer.py と responder.py の違いは構え。answerer は「答えを作る」、responder は
「台本のどこを読み上げればよいかを指す」。だから responder の出力は毎回 ①該当する節
②そのまま言える返し ③言ってよい数字 ④🚫言ってはいけないこと1つ、の4点に固定してあり、
材料(kb/)は全文を渡す(切り詰めが何を起こすかは §5.8)。台本があって「その通り話したい」
会議は responder、台本が薄く「その場で答える」会議は answerer。同時には使わない。
1.5 会議フォルダ — 案件固有はここにしか無い
コードは1本。案件ごとに変わるのは「会議フォルダ」の中身だけ、という形にしてある。
会議のたびにフォルダを1つ作り、MEETLIVE_MEETING でそれを指す。それ以外の環境変数は、
古い運用のための後方互換として残してあるだけで、ふだんは触らない。
<会議フォルダ>/ 例: ~/meetings/2026-01-20-acme/ (git に載せない場所)
meeting.json 今日の構え(下の表)。この1枚が入口
agenda_steps.json 段と必須取得物
talk_script.md 台本(テレプロンプターの中身)
phrasebook.json 定型回答・約束の境界の文言
stage_resources.json 舞台に出せるもの(URL・画像・声で呼ぶ語)
ledger.yaml 事実台帳(前提監視の基準・任意)
bank.json 先読み回答(任意)
kb/ 台本外の回答の材料。🔴 置いたものはそのままLLMへ送られる
docs/ 資料棚。会議中に画面のボタンで開く手元資料(.md/.txt/.html)
export MEETLIVE_MEETING=~/meetings/2026-01-20-acme
export MEETLIVE_DIR=~/meetlive_state/2026-01-20-acme # 省略時は ./meetlive_state/<フォルダ名>
export MEETLIVE_CREDS_FILE=~/secrets/acme_logins.md # 🔴 会議フォルダの外
python3 scripts/viewer2.py --port 47328
解決の順番(キー単位・迷ったらこの順)
- 会議フォルダの中のファイル … あればこれが勝つ
- 個別の環境変数(
MEETLIVE_AGENDA等)… 会議フォルダに無いときだけ - 同梱の
config/*.example… どちらも無いとき。必須の入力は stderr に警告を出す
前の案件の MEETLIVE_AGENDA がシェルに残っていても、会議フォルダが勝つので事故らない。
逆に、環境変数が指すファイルが存在しないときは例へ落ちずに止まる(黙って別の案件の
台帳を読んでいた、が起きないように)。会議フォルダ自体が無いときも同じく止まる。
meeting.json の各キー
| キー | 既定 | 意味 |
|---|---|---|
title |
(空) | 会議の名前(ログ・画面用) |
start |
(空) | 開始予定 "HH:MM"。--start を渡さないときの既定(その日の時刻) |
total_min |
0 |
会議の長さ(分)。0 なら agenda_steps.json の「会議分」 |
host_label |
進行役 |
こちら側の呼び方(プロンプト内) |
counterpart |
相手 |
相手の呼び方(プロンプト内) |
share |
host |
画面共有の構え host / guest / none |
layout |
auto |
画面の並べ方 columns(左右2列) / rows(上下) / auto(向きで切替) |
features |
全部 true |
起動する層の ON/OFF (copilot / responder / premise_watch / stage) |
stage.set_label |
(空) | 今回の舞台セットの呼び名 |
stage.order |
(空=全部) | 出す舞台ボタンだけを順に並べる |
card_policy.auto_dismiss_kinds |
["warn","premise_warn"] |
自動で消える種別。ここに無いカードは「済」を押すまで残る |
card_policy.ttl_sec / max_turns / min_show_sec |
120 / 8 / 40 |
自動で消える種別の引っ込み方 |
card_policy.stack_max / history_max |
60 / 30 |
カード列と履歴の上限 |
call_words |
コパイロット,… |
呼びかけ語。🔴 STT の誤変換の綴りも並べる |
start_homophones |
(空) | 開始合図が音声認識で化けた綴り。実際に化けたものだけ足す |
creds_label |
🔑 合言葉 |
鍵パネルのボタン名 |
優先順位はキー単位で決めてある。舞台の order / set_label は「今日の構え」なので
meeting.json が正。個々の URL は走行中に差し替えたいので <状態Dir>/stage_urls.json
が勝つ(会議直前にURLが変わっても、プロセスを止めずに直せる)。
稼働ライン — 沈黙と故障の区別
画面の上段に「受信 08:12:33 ・ 逐語 08:12:30 ・ 心拍 08:12:31」の1行が出る。 「起動したのに何も出ない」ときに、材料が来ていないのか機構が落ちているのかを、 ここだけで見分けるためのもの。
- 受信 … 最後に音が届いた時刻(
partial.json/latency.jsonlの更新) - 逐語 … 最後に確定発話が書かれた時刻(
transcript.jsonl) - 心拍 … 返し役・番人が
<状態Dir>/heartbeat.jsonに書く{"ts": ISO8601, "role": "responder"|"copilot", "model": ..., "note": ...}。 ファイルが無い / 60秒より古いときは「心拍なし」と表示する(そこで落ちたりはしない)
3つとも「—」でも異常とは限らない。まだ誰も喋っていないだけのことがあるので、 画面には「材料が無ければカードは出ません(それは設計どおり)」と常に添えてある。
秘密の置き場 — 会議フォルダには置かない
合言葉・管理画面のログインは MEETLIVE_CREDS_FILE で会議フォルダの外を指す。
会議フォルダは資料置き場なので、うっかりリポジトリに載る事故が起きうるため。
書式は md の表。| 用途 | URL | 合言葉 | の行だけを拾う:
| 用途 | URL | 合言葉 |
|---|---|---|
| 管理画面 | https://example.com/admin | `xxxxxxxx` |
この中身はHTMLに一切埋め込まない。手元の画面が /creds を叩いた瞬間にだけ読む。
共有する別窓(/stage/*)はこの経路に触れないので、画面共有に合言葉は映らない
(この不変条件は tests/test_viewer_state.py が毎回確かめている)。
2. 環境変数リファレンス(後方互換)
ふだんは MEETLIVE_MEETING 1本でよい(§1.5)。以下は、会議フォルダを使わない場合と、
会議フォルダに置けないもの(状態Dir・秘密・モデル)のための一覧。
未設定でも顧客データへは絶対に落ちない。落ちる先は次の2つだけ:
状態ディレクトリ = ./meetlive_state(カレント直下)、入力ファイル = 同梱の config/*.example。
環境変数が指すファイルが存在しないときは、例へ落ちずに SystemExit で止まる
(解決は scripts/meetlive_config.py の1箇所に集約してある。各スクリプトは
環境変数を直接読まない ── その規律は tests/test_l_meeting_copilot.sh が見張っている)。
置き場
| 変数 | 既定 | 意味 |
|---|---|---|
MEETLIVE_DIR |
./meetlive_state |
状態(逐語・カード・ログ)の置き場。🔴案件ごと・会議ごとに必ず分ける |
MEETLIVE_LEDGER |
config/ledger.yaml.example |
前提監視が読む事実台帳(YAML・facts[].id / .title) |
MEETLIVE_AGENDA |
config/agenda_steps.example.json |
段と必須取得物 |
MEETLIVE_SCRIPT |
config/talk_script.example.md |
台本(テレプロンプターの中身) |
MEETLIVE_PHRASEBOOK |
config/phrasebook.example.json |
定型回答・約束の境界の文言 |
MEETLIVE_STAGE |
config/stage_resources.example.json |
舞台に出せるもの(URL・画像・声で呼ぶ語) |
MEETLIVE_KNOWLEDGE_DIR |
config/ |
answerer の接地資料ディレクトリ。🔴この直下の .md/.txt を名前順に全部読み、そのままLLMへ送る(§2.1) |
MEETLIVE_SCRIPT_NAME |
talk_script.example.md |
接地資料のうち先頭に置く台本のファイル名 |
MEETLIVE_MEETING |
(空) | 会議フォルダ(§1.5)。これ1本で上の入力が全部決まる |
MEETLIVE_CREDS_FILE |
(空) | 合言葉の md(§1.5)。未設定なら鍵パネルは中身なし |
MEETLIVE_DOCS |
<会議フォルダ>/docs |
資料棚。会議フォルダが無ければ <状態Dir>/docs |
MEETLIVE_BANK |
<会議フォルダ>/bank.json |
先読み回答バンク(任意) |
MEETLIVE_LAYOUT |
auto |
画面の並べ方。meeting.json の layout が優先 |
MEETLIVE_HEARTBEAT_STALE_SEC |
60 |
これより古い心拍は「心拍なし」と出す |
2.1 🔴 MEETLIVE_KNOWLEDGE_DIR に何を置くかは、そのまま「LLMへ送るもの」を決める
answerer.py はこのディレクトリ直下の .md / .txt を名前順に全部読み、
上限(MEETLIVE_KNOWLEDGE_PER_FILE / _TOTAL)まで逐語でプロンプトへ載せる。
ファイル名の allowlist は持っていない。置いたものは送られる。
したがって、案件フォルダをまるごと指さないこと。値付けの検討メモ・社内の下書き・ 相手に見せられない判断の記録が同じ階層にあれば、それも一緒に送られる。
# ✗ 危ない: 何が入っているか分からない階層を丸ごと指す
export MEETLIVE_KNOWLEDGE_DIR=/path/to/projects/acme
# ✓ 送ってよい資料だけを置いた専用ディレクトリを作って指す
mkdir -p /path/to/projects/acme/meetlive_knowledge
cp talk_script.md requirements.md minutes_prev.md /path/to/projects/acme/meetlive_knowledge/
export MEETLIVE_KNOWLEDGE_DIR=/path/to/projects/acme/meetlive_knowledge
サブディレクトリは読まない(直下だけ)。拡張子が .md / .txt 以外のものも読まない。
会議の中身
| 変数 | 既定 | 意味 |
|---|---|---|
MEETLIVE_CALL_WORDS |
コパイロット,こぱいろっと,秘書,ひしょ |
呼びかけ語(カンマ区切り)。STT の誤変換の綴りも並べる |
MEETLIVE_COUNTERPART |
相手 |
相手の呼び方(プロンプト内で使う。例:「〇〇さん」) |
MEETLIVE_HOST_LABEL |
進行役 |
こちら側の呼び方(プロンプト内で使う) |
MEETLIVE_MODE_START_WORD |
同席開始 |
同席の開始合図 |
MEETLIVE_MODE_END_WORD |
同席終了 |
同席の終了合図 |
MEETLIVE_START_HOMOPHONES |
(空) | 開始合図が STT で化けた綴り。実際に化けた語を足す |
MEETLIVE_PREMISE_IDS |
(空→台帳の先頭N件) | 監視する事実idのカンマ区切り |
MEETLIVE_PREMISE_MAX |
16 |
id 未指定のとき台帳から取る件数の上限 |
MEETLIVE_PREMISE_COOLDOWN |
45 |
同種の前提カードを間引く秒数 |
モデル・接続
| 変数 | 既定 | 意味 |
|---|---|---|
MEETLIVE_AGENT_CLI |
(空→PATHにある方・両方あれば claude) |
使う CLI: claude / codex |
MEETLIVE_MODEL_PREMISE / _EFFORT_PREMISE |
claude: claude-sonnet-5/mediumcodex: gpt-5.6-sol/low |
前提監視(量産呼び出し) |
MEETLIVE_MODEL_PREMISE_FALLBACK / _EFFORT_PREMISE_FALLBACK |
claude: claude-opus-5/mediumcodex: gpt-5.6-sol/medium |
既定が空を返したときだけ1回 |
MEETLIVE_MODEL_ANSWER / _EFFORT_ANSWER |
claude: claude-opus-5/lowcodex: gpt-5.6-sol/xhigh |
台本外の回答(一発呼び出し) |
MEETLIVE_MODEL_ANSWER_FALLBACK / _EFFORT_ANSWER_FALLBACK |
claude: claude-sonnet-5/lowcodex: gpt-5.6-sol/low |
同上のフォールバック |
既定が CLI ごとに違うのは、モデルidがCLIをまたいで通用しないから(片方の既定を
もう片方に渡すと、CLI 自身のエラーで空が返る=画面の上では「材料になし」と区別が
つかない)。MEETLIVE_MODEL_* を明示すれば、どちらの CLI でもそれが勝つ。
⚠️ codex の前提監視だけ effort が低いのは節約ではない。 前提監視は発話ごとに
叩き、呼び出し側の制限は20秒。実測(gpt-5.6-sol・短い1往復・2026-09-06)は
low 8.8秒 / medium 7.3秒 / xhigh 17.3秒——xhigh は本番の長い前提リストでは
入らず、間に合わないと沈黙する(空振りではなく無音になる)。
| MEETLIVE_KNOWLEDGE_PER_FILE / _TOTAL | 9000 / 40000 | 接地資料の文字数上限 |
| MEETLIVE_TOKEN | (空) | 子機との合言葉。receiver.py --token の既定値 |
| DEEPGRAM_API_KEY / OPENAI_API_KEY | — | 使う STT バックエンドに応じて必須 |
3. セットアップ
3.1 前提
- 親機: Linux または WSL2。Python 3.9+。
claudeかcodexのどちらかの CLIが PATH にあり、認証済みであること。premise_watch.py/answerer.py/responder.pyはscripts/agent_cli.py経由でサブプロセスを起動する(claude ならclaude -p <prompt> --model <m> --effort <e>、codex ならcodex exec -m <m> -c model_reasoning_effort=<e> --ephemeral -s read-only -o <tmp> <prompt>)。 どちらも無いと、この3つは黙って何も出さない。使う CLI はMEETLIVE_AGENT_CLIで固定できる (未指定なら PATH にある方・両方あればclaude) - 子機: Windows ノートPC。Python 3.9+(
setup.cmdが無ければ winget で入れる) - 2台をつなぐ網: 子機から親機の TCP ポートへ届くこと。実運用では tailscale 等の プライベート網を想定。同じ LAN 内なら LAN の IP でよい
- STT の API キー: Deepgram(
DEEPGRAM_API_KEY)または OpenAI(OPENAI_API_KEY)。 キー無しでも--backend stubで配管の検証だけはできる(発話区間を検知して[STUB] host の発話 1.2秒のような行を transcript へ書く。話者・時刻・遅延・ jsonl の形式は本番と完全に同じ経路を通る) - イヤホン/イヤモニ(§0を読むこと。任意ではない)
3.2 親機のセットアップ
# 1) 置き場を作る
cd <このスキルの scripts/ を置いた場所>
python3 -m venv venv
./venv/bin/pip install numpy websockets pyyaml
依存はこれだけ(実際の import から数えたもの):
| 部品 | どこで使うか |
|---|---|
numpy |
receiver.py(リサンプラ・音量測定)、stt.py(VAD・PCM変換) |
websockets |
stt.py の OpenAI / Deepgram バックエンド(関数の中で import している) |
pyyaml |
premise_watch.py が事実台帳を読む |
copilot.py / viewer2.py / answerer.py / action_log.py / meetlive_config.py は
標準ライブラリだけで動く。
# 2) 案件の設定を指す
export MEETLIVE_DIR=$PWD/meetlive_state/2026-01-20-acme # 会議ごとに別のディレクトリ
export MEETLIVE_LEDGER=/path/to/projects/acme/ledgers/ledger.yaml
export MEETLIVE_AGENDA=/path/to/projects/acme/agenda_steps.json
export MEETLIVE_SCRIPT=/path/to/projects/acme/talk_script.md
export MEETLIVE_PHRASEBOOK=/path/to/projects/acme/phrasebook.json
export MEETLIVE_STAGE=/path/to/projects/acme/stage_resources.json
export MEETLIVE_KNOWLEDGE_DIR=/path/to/projects/acme/meetlive_knowledge # 🔴 §2.1
export MEETLIVE_SCRIPT_NAME=talk_script.md
export MEETLIVE_COUNTERPART="〇〇さん"
export MEETLIVE_CALL_WORDS="コパイロット,こぱいろっと,秘書,ひしょ"
export MEETLIVE_TOKEN="$(openssl rand -base64 18)" # 子機の config.txt と同じ値にする
export DEEPGRAM_API_KEY=...
# 3) 会議前に設定が読めるか確かめる(LLMもネットワークも使わない)
./venv/bin/python copilot.py --selfcheck
--selfcheck は段取り・台本・語彙集・舞台の資源を読んで件数を出して終わる。
⚠ MEETLIVE_XXX が未設定です。同梱の例を読みます が出たら、その環境変数が
効いていない(=架空の例のまま会議に入るところだった)。
# 4) 起動 (3つとも別プロセス。nohup なりターミナル多重化なりで並べる)
./venv/bin/python receiver.py --backend deepgram --port 47311 >> receiver.log 2>&1 &
./venv/bin/python copilot.py --start 2026-01-20T15:00:00 >> copilot.log 2>&1 &
./venv/bin/python viewer2.py --start 2026-01-20T15:00:00 --port 47323 >> viewer2.log 2>&1 &
起動順について(正確に)
硬い制約は1つだけ: receiver.py が待ち受けていないと子機は繋がらない
(繋がらない間、子機は最大30秒まで待ち時間を伸ばして繰り返し試す)。
だから receiver を先に上げる。
copilot.py と viewer2.py の間には順序の制約は無い。どちらも同じ
transcript.jsonl を独立に読み、同じ式で段を計算するだけだからである。
ただし次の2つは守らないと、画面と番人が食い違う:
--startを copilot と viewer2 で同じ値にする。経過時間・予定時刻・超過判定が この値基準。(receiver.pyに--startは無い。逐語を書くだけなので要らない)MEETLIVE_AGENDAを同じファイルにし、編集したら copilot と viewer2 の 両方を上げ直す。copilot.pyは段取りJSONを起動時に1回だけ読むviewer2.pyは、中段に出す台本ブロックだけは毎リクエスト読み直すが、 段の判定に使う段取りは起動時に読んだものを使い続ける (ここだけ読み直すと、番人が持つ古いキーワードと段の判定がズレるため)
片方だけ上げ直すと、下段のカードに書かれた段番号と上段の段番号がズレる。
3.2.5 起動と停止 — run.sh / stop.sh
上の4行を毎回手で並べるかわりに、会議フォルダ1つを渡して必要な層だけ起こす口がある。
起こす層は meeting.json の features が決める。
export MEETLIVE_MEETING=~/meetings/2026-01-20-acme
export MEETLIVE_DIR=~/meetlive_state/2026-01-20-acme
export MEETLIVE_CREDS_FILE=~/secrets/acme_logins.md # 🔴 会議フォルダの外
export MEETLIVE_PYTHON=$PWD/venv/bin/python # 省略時は python3
./run.sh --dry-run # 何をどう起こすかを見るだけ(前夜にこれを見る)
./run.sh --port 47323 --backend deepgram --keywords "固有名詞,を,カンマ区切り"
./stop.sh --port 47323
ログは状態ディレクトリの logs/<層>.log、pid は logs/<層>.pid。
| 層 | features のキー |
備考 |
|---|---|---|
receiver.py |
receiver(既定 true) |
子機が繋ぐ先。先に上げる |
viewer2.py |
(常に起こす) | これが無いと何も見えない |
copilot.py |
copilot |
進行の番人。ルールだけ |
responder.py |
responder |
そのまま言える返し。材料は kb/ 全量 |
premise_watch.py |
premise_watch |
常駐ではない。copilot が発話ごとに起こす子プロセス |
🔴 copilot と responder を両方 true にはできない(同じ cards.jsonl に書くので、
片方のカードがもう片方を押し出す)。run.sh は両方 true なら起動せずに止まる。
🔴 premise_watch: true でも copilot: false なら前提監視は動かない(起こす親がいない)。
run.sh はその組み合わせのとき警告を出す。8時に画面を見てから気づく類の穴なので、前夜の
--dry-run で読むこと。
停止 — 層ごとに口が違う。kill は使わない
| 層 | 停止の口 |
|---|---|
viewer2.py |
HTTP GET /quit(localhost からのみ・自分で降板する) |
responder.py |
停止ファイル touch <状態Dir>/responder.stop(次の周回で終わる) |
receiver.py / copilot.py |
停止の口が無い。起動した端末で Ctrl-C = 手動 |
stop.sh は前の2つを叩き、後の2つについては「手で畳むもの」として pid を表示するだけで、
自分では止めない。kill / pkill は会議中に走っている別の python を巻き込み、
書きかけの逐語やカードを壊すので使わない(§7.1 も同じ理由)。receiver と copilot を
nohup で後ろに回したなら、畳むのは人の判断で行うこと。
3.3 子機(Windows)のセットアップ
scripts/portable/ の中身を、そのままノートPCへ持っていく(zip でよい)。
setup.cmdをダブルクリック(初回だけ・2〜3分)- Python を探し、無ければ winget で入れる
venv/を作り、soundcardとnumpyを入れるconfig.txt.exampleをconfig.txtへコピーし、メモ帳で開く
config.txtを書き換える
🔴host=<親機のアドレス> ← プライベート網のIP port=47311 ← receiver.py --port と同じ token=<親機と同じ合言葉> ← MEETLIVE_TOKEN と同じ値 rate=16000 mic_match= ← 任意。§6 を見よconfig.txtは親機のアドレスと合言葉が平文で入るファイル。 git に入れない・他人に渡さない。配るのはconfig.txt.exampleの方だけ。 例のまま(CHANGE_ME...)で起動すると、その場で止まって何を直すか出す。- イヤホンを挿す(START.bat より先に。§0)
START.batをダブルクリック → 窓が2つ開くMIC (agent_mic) - my voice…[mic-only] 接続OK -> 送信中が出れば成功LOOP (agent_loop) - other side…[loop-only] connected OK -> sendingが出れば成功- 2つとも出て初めて成功。片方だけだと片側の声が丸ごと落ちる
- 親機側のログにも
++ host 接続++ guest 接続が出る
- 止めるときは
STOP.bat(または2つの窓を閉じる)
portable/ の中身:
| ファイル | 役割 |
|---|---|
agent_mic.py |
自分の声。推奨経路。デバイス列挙もループバックもしない最小版 |
agent_loop.py |
相手の声(WASAPI ループバック)。v10。録音スレッドと送信ループを分けてある |
agent_loop_v9.py |
上の旧版。無音が続くと1バイトも送らないので実運用不可。デバイスが開けるかの切り分け用 |
agent.py |
2系統を1プロセスで扱う簡易版。片方が落ちると両方死ぬので非推奨 |
agent_common.py |
config.txt の読み込み(接続先の既定値をコードに持たない) |
START.bat / start_all.ps1 |
MIC窓とLOOP窓を開くランチャ。LOOP窓は落ちたら3秒後に自動再起動 |
STOP.bat / stop.cmd |
止める |
setup.cmd / start.cmd |
初回セットアップ / agent.py の起動 |
config.txt.example |
設定の雛形 |
手順.txt |
子機を使う人へ渡す手順書(この SKILL.md を読まない人向け) |
子機と親機をつなぐ線の仕様
自前の取り込みプログラムを書くならこれに合わせる。
接続直後に JSON 1行 + "\n":
{"ch":"T","rate":16000,"token":"合言葉"}
ch = "T"(こちらのマイク) / "G"(相手側のループバック)
以降くり返し:
struct "<dI" = (子機の時刻 float64, 続く PCM のバイト数 uint32) + PCM int16 LE mono
子機の時刻は参考値で、親機は使わない。親機は「受け取ったサンプル数」だけで
時間を進める(WSL2 では time.time() がホスト再同期で巻き戻り、time.monotonic() が
実時間より約7%速い、という実測があったため。48kHz の水晶で刻まれた音のサンプル数だけが
正しい時間を持っている)。
だから子機側は、無音の間も無音サンプルを送り続けなければならない。
送らないとそのチャンネルの時刻だけが実時間から遅れていく。
4. 使い方
4.1 2つの画面の違い(取り違えると事故る)
| カンペ画面 | 舞台画面 | |
|---|---|---|
| URL | http://<親機>:47323/ |
http://<親機>:47323/stage/... |
| 誰が見るか | 自分だけ。スマホ/携帯ディスプレイで見る | 相手。これを画面共有する |
| 中身 | 段の一覧・台本・カード・舞台の操縦ボタン | 資源1枚だけ(黒画面・スライド・画像など) |
舞台は window.open(url, 'meetlive_stage') で開く名前付きの1枚の窓。
共有するのはこの窓だけで、中身が声やボタンで切り替わる。
iframe は使っていない(相手先サイトが x-frame-options: DENY だったり、ログイン
cookie が SameSite=lax だったりして中身が出ないため)。
カンペ画面をPCに出すと画面共有に映り込むので、カンペはスマホで見る。
?theme=washitsu を付けると和風の見た目になる(既定は暗い配色)。
4.2 会議の流れ
- 子機の2窓が「送信中」になっているのを確認する
- カンペ画面をスマホで開く
- 舞台を使うなら、カンペ画面の「🎭 舞台を開く」を押して別窓を出し、それを画面共有する
- 「同席開始」と声に出す → ここで番人の状態がリセットされる (前夜のリハ発話やテスト行を本番に持ち込まないため。この合図が無いと、 起動時に読んだ古い逐語が段の判定に混ざる)
- 会議中に使える声のコマンド:
<呼びかけ語>、次/<呼びかけ語>、戻って… 段を手で送る/戻す<呼びかけ語>、時間… 経過・残り・いまの段の予定枠<呼びかけ語>、成果は… 必須取得物の未達一覧<呼びかけ語>、スライド… 舞台を切り替える(語はstage_resources.jsonのmatch)<呼びかけ語>、舞台消して… 舞台を黒画面に戻す- それ以外 … 台本と段取りの全文検索。当たらなければ「手元にありません」
- 「同席終了」と言う → 逐語に区切りが入る(判定は続くが状態は保持される)
- 子機の
STOP.bat、親機のプロセスを止める
4.3 会議のあと — 何をしたか検証する
MEETLIVE_DIR=$PWD/meetlive_state/2026-01-20-acme \
python3 action_log.py --date 2026-01-20 --md action_log_0120.md
カード発火・舞台切替・前提監視の判定・段の進行を、1本の時刻順の年表にする。
--kind card などで種別を絞れる。--from-time / --to-time で実開始で切れる。
これは飾りではない。「モニタが鳴らし続けたあの催促は、誤検知だったのか、
本当に未達だったのか」を後から確かめる唯一の手段である(§5.4)。
年表と transcript.jsonl を並べて読むこと。
5. 実戦で得た設計知見
ここは「そう決めた理由」を残す節。同じ穴を掘り直さないために書いてある。
5.1 🔴 議事録AIの要約を入力に使わない。逐語の生ログだけを信じる
Gemini 等の議事録AIが出す要約を、モニタや台帳の入力に使ってはならない。 実測で、要約から取った項目は10件中4件しか正しくなかった。 最悪の壊れ方は「疑問形を約束に格上げする」——相手が「〜できますか?」と聞いただけの ものが、要約では「〜することで合意」になる。これが事実台帳に入ると、前提監視は 間違った前提を基準に「矛盾」を判定し始める。
この道具の配管は最初からそうなっている: receiver.py が STT の確定発話だけを
transcript.jsonl へ追記し、copilot.py はそれを tail する。要約が入る隙間が無い。
会議後の台帳更新でも同じ規律を守ること(要約は索引としてなら使ってよい。
配管には使わない)。
5.2 前提監視は量産呼び出しになる。既定を安いモデルに置く
premise_watch.py は相手の発話1件ごとに1回呼ばれる(copilot.call_premise_watch)。
実測で 50分の会議で218回。ここに上位モデルを置くとクォータの底が抜ける。
だから既定は Sonnet 相当・--effort medium、異常時(空が返ったとき)だけ上位モデルへ
1回フォールバックする形にしてある。
呼ぶ回数を減らす方向で節約しないこと。 発話をフィルタで間引くと精度が落ちる—— 会話は途切れ途切れで、断片も拾わないと文脈が繋がらない。呼ぶ頻度は変えず、単価を下げる。
対照的に answerer.py は台本外の質問のときだけ・30秒に1回まで(ANSWERER_MIN_GAP)の
一発呼び出しなので、上位モデルを置いてよい。
量産呼び出しか一発呼び出しかで、置くモデルを分けるのが原則。
5.3 🔴 イヤホン/イヤモニは必須。任意ではない
§0 に書いたとおり。話者分離を物理チャンネルでやっている以上、
スピーカーで相手の声を鳴らした瞬間に土台が崩れる。--xtalk-gate は保険。
5.4 🔴 必須取得物の検知キーワードは、実際の会話で試さないと機能しない
unmet() は「その必須取得物の検知キーワード(正規表現)が、蓄積した発話全文に
1つも当たらない」ときに未達とみなす。つまりキーワードが実際の言い回しに
当たらなければ、永遠に未達のまま催促が鳴り続ける。
実際に、18分間ずっと同じ催促が鳴り続けた会議があった。 ただし——あとで逐語と突き合わせたら、それは誤検知ではなく、本当に未達だった。 モニタは正しく鳴らし続けていた。
ここが両面である。
モニタは「キーワード設計が悪い」と「本当に取れていない」を区別できない。 どちらも同じ「鳴り続ける」として出てくる。
したがって:
- キーワード設計をサボると、真実を鳴らし続けるだけの装置にもなりうる (鳴っていること自体は情報量ゼロ。原因が2つあるので)
- 正規表現として当たるので、言い換えを
|で並べておくのが実用的。 例:"(月末|来月末|末日)まで" - 会議の前に、想定される相手の言い回しを声に出して1度通すこと
- 会議の後に、必ず
action_log.pyの年表と逐語を突き合わせ、 「鳴っていた催促はどちらだったのか」を判定して、キーワードへ反映する
会議1回ごとにこれを回さないと、キーワードは永遠にチューニングされない。
5.5 🔴 案件を移すときは、必ず状態ディレクトリ(MEETLIVE_DIR)を隔離する
同じディレクトリを使い回すと、過去の会議の逐語が段の判定に混ざる。
copilot.tail() は起動時に既存の transcript.jsonl を読み、直近の「同席開始」以降を
無言で再生して状態を復元する。viewer2.build_nav() も逐語を切り詰めずに全部読む
(末尾N行だけ見ると、長い会議で古い発話が窓から落ちて段が巻き戻るため)。
つまり古い逐語は消えずに効き続ける。案件ごと・会議ごとに分けること。
MEETLIVE_DIR=$PWD/meetlive_state/2026-01-20-acme # 会議1回 = 1ディレクトリ
cards.jsonl / premise_watch.jsonl / stage_cmd.jsonl / display_log.jsonl も
すべてここに溜まるので、隔離しておくと会議後の検証もそのまま案件別になる。
5.6 番人は呼ばれ待ちをしない層を持つ
初期版は「呼ばれたら答える」だけだった。その結果、相手が前提を覆す発言をしても、
呼ばれない限り何も出なかった。前提監視(premise_watch.py)はこれを直すために、
相手の発話ごとに自発的に撃つ層として足したもの。
判定は3値(矛盾⚠ / 既知✔ / 新規+)で、カードを出さなかった判定も含めて全件
premise_watch.jsonl に残る。画面に出なかったからといって、判定していないわけではない。
5.7 事実台帳は10〜20件に絞る
MEETLIVE_PREMISE_MAX の既定が16なのはこのため。全部入れるとプロンプトが膨らみ、
「矛盾」の判定がぼやけて鳴らなくなる。今日の会議で覆されたら困る事実だけを選ぶ:
契約・約束の範囲境界、相手の現状についてこちらが握っている認識、前回決まったこと。
5.8 🔴 材料の切り詰めは「沈黙」ではなく「捏造」を生む
2026-09-03 の実測。返し役の材料(台本)を 9,000字に切ったところ、答えが後半にある問いで 「手元にありません」ではなく、台本と逆の答えを自信をもって返した(台本が禁じている 「はい、ぜひお願いします」を、そのまま言える返しとして出した)。
切り詰めは「知らないことを知らないと言う」方向には働かない。材料の一部だけを見た
モデルは、見えている範囲でいちばんもっともらしい話を作る。だから responder.py は
MEETLIVE_KNOWLEDGE_PER_FILE / _TOTAL に従わない(answerer.py は従う。あちらは
台本外の質問に一発で答える構えなので、材料が広いほど的が散る)。
🔴 kb/ に置くものは、そのまま送られる分量として選ぶ。切るなら棚から抜くのであって、
コードに切らせない。
同じ実測から、機械の関門を2つ入れてある。どちらかに落ちたら「材料になし」に降格する:
- 接地 — 返しの中に台本の30字以上の連続片が無ければ、それは台本の文言ではない (捏造答えは台本と17字しか一致せず、本番で通った15本は64〜142字一致していた)
- 節の語 — 節の見出し語が相手の発話に1つも出てこないなら、話題の違う節へ吸い寄せられた形
そして**「材料になし」はカードにしない**(議題外の雑談のたびに画面が「手元にありません」で 塗り替わり、直前の使える返しを押し出すため)。ただし声で呼ばれたときだけは必ず出す —— 呼んだのに何も出ないと、壊れたのか材料が無いのかが手元から区別できない。
6. 自分の案件に合わせる — 書くのは3ファイル
最低限これだけ書けば動く。残り2つ(phrasebook.json / stage_resources.json)は
既定のままでも成立する。
6.1 ledger.yaml — 事実台帳(前提監視の基準)
facts:
- id: F-001
title: 発注は保守フェーズのみ。新規開発は今回の範囲に含まない
- id: F-002
title: 相手先の在庫管理は今も表計算ソフトで、担当者1名に集中している
id と title だけあればよい(他の列は人間の管理用)。
既に3台帳(templates/context_ledgers_template.md)を運用しているなら、
その ledger.yaml をそのまま MEETLIVE_LEDGER に指せる。
title は一文で言い切ること。「〜について」のような見出しだと、
LLM が矛盾を判定できない。
6.2 agenda_steps.json — 段と必須取得物
{
"会議分": 45,
"警報分": 10,
"steps": [
{
"id": "s1",
"title": "① 現状の確認",
"目安分": 10,
"検知キーワード": ["まず現状", "いまの運用"],
"必須取得物": [
{"名前": "現行の担当者数",
"検知キーワード": ["(担当|運用)(は|が)?\\s*\\d+\\s*(名|人)", "一人でやって"]}
],
"nudge": "相手に喋らせる。こちらの案はまだ出さない",
"台本": ["まず、いまの運用を確認させてください。"]
}
]
}
- 段の検知キーワードはこちら側の発話にだけ当たる。 → 台本で必ず口にする語を選ぶ。相手が言いそうな語を入れると勝手に進む
- 必須取得物の検知キーワードは両者の発話に当たる(答えるのは相手なので) → 🔴 §5.4 を読むこと。ここのチューニングが全体の当たり外れを決める
- 英語キー(
title/minutes/keywords/musts/name/script/total_minutes/warn_at_minutes)でも書ける
6.3 talk_script.md — 台本(テレプロンプターの中身)
## 【1】 現状の確認
**まず、いまの運用を確認させてください。**
### 聞く順番
**データは、何年分くらい残っていますか。** (移行範囲の見積りに効く)
▸ 「わからない」と言われたら → 「では、いまの置き方で仮に作っておきます」
(ト書き。小さく薄く出る)
| 行の形 | 画面での出方 |
|---|---|
## 【N】 見出し |
段N の始まり。N は agenda_steps.json の並び順(1始まり)と対応 |
**声に出す文** |
大きく表示。後ろに続く文字は小さい注記になる |
### 小見出し |
段の中の区切り |
▸ 条件 → 言うこと |
折りたたまれた分岐 |
(ト書き)・表 |
小さく薄い注記 |
▸ 行の「」で囲んだ部分は、番人が「台本が想定している言い回し」として拾う。
相手がその言い方をしたら、疑問符が無くても第2層(answerer)を呼んでよい、の判定材料になる。
## 【N】 以外の ## 節はテレプロンプターに出ないので、準備メモはそちらへ書く。
6.4 呼びかけ語は、誤変換の綴りも並べる
export MEETLIVE_CALL_WORDS="コパイロット,こぱいろっと,カタカナ以外の誤変換,..."
STT は呼びかけ語を高確率で化かす。1回でも化けた綴りは、そのまま足す。
開始合図が化けた場合は MEETLIVE_START_HOMOPHONES に足す(こちらは「開始/スタート」との
組み合わせでのみ効くので、多少広く入れても暴発しにくい)。
7. トラブルシュート
7.1 ポートが埋まっている / 差し替えたい — kill を使わない
3つとも自分で席を譲る仕組みを持っているので、プロセスを撃つ必要がない。
| プロセス | 差し替え方 | 仕組み |
|---|---|---|
viewer2.py |
curl http://127.0.0.1:47323/quit してから新しいのを起動 |
/quit はlocalhost からのみ受け付け、0.3秒後に自ら終了する。外から止められると会議中に事故るのでこの制限がある |
copilot.py |
新しいのをそのまま起動するだけ | 起動時に <state>/copilot.owner へ自分のpidを書く。古い方は次のポーリング(0.3秒)で持ち主が変わったのに気づき、自分から降りる |
receiver.py |
allow_reuse_address が立っているので、止めた直後に上げ直せる |
子機の二重接続も同様に、新しい接続が古い接続を打ち切って差し替える(取り残しがあると全行が二重に出るため) |
kill -9 で落とすと、copilot.owner に死んだpidが残ったり、状態ファイルが中途半端に
なったりする。上の経路を使うこと。
7.2 音が届かない — 確認の順序
- 子機の2窓に
接続OK -> 送信中/connected OK -> sendingが出ているか- 出ていない →
config.txtのhost/port、プライベート網の接続を見る 合言葉が違うが親機のログに出る →tokenが親機と一致していない
- 出ていない →
- 親機のログに
++ host 接続++ guest 接続が両方出ているか <state>/transcript.jsonlが増えているか- 増えない → STT のキー(
DEEPGRAM_API_KEY/OPENAI_API_KEY)を確認 - 切り分け:
--backend stubで上げ直す。キー無しで[STUB] ... の発話 N秒が 出るなら、配管は生きていて STT だけが問題と分かる
- 増えない → STT のキー(
- 片側だけ入らない
- 相手の声だけ入らない → 会議アプリの音が出ているデバイスが「既定のスピーカー」か。
イヤホンを挿し直したら
START.batを叩き直す(取り込み先は起動時に決まる) - 自分の声だけ入らない → 既定のマイクを確認。既定マイクが Bluetooth 機器だと
取得時にネイティブ層ごと落ちることがある。その場合は
config.txtのmic_matchに内蔵マイク名の一部を書いて指名する
- 相手の声だけ入らない → 会議アプリの音が出ているデバイスが「既定のスピーカー」か。
イヤホンを挿し直したら
- 文字が二重に出る → 子機が2つ動いている。
STOP.batしてからSTART.bat
7.3 カードが出ない — 確認の箇所
MEETLIVE_DIRが copilot と viewer2 で同じか。copilot.pyは<state>/cards.jsonlへ書き、viewer2.pyは同じファイルの末尾40行を読む。 ここが食い違うと、番人は動いているのに画面には永遠に何も出ない<state>/cards.jsonlに行が増えているか- 増えている → 表示側の問題。カードは
ttl秒で消え、新しい発話が2つ来ても消える (ただし出してから最低8秒は残る)。会話が速い区間では見逃しやすい - 増えていない →
copilot.logを見る。CARDの行が出ていなければ判定に達していない
- 増えている → 表示側の問題。カードは
- 前提カードが出ない
<state>/premise_watch.jsonlを見る。カードを出さなかった判定も全件残っているtype: なしばかり → 事実台帳のtitleが見出し調で、矛盾を判定できていないdebounced: trueばかり → 同種の連発を45秒で間引いている(MEETLIVE_PREMISE_COOLDOWN)- ファイル自体が空 → CLI が PATH に無い / 認証が切れている / モデルidが
いま使っている CLI のものでない。まず
python3 agent_cli.pyで、どちらの CLI が 選ばれて何が起動されるかを1行で見る。次に 手でpython3 premise_watch.py "テストの発話"を叩いて確かめる
- 台本外の質問に答えない
- 発火条件が厳しい: 「明確な疑問形の語尾 かつ 30字以上」または
「台本の
▸行が想定している言い回し」のどちらか。しかも30秒に1回まで - これは意図的に厳しくしてある(緩めると相手の言いさし断片で撃ちまくり、 画面がカードで埋まって台本が読めなくなる)
<state>/answerer_log.jsonlに、カードを出さなかった「手元にない」も含めて全件残る
- 発火条件が厳しい: 「明確な疑問形の語尾 かつ 30字以上」または
「台本の
- 段が進まない / 勝手に進む
- 段の検知キーワードはこちら側の発話にだけ当たる。イヤホンをしていないと
相手の声が
hostとして入り、勝手に進む(§0) <呼びかけ語>、次で手で送れる。手動の送りは自動判定に加算される
- 段の検知キーワードはこちら側の発話にだけ当たる。イヤホンをしていないと
相手の声が
7.4 会議前の点検(1分)
python3 copilot.py --selfcheck # 設定が読めるか・件数は妥当か
python3 premise_watch.py "テストの発話です" # LLM経路と台帳が生きているか
--selfcheck で ⚠ ... 未設定です。同梱の例を読みます が1行でも出たら、
架空の例のまま本番に入るところだったということ。環境変数を見直す。
8. 収録物
meeting-copilot/
├── SKILL.md
├── config/ … 全部「架空の案件」の例。中身を入れ替えて使う
│ ├── meeting.example.json … 会議1回ぶんの構え(会議フォルダの入口・§1.5)
│ ├── ledger.yaml.example … 事実台帳(前提監視の基準)
│ ├── agenda_steps.example.json … 段と必須取得物
│ ├── talk_script.example.md … 台本(テレプロンプターの中身)
│ ├── phrasebook.example.json … 定型回答・約束の境界の文言
│ ├── stage_resources.example.json … 舞台に出せるもの
│ └── example_meeting/ … そのまま起動できるデモ会議フォルダ(全部架空)
├── tests/ … 標準ライブラリだけの回帰テスト(CIのグループL)
│ ├── test_viewer_state.py … 済の永続化/カード列/資料棚/合言葉/舞台/レイアウト
│ └── test_mode_signal.py … 開始合図(書く側と読む側が同じ入力を受理するか)
└── scripts/
├── meetlive_config.py … 置き場と設定の解決(既定の一元管理)
├── mode_signal.py … 「同席開始」の判定(書く側・読む側で共有する1本)
├── receiver.py … 音を受けて逐語へ
├── stt.py … STTアダプタ(stub / OpenAI / Deepgram)
├── copilot.py … 番人(第1層・LLM無し)
├── premise_watch.py … 前提監視(量産呼び出し)
├── answerer.py … 台本外の回答(一発呼び出し)
├── viewer2.py … カンペ画面 + 舞台画面
├── action_log.py … 会議後の行動年表
└── portable/ … 子機(Windows)一式。ノートPCへ持っていく
├── agent_mic.py / agent_loop.py / agent_loop_v9.py / agent.py
├── agent_common.py
├── START.bat / STOP.bat / start_all.ps1
├── setup.cmd / start.cmd / stop.cmd / agent_loop.cmd
├── config.txt.example … 🔴 実物(config.txt)はコミットしない
└── 手順.txt … 子機を使う人へ渡す手順書
この一式に案件のデータは入っていない。 顧客名・URL・合言葉・金額・実在のパスは すべて設定ファイル側にあり、設定ファイルの実物はこのスキルの外に置く。