Skip to content

Replace migrate slash command with a harness-independent skill - #7

Open
jfrancoa wants to merge 7 commits into
mainfrom
jose/migrate-memories-skill
Open

Replace migrate slash command with a harness-independent skill#7
jfrancoa wants to merge 7 commits into
mainfrom
jose/migrate-memories-skill

Conversation

@jfrancoa

@jfrancoa jfrancoa commented Aug 5, 2026

Copy link
Copy Markdown

Motivation

Anyone switching to Engram from another local memory system arrives with an existing store
of accumulated memories and no way to bring it along. This PR adds a one-shot importer
with claude-mem as the first supported source, exposed as a skill rather than a slash
command: commands are a Claude-Code-only component type, while the Codex plugin manifest
(as shipped by dual-host plugins today) carries skills, mcpServers, and hooks — so a
skill keeps the guided flow portable to a future Codex release. Bumps the plugin to 0.2.0.

Supersedes #6, which is the same migration feature with a slash command instead of the
skill — kept open as the fallback variant; only one of the two should merge.

What's inside

Migration engine (plugin/core/migrate/) — source-adapter architecture: an adapter
yields plain records (content, original timestamp, project hint); the engine groups them
into chronological conversations (one per repo and day) and submits them through Engram's
extraction pipeline with the original dates as context. Engram classifies each memory
into the group's topics itself — the migration never picks a topic, so it works with any
topic setup
(per review feedback from @augustas1). The group schema is fetched only to
learn the configured scope properties: repo_name is resolved via git-remote probing
(--map overrides) and attached per batch only when the group configures it; with no
repo_name property nothing can be mis-filed, so unresolvable projects migrate too.
Dry-run by default; --execute writes; --rollback deletes exactly what the migration
created via the server's per-run commit manifests. A checkpoint
(~/.engram/migrate/<source>.json) makes every run idempotent and resumable. A new source
is one adapter module plus a registry entry.

Skill (plugin/skills/migrate-memories/) — the guided flow (dry-run stripped of
--execute/--rollback → present report → fresh confirmation → execute → summarize),
invoked through a self-locating scripts/migrate.sh that resolves the plugin root from
its own path — no CLAUDE_PLUGIN_ROOT, no host env — and delegates to bin/engram-migrate.
Works unchanged wherever skills load. Eval prompts kept in evals/ for future iteration.

Supporting refactorcore/__init__.py re-exports are lazy (PEP 562) and the SDK
import moved inside get_client(), so dry-run and tests run without the plugin venv; both
hooks regression-tested.

Key areas for review

  • engine.py execute/reconcile_pending/rollback — checkpoint discipline; rollback
    refuses still-running manifests, records non-404 delete failures, and detects identity
    changes via a user_id stamp before it will reset the checkpoint
  • __main__.py _batch_properties/_schema — the schema serves property setup only (no topic validation); required-scope handling fails closed
    under --execute; session_id auto-filled with a migration marker; --property repo_name rejected
  • claude_mem.py — read-only guarantee at the sqlite level (mode=ro, percent-encoded
    URI path so ?/#/% can't smuggle params)
  • core/__init__.py — the lazy re-export refactor sits under the live hooks' import path

Testing

  • 14 unit tests (synthetic claude-mem DB + fake client): curation/composition, read-only
    enforcement (including URI-reserved path chars), batching/skip, chronological ordering
    and undated-record exclusion, checkpoint roundtrip, reconciliation, execute flow,
    conversation input building, manifest-based rollback. Green on system python and the
    plugin venv; ruff clean.
  • Full production runs of both paths against a real store (~10k observations, 1.2k
    summaries, 33 projects): pre-extracted (2,531 items, 61 runs, 0 failures) → manifest
    rollback (1,564 memories deleted exactly, organic memories untouched) → conversation
    re-import (2,830 records as 156 chronological conversations, 0 failures, resumed across
    three interruptions with no duplicates). Idempotency confirmed: re-runs send nothing.
  • Skill exercised with live agent evals: dry-run-only with no writes absent confirmation,
    and the confirmation gate held under "just run it with --execute" pressure.

🤖 Generated with Claude Code

jfrancoa and others added 4 commits August 5, 2026 07:24
One-shot migration of an existing local memory store into Engram, built
around a small source-adapter contract so new systems are one module +
one registry entry. First source: claude-mem (SQLite, read-only).

- Two ingestion paths: pre-extracted (verbatim, [date]-prefixed, explicit
  topic mapping validated against the live group schema) and conversation
  (extraction pipeline with created_at date context, submitted strictly
  earliest-to-latest; a slow run aborts resumably instead of skipping
  ahead to preserve chronology)
- Checkpoint in ~/.engram/migrate/<source>.json makes every run
  idempotent and resumable; --rollback deletes exactly what the migration
  created via the server's per-run commit manifests
- Dry-run by default; --execute writes. Repo scoping via git-remote
  probing with --map overrides; unmappable projects are skipped, never
  mis-filed. Group-required scope properties are checked up front
  (session_id auto-filled with a migration marker)
- Ships as bin/engram-migrate (self-locating, plugin bin/ is on PATH)
  and the /engram:migrate command; stdlib-only unit tests included
- core/__init__.py re-exports are now lazy and the SDK import moved
  inside get_client(), so dry-run and tests work without the venv

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review findings (F1-F14) against the migrate feature, fixed:

- rollback: refuse manifests of still-running runs, record non-404 delete
  failures instead of crashing, require a stable identity, detect an
  identity change via a user_id stamp in the checkpoint, and keep the
  checkpoint when every delete reports already-gone (possible mismatch)
- conversation chronology: abort on a failed batch (not just a timed-out
  one), refuse to submit while earlier runs are still in flight, and cap
  one conversation at MAX_CONVERSATION_MESSAGES
- validation: --execute refuses to run without the group schema instead
  of failing open per batch; --property repo_name is rejected; --topic-map
  and --batch-size error in conversation mode instead of being ignored
- robustness: submit failures are recorded per batch instead of killing
  the CLI; _wait and reconcile surface the real error and release runs
  the server 404s; corrupt checkpoints exit cleanly with the file named
- CLI args: --limit/--batch-size must be positive, --limit counts fresh
  (post-checkpoint) records, --repos-dir is expanduser'd, empty KEY=VALUE
  halves are rejected; exit code 3 marks an incomplete (pending) run
- adapter: NULL observation types no longer crash the report; non-array
  facts JSON is kept verbatim instead of mangled
- report: per-project counts, honest checkpoint label, mode-agnostic
  sample line, no --map advice for unmappable "(none)" records
- docs: /engram:migrate strips --execute/--rollback from the dry-run
  step; README clarifies where engram-migrate is on PATH; stale
  docstrings and the launcher's env-precedence comment corrected

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Percent-encode the sqlite URI path so ?/#/% in --db can't smuggle
  URI params past mode=ro
- Conversation mode excludes records without a usable created_at and
  reports the count (they can't be placed chronologically; pre-extracted
  mode carries them), instead of silently importing them first

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slash commands are a Claude-Code-only component: the Codex plugin
manifest (as shipped by dual-host plugins today) carries skills, MCP
servers, and hooks — no commands. Shipping the guided migration flow as
a command would block a future Codex release of this plugin.

The flow now lives in skills/migrate-memories/: same five steps
(dry-run stripped of --execute/--rollback, present report, fresh
confirmation, execute, summarize), invoked through a self-locating
scripts/migrate.sh that resolves the plugin root from its own path —
no CLAUDE_PLUGIN_ROOT or any host env — and delegates to the verified
bin/engram-migrate launcher. Works unchanged wherever skills load.

Skill authored with skill-creator; behavioral checks (dry-run-first and
the confirmation gate under "just run it with --execute" pressure) pass,
and the skill run used ~40% fewer tokens than exploration-based
baselines. Caveat: baselines could see the skill files in the tree, so
the comparison understates the no-skill gap. Eval prompts kept in
skills/migrate-memories/evals/ for future iteration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jfrancoa
jfrancoa requested a lite review from Copilot August 5, 2026 09:53

@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

@jfrancoa
jfrancoa changed the base branch from jose/migrate-from-claude-mem to main August 5, 2026 09:55

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

This PR replaces the previous Claude-Code-only /engram:migrate guided migration slash command with a harness-independent migrate-memories skill, so the migration flow can work across hosts that support skills (e.g., future Codex dual-host manifests).

Changes:

  • Added a new migrate-memories skill that documents a safe, confirmation-gated migration flow (dry-run → review → confirm → execute → summarize).
  • Added a self-locating scripts/migrate.sh wrapper that resolves the plugin root from its own path and delegates to plugin/bin/engram-migrate without relying on host env vars.
  • Updated README migration instructions and removed the old plugin/commands/migrate.md slash-command implementation; added eval prompts for future iteration.

Reviewed changes

Copilot reviewed 12 out of 14 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
README.md Updates user-facing migration guidance to point to the new skill and CLI usage.
plugin/skills/migrate-memories/SKILL.md Introduces the skill definition and step-by-step migration flow (dry-run + explicit confirmation gates).
plugin/skills/migrate-memories/scripts/migrate.sh Adds a harness-independent entrypoint that locates and executes the migration CLI.
plugin/skills/migrate-memories/evals/evals.json Adds behavioral eval prompts/assertions for the skill-driven migration flow.
plugin/commands/migrate.md Removes the old /engram:migrate slash command guidance/implementation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread README.md
- Drop the stale /engram:migrate reference from the CLI module docstring;
  point at the migrate-memories skill wrapper instead

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jfrancoa
jfrancoa requested a lite review from Copilot August 5, 2026 10:02

@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

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 12 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (3)

plugin/core/migrate/main.py:52

  • Same as above: sys.exit(<message>) exits with status 1 here; for malformed NAME=VALUE pairs this should be treated as a usage error (exit 2) and written to stderr.
        # an empty half silently doing nothing (e.g. --map proj= falling through to the
        # git probe) is worse than an error
        if not k.strip() or not v.strip():
            sys.exit(f"--{flag} {p!r}: name and value must be non-empty")
        out[k.strip()] = v.strip()

plugin/core/migrate/engine.py:373

  • In rollback(), iterating ops.created assumes both committed_operations and its created list are always present and non-null. If the API/SDK returns committed_operations.created = null (or omits it) for runs with no creates, this will raise and prevent rollback from completing.
        ops = rs.committed_operations
        ids = [op.memory_id for op in (ops.created if ops else [])]

plugin/core/migrate/main.py:47

  • The module docstring says exit code 2 is for usage errors, but _parse_kv uses sys.exit(<message>), which exits with status 1. Prefer emitting the message to stderr and exiting with code 2 for invalid --map/--topic-map/--property values.

This issue also appears on line 48 of the same file.

        if "=" not in p:
            sys.exit(f"--{flag} expects NAME=VALUE, got {p!r}")
        k, v = p.split("=", 1)

Eval 1 tolerated executing on the prompt's blanket consent, which
SKILL.md step 3 forbids — a future iteration graded against it could
pass behavior the skill prohibits. The expected output and assertions
now encode the fresh-in-conversation-confirmation contract.

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 12 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (2)

plugin/core/migrate/engine.py:373

  • rollback() assumes client.runs.get() always returns an object with a committed_operations attribute and that it always has a created iterable. If the SDK omits committed_operations for some terminal run states (or created is None), this will raise AttributeError/TypeError and abort rollback, leaving the checkpoint and partially deleted state.
        ops = rs.committed_operations
        ids = [op.memory_id for op in (ops.created if ops else [])]

plugin/skills/migrate-memories/SKILL.md:46

  • This option suggests running --execute directly, but the flow above explicitly requires always doing a dry-run first and only appending --execute after a fresh in-conversation confirmation. As written, this conflicts with the skill’s safety gate.
- `--limit N --execute` — a small smoke run before committing to a full migration.

Comment thread plugin/core/migrate/claude_mem.py Outdated
# claude-mem observation `type` → our kind. Everything describing the codebase and its
# decisions maps to `architecture`; work items map to `task`. A type missing here (from a
# newer claude-mem) is skipped and reported by describe_selection rather than mis-filed.
KIND_BY_TYPE = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since topics are Engram's strength, we might want to make it more flexible and closer to the plugin behaviour.
Plugin checks configured topics of the live project for the only reason that there might be scope properties (which would be required at input).

I am thinking maybe it's better to let Engram extract and classify memories (instead of using pre-extracted mode here) so this migrations works regardless of topic setup (e.g. currently it fails if project has topics with no properties). We might still need to fetch topics from project just to see what properties are configured, just to figure out property setup. The plugin does it this way:

  1. Fetches project's group to see what properties are configured on topics
  2. Pass required properties (default repo_name, session_id if configured) when adding memories
    OR pass no properties if none are configured.
  3. Plugins also let's configure custom properties (other than repo_name, session_id) but maybe we don't need to build this for the migration skill.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Re-implemented in 54e9ddf exactly along these lines: conversation input is now the only ingestion path, so Engram's extraction classifies every memory itself — the whole kind→topic mapping (and --topic-map, --input, the pre-extracted planner) is gone. The group schema is fetched only to learn the configured scope properties: repo_name is attached per batch only when the group configures it, and when it doesn't, unresolvable projects migrate too since nothing can be mis-filed. session_id keeps its migration marker when configured; custom --property stays since it was already built and small.

Review feedback (augustas1 on #7): topic classification is Engram's
strength, and a client-side kind→topic mapping breaks on any group
whose topics differ from the author's (TaskStatus is not in the default
group). The group schema also only matters for scope properties, not
topics.

- Conversation input is now the only ingestion path: memories go
  through the extraction pipeline with their original dates as context
  and Engram routes each one into the group's topics itself
- Removed: Record.kind, the KINDS vocabulary, KIND_TO_TOPIC, the
  pre-extracted planner, --input, --topic-map, --batch-size, and the
  [date] content prefix
- The schema fetch now serves property setup only: repo_name is
  attached per batch only when the group configures it; when the group
  has no repo_name property nothing can be mis-filed, so unresolvable
  projects migrate too instead of being skipped
- session_id auto-fill and --property behavior unchanged

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 12 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (1)

plugin/core/client.py:92

  • get_client() imports the Engram SDK unconditionally; when with-venv.sh falls back to system python (e.g., first run before deps are installed), this will raise ImportError and crash the hook/CLI instead of failing open as intended. Consider importing only after confirming an API key is present and catching ImportError to return None.
def get_client():
    # SDK import stays local: everything else in this module (key/identity resolution, the
    # REST helper) is stdlib-only and must keep working where the SDK isn't installed.
    from engram import EngramClient

Comment on lines +16 to +21
# Migrated by default. discovery/change/refactor are transient per-session observations —
# the bulk of a store (>80% here) with low recall value — so they ride behind --all instead
# of polluting recall out of the box. A type outside ALL_TYPES (from a newer claude-mem) is
# skipped and reported by describe_selection rather than migrated blind.
CURATED_TYPES = ("decision", "bugfix", "feature", "security_alert", "security_note")
ALL_TYPES = CURATED_TYPES + ("discovery", "change", "refactor")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I don't think we should be hardcoding what is included or not in the plugin. The user can configure their engram topics to decide what is/isn't relevant, so we should obey that, try inserting everything, and let extraction decide what to keep and how to categorise it IMO.

cp["pending"][run.run_id] = uids
save_checkpoint(cp_path, cp)

state, err = _wait(client, run.run_id, wait_timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We don't generally advise waiting on every pipeline run, just add and if it doesn't immediately error (because e.g. you missed a required property etc) move onto the next input. Engram will handle queuing internally. If you want to track status later, you can use the run_id which the client.memories.add call returns.

from ..util import git_repo


def repo_resolver(repos_dirs, overrides):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Could this resolution logic (and elsewhere, e.g. _batch_properties in __main__) be shared with the main plugin core? We'd want the migration script to be following the same conventions as the realtime memory adds, and this migration script should follow the same configuration files.

Comment thread plugin/core/__init__.py
Comment on lines +8 to +10
# Lazy re-exports (PEP 562): importing the package must not drag in the Engram SDK, so that
# SDK-free entry points (core.migrate dry-run, unit tests) work outside the plugin venv.
# `from core import X` in the hooks resolves through __getattr__ at their import time.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

As the SDK is required to actually run the .adds, I think we can safely assume it for the dry run too 🙂

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.

4 participants