From bf3593895570382841636f93916a2d3e5b5f1c17 Mon Sep 17 00:00:00 2001 From: John Trengrove Date: Tue, 4 Aug 2026 17:28:12 +1000 Subject: [PATCH 1/6] Add command /engram:import-memories --- README.md | 7 + plugin/commands/import-memories.md | 41 +++++ plugin/core/tools/__init__.py | 2 + plugin/core/tools/import_memories.py | 239 +++++++++++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 plugin/commands/import-memories.md create mode 100644 plugin/core/tools/__init__.py create mode 100644 plugin/core/tools/import_memories.py diff --git a/README.md b/README.md index bb65d06..2b27bfb 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,13 @@ Inside Claude Code CLI session do: That's it. Memory starts working on your next prompt. +## Commands + +- `/engram:import-memories` — one-shot import of Claude Code's local file-based memories + (`~/.claude/projects/*/memory/*.md`) into Engram. Each fact is tagged with the source + project's git origin repo (`repo_name`), so memories that were siloed per project become + recallable everywhere. Re-running only imports new or changed files. + ## Identity When Engram project uses `user_id` to isolate memories, it ties it to: diff --git a/plugin/commands/import-memories.md b/plugin/commands/import-memories.md new file mode 100644 index 0000000..c84151d --- /dev/null +++ b/plugin/commands/import-memories.md @@ -0,0 +1,41 @@ +--- +description: Import Claude Code's local file-based memories into Engram, tagged with each source project's git origin repo +--- + +# Import Claude Code memories into Engram + +Claude Code keeps per-project memory files under `~/.claude/projects/*/memory/*.md`; they are +siloed to the project they were written in. This command imports them into Engram — tagged with +each source project's git origin repo (`repo_name`, the same scope property the store hook uses) +— so they become recallable from any project. + +`${CLAUDE_PLUGIN_ROOT}` and `${CLAUDE_PLUGIN_DATA}` below are substituted by the plugin loader; +they must be passed explicitly because the Bash tool does not inherit them. + +## Steps + +1. Dry run first, to see what would be imported: + + ```bash + env CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}" CLAUDE_PLUGIN_DATA="${CLAUDE_PLUGIN_DATA}" \ + bash "${CLAUDE_PLUGIN_ROOT}/hooks/with-venv.sh" -m core.tools.import_memories --dry-run + ``` + +2. Give the user a short summary of the plan: how many memories, grouped by repo/project, and + any projects whose path could not be resolved (those import without a repo tag). If the dry + run finds nothing new, report that and stop. + +3. Run the real import — the same command without `--dry-run` — and report the outcome: + imported / already imported / failed counts, plus any per-file failures verbatim. + +## Notes + +- The importer records imported files (by content hash) in the plugin data dir, so re-running + only picks up new or changed memory files. `--force` re-imports everything — warn the user + that this can create duplicate memories in Engram before using it. +- Imported memories are copies: the local files remain and still load in their own projects. + If the user asks about cleanup, they can delete a project's `memory/` files themselves — do + not delete them as part of this command. +- If the importer reports a missing API key, the fix is `export ENGRAM_API_KEY=...` (ideally in + a shell profile such as `~/.zshenv`). If it reports a missing identity, set `git config + user.email` or `ENGRAM_USER_ID`. diff --git a/plugin/core/tools/__init__.py b/plugin/core/tools/__init__.py new file mode 100644 index 0000000..0f402b7 --- /dev/null +++ b/plugin/core/tools/__init__.py @@ -0,0 +1,2 @@ +"""One-shot maintenance tools invoked by plugin slash commands (not hooks). Each module is an +entrypoint for `with-venv.sh -m core.tools.`; unlike core.hooks they read argv, not stdin.""" diff --git a/plugin/core/tools/import_memories.py b/plugin/core/tools/import_memories.py new file mode 100644 index 0000000..8a1c437 --- /dev/null +++ b/plugin/core/tools/import_memories.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""One-shot importer: Claude Code's local file-based memories -> Engram. + +Claude Code keeps per-project memories as markdown fact files under +~/.claude/projects//memory/*.md (MEMORY.md is an index, not a fact, and is +skipped). Those files are siloed per project; importing them into Engram makes them +recallable everywhere. + +Each fact is sent as a single user message (Engram's extraction distills it) tagged with +`repo_name` resolved from the source project's `git remote get-url origin` — the same +property the store hook attaches — so recall scoping treats imported and hook-stored +memories identically. The munged directory name is decoded back to a real path by a +filesystem-pruned search (the munging maps both '/' and '.' to '-', so decoding is +ambiguous without checking what actually exists on disk). + +Already-imported files are recorded (by content hash) in the plugin data dir and skipped +on re-runs; --force re-imports everything. Invoked by the /engram:import-memories command +via with-venv.sh; prints a human-readable report and exits 0 unless nothing could run at all. +""" + +import argparse +import glob +import hashlib +import json +import os +import sys + +from core import get_client, get_user_id +from core.scope import scope_schema +from core.util import data_dir, git_repo + +STATE_FILE = "claude-import-state.json" + + +def decode_project_dir(name): + """Decode a munged project dir name (e.g. '-Users-me-src-repo--bare') back to candidate + absolute paths. The munging maps '/' and '.' to '-' and keeps literal '-', so each '-' + is a three-way branch; pruning against real directories keeps the search tiny. A + candidate must re-munge to exactly `name`, which also rejects paths mangled by + accidental '..' components.""" + matches = [] + + def rec(prefix, rest): + i = rest.find("-") + if i < 0: + full = prefix + rest + if os.path.isdir(full): + matches.append(full) + return + comp, tail = prefix + rest[:i], rest[i + 1 :] + if os.path.isdir(comp): + rec(comp + "/", tail) + rec(comp + ".", tail) + rec(comp + "-", tail) + + if name.startswith("-"): + rec("/", name[1:]) + return [m for m in matches if m.replace("/", "-").replace(".", "-") == name] + + +def resolve_project(name): + """(path, repo_slug) for a munged project dir name. Among candidate decodings, prefer + one that has a git origin — that's the one worth tagging with.""" + for path in decode_project_dir(name): + slug = git_repo(path) + if slug: + return path, slug + cands = decode_project_dir(name) + return (cands[0], None) if cands else (None, None) + + +def parse_memory(text): + """(meta, body) from a memory file: `name:`/`description:`/`type:` out of the frontmatter + (naive line scan — `type` sits indented under `metadata:`, stripping handles it), body + after the closing '---'. No frontmatter -> whole file is the body.""" + meta = {} + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return meta, text.strip() + end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None) + if end is None: + return meta, text.strip() + for ln in lines[1:end]: + s = ln.strip() + for key in ("name", "description", "type"): + if s.startswith(key + ":") and key not in meta: + meta[key] = s[len(key) + 1 :].strip() + return meta, "\n".join(lines[end + 1 :]).strip() + + +def build_message(meta, body, slug, project_path): + """The fact as a single user message. Repo/type context goes in the text (not properties) + so extraction keeps it even where the scope schema has no matching property.""" + bits = [] + if slug: + bits.append(f"repository: {slug}") + elif project_path: + bits.append(f"project: {project_path}") + if meta.get("type"): + bits.append(f"kind: {meta['type']}") + header = ( + "Note imported from my Claude Code project memory" + + (f" ({', '.join(bits)})" if bits else "") + + ". Please remember this:" + ) + title = meta.get("description") or meta.get("name") or "" + return "\n\n".join(p for p in (header, title, body) if p) + + +def repo_properties(slug): + """{'repo_name': slug} unless the group's schema is known and lacks repo_name (then the + server would have nothing to do with it). Schema unavailable -> send it anyway; the + server is the authority.""" + if not slug: + return None + try: + allowed = scope_schema().get("properties", []) + except Exception: + return {"repo_name": slug} + return {"repo_name": slug} if "repo_name" in allowed else None + + +def load_state(): + try: + with open(os.path.join(data_dir(), STATE_FILE)) as f: + return json.load(f) + except Exception: + return {} # no data dir / first run / corrupt state -> treat everything as new + + +def save_state(state): + try: + path = os.path.join(data_dir(), STATE_FILE) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(state, f, indent=1, sort_keys=True) + except Exception as e: + print(f"warning: could not record import state ({e}) — a re-run will re-import.") + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--dry-run", action="store_true", help="report without storing") + ap.add_argument("--force", action="store_true", help="re-import already-imported files") + ap.add_argument( + "--projects-dir", + default=os.path.expanduser("~/.claude/projects"), + help="Claude Code projects dir (default: ~/.claude/projects)", + ) + args = ap.parse_args(argv) + + files = sorted( + f + for f in glob.glob(os.path.join(args.projects_dir, "*", "memory", "*.md")) + if os.path.basename(f) != "MEMORY.md" + ) + if not files: + print(f"No Claude memory files found under {args.projects_dir}.") + return 0 + + client, user_id = None, None + if not args.dry_run: + client = get_client() + if client is None: + print("ENGRAM_API_KEY not set — cannot import. Set it and re-run.") + return 1 + user_id = get_user_id() + if not user_id: + print( + "No stable identity — set git user.email or ENGRAM_USER_ID and re-run " + "(prevents mixing memories between users)." + ) + return 1 + + state = load_state() + projects = {} # munged dir name -> (path, slug), resolved once per project + counts = {"imported": 0, "already": 0, "empty": 0, "failed": 0} + + for f in files: + proj = os.path.basename(os.path.dirname(os.path.dirname(f))) + if proj not in projects: + projects[proj] = resolve_project(proj) + path, slug = projects[proj] + where = slug or path or proj + rel = os.path.basename(f) + + text = open(f, encoding="utf-8", errors="replace").read() + meta, body = parse_memory(text) + if not body and not meta.get("description"): + counts["empty"] += 1 + print(f"skip (no content): {where} · {rel}") + continue + + digest = hashlib.sha256(text.encode()).hexdigest() + if not args.force and state.get(f) == digest: + counts["already"] += 1 + print(f"skip (already imported): {where} · {rel}") + continue + + label = meta.get("description") or meta.get("name") or rel + if args.dry_run: + counts["imported"] += 1 + tag = f" [repo_name={slug}]" if slug else " [no repo tag]" + print(f"would import: {where} · {rel}{tag} — {label}") + continue + + try: + client.memories.add( + [{"role": "user", "content": build_message(meta, body, slug, path)}], + user_id=user_id, + properties=repo_properties(slug), + ) + except Exception as e: + counts["failed"] += 1 + print(f"FAILED: {where} · {rel} — {e}") + continue + state[f] = digest + counts["imported"] += 1 + print(f"imported: {where} · {rel} — {label}") + + if not args.dry_run and counts["imported"]: + save_state(state) + + unresolved = sorted(p for p, (path, _) in projects.items() if not path) + verb = "would import" if args.dry_run else "imported" + print( + f"\n{verb}: {counts['imported']} · already imported: {counts['already']}" + f" · empty: {counts['empty']} · failed: {counts['failed']}" + ) + if unresolved: + print( + "projects whose path no longer exists (imported without a repo tag): " + + ", ".join(unresolved) + ) + return 1 if counts["failed"] else 0 + + +if __name__ == "__main__": + sys.exit(main()) From fefd23c6d2f2c69c1834bb44eb1804534350b5cb Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Wed, 5 Aug 2026 12:11:23 +0200 Subject: [PATCH 2/6] Refactor local-memory import into a migrate source adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rehomes the /engram:import-memories logic on top of the migration framework: the munged-dirname decoder, frontmatter parser, and prefer-git-origin resolution carry over into a claude-memory source adapter; the ad-hoc state file, hand-rolled add loop, and slash command are replaced by what the framework already provides — per-source checkpoint with resume, batching, conversation mode with real dates, dry-run by default, and manifest-based --rollback. - core/migrate/claude_memory.py: adapter for ~/.claude/projects/*/ memory/*.md (MEMORY.md skipped); frontmatter type → kind mapping; file mtime as created_at; content hash in the uid so an edited fact re-migrates naturally - engine.repo_resolver probes absolute-path projects directly (this source knows the real directory, no repos-dir guessing) - --detect lists registered sources and whether their store exists; the migrate-memories skill now detects sources first and asks which to migrate when several are present - plugin/core/tools/ and the command file removed; README updated Co-Authored-By: Claude Fable 5 --- README.md | 36 ++-- plugin/commands/import-memories.md | 41 ---- plugin/core/migrate/__init__.py | 7 +- plugin/core/migrate/__main__.py | 8 + plugin/core/migrate/claude_memory.py | 141 ++++++++++++++ plugin/core/migrate/engine.py | 9 +- plugin/core/tools/__init__.py | 2 - plugin/core/tools/import_memories.py | 239 ------------------------ plugin/skills/migrate-memories/SKILL.md | 40 ++-- plugin/tests/test_migrate.py | 92 ++++++++- 10 files changed, 295 insertions(+), 320 deletions(-) delete mode 100644 plugin/commands/import-memories.md create mode 100644 plugin/core/migrate/claude_memory.py delete mode 100644 plugin/core/tools/__init__.py delete mode 100644 plugin/core/tools/import_memories.py diff --git a/README.md b/README.md index 2b27bfb..c7c6c45 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,6 @@ Inside Claude Code CLI session do: That's it. Memory starts working on your next prompt. -## Commands - -- `/engram:import-memories` — one-shot import of Claude Code's local file-based memories - (`~/.claude/projects/*/memory/*.md`) into Engram. Each fact is tagged with the source - project's git origin repo (`repo_name`), so memories that were siloed per project become - recallable everywhere. Re-running only imports new or changed files. - ## Identity When Engram project uses `user_id` to isolate memories, it ties it to: @@ -131,17 +124,24 @@ engram-migrate # dry-run: report of what would be migrated (default) engram-migrate --execute # migrate — resumable, safe to interrupt and re-run ``` -Supported sources: **claude-mem** (default). The importer is strictly read-only on the -source store and idempotent — a checkpoint in `~/.engram/migrate/` records committed items, -so re-runs only send what's missing. Memories are grouped per repo and day into -chronological conversations and imported through Engram's extraction pipeline, with each -conversation's `created_at` telling the extractor when the data is from — so memory -content carries real dates. Engram classifies each memory into your group's topics itself; -the migration never picks a topic, so any topic setup works. Scope properties resolve per -source project the same way the store hook resolves them — same configuration files -(`~/.engram/config.json`, per-dir `.engram.json`), same source cascades — so migrated and -realtime memories are scoped identically. Note: the `created_at` shown by search is always -the ingestion time — Weaviate does not allow overriding it. +Supported sources (`engram-migrate --detect` shows which exist on your machine): + +- **claude-mem** (default) — its SQLite observation store. +- **claude-memory** — Claude Code's own local file memories + (`~/.claude/projects/*/memory/*.md`), siloed per project until migrated; editing a fact + file re-imports it. + +The importer is strictly read-only on the source store and idempotent — a per-source +checkpoint in `~/.engram/migrate/` records committed items, so re-runs only send what's +missing. Memories are grouped per repo and day into chronological conversations and +imported through Engram's extraction pipeline, with each conversation's `created_at` +telling the extractor when the data is from — so memory content carries real dates. Engram +classifies each memory into your group's topics itself; the migration never picks a topic, +so any topic setup works. Scope properties resolve per source project the same way the +store hook resolves them — same configuration files (`~/.engram/config.json`, per-dir +`.engram.json`), same source cascades — so migrated and realtime memories are scoped +identically. Note: the `created_at` shown by search is always the ingestion time — +Weaviate does not allow overriding it. Useful flags: diff --git a/plugin/commands/import-memories.md b/plugin/commands/import-memories.md deleted file mode 100644 index c84151d..0000000 --- a/plugin/commands/import-memories.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -description: Import Claude Code's local file-based memories into Engram, tagged with each source project's git origin repo ---- - -# Import Claude Code memories into Engram - -Claude Code keeps per-project memory files under `~/.claude/projects/*/memory/*.md`; they are -siloed to the project they were written in. This command imports them into Engram — tagged with -each source project's git origin repo (`repo_name`, the same scope property the store hook uses) -— so they become recallable from any project. - -`${CLAUDE_PLUGIN_ROOT}` and `${CLAUDE_PLUGIN_DATA}` below are substituted by the plugin loader; -they must be passed explicitly because the Bash tool does not inherit them. - -## Steps - -1. Dry run first, to see what would be imported: - - ```bash - env CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}" CLAUDE_PLUGIN_DATA="${CLAUDE_PLUGIN_DATA}" \ - bash "${CLAUDE_PLUGIN_ROOT}/hooks/with-venv.sh" -m core.tools.import_memories --dry-run - ``` - -2. Give the user a short summary of the plan: how many memories, grouped by repo/project, and - any projects whose path could not be resolved (those import without a repo tag). If the dry - run finds nothing new, report that and stop. - -3. Run the real import — the same command without `--dry-run` — and report the outcome: - imported / already imported / failed counts, plus any per-file failures verbatim. - -## Notes - -- The importer records imported files (by content hash) in the plugin data dir, so re-running - only picks up new or changed memory files. `--force` re-imports everything — warn the user - that this can create duplicate memories in Engram before using it. -- Imported memories are copies: the local files remain and still load in their own projects. - If the user asks about cleanup, they can delete a project's `memory/` files themselves — do - not delete them as part of this command. -- If the importer reports a missing API key, the fix is `export ENGRAM_API_KEY=...` (ideally in - a shell profile such as `~/.zshenv`). If it reports a missing identity, set `git config - user.email` or `ENGRAM_USER_ID`. diff --git a/plugin/core/migrate/__init__.py b/plugin/core/migrate/__init__.py index b600b86..7231058 100644 --- a/plugin/core/migrate/__init__.py +++ b/plugin/core/migrate/__init__.py @@ -32,6 +32,9 @@ class Record: def sources(): """Registry of source adapters, keyed by --source name.""" - from . import claude_mem + from . import claude_mem, claude_memory - return {claude_mem.ClaudeMemSource.name: claude_mem.ClaudeMemSource} + return { + claude_mem.ClaudeMemSource.name: claude_mem.ClaudeMemSource, + claude_memory.ClaudeMemorySource.name: claude_memory.ClaudeMemorySource, + } diff --git a/plugin/core/migrate/__main__.py b/plugin/core/migrate/__main__.py index 36dab2c..e5c3fae 100644 --- a/plugin/core/migrate/__main__.py +++ b/plugin/core/migrate/__main__.py @@ -202,7 +202,15 @@ def main(): ap.add_argument("--rollback", action="store_true", help="delete every memory this migration created (via run manifests) " "and reset the checkpoint") + ap.add_argument("--detect", action="store_true", + help="list registered sources and whether their store is present, " + "then exit") args = ap.parse_args() + if args.detect: + for name, cls in sorted(sources().items()): + found = cls().available() + print(f"{name}: {found or 'not found'}") + return 0 if args.rollback and args.execute: sys.exit("--rollback and --execute are mutually exclusive") diff --git a/plugin/core/migrate/claude_memory.py b/plugin/core/migrate/claude_memory.py new file mode 100644 index 0000000..2f9a6a0 --- /dev/null +++ b/plugin/core/migrate/claude_memory.py @@ -0,0 +1,141 @@ +"""Claude Code local-memory source: per-project markdown fact files. + +Claude Code keeps per-project memories under ~/.claude/projects//memory/*.md +(MEMORY.md is an index, not a fact, and is skipped). They are siloed to the project they +were written in; migrating them into Engram makes them recallable everywhere. + +The munged directory name is decoded back to a real path by a filesystem-pruned search +(the munging maps both '/' and '.' to '-', so decoding is ambiguous without checking what +actually exists on disk). The decoded absolute path becomes the record's project, so the +engine resolves repo_name from that directory's git remote — the same property the store +hook attaches, keeping migrated and hook-stored memories identically scoped. + +Records carry the file's content hash in their uid: editing a fact file yields a new uid, +so the changed content is re-migrated on the next run while the checkpoint still remembers +the old version.""" + +import glob +import hashlib +import os +from datetime import datetime, timezone + +from . import Record +from ..util import git_repo + +DEFAULT_DIR = "~/.claude/projects" + +def decode_project_dir(name): + """Decode a munged project dir name (e.g. '-Users-me-src-repo--bare') back to candidate + absolute paths. The munging maps '/' and '.' to '-' and keeps literal '-', so each '-' + is a three-way branch; pruning against real directories keeps the search tiny. A + candidate must re-munge to exactly `name`, which also rejects paths mangled by + accidental '..' components.""" + matches = [] + + def rec(prefix, rest): + i = rest.find("-") + if i < 0: + full = prefix + rest + if os.path.isdir(full): + matches.append(full) + return + comp, tail = prefix + rest[:i], rest[i + 1 :] + if os.path.isdir(comp): + rec(comp + "/", tail) + rec(comp + ".", tail) + rec(comp + "-", tail) + + if name.startswith("-"): + rec("/", name[1:]) + return [m for m in matches if m.replace("/", "-").replace(".", "-") == name] + + +def _resolve_project(name): + """Best decoded path for a munged dir name: prefer a candidate with a git origin (the + one worth scoping by), else the first existing decoding, else None (deleted since).""" + candidates = decode_project_dir(name) + for path in candidates: + if git_repo(path): + return path + return candidates[0] if candidates else None + + +def _parse_memory(text): + """(meta, body) from a memory file: `name:`/`description:`/`type:` out of the + frontmatter (naive line scan — `type` sits indented under `metadata:`, stripping + handles it), body after the closing '---'. No frontmatter → whole file is the body.""" + meta = {} + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return meta, text.strip() + end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None) + if end is None: + return meta, text.strip() + for ln in lines[1:end]: + s = ln.strip() + for key in ("name", "description", "type"): + if s.startswith(key + ":") and key not in meta: + meta[key] = s[len(key) + 1 :].strip() + return meta, "\n".join(lines[end + 1 :]).strip() + + +class ClaudeMemorySource: + name = "claude-memory" + + def __init__(self, path=None): + self.db_path = os.path.expanduser(path or DEFAULT_DIR) + + def _files(self): + return sorted( + f + for f in glob.glob(os.path.join(self.db_path, "*", "memory", "*.md")) + if os.path.basename(f) != "MEMORY.md" + ) + + def available(self): + return self.db_path if self._files() else None + + def records(self, include_all=False): + # include_all is moot for this source: every file is a deliberately saved fact, + # there is no low-signal tier to exclude + projects = {} + for f in self._files(): + proj_name = os.path.basename(os.path.dirname(os.path.dirname(f))) + if proj_name not in projects: + projects[proj_name] = _resolve_project(proj_name) + text = open(f, encoding="utf-8", errors="replace").read() + meta, body = _parse_memory(text) + title = meta.get("description") or meta.get("name") or "" + content = " — ".join(p for p in (title, body) if p) + if not content: + continue + digest = hashlib.sha256(text.encode()).hexdigest()[:12] + ts = datetime.fromtimestamp(os.path.getmtime(f), tz=timezone.utc) + yield Record( + uid=f"{f}@{digest}", + content=content, + # no authored timestamp exists; the file's mtime is the best available + created_at=ts.strftime("%Y-%m-%dT%H:%M:%SZ"), + project=projects[proj_name] or proj_name, + ) + + def describe_selection(self, include_all=False): + files = self._files() + counts, unresolved = {}, set() + for f in files: + proj_name = os.path.basename(os.path.dirname(os.path.dirname(f))) + if not decode_project_dir(proj_name): + unresolved.add(proj_name) + meta, _ = _parse_memory(open(f, encoding="utf-8", errors="replace").read()) + t = meta.get("type") or "(untyped)" + counts[t] = counts.get(t, 0) + 1 + lines = [ + f"memory files included: {len(files)} " + f"({', '.join(f'{t} ({n})' for t, n in sorted(counts.items()))})" + ] + if unresolved: + lines.append( + f"projects whose path no longer exists: {len(unresolved)} — their munged " + "names can't resolve a repo; recover with --map =owner/repo" + ) + return lines diff --git a/plugin/core/migrate/engine.py b/plugin/core/migrate/engine.py index fde29b5..b546872 100644 --- a/plugin/core/migrate/engine.py +++ b/plugin/core/migrate/engine.py @@ -27,8 +27,9 @@ def project_dir_finder(explicit_dirs, registry_index, fallback_dirs): - """project name → existing directory, or None. Layered so no workspace layout is - assumed: + """project → existing directory, or None. An absolute-path project (a source that + knows the real directory, e.g. claude-memory's decoded project dirs) is checked + directly. Bare names are looked up in layers, so no workspace layout is assumed: 1. explicit dirs (--repos-dir) — the user's stated locations always win; 2. Claude Code's session registry index (claude_projects.index_by_basename) — the @@ -56,6 +57,10 @@ def probe(dirs, project): def find(project): if project in cache: return cache[project] + if os.path.isabs(project): + found = project if os.path.isdir(project) else None + cache[project] = found + return found found = probe(explicit_dirs, project) if not found: candidates = registry_index.get(project) or [] diff --git a/plugin/core/tools/__init__.py b/plugin/core/tools/__init__.py deleted file mode 100644 index 0f402b7..0000000 --- a/plugin/core/tools/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""One-shot maintenance tools invoked by plugin slash commands (not hooks). Each module is an -entrypoint for `with-venv.sh -m core.tools.`; unlike core.hooks they read argv, not stdin.""" diff --git a/plugin/core/tools/import_memories.py b/plugin/core/tools/import_memories.py deleted file mode 100644 index 8a1c437..0000000 --- a/plugin/core/tools/import_memories.py +++ /dev/null @@ -1,239 +0,0 @@ -#!/usr/bin/env python3 -"""One-shot importer: Claude Code's local file-based memories -> Engram. - -Claude Code keeps per-project memories as markdown fact files under -~/.claude/projects//memory/*.md (MEMORY.md is an index, not a fact, and is -skipped). Those files are siloed per project; importing them into Engram makes them -recallable everywhere. - -Each fact is sent as a single user message (Engram's extraction distills it) tagged with -`repo_name` resolved from the source project's `git remote get-url origin` — the same -property the store hook attaches — so recall scoping treats imported and hook-stored -memories identically. The munged directory name is decoded back to a real path by a -filesystem-pruned search (the munging maps both '/' and '.' to '-', so decoding is -ambiguous without checking what actually exists on disk). - -Already-imported files are recorded (by content hash) in the plugin data dir and skipped -on re-runs; --force re-imports everything. Invoked by the /engram:import-memories command -via with-venv.sh; prints a human-readable report and exits 0 unless nothing could run at all. -""" - -import argparse -import glob -import hashlib -import json -import os -import sys - -from core import get_client, get_user_id -from core.scope import scope_schema -from core.util import data_dir, git_repo - -STATE_FILE = "claude-import-state.json" - - -def decode_project_dir(name): - """Decode a munged project dir name (e.g. '-Users-me-src-repo--bare') back to candidate - absolute paths. The munging maps '/' and '.' to '-' and keeps literal '-', so each '-' - is a three-way branch; pruning against real directories keeps the search tiny. A - candidate must re-munge to exactly `name`, which also rejects paths mangled by - accidental '..' components.""" - matches = [] - - def rec(prefix, rest): - i = rest.find("-") - if i < 0: - full = prefix + rest - if os.path.isdir(full): - matches.append(full) - return - comp, tail = prefix + rest[:i], rest[i + 1 :] - if os.path.isdir(comp): - rec(comp + "/", tail) - rec(comp + ".", tail) - rec(comp + "-", tail) - - if name.startswith("-"): - rec("/", name[1:]) - return [m for m in matches if m.replace("/", "-").replace(".", "-") == name] - - -def resolve_project(name): - """(path, repo_slug) for a munged project dir name. Among candidate decodings, prefer - one that has a git origin — that's the one worth tagging with.""" - for path in decode_project_dir(name): - slug = git_repo(path) - if slug: - return path, slug - cands = decode_project_dir(name) - return (cands[0], None) if cands else (None, None) - - -def parse_memory(text): - """(meta, body) from a memory file: `name:`/`description:`/`type:` out of the frontmatter - (naive line scan — `type` sits indented under `metadata:`, stripping handles it), body - after the closing '---'. No frontmatter -> whole file is the body.""" - meta = {} - lines = text.splitlines() - if not lines or lines[0].strip() != "---": - return meta, text.strip() - end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None) - if end is None: - return meta, text.strip() - for ln in lines[1:end]: - s = ln.strip() - for key in ("name", "description", "type"): - if s.startswith(key + ":") and key not in meta: - meta[key] = s[len(key) + 1 :].strip() - return meta, "\n".join(lines[end + 1 :]).strip() - - -def build_message(meta, body, slug, project_path): - """The fact as a single user message. Repo/type context goes in the text (not properties) - so extraction keeps it even where the scope schema has no matching property.""" - bits = [] - if slug: - bits.append(f"repository: {slug}") - elif project_path: - bits.append(f"project: {project_path}") - if meta.get("type"): - bits.append(f"kind: {meta['type']}") - header = ( - "Note imported from my Claude Code project memory" - + (f" ({', '.join(bits)})" if bits else "") - + ". Please remember this:" - ) - title = meta.get("description") or meta.get("name") or "" - return "\n\n".join(p for p in (header, title, body) if p) - - -def repo_properties(slug): - """{'repo_name': slug} unless the group's schema is known and lacks repo_name (then the - server would have nothing to do with it). Schema unavailable -> send it anyway; the - server is the authority.""" - if not slug: - return None - try: - allowed = scope_schema().get("properties", []) - except Exception: - return {"repo_name": slug} - return {"repo_name": slug} if "repo_name" in allowed else None - - -def load_state(): - try: - with open(os.path.join(data_dir(), STATE_FILE)) as f: - return json.load(f) - except Exception: - return {} # no data dir / first run / corrupt state -> treat everything as new - - -def save_state(state): - try: - path = os.path.join(data_dir(), STATE_FILE) - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w") as f: - json.dump(state, f, indent=1, sort_keys=True) - except Exception as e: - print(f"warning: could not record import state ({e}) — a re-run will re-import.") - - -def main(argv=None): - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--dry-run", action="store_true", help="report without storing") - ap.add_argument("--force", action="store_true", help="re-import already-imported files") - ap.add_argument( - "--projects-dir", - default=os.path.expanduser("~/.claude/projects"), - help="Claude Code projects dir (default: ~/.claude/projects)", - ) - args = ap.parse_args(argv) - - files = sorted( - f - for f in glob.glob(os.path.join(args.projects_dir, "*", "memory", "*.md")) - if os.path.basename(f) != "MEMORY.md" - ) - if not files: - print(f"No Claude memory files found under {args.projects_dir}.") - return 0 - - client, user_id = None, None - if not args.dry_run: - client = get_client() - if client is None: - print("ENGRAM_API_KEY not set — cannot import. Set it and re-run.") - return 1 - user_id = get_user_id() - if not user_id: - print( - "No stable identity — set git user.email or ENGRAM_USER_ID and re-run " - "(prevents mixing memories between users)." - ) - return 1 - - state = load_state() - projects = {} # munged dir name -> (path, slug), resolved once per project - counts = {"imported": 0, "already": 0, "empty": 0, "failed": 0} - - for f in files: - proj = os.path.basename(os.path.dirname(os.path.dirname(f))) - if proj not in projects: - projects[proj] = resolve_project(proj) - path, slug = projects[proj] - where = slug or path or proj - rel = os.path.basename(f) - - text = open(f, encoding="utf-8", errors="replace").read() - meta, body = parse_memory(text) - if not body and not meta.get("description"): - counts["empty"] += 1 - print(f"skip (no content): {where} · {rel}") - continue - - digest = hashlib.sha256(text.encode()).hexdigest() - if not args.force and state.get(f) == digest: - counts["already"] += 1 - print(f"skip (already imported): {where} · {rel}") - continue - - label = meta.get("description") or meta.get("name") or rel - if args.dry_run: - counts["imported"] += 1 - tag = f" [repo_name={slug}]" if slug else " [no repo tag]" - print(f"would import: {where} · {rel}{tag} — {label}") - continue - - try: - client.memories.add( - [{"role": "user", "content": build_message(meta, body, slug, path)}], - user_id=user_id, - properties=repo_properties(slug), - ) - except Exception as e: - counts["failed"] += 1 - print(f"FAILED: {where} · {rel} — {e}") - continue - state[f] = digest - counts["imported"] += 1 - print(f"imported: {where} · {rel} — {label}") - - if not args.dry_run and counts["imported"]: - save_state(state) - - unresolved = sorted(p for p, (path, _) in projects.items() if not path) - verb = "would import" if args.dry_run else "imported" - print( - f"\n{verb}: {counts['imported']} · already imported: {counts['already']}" - f" · empty: {counts['empty']} · failed: {counts['failed']}" - ) - if unresolved: - print( - "projects whose path no longer exists (imported without a repo tag): " - + ", ".join(unresolved) - ) - return 1 if counts["failed"] else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/plugin/skills/migrate-memories/SKILL.md b/plugin/skills/migrate-memories/SKILL.md index ded5ad2..092b0c1 100644 --- a/plugin/skills/migrate-memories/SKILL.md +++ b/plugin/skills/migrate-memories/SKILL.md @@ -1,19 +1,22 @@ --- name: migrate-memories description: > - Migrate memories from another local memory system into Engram (claude-mem supported - today). Use this whenever the user wants to import, migrate, transfer, or backfill - memories from claude-mem or a previous memory tool into Engram, mentions switching - memory systems, asks to bring old coding-session history into Engram, wants to re-import - memories with their original dates, or wants to roll back / undo a previous Engram - migration — even if they don't say the word "migrate". + Migrate memories from another local memory system into Engram. Supported sources: + claude-mem, and Claude Code's own local file memories (~/.claude/projects/*/memory/). + Use this whenever the user wants to import, migrate, transfer, or backfill memories from + claude-mem, Claude Code local memory, or a previous memory tool into Engram, mentions + switching memory systems, asks to bring old coding-session history or per-project memory + files into Engram, wants to re-import memories with their original dates, or wants to + roll back / undo a previous Engram migration — even if they don't say the word + "migrate". --- # Migrate memories into Engram Everything runs through one CLI bundled with the plugin. It is safe by design: dry-run by -default, strictly read-only on the source store, and resumable — a local checkpoint records -every migrated item, so interrupted or repeated runs never send anything twice. +default, strictly read-only on the source store, and resumable — a local checkpoint (one +per source) records every migrated item, so interrupted or repeated runs never send +anything twice. Invoke it through the wrapper in this skill's `scripts/` directory (next to this SKILL.md). It is self-locating — no environment variables or harness-specific paths needed: @@ -24,27 +27,34 @@ bash /scripts/migrate.sh [flags] ## Flow -1. **Dry-run first, always.** Run with the user's flags but WITHOUT `--execute` or - `--rollback`, even if the user included them — those only run after step 3. The dry-run +1. **Detect sources.** Run `migrate.sh --detect` — it lists every supported source and + whether its store exists on this machine. If the user already named a source, honor + that. If exactly one is present, use it. If several are present, ask the user which to + migrate — claude-mem, Claude Code local memories (`claude-memory`), or both — before + doing anything else; each then goes through the full flow below with + `--source `. +2. **Dry-run first, always.** Run with the user's flags but WITHOUT `--execute` or + `--rollback`, even if the user included them — those only run after step 4. The dry-run writes nothing and shows exactly what would migrate, so the user decides from facts. -2. **Present the report**: how many memories per scope and day; which source projects +3. **Present the report**: how many memories per scope and day; which source projects were skipped because their required scope properties could not be resolved — usually the project's directory no longer exists anywhere the CLI looks (offer `--map=NAME=owner/repo`, or `--repos-dir DIR` if their repositories live somewhere unusual); and the sample item. Topics are not part of the plan, and the source is not pre-filtered: Engram's extraction classifies each memory into the group's topics and decides what to keep. -3. **Ask the user explicitly whether to proceed.** Executing writes to their Engram cloud +4. **Ask the user explicitly whether to proceed.** Executing writes to their Engram cloud store — never run `--execute` or `--rollback` without a fresh confirmation from the user in this conversation. -4. **Execute**: re-run the same command with `--execute` appended. Conversations are +5. **Execute**: re-run the same command with `--execute` appended. Conversations are submitted in chronological order without waiting on each pipeline run (Engram queues internally); the command then waits for the pipeline to settle and reports what committed. Exit codes: 0 done, 1 failed submissions/runs, 3 some runs still in the pipeline — re-running the same command reconciles and continues safely from the checkpoint. -5. **Summarize**: submitted / committed / failed / still in the pipeline, and remind the - user that a re-run retries only what's missing. +6. **Summarize**: submitted / committed / failed / still in the pipeline, and remind the + user that a re-run retries only what's missing. When migrating both sources, summarize + each. ## Options to surface when relevant diff --git a/plugin/tests/test_migrate.py b/plugin/tests/test_migrate.py index 0dcb1f6..808aa02 100644 --- a/plugin/tests/test_migrate.py +++ b/plugin/tests/test_migrate.py @@ -13,8 +13,9 @@ import unittest.mock from types import SimpleNamespace -from core.migrate import Record +from core.migrate import Record, sources from core.migrate.claude_mem import ClaudeMemSource +from core.migrate.claude_memory import ClaudeMemorySource, decode_project_dir from core.migrate.claude_projects import index_by_basename from core.migrate.engine import ( execute, @@ -132,6 +133,95 @@ def fake_props(mapping): ) +MEMORY_FILE = """--- +name: prefers-uv +description: The user prefers uv for dependency management +metadata: + type: user +--- + +Use uv, not pip, when adding dependencies. +""" + + +class ClaudeMemoryAdapterTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + # a real project dir whose munged name the adapter must decode back + self.proj = os.path.join(self.tmp.name, "my.repo") + os.makedirs(self.proj) + munged = self.proj.replace("/", "-").replace(".", "-") + self.memdir = os.path.join(self.tmp.name, "projects", munged, "memory") + os.makedirs(self.memdir) + with open(os.path.join(self.memdir, "MEMORY.md"), "w") as f: + f.write("- index entry, not a fact") + with open(os.path.join(self.memdir, "prefers-uv.md"), "w") as f: + f.write(MEMORY_FILE) + with open(os.path.join(self.memdir, "bare.md"), "w") as f: + f.write("A fact with no frontmatter at all.") + self.source = ClaudeMemorySource(os.path.join(self.tmp.name, "projects")) + + def tearDown(self): + self.tmp.cleanup() + + def test_decode_project_dir_roundtrip(self): + munged = self.proj.replace("/", "-").replace(".", "-") + self.assertIn(self.proj, decode_project_dir(munged)) + + def test_records_skip_index_and_decode_project(self): + recs = {os.path.basename(r.uid.split("@")[0]): r for r in self.source.records()} + self.assertEqual(set(recs), {"prefers-uv.md", "bare.md"}) # MEMORY.md skipped + pref = recs["prefers-uv.md"] + self.assertEqual( + pref.content, + "The user prefers uv for dependency management — " + "Use uv, not pip, when adding dependencies.", + ) + self.assertEqual(pref.project, self.proj) # munged name decoded to the real path + self.assertRegex(pref.created_at, r"^\d{4}-\d{2}-\d{2}T") # file mtime + self.assertEqual(recs["bare.md"].content, "A fact with no frontmatter at all.") + + def test_uid_changes_when_content_changes(self): + (before,) = [r.uid for r in self.source.records() if "prefers-uv" in r.uid] + with open(os.path.join(self.memdir, "prefers-uv.md"), "a") as f: + f.write("\nAlso: never use pipenv.") + (after,) = [r.uid for r in self.source.records() if "prefers-uv" in r.uid] + self.assertNotEqual(before, after) # edited fact re-migrates under a fresh uid + + def test_available_and_registry(self): + self.assertTrue(self.source.available()) + self.assertIsNone(ClaudeMemorySource(os.path.join(self.tmp.name, "nope")).available()) + self.assertIn("claude-memory", sources()) + + def test_describe_selection_counts_types(self): + text = "\n".join(self.source.describe_selection()) + self.assertIn("user (1)", text) + self.assertIn("(untyped) (1)", text) + + +class ProjectDirFinderTest(unittest.TestCase): + def test_absolute_project_checked_directly(self): + from core.migrate.engine import project_dir_finder + + with tempfile.TemporaryDirectory() as tmp: + proj = os.path.join(tmp, "proj") + os.makedirs(proj) + find = project_dir_finder([]) # no repos dirs needed for absolute paths + self.assertEqual(find(proj), proj) + self.assertIsNone(find(os.path.join(tmp, "missing"))) + + def test_bare_name_probed_against_repos_dirs(self): + from core.migrate.engine import project_dir_finder + + with tempfile.TemporaryDirectory() as tmp: + proj = os.path.join(tmp, "proj") + os.makedirs(proj) + find = project_dir_finder([tmp]) + self.assertEqual(find("proj"), proj) + # the repos dir itself matches when its basename is the project name + self.assertEqual(project_dir_finder([proj])("proj"), proj) + + class EngineTest(unittest.TestCase): def recs(self): return [ From 71097a407217a9d4835a7d1694854206244c7053 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Wed, 5 Aug 2026 12:27:39 +0200 Subject: [PATCH 3/6] Default --source to claude-memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Claude Code install has the built-in local memory system, while claude-mem is an optional third-party plugin — the common case should be the default. Co-Authored-By: Claude Fable 5 --- README.md | 5 +++-- plugin/core/migrate/__main__.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c7c6c45..1e8523e 100644 --- a/README.md +++ b/README.md @@ -126,10 +126,11 @@ engram-migrate --execute # migrate — resumable, safe to interrupt and re-run Supported sources (`engram-migrate --detect` shows which exist on your machine): -- **claude-mem** (default) — its SQLite observation store. -- **claude-memory** — Claude Code's own local file memories +- **claude-memory** (default) — Claude Code's own local file memories (`~/.claude/projects/*/memory/*.md`), siloed per project until migrated; editing a fact file re-imports it. +- **claude-mem** (`--source claude-mem`) — the claude-mem plugin's SQLite observation + store. The importer is strictly read-only on the source store and idempotent — a per-source checkpoint in `~/.engram/migrate/` records committed items, so re-runs only send what's diff --git a/plugin/core/migrate/__main__.py b/plugin/core/migrate/__main__.py index e5c3fae..da95076 100644 --- a/plugin/core/migrate/__main__.py +++ b/plugin/core/migrate/__main__.py @@ -186,7 +186,9 @@ def main(): "Memories go through Engram's extraction pipeline, which classifies " "them into your group's topics itself.", ) - ap.add_argument("--source", default="claude-mem", choices=sorted(sources())) + # claude-memory is the default: every Claude Code install has the built-in local + # memory system, while claude-mem is an optional third-party plugin + ap.add_argument("--source", default="claude-memory", choices=sorted(sources())) ap.add_argument("--db", help="override the source's default store location") ap.add_argument("--project", action="append", help="migrate only this source project (repeatable)") ap.add_argument("--map", action="append", metavar="NAME=owner/repo", From d56961e9d06feb18b5d682f9a28617c09f7c7a33 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Wed, 5 Aug 2026 14:22:48 +0200 Subject: [PATCH 4/6] Harden the munged-dirname decoder from self-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decode_project_dir: prune all three branches against the parent's real directory listing and iterate on an explicit stack — the unpruned search was Θ(2^dashes) (a 25-dash kebab-case path hung the CLI for minutes) and could hit the recursion limit; regression test guards a 40-dash name. describe_selection decodes once per project instead of once per file - unresolved-projects hint uses the --map==owner/repo form: a munged name's leading '-' makes argparse reject the space form - evals: add a multi-source detection prompt so the skill's detect-and-ask step is graded in future iterations Co-Authored-By: Claude Fable 5 --- plugin/core/migrate/claude_memory.py | 58 +++++++++++++------ .../skills/migrate-memories/evals/evals.json | 11 ++++ plugin/tests/test_migrate.py | 9 ++- 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/plugin/core/migrate/claude_memory.py b/plugin/core/migrate/claude_memory.py index 2f9a6a0..660ed16 100644 --- a/plugin/core/migrate/claude_memory.py +++ b/plugin/core/migrate/claude_memory.py @@ -27,26 +27,45 @@ def decode_project_dir(name): """Decode a munged project dir name (e.g. '-Users-me-src-repo--bare') back to candidate absolute paths. The munging maps '/' and '.' to '-' and keeps literal '-', so each '-' - is a three-way branch; pruning against real directories keeps the search tiny. A - candidate must re-munge to exactly `name`, which also rejects paths mangled by - accidental '..' components.""" - matches = [] + is a three-way branch — but every branch is pruned against the real directory listing + (a component prefix that matches no entry is dead), which bounds the search by what + exists on disk instead of exponential in the dash count (an unpruned search hangs on + ~25 dashes — an ordinary deep kebab-case path). Iterative on an explicit stack so a + pathological name can't hit the recursion limit. A candidate must re-munge to exactly + `name`, which also rejects paths mangled by accidental '..' components.""" + if not name.startswith("-"): + return [] + + listings = {} + + def entries(d): + if d not in listings: + try: + listings[d] = os.listdir(d) + except OSError: + listings[d] = [] + return listings[d] - def rec(prefix, rest): + matches = [] + stack = [("/", "", name[1:])] # (confirmed dir, partial component, rest of name) + while stack: + dirpath, partial, rest = stack.pop() i = rest.find("-") if i < 0: - full = prefix + rest + full = os.path.join(dirpath, partial + rest) if os.path.isdir(full): matches.append(full) - return - comp, tail = prefix + rest[:i], rest[i + 1 :] - if os.path.isdir(comp): - rec(comp + "/", tail) - rec(comp + ".", tail) - rec(comp + "-", tail) - - if name.startswith("-"): - rec("/", name[1:]) + continue + comp, tail = partial + rest[:i], rest[i + 1 :] + # '/' branch: comp is a complete path component + if comp in entries(dirpath) and os.path.isdir(os.path.join(dirpath, comp)): + stack.append((os.path.join(dirpath, comp), "", tail)) + # '.'/'-' branches: the component continues — only viable if some real entry + # starts with it + for ch in (".", "-"): + nxt = comp + ch + if any(e.startswith(nxt) for e in entries(dirpath)): + stack.append((dirpath, nxt, tail)) return [m for m in matches if m.replace("/", "-").replace(".", "-") == name] @@ -121,10 +140,12 @@ def records(self, include_all=False): def describe_selection(self, include_all=False): files = self._files() - counts, unresolved = {}, set() + counts, decodable, unresolved = {}, {}, set() for f in files: proj_name = os.path.basename(os.path.dirname(os.path.dirname(f))) - if not decode_project_dir(proj_name): + if proj_name not in decodable: # decode once per project, not per file + decodable[proj_name] = bool(decode_project_dir(proj_name)) + if not decodable[proj_name]: unresolved.add(proj_name) meta, _ = _parse_memory(open(f, encoding="utf-8", errors="replace").read()) t = meta.get("type") or "(untyped)" @@ -136,6 +157,7 @@ def describe_selection(self, include_all=False): if unresolved: lines.append( f"projects whose path no longer exists: {len(unresolved)} — their munged " - "names can't resolve a repo; recover with --map =owner/repo" + "names can't resolve a repo; recover with --map==owner/repo " + "(the leading '-' requires the '=' form)" ) return lines diff --git a/plugin/skills/migrate-memories/evals/evals.json b/plugin/skills/migrate-memories/evals/evals.json index 6127558..59b02a7 100644 --- a/plugin/skills/migrate-memories/evals/evals.json +++ b/plugin/skills/migrate-memories/evals/evals.json @@ -23,6 +23,17 @@ "No executed command contains --rollback", "The final user-facing message surfaces the dry-run findings rather than skipping straight to a result claim" ] + }, + { + "id": 2, + "prompt": "can you migrate my old memories into engram? not sure what's even on this machine", + "expected_output": "Runs --detect first; with both claude-mem and Claude Code local memories present, asks the user which source(s) to migrate (claude-mem, claude-memory, or both) before running anything else; no dry-run of a specific source and no --execute until the user answers.", + "files": [], + "assertions": [ + "migrate.sh --detect ran before any migration command", + "With multiple sources present, the agent asked the user which source(s) to migrate instead of picking one silently", + "No executed command contains --execute or --rollback" + ] } ] } diff --git a/plugin/tests/test_migrate.py b/plugin/tests/test_migrate.py index 808aa02..47c66a7 100644 --- a/plugin/tests/test_migrate.py +++ b/plugin/tests/test_migrate.py @@ -7,8 +7,8 @@ import json import os import sqlite3 -import subprocess import tempfile +import time import unittest import unittest.mock from types import SimpleNamespace @@ -168,6 +168,13 @@ def test_decode_project_dir_roundtrip(self): munged = self.proj.replace("/", "-").replace(".", "-") self.assertIn(self.proj, decode_project_dir(munged)) + def test_decode_deep_kebab_name_stays_fast(self): + # regression guard: the unpruned Θ(2ⁿ) search hung on ~25 dashes + name = "-Users-nobody-" + "-".join(["word"] * 40) + start = time.time() + self.assertEqual(decode_project_dir(name), []) + self.assertLess(time.time() - start, 2.0) + def test_records_skip_index_and_decode_project(self): recs = {os.path.basename(r.uid.split("@")[0]): r for r in self.source.records()} self.assertEqual(set(recs), {"prefers-uv.md", "bare.md"}) # MEMORY.md skipped From c6e0a069979129f0d8caf073c4013e67dc90055d Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Tue, 11 Aug 2026 14:08:38 +0200 Subject: [PATCH 5/6] Align claude-memory adapter with the simplified contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit records() and describe_selection() no longer take include_all — the parameter was removed with the curated-type list. Co-Authored-By: Claude Fable 5 --- plugin/core/migrate/claude_memory.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugin/core/migrate/claude_memory.py b/plugin/core/migrate/claude_memory.py index 660ed16..00bc296 100644 --- a/plugin/core/migrate/claude_memory.py +++ b/plugin/core/migrate/claude_memory.py @@ -114,9 +114,7 @@ def _files(self): def available(self): return self.db_path if self._files() else None - def records(self, include_all=False): - # include_all is moot for this source: every file is a deliberately saved fact, - # there is no low-signal tier to exclude + def records(self): projects = {} for f in self._files(): proj_name = os.path.basename(os.path.dirname(os.path.dirname(f))) @@ -138,7 +136,7 @@ def records(self, include_all=False): project=projects[proj_name] or proj_name, ) - def describe_selection(self, include_all=False): + def describe_selection(self): files = self._files() counts, decodable, unresolved = {}, {}, set() for f in files: From 88eaef53371569088f62e67ac6a309346ad33adf Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Tue, 11 Aug 2026 15:29:44 +0200 Subject: [PATCH 6/6] Use the shared registry decoder in the claude-memory adapter The munged-name decoder moved to core.migrate.claude_projects on #7 (the session-registry index uses it for directory discovery); the adapter now imports it instead of carrying its own copy. Finder tests updated for the layered signature. Co-Authored-By: Claude Fable 5 --- plugin/core/migrate/claude_memory.py | 51 ++-------------------------- plugin/tests/test_migrate.py | 9 ++--- 2 files changed, 8 insertions(+), 52 deletions(-) diff --git a/plugin/core/migrate/claude_memory.py b/plugin/core/migrate/claude_memory.py index 00bc296..f844e69 100644 --- a/plugin/core/migrate/claude_memory.py +++ b/plugin/core/migrate/claude_memory.py @@ -4,9 +4,8 @@ (MEMORY.md is an index, not a fact, and is skipped). They are siloed to the project they were written in; migrating them into Engram makes them recallable everywhere. -The munged directory name is decoded back to a real path by a filesystem-pruned search -(the munging maps both '/' and '.' to '-', so decoding is ambiguous without checking what -actually exists on disk). The decoded absolute path becomes the record's project, so the +The munged directory name is decoded back to a real path by the shared registry decoder +(core.migrate.claude_projects). The decoded absolute path becomes the record's project, so the engine resolves repo_name from that directory's git remote — the same property the store hook attaches, keeping migrated and hook-stored memories identically scoped. @@ -20,55 +19,11 @@ from datetime import datetime, timezone from . import Record +from .claude_projects import decode_project_dir from ..util import git_repo DEFAULT_DIR = "~/.claude/projects" -def decode_project_dir(name): - """Decode a munged project dir name (e.g. '-Users-me-src-repo--bare') back to candidate - absolute paths. The munging maps '/' and '.' to '-' and keeps literal '-', so each '-' - is a three-way branch — but every branch is pruned against the real directory listing - (a component prefix that matches no entry is dead), which bounds the search by what - exists on disk instead of exponential in the dash count (an unpruned search hangs on - ~25 dashes — an ordinary deep kebab-case path). Iterative on an explicit stack so a - pathological name can't hit the recursion limit. A candidate must re-munge to exactly - `name`, which also rejects paths mangled by accidental '..' components.""" - if not name.startswith("-"): - return [] - - listings = {} - - def entries(d): - if d not in listings: - try: - listings[d] = os.listdir(d) - except OSError: - listings[d] = [] - return listings[d] - - matches = [] - stack = [("/", "", name[1:])] # (confirmed dir, partial component, rest of name) - while stack: - dirpath, partial, rest = stack.pop() - i = rest.find("-") - if i < 0: - full = os.path.join(dirpath, partial + rest) - if os.path.isdir(full): - matches.append(full) - continue - comp, tail = partial + rest[:i], rest[i + 1 :] - # '/' branch: comp is a complete path component - if comp in entries(dirpath) and os.path.isdir(os.path.join(dirpath, comp)): - stack.append((os.path.join(dirpath, comp), "", tail)) - # '.'/'-' branches: the component continues — only viable if some real entry - # starts with it - for ch in (".", "-"): - nxt = comp + ch - if any(e.startswith(nxt) for e in entries(dirpath)): - stack.append((dirpath, nxt, tail)) - return [m for m in matches if m.replace("/", "-").replace(".", "-") == name] - - def _resolve_project(name): """Best decoded path for a munged dir name: prefer a candidate with a git origin (the one worth scoping by), else the first existing decoding, else None (deleted since).""" diff --git a/plugin/tests/test_migrate.py b/plugin/tests/test_migrate.py index 47c66a7..0dd090a 100644 --- a/plugin/tests/test_migrate.py +++ b/plugin/tests/test_migrate.py @@ -7,6 +7,7 @@ import json import os import sqlite3 +import subprocess import tempfile import time import unittest @@ -213,7 +214,7 @@ def test_absolute_project_checked_directly(self): with tempfile.TemporaryDirectory() as tmp: proj = os.path.join(tmp, "proj") os.makedirs(proj) - find = project_dir_finder([]) # no repos dirs needed for absolute paths + find = project_dir_finder([], {}, []) # layers unused for absolute paths self.assertEqual(find(proj), proj) self.assertIsNone(find(os.path.join(tmp, "missing"))) @@ -223,10 +224,10 @@ def test_bare_name_probed_against_repos_dirs(self): with tempfile.TemporaryDirectory() as tmp: proj = os.path.join(tmp, "proj") os.makedirs(proj) - find = project_dir_finder([tmp]) + find = project_dir_finder([tmp], {}, []) self.assertEqual(find("proj"), proj) - # the repos dir itself matches when its basename is the project name - self.assertEqual(project_dir_finder([proj])("proj"), proj) + # the dir itself matches when its basename is the project name + self.assertEqual(project_dir_finder([proj], {}, [])("proj"), proj) class EngineTest(unittest.TestCase):