既読スレッド再注入機構 — M層の「1ヶ月前のスレッド記憶喪失」解消

W層: architect 対象: bot/persistent_session.py, bot/router.py, bot/app.py, bot/tmux_session.py 設計のみ / 実装は別途 親テーマ: 記憶消失 (#513 系)
一言で: 生きているM層セッションに「この会話スレッドはもう記憶に取り込んだ」という軽量な既読リストを持たせる。 未読のスレッドに話しかけられた時だけ、そのスレッドのSlack履歴を読み直して注入する。 セッションが切り替わった(=記憶がリセットされた)ら既読リストも捨てる。毎回は読まない。忘れた時だけ読み直す。

1. 目的

M層は route ごとに単一のローリング常駐セッション(PersistentSession)で動く。 セッションは --resume で会話継続し、トークン超過で rotate()、さらに Claude 側の自動コンパクションで文脈を圧縮する。 その結果、数週間前のスレッドの文脈は現行の生きたセッションには載っておらず、再読込もされないため、 古いスレッドに話しかけると「記憶がない」応答になる。

本設計は、CEO と合意済みの「既読スレッドリスト+未読時のみ再読込」方式を、既存の記憶モデル(--resume / rotate / build_context_summary 注入)を壊さずに最小実装へ落とす。

2. 現状の構造(なぜ忘れるのか)

スレッド履歴の注入は現在 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_memorythread_context の中身注入されるか
新規スレッド初回False/TrueSlack全文 (fetch_thread_history)_has_memory=False の時のみ
確定済みスレッド継続True(生存中)ローカルintentログ注入されない
bot再起動/rotate直後False上記いずれかされる
欠陥: 生きたセッション(_has_memory=True)に古いスレッドで話しかけると、 ①_has_memory=True なので注入ゲートを通らない ②そのスレッドの本文は過去の rotate / 自動コンパクションで既に圧縮・破棄済み。 → 記憶喪失。現在の注入ゲートは「セッション寿命」しか見ておらず「このスレッドを今の文脈が持っているか」を見ていない。

3. 合意方針(これを設計に落とす)

  1. 生きているM層セッションが「記憶に取り込んだ thread_ts」の軽量リストを保持する。
  2. メッセージ到来時、その thread_ts がリストに無ければ、当該スレッドの Slack 履歴を読み直してM層へ注入し、リストに追加する。
  3. リストはセッションが切り替わるたびにクリアする。契機は3つ:
    (1) トークン超過による rotate / (2) Claude の自動コンパクション / (3) --resume 再開・アカウント切替。
  4. 「毎回読み直す」のではなく「記憶に無い時だけ読み直す」。

4. 設計詳細(論点 a〜f)

(a) 既読リストの保持場所とデータ構造

採用: プロセス内メモリ・セッションオブジェクト属性

# bot/persistent_session.py __init__ (L256付近に追加)
self._known_threads: set[str] = set()

却下: ファイル永続化

bot再起動での消失は「セッション切替」の一種(--resume再開)であり、クリア契機(3)と同義。よって揮発でよい

(b) 3つのクリア契機の具体的フック位置

クリア契機は「現行文脈がリセット/圧縮される瞬間」。物理的な追加はわずか2箇所で3契機を全てカバーする。 なぜなら rotate・restart・アカウント切替は全て _do_start() を通って新プロセスを spawn するため。

契機実際の経路フック位置
(1) トークン超過 rotate rotate() L461stop()start(force_new=True)_do_start() _do_start() 冒頭 L328
self._known_threads.clear()
(3a) --resume再開 / restart restart() L434start()_do_start()
(3b) アカウント切替 send内切替 L607-609get_or_create_session のアカウント変更 tmux_session.py:393rotate())— いずれも _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 で足りる)。

(c) 「記憶があるか」の判定方法

リスト照合のみで十分。タイムスタンプやトークン按分は不要。

# 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)

(d) Slack履歴再読込の注入方法(既存ロジック再利用)

判定と再読込の配置(採用: 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 が空を返すケース)でも、 そのメッセージ自体が現行文脈に載る=以後は既読で良いため。次メッセージから同一セッション内では再読込しない(=「毎回読まない」を担保)。

(e) 自動コンパクション / --resume と二重管理でバッティングしない根拠

(f) スレッドが溜まり続ける懸念への対処

5. 変更ファイル一覧

#ファイル変更行の目安
1bot/persistent_session.py_known_threads 追加 / knows_threadmark_thread_known 追加 / _do_start_log_auto_compactclear()256, 328, 640, 末尾メソッド
2bot/tmux_session.pypeek_session(key) 追加(非生成の覗き見)346付近
3bot/app.pycached_route 分岐で既読判定→未読時のみ Slack 再読込 / bot_user_id 解決の共通化1452-1465, 1470-1476
4bot/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

6. 新データフロー(メッセージ到来時)

  1. app.py: cached_route を取得。生存セッションを peek_session で覗く。
  2. 既読(knows_thread=True)→ thread_context=""。従来通り --resume の連続性に委ねる(Slack 再読込しない = 毎回読まない)。
  3. 未読(False / セッション無し)→ fetch_thread_history で当該スレッドの Slack 全文を取得。
  4. router: thread_context があり未読/記憶なしなら _build_thread_section で初期プロンプト先頭に注入。
  5. router: send_prompt 実行 → 応答後 mark_thread_known(thread_ts) で既読化。
  6. セッション切替(rotate / 自動コンパクション / resume / account切替)→ 既読リスト全消去 → 以後そのスレッドは再び未読扱いで読み直される(自己修復)。

7. 代替案(ブレインストーミング)

内容採否理由
案A既読 set をセッションオブジェクトに持たせ、app.py で未読判定→未読時のみ Slack 再読込、router で注入+既読化 採用 最小変更(4ファイル・実質10数行)。router に Slack I/O を持ち込まない。既存 fetch_thread_history/_build_thread_section を再利用。CEO 方針「未読時のみ読み直す」を厳密に満たす
案BSlack 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 コスト増

8. 設計完了チェックリスト

前提条件

副作用・影響範囲

リスク・懸念事項

9. 実装Issueドラフト

タイトル案

feat(memory): 生存M層セッションに既読スレッド再注入機構を追加(古いスレッドの記憶喪失解消)

## 経緯

M層は route ごとに単一のローリング常駐セッションで動作し、--resume 継続・トークン超過 rotate・Claude 自動コンパクションで文脈が入れ替わる。 その結果、数週間前のスレッドに話しかけると、当該スレッド本文が現行の生きた文脈に残っておらず「記憶がない」応答になる。 現状の注入は「セッションが記憶を持たない時(初回/再起動/rotate 直後)」だけで、生存セッション中の未読スレッドを再読込する仕組みが無いbot/router.py:581)。 CEO と「既読リスト+未読時のみ再読込」方式で合意済み。設計書: output/DESIGN_thread_memory_reinject_513.html

## 要件

## 受け入れ条件

## メタデータ

依頼者: CEO / M層: platform / 起票先: MNML-LLC/mnml-agents / 設計: W層 architect / 親テーマ: 記憶消失(#513系)


設計のみ。実装(コード変更・コミット・PR)は含まない。実装は本 Issue 起票後、ベンダー(Web Claude) または W層 coder が担当する。