Replace migrate slash command with a harness-independent skill - #7
Replace migrate slash command with a harness-independent skill#7jfrancoa wants to merge 10 commits into
Conversation
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.
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)
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
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>
- 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>
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>
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>
54e9ddf to
8fb33c6
Compare
- Submit without waiting on each pipeline run: Engram queues internally, so conversations are submitted back-to-back in chronological order and only an immediate add error stops the loop. A settle pass then polls the checkpointed run ids to record what committed; leftovers reconcile on the next invocation (exit 3) - Share scope resolution with the plugin core: per-project properties now come from core.scope.resolve_scope on the project's directory — the same function, configuration files, and source cascades the store hook uses, including the repo_name git-repo → cwd fallback. The migration-only resolver and _batch_properties are gone; --map and --property remain as overrides; projects whose required properties can't be resolved are still skipped and reported - Assume the SDK everywhere: revert the lazy re-exports in core/__init__.py and the lazy imports in client.py and the engine (core/__init__.py and client.py now match main exactly); tests run under the plugin venv Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (4)
plugin/core/migrate/engine.py:84
- Records with rec.project == None are always treated as unresolvable and skipped because props_for() is never called in that case (props_by_project is set to None). _props_builder(props_for) explicitly supports a missing project by returning only trustworthy required properties (e.g., session_id marker) + explicit overrides, so these records can migrate safely when the group doesn’t require repo_name.
project = rec.project or "(none)"
if project not in props_by_project:
props_by_project[project] = props_for(rec.project) if rec.project else None
props = props_by_project[project]
if props is None:
mapping[project] = None
skipped[project] = skipped.get(project, 0) + 1
continue
plugin/core/migrate/engine.py:25
- engine.py imports the Engram SDK (ConversationInput/MessageInput) at module import time. That makes even dry-run/report-only code paths fail in environments without the plugin venv, contradicting the stated goal that dry-run/tests can run SDK-free. Consider lazy-importing the SDK only when building/submitting ConversationInput (e.g., inside _build_input/execute) so planning/reporting works without the SDK installed.
from engram import ConversationInput, MessageInput
plugin/core/migrate/engine.py:126
- The skipped-projects note implies --map is the remedy for required-scope resolution failures, but projects can also be skipped due to other missing required properties (which are instead addressable via --property). This message can mislead users into trying only --map when they need to supply other required properties.
lines.append(
" skipped — the group's required scope properties could not be resolved "
"(--map=NAME=owner/repo supplies repo_name):"
)
plugin/.claude-plugin/plugin.json:5
- PR description says the plugin is bumped to 0.2.0, but the manifest version is set to 1.1.0 (from 1.0.0). Please align the PR description and the shipped manifest version so downstream tooling (e.g., client_origin_header tests and plugin distribution) reflects the intended release.
"description": "Persistent cross-session memory backed by Weaviate Engram. Stores each conversation turn and recalls relevant memories before Claude answers.",
"version": "1.1.0",
"author": { "name": "Weaviate" },
Review feedback (danmichaeljones): the plugin should not hardcode what is relevant — insert everything and let Engram's extraction decide what to keep. The type list was a leftover from the pre-extracted design, where whatever was sent got stored verbatim; with extraction as the filter there is nothing left for the adapter to curate, and unknown or NULL types are just text to the extractor. - claude-mem adapter reads every observation row (no type WHERE clause); describe_selection reports counts per type, nothing excluded - --all removed; adapter contract simplifies to records() / describe_selection() with no include_all parameter Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (6)
plugin/tests/test_migrate.py:54
- This comment is now inaccurate: the adapter/test intentionally includes unknown/new observation types (see test_selection_and_composition expecting obs:5). Update the comment to reflect that type is not used as a filter.
# unknown type → never yielded
plugin/core/migrate/engine.py:80
- Records with an empty/None project are always skipped because props_for() is never called (line 79 sets props to None). This contradicts the intended behavior where migrations should still proceed when required scope properties can be satisfied without a project directory (e.g., groups without repo_name, or only requiring session_id). Call props_for() even when rec.project is falsy and let it decide whether required properties are satisfiable.
project = rec.project or "(none)"
if project not in props_by_project:
props_by_project[project] = props_for(rec.project) if rec.project else None
props = props_by_project[project]
plugin/tests/test_migrate.py:51
- This comment is now inaccurate: the adapter/test intentionally includes "discovery" observations (see test_selection_and_composition expecting obs:4). Update the comment so it doesn't imply these rows are excluded.
This issue also appears on line 54 of the same file.
# excluded by default
plugin/skills/migrate-memories/SKILL.md:31
- This flow text says the report is "per repo and day", but the current implementation groups by (day, source project) (plugin/core/migrate/engine.py:77-99). Adjust the wording so the skill guidance matches what the CLI actually reports.
2. **Present the report**: how many memories per repo and day; which source projects were
skipped because no git remote was found (offer `--map NAME=owner/repo` for ones worth
plugin/.claude-plugin/plugin.json:5
- PR description mentions bumping the plugin to 0.2.0, but this manifest changes the version from 1.0.0 to 1.1.0. Please align the PR description and the actual versioning scheme (either update the version here or adjust the stated version bump).
"description": "Persistent cross-session memory backed by Weaviate Engram. Stores each conversation turn and recalls relevant memories before Claude answers.",
"version": "1.1.0",
"author": { "name": "Weaviate" },
README.md:131
- The migration engine groups records by (day, source project) (plugin/core/migrate/engine.py:77-99), not strictly by resolved repo_name. This README sentence says "per repo and day", which can be misleading if multiple source project names map to the same repo_name (via --map or source-side merges). Update wording to match the implementation.
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
Review round: F1, F3, F10, F12, F13, plus making directory discovery work for any workspace layout instead of assuming projects are siblings of the current directory. - claude_projects.py: ~/.claude/projects/ holds one munged name per directory the user ever ran a session in; decoding them reconstructs the user's real layout. index_by_basename() maps basenames (what claude-mem records) to those directories, newest session first. The hardened munged-name decoder now lives here - project_dir_finder is layered: explicit --repos-dir dirs, then the registry index (a candidate with a git remote wins, then the most recent session), then cwd and its parent as a last resort for stores copied from another machine. Live result on a real store: skipped projects dropped from 17 to the handful whose directories no longer exist anywhere - F1: props_for wraps resolve_scope — one project's broken .engram.json or an uncached offline dry-run skips that project with a note instead of aborting the whole migration with a traceback (both crashes were reproduced first) - F3: the all-gone rollback branch resets the checkpoint when its recorded identity matched; only unstamped legacy checkpoints keep it, and the message now names the file to delete - F10: runs reconciled at startup are counted in the final summary - F12: skip reason and hint corrected (missing required properties, not "no git remote"; --property mentioned), --repos-dir documented, --project marked repeatable, exit-code docstring made truthful, describe_selection labeled as row counts, report says "scopes" - F13: stale fixture comments fixed; tests added for the discovery layers, _props_builder (error-skip, map/extra merge, dir-less marker), and the settle timeout path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (1)
plugin/core/migrate/claude_mem.py:121
describe_selection()says rows with no usable content are dropped "at planning", but the adapter actually drops those rows while iterating the source DB (in_observations()/_summaries()itcontinues whencontentis empty). This is a small but concrete mismatch in the dry-run report text.
return [
f"observation rows: {by_type} — rows with no usable content are dropped "
"at planning",
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 carries
skills,mcpServers, andhooks— a skill keeps the guidedflow portable. Bumps the plugin to 1.1.0.
Supersedes #6 (same feature with a slash command; only one of the two should merge).
Shaped by review: Engram classifies topics itself (@augustas1), no per-run waiting and
shared scope resolution (@danmichaeljones), no source-side curation (@danmichaeljones).
How it works
plugin/core/migrate/) stream plain records — uid, content, originaltimestamp, project hint. Every observation row migrates; Engram's extraction decides
what each memory becomes and what to keep. A new source is one adapter module plus a
registry entry.
project and day) submitted through the extraction pipeline with their original dates
as context. The migration never names a topic, so any topic setup works.
core.scope.resolve_scopeon the project's directory — the same function, configfiles, and source cascades realtime adds use. The group schema is fetched only to
learn which properties are required; a project whose required properties can't be
resolved is skipped and reported (
--map/--propertyrecover it). A resolution errorin one project (broken
.engram.json, offline schema fetch) skips that projectinstead of aborting the run.
Claude Code's own session registry (
~/.claude/projects/), whose munged entriesdecode to every directory the user ever worked in — no workspace convention assumed.
Explicit
--repos-dirdirs win; cwd and its parent are a last resort for storescopied from another machine.
order (Engram queues internally); a settle pass polls the checkpointed run ids to
report what committed. Exit codes: 0 done, 1 failures, 3 still in the pipeline.
missing;
--rollbackdeletes via the server's per-run commit manifests, so organicmemories are untouchable, and refuses to reset state it cannot verify.
plugin/skills/migrate-memories/): dry-run stripped of--execute/--rollback→ report → fresh confirmation → execute → summary, invokedthrough a self-locating
scripts/migrate.shwith no host env dependencies.Key areas for review
engine.pyexecute/settle/reconcile_pending— checkpoint discipline aroundno-wait submission; failed runs release their items for resubmission
__main__.py_props_builder— hook-parity resolution, per-project error handling,--map/--propertyprecedenceclaude_projects.py— the munged-name decoder (listing-pruned, iterative) and theregistry index
engine.pyrollback— manifest-driven deletion; still-running runs and non-404delete failures keep the checkpoint
Testing
composition, sqlite read-only enforcement including URI-reserved path characters,
chronological planning, skip/undated/skip_uids handling, discovery layers, props
building (error-skip, map/extra merge), checkpoint roundtrip and corruption handling,
reconcile, submit-without-wait, submit-error stop, settle including the timeout path,
manifest rollback.
ruffclean.discovery resolved every project whose directory still exists (skips dropped from 17
projects to 5), dry-run offline and with a corrupted per-project
.engram.jsondegrade to per-project skips (both previously crashed — reproduced first), and a full
production migration ran earlier in the PR's history with zero failures.
🤖 Generated with Claude Code