Migrate Claude Code local memories into Engram (claude-memory source) - #4
Migrate Claude Code local memories into Engram (claude-memory source)#4trengrj wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Orca Security Scan Summary
| Status | Check | Issues by priority | |
|---|---|---|---|
| Infrastructure as Code | View in Orca | ||
| SAST | View in Orca | ||
| Secrets | View in Orca | ||
| Vulnerabilities | View in Orca |
5ce9119 to
02e341f
Compare
There was a problem hiding this comment.
Pull request overview
Adds support for migrating Claude Code’s per-project markdown “local memories” into Engram by implementing a new claude-memory source adapter within the existing migration framework (from #7), plus a --detect UX to help users choose among installed sources.
Changes:
- Introduces
core.migrate.claude_memorysource adapter that reads~/.claude/projects/*/memory/*.md, decodes munged project directory names, maps frontmattertypeto migration kinds, and uses file mtime for timestamps. - Extends migration CLI with
--detectand updates repo resolution to probe absolute-path projects directly. - Updates docs/skill text and adds unit tests covering the new adapter + absolute-path repo resolution.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents the new claude-memory source and the new --detect flow. |
| plugin/tests/test_migrate.py | Adds unit tests for claude-memory adapter behavior and absolute-path repo resolution. |
| plugin/skills/migrate-memories/SKILL.md | Updates the skill instructions to detect sources and guide selection (including claude-memory). |
| plugin/core/migrate/engine.py | Enhances repo_resolver to probe absolute-path projects directly. |
| plugin/core/migrate/claude_memory.py | New source adapter for Claude Code local markdown memories, including munged-path decoding and record generation. |
| plugin/core/migrate/main.py | Adds --detect option to list registered sources and whether their stores exist. |
| plugin/core/migrate/init.py | Registers the new claude-memory source in the adapter registry. |
Suppressed comments (1)
plugin/core/migrate/claude_memory.py:140
- describe_selection() re-runs decode_project_dir() for every file and also opens files without closing them; both can add noticeable overhead when scanning many memories. Cache decode results per project and read files via a context manager.
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):
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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() |
| with tempfile.TemporaryDirectory() as tmp: | ||
| repo = os.path.join(tmp, "proj") | ||
| os.makedirs(repo) | ||
| subprocess.run(["git", "init", "-q", repo], check=True) |
There was a problem hiding this comment.
Orca Security Scan Summary
| Status | Check | Issues by priority | |
|---|---|---|---|
| Secrets | View in Orca |
245be23 to
4aecbe3
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (3)
plugin/core/migrate/claude_memory.py:163
- describe_selection() also reads each file via open(...).read() without closing it. In a real ~/.claude/projects store with many files this can exhaust file descriptors during a dry-run report.
meta, _ = _parse_memory(open(f, encoding="utf-8", errors="replace").read())
plugin/core/migrate/claude_memory.py:137
- In records(), the memory file is read via open(...).read() without a context manager, which can leak file descriptors on large stores. Also, the Record uid currently embeds the absolute path to the memory file; render_report prints the uid in the sample item (engine.py:194), which can leak local machine paths into user-visible output and (via the skill) into the chat transcript. Consider reading as bytes with a context manager, hashing the raw bytes, and using a path relative to the source root for the uid.
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()
plugin/tests/test_migrate.py:173
- This test uses a strict wall-clock timeout (time.time() < 2.0s). Time-based assertions like this tend to be flaky under CI load and when running on slower machines/containers. Using a monotonic clock and a more forgiving threshold keeps the regression guard while reducing spurious failures.
name = "-Users-nobody-" + "-".join(["word"] * 40)
start = time.time()
self.assertEqual(decode_project_dir(name), [])
self.assertLess(time.time() - start, 2.0)
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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=<name>=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 <noreply@anthropic.com>
c49721d to
7d6e737
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (4)
plugin/core/migrate/claude_memory.py:126
records()reads each file withopen(...).read()without closing the file handle. With many memory files this can exhaust file descriptors during a single migration run. Use a context manager when reading each file.
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)
plugin/core/migrate/claude_memory.py:151
describe_selection()also reads files viaopen(...).read()without closing them. Even though this is a dry-run path, it can still leak file descriptors when many memory files exist.
for f in files:
proj_name = os.path.basename(os.path.dirname(os.path.dirname(f)))
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)"
plugin/tests/test_migrate.py:207
- This test shells out to the
gitexecutable. The migration code itself degrades gracefully whengitis unavailable (it catches subprocess failures incore.util.git_out), but this unit test will hard-fail in environments withoutgitinstalled. Consider skipping the test whengitis not present.
def test_absolute_project_probed_directly(self):
from core.migrate.engine import repo_resolver
with tempfile.TemporaryDirectory() as tmp:
repo = os.path.join(tmp, "proj")
os.makedirs(repo)
plugin/tests/test_migrate.py:168
- This timing assertion uses
time.time()to measure duration.time.time()is not monotonic and can move backwards/forwards if the system clock changes, which can make this test flaky. Usetime.monotonic()for elapsed time measurement.
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)
Motivation
Claude Code keeps per-project memories as markdown fact files under
~/.claude/projects/<munged-path>/memory/*.md— siloed to the project they were writtenin. Importing them into Engram makes them recallable everywhere, tagged with the same
repo_namescope the store hook uses.Taken over and rebased onto #7: the original standalone importer
(
core/tools/import_memories.py+/engram:import-memoriescommand) is now a sourceadapter of the migration framework #7 ships — same outcome, but it inherits everything
the framework already provides instead of reimplementing it: per-source checkpoint with
resume, dry-run by default, per-repo batching, conversation mode with real-date context,
and exact
--rollbackvia server run manifests. Stacked on #7(base:
jose/migrate-memories-skill); merge order is #7 → this.What carried over from the original PR
The hard-won pieces are preserved (with tests they didn't have before):
decode_project_dir— the filesystem-pruned decoder for munged project dirnames(both
/and.become-; candidates must re-munge exactly), preferring the decodingthat has a git origin
name/description/type) and the MEMORY.md-skip rule(
<file>@<sha256[:12]>), so an edited fact file re-migrates naturally with no separatestate file and no
--forceflagWhat's new
core/migrate/claude_memory.py— the adapter (--source claude-memory, the default):file mtime as
created_at, decoded absolute path as the record's project. No topic orkind mapping — after the rework on Replace migrate slash command with a harness-independent skill #7 (review feedback from @augustas1), Engram's
extraction classifies every memory itself
engine.repo_resolverprobes absolute-path projects directly — this source knows thereal directory, so no repos-dir guessing
--detectlists registered sources and whether their store exists; themigrate-memoriesskill runs it first and, when several sources are present, asks theuser which to migrate (claude-mem, Claude Code local memories, or both) before touching
anything
core/tools/are removed (commands are Claude-Code-only; the skillis the harness-portable surface — see Replace migrate slash command with a harness-independent skill #7)
Verification
ruffclean): munged-namedecode roundtrip, MEMORY.md skip, type→kind mapping, mtime dates, uid-changes-on-edit,
absolute-path repo resolution against a real git repo, registry entry
--detectreports bothsources; a dry-run of
--source claude-memoryplanned 30 memories across 14 decodedrepos with kinds routed to DeveloperPreferences/DomainAndArchitecture/TaskStatus,
correctly skipping munged names whose paths no longer exist and remoteless directories
(recoverable with
--map); nothing written🤖 Generated with Claude Code