"""coding-agent-memory-across-sessions.py — audit what a Claude Code auto memory index actually loads at session start, and how much memory outlives the session transcripts beside it. What it does: audit [projects-root] walks every / directory under the Claude Code projects root (default: $CLAUDE_CONFIG_DIR/projects, else ~/.claude/projects) and, per project, reports: - MEMORY.md lines and bytes, and how many lines survive the documented session-start read limit ("The first 200 lines of MEMORY.md, or the first 25KB, whichever comes first", code.claude.com/docs/en/memory, read 2026-09-14), after stripping YAML frontmatter and block-level HTML comments, which the errors page says are "stripped before the index is loaded" - topic files (*.md beside MEMORY.md), how many carry frontmatter, how many carry the `modified` stamp (Claude Code v2.1.214+), the `type` values, how many are never named in MEMORY.md, and how many are named only past the load cut (so the index that loads cannot point to them) - session transcripts (*.jsonl) still on disk and the oldest one's age in days by file mtime (the default retention sweep is 30 days) Directory names are replaced by p01, p02, ... (largest index first) unless you pass --names. preview prints the line and byte where loading stops and the first 70 characters of every line that would be dropped. Options: --csv FILE also write the audit table as CSV --kb-bytes N bytes in "25KB" (default 25000; the docs do not say 25000 or 25600, so run both if a file sits near the edge) --names print real project directory names instead of p01, p02, ... Limits of the simulation: whether a line that straddles the byte limit is kept in part is not documented, so this counts whole lines only; only top-level topic files are counted; the `type` and `modified` keys are matched at any indentation inside the frontmatter. Exit code: 0 when every index loads in full, 1 when at least one index is past a limit. Run: python coding-agent-memory-across-sessions.py audit --csv memory-audit.csv python coding-agent-memory-across-sessions.py preview ~/.claude/projects//memory/MEMORY.md Python 3.13, standard library only. Read-only: it never writes inside the projects root. """ from __future__ import annotations import argparse import collections import csv import os import re import sys import time MAX_LINES = 200 FRONTMATTER_RE = re.compile(r"\A---\r?\n.*?\r?\n---[ \t]*(?:\r?\n|\Z)", re.S) HTML_COMMENT_BLOCK_RE = re.compile(r"^[ \t]*[ \t]*(?:\r?\n|\Z)", re.S | re.M) TYPE_RE = re.compile(r"^\s*type:\s*['\"]?([A-Za-z_]+)", re.M) MODIFIED_RE = re.compile(r"^\s*modified:", re.M) def loadable_text(raw: str) -> str: """The part of MEMORY.md the limits are measured on: frontmatter and block comments removed.""" text = FRONTMATTER_RE.sub("", raw, count=1) return HTML_COMMENT_BLOCK_RE.sub("", text) def load_cut(text: str, max_bytes: int) -> tuple[int, int, int]: """Return (lines kept, bytes kept, total lines) under 200 lines or max_bytes, whichever first.""" lines = text.splitlines(keepends=True) kept_lines = kept_bytes = 0 for line in lines: size = len(line.encode("utf-8")) if kept_lines == MAX_LINES or kept_bytes + size > max_bytes: break kept_lines += 1 kept_bytes += size return kept_lines, kept_bytes, len(lines) def frontmatter(text: str) -> str | None: m = FRONTMATTER_RE.match(text) return m.group(0) if m else None def audit_project(pdir: str, max_bytes: int, now: float) -> dict: row = {"has_index": 0, "index_lines": 0, "index_bytes": 0, "loaded_lines": 0, "dropped_lines": 0, "dropped_bytes": 0, "topic_files": 0, "topic_frontmatter": 0, "topic_modified": 0, "topic_never_indexed": 0, "topic_indexed_past_cut": 0, "transcripts": 0, "oldest_transcript_days": ""} types: collections.Counter = collections.Counter() mdir = os.path.join(pdir, "memory") index_loaded = index_all = "" idx = os.path.join(mdir, "MEMORY.md") if os.path.isfile(idx): raw_bytes = open(idx, "rb").read() text = loadable_text(raw_bytes.decode("utf-8", errors="replace")) kept, kept_b, total = load_cut(text, max_bytes) row.update(has_index=1, index_lines=total, index_bytes=len(raw_bytes), loaded_lines=kept, dropped_lines=total - kept, dropped_bytes=len(text.encode("utf-8")) - kept_b) index_all = text index_loaded = "".join(text.splitlines(keepends=True)[:kept]) if os.path.isdir(mdir): for name in sorted(os.listdir(mdir)): path = os.path.join(mdir, name) if name == "MEMORY.md" or not name.endswith(".md") or not os.path.isfile(path): continue row["topic_files"] += 1 fm = frontmatter(open(path, encoding="utf-8", errors="replace").read()) if fm: row["topic_frontmatter"] += 1 if MODIFIED_RE.search(fm): row["topic_modified"] += 1 t = TYPE_RE.search(fm) types[t.group(1) if t else "(no type)"] += 1 if name not in index_all: row["topic_never_indexed"] += 1 elif name not in index_loaded: row["topic_indexed_past_cut"] += 1 ages = [(now - os.path.getmtime(os.path.join(pdir, f))) / 86400 for f in os.listdir(pdir) if f.endswith(".jsonl")] row["transcripts"] = len(ages) if ages: row["oldest_transcript_days"] = f"{max(ages):.1f}" return {"row": row, "types": types} def cmd_audit(args) -> int: base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude") root = args.root or os.path.join(base, "projects") if not os.path.isdir(root): print(f"no projects directory at {root}", file=sys.stderr) return 2 now = time.time() results = [] for name in os.listdir(root): pdir = os.path.join(root, name) if os.path.isdir(pdir): r = audit_project(pdir, args.kb_bytes, now) r["row"] = {"project": name, **r["row"]} results.append(r) results.sort(key=lambda r: (-r["row"]["index_bytes"], r["row"]["project"])) for i, r in enumerate(results, 1): if not args.names: r["row"]["project"] = f"p{i:02d}" rows = [r["row"] for r in results] if args.csv and rows: with open(args.csv, "w", newline="", encoding="utf-8") as fh: w = csv.DictWriter(fh, fieldnames=list(rows[0].keys())) w.writeheader() w.writerows(rows) with_index = [r for r in rows if r["has_index"]] over = [r for r in with_index if r["dropped_lines"]] types: collections.Counter = collections.Counter() for r in results: types.update(r["types"]) def tot(key: str) -> int: return sum(int(r[key]) for r in rows) olds = [float(r["oldest_transcript_days"]) for r in rows if r["oldest_transcript_days"]] print(f"read limit {MAX_LINES} lines or {args.kb_bytes} bytes") print(f"project directories {len(rows)}") print(f"with MEMORY.md {len(with_index)}") print(f"index past a read limit {len(over)}") for r in over: print(f" {r['project']}: {r['index_lines']} lines, {r['index_bytes']} bytes -> loads " f"{r['loaded_lines']} lines, drops {r['dropped_lines']} lines / {r['dropped_bytes']} bytes") print(f"largest index {max((r['index_lines'] for r in with_index), default=0)} lines, " f"{max((r['index_bytes'] for r in with_index), default=0)} bytes") print(f"topic files {tot('topic_files')} (frontmatter {tot('topic_frontmatter')}, " f"modified stamp {tot('topic_modified')})") print(f"topic types {dict(types.most_common())}") print(f"never named in index {tot('topic_never_indexed')}") print(f"named only past the cut {tot('topic_indexed_past_cut')}") print(f"memory dirs, no transcript {sum(1 for r in with_index if not r['transcripts'])}") print(f"projects with transcripts {len(olds)} (oldest {max(olds, default=0):.1f} days by mtime)") return 1 if over else 0 def cmd_preview(args) -> int: raw = open(args.file, "rb").read().decode("utf-8", errors="replace") text = loadable_text(raw) kept, kept_b, total = load_cut(text, args.kb_bytes) print(f"{args.file}: {total} lines after stripping; loads lines 1-{kept} ({kept_b} bytes)") for n, line in enumerate(text.splitlines()[kept:], kept + 1): print(f" dropped {n:4d}: {line[:70]}") return 1 if kept < total else 0 def main() -> int: ap = argparse.ArgumentParser(description="Audit Claude Code auto memory against its read limits.") sub = ap.add_subparsers(dest="cmd", required=True) a = sub.add_parser("audit") a.add_argument("root", nargs="?") a.add_argument("--csv") a.add_argument("--names", action="store_true") a.add_argument("--kb-bytes", type=int, default=25000) p = sub.add_parser("preview") p.add_argument("file") p.add_argument("--kb-bytes", type=int, default=25000) args = ap.parse_args() return cmd_audit(args) if args.cmd == "audit" else cmd_preview(args) if __name__ == "__main__": sys.exit(main())