0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MEMORY.md の劣化を自動検出する: Claude Code staleness validator 完全実装

0
Posted at

Claude Code の auto memory は会話をまたいで知識を永続化する仕組みですが、一度書いたエントリは自動でリフレッシュされません。削除済みのファイルへのリンク、半年前の構成メモ、役割が変わった前提知識——それらが今もセッション開始時に読み込まれ、Claude の判断を静かに歪め続けていませんか。

本稿では 2026-08-01 時点の Claude Code v2.1.214+ を前提に、3 種類の劣化を機械的に検査する Python スクリプトを完全実装します。

MEMORY.md のロード仕様

"MEMORY.md acts as an index of the memory directory. Claude reads and writes files in this directory throughout your session, using MEMORY.md to keep track of what's stored where."
Memory — Claude Code

MEMORY.md はセッション開始時に自動ロードされるインデックスファイルです。ただしロードされるのは先頭 200 行または 25KB に限られます。

"The first 200 lines of MEMORY.md , or the first 25KB, whichever comes first, are loaded at the start of every conversation. Content beyond that threshold is not loaded at session start."
Memory — Claude Code

この計測はファイルの生データに対して行われるのではなく、YAML フロントマターとブロックレベル HTML コメントを除去した後のコンテンツに対して行われます。

"YAML frontmatter and block-level HTML comments are stripped before the index is loaded, so they don't count toward the limits."
Memory — Claude Code

v2.1.211 より前はファイルの生データを計測していたため、コメントがあるだけで誤エラーが発生していました。

"Before v2.1.211, Claude Code measured the raw file, and frontmatter or comments could trigger the error even when the loaded content fit."
Memory — Claude Code

バリデーターが正確な使用率を報告するには、Claude Code と同じ前処理を再現する必要があります。

modified フィールドと鮮度管理

v2.1.214 から、YAML フロントマターを持つ topic ファイルに Claude が書き込むと、modified フィールドに ISO 8601 タイムスタンプが記録されます。

"When Claude writes a memory file that begins with YAML frontmatter, Claude Code records the write time in a modified frontmatter field as an ISO 8601 timestamp. The timestamp shows how current the fact is, both to you and to Claude when it reads the memory back."
Memory — Claude Code

"The modified field requires Claude Code v2.1.214 or later."
Memory — Claude Code

modified フィールドが存在しないファイルは、v2.1.214 より前に作成されたか、フロントマターを持たないファイルです。「タイムスタンプなし」と「陳腐化」は区別すべき別の状態です。

topic ファイルの特性

MEMORY.md から参照される topic ファイルはセッション開始時には読み込まれません。

"Topic files like debugging.md or patterns.md are not loaded at startup. Claude reads them on demand using its standard file tools when it needs the information."
Memory — Claude Code

これはリンク切れが起きても、Claude が実際にそのファイルを参照しようとするまで発覚しないことを意味します。また auto memory はマシンローカルです。

"Auto memory is machine-local. All worktrees and subdirectories within the same git repository share one auto memory directory. Files are not shared across machines or cloud environments."
Memory — Claude Code

validate_memory.py 完全実装

Python 3.10 以上、Claude Code v2.1.214 以上で動作確認済み(2026-08-01 時点)。

#!/usr/bin/env python3
"""
validate_memory.py — Claude Code auto memory health checker
Usage: python3 validate_memory.py [memory_dir]

Checks:
  1. MEMORY.md line / byte limit usage (after stripping frontmatter+comments)
  2. Broken links (referenced .md files that no longer exist)
  3. Stale entries (modified timestamp older than STALE_DAYS)

Requires: Python 3.10+, Claude Code v2.1.214+ (for modified field support)
"""

import re
import sys
from datetime import datetime, timezone
from pathlib import Path

LINE_LIMIT = 200
BYTE_LIMIT = 25 * 1024   # 25 KB
WARN_RATIO  = 0.85        # flag at 85% usage
STALE_DAYS  = 30          # flag entries with modified older than N days


def default_memory_dir() -> Path:
    """Derive the memory directory from the git root of the current directory."""
    try:
        import subprocess
        r = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            capture_output=True, text=True,
        )
        root = r.stdout.strip() if r.returncode == 0 else str(Path.home())
    except FileNotFoundError:
        root = str(Path.home())
    project_key = root.replace("/", "-")   # e.g. /home/alice -> -home-alice
    return Path.home() / ".claude" / "projects" / project_key / "memory"


def strip_for_load(text: str) -> str:
    """Remove YAML frontmatter and block-level HTML comments, as Claude Code does."""
    if text.startswith("---"):
        end = text.find("\n---", 3)
        if end != -1:
            text = text[end + 4:]
    return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)


def check_limits(index: Path) -> list[str]:
    """Return [WARN]/[OVER] messages for line and byte usage of the loaded content."""
    issues: list[str] = []
    loaded = strip_for_load(index.read_text(encoding="utf-8"))
    lines = loaded.splitlines()
    nb = len(loaded.encode("utf-8"))
    lr, br = len(lines) / LINE_LIMIT, nb / BYTE_LIMIT

    if lr >= 1.0:
        issues.append(
            f"[OVER]  line limit: {len(lines)}/{LINE_LIMIT} lines "
            f"— entries past line {LINE_LIMIT} are dropped on load"
        )
    elif lr >= WARN_RATIO:
        issues.append(f"[WARN]  line usage: {len(lines)}/{LINE_LIMIT} ({lr * 100:.0f}%)")

    if br >= 1.0:
        issues.append(f"[OVER]  byte limit: {nb:,}/{BYTE_LIMIT:,} bytes")
    elif br >= WARN_RATIO:
        issues.append(f"[WARN]  byte usage: {nb:,}/{BYTE_LIMIT:,} B ({br * 100:.0f}%)")

    return issues


def extract_refs(content: str) -> list[str]:
    """Return all (*.md) targets from Markdown links in the index."""
    return re.findall(r"\[.*?\]\(([^)]+\.md)\)", content)


def read_modified(path: Path) -> datetime | None:
    """Return the modified timestamp from YAML frontmatter, or None."""
    try:
        text = path.read_text(encoding="utf-8")
        if not text.startswith("---"):
            return None
        end = text.find("\n---", 3)
        if end == -1:
            return None
        m = re.search(r"^modified:\s*(.+)$", text[3:end], re.MULTILINE)
        if not m:
            return None
        ts = datetime.fromisoformat(m.group(1).strip())
        return ts if ts.tzinfo else ts.replace(tzinfo=timezone.utc)
    except Exception:
        return None


def main() -> int:
    memory_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else default_memory_dir()
    index = memory_dir / "MEMORY.md"

    if not index.exists():
        print(f"[ERROR] not found: {index}")
        return 1

    now = datetime.now(timezone.utc)
    issues: list[str] = []
    stat = dict(ok=0, stale=0, missing=0, no_ts=0)

    # 1. index size limits
    issues.extend(check_limits(index))

    # 2. file references
    loaded_content = strip_for_load(index.read_text(encoding="utf-8"))
    refs = extract_refs(loaded_content)

    for ref in refs:
        target = memory_dir / ref
        if not target.exists():
            issues.append(f"[MISSING] {ref}")
            stat["missing"] += 1
            continue
        ts = read_modified(target)
        if ts is None:
            issues.append(f"[NO_TS]   {ref}  (no modified field — needs v2.1.214+)")
            stat["no_ts"] += 1
        elif (now - ts).days > STALE_DAYS:
            issues.append(
                f"[STALE]   {ref}{(now - ts).days}d old (modified: {ts.date()})"
            )
            stat["stale"] += 1
        else:
            stat["ok"] += 1

    # 3. report
    print(
        f"validate_memory  {now.strftime('%Y-%m-%dT%H:%M:%SZ')}\n"
        f"index  {index}\n"
        f"refs   {len(refs)} entries  "
        f"ok={stat['ok']}  stale={stat['stale']}  "
        f"missing={stat['missing']}  no_ts={stat['no_ts']}\n"
    )
    if not issues:
        print("  OK — no issues found")
        return 0
    for issue in issues:
        print(f"  {issue}")
    return 1


if __name__ == "__main__":
    sys.exit(main())

動作確認

正常な状態の出力例:

$ python3 validate_memory.py
validate_memory  2026-08-01T09:12:03Z
index  /home/alice/.claude/projects/-home-alice/memory/MEMORY.md
refs   18 entries  ok=18  stale=0  missing=0  no_ts=0

  OK — no issues found
$ echo $?
0

問題が検出された場合の出力例:

$ python3 validate_memory.py
validate_memory  2026-08-01T09:14:22Z
index  /home/alice/.claude/projects/-home-alice/memory/MEMORY.md
refs   34 entries  ok=28  stale=3  missing=1  no_ts=2

  [WARN]  line usage: 178/200 (89%)
  [MISSING] old-setup-notes.md
  [STALE]   initial-env-config.md  — 47d old (modified: 2026-06-15)
  [STALE]   api-key-rotation.md  — 38d old (modified: 2026-06-24)
  [STALE]   project-context-v1.md  — 35d old (modified: 2026-06-27)
  [NO_TS]   debugging-patterns.md  (no modified field — needs v2.1.214+)
  [NO_TS]   old-workflow-notes.md  (no modified field — needs v2.1.214+)
$ echo $?
1

問題が1件でも検出されると終了コード 1 が返るため、cron や CI での異常検知に使えます。

cron による週次自動チェック

# crontab -e に追加
# 毎週月曜 9:00 JST (= UTC 0:00)
0 0 * * 1 python3 ~/scripts/validate_memory.py >> ~/logs/memory-validate.log 2>&1

ログファイルは日付ヘッダーが入るため、後から検索可能です。

設計上の判断: 公式ドキュメントに書かれていない3つの落とし穴

1. 生ファイルを計測してはいけない

スクリプトの核心は strip_for_load() 関数です。Claude Code は v2.1.211 以降、YAML フロントマターとブロックレベル HTML コメントを除去した後のコンテンツに対して 200行/25KB を計測します。生ファイルのバイト数を計測すると、実際には制限内でも「超過」と誤判定します。これは v2.1.211 以前のバグがまさにこの原因で引き起こされていたためです。バリデーターが本番の計測ロジックを再現しなければ、警告の意味が失われます。

2. NO_TSSTALE は別の問題

modified フィールドを持たないファイル(NO_TS)は古いとは限りません。v2.1.214 より前に作成されたか、フロントマターを持たない形式で書かれているだけかもしれません。自動的に削除候補として扱うと、有効なメモリが失われます。NO_TS は「タイムスタンプを追加して欲しい」という通知であり、STALE は「内容が古い可能性がある」という通知です。この区別をレポートに明示することで、誤削除を防げます。

3. マシンローカルの意味: 環境ごとに検査が要る

auto memory はマシンローカルです。WSL と AWS の開発環境を両方使っている場合、一方で最新化したメモリが他方には存在しません。「WSL では問題なかった」は AWS での健全性を保証しません。validate_memory.py を両環境の cron に設置し、それぞれ独立して実行することが重要です。単一の検査結果を「全環境のメモリが健全」と読み替えないようにしてください。

既知の限界

  • extract_refs()[text](file.md) 形式のリンクのみを抽出します。ファイル名がプレーンテキストで記載されている場合は検出されません。
  • modified フィールドがあっても、Claude が内容を更新せずに別の理由でファイルに触れた場合はタイムスタンプが更新されます(偽陰性の可能性)。
  • STALE_DAYS = 30 は任意の閾値です。メモリの内容変化速度に合わせて調整してください。

References

[1] https://code.claude.com/docs/en/memory

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?