Skip to content

記憶層 (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.pyCONTEXT.md のローテーション(肥大防止)
session_context.pySessionStart で注入するテキストの組み立て(フック参照)
memory_report.py棚卸しレポート(stale / 重複 / 肥大の列挙、read-only)
vendored_chunker.pymd チャンク分割(memsearch からの無改変コピー)

真実源は Markdown、索引は「影」

全体を貫く前提が 「真実は md、SQLite はそこから再構築可能な影」 です。index_memory.py の docstring にも明記されています。

真実源は md。このDB(memory_index.db)は md から再構築可能な索引。LLMを一切呼ばない=サブスク枠内・課金ゼロ。

この割り切りにより、索引は「いつ壊しても reindex で作り直せる」ものとして扱え、設計が大幅に単純化されています。

FTS5 スキーマ:なぜ 2 テーブルなのか

fts_index.py2 つの FTS5 仮想テーブル を持ちます。これは日本語(CJK)検索の都合です。

python
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 テーブルへ自動反映されます。

python
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(後述)を主キーに持ちます。

python
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() のロジック:

  1. md を chunk_markdown() で chunk 化し、各チャンクの content_hash 集合 new_hashes を作る。
  2. DB から同じ source の既存 chunk_hash 集合 old_hashes を引く。
  3. old_hashes - new_hashes(md から消えたチャンク)を DELETE。
  4. new_hashes のうち old_hashes に無いものだけ INSERT。
  5. 同じハッシュ(=内容無変更)のチャンクは何もしない。
python
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」を一括削除します。

python
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() はクエリの性質で検索経路を切り替えます。

python
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 統合漢字・ハングル)。

python
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()

  1. 見出しから日付(2026-06-11 / 2026/6/11 / 2026年6月11日)を正規表現で抽出。
  2. 無ければファイルの最終更新日時(mtime)を使う。
python
_DATE_RE = re.compile(r"(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})")

この date は recall スキルが「古い記憶は現状と食い違う可能性がある」と判断する材料になります。

検索時にも reindex する理由

index_memory.pysearch サブコマンドは、検索の直前にも reindex を走らせます。

python
# クラッシュ後でも最新を引けるよう、検索前に差分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 形式の日付が無いと保存を拒否します。「昨日」「来週」のような相対表記を記憶に残させないためです。

python
def _validate_heading(heading):
    if not _DATE_RE.search(heading):
        _fail("見出しに絶対日付(YYYY-MM-DD)が必要です。相対日付(昨日・来週)は禁止")

② 完全重複の拒否

保存しようとするブロックが既存ブロックと(空白差・末尾改行差を正規化した上で)一致したら拒否します。

python
for b in blocks:
    if _norm(b) == _norm(block):
        _fail("同一内容のブロックが既に存在します(保存不要)")

③ 字数上限とエラー駆動の統合

1 ファイルが上限(MEMORY_FILE_MAX_CHARS、既定 10000 字)を超える保存はエラーで拒否し、既存ブロックの見出し一覧を提示します。

python
if len(new_text) > _max_chars():
    _fail(
        f"ファイルが上限 {_max_chars()} 字を超えます(現在 {len(text)} 字)。"
        "replace で既存ブロックを統合・更新してから保存してください",
        blocks,   # ← 統合候補として既存見出しを列挙
    )

これが error-driven consolidation(エラー駆動の統合)です。「上限に達したら自動で古いものを消す」のではなく、「エラーを返してエージェントに統合を強制する」ことで、情報の取捨選択という知的判断をセッション内(サブスク枠)に委ねます。remember スキルにも「上限を理由に保存を諦めるな」と明記されています。

appendreplace の 2 操作があり、replace--match(見出しの部分文字列)で対象ブロックを一意特定します。複数一致・ゼロ一致はエラーにして誤爆を防ぎます。保存成功時は index_markdown_file() で即座に索引も更新します。

TIP

CONTEXT.md だけは「新しいものが先頭」の運用のため、append 時に先頭へ挿入されます。その他の記憶 md は末尾追記です。

CONTEXT.md ローテーション:rotate_context.py

CONTEXT.md は「H2 見出し(## ...)単位のブロックの集まり」で、新しいまとめを先頭に追加する運用です。放置すると肥大するため、rotate_context.py先頭 N ブロック(新しい)を残し、末尾(古い)をアーカイブへ退避します。

python
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.pyread-only で「整理候補」を列挙します(変更は一切しません)。memory-gc スキルの入力になります。

カテゴリ検出ロジック
stale_chunk_datestale-days(既定 90 日)より古いブロック
duplicatesdifflib.SequenceMatcher の類似度が 0.7 以上のブロックのペア
oversized字数が上限の 80% を超えたファイル

重複検出は O(n²) の総当たりですが、MAX_PAIRWISE_BLOCKS = 300 で上限を設け暴走を防いでいます(記憶 md は通常これより遥かに小さい)。

「候補列挙はローカルツール(課金ゼロ)、統合の判断と実行はセッション内」という分業がここでも徹底されています。

CLI の使い方(リファレンス)

bash
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

agent-team-pack(MIT OSS)の仕組みを読み解いた技術解説資料