Skip to content

Migrate Claude Code local memories into Engram (claude-memory source) - #4

Open
trengrj wants to merge 4 commits into
jose/migrate-memories-skillfrom
trengrj/claude-import
Open

Migrate Claude Code local memories into Engram (claude-memory source)#4
trengrj wants to merge 4 commits into
jose/migrate-memories-skillfrom
trengrj/claude-import

Conversation

@trengrj

@trengrj trengrj commented Aug 4, 2026

Copy link
Copy Markdown
Member

Motivation

Claude Code keeps per-project memories as markdown fact files under
~/.claude/projects/<munged-path>/memory/*.md — siloed to the project they were written
in. Importing them into Engram makes them recallable everywhere, tagged with the same
repo_name scope the store hook uses.

Taken over and rebased onto #7: the original standalone importer
(core/tools/import_memories.py + /engram:import-memories command) is now a source
adapter
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 --rollback via 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 decoding
    that has a git origin
  • the frontmatter parser (name/description/type) and the MEMORY.md-skip rule
  • content-hash change detection — now folded into checkpoint uids
    (<file>@<sha256[:12]>), so an edited fact file re-migrates naturally with no separate
    state file and no --force flag

What'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 or
    kind 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_resolver probes absolute-path projects directly — this source knows the
    real directory, so no repos-dir guessing
  • --detect lists registered sources and whether their store exists; the
    migrate-memories skill runs it first and, when several sources are present, asks the
    user which to migrate (claude-mem, Claude Code local memories, or both) before touching
    anything
  • The command file and core/tools/ are removed (commands are Claude-Code-only; the skill
    is the harness-portable surface — see Replace migrate slash command with a harness-independent skill #7)

Verification

  • 20 unit tests green on system python and the plugin venv (ruff clean): munged-name
    decode roundtrip, MEMORY.md skip, type→kind mapping, mtime dates, uid-changes-on-edit,
    absolute-path repo resolution against a real git repo, registry entry
  • Live on a real machine (75 memory files, 44 project dirs): --detect reports both
    sources; a dry-run of --source claude-memory planned 30 memories across 14 decoded
    repos 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

@orca-security-eu orca-security-eu Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orca Security Scan Summary

Status Check Issues by priority
Passed Passed Infrastructure as Code high 0   medium 0   low 0   info 0 View in Orca
Passed Passed SAST high 0   medium 0   low 0   info 0 View in Orca
Passed Passed Secrets high 0   medium 0   low 0   info 0 View in Orca
Passed Passed Vulnerabilities high 0   medium 0   low 0   info 0 View in Orca

@jfrancoa
jfrancoa force-pushed the trengrj/claude-import branch from 5ce9119 to 02e341f Compare August 5, 2026 10:11
@jfrancoa jfrancoa changed the title Add command /engram:import-memories Migrate Claude Code local memories into Engram (claude-memory source) Aug 5, 2026
@jfrancoa
jfrancoa changed the base branch from main to jose/migrate-memories-skill August 5, 2026 10:11
@jfrancoa
jfrancoa requested a lite review from Copilot August 5, 2026 10:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_memory source adapter that reads ~/.claude/projects/*/memory/*.md, decodes munged project directory names, maps frontmatter type to migration kinds, and uses file mtime for timestamps.
  • Extends migration CLI with --detect and 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()
Comment on lines +203 to +206
with tempfile.TemporaryDirectory() as tmp:
repo = os.path.join(tmp, "proj")
os.makedirs(repo)
subprocess.run(["git", "init", "-q", repo], check=True)

@orca-security-eu orca-security-eu Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orca Security Scan Summary

Status Check Issues by priority
Passed Passed Secrets high 0   medium 0   low 0   info 0 View in Orca

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

trengrj and others added 4 commits August 10, 2026 12:12
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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with open(...).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 via open(...).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 git executable. The migration code itself degrades gracefully when git is unavailable (it catches subprocess failures in core.util.git_out), but this unit test will hard-fail in environments without git installed. Consider skipping the test when git is 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. Use time.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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants