記憶層 (memory_system)
CLI エージェントに永続記憶を与える軽量ライブラリです。LLM を一切呼ばず、Python 標準の sqlite3 だけで動きます。 ここがパッケージの技術的な心臓部なので、ファイルごとに「何をどう実装しているか」を掘り下げます。
ファイル構成と役割
| ファイル | 役割 |
|---|---|
fts_index.py | 記憶層の核。md を chunk 化し、SQLite + FTS5 に差分索引・検索する |
index_memory.py | エントリポイント CLI(reindex / search) |
memory_write.py | 保存 CLI(append / replace)。品質ルールを機械的に強制 |
rotate_context.py | CONTEXT.md のローテーション(肥大防止) |
session_context.py | SessionStart で注入するテキストの組み立て(フック参照) |
memory_report.py | 棚卸しレポート(stale / 重複 / 肥大の列挙、read-only) |
vendored_chunker.py | md チャンク分割(memsearch からの無改変コピー) |
真実源は Markdown、索引は「影」
全体を貫く前提が 「真実は md、SQLite はそこから再構築可能な影」 です。index_memory.py の docstring にも明記されています。
真実源は md。このDB(memory_index.db)は md から再構築可能な索引。LLMを一切呼ばない=サブスク枠内・課金ゼロ。
この割り切りにより、索引は「いつ壊しても reindex で作り直せる」ものとして扱え、設計が大幅に単純化されています。
FTS5 スキーマ:なぜ 2 テーブルなのか
fts_index.py は 2 つの FTS5 仮想テーブル を持ちます。これは日本語(CJK)検索の都合です。
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(content);
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts_trigram
USING fts5(content, tokenize='trigram');chunks_fts(既定の unicode61 トークナイザ):英数字・語単位の検索向け。chunks_fts_trigram(trigram トークナイザ):CJK の部分一致向け。
なぜ分けるのか。FTS5 の既定 unicode61 は 日本語を 1 文字ずつのトークンに割ってしまい、フレーズ検索が壊れるためです。trigram(3 文字ずつ)を別建てすることで日本語のフレーズ検索が機能します。この 2 テーブル構成と発想は hermes-agent から流用したものです。
実体テーブル chunks への INSERT/DELETE は トリガで両 FTS テーブルへ自動反映されます。
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
INSERT INTO chunks_fts(rowid, content) VALUES (new.rowid, new.content);
INSERT INTO chunks_fts_trigram(rowid, content) VALUES (new.rowid, new.content);
END;実体テーブル chunks のスキーマは次の通りで、chunk_hash(後述)を主キーに持ちます。
CREATE TABLE IF NOT EXISTS chunks (
chunk_hash TEXT PRIMARY KEY,
source TEXT NOT NULL, -- 元 md の絶対パス
heading TEXT, -- H2 見出し
content TEXT NOT NULL,
start_line INTEGER,
end_line INTEGER
);差分再索引:変更チャンクだけを更新する
reindex は記憶 md 全体を毎回入れ直すのではなく、変わったチャンクだけを更新します。鍵は各チャンクの content_hash(SHA-256)です。
index_markdown_file() のロジック:
- md を
chunk_markdown()で chunk 化し、各チャンクのcontent_hash集合new_hashesを作る。 - DB から同じ
sourceの既存chunk_hash集合old_hashesを引く。 old_hashes - new_hashes(md から消えたチャンク)を DELETE。new_hashesのうちold_hashesに無いものだけ INSERT。- 同じハッシュ(=内容無変更)のチャンクは何もしない。
new_hashes = {c.content_hash for c in chunks}
old_hashes = {row[0] for row in
db.execute("SELECT chunk_hash FROM chunks WHERE source = ?", (path,))}
for stale in old_hashes - new_hashes: # md から消えた節を索引から削除
db.execute("DELETE FROM chunks WHERE chunk_hash = ?", (stale,))
for c in chunks:
if c.content_hash in old_hashes: # 無変更はスキップ
continue
db.execute("INSERT OR IGNORE INTO chunks ...", (...))この設計により、記憶 md が増えても reindex のコストは「実際に変わった分」に比例します。フックが毎セッション叩いても軽いのはこのためです。
ファイルごと削除されたケースの掃除
セクション単位の削除は上記の差分同期が拾いますが、md ファイルごと削除された場合は再走査されません。そのため prune_missing_sources() が「DB にあるが実体ファイルが存在しない source」を一括削除します。
for src in db.execute("SELECT DISTINCT source FROM chunks"):
if not Path(src).exists():
db.execute("DELETE FROM chunks WHERE source = ?", (src,))日本語検索のルーティングと LIKE フォールバック
search() はクエリの性質で検索経路を切り替えます。
def search(db, query, limit=10):
q = query.strip()
if not q:
return []
if _contains_cjk(q): # 日本語を含む
if len(q) >= 3:
hits = _fts_match(db, "chunks_fts_trigram", q, limit)
if hits:
return hits
return _like_search(db, q, limit) # 2文字以下 or trigram空振り
return _fts_match(db, "chunks_fts", q, limit) # 英数字- 英数字クエリ →
chunks_fts(unicode61)で語単位検索。 - CJK クエリ(3 文字以上) →
chunks_fts_trigramで部分一致。 - CJK クエリ(2 文字以下)または trigram が空振り →
content LIKE '%q%'にフォールバック。
trigram は 3 文字単位なので 2 文字以下の語を索引できません。そこで _like_search が決定的な部分一致を保証します(hermes-agent の「短い CJK は LIKE」方針を踏襲)。
CJK 判定はコードポイント範囲で行います(ひらがな・カタカナ・CJK 統合漢字・ハングル)。
def _contains_cjk(text):
for ch in text:
o = ord(ch)
if 0x3040 <= o <= 0x30FF or 0x4E00 <= o <= 0x9FFF or 0xAC00 <= o <= 0xD7AF:
return True
return Falseまた、ユーザクエリは _as_phrase() で FTS5 フレーズ("...")に包み、特殊文字による SQL エラーを防いでいます。
検索結果に「日付」を付ける
想起した記憶の 新旧を判断できるように、各ヒットには date が付きます。_chunk_date() は
- 見出しから日付(
2026-06-11/2026/6/11/2026年6月11日)を正規表現で抽出。 - 無ければファイルの最終更新日時(mtime)を使う。
_DATE_RE = re.compile(r"(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})")この date は recall スキルが「古い記憶は現状と食い違う可能性がある」と判断する材料になります。
検索時にも reindex する理由
index_memory.py の search サブコマンドは、検索の直前にも reindex を走らせます。
# クラッシュ後でも最新を引けるよう、検索前に差分reindex(変更分だけ=高速)。
# Stopフックはクラッシュ時に発火しないため、索引の鮮度は検索時に担保する。
try:
reindex(db)
except Exception as e:
print(f"warning: reindex failed, results may be stale: {e}", file=sys.stderr)Stop フックはクラッシュ時には発火しないため、Stop だけに鮮度を頼ると索引が古くなる恐れがあります。検索時にも差分 reindex することで「いつ検索しても最新の md が引ける」を保証しています(差分なので低コスト)。
保存の品質ルール:memory_write.py
エージェントが md を直接編集する代わりに 必ずこの CLI を通すことで、記憶の品質を機械的に強制します。hermes-agent の memory ツールと同じ思想です。強制される 3 つのルール:
① 絶対日付の強制
見出しに YYYY-MM-DD 形式の日付が無いと保存を拒否します。「昨日」「来週」のような相対表記を記憶に残させないためです。
def _validate_heading(heading):
if not _DATE_RE.search(heading):
_fail("見出しに絶対日付(YYYY-MM-DD)が必要です。相対日付(昨日・来週)は禁止")② 完全重複の拒否
保存しようとするブロックが既存ブロックと(空白差・末尾改行差を正規化した上で)一致したら拒否します。
for b in blocks:
if _norm(b) == _norm(block):
_fail("同一内容のブロックが既に存在します(保存不要)")③ 字数上限とエラー駆動の統合
1 ファイルが上限(MEMORY_FILE_MAX_CHARS、既定 10000 字)を超える保存はエラーで拒否し、既存ブロックの見出し一覧を提示します。
if len(new_text) > _max_chars():
_fail(
f"ファイルが上限 {_max_chars()} 字を超えます(現在 {len(text)} 字)。"
"replace で既存ブロックを統合・更新してから保存してください",
blocks, # ← 統合候補として既存見出しを列挙
)これが error-driven consolidation(エラー駆動の統合)です。「上限に達したら自動で古いものを消す」のではなく、「エラーを返してエージェントに統合を強制する」ことで、情報の取捨選択という知的判断をセッション内(サブスク枠)に委ねます。remember スキルにも「上限を理由に保存を諦めるな」と明記されています。
append と replace の 2 操作があり、replace は --match(見出しの部分文字列)で対象ブロックを一意特定します。複数一致・ゼロ一致はエラーにして誤爆を防ぎます。保存成功時は index_markdown_file() で即座に索引も更新します。
TIP
CONTEXT.md だけは「新しいものが先頭」の運用のため、append 時に先頭へ挿入されます。その他の記憶 md は末尾追記です。
CONTEXT.md ローテーション:rotate_context.py
CONTEXT.md は「H2 見出し(## ...)単位のブロックの集まり」で、新しいまとめを先頭に追加する運用です。放置すると肥大するため、rotate_context.py が 先頭 N ブロック(新しい)を残し、末尾(古い)をアーカイブへ退避します。
def split_blocks(text):
# 最初の "## " より前(frontmatter 等)を「前文」、以降を H2 ブロックに分割
parts = re.split(r"(?m)^(?=## )", text)
if parts and not parts[0].startswith("## "):
return parts[0], parts[1:]
return "", parts
def rotate_context_md(path, keep_n=5, archive_path=None):
preamble, blocks = split_blocks(text)
if len(blocks) <= keep_n:
return 0 # 冪等:閾値以下なら何もしない
kept, archived = blocks[:keep_n], blocks[keep_n:]
# archived を logs/context_archive.md へ追記、kept だけを書き戻す退避は「消す」のではなく logs/context_archive.md への追記なので情報は失われません。閾値以下なら何もしない冪等な実装で、Stop フックから毎回呼んでも安全です。split_blocks() は memory_write / memory_report からも共用される基盤関数です。
棚卸しレポート:memory_report.py
記憶が経年劣化しないよう、memory_report.py が read-only で「整理候補」を列挙します(変更は一切しません)。memory-gc スキルの入力になります。
| カテゴリ | 検出ロジック |
|---|---|
| stale | _chunk_date が stale-days(既定 90 日)より古いブロック |
| duplicates | difflib.SequenceMatcher の類似度が 0.7 以上のブロックのペア |
| oversized | 字数が上限の 80% を超えたファイル |
重複検出は O(n²) の総当たりですが、MAX_PAIRWISE_BLOCKS = 300 で上限を設け暴走を防いでいます(記憶 md は通常これより遥かに小さい)。
「候補列挙はローカルツール(課金ゼロ)、統合の判断と実行はセッション内」という分業がここでも徹底されています。
CLI の使い方(リファレンス)
export MEMORY_DIRS="$HOME/.claude/memory:/path/to/workspace/memory"
# 索引(差分のみ)
python3 memory_system/index_memory.py reindex
# 想起(日本語OK・JSON 出力可)
python3 memory_system/index_memory.py search "クエリ" -n 5
python3 memory_system/index_memory.py search "クエリ" --json
# 保存(重複拒否・上限・絶対日付を CLI が強制)
python3 memory_system/memory_write.py append \
--file mem.md --heading "2026-06-11 決定: X" --body "本文(決定なら理由も)"
python3 memory_system/memory_write.py replace \
--file mem.md --match "決定: X" --heading "2026-06-11 決定: X(改)" --body "新本文"
# CONTEXT.md ローテ(先頭 5 ブロックを残す)
python3 memory_system/rotate_context.py path/to/CONTEXT.md -n 5
# 棚卸し(read-only)
python3 memory_system/memory_report.py --stale-days 90