Replace migrate slash command with a harness-independent skill - #7
Replace migrate slash command with a harness-independent skill#7jfrancoa wants to merge 7 commits into
Conversation
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>
There was a problem hiding this comment.
Orca Security Scan Summary
| Status | Check | Issues by priority | |
|---|---|---|---|
| Secrets | View in Orca |
There was a problem hiding this comment.
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-memoriesskill that documents a safe, confirmation-gated migration flow (dry-run → review → confirm → execute → summarize). - Added a self-locating
scripts/migrate.shwrapper that resolves the plugin root from its own path and delegates toplugin/bin/engram-migratewithout relying on host env vars. - Updated README migration instructions and removed the old
plugin/commands/migrate.mdslash-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.
- 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>
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 |
There was a problem hiding this comment.
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.createdassumes bothcommitted_operationsand itscreatedlist are always present and non-null. If the API/SDK returnscommitted_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_kvusessys.exit(<message>), which exits with status 1. Prefer emitting the message to stderr and exiting with code 2 for invalid--map/--topic-map/--propertyvalues.
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>
There was a problem hiding this comment.
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()assumesclient.runs.get()always returns an object with acommitted_operationsattribute and that it always has acreatediterable. If the SDK omitscommitted_operationsfor some terminal run states (orcreatedisNone), this will raiseAttributeError/TypeErrorand 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
--executedirectly, but the flow above explicitly requires always doing a dry-run first and only appending--executeafter 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.
| # 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 = { |
There was a problem hiding this comment.
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:
- Fetches project's group to see what properties are configured on topics
- Pass required properties (default
repo_name,session_idif configured) when adding memories
OR pass no properties if none are configured. - 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.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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; whenwith-venv.shfalls back to system python (e.g., first run before deps are installed), this will raiseImportErrorand crash the hook/CLI instead of failing open as intended. Consider importing only after confirming an API key is present and catchingImportErrorto returnNone.
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
| # 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") |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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.
| # 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. |
There was a problem hiding this comment.
As the SDK is required to actually run the .adds, I think we can safely assume it for the dry run too 🙂
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, andhooks— so askill 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 adapteryields 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_nameis resolved via git-remote probing(
--mapoverrides) and attached per batch only when the group configures it; with norepo_nameproperty nothing can be mis-filed, so unresolvable projects migrate too.Dry-run by default;
--executewrites;--rollbackdeletes exactly what the migrationcreated via the server's per-run commit manifests. A checkpoint
(
~/.engram/migrate/<source>.json) makes every run idempotent and resumable. A new sourceis 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.shthat resolves the plugin root fromits own path — no
CLAUDE_PLUGIN_ROOT, no host env — and delegates tobin/engram-migrate.Works unchanged wherever skills load. Eval prompts kept in
evals/for future iteration.Supporting refactor —
core/__init__.pyre-exports are lazy (PEP 562) and the SDKimport moved inside
get_client(), so dry-run and tests run without the plugin venv; bothhooks regression-tested.
Key areas for review
engine.pyexecute/reconcile_pending/rollback— checkpoint discipline; rollbackrefuses still-running manifests, records non-404 delete failures, and detects identity
changes via a
user_idstamp before it will reset the checkpoint__main__.py_batch_properties/_schema— the schema serves property setup only (no topic validation); required-scope handling fails closedunder
--execute;session_idauto-filled with a migration marker;--property repo_namerejectedclaude_mem.py— read-only guarantee at the sqlite level (mode=ro, percent-encodedURI path so
?/#/%can't smuggle params)core/__init__.py— the lazy re-export refactor sits under the live hooks' import pathTesting
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;
ruffclean.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.
and the confirmation gate held under "just run it with --execute" pressure.
🤖 Generated with Claude Code