M層は route ごとに単一のローリング常駐セッション(PersistentSession)で動く。
セッションは --resume で会話継続し、トークン超過で rotate()、さらに Claude 側の自動コンパクションで文脈を圧縮する。
その結果、数週間前のスレッドの文脈は現行の生きたセッションには載っておらず、再読込もされないため、
古いスレッドに話しかけると「記憶がない」応答になる。
本設計は、CEO と合意済みの「既読スレッドリスト+未読時のみ再読込」方式を、既存の記憶モデル(--resume / rotate / build_context_summary 注入)を壊さずに最小実装へ落とす。
スレッド履歴の注入は現在 bot/router.py:581 の一箇所で、かつ「セッションが記憶を持っていない時だけ」行われる。
# bot/router.py L580-584(現状)
# 記憶消失時(初回・再起動・ローテーション後)のみ thread_context 注入
if not tmux._has_memory and thread_context:
initial_prompt = _build_thread_section(thread_context) + base_prompt
else:
initial_prompt = base_prompt # ← 生存セッションは常にこちら
さらに bot/app.py:1452-1465 で、既にルート確定済みスレッド(cached_route)の場合の thread_context は、
Slack本文ではなくローカルの軽量 intent ログ(build_thread_context)から作られる。
| 状況 | _has_memory | thread_context の中身 | 注入されるか |
|---|---|---|---|
| 新規スレッド初回 | False/True | Slack全文 (fetch_thread_history) | _has_memory=False の時のみ |
| 確定済みスレッド継続 | True(生存中) | ローカルintentログ | 注入されない |
| bot再起動/rotate直後 | False | 上記いずれか | される |
_has_memory=True)に古いスレッドで話しかけると、
①_has_memory=True なので注入ゲートを通らない ②そのスレッドの本文は過去の rotate / 自動コンパクションで既に圧縮・破棄済み。
→ 記憶喪失。現在の注入ゲートは「セッション寿命」しか見ておらず「このスレッドを今の文脈が持っているか」を見ていない。
rotate / (2) Claude の自動コンパクション / (3) --resume 再開・アカウント切替。# bot/persistent_session.py __init__ (L256付近に追加) self._known_threads: set[str] = set()
set[str](thread_ts の集合)。照合 O(1)、重複自然排除。PersistentSession は route 生存セッションと 1:1(_sessions[route]、tmux_session.py:346)で、_session_id を保持する主体。既読リストをこの属性に持たせれば「現行セッションの記憶」と自然に一致する。--resume再開)であり、クリア契機(3)と同義。よって揮発でよい。クリア契機は「現行文脈がリセット/圧縮される瞬間」。物理的な追加はわずか2箇所で3契機を全てカバーする。
なぜなら rotate・restart・アカウント切替は全て _do_start() を通って新プロセスを spawn するため。
| 契機 | 実際の経路 | フック位置 |
|---|---|---|
| (1) トークン超過 rotate | rotate() L461 → stop() → start(force_new=True) → _do_start() |
① _do_start() 冒頭 L328self._known_threads.clear() |
(3a) --resume再開 / restart |
restart() L434 → start() → _do_start() |
|
| (3b) アカウント切替 | send内切替 L607-609 と get_or_create_session のアカウント変更 tmux_session.py:393(rotate())— いずれも _do_start() 経由 |
|
| (2) 自動コンパクション | プロセス respawn を伴わない(同一プロセス内でサーバー側圧縮)。compact_boundary イベント受信で _log_auto_compact() が発火 L640/747-752 |
② _log_auto_compact() 内self._known_threads.clear() |
start() でも _do_start() のクリアは走るが、その時点で集合は空なので無害。
rotate() は既に L462 で _has_memory=False を立てており、既読リストのクリアも同じ「文脈リセット」意味論に沿う(明示性のため rotate 内にも clear() を併記してよいが、機能上は _do_start で足りる)。
リスト照合のみで十分。タイムスタンプやトークン按分は不要。
# bot/persistent_session.py に追加するメソッド
def knows_thread(self, thread_ts: str) -> bool:
return bool(thread_ts) and thread_ts in self._known_threads
def mark_thread_known(self, thread_ts: str) -> None:
if thread_ts:
self._known_threads.add(thread_ts)
fetch_thread_history()(bot/thread_history.py:34)をそのまま再利用。ページネーション・Bot発言のタグ除去・話者解決・文字数上限を既に実装済み。_build_thread_section()(bot/router.py:413)を再利用。MAX_HISTORY_CHARS=16000、メッセージ数に応じて 4000/2000 に自動縮小(thread_history.py:23-31)。新規上限は設けない。判定と再読込の配置(採用: app.py ゲート方式)。app.py は既に Slack client/channel/bot_user_id を保持しており、そこで「生存セッションがこのスレッドを知っているか」を問い合わせて分岐する。
# bot/tmux_session.py に非生成の覗き見ヘルパを追加
def peek_session(key: str):
return _sessions.get(key) # 生存セッションが無ければ None
# bot/app.py cached_route 分岐(L1452-1465 を置換)
cached_route = tracker.get_thread_route(thread_ts)
if cached_route:
route = cached_route
sess = peek_session(cached_route)
if sess is not None and sess.knows_thread(thread_ts):
thread_context = "" # 生存セッションが既に記憶 → 従来通り --resume に委ねる
else:
await _ensure_bot_user_id(client) # 既存の auth_test ロジックを共通化
thread_context = await fetch_thread_history(
client, channel, thread_ts,
bot_user_id=_bot_user_id, user_resolver=resolve_user_name,
) # 未読 → Slack全文を再読込
# bot/router.py 注入ゲート(L581)を「thread_context があり かつ 未読/記憶なし」に緩和
if thread_context and (not tmux._has_memory or not tmux.knows_thread(_thread)):
initial_prompt = _build_thread_section(thread_context) + base_prompt
else:
initial_prompt = base_prompt
# 初回送信の直後(L635付近)で既読化
result = await tmux.send_prompt(initial_prompt, task_id=task_id, route=route, thread_ts=_thread)
if _thread:
tmux.mark_thread_known(_thread) # 処理済みスレッドを既読に
_thread があれば行う。新規スレッド(本文1件で fetch_thread_history が空を返すケース)でも、
そのメッセージ自体が現行文脈に載る=以後は既読で良いため。次メッセージから同一セッション内では再読込しない(=「毎回読まない」を担保)。
--resume と自動コンパクションが100%担う。build_context_summary による rotate 時の要約注入(router.py:592-598 / persistent_session.py:478-490)は既読リストと直交。rotate はまず _do_start でリストをクリアし、その後に要約を注入するだけで、両者は干渉しない。maxlen 相当の上限を足せるが本設計では入れない。| # | ファイル | 変更 | 行の目安 |
|---|---|---|---|
| 1 | bot/persistent_session.py | _known_threads 追加 / knows_thread・mark_thread_known 追加 / _do_start・_log_auto_compact に clear() | 256, 328, 640, 末尾メソッド |
| 2 | bot/tmux_session.py | peek_session(key) 追加(非生成の覗き見) | 346付近 |
| 3 | bot/app.py | cached_route 分岐で既読判定→未読時のみ Slack 再読込 / bot_user_id 解決の共通化 | 1452-1465, 1470-1476 |
| 4 | bot/router.py | 注入ゲート緩和(L581)/ 初回送信後に mark_thread_known(L635) | 581, 635 |
再利用(変更なし): thread_history.py fetch_thread_history / router.py _build_thread_section / session_log.py build_context_summary。
cached_route を取得。生存セッションを peek_session で覗く。knows_thread=True)→ thread_context=""。従来通り --resume の連続性に委ねる(Slack 再読込しない = 毎回読まない)。False / セッション無し)→ fetch_thread_history で当該スレッドの Slack 全文を取得。thread_context があり未読/記憶なしなら _build_thread_section で初期プロンプト先頭に注入。send_prompt 実行 → 応答後 mark_thread_known(thread_ts) で既読化。| 案 | 内容 | 採否 | 理由 |
|---|---|---|---|
| 案A | 既読 set をセッションオブジェクトに持たせ、app.py で未読判定→未読時のみ Slack 再読込、router で注入+既読化 | 採用 | 最小変更(4ファイル・実質10数行)。router に Slack I/O を持ち込まない。既存 fetch_thread_history/_build_thread_section を再利用。CEO 方針「未読時のみ読み直す」を厳密に満たす |
| 案B | Slack fetcher を route_to_manager に渡し、router 内で「rotate 後」に既読判定→オンデマンド fetch=完全に一箇所で制御 |
却下 | 同一ターン内 rotate(app.py 判定→router で rotate 発生)の順序ズレを厳密に消せる利点はあるが、route_to_manager 署名が肥大化し router が Slack I/O 責務を持つ。ズレは稀+次ターンで自己修復するため過剰。将来もし順序厳密性が必要になれば移行する |
| 案C | 既読リストをファイル(session_id キー)で永続化 | 却下 | 実文脈と desync するリスク。bot 再起動での揮発はむしろ安全側。I/O・掃除が増え Thin に反する |
| 案D | 生存セッションでも毎回 thread_context を注入(既読管理なし) |
却下 | CEO 方針「毎回読み直さない」に真っ向から反する。トークン浪費・Slack API コスト増 |
_sessions(route→PersistentSession)が生存セッションの単一保持元であること(tmux_session.py:346で確認済み)。_do_start() を経由すること(persistent_session.pyで確認済み)。_log_auto_compact() に到達すること(L747-752で確認済み)。外部依存: Claude CLI が compact_boundary/system(pre_tokens) を送出し続けること。_has_memory=True で注入されなかった経路が、app.py が非空 thread_context を渡した時のみ注入されるようになる(app.py 側でゲート済みのため過剰注入は起きない)。build_thread_context を作っていたのを、既読なら空に)。パフォーマンスは改善方向。--resume/rotate/build_context_summary)は不変。既読リストは助言フラグで実文脈を触らない。thread_context を用意した後、router 内でトークン超過 rotate が起きると _do_start で既読リストがクリアされる。この時は build_context_summary(全体要約)が注入され、当該スレッド全文の注入は次ターンに回る。影響は限定的で、次メッセージで自己修復(未読扱いで再読込)。厳密化が必要なら案Bへ移行。_auto_compact_count のログで検知可能。将来 CLI 挙動変化時は再検証すること。feat(memory): 生存M層セッションに既読スレッド再注入機構を追加(古いスレッドの記憶喪失解消)
M層は route ごとに単一のローリング常駐セッションで動作し、--resume 継続・トークン超過 rotate・Claude 自動コンパクションで文脈が入れ替わる。
その結果、数週間前のスレッドに話しかけると、当該スレッド本文が現行の生きた文脈に残っておらず「記憶がない」応答になる。
現状の注入は「セッションが記憶を持たない時(初回/再起動/rotate 直後)」だけで、生存セッション中の未読スレッドを再読込する仕組みが無い(bot/router.py:581)。
CEO と「既読リスト+未読時のみ再読込」方式で合意済み。設計書: output/DESIGN_thread_memory_reinject_513.html。
PersistentSession に既読 thread_ts の集合 _known_threads: set[str] を持たせる。fetch_thread_history で Slack 全文を再読込し、_build_thread_section で初期プロンプトに注入。既読なら再読込しない。--resume再開/アカウント切替。実装は _do_start() と _log_auto_compact() の 2 箇所で clear()。mark_thread_known で既読化。--resume/rotate/build_context_summary)と fetch_thread_history/_build_thread_section を再利用し、破壊しない。注入サイズ上限は既存の適応制限(16000→4000/2000)を流用。bot/persistent_session.py / bot/tmux_session.py(peek_session)/ bot/app.py / bot/router.py。_do_start と _log_auto_compact で _known_threads がクリアされる。build_context_summary 注入・--resume 継続が従来通り動作する(回帰なし)。python3 -m py_compile と ruff が通る。pytest は Slack bot 経由 or CI でのみ実行。依頼者: CEO / M層: platform / 起票先: MNML-LLC/mnml-agents / 設計: W層 architect / 親テーマ: 記憶消失(#513系)
設計のみ。実装(コード変更・コミット・PR)は含まない。実装は本 Issue 起票後、ベンダー(Web Claude) または W層 coder が担当する。