Librarian -司書をつくる-
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がサマリーと昇格済みファイルの両方を読む
正直、最初に動いた時は「思ったよりちゃんと司書っぽいな」と思った。完璧じゃない。サマリーの精度はこれから磨かないといけない。でも、「メモリは貯まっていたが活用できていない」という最初の問題に、答えが一つ出た。土台は出来た。