MONET(オーケストレーター)には、最近の文脈を自動で読み込ませる仕組みを作る予定だった。でも、その「書き込む人」が、まだいなかった。
メモリは貯まっていた。でも活用できていなかった。
Typewriter と、その後ろにいるMONETの Claude。会話するたびに記録は増えていく。Qdrant に突っ込んだベクトル、SQLite に溜まった生のやり取り、フィードバックの断片。確かに「ある」んだけど、置いてあるだけ。冷蔵庫の奥で忘れられてく作り置きみたいな状態だった。
蓄えるだけのメモリは、無いのとそんなに変わらない。
「こういうことを過去に話した」「こういう判断基準で動いてる」を毎回フルで読ませるのは現実的じゃない。トークンが爆発する。かといって全部切り捨てたら、僕は毎セッション同じことを Claude に説明し直すことになる。それはもう「パートナー」じゃなくて「初対面のアシスタント」。昨日話したことを今日また説明して、明日もまた説明する。それを避けたくてメモリを作ったのに、これじゃ意味がない。
メモリを使えるようにするには、誰かが選別と要約をやらないといけない。
人間の僕がやる? 無理。記録は毎日増える。そんなことに時間を使ってたら本業が止まる。
じゃあMONETの Claude にやらせる? これも違う気がした。MONETは「今この瞬間の会話」を相手にしてる LLM。メモリの整理は「過去全部を見渡す」仕事。役割が違う。同じ Claude に両方やらせると、たぶん両方が中途半端になる。
それで、図書館司書というメタファーが出てきた。
司書は本を書く人じゃない。本を選ぶ人でもない。棚を整理して、読者がアクセスできる形にして、必要な時に取り出せるようにする人。
僕が欲しかったのはまさにそれだった。会話の記録は本。Qdrant は閉架書庫。SQLite は利用記録。でも肝心の「どこに何があるか」を知っていて、必要な時に必要な分だけ持ってきてくれる人がいない。
だから作ることにした。Librarian という名前のメモリ管理エージェント。メタファーがそのまま名前になった。実装は例によって Code に頼んだ。
Librarian は独立したアプリにした。MONETの中に組み込むんじゃなくて、別プロセスとして外に置く。Python のバックエンドと、React の UI と、定期実行用のスクリプト。完全に独立して動く。
中で動く Claude は Agent SDK 経由。Typewriter で使ったのと同じやつ。Typewriter は「外向きに文章を書く Claude」、Librarian は「内向きにメモリを整理する Claude」。役割が違うだけで、構造はかなり似ている。
メモリのソースは2つ。Qdrant が意味検索用のベクトル、SQLite が会話の生データ。類似検索が要るときは Qdrant、原文の正確な引用が要るときは SQLite。両方ないと回らない、という判断で残した。
Librarian が起動すると、最新100件のメモリを取ってくる。内訳は、25件が「人物像」のレイヤー、75件が「最近の会話」のレイヤー。レイヤー分けの設計自体はもう少し前のセッションで決まっていて、Librarian はそれを読みに行く側。25と75は試行錯誤で決めた値で、特別な根拠があるわけじゃない。
100件を取ってきたら、SDK の Claude に渡して圧縮・要約させる。出力は context_summary.txt という1枚のテキストファイル。MONETは起動時にこのファイルを読んで、システムプロンプトの末尾にくっつける。これで「最近の文脈」を毎回説明し直す手間がなくなる。僕が話しかける前に、Claude の方が最近の文脈を持った状態で立ち上がってくる。
Qdrant + SQLite → Librarian(SDK の Claude が要約) → context_summary.txt → MONET
実行は毎日4AM。cron じゃなく launchd(macOS 版 cron みたいなもの)で自動実行させた。設定ファイルを書いて launchctl load で読ませると、指定した時刻に勝手に動く。
毎日4AM、Librarian が起き出して、前日までのメモリを読み直して、サマリーを更新する。僕が朝MONETを起動した時には、もう新しい context_summary.txt が出来上がってる。
これがけっこう気持ちよかった。寝てる間に司書が棚を整理してくれていて、朝、書庫の入り口にその日の閲覧用サマリーが置いてある感覚。
サマリー生成だけじゃなく、もう一つ「昇格」という仕組みを入れた。
context_summary.txt は文字通り「最近の文脈」で、日々上書きされる。でもその中には「これは日替わりのサマリーじゃなくて、永続的に持っておくべき」という内容が混ざる。価値観、判断基準、変わらない事実。
それを system_prompt_core.txt という別ファイルに切り出すのが昇格。切り出しは手動で、Librarian の UI に昇格用のフォームがある。僕がカテゴリ(価値観・判断基準 / 重要な決定事項 / 永続的な事実)を選んで、内容を貼って、ボタンを押すと core.txt に追記される。MONETは起動時に core.txt と summary.txt の両方を読む。
ここで一つ、当初の実装を捨てた話がある。
最初は「昇格した内容はサマリーから除外する」というロジックを入れていた。重複したらトークンが無駄になるから、core.txt に入れたものは context_summary.txt から外す。一見、合理的に見える。
でも、これを削除した。
理由は、LLM に「これを含めないで」とお願いする方式が安定しないこと。同じ条件でも、サマリー生成のたびに判断がブレる。「これは入れた方がいい」「いや外した方がいい」が日替わりで変わる。
それに、よく考えたら重複してもそんなに困らないんだよね。トークンが数十〜数百増えるだけ。Claude が同じ内容を二箇所で読んだとしても、矛盾しないなら害はない。
深くするところは深く、構造自体はシンプルに。自分でずっと言ってきた方針の、まさにその場面だった。複雑なロジックを足すと、その複雑さが新しい不安定さを生む。だったらシンプルな構造にして、多少の冗長さは許容する。最終的にはこっちの方が長持ちすると思っている。
Librarian の最初のバージョンは、これでひとまず形になった。
- Qdrant + SQLite からメモリを読む
- SDK の Claude でサマリー生成
- 毎日4AMに launchd で自動実行
- UI から手動で「昇格」できる
- MONETがサマリーと昇格済みファイルの両方を読む
正直、最初に動いた時は「思ったよりちゃんと司書っぽいな」と思った。完璧じゃない。サマリーの精度はこれから磨かないといけない。でも、「メモリは貯まっていたが活用できていない」という最初の問題に、答えが一つ出た。土台は出来た。