diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index faf3f6e5b..ccaa4dcea 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./integrations/claude-code-plugin",
"description": "Cross-session memory and token savings for coding agents.",
- "version": "0.4.0"
+ "version": "0.3.2"
}
]
}
diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json
index 78bd1056e..84f31855b 100644
--- a/.cursor-plugin/marketplace.json
+++ b/.cursor-plugin/marketplace.json
@@ -12,7 +12,7 @@
"name": "mem0",
"source": "./integrations/cursor-plugin",
"description": "Cross-session memory and token savings for coding agents.",
- "version": "0.4.0"
+ "version": "0.3.2"
}
]
}
diff --git a/.kimi-plugin/marketplace.json b/.kimi-plugin/marketplace.json
index e4ae8be2c..0b8cbcac0 100644
--- a/.kimi-plugin/marketplace.json
+++ b/.kimi-plugin/marketplace.json
@@ -5,7 +5,7 @@
{
"id": "mem0",
"displayName": "Mem0",
- "version": "0.4.0",
+ "version": "0.3.2",
"description": "Cross-session memory and token savings for coding agents.",
"homepage": "https://mem0.ai",
"keywords": ["memory", "personalization", "mcp", "semantic-search"],
diff --git a/docs/docs.json b/docs/docs.json
index babc358f2..c1c4887d2 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -370,7 +370,6 @@
"icon": "terminal",
"pages": [
"integrations/claude-code",
- "integrations/session-handoff",
"integrations/claude-ai",
"integrations/cursor",
"integrations/codex",
diff --git a/docs/integrations/antigravity.mdx b/docs/integrations/antigravity.mdx
index fed068109..453767c18 100644
--- a/docs/integrations/antigravity.mdx
+++ b/docs/integrations/antigravity.mdx
@@ -5,7 +5,7 @@ description: "Add persistent memory to Google Antigravity with the Mem0 plugin:
Add persistent memory to [**Google Antigravity**](https://antigravity.google) (`agy` CLI and Desktop IDE) with the Mem0 plugin. The plugin captures completed work, and Antigravity can search those memories in later sessions.
-Current plugin version: `0.4.0`.
+Current plugin version: `0.3.2`.
## Prerequisites
@@ -112,10 +112,6 @@ The local `search_memories` tool accepts `query`, `top_k`, `category`, `scope` (
Captured prompts and responses retain their full redacted text without a per-message character cutoff. Large extraction inputs are split across requests without dropping message text; recall output and tool-result previews have separate limits.
-## Session handoff
-
-The plugin can hand off supported native session context into a new Codex task through the shared importer. Handoff requires Python 3.11+ and a locally installed, signed-in Codex CLI. See [session handoff](/integrations/session-handoff) for commands, transferred content, and recovery.
-
## Troubleshooting
- **No tools appearing**: Restart your Antigravity session after installation
diff --git a/docs/integrations/claude-code.mdx b/docs/integrations/claude-code.mdx
index 165274b63..7bfc1e37e 100644
--- a/docs/integrations/claude-code.mdx
+++ b/docs/integrations/claude-code.mdx
@@ -5,7 +5,7 @@ description: "Persistent cross-session memory for Claude Code. Install once, mem
Claude Code forgets everything between sessions. This plugin fixes that. Install it, work normally, and Claude remembers what happened across sessions.
-Current plugin version: `0.4.0`.
+Current plugin version: `0.3.2`.
## Prerequisites
@@ -160,10 +160,6 @@ claude plugin marketplace update mem0-plugins
claude plugin update mem0@mem0-plugins --scope user
```
-## Session handoff
-
-Run `/mem0:handoff codex` to create a Codex task from the current Claude session, preserving its title, project, and active conversation. Handoff requires Python 3.11+ and a locally installed, signed-in Codex CLI. See [session handoff](/integrations/session-handoff) for commands, transferred content, and recovery.
-
## Troubleshooting
| Problem | Fix |
diff --git a/docs/integrations/codex.mdx b/docs/integrations/codex.mdx
index d311b1b8d..eec5ba6ad 100644
--- a/docs/integrations/codex.mdx
+++ b/docs/integrations/codex.mdx
@@ -5,7 +5,7 @@ description: "Add persistent memory to OpenAI Codex with automatic capture, auto
Add persistent memory to [**OpenAI Codex**](https://openai.com/index/codex/) with the Mem0 plugin. Codex forgets everything between tasks. This plugin fixes that by connecting to Mem0's cloud memory layer via MCP, automatically capturing learnings at key lifecycle points, and retrieving relevant context on the first prompt of a session. Codex can use the search tool for recall later in the session.
-Current plugin version: `0.4.0`.
+Current plugin version: `0.3.2`.
## Prerequisites
@@ -176,10 +176,6 @@ The local `search_memories` tool accepts `query`, `top_k`, `category`, `scope` (
Captured prompts and responses retain their full redacted text without a per-message character cutoff. Large extraction inputs are split across requests without dropping message text; recall output and tool-result previews have separate limits.
-## Session handoff
-
-The plugin can hand off supported native session context into a new Codex task through the shared importer. Handoff requires Python 3.11+ and a locally installed, signed-in Codex CLI. See [session handoff](/integrations/session-handoff) for commands, transferred content, and recovery.
-
## Troubleshooting
- **"Connection failed"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`
diff --git a/docs/integrations/cursor.mdx b/docs/integrations/cursor.mdx
index 8ea20bb2b..02d6317f1 100644
--- a/docs/integrations/cursor.mdx
+++ b/docs/integrations/cursor.mdx
@@ -5,7 +5,7 @@ description: "Add persistent memory to Cursor with automatic capture, explicit r
Add persistent memory to [**Cursor**](https://cursor.com) with the Mem0 plugin. Cursor captures completed work in the background, and its agent can search relevant project context in later sessions. You can also connect only the hosted MCP server when you do not need lifecycle capture.
-Current plugin version: `0.4.0`.
+Current plugin version: `0.3.2`.
## Prerequisites
@@ -161,10 +161,6 @@ The local `search_memories` tool accepts `query`, `top_k`, `category`, `scope` (
Captured prompts and responses retain their full redacted text without a per-message character cutoff. Large extraction inputs are split across requests without dropping message text; recall output and tool-result previews have separate limits.
-## Session handoff
-
-The plugin can hand off supported native session context into a new Codex task through the shared importer. Handoff requires Python 3.11+ and a locally installed, signed-in Codex CLI. See [session handoff](/integrations/session-handoff) for commands, transferred content, and recovery.
-
## Troubleshooting
- **"Connection failed"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`
diff --git a/docs/integrations/deepseek-plugin.mdx b/docs/integrations/deepseek-plugin.mdx
index 014a6e76c..bba14a3f5 100644
--- a/docs/integrations/deepseek-plugin.mdx
+++ b/docs/integrations/deepseek-plugin.mdx
@@ -5,7 +5,7 @@ description: "Add persistent memory to DeepSeek Harness with automatic recall, a
Add persistent memory to the [**DeepSeek Harness**](https://github.com/deepseek-ai/deepseek-harness) with `@mem0/deepseek-plugin`. The plugin recalls relevant context before a model request, captures completed turns, and provides explicit Mem0 tools when the agent needs them.
-Current package version: `0.4.0`.
+Current package version: `0.3.2`.
## Overview
@@ -69,7 +69,7 @@ source ~/.bashrc
2. Install it into a disposable Harness profile so Harness supplies its peer dependencies:
```sh
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
- plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.4.0.tgz
+ plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.2.tgz
```
3. Copy `cordis.example.yml`, set its installed package path and your `userId`, then load it with the same profile:
@@ -119,10 +119,6 @@ Writes are tagged `source="DEEPSEEK_HARNESS"` so Mem0 can attribute usage to thi
This plugin is a developer preview and tracks the evolving DeepSeek Harness plugin API.
-## Session handoff
-
-The plugin can hand off supported native session context into a new Codex task through the shared importer. Handoff requires Python 3.11+ and a locally installed, signed-in Codex CLI. See [session handoff](/integrations/session-handoff) for commands, transferred content, and recovery.
-
## Troubleshooting
- **`MISSING_CREDENTIAL` for `deepseek-official`**: Configure `DEEPSEEK_API_KEY` through Harness's Models page or export it in the shell that launches Harness.
diff --git a/docs/integrations/kimi.mdx b/docs/integrations/kimi.mdx
index db761d4a0..2d8151edc 100644
--- a/docs/integrations/kimi.mdx
+++ b/docs/integrations/kimi.mdx
@@ -5,7 +5,7 @@ description: "Add persistent project memory to Kimi Code with automatic capture,
Kimi Code forgets project decisions between sessions. The Mem0 plugin captures completed work, recalls relevant context before a response, and gives Kimi explicit memory tools and skills.
-Current plugin version: `0.4.0`.
+Current plugin version: `0.3.2`.
## Prerequisites
@@ -93,10 +93,6 @@ The local `search_memories` tool accepts `query`, `top_k`, `category`, `scope` (
Captured prompts and responses retain their full redacted text without a per-message character cutoff. Large extraction inputs are split across requests without dropping message text; recall output and tool-result previews have separate limits.
-## Session handoff
-
-The plugin can hand off supported native session context into a new Codex task through the shared importer. Handoff requires Python 3.11+ and a locally installed, signed-in Codex CLI. See [session handoff](/integrations/session-handoff) for commands, transferred content, and recovery.
-
## Troubleshooting
| Problem | Fix |
diff --git a/docs/integrations/openclaw.mdx b/docs/integrations/openclaw.mdx
index 2658b456c..d72b3d9b5 100644
--- a/docs/integrations/openclaw.mdx
+++ b/docs/integrations/openclaw.mdx
@@ -5,7 +5,7 @@ description: "Add long-term memory to OpenClaw agents using the Mem0 plugin with
Add long-term memory to [OpenClaw](https://github.com/openclaw/openclaw) agents with the `@mem0/openclaw-mem0` plugin. Your agent forgets everything between sessions. This plugin fixes that by automatically watching conversations, extracting what matters, and bringing it back when relevant.
-Current package version: `1.2.0`.
+Current package version: `0.3.2`.
## Overview
@@ -387,10 +387,6 @@ openclaw plugins list
openclaw plugins inspect openclaw-mem0
```
-## Session handoff
-
-The plugin can hand off supported native session context into a new Codex task through the shared importer. Handoff requires Python 3.11+ and a locally installed, signed-in Codex CLI. See [session handoff](/integrations/session-handoff) for commands, transferred content, and recovery.
-
## Troubleshooting
### "plugins.allow excludes mem0" Error
diff --git a/docs/integrations/opencode.mdx b/docs/integrations/opencode.mdx
index bd41a74aa..62db7eb09 100644
--- a/docs/integrations/opencode.mdx
+++ b/docs/integrations/opencode.mdx
@@ -5,7 +5,7 @@ description: "Add persistent memory to OpenCode with the Mem0 plugin: native SDK
Add persistent memory to [**OpenCode**](https://opencode.ai) with the Mem0 plugin. Your agent forgets everything between sessions. Mem0 fixes that by storing decisions, preferences, and learnings so they carry over automatically.
-Current package version: `0.4.0`.
+Current package version: `0.3.2`.
## Prerequisites
@@ -122,10 +122,6 @@ The plugin uses the [mem0ai](https://www.npmjs.com/package/mem0ai) TypeScript SD
Automatic capture sends the selected user prompt with its full redacted text, without a per-message character cutoff. It does not capture every prompt or the full assistant transcript. These automatic writes use user and project scope, with the session ID in `metadata.session_id`; session-scoped tools use top-level `run_id`. A session-scoped search therefore does not include automatic writes that lack `run_id`.
-## Session handoff
-
-The plugin can hand off supported native session context into a new Codex task through the shared importer. Handoff requires Python 3.11+ and a locally installed, signed-in Codex CLI. See [session handoff](/integrations/session-handoff) for commands, transferred content, and recovery.
-
## Troubleshooting
- **No tools appearing**: Restart OpenCode after installing
diff --git a/docs/integrations/pi-agent.mdx b/docs/integrations/pi-agent.mdx
index c29660a36..91e5bc1be 100644
--- a/docs/integrations/pi-agent.mdx
+++ b/docs/integrations/pi-agent.mdx
@@ -5,7 +5,7 @@ description: "Add persistent memory to Pi Agent with the Mem0 plugin, semantic s
Add persistent memory to [**Pi Agent**](https://pi.dev) with `@mem0/pi-agent-plugin`. Your agent forgets everything between sessions. This plugin fixes that by automatically capturing knowledge from conversations, storing it in Mem0's cloud memory layer, and retrieving relevant context before every response.
-Current package version: `0.4.0`.
+Current package version: `0.3.2`.
## Overview
@@ -144,10 +144,6 @@ You: What do you know about my preferences?
# Pi retrieves stored memories, no re-explaining needed
```
-## Session handoff
-
-The plugin can hand off supported native session context into a new Codex task through the shared importer. Handoff requires Python 3.11+ and a locally installed, signed-in Codex CLI. See [session handoff](/integrations/session-handoff) for commands, transferred content, and recovery.
-
## Troubleshooting
- **"No API key found"**: Verify `MEM0_API_KEY` is set: `echo $MEM0_API_KEY`. If empty, add it to your shell profile (see Prerequisites)
diff --git a/docs/integrations/session-handoff.mdx b/docs/integrations/session-handoff.mdx
deleted file mode 100644
index 4e745a895..000000000
--- a/docs/integrations/session-handoff.mdx
+++ /dev/null
@@ -1,83 +0,0 @@
----
-title: Session handoff
-description: "Continue a coding-agent session in Codex using Mem0's shared handoff runtime."
-icon: arrow-right-arrow-left
----
-
-Session handoff creates a normal Codex task from a coding agent's saved conversation. It preserves the title, project, and supported active context, including readable compaction summaries and completed tool history. It runs only when you request it.
-
-All ten Mem0 plugin packages use the same handoff engine. Host adapters read native session state and pass it to a common converter, validator, and Codex importer. The transfer does not reconstruct the session from memories or ask a model to write a new handoff summary.
-
-## Requirements
-
-- Python 3.11 or newer available as `python3`.
-- Codex CLI installed, signed in, and available as `codex` on the same machine. Its app-server must support native external-session import and thread compaction. Open Codex once so its configuration and model metadata exist.
-- The source conversation and its project directory available locally.
-- Pi handoff requires Node.js 22.19 or newer, matching the native Pi SDK requirement.
-
-The handoff runtime does not call Mem0 or use its API key. Memory features and host installation may still require their normal configuration. Handoff is separate from automatic memory capture.
-
-## Commands
-
-| Plugin | Entry point | Source |
-| --- | --- | --- |
-| Claude Code | `/mem0:handoff codex` | Current Claude session, read before the command's model invocation |
-| OpenCode | `/mem0-handoff codex` | Current session through the native session API |
-| Pi Agent | `/mem0-handoff codex` | Current session's active branch and native compaction context |
-| OpenClaw | `/mem0-handoff codex` | Current session's native transcript |
-| DeepSeek Harness | Explicitly request `mem0_handoff` | Current session's native derived messages |
-| Cursor, Codex, Kimi, Antigravity | Invoke the `handoff` skill with a completed native transcript path | The explicitly selected native conversation |
-| Portable Agent Plugins | Invoke the `handoff` skill with a source host and completed transcript, or a handoff bundle | Any supported source format |
-
-The result includes the new task ID, title, and project. Open Codex and select that task. Handoff does not submit a new prompt or continue the task automatically.
-
-Native tools exclude their own handoff invocation when its ID is available. Other unfinished calls still cause an error. For skills that run through a shell tool, use a completed transcript or exported bundle: reading that host's live transcript from its own tool loop can include the still-running shell call. Do not retry repeatedly or remove unrelated unfinished calls to force an import.
-
-## Source formats and limits
-
-- **Claude Code:** local JSONL, active parent chain, readable compaction summary, supported attachments and images. Session IDs are resolved under `~/.claude/projects/`.
-- **Codex:** readable rollout response items and replacement compaction history. Opaque compaction state cannot be transferred and causes an error.
-- **Cursor:** JSONL records with user/assistant roles and structured `message.content` (text, tool calls, and results). Missing tool results or unsupported records cause an error.
-- **Kimi:** native v2 wire events replayed into the active readable context.
-- **Antigravity:** completed `USER_INPUT` and `PLANNER_RESPONSE` text steps. Other trajectory step types require a supported context export and cause an error.
-- **Pi and OpenClaw:** active message branch and readable compaction context. Pi uses the native session manager's selected leaf, including branch navigation.
-- **OpenCode and DeepSeek:** the host's active session context, normalized by the shared TypeScript adapter.
-
-Source formats evolve with their hosts. A format the plugin cannot represent safely fails explicitly; the importer does not silently shorten the conversation or fall back to a memory search. The portable bundle also accepts the common handoff format for hosts that can export their own context.
-
-## What transfers
-
-- User and assistant text, completed tool calls and results, and supported images.
-- The source's readable compaction summary and supported active history after it.
-- The session title and project. Git projects open at their repository root; an explicit directory override selects that directory.
-
-Hidden reasoning and host-only control records do not transfer. Tool calls and results become visible tagged text because Codex's external importer cannot recreate every host's native tool events. Images are saved as private local assets under the selected Codex home.
-
-If the imported state exceeds Codex's automatic-compaction limit, Codex compacts it before reporting success. That step invokes Codex's configured model. Smaller imports do not ask a model to summarize the conversation. Transcripts are transferred without the memory pipeline's secret redaction, so review the source before handing it off; the imported context is available to Codex and its model when compacting or continuing.
-
-## Recovery and manual use
-
-Incomplete JSONL, unmatched tool calls or results, unsupported model-visible records, and missing project directories cause a failure instead of silent data loss. For context too large for Codex to compact, compact in the source host and retry if it provides a readable summary.
-
-If task creation fails after conversion, a private reusable bundle is saved under `~/.mem0/handoffs/`. The error reports its path. Check Codex for an already-created task before retrying an import that failed during naming or compaction.
-
-Native Python and portable plugins ship `core/session_handoff.py`. TypeScript packages ship `dist/session_handoff.py`. From the installed plugin directory, use the relevant path:
-
-```bash
-# Preview a native transcript without creating a task.
-python3 core/session_handoff.py --source codex --session /absolute/path/to/rollout.jsonl
-
-# Create a task from an explicit completed native transcript.
-python3 core/session_handoff.py --source kimi --session /absolute/path/to/wire.jsonl --create --command-output
-
-# Retry a saved bundle.
-python3 core/session_handoff.py --bundle /absolute/path/to/handoff.json --create --command-output
-```
-
-Use `--title "Session title"` when the transcript does not include a title, and `--cwd /existing/project` when it does not include a directory (such as Cursor and Antigravity exports). DeepSeek handoff must run as a direct tool call; a nested code-mode call still has an unfinished enclosing program.
-
-The importer also accepts `--export /path/to/bundle.json`, `--cwd /existing/project`, `--codex-home /path/to/codex-home`, and `--codex-bin /path/to/codex`. Exported bundles contain the transferred conversation; keep them private. The original `claude_to_codex.py` entry point remains available for explicit Claude imports.
-
-Codex currently requires a temporary import file under `~/.claude/projects/`, even for normalized context from another host. The plugin removes that staging file after the import attempt. `--claude-projects-dir` changes Claude source lookup, not this native staging location.
-
-The destination is Codex. Importing into other hosts, round trips between existing sessions, and background synchronization are not implemented.
diff --git a/docs/llms.txt b/docs/llms.txt
index f9cdbc08a..62f0fa51d 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -267,7 +267,6 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
- [Strands Agents](https://docs.mem0.ai/integrations/strands) [Both]: Use when the user is on AWS Strands and wants a native MemoryStore.
### AI Coding Tools
-- [Session handoff](https://docs.mem0.ai/integrations/session-handoff) [Both]: Use when transferring native coding-agent sessions into Codex through the shared Mem0 handoff runtime, or recovering a failed import.
- [Claude Code](https://docs.mem0.ai/integrations/claude-code) [Platform]: Use when wiring memory into Claude Code.
- [Claude.ai](https://docs.mem0.ai/integrations/claude-ai) [Platform]: Use when connecting Mem0 to Claude.ai (the hosted web app) via a custom remote MCP connector, or when Claude's native memory seems to be crowding out mem0 tool calls.
- [Cursor](https://docs.mem0.ai/integrations/cursor) [Platform]: Use when adding lifecycle capture, explicit memory recall, six skills, and a sidekick to Cursor.
@@ -419,7 +418,7 @@ Each subdirectory is a Claude Code Skill (`SKILL.md` + supporting assets). Load
Source: https://github.com/mem0ai/mem0/tree/main/integrations/claude-code-plugin
-The self-contained Claude Code plugin lives in `integrations/claude-code-plugin/` (v0.4.0, installs as `mem0@mem0-plugins`). It captures evidence locally through lifecycle hooks, extracts memories in a detached background worker, and exposes a single local MCP tool, `search_memories`, plus six memory skills, the explicit `/mem0:handoff` command and the unchanged `mem0:sidekick` agent. Pure-stdlib Python, nothing to install.
+The self-contained Claude Code plugin lives in `integrations/claude-code-plugin/` (v0.3.2, installs as `mem0@mem0-plugins`). It captures evidence locally through lifecycle hooks, extracts memories in a detached background worker, and exposes a single local MCP tool, `search_memories`, plus six memory skills, the explicit `/mem0:handoff` command and the unchanged `mem0:sidekick` agent. Pure-stdlib Python, nothing to install.
### Coding-Agent Plugin Sources
diff --git a/integrations/agent-plugin-core/CHANGELOG.md b/integrations/agent-plugin-core/CHANGELOG.md
new file mode 100644
index 000000000..e23d0d7b1
--- /dev/null
+++ b/integrations/agent-plugin-core/CHANGELOG.md
@@ -0,0 +1,10 @@
+# Changelog
+
+## 0.3.2
+
+- Add explicit session handoff to a new Codex task. Host readers supply native active context to one common converter, validator, importer, and recovery implementation; memory extraction is not a transcript source.
+- Installable plugins share one pinned runtime cache through a small launcher. First use downloads the exact GitHub commit and verifies SHA-256 digests; subsequent uses verify and reuse the local cache. No transcript is uploaded to GitHub.
+- Preserve the session title, project, readable compaction context, supported images, and completed tool calls/results. Hidden reasoning and harness configuration are excluded. Unsupported records, opaque compaction, and unfinished responses fail explicitly.
+- Requires Python 3.11+ and a signed-in local Codex CLI supporting external-session import. Pi additionally requires Node.js 22.19+ for its native SDK. Other source formats may require an explicit completed transcript, directory, and title.
+- Handoff does not call Mem0. Large imports may invoke Codex’s native model compaction; smaller imports do not generate a summary. Failed creation saves a private recovery bundle under `~/.mem0/handoffs/`.
+- Replace strict before-answer and repeated-search instructions with focused optional retrieval. Existing context can answer the question without another search. Search scoping, retrieval limits, and capture scheduling are unchanged.
diff --git a/integrations/agent-plugin-core/README.md b/integrations/agent-plugin-core/README.md
index 059258ebe..c6271b893 100644
--- a/integrations/agent-plugin-core/README.md
+++ b/integrations/agent-plugin-core/README.md
@@ -53,15 +53,17 @@ For installation, follow the host guides: [Claude Code](../../docs/integrations/
## Session handoff
-`python/session_handoff.py` is the common entry point. `python/handoff_sources.py` reads native transcript formats; `python/claude_to_codex.py` retains the Claude parser and the single implementation of validation, private recovery, assets, context limits, and Codex import. It is adapted from [mem0ai/memo](https://github.com/mem0ai/memo/blob/aeeb1593284d1d2fca3b4bcf1e32ea10f71df549/docs/session-handoff.md).
+`python/session_handoff.py` is the small common launcher. `python/handoff_sources.py` reads native transcript formats; `python/claude_to_codex.py` retains the Claude parser and the single implementation of validation, private recovery, assets, context limits, and Codex import. It is adapted from [mem0ai/memo](https://github.com/mem0ai/memo/blob/aeeb1593284d1d2fca3b4bcf1e32ea10f71df549/docs/session-handoff.md).
The TypeScript hosts read their native active context and use `typescript/src/handoff.ts` to normalize messages and pass a `mem0.session-handoff.v1` bundle to the same Python engine over stdin. OpenClaw supplies its trusted native transcript path. Native Python and portable plugins generate the shared `handoff` skill, with source-specific instructions.
The common bundle contains `source` (`host`, `session_id`, `title`, `cwd`, optional `path`), `items` (user/assistant messages, paired function calls/results, and supported images), and optional `warnings`. The engine derives counts and validates the bundle. Unknown model-visible content, missing tool results, and opaque compaction state fail rather than disappearing from the imported task. Handoff runs only on explicit user request, independently of memory hooks and the Mem0 API. Creation requires Python 3.11+ and a local Codex CLI with native session import support.
-`build/handoff-runtime.json` declares the runtime files once. Every TypeScript build uses `build/package_handoff.mjs` to package them. Conformance checks reject missing or stale copies, as well as generated Python bundle drift. When the importer changes, regenerate the Python bundles and rebuild the TypeScript packages; no per-plugin importer edits are needed. A new host needs only a native context reader or adapter producing the common bundle.
+The importer source exists only here. Installable packages contain the small launcher and `build/handoff-runtime.json`, which pins a Git commit and SHA-256 digests. On the first explicit handoff, the launcher downloads those exact two source files from GitHub into `~/.mem0/handoff-runtime/`. All ten plugins reuse that cache. Every use verifies the files; a valid cache works offline. A missing or invalid cache requires GitHub access, and download or digest failures stop the handoff. No transcript is sent to GitHub.
-See the [handoff guide](../../docs/integrations/session-handoff.mdx) for host commands, verified formats, current-session limits, and recovery.
+Every TypeScript build uses `build/package_handoff.mjs`; the Python builder uses the same manifest. Builds reject a pin whose digests differ from the canonical source, and conformance checks reject missing or stale launchers/manifests. To update the importer, commit its shared source, update the manifest to that immutable commit and its file digests, then regenerate the Python bundles and rebuild the TypeScript packages. No per-plugin importer edits or duplicated engine files are needed.
+
+See the [0.3.2 changelog](CHANGELOG.md#032) and each plugin’s changelog for invocation details.
## Build and verify
diff --git a/integrations/agent-plugin-core/build/build.py b/integrations/agent-plugin-core/build/build.py
index 59bf209a3..23edf5adc 100644
--- a/integrations/agent-plugin-core/build/build.py
+++ b/integrations/agent-plugin-core/build/build.py
@@ -2,6 +2,7 @@
from __future__ import annotations
import argparse
+import hashlib
import json
import re
import shutil
@@ -18,6 +19,7 @@ CORE_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = CORE_ROOT.parents[1]
INTEGRATIONS_ROOT = REPOSITORY_ROOT / "integrations"
SHARED_SKILLS = CORE_ROOT / "skills"
+HANDOFF_MANIFEST = CORE_ROOT / "build" / "handoff-runtime.json"
PORTABLE_PLUGIN = "mem0-agent-plugin"
NATIVE_PLUGINS = {
"claude-code": INTEGRATIONS_ROOT / "claude-code-plugin",
@@ -116,7 +118,14 @@ def _bundle_python(
) -> None:
core = staged / "core"
core.mkdir()
+ handoff = json.loads(HANDOFF_MANIFEST.read_text(encoding="utf-8"))
+ for name, digest in handoff["files"].items():
+ if hashlib.sha256((CORE_ROOT / "python" / name).read_bytes()).hexdigest() != digest:
+ raise ValueError(f"Update the shared handoff runtime pin after changing {name}")
+ shutil.copy2(HANDOFF_MANIFEST, core / HANDOFF_MANIFEST.name)
for source in sorted((CORE_ROOT / "python").glob("*.py")):
+ if source.name in handoff["files"]:
+ continue
if portable and source.name in {"flush_worker.py", "hook_runner.py"}:
continue
shutil.copy2(source, core / source.name)
diff --git a/integrations/agent-plugin-core/build/handoff-runtime.json b/integrations/agent-plugin-core/build/handoff-runtime.json
index b0b8732b2..b6b935e8d 100644
--- a/integrations/agent-plugin-core/build/handoff-runtime.json
+++ b/integrations/agent-plugin-core/build/handoff-runtime.json
@@ -1,5 +1,8 @@
-[
- "claude_to_codex.py",
- "session_handoff.py",
- "handoff_sources.py"
-]
+{
+ "revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
+ "files": {
+ "claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
+ "handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
+ },
+ "artifacts": ["session_handoff.py", "handoff-runtime.json"]
+}
diff --git a/integrations/agent-plugin-core/build/package_handoff.mjs b/integrations/agent-plugin-core/build/package_handoff.mjs
index 620a1fbc8..92c540bdf 100644
--- a/integrations/agent-plugin-core/build/package_handoff.mjs
+++ b/integrations/agent-plugin-core/build/package_handoff.mjs
@@ -1,12 +1,19 @@
-import { copyFile, mkdir, readFile } from "node:fs/promises";
+import { createHash } from "node:crypto";
+import { copyFile, mkdir, readFile, rm } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
export async function packageHandoff(output = "dist") {
- const files = JSON.parse(await readFile(new URL("./handoff-runtime.json", import.meta.url), "utf8"));
+ const manifest = JSON.parse(await readFile(new URL("./handoff-runtime.json", import.meta.url), "utf8"));
await mkdir(output, { recursive: true });
- for (const name of files) {
- await copyFile(new URL(`../python/${name}`, import.meta.url), resolve(output, name));
+ for (const [name, digest] of Object.entries(manifest.files)) {
+ const bytes = await readFile(new URL(`../python/${name}`, import.meta.url));
+ if (createHash("sha256").update(bytes).digest("hex") !== digest) throw new Error(`Update the shared handoff runtime pin after changing ${name}`);
+ }
+ for (const name of Object.keys(manifest.files)) await rm(resolve(output, name), { force: true });
+ for (const name of manifest.artifacts) {
+ const source = name.endsWith(".py") ? `../python/${name}` : `./${name}`;
+ await copyFile(new URL(source, import.meta.url), resolve(output, name));
}
}
diff --git a/integrations/agent-plugin-core/conformance/artifacts.py b/integrations/agent-plugin-core/conformance/artifacts.py
index 6a0ffb43a..f4eb47720 100644
--- a/integrations/agent-plugin-core/conformance/artifacts.py
+++ b/integrations/agent-plugin-core/conformance/artifacts.py
@@ -21,7 +21,8 @@ TYPESCRIPT_ARTIFACTS = {
),
"deepseek": (INTEGRATIONS_ROOT / "deepseek-plugin", ("dist/index.js", "dist/index.d.ts")),
}
-HANDOFF_RUNTIME_FILES = json.loads((CORE_ROOT / "build" / "handoff-runtime.json").read_text(encoding="utf-8"))
+HANDOFF_MANIFEST = json.loads((CORE_ROOT / "build" / "handoff-runtime.json").read_text(encoding="utf-8"))
+HANDOFF_RUNTIME_FILES = HANDOFF_MANIFEST["artifacts"]
TYPESCRIPT_ARTIFACTS = {
host: (package, (*required, *(f"dist/{name}" for name in HANDOFF_RUNTIME_FILES)))
for host, (package, required) in TYPESCRIPT_ARTIFACTS.items()
@@ -36,9 +37,10 @@ def verify_artifact(group: str, package: Path, required: tuple[str, ...]) -> dic
errors = [f"missing package artifact: {name}" for name in required if not (package / name).is_file()]
dist = package / "dist"
if group in TYPESCRIPT_ARTIFACTS:
+ errors.extend(f"duplicated handoff engine in package: dist/{name}" for name in HANDOFF_MANIFEST["files"] if (dist / name).exists())
for name in HANDOFF_RUNTIME_FILES:
artifact = dist / name
- if artifact.is_file() and artifact.read_bytes() != (CORE_ROOT / "python" / name).read_bytes():
+ if artifact.is_file() and artifact.read_bytes() != (CORE_ROOT / ("python" if name.endswith(".py") else "build") / name).read_bytes():
errors.append(f"handoff runtime differs from shared source: dist/{name}")
for pattern in ("*.js", "*.mjs", "*.cjs", "*.d.ts"):
for artifact in dist.rglob(pattern) if dist.is_dir() else ():
diff --git a/integrations/agent-plugin-core/python/memory_core.py b/integrations/agent-plugin-core/python/memory_core.py
index 1e99f99e7..93a3fde15 100644
--- a/integrations/agent-plugin-core/python/memory_core.py
+++ b/integrations/agent-plugin-core/python/memory_core.py
@@ -29,7 +29,7 @@ from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
-PLUGIN_VERSION = "0.4.0"
+PLUGIN_VERSION = "0.3.2"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
diff --git a/integrations/agent-plugin-core/python/session_handoff.py b/integrations/agent-plugin-core/python/session_handoff.py
index 4aada69e6..b7108f336 100644
--- a/integrations/agent-plugin-core/python/session_handoff.py
+++ b/integrations/agent-plugin-core/python/session_handoff.py
@@ -1,7 +1,81 @@
#!/usr/bin/env python3
-"""Import an explicit native session or neutral handoff bundle into Codex."""
+"""Run the shared handoff engine locally or from its verified immutable cache."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import sys
+import tempfile
+from pathlib import Path
+from urllib.request import urlopen
+
+ENGINE_FILES = {"claude_to_codex.py", "handoff_sources.py"}
+SOURCE_URL = "https://raw.githubusercontent.com/mem0ai/mem0"
+
+
+def _verified(path: Path, digest: str) -> bool:
+ try:
+ return hashlib.sha256(path.read_bytes()).hexdigest() == digest
+ except FileNotFoundError:
+ return False
+
+
+def runtime_root(launcher_dir: Path | None = None) -> Path:
+ here = launcher_dir or Path(__file__).resolve().parent
+ if all((here / name).is_file() for name in ENGINE_FILES):
+ return here # The canonical development checkout already has both engines.
+ manifest = json.loads((here / "handoff-runtime.json").read_text(encoding="utf-8"))
+ if not isinstance(manifest, dict):
+ raise ValueError("invalid handoff runtime manifest")
+ revision, files = manifest.get("revision"), manifest.get("files")
+ if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
+ raise ValueError("handoff runtime revision must be an immutable commit SHA")
+ if (
+ not isinstance(files, dict)
+ or set(files) != ENGINE_FILES
+ or any(not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) for digest in files.values())
+ ):
+ raise ValueError("invalid handoff runtime file hashes")
+ cache = Path.home() / ".mem0" / "handoff-runtime" / revision
+ missing = {name: digest for name, digest in files.items() if not _verified(cache / name, digest)}
+ if not missing:
+ return cache
+ cache.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with tempfile.TemporaryDirectory(prefix=f".{revision}-", dir=cache.parent) as temporary:
+ staged = Path(temporary)
+ for name, digest in missing.items():
+ url = f"{SOURCE_URL}/{revision}/integrations/agent-plugin-core/python/{name}"
+ try:
+ with urlopen(url, timeout=30) as response:
+ body = response.read()
+ except OSError as exc:
+ raise OSError(f"could not download pinned handoff runtime {name}: {exc}") from exc
+ if hashlib.sha256(body).hexdigest() != digest:
+ raise ValueError(f"SHA256 mismatch for pinned handoff runtime {name}; refusing to execute it")
+ (staged / name).write_bytes(body)
+ # All downloads are verified before publishing; each replacement is atomic.
+ cache.mkdir(exist_ok=True, mode=0o700)
+ for name in missing:
+ os.replace(staged / name, cache / name)
+ if not all(_verified(cache / name, digest) for name, digest in files.items()):
+ raise ValueError("handoff runtime cache changed during installation; refusing to execute it")
+ return cache
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ root = runtime_root()
+ except (OSError, ValueError) as exc:
+ print(f"handoff runtime unavailable: {exc}", file=sys.stderr)
+ return 1
+ sys.path.insert(0, str(root))
+ from claude_to_codex import main as engine_main
+
+ return engine_main(argv, default_source=None)
-from claude_to_codex import main
if __name__ == "__main__":
- raise SystemExit(main(default_source=None))
+ raise SystemExit(main())
diff --git a/integrations/agent-plugin-core/skills/handoff/SKILL.md.tmpl b/integrations/agent-plugin-core/skills/handoff/SKILL.md.tmpl
index 78d87d576..a16628399 100644
--- a/integrations/agent-plugin-core/skills/handoff/SKILL.md.tmpl
+++ b/integrations/agent-plugin-core/skills/handoff/SKILL.md.tmpl
@@ -9,7 +9,9 @@ allowed-tools: Bash(python3 {{PLUGIN_ROOT}}/core/session_handoff.py *)
All hosts share one local import engine. Native readers and SDK adapters supply
complete conversation items; Mem0 memory capture is not a transcript source.
-Requires Python 3.11+ and a Codex CLI with native session import support. The
+Requires Python 3.11+ and a Codex CLI with native session import support.
+First use downloads a pinned, hash-verified runtime from GitHub; later uses share
+the verified local cache. No transcript is sent to GitHub. The
supported destination is Codex. This does not transfer files or change branches.
Visible conversation, tool history, and supported source compaction summaries
diff --git a/integrations/agent-plugin-core/tests/test_build.py b/integrations/agent-plugin-core/tests/test_build.py
index 8e07e0772..801b58df8 100644
--- a/integrations/agent-plugin-core/tests/test_build.py
+++ b/integrations/agent-plugin-core/tests/test_build.py
@@ -127,7 +127,9 @@ def test_handoff_is_bundled_with_host_appropriate_invocation(host: str, tmp_path
root = build(host, kind, tmp_path / host)
skill = (root / "skills" / "handoff" / "SKILL.md").read_text()
assert (root / "core" / "session_handoff.py").is_file()
- assert (root / "core" / "handoff_sources.py").is_file()
+ assert (root / "core" / "handoff-runtime.json").is_file()
+ assert not (root / "core" / "handoff_sources.py").exists()
+ assert not (root / "core" / "claude_to_codex.py").exists()
assert "Only run on an explicit user request" in skill
assert "--target codex --create --command-output" in skill
if host == "claude-code":
diff --git a/integrations/agent-plugin-core/tests/test_conformance.py b/integrations/agent-plugin-core/tests/test_conformance.py
index 8d81ce4eb..b0e6fdf64 100644
--- a/integrations/agent-plugin-core/tests/test_conformance.py
+++ b/integrations/agent-plugin-core/tests/test_conformance.py
@@ -115,9 +115,12 @@ def test_handoff_packaging_rejects_missing_and_drifted_runtime(tmp_path: Path) -
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("", encoding="utf-8")
for name in HANDOFF_RUNTIME_FILES:
- (dist / name).write_bytes((PLUGIN_ROOT / "python" / name).read_bytes())
+ (dist / name).write_bytes((PLUGIN_ROOT / ("python" if name.endswith(".py") else "build") / name).read_bytes())
assert verify_artifact("opencode", tmp_path, required)["status"] == "passed"
+ (dist / "claude_to_codex.py").write_text("# obsolete bundled engine\n", encoding="utf-8")
+ assert "duplicated handoff engine" in verify_artifact("opencode", tmp_path, required)["output"]
+ (dist / "claude_to_codex.py").unlink()
(dist / "session_handoff.py").write_text("# stale importer\n", encoding="utf-8")
assert "differs from shared source" in verify_artifact("opencode", tmp_path, required)["output"]
(dist / "session_handoff.py").unlink()
diff --git a/integrations/agent-plugin-core/tests/test_handoff_runtime.py b/integrations/agent-plugin-core/tests/test_handoff_runtime.py
new file mode 100644
index 000000000..5765357d3
--- /dev/null
+++ b/integrations/agent-plugin-core/tests/test_handoff_runtime.py
@@ -0,0 +1,169 @@
+from __future__ import annotations
+
+import hashlib
+import importlib.util
+import io
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from urllib.error import URLError
+
+import pytest
+
+CORE = Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location("handoff_launcher", CORE / "python" / "session_handoff.py")
+assert SPEC and SPEC.loader
+launcher = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(launcher)
+REVISION = "1" * 40
+FILES = {
+ "claude_to_codex.py": b"def main(argv=None, default_source=None):\n return 0\n",
+ "handoff_sources.py": b"# reader\n",
+}
+
+
+def installation(tmp_path, monkeypatch):
+ plugin = tmp_path / "plugin"
+ plugin.mkdir()
+ manifest = {"revision": REVISION, "files": {name: hashlib.sha256(body).hexdigest() for name, body in FILES.items()}}
+ (plugin / "handoff-runtime.json").write_text(json.dumps(manifest))
+ monkeypatch.setattr(launcher.Path, "home", lambda: tmp_path)
+ requested = []
+
+ def download(url, timeout):
+ requested.append(url)
+ assert timeout == 30
+ return io.BytesIO(FILES[url.rsplit("/", 1)[-1]])
+
+ monkeypatch.setattr(launcher, "urlopen", download)
+ return plugin, tmp_path / ".mem0" / "handoff-runtime" / REVISION, requested
+
+
+def test_standalone_download_is_pinned_then_works_offline(tmp_path, monkeypatch):
+ plugin, cache, requested = installation(tmp_path, monkeypatch)
+ assert launcher.runtime_root(plugin) == cache
+ assert set(requested) == {
+ f"https://raw.githubusercontent.com/mem0ai/mem0/{REVISION}/integrations/agent-plugin-core/python/{name}"
+ for name in FILES
+ }
+ assert {name: (cache / name).read_bytes() for name in FILES} == FILES
+
+ def offline(*args, **kwargs):
+ raise AssertionError("verified cached runtime must not access the network")
+
+ monkeypatch.setattr(launcher, "urlopen", offline)
+ assert launcher.runtime_root(plugin) == cache
+
+
+def test_canonical_checkout_needs_no_manifest_cache_or_network(tmp_path, monkeypatch):
+ for name, body in FILES.items():
+ (tmp_path / name).write_bytes(body)
+ monkeypatch.setattr(launcher, "urlopen", lambda *args, **kwargs: pytest.fail("unexpected network"))
+ assert launcher.runtime_root(tmp_path) == tmp_path
+ assert not (tmp_path / ".mem0").exists()
+
+
+@pytest.mark.parametrize("damage", ["tampered", "missing"])
+def test_every_use_detects_and_repairs_damaged_cache(tmp_path, monkeypatch, damage):
+ plugin, cache, requested = installation(tmp_path, monkeypatch)
+ launcher.runtime_root(plugin)
+ target = cache / "handoff_sources.py"
+ if damage == "tampered":
+ target.write_bytes(b"untrusted code")
+ else:
+ target.unlink()
+ requested.clear()
+ assert launcher.runtime_root(plugin) == cache
+ assert target.read_bytes() == FILES[target.name]
+ assert len(requested) == 1
+ assert requested[0].endswith("/handoff_sources.py")
+
+
+def test_failed_second_download_publishes_no_partial_runtime(tmp_path, monkeypatch):
+ plugin, cache, _ = installation(tmp_path, monkeypatch)
+
+ def download(url, timeout):
+ if url.endswith("handoff_sources.py"):
+ raise URLError("offline")
+ return io.BytesIO(FILES["claude_to_codex.py"])
+
+ monkeypatch.setattr(launcher, "urlopen", download)
+ with pytest.raises(OSError, match="could not download pinned handoff runtime handoff_sources.py"):
+ launcher.runtime_root(plugin)
+ assert not cache.exists()
+ assert list(cache.parent.iterdir()) == []
+
+
+def test_hash_mismatch_never_replaces_existing_cache(tmp_path, monkeypatch):
+ plugin, cache, _ = installation(tmp_path, monkeypatch)
+ launcher.runtime_root(plugin)
+ (cache / "handoff_sources.py").write_bytes(b"tampered")
+ monkeypatch.setattr(launcher, "urlopen", lambda *args, **kwargs: io.BytesIO(b"wrong response"))
+ with pytest.raises(ValueError, match="SHA256 mismatch"):
+ launcher.runtime_root(plugin)
+ assert (cache / "claude_to_codex.py").read_bytes() == FILES["claude_to_codex.py"]
+ assert (cache / "handoff_sources.py").read_bytes() == b"tampered"
+ assert list(cache.parent.iterdir()) == [cache]
+
+
+def test_tampered_cache_cannot_run_offline(tmp_path, monkeypatch, capsys):
+ plugin, cache, _ = installation(tmp_path, monkeypatch)
+ launcher.runtime_root(plugin)
+ (cache / "claude_to_codex.py").write_bytes(b"untrusted code")
+
+ def offline(*args, **kwargs):
+ raise URLError("offline")
+
+ monkeypatch.setattr(launcher, "urlopen", offline)
+ resolve = launcher.runtime_root
+ monkeypatch.setattr(launcher, "runtime_root", lambda: resolve(plugin))
+ assert launcher.main(["--bundle", "-"]) == 1
+ assert "handoff runtime unavailable" in capsys.readouterr().err
+
+
+@pytest.mark.parametrize(
+ "manifest",
+ [
+ {"revision": "main", "files": {}},
+ {"revision": REVISION, "files": {"../../outside.py": "a" * 64}},
+ {"revision": REVISION, "files": {name: "not-a-hash" for name in FILES}},
+ [],
+ ],
+)
+def test_manifest_rejects_unpinned_or_unexpected_files(tmp_path, monkeypatch, manifest):
+ plugin, _, requested = installation(tmp_path, monkeypatch)
+ (plugin / "handoff-runtime.json").write_text(json.dumps(manifest))
+ with pytest.raises(ValueError):
+ launcher.runtime_root(plugin)
+ assert requested == []
+
+
+def test_missing_manifest_returns_actionable_error(tmp_path, monkeypatch, capsys):
+ resolve = launcher.runtime_root
+ monkeypatch.setattr(launcher, "runtime_root", lambda: resolve(tmp_path))
+ assert launcher.main([]) == 1
+ assert "handoff-runtime.json" in capsys.readouterr().err
+
+
+def test_launcher_executes_adjacent_engine_with_generic_cli_contract(tmp_path):
+ shutil.copy2(CORE / "python" / "session_handoff.py", tmp_path / "session_handoff.py")
+ (tmp_path / "handoff_sources.py").write_text("# reader\n")
+ (tmp_path / "claude_to_codex.py").write_text(
+ "import sys\ndef main(argv=None, default_source='unexpected'):\n"
+ " assert default_source is None\n assert sys.argv[1:] == ['--bundle', '-']\n return 7\n"
+ )
+ result = subprocess.run(
+ [sys.executable, str(tmp_path / "session_handoff.py"), "--bundle", "-"], capture_output=True
+ )
+ assert result.returncode == 7, result.stderr.decode()
+
+
+def test_manifest_declares_only_launcher_and_hashes_pinned_engines():
+ manifest = json.loads((CORE / "build" / "handoff-runtime.json").read_text())
+ assert manifest["artifacts"] == ["session_handoff.py", "handoff-runtime.json"]
+ assert manifest["revision"] == "0fb924051fa876b5d6311fe3378794cc92fb6ff8"
+ assert set(manifest["files"]) == launcher.ENGINE_FILES
+ for name, digest in manifest["files"].items():
+ assert digest == hashlib.sha256((CORE / "python" / name).read_bytes()).hexdigest()
diff --git a/integrations/agent-plugin-core/typescript/src/handoff.ts b/integrations/agent-plugin-core/typescript/src/handoff.ts
index 5c82eb70f..ad1547056 100644
--- a/integrations/agent-plugin-core/typescript/src/handoff.ts
+++ b/integrations/agent-plugin-core/typescript/src/handoff.ts
@@ -129,9 +129,3 @@ export function runHandoff(scriptUrl: URL, bundle: HandoffBundle): Promise {
return run(scriptUrl, [`--source=${required(host, "Source host")}`, `--session=${required(session, "Native session path")}`]);
}
-/** Explicit Claude transcript fallback for legacy callers. */
-export async function runClaudeToCodex(scriptUrl: URL, session: string, cwd?: string): Promise {
- const args = [`--session=${required(session, "A Claude session ID or JSONL path")}`];
- if (cwd !== undefined) args.push(`--cwd=${required(cwd, "Existing project directory")}`);
- return run(scriptUrl, args);
-}
diff --git a/integrations/agent-plugin-core/typescript/tests/handoff.test.ts b/integrations/agent-plugin-core/typescript/tests/handoff.test.ts
index 2ec2f3dab..e5afd7fa8 100644
--- a/integrations/agent-plugin-core/typescript/tests/handoff.test.ts
+++ b/integrations/agent-plugin-core/typescript/tests/handoff.test.ts
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { test } from "node:test";
-import { buildHandoffBundle, runClaudeToCodex, runHandoff } from "../src/handoff.ts";
+import { buildHandoffBundle, runNativeSession, runHandoff } from "../src/handoff.ts";
const source = {host: "test", session_id: "native", title: "Native title", cwd: "/tmp"};
test("native context preserves summary, full tools/images, excludes only triggering call, rejects loss", async () => {
@@ -31,15 +31,15 @@ test("native context preserves summary, full tools/images, excludes only trigger
assert.deepEqual((deepseek.items[2].output as any[])[0], {type: "text", text: "[Tool error]"});
});
-test("transport sends native bundle on stdin and legacy arguments literally", async () => {
+test("transport sends native bundle on stdin and native arguments literally", async () => {
const dir = await mkdtemp(join(tmpdir(), "mem0-handoff-"));
const script = join(dir, "importer.py");
try {
await writeFile(script, "import json, sys\nprint(json.dumps(sys.argv[1:]))\n");
const session = "--session with spaces; $(touch should-never-exist)";
- const args = JSON.parse(await runClaudeToCodex(pathToFileURL(script), session, dir));
- assert.deepEqual(args, [`--session=${session}`, `--cwd=${dir}`, "--create", "--command-output", "--target", "codex"]);
- await assert.rejects(runClaudeToCodex(pathToFileURL(script), " "), /session ID/);
+ const args = JSON.parse(await runNativeSession(pathToFileURL(script), "openclaw", session));
+ assert.deepEqual(args, ["--source=openclaw", `--session=${session}`, "--create", "--command-output", "--target", "codex"]);
+ assert.throws(() => runNativeSession(pathToFileURL(script), "openclaw", " "), /session path/);
await writeFile(script, "import json, sys\nprint(json.dumps(json.load(sys.stdin)))\n");
const bundle = await buildHandoffBundle(source, [{role: "user", content: "private session"}]);
assert.deepEqual(JSON.parse(await runHandoff(pathToFileURL(script), bundle)), bundle);
diff --git a/integrations/antigravity-plugin/CHANGELOG.md b/integrations/antigravity-plugin/CHANGELOG.md
new file mode 100644
index 000000000..1132e85c9
--- /dev/null
+++ b/integrations/antigravity-plugin/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+## 0.3.2
+
+- The `handoff` skill accepts supported completed Antigravity text steps and their project directory. Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
+- Lightened search prompts: search when prior work may help; repeat only for a specific gap.
diff --git a/integrations/antigravity-plugin/core/claude_to_codex.py b/integrations/antigravity-plugin/core/claude_to_codex.py
deleted file mode 100644
index fd372e198..000000000
--- a/integrations/antigravity-plugin/core/claude_to_codex.py
+++ /dev/null
@@ -1,1492 +0,0 @@
-#!/usr/bin/env python3
-"""Shared local session-handoff engine and backwards-compatible Claude CLI.
-
-Native readers and SDK adapters supply complete conversation items. This engine
-validates and exports their bundles, then uses Codex's native external-session
-importer to create a task with visible historical turns. The legacy command
-still defaults to reading a Claude Code transcript; session_handoff.py requires
-an explicit source host or a neutral bundle.
-
-No model generates a handoff summary. Large imports may use Codex's native
-compaction before the new task is returned.
-"""
-
-# Adapted from mem0ai/memo at aeeb1593284d1d2fca3b4bcf1e32ea10f71df549 (Apache-2.0).
-from __future__ import annotations
-
-import argparse
-import base64
-import binascii
-import hashlib
-import html
-import json
-import os
-import queue
-import re
-import shutil
-import subprocess
-import sys
-import tempfile
-import threading
-import time
-from dataclasses import asdict, dataclass, replace
-from pathlib import Path
-from typing import Any, Iterable
-
-FORMAT_VERSION = "mem0.session-handoff.v1"
-DEFAULT_CODEX_HOME = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
-DEFAULT_BUNDLE_DIR = Path.home() / ".mem0" / "handoffs"
-IMPORT_COMPLETED_NOTIFICATION = "externalAgentConfig/import/completed"
-IMAGE_EXTENSIONS = {
- "image/gif": "gif",
- "image/jpeg": "jpg",
- "image/png": "png",
- "image/webp": "webp",
-}
-
-
-class HandoffError(RuntimeError):
- """A source session cannot be transferred without losing state."""
-
-
-@dataclass(frozen=True)
-class SourceInfo:
- path: str
- sha256: str
- session_id: str
- title: str
- cwd: str
- leaf_uuid: str
- compact_boundary_uuid: str | None
- first_imported_uuid: str
- last_imported_uuid: str
- codex_cwd: str | None = None
- host: str = "claude-code"
-
-
-@dataclass
-class HandoffPlan:
- source: SourceInfo
- items: list[dict[str, Any]]
- source_records: int
- active_records: int
- imported_records: int
- hidden_reasoning_blocks_skipped: int
- approximate_tokens: int
- warnings: list[str]
-
- def bundle(self) -> dict[str, Any]:
- return {
- "format": FORMAT_VERSION,
- "source": asdict(self.source),
- "items": self.items,
- "counts": {
- "source_records": self.source_records,
- "active_records": self.active_records,
- "imported_records": self.imported_records,
- "responses_items": len(self.items),
- "hidden_reasoning_blocks_skipped": self.hidden_reasoning_blocks_skipped,
- "approximate_tokens": self.approximate_tokens,
- },
- "warnings": self.warnings,
- }
-
-
-@dataclass(frozen=True)
-class CodexContextLimits:
- model: str
- context_window: int
- usable_context_window: int
- auto_compact_token_limit: int
- max_context_window: int
- max_usable_context_window: int
- max_auto_compact_token_limit: int
-
-
-def _stable_jsonl(path: Path) -> tuple[list[dict[str, Any]], str]:
- before = path.stat()
- raw = path.read_bytes()
- after = path.stat()
- if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
- raise HandoffError(f"Source session changed while it was being read: {path}")
- if raw and not raw.endswith(b"\n"):
- raise HandoffError(
- "The final JSONL record is incomplete. Finish or stop the active source response before transferring it."
- )
-
- records: list[dict[str, Any]] = []
- for line_number, line in enumerate(raw.splitlines(), 1):
- if not line.strip():
- continue
- try:
- record = json.loads(line)
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid source JSONL at {path}:{line_number}: {exc}") from exc
- if not isinstance(record, dict):
- raise HandoffError(f"Source JSONL record is not an object at {path}:{line_number}.")
- records.append(record)
- if not records:
- raise HandoffError(f"Source session is empty: {path}")
- return records, hashlib.sha256(raw).hexdigest()
-
-
-def _resolve_session(value: str, projects_dir: Path) -> Path:
- supplied = Path(value).expanduser()
- if supplied.is_file():
- return supplied.resolve()
-
- matches = list(projects_dir.glob(f"*/{value}.jsonl"))
- if not matches:
- raise HandoffError(
- f"No Claude session named {value!r} exists below {projects_dir}. "
- "Pass the session ID or its full JSONL path."
- )
- if len(matches) != 1:
- joined = "\n".join(f" {path}" for path in matches)
- raise HandoffError(f"Session ID {value!r} is ambiguous:\n{joined}")
- return matches[0].resolve()
-
-
-def _active_chain(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
- with_uuid = [
- record for record in records if isinstance(record.get("uuid"), str) and record.get("isSidechain") is not True
- ]
- if not with_uuid:
- raise HandoffError("Claude session has no main-agent conversation records.")
-
- by_uuid = {record["uuid"]: record for record in with_uuid}
- leaf = with_uuid[-1]
- chain: list[dict[str, Any]] = []
- seen: set[str] = set()
- current: dict[str, Any] | None = leaf
- while current is not None:
- uuid = current["uuid"]
- if uuid in seen:
- raise HandoffError(f"Claude session contains a parent cycle at {uuid}.")
- seen.add(uuid)
- chain.append(current)
- parent_uuid = current.get("parentUuid")
- if parent_uuid is None:
- break
- current = by_uuid.get(parent_uuid)
- if current is None:
- raise HandoffError(f"Claude's active branch references missing parent {parent_uuid}.")
- chain.reverse()
- return chain
-
-
-def _after_latest_compaction(
- chain: list[dict[str, Any]],
-) -> tuple[list[dict[str, Any]], str | None]:
- compact_index: int | None = None
- for index, record in enumerate(chain):
- if record.get("type") == "system" and record.get("subtype") == "compact_boundary":
- compact_index = index
- if compact_index is None:
- imported = chain
- compact_uuid = None
- else:
- imported = chain[compact_index + 1 :]
- compact_uuid = chain[compact_index]["uuid"]
- if not imported or imported[0].get("isCompactSummary") is not True:
- raise HandoffError(f"Claude compaction {compact_uuid} has no following compact summary.")
- imported = [record for record in imported if record.get("type") != "system"]
- if not imported:
- raise HandoffError("Claude's active state contains no transferable records.")
- return imported, compact_uuid
-
-
-def _tool_result_ids(record: dict[str, Any]) -> set[str]:
- if record.get("type") != "user":
- return set()
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- return set()
- return {
- str(block["tool_use_id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("tool_use_id")
- }
-
-
-def _tool_call_ids(records: list[dict[str, Any]]) -> set[str]:
- call_ids: set[str] = set()
- for record in records:
- if record.get("type") != "assistant":
- continue
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- continue
- call_ids.update(
- str(block["id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id")
- )
- return call_ids
-
-
-def _merge_parallel_tool_results(
- active_records: list[dict[str, Any]], all_records: list[dict[str, Any]]
-) -> list[dict[str, Any]]:
- """Restore sibling tool results that Claude stores outside the parent chain.
-
- Parallel Claude tool calls form a fork: later calls remain on the parent
- chain, while earlier results can be sibling records. Claude sends all of
- those results back to the model. Insert them together immediately after the
- assistant response that issued the calls.
- """
- results_by_call: dict[str, list[tuple[int, dict[str, Any]]]] = {}
- for source_index, record in enumerate(all_records):
- for call_id in _tool_result_ids(record):
- results_by_call.setdefault(call_id, []).append((source_index, record))
-
- merged: list[dict[str, Any]] = []
- inserted_result_uuids: set[str] = set()
- index = 0
- while index < len(active_records):
- record = active_records[index]
- record_uuid = str(record.get("uuid") or "")
- if record_uuid in inserted_result_uuids:
- index += 1
- continue
- if record.get("type") != "assistant":
- merged.append(record)
- index += 1
- continue
-
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(active_records):
- candidate = active_records[index]
- candidate_id = (candidate.get("message") or {}).get("id")
- if candidate.get("type") != "assistant" or not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- merged.extend(group)
-
- matching_results: list[tuple[int, dict[str, Any]]] = []
- for call_id in _tool_call_ids(group):
- matching_results.extend(results_by_call.get(call_id, []))
- for _, result in sorted(matching_results, key=lambda pair: pair[0]):
- result_uuid = str(result.get("uuid") or "")
- if result_uuid and result_uuid not in inserted_result_uuids:
- merged.append(result)
- inserted_result_uuids.add(result_uuid)
- return merged
-
-
-def _image_payload(source: Any, context: str) -> tuple[str, str]:
- if not isinstance(source, dict) or source.get("type") != "base64":
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = str(source.get("media_type") or "").lower()
- data = source.get("data")
- if media_type not in IMAGE_EXTENSIONS or not isinstance(data, str) or not data:
- raise HandoffError(f"{context} has an unsupported or missing image type.")
- return media_type, data
-
-
-def _data_url_payload(image_url: Any, context: str) -> tuple[str, str]:
- if not isinstance(image_url, str):
- raise HandoffError(f"{context} has no transferable image data.")
- match = re.fullmatch(r"data:([^;,]+);base64,(.+)", image_url, flags=re.DOTALL)
- if not match:
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = match.group(1).lower()
- if media_type not in IMAGE_EXTENSIONS:
- raise HandoffError(f"{context} has unsupported image type {media_type!r}.")
- return media_type, match.group(2)
-
-
-def _save_image(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> Path:
- try:
- payload = base64.b64decode(encoded, validate=True)
- except (binascii.Error, ValueError) as exc:
- raise HandoffError(f"{context} contains invalid base64 image data.") from exc
- if not payload:
- raise HandoffError(f"{context} contains an empty image.")
-
- digest = hashlib.sha256(payload).hexdigest()
- asset_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
- path = asset_dir / f"{digest}.{IMAGE_EXTENSIONS[media_type]}"
- if path.exists():
- if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
- raise HandoffError(f"The existing handoff image is corrupted: {path}")
- return path
-
- descriptor, filename = tempfile.mkstemp(prefix=f".{path.name}.", dir=asset_dir)
- temporary = Path(filename)
- try:
- with os.fdopen(descriptor, "wb") as stream:
- stream.write(payload)
- os.replace(temporary, path)
- except OSError as exc:
- temporary.unlink(missing_ok=True)
- raise HandoffError(f"Could not save the handoff image at {path}: {exc}") from exc
- return path
-
-
-def _image_reference(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> str:
- path = _save_image(media_type, encoded, asset_dir, context)
- return f"[Image saved at {path}]"
-
-
-def _tool_result_text(value: Any, asset_dir: Path, context: str) -> str:
- if value is None:
- return ""
- if isinstance(value, str):
- return value
- if isinstance(value, (int, float, bool)):
- return str(value)
- if isinstance(value, list):
- parts: list[str] = []
- for part in value:
- if isinstance(part, dict) and part.get("type") == "text":
- parts.append(str(part.get("text", "")))
- elif isinstance(part, dict) and part.get("type") == "image":
- media_type, encoded = _image_payload(part.get("source"), context)
- parts.append(_image_reference(media_type, encoded, asset_dir, context))
- else:
- parts.append(json.dumps(part, ensure_ascii=False, separators=(",", ":")))
- return "\n".join(part for part in parts if part)
- if isinstance(value, dict) and value.get("type") == "image":
- media_type, encoded = _image_payload(value.get("source"), context)
- return _image_reference(media_type, encoded, asset_dir, context)
- return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
-
-
-def _message(role: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
- return {"type": "message", "role": role, "content": parts}
-
-
-def _attachment_item(record: dict[str, Any]) -> dict[str, Any] | None:
- attachment = record.get("attachment")
- if not isinstance(attachment, dict):
- raise HandoffError(f"Claude attachment {record.get('uuid')} has no payload.")
-
- attachment_type = attachment.get("type")
- filename = str(attachment.get("filename") or attachment.get("displayPath") or "unknown")
- content = attachment.get("content")
- if attachment_type == "file" and isinstance(content, dict):
- file_payload = content.get("file") if content.get("type") == "text" else None
- if isinstance(file_payload, dict) and isinstance(file_payload.get("content"), str):
- text = file_payload["content"]
- display = str(file_payload.get("filePath") or filename)
- wrapped = f'\n{text}\n'
- return _message("user", [{"type": "input_text", "text": wrapped}])
-
- if attachment_type == "image" and isinstance(content, dict):
- image_url = content.get("image_url") or content.get("data")
- if isinstance(image_url, str) and image_url.startswith("data:"):
- return _message("user", [{"type": "input_image", "image_url": image_url}])
-
- if attachment_type in {"file", "image"}:
- raise HandoffError(f"Claude {attachment_type} attachment {record.get('uuid')} has an unsupported payload.")
-
- # Claude also records its own skill list, tool availability, permissions,
- # token reminders, hooks, and task status as attachments. Those configure
- # Claude's harness; they are not part of the user's project conversation and
- # must not become user messages in Codex.
- return None
-
-
-def _assistant_items(records: list[dict[str, Any]], calls: dict[str, str]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- skipped_reasoning = 0
- text_parts: list[dict[str, Any]] = []
-
- def flush_text() -> None:
- if text_parts:
- items.append(_message("assistant", list(text_parts)))
- text_parts.clear()
-
- for record in records:
- content = (record.get("message") or {}).get("content", [])
- if isinstance(content, str):
- text_parts.append({"type": "output_text", "text": content})
- continue
- if not isinstance(content, list):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid content.")
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "thinking" or kind == "redacted_thinking":
- skipped_reasoning += 1
- continue
- if kind == "text":
- text_parts.append({"type": "output_text", "text": str(block.get("text", ""))})
- continue
- if kind == "tool_use":
- flush_text()
- call_id = str(block.get("id") or "")
- name = str(block.get("name") or "")
- if not call_id or not name:
- raise HandoffError(f"Claude tool call in {record.get('uuid')} has no ID or name.")
- if call_id in calls:
- raise HandoffError(f"Claude tool call ID is duplicated: {call_id}")
- calls[call_id] = name
- items.append(
- {
- "type": "function_call",
- "call_id": call_id,
- "name": name,
- "arguments": json.dumps(
- block.get("input", {}),
- ensure_ascii=False,
- separators=(",", ":"),
- ),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude assistant block {kind!r} in {record.get('uuid')}.")
- flush_text()
- return items, skipped_reasoning
-
-
-def _user_items(record: dict[str, Any], calls: dict[str, str], completed_calls: set[str]) -> list[dict[str, Any]]:
- if record.get("isMeta") is True:
- return []
- content = (record.get("message") or {}).get("content")
- if isinstance(content, str):
- return [_message("user", [{"type": "input_text", "text": content}])]
- if not isinstance(content, list):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid content.")
-
- items: list[dict[str, Any]] = []
- user_parts: list[dict[str, Any]] = []
-
- def flush_user() -> None:
- if user_parts:
- items.append(_message("user", list(user_parts)))
- user_parts.clear()
-
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "text":
- user_parts.append({"type": "input_text", "text": str(block.get("text", ""))})
- continue
- if kind == "image":
- source = block.get("source") or {}
- if source.get("type") == "base64" and source.get("data") and source.get("media_type"):
- user_parts.append(
- {
- "type": "input_image",
- "image_url": f"data:{source['media_type']};base64,{source['data']}",
- }
- )
- continue
- raise HandoffError(f"Claude image in {record.get('uuid')} is not stored as transferable base64 data.")
- if kind == "tool_result":
- flush_user()
- call_id = str(block.get("tool_use_id") or "")
- if not call_id:
- raise HandoffError(f"Claude tool result in {record.get('uuid')} has no call ID.")
- if call_id not in calls:
- raise HandoffError(f"Claude tool result {call_id} has no matching call in the active state.")
- if call_id in completed_calls:
- raise HandoffError(f"Claude tool result is duplicated: {call_id}")
- completed_calls.add(call_id)
- items.append(
- {
- "type": "function_call_output",
- "call_id": call_id,
- "name": calls[call_id],
- "output": block.get("content"),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude user block {kind!r} in {record.get('uuid')}.")
- flush_user()
- return items
-
-
-def _responses_items(records: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- calls: dict[str, str] = {}
- completed_calls: set[str] = set()
- skipped_reasoning = 0
-
- index = 0
- while index < len(records):
- record = records[index]
- record_type = record.get("type")
- if record_type == "assistant":
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(records):
- candidate = records[index]
- if candidate.get("type") != "assistant":
- break
- candidate_id = (candidate.get("message") or {}).get("id")
- if not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- assistant_items, skipped = _assistant_items(group, calls)
- items.extend(assistant_items)
- skipped_reasoning += skipped
- continue
- if record_type == "user":
- items.extend(_user_items(record, calls, completed_calls))
- elif record_type == "attachment":
- attachment_item = _attachment_item(record)
- if attachment_item is not None:
- items.append(attachment_item)
- elif record_type not in {"system"}:
- raise HandoffError(f"Unsupported model-visible Claude record {record_type!r} at {record.get('uuid')}.")
- index += 1
-
- unfinished = sorted(set(calls) - completed_calls)
- if unfinished:
- joined = ", ".join(unfinished[:5])
- raise HandoffError(
- f"Claude's active state ends with unfinished tool call(s): {joined}. "
- "Finish or stop the Claude turn before transferring it."
- )
- if not items:
- raise HandoffError("Claude's active state produced no Codex history items.")
- return items, skipped_reasoning
-
-
-def _without_image_payloads(value: Any) -> Any:
- if isinstance(value, list):
- return [_without_image_payloads(item) for item in value]
- if not isinstance(value, dict):
- return value
-
- cleaned = {key: _without_image_payloads(item) for key, item in value.items()}
- if cleaned.get("type") == "input_image" and isinstance(cleaned.get("image_url"), str):
- cleaned["image_url"] = "[Image saved locally during handoff]"
- if cleaned.get("type") == "image" and isinstance(cleaned.get("source"), dict):
- source = dict(cleaned["source"])
- if source.get("type") == "base64" and "data" in source:
- source["data"] = "[Image saved locally during handoff]"
- cleaned["source"] = source
- return cleaned
-
-
-def _token_count(value: Any) -> int:
- text = json.dumps(_without_image_payloads(value), ensure_ascii=False, separators=(",", ":"))
- try:
- import tiktoken
-
- return len(tiktoken.get_encoding("o200k_base").encode(text))
- except ImportError:
- return (len(text) + 3) // 4
-
-
-def build_plan(session: str, projects_dir: Path) -> HandoffPlan:
- path = _resolve_session(session, projects_dir)
- records, sha256 = _stable_jsonl(path)
- chain = _active_chain(records)
- imported, compact_uuid = _after_latest_compaction(chain)
- imported = _merge_parallel_tool_results(imported, records)
- items, skipped_reasoning = _responses_items(imported)
-
- session_id = next(
- (str(record["sessionId"]) for record in reversed(records) if record.get("sessionId")),
- path.stem,
- )
- title = next(
- (
- str(record["customTitle"])
- for record in reversed(records)
- if record.get("type") == "custom-title" and record.get("customTitle")
- ),
- f"Claude session {session_id[:8]}",
- )
- cwd = next(
- (str(record["cwd"]) for record in chain if record.get("cwd")),
- "",
- )
- if not cwd:
- raise HandoffError("Claude session does not record its working directory.")
-
- warnings: list[str] = []
- if skipped_reasoning:
- warnings.append(f"Skipped {skipped_reasoning} Claude hidden-reasoning block(s); they are not portable.")
-
- source = SourceInfo(
- path=str(path),
- sha256=sha256,
- session_id=session_id,
- title=title,
- cwd=str(Path(cwd).resolve()),
- leaf_uuid=chain[-1]["uuid"],
- compact_boundary_uuid=compact_uuid,
- first_imported_uuid=imported[0]["uuid"],
- last_imported_uuid=imported[-1]["uuid"],
- codex_cwd=_git_root(Path(cwd)),
- )
- return HandoffPlan(
- source=source,
- items=items,
- source_records=len(records),
- active_records=len(chain),
- imported_records=len(imported),
- hidden_reasoning_blocks_skipped=skipped_reasoning,
- approximate_tokens=_token_count(items),
- warnings=warnings,
- )
-
-
-def _write_private(path: Path, body: str) -> None:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
- try:
- with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
- stream.write(body)
- os.replace(temporary, path)
- finally:
- Path(temporary).unlink(missing_ok=True)
-
-
-def write_bundle(plan: HandoffPlan, path: Path) -> Path:
- path = path.expanduser().resolve()
- _write_private(path, json.dumps(plan.bundle(), ensure_ascii=False))
- return path
-
-
-def _validate_items(items: Any) -> None:
- if not isinstance(items, list) or not items:
- raise HandoffError("Handoff bundle contains no history items.")
- calls: dict[str, str] = {}
- completed: set[str] = set()
- saw_user = False
- for item in items:
- if not isinstance(item, dict):
- raise HandoffError("Invalid handoff history item.")
- kind = item.get("type")
- status = item.get("status")
- if (
- not isinstance(kind, str)
- or (status is not None and not isinstance(status, str))
- or status in {"incomplete", "in_progress"}
- ):
- raise HandoffError("Invalid or incomplete handoff history item.")
- if kind == "message":
- role = item.get("role")
- parts = item.get("content")
- if (
- not isinstance(role, str)
- or role not in {"user", "assistant"}
- or not isinstance(parts, list)
- or not parts
- ):
- raise HandoffError("Invalid handoff message role or content.")
- saw_user = saw_user or role == "user"
- for part in parts:
- if not isinstance(part, dict) or not isinstance(part.get("type"), str):
- raise HandoffError("Invalid handoff message part.")
- if part.get("type") in {"input_text", "output_text"} and isinstance(part.get("text"), str):
- continue
- if part.get("type") == "input_image":
- _, encoded = _data_url_payload(part.get("image_url"), "Handoff image")
- try:
- if not base64.b64decode(encoded, validate=True):
- raise ValueError("empty image")
- except (ValueError, binascii.Error) as exc:
- raise HandoffError("Invalid handoff image data.") from exc
- continue
- raise HandoffError("Unsupported handoff message part.")
- elif kind == "function_call":
- call_id, name, arguments = item.get("call_id"), item.get("name"), item.get("arguments")
- if not isinstance(call_id, str) or not call_id or not isinstance(name, str) or not name:
- raise HandoffError("Invalid handoff tool call ID or name.")
- if call_id in calls or not isinstance(arguments, str):
- raise HandoffError("Duplicate or invalid handoff tool call.")
- try:
- json.loads(arguments)
- except json.JSONDecodeError as exc:
- raise HandoffError("Handoff tool arguments are not JSON.") from exc
- calls[call_id] = name
- elif kind == "function_call_output":
- call_id = item.get("call_id")
- if not isinstance(call_id, str) or call_id not in calls or call_id in completed:
- raise HandoffError("Unmatched or duplicate handoff tool result.")
- if "output" not in item:
- raise HandoffError("Handoff tool result has no output.")
- completed.add(call_id)
- item.setdefault("name", calls[call_id])
- else:
- raise HandoffError(f"Unsupported handoff item type: {kind!r}.")
- if set(calls) != completed:
- raise HandoffError("Session has unfinished tool calls; finish or stop the source turn before handoff.")
- if not saw_user:
- raise HandoffError("Handoff contains no user message.")
-
-
-def plan_from_bundle(payload: Any) -> HandoffPlan:
- formats = {FORMAT_VERSION, "mem0.claude-to-codex.v1", "memo.claude-to-codex.v1"}
- if not isinstance(payload, dict) or not isinstance(payload.get("format"), str) or payload["format"] not in formats:
- raise HandoffError("Unsupported handoff bundle format.")
- source_payload = payload.get("source")
- items = payload.get("items")
- warnings = payload.get("warnings", [])
- if not isinstance(source_payload, dict) or not isinstance(warnings, list):
- raise HandoffError("Handoff bundle has no source or has invalid warnings.")
- _validate_items(items)
- try:
- fields = dict(source_payload)
- legacy = payload["format"] != FORMAT_VERSION
- fields.setdefault("host", "claude-code" if legacy else "")
- for key in ("host", "session_id", "title", "cwd"):
- if not isinstance(fields.get(key), str) or not fields[key].strip():
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[a-z][a-z0-9-]*", fields["host"]):
- raise ValueError("invalid source host")
- fields.setdefault("path", f"{fields['host']}:{fields['session_id']}")
- fields.setdefault("sha256", hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest())
- fields.setdefault("leaf_uuid", str(len(items)))
- fields.setdefault("first_imported_uuid", "1")
- fields.setdefault("last_imported_uuid", str(len(items)))
- fields.setdefault("compact_boundary_uuid", None)
- source = SourceInfo(**fields)
- for key, value in asdict(source).items():
- if value is None and key in {"codex_cwd", "compact_boundary_uuid"}:
- continue
- if not isinstance(value, str):
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[0-9a-f]{64}", source.sha256):
- raise ValueError("invalid source digest")
- counts = payload.get("counts", {})
- return HandoffPlan(
- source=source,
- items=items,
- source_records=int(counts.get("source_records", len(items))),
- active_records=int(counts.get("active_records", len(items))),
- imported_records=int(counts.get("imported_records", len(items))),
- hidden_reasoning_blocks_skipped=int(counts.get("hidden_reasoning_blocks_skipped", 0)),
- approximate_tokens=_token_count(items),
- warnings=[str(warning) for warning in warnings],
- )
- except (KeyError, TypeError, ValueError, AttributeError) as exc:
- raise HandoffError("Handoff bundle is incomplete or has invalid source fields.") from exc
-
-
-def load_bundle(path: Path) -> HandoffPlan:
- try:
- text = sys.stdin.read() if str(path) == "-" else path.expanduser().resolve().read_text(encoding="utf-8")
- return plan_from_bundle(json.loads(text))
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid handoff bundle JSON: {path}") from exc
-
-
-def _git_root(cwd: Path) -> str | None:
- completed = subprocess.run(
- ["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
- text=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- check=False,
- )
- if completed.returncode != 0:
- return None
- root = Path(completed.stdout.strip()).resolve()
- return str(root) if root.is_dir() else None
-
-
-def _with_cwd(plan: HandoffPlan, cwd: Path | None) -> HandoffPlan:
- source_cwd = Path(plan.source.cwd).expanduser().resolve()
- target = (
- cwd.expanduser().resolve()
- if cwd
- else Path(plan.source.codex_cwd).expanduser().resolve()
- if plan.source.codex_cwd
- else Path(_git_root(source_cwd) or source_cwd)
- )
- if not target.is_dir():
- raise HandoffError(f"Codex working directory does not exist: {target}")
- return replace(plan, source=replace(plan.source, cwd=str(source_cwd), codex_cwd=str(target)))
-
-
-def _codex_cwd(plan: HandoffPlan) -> str:
- return plan.source.codex_cwd or plan.source.cwd
-
-
-def _default_bundle_path(plan: HandoffPlan) -> Path:
- session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip(".-")[:80] or "session"
- name = f"{session}-{plan.source.sha256[:12]}.json"
- return DEFAULT_BUNDLE_DIR / name
-
-
-def _codex_context_limits(codex_home: Path) -> CodexContextLimits:
- try:
- import tomllib
- except ImportError as exc:
- raise HandoffError("Creating a Codex task requires Python 3.11 or newer; rerun with python3.11.") from exc
-
- codex_home = codex_home.expanduser().resolve()
- config_path = codex_home / "config.toml"
- cache_path = codex_home / "models_cache.json"
- try:
- config = tomllib.loads(config_path.read_text(encoding="utf-8"))
- except (OSError, tomllib.TOMLDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex configuration at {config_path}: {exc}") from exc
- try:
- cache = json.loads(cache_path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex model metadata at {cache_path}: {exc}") from exc
-
- model = str(config.get("model") or "")
- models = cache.get("models") if isinstance(cache, dict) else None
- if not isinstance(models, list):
- raise HandoffError(f"Codex model metadata has no model list: {cache_path}")
- model_info = next(
- (
- item
- for item in models
- if isinstance(item, dict)
- and (
- item.get("slug") == model
- or item.get("model") == model
- or (not model and item.get("is_default") is True)
- )
- ),
- None,
- )
- if not isinstance(model_info, dict):
- raise HandoffError(f"Codex model {model!r} is missing from {cache_path}; refresh Codex's model list.")
- model = str(model_info.get("slug") or model_info.get("model") or model)
-
- cached_context = model_info.get("context_window")
- cached_max = model_info.get("max_context_window") or cached_context
- if not isinstance(cached_context, int) or not isinstance(cached_max, int):
- raise HandoffError(f"Codex model {model!r} does not report its context limits.")
- configured_context = config.get("model_context_window")
- context_window = min(configured_context, cached_max) if isinstance(configured_context, int) else cached_context
- effective_percent = model_info.get("effective_context_window_percent", 95)
- if not isinstance(effective_percent, int) or not 1 <= effective_percent <= 100:
- raise HandoffError(f"Codex model {model!r} reports an invalid effective context percentage.")
-
- context_auto_limit = context_window * 9 // 10
- configured_auto_limit = config.get("model_auto_compact_token_limit")
- auto_compact_limit = (
- min(configured_auto_limit, context_auto_limit) if isinstance(configured_auto_limit, int) else context_auto_limit
- )
- return CodexContextLimits(
- model=model,
- context_window=context_window,
- usable_context_window=context_window * effective_percent // 100,
- auto_compact_token_limit=auto_compact_limit,
- max_context_window=cached_max,
- max_usable_context_window=cached_max * effective_percent // 100,
- max_auto_compact_token_limit=cached_max * 9 // 10,
- )
-
-
-class CodexAppServer:
- """Small JSON-RPC client for a one-off local Codex app-server process."""
-
- def __init__(
- self,
- codex_bin: str = "codex",
- context_window_override: int | None = None,
- codex_home: Path = DEFAULT_CODEX_HOME,
- ) -> None:
- resolved = shutil.which(codex_bin)
- if not resolved:
- raise HandoffError(f"Codex executable not found: {codex_bin}")
- command = [resolved]
- if context_window_override is not None:
- command.extend(["-c", f"model_context_window={context_window_override}"])
- command.extend(["app-server", "--stdio"])
- self.process = subprocess.Popen(
- command,
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- bufsize=1,
- env={**os.environ, "CODEX_HOME": str(codex_home.expanduser().resolve())},
- )
- self._responses: queue.Queue[dict[str, Any]] = queue.Queue()
- self._notifications: queue.Queue[dict[str, Any]] = queue.Queue()
- self._stderr: list[str] = []
- self._next_id = 1
- threading.Thread(target=self._read_stdout, daemon=True).start()
- threading.Thread(target=self._read_stderr, daemon=True).start()
- try:
- self.request(
- "initialize",
- {
- "clientInfo": {
- "name": "mem0_session_handoff",
- "title": "Mem0 local session handoff",
- "version": "0.1.0",
- }
- },
- )
- self.notify("initialized", {})
- except Exception:
- self.close()
- raise
-
- def _read_stdout(self) -> None:
- assert self.process.stdout is not None
- for line in self.process.stdout:
- try:
- message = json.loads(line)
- except json.JSONDecodeError:
- continue
- if not isinstance(message, dict):
- continue
- if "id" in message:
- self._responses.put(message)
- elif "method" in message:
- self._notifications.put(message)
-
- def _read_stderr(self) -> None:
- assert self.process.stderr is not None
- for line in self.process.stderr:
- self._stderr.append(line.rstrip())
-
- def _send(self, payload: dict[str, Any]) -> None:
- if self.process.poll() is not None:
- error = "\n".join(self._stderr[-20:])
- raise HandoffError(f"Codex app-server stopped unexpectedly.\n{error}")
- assert self.process.stdin is not None
- self.process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
- self.process.stdin.flush()
-
- def request(self, method: str, params: dict[str, Any], timeout: float = 30) -> Any:
- request_id = self._next_id
- self._next_id += 1
- self._send({"method": method, "id": request_id, "params": params})
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out on {method}.")
- try:
- response = self._responses.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out on {method}.") from exc
- if response.get("id") != request_id:
- deferred.append(response)
- continue
- if "error" in response:
- raise HandoffError(f"Codex {method} failed: {response['error']}")
- return response.get("result")
- finally:
- for response in deferred:
- self._responses.put(response)
-
- def notify(self, method: str, params: dict[str, Any]) -> None:
- self._send({"method": method, "params": params})
-
- def wait_for_notification(
- self,
- method: str,
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.") from exc
- if notification.get("method") != method:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def wait_for_any_notification(
- self,
- methods: set[str],
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.") from exc
- if notification.get("method") not in methods:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def close(self) -> None:
- if self.process.poll() is None:
- self.process.terminate()
- try:
- self.process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self.process.kill()
- self.process.wait(timeout=5)
-
- def __enter__(self) -> "CodexAppServer":
- return self
-
- def __exit__(self, *_: Any) -> None:
- self.close()
-
-
-def _item_text(item: dict[str, Any], asset_dir: Path) -> tuple[str, str]:
- """Convert one Responses item to a complete visible import message."""
- item_type = item.get("type")
- if item_type == "message":
- role = str(item.get("role") or "")
- if role not in {"user", "assistant"}:
- raise HandoffError(f"Codex's session importer cannot represent role {role!r}.")
- parts: list[str] = []
- for part in item.get("content") or []:
- if not isinstance(part, dict):
- raise HandoffError("A handoff message contains an invalid content item.")
- part_type = part.get("type")
- if part_type in {"input_text", "output_text"}:
- parts.append(str(part.get("text") or ""))
- elif part_type == "input_image":
- media_type, encoded = _data_url_payload(part.get("image_url"), "A Claude message image")
- parts.append(_image_reference(media_type, encoded, asset_dir, "A Claude message image"))
- else:
- raise HandoffError(f"Codex's session importer cannot represent content type {part_type!r}.")
- text = "\n\n".join(part for part in parts if part)
- if not text:
- raise HandoffError("A handoff message contains no transferable text.")
- return role, text
-
- if item_type == "function_call":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- arguments = str(item.get("arguments") or "{}")
- return (
- "assistant",
- f'\n{arguments}\n',
- )
-
- if item_type == "function_call_output":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- output = _tool_result_text(item.get("output"), asset_dir, f"Claude tool result {call_id}")
- return (
- "assistant",
- f'\n{output}\n',
- )
-
- raise HandoffError(f"Codex's session importer cannot represent item type {item_type!r}.")
-
-
-def _native_import_records(plan: HandoffPlan, asset_dir: Path) -> list[dict[str, Any]]:
- """Build the Claude-shaped history consumed by Codex's native importer."""
- cwd = _codex_cwd(plan)
- records: list[dict[str, Any]] = [
- {
- "type": "custom-title",
- "customTitle": plan.source.title,
- "sessionId": plan.source.session_id,
- }
- ]
- saw_user = False
- for index, item in enumerate(plan.items, 1):
- role, text = _item_text(item, asset_dir)
- saw_user = saw_user or role == "user"
- records.append(
- {
- "type": role,
- "sessionId": plan.source.session_id,
- "uuid": f"mem0-handoff-{index}",
- "cwd": cwd,
- "isSidechain": False,
- "message": {"role": role, "content": text},
- }
- )
- if not saw_user:
- raise HandoffError("The active Claude context contains no user message.")
- return records
-
-
-def _native_import_path(plan: HandoffPlan, claude_projects_dir: Path) -> Path:
- source_key = hashlib.sha256(plan.source.path.encode("utf-8")).hexdigest()[:24]
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- safe_session = safe_session[:80] or source_key
- return claude_projects_dir.expanduser().resolve() / ".mem0-handoffs" / f"{safe_session}-{source_key}.jsonl"
-
-
-def _write_native_import(plan: HandoffPlan, path: Path, asset_dir: Path) -> str:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- body = "".join(
- json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
- for record in _native_import_records(plan, asset_dir)
- )
- _write_private(path, body)
- return hashlib.sha256(body.encode("utf-8")).hexdigest()
-
-
-def _native_import_params(source_path: Path, cwd: str) -> dict[str, Any]:
- return {
- "migrationItems": [
- {
- "itemType": "SESSIONS",
- "description": f"Transfer Claude session {source_path.name}",
- "cwd": None,
- "details": {
- "plugins": [],
- "sessions": [{"path": str(source_path), "cwd": cwd, "title": None}],
- "mcpServers": [],
- "hooks": [],
- "subagents": [],
- "commands": [],
- },
- }
- ]
- }
-
-
-def _thread_id_from_completion(params: Any, source_path: Path) -> str | None:
- if not isinstance(params, dict):
- return None
- canonical = str(source_path.resolve())
- for result in params.get("itemTypeResults") or []:
- if not isinstance(result, dict) or result.get("itemType") != "SESSIONS":
- continue
- for success in result.get("successes") or []:
- if not isinstance(success, dict):
- continue
- if success.get("source") in {None, canonical} and success.get("target"):
- return str(success["target"])
- return None
-
-
-def _thread_id_from_ledger(codex_home: Path, source_path: Path, content_sha256: str) -> str | None:
- ledger_path = codex_home.expanduser() / "external_agent_session_imports.json"
- if not ledger_path.is_file():
- return None
- try:
- ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
- except json.JSONDecodeError:
- return None
- canonical = str(source_path.resolve())
- matches = [
- record
- for record in ledger.get("records", [])
- if isinstance(record, dict)
- and record.get("source_path") == canonical
- and record.get("content_sha256") == content_sha256
- and record.get("imported_thread_id")
- ]
- return str(matches[-1]["imported_thread_id"]) if matches else None
-
-
-def _notification_thread_id(params: Any) -> str | None:
- if not isinstance(params, dict):
- return None
- if params.get("threadId"):
- return str(params["threadId"])
- turn = params.get("turn")
- if isinstance(turn, dict) and turn.get("threadId"):
- return str(turn["threadId"])
- return None
-
-
-def _compact_imported_thread(
- server: CodexAppServer,
- thread_id: str,
-) -> dict[str, Any] | None:
- server.request("thread/resume", {"threadId": thread_id}, timeout=120)
- server.request("thread/compact/start", {"threadId": thread_id}, timeout=30)
-
- latest_usage: dict[str, Any] | None = None
- saw_compaction_item = False
- while True:
- notification = server.wait_for_any_notification(
- {"item/completed", "thread/tokenUsage/updated", "turn/completed", "error"},
- lambda params: _notification_thread_id(params) in {None, thread_id},
- timeout=600,
- )
- method = notification.get("method")
- params = notification.get("params")
- if method == "thread/tokenUsage/updated" and isinstance(params, dict):
- token_usage = params.get("tokenUsage")
- if isinstance(token_usage, dict):
- latest_usage = token_usage
- continue
- if method == "item/completed" and isinstance(params, dict):
- item = params.get("item")
- if isinstance(item, dict) and item.get("type") == "contextCompaction":
- saw_compaction_item = True
- continue
- if method == "error":
- error = params.get("error") if isinstance(params, dict) else params
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if method == "turn/completed" and isinstance(params, dict):
- turn = params.get("turn")
- if not isinstance(turn, dict):
- raise HandoffError("Codex returned an invalid compaction result.")
- if turn.get("status") != "completed":
- error = turn.get("error") or turn.get("status")
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if not saw_compaction_item:
- raise HandoffError("Codex completed the compaction turn without a compaction item.")
- return latest_usage
-
-
-def _set_thread_name(
- server: CodexAppServer,
- thread_id: str,
- name: str,
-) -> None:
- server.request(
- "thread/name/set",
- {"threadId": thread_id, "name": name},
- timeout=30,
- )
-
-
-def create_codex_thread(
- plan: HandoffPlan,
- codex_bin: str = "codex",
- codex_home: Path = DEFAULT_CODEX_HOME,
-) -> dict[str, Any]:
- limits = _codex_context_limits(codex_home)
- should_compact = plan.approximate_tokens >= limits.auto_compact_token_limit
- if should_compact and plan.approximate_tokens >= limits.max_auto_compact_token_limit:
- raise HandoffError(
- f"The active session state is approximately {plan.approximate_tokens:,} tokens. "
- f"Codex cannot safely compact more than approximately "
- f"{limits.max_auto_compact_token_limit:,} tokens in one request. "
- "Compact in the source host and retry the handoff."
- )
-
- # Codex only imports sources staged under its native Claude home.
- source_path = _native_import_path(plan, Path.home() / ".claude" / "projects")
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- asset_dir = (
- codex_home.expanduser().resolve()
- / "external-agent-assets"
- / plan.source.host
- / (safe_session[:80] or "session")
- )
- content_sha256 = _write_native_import(plan, source_path, asset_dir)
- try:
- context_override = limits.max_context_window if should_compact else None
- with CodexAppServer(codex_bin, context_override, codex_home=codex_home) as server:
- response = server.request(
- "externalAgentConfig/import",
- _native_import_params(source_path, _codex_cwd(plan)),
- timeout=120,
- )
- import_id = str((response or {}).get("importId") or "")
- if not import_id:
- raise HandoffError(f"Codex externalAgentConfig/import returned no import ID: {response!r}")
- completed = server.wait_for_notification(
- IMPORT_COMPLETED_NOTIFICATION,
- lambda params: isinstance(params, dict) and params.get("importId") == import_id,
- )
- completed_params = completed.get("params")
- thread_id = _thread_id_from_completion(completed_params, source_path)
- if not thread_id:
- thread_id = _thread_id_from_ledger(codex_home, source_path, content_sha256)
- if not thread_id:
- raise HandoffError(
- "Codex finished importing the session but did not report the new task ID. "
- f"Import result: {json.dumps(completed_params, ensure_ascii=False)}"
- )
-
- read = server.request(
- "thread/read",
- {"threadId": thread_id, "includeTurns": True},
- )
- thread = (read or {}).get("thread") if isinstance(read, dict) else None
- if not isinstance(thread, dict):
- raise HandoffError(f"Codex could not read imported task {thread_id}.")
- turns = thread.get("turns") or []
- preview = str(thread.get("preview") or "")
- if not turns or not preview:
- raise HandoffError(f"Codex imported task {thread_id}, but it has no visible history.")
-
- compaction_usage = _compact_imported_thread(server, thread_id) if should_compact else None
- _set_thread_name(server, thread_id, plan.source.title)
-
- return {
- "thread_id": thread_id,
- "title": plan.source.title,
- "cwd": _codex_cwd(plan),
- "source_session_id": plan.source.session_id,
- "source_host": plan.source.host,
- "visible_turns": len(turns),
- "preview": preview,
- "responses_items_converted": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "target_model": limits.model,
- "target_context_window": limits.context_window,
- "target_usable_context_window": limits.usable_context_window,
- "target_auto_compact_token_limit": limits.auto_compact_token_limit,
- "compacted_before_return": should_compact,
- "compaction_context_window": (limits.max_context_window if should_compact else None),
- "compaction_token_usage": compaction_usage,
- "model_invoked": should_compact,
- }
- finally:
- source_path.unlink(missing_ok=True)
- try:
- source_path.parent.rmdir()
- except OSError:
- pass
-
-
-def _summary(plan: HandoffPlan) -> dict[str, Any]:
- return {
- "source": plan.source.path,
- "source_host": plan.source.host,
- "session_id": plan.source.session_id,
- "title": plan.source.title,
- "source_cwd": plan.source.cwd,
- "codex_cwd": _codex_cwd(plan),
- "leaf_uuid": plan.source.leaf_uuid,
- "compact_boundary_uuid": plan.source.compact_boundary_uuid,
- "source_records": plan.source_records,
- "active_records": plan.active_records,
- "imported_records": plan.imported_records,
- "responses_items": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "warnings": plan.warnings,
- }
-
-
-def _command_output(result: dict[str, Any]) -> str:
- title = str(result["title"])
- cwd = str(result["cwd"])
- project = Path(cwd).name or cwd
- lines = [
- f'Created Codex task "{title}".',
- f"Task ID: {result['thread_id']}",
- f"Project: {project}",
- ]
- if result.get("compacted_before_return"):
- lines.append("Codex compacted the transferred context before opening the task.")
- lines.append(f'Open Codex and select "{title}" under {project}.')
- return "\n".join(lines)
-
-
-def _parse_args(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> argparse.Namespace:
- parser = argparse.ArgumentParser(description=__doc__)
- source = parser.add_mutually_exclusive_group(required=True)
- source.add_argument("--session", help="Native session transcript path (Claude also accepts its session ID)")
- parser.add_argument(
- "--source",
- choices=("claude-code", "cursor", "codex", "kimi", "antigravity", "openclaw", "pi-agent"),
- default=default_source,
- )
- parser.add_argument("--title", help="Override the imported task title")
- source.add_argument("--bundle", type=Path, help="Previously exported handoff bundle")
- parser.add_argument(
- "--claude-projects-dir",
- type=Path,
- default=Path.home() / ".claude" / "projects",
- )
- parser.add_argument("--export", type=Path, help="Write a private reusable handoff bundle")
- parser.add_argument(
- "--cwd",
- type=Path,
- help="Use this existing directory instead of the source session's directory",
- )
- parser.add_argument("--create", action="store_true", help="Create the Codex task")
- parser.add_argument(
- "--target",
- choices=("codex",),
- default="codex",
- help="Destination coding agent",
- )
- parser.add_argument(
- "--command-output",
- action="store_true",
- help="Print the short result used by Mem0's user-facing command",
- )
- parser.add_argument("--codex-bin", default="codex")
- parser.add_argument(
- "--codex-home",
- type=Path,
- default=DEFAULT_CODEX_HOME,
- )
- return parser.parse_args(argv)
-
-
-def main(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> int:
- args = _parse_args(argv, default_source)
- try:
- if args.bundle:
- plan = load_bundle(args.bundle)
- elif args.source == "claude-code":
- plan = build_plan(args.session, args.claude_projects_dir)
- else:
- if not args.source:
- raise HandoffError("--source is required with --session.")
- from handoff_sources import read_source
-
- plan = read_source(args.source, Path(args.session), cwd=args.cwd, title=args.title)
- if args.title:
- plan = replace(plan, source=replace(plan.source, title=args.title))
- plan = _with_cwd(plan, args.cwd)
- output: dict[str, Any] = {"plan": _summary(plan)}
- if args.export:
- output["bundle"] = str(write_bundle(plan, args.export))
- if args.create:
- try:
- output["codex"] = create_codex_thread(
- plan,
- codex_bin=args.codex_bin,
- codex_home=args.codex_home.expanduser(),
- )
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- fallback = args.export or _default_bundle_path(plan)
- saved = write_bundle(plan, fallback)
- raise HandoffError(f"{exc} The complete handoff was saved at {saved}.") from exc
- if args.command_output:
- if not args.create:
- raise HandoffError("--command-output requires --create.")
- print(_command_output(output["codex"]))
- else:
- print(json.dumps(output, indent=2, ensure_ascii=False))
- return 0
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- print(f"handoff failed: {exc}", file=sys.stderr)
- return 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/integrations/antigravity-plugin/core/handoff-runtime.json b/integrations/antigravity-plugin/core/handoff-runtime.json
new file mode 100644
index 000000000..b6b935e8d
--- /dev/null
+++ b/integrations/antigravity-plugin/core/handoff-runtime.json
@@ -0,0 +1,8 @@
+{
+ "revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
+ "files": {
+ "claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
+ "handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
+ },
+ "artifacts": ["session_handoff.py", "handoff-runtime.json"]
+}
diff --git a/integrations/antigravity-plugin/core/handoff_sources.py b/integrations/antigravity-plugin/core/handoff_sources.py
deleted file mode 100644
index 549c9969e..000000000
--- a/integrations/antigravity-plugin/core/handoff_sources.py
+++ /dev/null
@@ -1,452 +0,0 @@
-"""Native transcript readers; all destinations use the shared handoff importer.
-
-Formats: openai/codex rollout payloads; MoonshotAI/kimi-code contextMemory;
-Pi's session-manager.buildSessionContext; native Cursor/Antigravity transcripts.
-Unsupported state changes fail instead of silently dropping active context.
-"""
-
-from __future__ import annotations
-
-import json
-from pathlib import Path
-from typing import Any
-
-import claude_to_codex as engine
-
-
-def _message(role: str, text: str) -> dict:
- return {"role": role, "content": [{"type": "text", "text": text}]}
-
-
-def _parts(content: Any, role: str, warnings: list[str]) -> list[dict]:
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if not isinstance(part, dict):
- raise engine.HandoffError("Native message has an invalid content block.")
- kind = part.get("type")
- if kind in {"thinking", "redacted_thinking", "think"}:
- if "Hidden reasoning was excluded." not in warnings:
- warnings.append("Hidden reasoning was excluded.")
- elif kind in {"text", "input_text", "output_text"} and isinstance(part.get("text"), str):
- parts.append({"type": "input_text" if role == "user" else "output_text", "text": part["text"]})
- elif kind == "image":
- source = part.get("source") or {
- "type": "base64",
- "data": part.get("data"),
- "media_type": part.get("mimeType"),
- }
- media_type, data = engine._image_payload(source, "Native message image")
- parts.append({"type": "input_image", "image_url": f"data:{media_type};base64,{data}"})
- elif kind in {"image_url", "input_image"}:
- url = part.get("image_url")
- if isinstance(url, dict):
- url = url.get("url")
- engine._data_url_payload(url, "Native message image")
- parts.append({"type": "input_image", "image_url": url})
- elif kind not in {"toolCall", "tool_use"}:
- raise engine.HandoffError(f"Unsupported native content block: {kind!r}.")
- return parts
-
-
-def _call_item(call: dict) -> dict:
- function = call.get("function", call)
- arguments = function.get("arguments", "{}")
- return {
- "type": "function_call",
- "call_id": call.get("id"),
- "name": function.get("name"),
- "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments),
- }
-
-
-def _messages_items(messages: list[dict], warnings: list[str]) -> list[dict]:
- items = []
- for message in messages:
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid native message.")
- role = message.get("role")
- if role in {"system", "developer"}:
- if "Source harness instructions were excluded." not in warnings:
- warnings.append("Source harness instructions were excluded.")
- continue
- if role in {"tool", "toolResult"}:
- output_parts = _parts(message.get("content"), "assistant", warnings)
- output = []
- for part in output_parts:
- if part["type"] == "input_image":
- media_type, data = engine._data_url_payload(part["image_url"], "Tool result image")
- output.append(
- {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}}
- )
- else:
- output.append({"type": "text", "text": part["text"]})
- if message.get("isError"):
- output.insert(0, {"type": "text", "text": "Tool failed."})
- if message.get("note"):
- output.append({"type": "text", "text": str(message["note"])})
- item = {
- "type": "function_call_output",
- "call_id": message.get("toolCallId") or message.get("tool_call_id"),
- "output": output,
- }
- if message.get("toolName") or message.get("name"):
- item["name"] = message.get("toolName") or message["name"]
- items.append(item)
- continue
- if role not in {"user", "assistant"}:
- raise engine.HandoffError(f"Unsupported native message role: {role!r}.")
- if message.get("partial") or message.get("stopReason") in {"error", "aborted"}:
- raise engine.HandoffError("Native assistant response is incomplete; finish the source turn first.")
- content = message.get("content", [])
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if isinstance(part, dict) and part.get("type") in {"toolCall", "tool_use"}:
- if role != "assistant":
- raise engine.HandoffError("Native user message contains an assistant tool call.")
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- parts = []
- items.append(
- _call_item(
- {
- "id": part.get("id"),
- "name": part.get("name"),
- "arguments": json.dumps(part.get("arguments", part.get("input", {}))),
- }
- )
- )
- else:
- parts.extend(_parts([part], role, warnings))
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- for call in message.get("toolCalls") or message.get("tool_calls") or []:
- items.append(_call_item(call))
- return items
-
-
-def _codex(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Native Responses items are the authoritative history, event_msg is UI data.
- items, source = [], {}
- for record in records:
- kind, payload = record.get("type"), record.get("payload")
- if not isinstance(payload, dict):
- raise engine.HandoffError("Invalid Codex rollout payload.")
- if kind == "session_meta":
- source.update(session_id=payload.get("id"), cwd=payload.get("cwd"))
- elif kind == "compacted":
- replacement = payload.get("replacement_history")
- if not isinstance(replacement, list) or not replacement:
- raise engine.HandoffError(
- "Codex compaction is opaque; a complete plaintext replacement history is required."
- )
- items = list(replacement)
- elif kind == "response_item":
- items.append(payload)
- elif kind == "event_msg":
- if payload.get("type") == "thread_rolled_back":
- raise engine.HandoffError("Codex rollback requires a native active-context export.")
- elif kind != "turn_context":
- raise engine.HandoffError(f"Unsupported Codex rollout record: {kind!r}.")
- result = []
- for item in items:
- kind = item.get("type")
- if kind == "reasoning":
- warnings.append("Hidden reasoning was excluded.")
- elif kind == "message" and item.get("role") in {"system", "developer"}:
- warnings.append("Source harness instructions were excluded.")
- elif kind == "custom_tool_call":
- result.append(
- {
- "type": "function_call",
- "call_id": item.get("call_id"),
- "name": item.get("name"),
- "arguments": json.dumps({"input": item.get("input")}),
- }
- )
- elif kind == "custom_tool_call_output":
- result.append({**item, "type": "function_call_output"})
- elif kind == "compaction":
- raise engine.HandoffError(
- "Codex compaction contains opaque model state; it cannot be transferred losslessly."
- )
- else:
- result.append(dict(item))
- return result, source
-
-
-def _cursor(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Cursor's persisted transcript uses role + message.content, without Claude's parent chain.
- converted = []
- source = {}
- for index, record in enumerate(records):
- role = record.get("role") or record.get("type")
- if role not in {"user", "assistant"} or not isinstance(record.get("message"), dict):
- raise engine.HandoffError("Unsupported Cursor transcript record; provide a complete native JSONL export.")
- converted.append({**record, "type": role, "uuid": str(index)})
- if record.get("session_id"):
- source["session_id"] = record["session_id"]
- if record.get("cwd"):
- source["cwd"] = record["cwd"]
- items, skipped = engine._responses_items(converted)
- if skipped:
- warnings.append("Hidden reasoning was excluded.")
- return items, source
-
-
-def _antigravity(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- messages = []
- for step in records:
- if step.get("status") != "DONE":
- raise engine.HandoffError("Antigravity has an unfinished transcript step; finish the source turn first.")
- kind, content = step.get("type"), step.get("content")
- if not isinstance(content, str):
- raise engine.HandoffError("Antigravity transcript content is not transferable text.")
- if kind == "USER_INPUT":
- messages.append(_message("user", content))
- elif kind == "PLANNER_RESPONSE" and step.get("source") == "MODEL":
- messages.append(_message("assistant", content))
- else:
- raise engine.HandoffError(
- f"Unsupported Antigravity step {kind!r}; its visible conversation semantics are not verified."
- )
- return _messages_items(messages, warnings), {}
-
-
-def _kimi_compact(messages: list[dict], record: dict) -> list[dict]:
- summary = record.get("contextSummary", record.get("summary"))
- if isinstance(summary, dict):
- summary_message = summary
- elif isinstance(summary, str):
- summary_message = {**_message("user", summary), "origin": {"kind": "compaction_summary"}}
- else:
- raise engine.HandoffError("Kimi compaction has no transferable summary.")
- if record.get("legacyTail") or "keptUserMessageCount" not in record:
- count = record.get("compactedCount", record.get("count"))
- if not isinstance(count, int) or not 0 <= count <= len(messages):
- raise engine.HandoffError("Invalid Kimi compaction boundary.")
- return [summary_message, *messages[count:]]
- users = []
- for message in messages:
- origin = message.get("origin") or {}
- if message.get("role") == "user" and (
- origin.get("kind") in {None, "user"}
- or (origin.get("kind") in {"skill_activation", "plugin_command"} and origin.get("trigger") == "user-slash")
- ):
- users.append(message)
- # Kimi trims user inputs above this native budget. Do not approximate that destructive rewrite.
- tokens = 0
- for message in users:
- if message.get("toolCalls"):
- raise engine.HandoffError("Unsupported Kimi compaction user tool calls.")
- tokens += 1 # estimateTokens('user')
- for part in message.get("content", []):
- if part.get("type") not in {"text", "think"}:
- tokens += 2000
- else:
- text = part.get("text", part.get("think", ""))
- ascii_count = sum(ord(char) <= 127 for char in text)
- tokens += (ascii_count + 3) // 4 + len(text) - ascii_count
- if tokens > 20000 or record.get("keptHeadUserMessageCount"):
- raise engine.HandoffError("Kimi compaction elided user content; use a native active-context bundle export.")
- continuation = _message(
- "user",
- "\nContext compaction is complete — continue the work that was in progress when it began.\n",
- )
- return [*users, summary_message, continuation]
-
-
-def _kimi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Mirrors Kimi v2 context.append_message and completed loop events, not UI stream fragments.
- messages, source = [], {}
- opened, step_id = None, None
- for record in records:
- if record.get("agentId") not in {None, "main"}:
- continue
- kind = record.get("type", "")
- if kind in {"profile.bind", "config.update"}:
- cwd = (record.get("environmentDisclosure") or {}).get("cwd") or record.get("cwd")
- if cwd:
- source["cwd"] = cwd
- elif kind == "context.append_message":
- if opened is not None:
- raise engine.HandoffError("Kimi interleaved messages require a completed native context export.")
- messages.append(record.get("message"))
- elif kind == "context.append_loop_event":
- event = record.get("event") or {}
- event_type = event.get("type")
- if event_type == "step.begin":
- if opened is not None:
- raise engine.HandoffError("Kimi previous response did not complete.")
- step_id = event.get("uuid")
- opened = {"role": "assistant", "content": [], "toolCalls": []}
- messages.append(opened)
- elif event_type == "step.end":
- if event.get("uuid") != step_id or event.get("finishReason") in {"error", "interrupted"}:
- raise engine.HandoffError("Kimi response is incomplete or interrupted.")
- opened, step_id = None, None
- elif event_type in {"content.part", "tool.call"}:
- if opened is None or event.get("stepUuid") != step_id:
- raise engine.HandoffError("Kimi content has no matching active response.")
- if event_type == "content.part":
- opened["content"].append(event.get("part"))
- else:
- opened["toolCalls"].append(
- {
- "id": event.get("toolCallId"),
- "name": event.get("name"),
- "arguments": json.dumps(event.get("args", {})),
- }
- )
- elif event_type == "tool.result":
- result = event.get("result") or {}
- messages.append(
- {
- "role": "tool",
- "toolCallId": event.get("toolCallId"),
- "content": result.get("output"),
- "isError": result.get("isError"),
- "note": result.get("note"),
- }
- )
- else:
- raise engine.HandoffError(f"Unsupported Kimi loop event: {event_type!r}.")
- elif kind == "context.clear":
- messages, opened, step_id = [], None, None
- elif kind == "context.apply_compaction":
- if opened is not None:
- raise engine.HandoffError("Kimi compaction began during an unfinished response.")
- messages = _kimi_compact(messages, record)
- elif kind in {"context.undo", "micro_compaction.apply", "context.spliced"}:
- raise engine.HandoffError(f"Kimi {kind} needs a native active-context export to preserve its state.")
- elif kind.startswith("context.") and kind != "context.update_token_count":
- raise engine.HandoffError(f"Unsupported Kimi context event: {kind!r}.")
- # Remaining durable events configure Kimi's harness; they are not model messages.
- if opened is not None:
- raise engine.HandoffError("Kimi response is still streaming.")
- return _messages_items(messages, warnings), source
-
-
-def _pi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- header = records[0]
- if header.get("type") != "session":
- raise engine.HandoffError("Pi/OpenClaw transcript has no session header.")
- entries = [record for record in records[1:] if isinstance(record.get("id"), str)]
- if len(entries) != len(records) - 1:
- raise engine.HandoffError("Pi/OpenClaw transcript entry has no ID.")
- index = {entry["id"]: entry for entry in entries}
- if len(index) != len(entries):
- raise engine.HandoffError("Pi/OpenClaw transcript has duplicate entry IDs.")
- chain, seen = [], set()
- current = entries[-1] if entries else None
- while current:
- if current["id"] in seen:
- raise engine.HandoffError("Pi/OpenClaw transcript has a parent cycle.")
- seen.add(current["id"])
- chain.append(current)
- parent = current.get("parentId")
- if parent is not None and parent not in index:
- raise engine.HandoffError("Pi/OpenClaw transcript has a missing parent.")
- current = index.get(parent)
- chain.reverse()
- title = next((entry.get("name") for entry in reversed(chain) if entry.get("type") == "session_info"), None)
- messages = []
- boundary = next((i for i in range(len(chain) - 1, -1, -1) if chain[i].get("type") == "compaction"), None)
- if boundary is not None:
- compact = chain[boundary]
- if not isinstance(compact.get("summary"), str):
- raise engine.HandoffError("Pi/OpenClaw compaction has no summary.")
- messages.append(
- _message(
- "user",
- f"The conversation history before this point was compacted into the following summary:\n\n\n{compact['summary']}\n",
- )
- )
- kept = next((i for i in range(boundary) if chain[i]["id"] == compact.get("firstKeptEntryId")), boundary)
- chain = chain[kept:boundary] + chain[boundary + 1 :]
- for entry in chain:
- kind = entry.get("type")
- if kind == "message":
- message = entry.get("message")
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid Pi/OpenClaw message.")
- if message.get("role") == "bashExecution":
- if message.get("excludeFromContext"):
- continue
- if message.get("truncated"):
- raise engine.HandoffError("Pi/OpenClaw shell output is truncated; provide a complete bundle.")
- text = f"Ran `{message.get('command', '')}`\n"
- text += f"```\n{message['output']}\n```" if message.get("output") else "(no output)"
- if message.get("cancelled"):
- text += "\n\n(command cancelled)"
- elif message.get("exitCode") not in {None, 0}:
- text += f"\n\nCommand exited with code {message['exitCode']}"
- message = _message("user", text)
- messages.append(message)
- elif kind == "branch_summary":
- messages.append(
- _message(
- "user",
- f"The following is a summary of a branch that this conversation came back from:\n\n\n{entry['summary']}",
- )
- )
- elif kind == "custom_message":
- messages.append({"role": "user", "content": entry.get("content")})
- elif kind == "session_info":
- title = entry.get("name")
- elif kind not in {"model_change", "thinking_level_change", "custom", "label"}:
- raise engine.HandoffError(f"Unsupported Pi/OpenClaw entry: {kind!r}.")
- return _messages_items(messages, warnings), {
- "session_id": header.get("id"),
- "cwd": header.get("cwd"),
- "title": title,
- }
-
-
-def read_source(host: str, path: Path, *, cwd: Path | None = None, title: str | None = None) -> engine.HandoffPlan:
- path = path.expanduser().resolve()
- records, digest = engine._stable_jsonl(path)
- warnings: list[str] = []
- readers = {
- "cursor": _cursor,
- "codex": _codex,
- "kimi": _kimi,
- "antigravity": _antigravity,
- "openclaw": _pi,
- "pi-agent": _pi,
- }
- try:
- items, metadata = readers[host](records, warnings)
- except (TypeError, AttributeError, KeyError, ValueError) as exc:
- raise engine.HandoffError(f"Invalid {host} native transcript structure: {exc}") from exc
- if host == "kimi" and path.name == "wire.jsonl" and path.parent.name == "main":
- metadata.setdefault("session_id", path.parents[2].name)
- state = path.parents[2] / "state.json"
- if state.is_file():
- try:
- metadata.setdefault("title", json.loads(state.read_text()).get("title"))
- except (json.JSONDecodeError, AttributeError):
- pass
- if host == "antigravity" and path.name == "transcript.jsonl" and path.parent.name == "logs":
- metadata.setdefault("session_id", path.parents[2].name)
- source_cwd = str(cwd.expanduser().resolve()) if cwd else metadata.get("cwd")
- if not source_cwd:
- raise engine.HandoffError(f"{host} transcript has no working directory; provide --cwd.")
- source = {
- "host": host,
- "path": str(path),
- "sha256": digest,
- "session_id": metadata.get("session_id") or path.stem,
- "cwd": source_cwd,
- "title": title or metadata.get("title") or f"{host} session {path.stem[:12]}",
- }
- return engine.plan_from_bundle(
- {"format": engine.FORMAT_VERSION, "source": source, "items": items, "warnings": list(dict.fromkeys(warnings))}
- )
diff --git a/integrations/antigravity-plugin/core/memory_core.py b/integrations/antigravity-plugin/core/memory_core.py
index 1e99f99e7..93a3fde15 100644
--- a/integrations/antigravity-plugin/core/memory_core.py
+++ b/integrations/antigravity-plugin/core/memory_core.py
@@ -29,7 +29,7 @@ from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
-PLUGIN_VERSION = "0.4.0"
+PLUGIN_VERSION = "0.3.2"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
diff --git a/integrations/antigravity-plugin/core/session_handoff.py b/integrations/antigravity-plugin/core/session_handoff.py
index 4aada69e6..b7108f336 100644
--- a/integrations/antigravity-plugin/core/session_handoff.py
+++ b/integrations/antigravity-plugin/core/session_handoff.py
@@ -1,7 +1,81 @@
#!/usr/bin/env python3
-"""Import an explicit native session or neutral handoff bundle into Codex."""
+"""Run the shared handoff engine locally or from its verified immutable cache."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import sys
+import tempfile
+from pathlib import Path
+from urllib.request import urlopen
+
+ENGINE_FILES = {"claude_to_codex.py", "handoff_sources.py"}
+SOURCE_URL = "https://raw.githubusercontent.com/mem0ai/mem0"
+
+
+def _verified(path: Path, digest: str) -> bool:
+ try:
+ return hashlib.sha256(path.read_bytes()).hexdigest() == digest
+ except FileNotFoundError:
+ return False
+
+
+def runtime_root(launcher_dir: Path | None = None) -> Path:
+ here = launcher_dir or Path(__file__).resolve().parent
+ if all((here / name).is_file() for name in ENGINE_FILES):
+ return here # The canonical development checkout already has both engines.
+ manifest = json.loads((here / "handoff-runtime.json").read_text(encoding="utf-8"))
+ if not isinstance(manifest, dict):
+ raise ValueError("invalid handoff runtime manifest")
+ revision, files = manifest.get("revision"), manifest.get("files")
+ if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
+ raise ValueError("handoff runtime revision must be an immutable commit SHA")
+ if (
+ not isinstance(files, dict)
+ or set(files) != ENGINE_FILES
+ or any(not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) for digest in files.values())
+ ):
+ raise ValueError("invalid handoff runtime file hashes")
+ cache = Path.home() / ".mem0" / "handoff-runtime" / revision
+ missing = {name: digest for name, digest in files.items() if not _verified(cache / name, digest)}
+ if not missing:
+ return cache
+ cache.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with tempfile.TemporaryDirectory(prefix=f".{revision}-", dir=cache.parent) as temporary:
+ staged = Path(temporary)
+ for name, digest in missing.items():
+ url = f"{SOURCE_URL}/{revision}/integrations/agent-plugin-core/python/{name}"
+ try:
+ with urlopen(url, timeout=30) as response:
+ body = response.read()
+ except OSError as exc:
+ raise OSError(f"could not download pinned handoff runtime {name}: {exc}") from exc
+ if hashlib.sha256(body).hexdigest() != digest:
+ raise ValueError(f"SHA256 mismatch for pinned handoff runtime {name}; refusing to execute it")
+ (staged / name).write_bytes(body)
+ # All downloads are verified before publishing; each replacement is atomic.
+ cache.mkdir(exist_ok=True, mode=0o700)
+ for name in missing:
+ os.replace(staged / name, cache / name)
+ if not all(_verified(cache / name, digest) for name, digest in files.items()):
+ raise ValueError("handoff runtime cache changed during installation; refusing to execute it")
+ return cache
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ root = runtime_root()
+ except (OSError, ValueError) as exc:
+ print(f"handoff runtime unavailable: {exc}", file=sys.stderr)
+ return 1
+ sys.path.insert(0, str(root))
+ from claude_to_codex import main as engine_main
+
+ return engine_main(argv, default_source=None)
-from claude_to_codex import main
if __name__ == "__main__":
- raise SystemExit(main(default_source=None))
+ raise SystemExit(main())
diff --git a/integrations/antigravity-plugin/plugin-build.json b/integrations/antigravity-plugin/plugin-build.json
index 481ad7f15..b31870f24 100644
--- a/integrations/antigravity-plugin/plugin-build.json
+++ b/integrations/antigravity-plugin/plugin-build.json
@@ -1,6 +1,6 @@
{
"id": "mem0",
- "version": "0.4.0",
+ "version": "0.3.2",
"homepage": "https://docs.mem0.ai/integrations/antigravity",
"native": {
"pluginRoot": "${ANTIGRAVITY_PLUGIN_ROOT}",
diff --git a/integrations/antigravity-plugin/skills/handoff/SKILL.md b/integrations/antigravity-plugin/skills/handoff/SKILL.md
index 881bc1c1b..4e4aec2aa 100644
--- a/integrations/antigravity-plugin/skills/handoff/SKILL.md
+++ b/integrations/antigravity-plugin/skills/handoff/SKILL.md
@@ -9,7 +9,9 @@ allowed-tools: Bash(python3 ${ANTIGRAVITY_PLUGIN_ROOT}/core/session_handoff.py *
All hosts share one local import engine. Native readers and SDK adapters supply
complete conversation items; Mem0 memory capture is not a transcript source.
-Requires Python 3.11+ and a Codex CLI with native session import support. The
+Requires Python 3.11+ and a Codex CLI with native session import support.
+First use downloads a pinned, hash-verified runtime from GitHub; later uses share
+the verified local cache. No transcript is sent to GitHub. The
supported destination is Codex. This does not transfer files or change branches.
Visible conversation, tool history, and supported source compaction summaries
diff --git a/integrations/claude-code-plugin/.claude-plugin/plugin.json b/integrations/claude-code-plugin/.claude-plugin/plugin.json
index 1deca3927..0dd8298e9 100644
--- a/integrations/claude-code-plugin/.claude-plugin/plugin.json
+++ b/integrations/claude-code-plugin/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "mem0",
- "version": "0.4.0",
+ "version": "0.3.2",
"description": "Cross-session memory and token savings for coding agents.",
"author": {
"name": "Mem0"
diff --git a/integrations/claude-code-plugin/CHANGELOG.md b/integrations/claude-code-plugin/CHANGELOG.md
new file mode 100644
index 000000000..9f25999e7
--- /dev/null
+++ b/integrations/claude-code-plugin/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+## 0.3.2
+
+- `/mem0:handoff codex` reads the current Claude session before model invocation. Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
+- Lightened search prompts: search when prior work may help; repeat only for a specific gap.
diff --git a/integrations/claude-code-plugin/README.md b/integrations/claude-code-plugin/README.md
index f5e81b86c..77f632147 100644
--- a/integrations/claude-code-plugin/README.md
+++ b/integrations/claude-code-plugin/README.md
@@ -4,7 +4,7 @@ Persistent cross-session memory for Claude Code, plus a Sonnet sidekick agent fo
Claude Code forgets everything between sessions. This plugin fixes that: hooks capture session details locally, a background worker turns them into Mem0 memories, and Claude automatically gets the relevant ones back at the start of later sessions.
-Current bundle version: `0.4.0`.
+Current bundle version: `0.3.2`.
## Prerequisites
@@ -103,7 +103,7 @@ Categories for `--category`: `project_knowledge`, `decisions_and_constraints`, `
## Session handoff
-Run `/mem0:handoff codex` to transfer the current Claude session into a new Codex task with its title, project, and active conversation. Requires Python 3.11+ and a locally installed, signed-in Codex CLI. See the [handoff guide](../../docs/integrations/session-handoff.mdx) for transfer limits and recovery.
+Run `/mem0:handoff codex` to transfer the current Claude session into a new Codex task with its title, project, and active conversation. Requires Python 3.11+ and a locally installed, signed-in Codex CLI. See the [handoff guide](../agent-plugin-core/README.md#session-handoff) for transfer limits and recovery.
## Search scope
diff --git a/integrations/claude-code-plugin/core/claude_to_codex.py b/integrations/claude-code-plugin/core/claude_to_codex.py
deleted file mode 100644
index fd372e198..000000000
--- a/integrations/claude-code-plugin/core/claude_to_codex.py
+++ /dev/null
@@ -1,1492 +0,0 @@
-#!/usr/bin/env python3
-"""Shared local session-handoff engine and backwards-compatible Claude CLI.
-
-Native readers and SDK adapters supply complete conversation items. This engine
-validates and exports their bundles, then uses Codex's native external-session
-importer to create a task with visible historical turns. The legacy command
-still defaults to reading a Claude Code transcript; session_handoff.py requires
-an explicit source host or a neutral bundle.
-
-No model generates a handoff summary. Large imports may use Codex's native
-compaction before the new task is returned.
-"""
-
-# Adapted from mem0ai/memo at aeeb1593284d1d2fca3b4bcf1e32ea10f71df549 (Apache-2.0).
-from __future__ import annotations
-
-import argparse
-import base64
-import binascii
-import hashlib
-import html
-import json
-import os
-import queue
-import re
-import shutil
-import subprocess
-import sys
-import tempfile
-import threading
-import time
-from dataclasses import asdict, dataclass, replace
-from pathlib import Path
-from typing import Any, Iterable
-
-FORMAT_VERSION = "mem0.session-handoff.v1"
-DEFAULT_CODEX_HOME = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
-DEFAULT_BUNDLE_DIR = Path.home() / ".mem0" / "handoffs"
-IMPORT_COMPLETED_NOTIFICATION = "externalAgentConfig/import/completed"
-IMAGE_EXTENSIONS = {
- "image/gif": "gif",
- "image/jpeg": "jpg",
- "image/png": "png",
- "image/webp": "webp",
-}
-
-
-class HandoffError(RuntimeError):
- """A source session cannot be transferred without losing state."""
-
-
-@dataclass(frozen=True)
-class SourceInfo:
- path: str
- sha256: str
- session_id: str
- title: str
- cwd: str
- leaf_uuid: str
- compact_boundary_uuid: str | None
- first_imported_uuid: str
- last_imported_uuid: str
- codex_cwd: str | None = None
- host: str = "claude-code"
-
-
-@dataclass
-class HandoffPlan:
- source: SourceInfo
- items: list[dict[str, Any]]
- source_records: int
- active_records: int
- imported_records: int
- hidden_reasoning_blocks_skipped: int
- approximate_tokens: int
- warnings: list[str]
-
- def bundle(self) -> dict[str, Any]:
- return {
- "format": FORMAT_VERSION,
- "source": asdict(self.source),
- "items": self.items,
- "counts": {
- "source_records": self.source_records,
- "active_records": self.active_records,
- "imported_records": self.imported_records,
- "responses_items": len(self.items),
- "hidden_reasoning_blocks_skipped": self.hidden_reasoning_blocks_skipped,
- "approximate_tokens": self.approximate_tokens,
- },
- "warnings": self.warnings,
- }
-
-
-@dataclass(frozen=True)
-class CodexContextLimits:
- model: str
- context_window: int
- usable_context_window: int
- auto_compact_token_limit: int
- max_context_window: int
- max_usable_context_window: int
- max_auto_compact_token_limit: int
-
-
-def _stable_jsonl(path: Path) -> tuple[list[dict[str, Any]], str]:
- before = path.stat()
- raw = path.read_bytes()
- after = path.stat()
- if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
- raise HandoffError(f"Source session changed while it was being read: {path}")
- if raw and not raw.endswith(b"\n"):
- raise HandoffError(
- "The final JSONL record is incomplete. Finish or stop the active source response before transferring it."
- )
-
- records: list[dict[str, Any]] = []
- for line_number, line in enumerate(raw.splitlines(), 1):
- if not line.strip():
- continue
- try:
- record = json.loads(line)
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid source JSONL at {path}:{line_number}: {exc}") from exc
- if not isinstance(record, dict):
- raise HandoffError(f"Source JSONL record is not an object at {path}:{line_number}.")
- records.append(record)
- if not records:
- raise HandoffError(f"Source session is empty: {path}")
- return records, hashlib.sha256(raw).hexdigest()
-
-
-def _resolve_session(value: str, projects_dir: Path) -> Path:
- supplied = Path(value).expanduser()
- if supplied.is_file():
- return supplied.resolve()
-
- matches = list(projects_dir.glob(f"*/{value}.jsonl"))
- if not matches:
- raise HandoffError(
- f"No Claude session named {value!r} exists below {projects_dir}. "
- "Pass the session ID or its full JSONL path."
- )
- if len(matches) != 1:
- joined = "\n".join(f" {path}" for path in matches)
- raise HandoffError(f"Session ID {value!r} is ambiguous:\n{joined}")
- return matches[0].resolve()
-
-
-def _active_chain(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
- with_uuid = [
- record for record in records if isinstance(record.get("uuid"), str) and record.get("isSidechain") is not True
- ]
- if not with_uuid:
- raise HandoffError("Claude session has no main-agent conversation records.")
-
- by_uuid = {record["uuid"]: record for record in with_uuid}
- leaf = with_uuid[-1]
- chain: list[dict[str, Any]] = []
- seen: set[str] = set()
- current: dict[str, Any] | None = leaf
- while current is not None:
- uuid = current["uuid"]
- if uuid in seen:
- raise HandoffError(f"Claude session contains a parent cycle at {uuid}.")
- seen.add(uuid)
- chain.append(current)
- parent_uuid = current.get("parentUuid")
- if parent_uuid is None:
- break
- current = by_uuid.get(parent_uuid)
- if current is None:
- raise HandoffError(f"Claude's active branch references missing parent {parent_uuid}.")
- chain.reverse()
- return chain
-
-
-def _after_latest_compaction(
- chain: list[dict[str, Any]],
-) -> tuple[list[dict[str, Any]], str | None]:
- compact_index: int | None = None
- for index, record in enumerate(chain):
- if record.get("type") == "system" and record.get("subtype") == "compact_boundary":
- compact_index = index
- if compact_index is None:
- imported = chain
- compact_uuid = None
- else:
- imported = chain[compact_index + 1 :]
- compact_uuid = chain[compact_index]["uuid"]
- if not imported or imported[0].get("isCompactSummary") is not True:
- raise HandoffError(f"Claude compaction {compact_uuid} has no following compact summary.")
- imported = [record for record in imported if record.get("type") != "system"]
- if not imported:
- raise HandoffError("Claude's active state contains no transferable records.")
- return imported, compact_uuid
-
-
-def _tool_result_ids(record: dict[str, Any]) -> set[str]:
- if record.get("type") != "user":
- return set()
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- return set()
- return {
- str(block["tool_use_id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("tool_use_id")
- }
-
-
-def _tool_call_ids(records: list[dict[str, Any]]) -> set[str]:
- call_ids: set[str] = set()
- for record in records:
- if record.get("type") != "assistant":
- continue
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- continue
- call_ids.update(
- str(block["id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id")
- )
- return call_ids
-
-
-def _merge_parallel_tool_results(
- active_records: list[dict[str, Any]], all_records: list[dict[str, Any]]
-) -> list[dict[str, Any]]:
- """Restore sibling tool results that Claude stores outside the parent chain.
-
- Parallel Claude tool calls form a fork: later calls remain on the parent
- chain, while earlier results can be sibling records. Claude sends all of
- those results back to the model. Insert them together immediately after the
- assistant response that issued the calls.
- """
- results_by_call: dict[str, list[tuple[int, dict[str, Any]]]] = {}
- for source_index, record in enumerate(all_records):
- for call_id in _tool_result_ids(record):
- results_by_call.setdefault(call_id, []).append((source_index, record))
-
- merged: list[dict[str, Any]] = []
- inserted_result_uuids: set[str] = set()
- index = 0
- while index < len(active_records):
- record = active_records[index]
- record_uuid = str(record.get("uuid") or "")
- if record_uuid in inserted_result_uuids:
- index += 1
- continue
- if record.get("type") != "assistant":
- merged.append(record)
- index += 1
- continue
-
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(active_records):
- candidate = active_records[index]
- candidate_id = (candidate.get("message") or {}).get("id")
- if candidate.get("type") != "assistant" or not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- merged.extend(group)
-
- matching_results: list[tuple[int, dict[str, Any]]] = []
- for call_id in _tool_call_ids(group):
- matching_results.extend(results_by_call.get(call_id, []))
- for _, result in sorted(matching_results, key=lambda pair: pair[0]):
- result_uuid = str(result.get("uuid") or "")
- if result_uuid and result_uuid not in inserted_result_uuids:
- merged.append(result)
- inserted_result_uuids.add(result_uuid)
- return merged
-
-
-def _image_payload(source: Any, context: str) -> tuple[str, str]:
- if not isinstance(source, dict) or source.get("type") != "base64":
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = str(source.get("media_type") or "").lower()
- data = source.get("data")
- if media_type not in IMAGE_EXTENSIONS or not isinstance(data, str) or not data:
- raise HandoffError(f"{context} has an unsupported or missing image type.")
- return media_type, data
-
-
-def _data_url_payload(image_url: Any, context: str) -> tuple[str, str]:
- if not isinstance(image_url, str):
- raise HandoffError(f"{context} has no transferable image data.")
- match = re.fullmatch(r"data:([^;,]+);base64,(.+)", image_url, flags=re.DOTALL)
- if not match:
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = match.group(1).lower()
- if media_type not in IMAGE_EXTENSIONS:
- raise HandoffError(f"{context} has unsupported image type {media_type!r}.")
- return media_type, match.group(2)
-
-
-def _save_image(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> Path:
- try:
- payload = base64.b64decode(encoded, validate=True)
- except (binascii.Error, ValueError) as exc:
- raise HandoffError(f"{context} contains invalid base64 image data.") from exc
- if not payload:
- raise HandoffError(f"{context} contains an empty image.")
-
- digest = hashlib.sha256(payload).hexdigest()
- asset_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
- path = asset_dir / f"{digest}.{IMAGE_EXTENSIONS[media_type]}"
- if path.exists():
- if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
- raise HandoffError(f"The existing handoff image is corrupted: {path}")
- return path
-
- descriptor, filename = tempfile.mkstemp(prefix=f".{path.name}.", dir=asset_dir)
- temporary = Path(filename)
- try:
- with os.fdopen(descriptor, "wb") as stream:
- stream.write(payload)
- os.replace(temporary, path)
- except OSError as exc:
- temporary.unlink(missing_ok=True)
- raise HandoffError(f"Could not save the handoff image at {path}: {exc}") from exc
- return path
-
-
-def _image_reference(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> str:
- path = _save_image(media_type, encoded, asset_dir, context)
- return f"[Image saved at {path}]"
-
-
-def _tool_result_text(value: Any, asset_dir: Path, context: str) -> str:
- if value is None:
- return ""
- if isinstance(value, str):
- return value
- if isinstance(value, (int, float, bool)):
- return str(value)
- if isinstance(value, list):
- parts: list[str] = []
- for part in value:
- if isinstance(part, dict) and part.get("type") == "text":
- parts.append(str(part.get("text", "")))
- elif isinstance(part, dict) and part.get("type") == "image":
- media_type, encoded = _image_payload(part.get("source"), context)
- parts.append(_image_reference(media_type, encoded, asset_dir, context))
- else:
- parts.append(json.dumps(part, ensure_ascii=False, separators=(",", ":")))
- return "\n".join(part for part in parts if part)
- if isinstance(value, dict) and value.get("type") == "image":
- media_type, encoded = _image_payload(value.get("source"), context)
- return _image_reference(media_type, encoded, asset_dir, context)
- return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
-
-
-def _message(role: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
- return {"type": "message", "role": role, "content": parts}
-
-
-def _attachment_item(record: dict[str, Any]) -> dict[str, Any] | None:
- attachment = record.get("attachment")
- if not isinstance(attachment, dict):
- raise HandoffError(f"Claude attachment {record.get('uuid')} has no payload.")
-
- attachment_type = attachment.get("type")
- filename = str(attachment.get("filename") or attachment.get("displayPath") or "unknown")
- content = attachment.get("content")
- if attachment_type == "file" and isinstance(content, dict):
- file_payload = content.get("file") if content.get("type") == "text" else None
- if isinstance(file_payload, dict) and isinstance(file_payload.get("content"), str):
- text = file_payload["content"]
- display = str(file_payload.get("filePath") or filename)
- wrapped = f'\n{text}\n'
- return _message("user", [{"type": "input_text", "text": wrapped}])
-
- if attachment_type == "image" and isinstance(content, dict):
- image_url = content.get("image_url") or content.get("data")
- if isinstance(image_url, str) and image_url.startswith("data:"):
- return _message("user", [{"type": "input_image", "image_url": image_url}])
-
- if attachment_type in {"file", "image"}:
- raise HandoffError(f"Claude {attachment_type} attachment {record.get('uuid')} has an unsupported payload.")
-
- # Claude also records its own skill list, tool availability, permissions,
- # token reminders, hooks, and task status as attachments. Those configure
- # Claude's harness; they are not part of the user's project conversation and
- # must not become user messages in Codex.
- return None
-
-
-def _assistant_items(records: list[dict[str, Any]], calls: dict[str, str]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- skipped_reasoning = 0
- text_parts: list[dict[str, Any]] = []
-
- def flush_text() -> None:
- if text_parts:
- items.append(_message("assistant", list(text_parts)))
- text_parts.clear()
-
- for record in records:
- content = (record.get("message") or {}).get("content", [])
- if isinstance(content, str):
- text_parts.append({"type": "output_text", "text": content})
- continue
- if not isinstance(content, list):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid content.")
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "thinking" or kind == "redacted_thinking":
- skipped_reasoning += 1
- continue
- if kind == "text":
- text_parts.append({"type": "output_text", "text": str(block.get("text", ""))})
- continue
- if kind == "tool_use":
- flush_text()
- call_id = str(block.get("id") or "")
- name = str(block.get("name") or "")
- if not call_id or not name:
- raise HandoffError(f"Claude tool call in {record.get('uuid')} has no ID or name.")
- if call_id in calls:
- raise HandoffError(f"Claude tool call ID is duplicated: {call_id}")
- calls[call_id] = name
- items.append(
- {
- "type": "function_call",
- "call_id": call_id,
- "name": name,
- "arguments": json.dumps(
- block.get("input", {}),
- ensure_ascii=False,
- separators=(",", ":"),
- ),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude assistant block {kind!r} in {record.get('uuid')}.")
- flush_text()
- return items, skipped_reasoning
-
-
-def _user_items(record: dict[str, Any], calls: dict[str, str], completed_calls: set[str]) -> list[dict[str, Any]]:
- if record.get("isMeta") is True:
- return []
- content = (record.get("message") or {}).get("content")
- if isinstance(content, str):
- return [_message("user", [{"type": "input_text", "text": content}])]
- if not isinstance(content, list):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid content.")
-
- items: list[dict[str, Any]] = []
- user_parts: list[dict[str, Any]] = []
-
- def flush_user() -> None:
- if user_parts:
- items.append(_message("user", list(user_parts)))
- user_parts.clear()
-
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "text":
- user_parts.append({"type": "input_text", "text": str(block.get("text", ""))})
- continue
- if kind == "image":
- source = block.get("source") or {}
- if source.get("type") == "base64" and source.get("data") and source.get("media_type"):
- user_parts.append(
- {
- "type": "input_image",
- "image_url": f"data:{source['media_type']};base64,{source['data']}",
- }
- )
- continue
- raise HandoffError(f"Claude image in {record.get('uuid')} is not stored as transferable base64 data.")
- if kind == "tool_result":
- flush_user()
- call_id = str(block.get("tool_use_id") or "")
- if not call_id:
- raise HandoffError(f"Claude tool result in {record.get('uuid')} has no call ID.")
- if call_id not in calls:
- raise HandoffError(f"Claude tool result {call_id} has no matching call in the active state.")
- if call_id in completed_calls:
- raise HandoffError(f"Claude tool result is duplicated: {call_id}")
- completed_calls.add(call_id)
- items.append(
- {
- "type": "function_call_output",
- "call_id": call_id,
- "name": calls[call_id],
- "output": block.get("content"),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude user block {kind!r} in {record.get('uuid')}.")
- flush_user()
- return items
-
-
-def _responses_items(records: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- calls: dict[str, str] = {}
- completed_calls: set[str] = set()
- skipped_reasoning = 0
-
- index = 0
- while index < len(records):
- record = records[index]
- record_type = record.get("type")
- if record_type == "assistant":
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(records):
- candidate = records[index]
- if candidate.get("type") != "assistant":
- break
- candidate_id = (candidate.get("message") or {}).get("id")
- if not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- assistant_items, skipped = _assistant_items(group, calls)
- items.extend(assistant_items)
- skipped_reasoning += skipped
- continue
- if record_type == "user":
- items.extend(_user_items(record, calls, completed_calls))
- elif record_type == "attachment":
- attachment_item = _attachment_item(record)
- if attachment_item is not None:
- items.append(attachment_item)
- elif record_type not in {"system"}:
- raise HandoffError(f"Unsupported model-visible Claude record {record_type!r} at {record.get('uuid')}.")
- index += 1
-
- unfinished = sorted(set(calls) - completed_calls)
- if unfinished:
- joined = ", ".join(unfinished[:5])
- raise HandoffError(
- f"Claude's active state ends with unfinished tool call(s): {joined}. "
- "Finish or stop the Claude turn before transferring it."
- )
- if not items:
- raise HandoffError("Claude's active state produced no Codex history items.")
- return items, skipped_reasoning
-
-
-def _without_image_payloads(value: Any) -> Any:
- if isinstance(value, list):
- return [_without_image_payloads(item) for item in value]
- if not isinstance(value, dict):
- return value
-
- cleaned = {key: _without_image_payloads(item) for key, item in value.items()}
- if cleaned.get("type") == "input_image" and isinstance(cleaned.get("image_url"), str):
- cleaned["image_url"] = "[Image saved locally during handoff]"
- if cleaned.get("type") == "image" and isinstance(cleaned.get("source"), dict):
- source = dict(cleaned["source"])
- if source.get("type") == "base64" and "data" in source:
- source["data"] = "[Image saved locally during handoff]"
- cleaned["source"] = source
- return cleaned
-
-
-def _token_count(value: Any) -> int:
- text = json.dumps(_without_image_payloads(value), ensure_ascii=False, separators=(",", ":"))
- try:
- import tiktoken
-
- return len(tiktoken.get_encoding("o200k_base").encode(text))
- except ImportError:
- return (len(text) + 3) // 4
-
-
-def build_plan(session: str, projects_dir: Path) -> HandoffPlan:
- path = _resolve_session(session, projects_dir)
- records, sha256 = _stable_jsonl(path)
- chain = _active_chain(records)
- imported, compact_uuid = _after_latest_compaction(chain)
- imported = _merge_parallel_tool_results(imported, records)
- items, skipped_reasoning = _responses_items(imported)
-
- session_id = next(
- (str(record["sessionId"]) for record in reversed(records) if record.get("sessionId")),
- path.stem,
- )
- title = next(
- (
- str(record["customTitle"])
- for record in reversed(records)
- if record.get("type") == "custom-title" and record.get("customTitle")
- ),
- f"Claude session {session_id[:8]}",
- )
- cwd = next(
- (str(record["cwd"]) for record in chain if record.get("cwd")),
- "",
- )
- if not cwd:
- raise HandoffError("Claude session does not record its working directory.")
-
- warnings: list[str] = []
- if skipped_reasoning:
- warnings.append(f"Skipped {skipped_reasoning} Claude hidden-reasoning block(s); they are not portable.")
-
- source = SourceInfo(
- path=str(path),
- sha256=sha256,
- session_id=session_id,
- title=title,
- cwd=str(Path(cwd).resolve()),
- leaf_uuid=chain[-1]["uuid"],
- compact_boundary_uuid=compact_uuid,
- first_imported_uuid=imported[0]["uuid"],
- last_imported_uuid=imported[-1]["uuid"],
- codex_cwd=_git_root(Path(cwd)),
- )
- return HandoffPlan(
- source=source,
- items=items,
- source_records=len(records),
- active_records=len(chain),
- imported_records=len(imported),
- hidden_reasoning_blocks_skipped=skipped_reasoning,
- approximate_tokens=_token_count(items),
- warnings=warnings,
- )
-
-
-def _write_private(path: Path, body: str) -> None:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
- try:
- with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
- stream.write(body)
- os.replace(temporary, path)
- finally:
- Path(temporary).unlink(missing_ok=True)
-
-
-def write_bundle(plan: HandoffPlan, path: Path) -> Path:
- path = path.expanduser().resolve()
- _write_private(path, json.dumps(plan.bundle(), ensure_ascii=False))
- return path
-
-
-def _validate_items(items: Any) -> None:
- if not isinstance(items, list) or not items:
- raise HandoffError("Handoff bundle contains no history items.")
- calls: dict[str, str] = {}
- completed: set[str] = set()
- saw_user = False
- for item in items:
- if not isinstance(item, dict):
- raise HandoffError("Invalid handoff history item.")
- kind = item.get("type")
- status = item.get("status")
- if (
- not isinstance(kind, str)
- or (status is not None and not isinstance(status, str))
- or status in {"incomplete", "in_progress"}
- ):
- raise HandoffError("Invalid or incomplete handoff history item.")
- if kind == "message":
- role = item.get("role")
- parts = item.get("content")
- if (
- not isinstance(role, str)
- or role not in {"user", "assistant"}
- or not isinstance(parts, list)
- or not parts
- ):
- raise HandoffError("Invalid handoff message role or content.")
- saw_user = saw_user or role == "user"
- for part in parts:
- if not isinstance(part, dict) or not isinstance(part.get("type"), str):
- raise HandoffError("Invalid handoff message part.")
- if part.get("type") in {"input_text", "output_text"} and isinstance(part.get("text"), str):
- continue
- if part.get("type") == "input_image":
- _, encoded = _data_url_payload(part.get("image_url"), "Handoff image")
- try:
- if not base64.b64decode(encoded, validate=True):
- raise ValueError("empty image")
- except (ValueError, binascii.Error) as exc:
- raise HandoffError("Invalid handoff image data.") from exc
- continue
- raise HandoffError("Unsupported handoff message part.")
- elif kind == "function_call":
- call_id, name, arguments = item.get("call_id"), item.get("name"), item.get("arguments")
- if not isinstance(call_id, str) or not call_id or not isinstance(name, str) or not name:
- raise HandoffError("Invalid handoff tool call ID or name.")
- if call_id in calls or not isinstance(arguments, str):
- raise HandoffError("Duplicate or invalid handoff tool call.")
- try:
- json.loads(arguments)
- except json.JSONDecodeError as exc:
- raise HandoffError("Handoff tool arguments are not JSON.") from exc
- calls[call_id] = name
- elif kind == "function_call_output":
- call_id = item.get("call_id")
- if not isinstance(call_id, str) or call_id not in calls or call_id in completed:
- raise HandoffError("Unmatched or duplicate handoff tool result.")
- if "output" not in item:
- raise HandoffError("Handoff tool result has no output.")
- completed.add(call_id)
- item.setdefault("name", calls[call_id])
- else:
- raise HandoffError(f"Unsupported handoff item type: {kind!r}.")
- if set(calls) != completed:
- raise HandoffError("Session has unfinished tool calls; finish or stop the source turn before handoff.")
- if not saw_user:
- raise HandoffError("Handoff contains no user message.")
-
-
-def plan_from_bundle(payload: Any) -> HandoffPlan:
- formats = {FORMAT_VERSION, "mem0.claude-to-codex.v1", "memo.claude-to-codex.v1"}
- if not isinstance(payload, dict) or not isinstance(payload.get("format"), str) or payload["format"] not in formats:
- raise HandoffError("Unsupported handoff bundle format.")
- source_payload = payload.get("source")
- items = payload.get("items")
- warnings = payload.get("warnings", [])
- if not isinstance(source_payload, dict) or not isinstance(warnings, list):
- raise HandoffError("Handoff bundle has no source or has invalid warnings.")
- _validate_items(items)
- try:
- fields = dict(source_payload)
- legacy = payload["format"] != FORMAT_VERSION
- fields.setdefault("host", "claude-code" if legacy else "")
- for key in ("host", "session_id", "title", "cwd"):
- if not isinstance(fields.get(key), str) or not fields[key].strip():
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[a-z][a-z0-9-]*", fields["host"]):
- raise ValueError("invalid source host")
- fields.setdefault("path", f"{fields['host']}:{fields['session_id']}")
- fields.setdefault("sha256", hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest())
- fields.setdefault("leaf_uuid", str(len(items)))
- fields.setdefault("first_imported_uuid", "1")
- fields.setdefault("last_imported_uuid", str(len(items)))
- fields.setdefault("compact_boundary_uuid", None)
- source = SourceInfo(**fields)
- for key, value in asdict(source).items():
- if value is None and key in {"codex_cwd", "compact_boundary_uuid"}:
- continue
- if not isinstance(value, str):
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[0-9a-f]{64}", source.sha256):
- raise ValueError("invalid source digest")
- counts = payload.get("counts", {})
- return HandoffPlan(
- source=source,
- items=items,
- source_records=int(counts.get("source_records", len(items))),
- active_records=int(counts.get("active_records", len(items))),
- imported_records=int(counts.get("imported_records", len(items))),
- hidden_reasoning_blocks_skipped=int(counts.get("hidden_reasoning_blocks_skipped", 0)),
- approximate_tokens=_token_count(items),
- warnings=[str(warning) for warning in warnings],
- )
- except (KeyError, TypeError, ValueError, AttributeError) as exc:
- raise HandoffError("Handoff bundle is incomplete or has invalid source fields.") from exc
-
-
-def load_bundle(path: Path) -> HandoffPlan:
- try:
- text = sys.stdin.read() if str(path) == "-" else path.expanduser().resolve().read_text(encoding="utf-8")
- return plan_from_bundle(json.loads(text))
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid handoff bundle JSON: {path}") from exc
-
-
-def _git_root(cwd: Path) -> str | None:
- completed = subprocess.run(
- ["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
- text=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- check=False,
- )
- if completed.returncode != 0:
- return None
- root = Path(completed.stdout.strip()).resolve()
- return str(root) if root.is_dir() else None
-
-
-def _with_cwd(plan: HandoffPlan, cwd: Path | None) -> HandoffPlan:
- source_cwd = Path(plan.source.cwd).expanduser().resolve()
- target = (
- cwd.expanduser().resolve()
- if cwd
- else Path(plan.source.codex_cwd).expanduser().resolve()
- if plan.source.codex_cwd
- else Path(_git_root(source_cwd) or source_cwd)
- )
- if not target.is_dir():
- raise HandoffError(f"Codex working directory does not exist: {target}")
- return replace(plan, source=replace(plan.source, cwd=str(source_cwd), codex_cwd=str(target)))
-
-
-def _codex_cwd(plan: HandoffPlan) -> str:
- return plan.source.codex_cwd or plan.source.cwd
-
-
-def _default_bundle_path(plan: HandoffPlan) -> Path:
- session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip(".-")[:80] or "session"
- name = f"{session}-{plan.source.sha256[:12]}.json"
- return DEFAULT_BUNDLE_DIR / name
-
-
-def _codex_context_limits(codex_home: Path) -> CodexContextLimits:
- try:
- import tomllib
- except ImportError as exc:
- raise HandoffError("Creating a Codex task requires Python 3.11 or newer; rerun with python3.11.") from exc
-
- codex_home = codex_home.expanduser().resolve()
- config_path = codex_home / "config.toml"
- cache_path = codex_home / "models_cache.json"
- try:
- config = tomllib.loads(config_path.read_text(encoding="utf-8"))
- except (OSError, tomllib.TOMLDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex configuration at {config_path}: {exc}") from exc
- try:
- cache = json.loads(cache_path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex model metadata at {cache_path}: {exc}") from exc
-
- model = str(config.get("model") or "")
- models = cache.get("models") if isinstance(cache, dict) else None
- if not isinstance(models, list):
- raise HandoffError(f"Codex model metadata has no model list: {cache_path}")
- model_info = next(
- (
- item
- for item in models
- if isinstance(item, dict)
- and (
- item.get("slug") == model
- or item.get("model") == model
- or (not model and item.get("is_default") is True)
- )
- ),
- None,
- )
- if not isinstance(model_info, dict):
- raise HandoffError(f"Codex model {model!r} is missing from {cache_path}; refresh Codex's model list.")
- model = str(model_info.get("slug") or model_info.get("model") or model)
-
- cached_context = model_info.get("context_window")
- cached_max = model_info.get("max_context_window") or cached_context
- if not isinstance(cached_context, int) or not isinstance(cached_max, int):
- raise HandoffError(f"Codex model {model!r} does not report its context limits.")
- configured_context = config.get("model_context_window")
- context_window = min(configured_context, cached_max) if isinstance(configured_context, int) else cached_context
- effective_percent = model_info.get("effective_context_window_percent", 95)
- if not isinstance(effective_percent, int) or not 1 <= effective_percent <= 100:
- raise HandoffError(f"Codex model {model!r} reports an invalid effective context percentage.")
-
- context_auto_limit = context_window * 9 // 10
- configured_auto_limit = config.get("model_auto_compact_token_limit")
- auto_compact_limit = (
- min(configured_auto_limit, context_auto_limit) if isinstance(configured_auto_limit, int) else context_auto_limit
- )
- return CodexContextLimits(
- model=model,
- context_window=context_window,
- usable_context_window=context_window * effective_percent // 100,
- auto_compact_token_limit=auto_compact_limit,
- max_context_window=cached_max,
- max_usable_context_window=cached_max * effective_percent // 100,
- max_auto_compact_token_limit=cached_max * 9 // 10,
- )
-
-
-class CodexAppServer:
- """Small JSON-RPC client for a one-off local Codex app-server process."""
-
- def __init__(
- self,
- codex_bin: str = "codex",
- context_window_override: int | None = None,
- codex_home: Path = DEFAULT_CODEX_HOME,
- ) -> None:
- resolved = shutil.which(codex_bin)
- if not resolved:
- raise HandoffError(f"Codex executable not found: {codex_bin}")
- command = [resolved]
- if context_window_override is not None:
- command.extend(["-c", f"model_context_window={context_window_override}"])
- command.extend(["app-server", "--stdio"])
- self.process = subprocess.Popen(
- command,
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- bufsize=1,
- env={**os.environ, "CODEX_HOME": str(codex_home.expanduser().resolve())},
- )
- self._responses: queue.Queue[dict[str, Any]] = queue.Queue()
- self._notifications: queue.Queue[dict[str, Any]] = queue.Queue()
- self._stderr: list[str] = []
- self._next_id = 1
- threading.Thread(target=self._read_stdout, daemon=True).start()
- threading.Thread(target=self._read_stderr, daemon=True).start()
- try:
- self.request(
- "initialize",
- {
- "clientInfo": {
- "name": "mem0_session_handoff",
- "title": "Mem0 local session handoff",
- "version": "0.1.0",
- }
- },
- )
- self.notify("initialized", {})
- except Exception:
- self.close()
- raise
-
- def _read_stdout(self) -> None:
- assert self.process.stdout is not None
- for line in self.process.stdout:
- try:
- message = json.loads(line)
- except json.JSONDecodeError:
- continue
- if not isinstance(message, dict):
- continue
- if "id" in message:
- self._responses.put(message)
- elif "method" in message:
- self._notifications.put(message)
-
- def _read_stderr(self) -> None:
- assert self.process.stderr is not None
- for line in self.process.stderr:
- self._stderr.append(line.rstrip())
-
- def _send(self, payload: dict[str, Any]) -> None:
- if self.process.poll() is not None:
- error = "\n".join(self._stderr[-20:])
- raise HandoffError(f"Codex app-server stopped unexpectedly.\n{error}")
- assert self.process.stdin is not None
- self.process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
- self.process.stdin.flush()
-
- def request(self, method: str, params: dict[str, Any], timeout: float = 30) -> Any:
- request_id = self._next_id
- self._next_id += 1
- self._send({"method": method, "id": request_id, "params": params})
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out on {method}.")
- try:
- response = self._responses.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out on {method}.") from exc
- if response.get("id") != request_id:
- deferred.append(response)
- continue
- if "error" in response:
- raise HandoffError(f"Codex {method} failed: {response['error']}")
- return response.get("result")
- finally:
- for response in deferred:
- self._responses.put(response)
-
- def notify(self, method: str, params: dict[str, Any]) -> None:
- self._send({"method": method, "params": params})
-
- def wait_for_notification(
- self,
- method: str,
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.") from exc
- if notification.get("method") != method:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def wait_for_any_notification(
- self,
- methods: set[str],
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.") from exc
- if notification.get("method") not in methods:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def close(self) -> None:
- if self.process.poll() is None:
- self.process.terminate()
- try:
- self.process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self.process.kill()
- self.process.wait(timeout=5)
-
- def __enter__(self) -> "CodexAppServer":
- return self
-
- def __exit__(self, *_: Any) -> None:
- self.close()
-
-
-def _item_text(item: dict[str, Any], asset_dir: Path) -> tuple[str, str]:
- """Convert one Responses item to a complete visible import message."""
- item_type = item.get("type")
- if item_type == "message":
- role = str(item.get("role") or "")
- if role not in {"user", "assistant"}:
- raise HandoffError(f"Codex's session importer cannot represent role {role!r}.")
- parts: list[str] = []
- for part in item.get("content") or []:
- if not isinstance(part, dict):
- raise HandoffError("A handoff message contains an invalid content item.")
- part_type = part.get("type")
- if part_type in {"input_text", "output_text"}:
- parts.append(str(part.get("text") or ""))
- elif part_type == "input_image":
- media_type, encoded = _data_url_payload(part.get("image_url"), "A Claude message image")
- parts.append(_image_reference(media_type, encoded, asset_dir, "A Claude message image"))
- else:
- raise HandoffError(f"Codex's session importer cannot represent content type {part_type!r}.")
- text = "\n\n".join(part for part in parts if part)
- if not text:
- raise HandoffError("A handoff message contains no transferable text.")
- return role, text
-
- if item_type == "function_call":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- arguments = str(item.get("arguments") or "{}")
- return (
- "assistant",
- f'\n{arguments}\n',
- )
-
- if item_type == "function_call_output":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- output = _tool_result_text(item.get("output"), asset_dir, f"Claude tool result {call_id}")
- return (
- "assistant",
- f'\n{output}\n',
- )
-
- raise HandoffError(f"Codex's session importer cannot represent item type {item_type!r}.")
-
-
-def _native_import_records(plan: HandoffPlan, asset_dir: Path) -> list[dict[str, Any]]:
- """Build the Claude-shaped history consumed by Codex's native importer."""
- cwd = _codex_cwd(plan)
- records: list[dict[str, Any]] = [
- {
- "type": "custom-title",
- "customTitle": plan.source.title,
- "sessionId": plan.source.session_id,
- }
- ]
- saw_user = False
- for index, item in enumerate(plan.items, 1):
- role, text = _item_text(item, asset_dir)
- saw_user = saw_user or role == "user"
- records.append(
- {
- "type": role,
- "sessionId": plan.source.session_id,
- "uuid": f"mem0-handoff-{index}",
- "cwd": cwd,
- "isSidechain": False,
- "message": {"role": role, "content": text},
- }
- )
- if not saw_user:
- raise HandoffError("The active Claude context contains no user message.")
- return records
-
-
-def _native_import_path(plan: HandoffPlan, claude_projects_dir: Path) -> Path:
- source_key = hashlib.sha256(plan.source.path.encode("utf-8")).hexdigest()[:24]
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- safe_session = safe_session[:80] or source_key
- return claude_projects_dir.expanduser().resolve() / ".mem0-handoffs" / f"{safe_session}-{source_key}.jsonl"
-
-
-def _write_native_import(plan: HandoffPlan, path: Path, asset_dir: Path) -> str:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- body = "".join(
- json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
- for record in _native_import_records(plan, asset_dir)
- )
- _write_private(path, body)
- return hashlib.sha256(body.encode("utf-8")).hexdigest()
-
-
-def _native_import_params(source_path: Path, cwd: str) -> dict[str, Any]:
- return {
- "migrationItems": [
- {
- "itemType": "SESSIONS",
- "description": f"Transfer Claude session {source_path.name}",
- "cwd": None,
- "details": {
- "plugins": [],
- "sessions": [{"path": str(source_path), "cwd": cwd, "title": None}],
- "mcpServers": [],
- "hooks": [],
- "subagents": [],
- "commands": [],
- },
- }
- ]
- }
-
-
-def _thread_id_from_completion(params: Any, source_path: Path) -> str | None:
- if not isinstance(params, dict):
- return None
- canonical = str(source_path.resolve())
- for result in params.get("itemTypeResults") or []:
- if not isinstance(result, dict) or result.get("itemType") != "SESSIONS":
- continue
- for success in result.get("successes") or []:
- if not isinstance(success, dict):
- continue
- if success.get("source") in {None, canonical} and success.get("target"):
- return str(success["target"])
- return None
-
-
-def _thread_id_from_ledger(codex_home: Path, source_path: Path, content_sha256: str) -> str | None:
- ledger_path = codex_home.expanduser() / "external_agent_session_imports.json"
- if not ledger_path.is_file():
- return None
- try:
- ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
- except json.JSONDecodeError:
- return None
- canonical = str(source_path.resolve())
- matches = [
- record
- for record in ledger.get("records", [])
- if isinstance(record, dict)
- and record.get("source_path") == canonical
- and record.get("content_sha256") == content_sha256
- and record.get("imported_thread_id")
- ]
- return str(matches[-1]["imported_thread_id"]) if matches else None
-
-
-def _notification_thread_id(params: Any) -> str | None:
- if not isinstance(params, dict):
- return None
- if params.get("threadId"):
- return str(params["threadId"])
- turn = params.get("turn")
- if isinstance(turn, dict) and turn.get("threadId"):
- return str(turn["threadId"])
- return None
-
-
-def _compact_imported_thread(
- server: CodexAppServer,
- thread_id: str,
-) -> dict[str, Any] | None:
- server.request("thread/resume", {"threadId": thread_id}, timeout=120)
- server.request("thread/compact/start", {"threadId": thread_id}, timeout=30)
-
- latest_usage: dict[str, Any] | None = None
- saw_compaction_item = False
- while True:
- notification = server.wait_for_any_notification(
- {"item/completed", "thread/tokenUsage/updated", "turn/completed", "error"},
- lambda params: _notification_thread_id(params) in {None, thread_id},
- timeout=600,
- )
- method = notification.get("method")
- params = notification.get("params")
- if method == "thread/tokenUsage/updated" and isinstance(params, dict):
- token_usage = params.get("tokenUsage")
- if isinstance(token_usage, dict):
- latest_usage = token_usage
- continue
- if method == "item/completed" and isinstance(params, dict):
- item = params.get("item")
- if isinstance(item, dict) and item.get("type") == "contextCompaction":
- saw_compaction_item = True
- continue
- if method == "error":
- error = params.get("error") if isinstance(params, dict) else params
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if method == "turn/completed" and isinstance(params, dict):
- turn = params.get("turn")
- if not isinstance(turn, dict):
- raise HandoffError("Codex returned an invalid compaction result.")
- if turn.get("status") != "completed":
- error = turn.get("error") or turn.get("status")
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if not saw_compaction_item:
- raise HandoffError("Codex completed the compaction turn without a compaction item.")
- return latest_usage
-
-
-def _set_thread_name(
- server: CodexAppServer,
- thread_id: str,
- name: str,
-) -> None:
- server.request(
- "thread/name/set",
- {"threadId": thread_id, "name": name},
- timeout=30,
- )
-
-
-def create_codex_thread(
- plan: HandoffPlan,
- codex_bin: str = "codex",
- codex_home: Path = DEFAULT_CODEX_HOME,
-) -> dict[str, Any]:
- limits = _codex_context_limits(codex_home)
- should_compact = plan.approximate_tokens >= limits.auto_compact_token_limit
- if should_compact and plan.approximate_tokens >= limits.max_auto_compact_token_limit:
- raise HandoffError(
- f"The active session state is approximately {plan.approximate_tokens:,} tokens. "
- f"Codex cannot safely compact more than approximately "
- f"{limits.max_auto_compact_token_limit:,} tokens in one request. "
- "Compact in the source host and retry the handoff."
- )
-
- # Codex only imports sources staged under its native Claude home.
- source_path = _native_import_path(plan, Path.home() / ".claude" / "projects")
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- asset_dir = (
- codex_home.expanduser().resolve()
- / "external-agent-assets"
- / plan.source.host
- / (safe_session[:80] or "session")
- )
- content_sha256 = _write_native_import(plan, source_path, asset_dir)
- try:
- context_override = limits.max_context_window if should_compact else None
- with CodexAppServer(codex_bin, context_override, codex_home=codex_home) as server:
- response = server.request(
- "externalAgentConfig/import",
- _native_import_params(source_path, _codex_cwd(plan)),
- timeout=120,
- )
- import_id = str((response or {}).get("importId") or "")
- if not import_id:
- raise HandoffError(f"Codex externalAgentConfig/import returned no import ID: {response!r}")
- completed = server.wait_for_notification(
- IMPORT_COMPLETED_NOTIFICATION,
- lambda params: isinstance(params, dict) and params.get("importId") == import_id,
- )
- completed_params = completed.get("params")
- thread_id = _thread_id_from_completion(completed_params, source_path)
- if not thread_id:
- thread_id = _thread_id_from_ledger(codex_home, source_path, content_sha256)
- if not thread_id:
- raise HandoffError(
- "Codex finished importing the session but did not report the new task ID. "
- f"Import result: {json.dumps(completed_params, ensure_ascii=False)}"
- )
-
- read = server.request(
- "thread/read",
- {"threadId": thread_id, "includeTurns": True},
- )
- thread = (read or {}).get("thread") if isinstance(read, dict) else None
- if not isinstance(thread, dict):
- raise HandoffError(f"Codex could not read imported task {thread_id}.")
- turns = thread.get("turns") or []
- preview = str(thread.get("preview") or "")
- if not turns or not preview:
- raise HandoffError(f"Codex imported task {thread_id}, but it has no visible history.")
-
- compaction_usage = _compact_imported_thread(server, thread_id) if should_compact else None
- _set_thread_name(server, thread_id, plan.source.title)
-
- return {
- "thread_id": thread_id,
- "title": plan.source.title,
- "cwd": _codex_cwd(plan),
- "source_session_id": plan.source.session_id,
- "source_host": plan.source.host,
- "visible_turns": len(turns),
- "preview": preview,
- "responses_items_converted": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "target_model": limits.model,
- "target_context_window": limits.context_window,
- "target_usable_context_window": limits.usable_context_window,
- "target_auto_compact_token_limit": limits.auto_compact_token_limit,
- "compacted_before_return": should_compact,
- "compaction_context_window": (limits.max_context_window if should_compact else None),
- "compaction_token_usage": compaction_usage,
- "model_invoked": should_compact,
- }
- finally:
- source_path.unlink(missing_ok=True)
- try:
- source_path.parent.rmdir()
- except OSError:
- pass
-
-
-def _summary(plan: HandoffPlan) -> dict[str, Any]:
- return {
- "source": plan.source.path,
- "source_host": plan.source.host,
- "session_id": plan.source.session_id,
- "title": plan.source.title,
- "source_cwd": plan.source.cwd,
- "codex_cwd": _codex_cwd(plan),
- "leaf_uuid": plan.source.leaf_uuid,
- "compact_boundary_uuid": plan.source.compact_boundary_uuid,
- "source_records": plan.source_records,
- "active_records": plan.active_records,
- "imported_records": plan.imported_records,
- "responses_items": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "warnings": plan.warnings,
- }
-
-
-def _command_output(result: dict[str, Any]) -> str:
- title = str(result["title"])
- cwd = str(result["cwd"])
- project = Path(cwd).name or cwd
- lines = [
- f'Created Codex task "{title}".',
- f"Task ID: {result['thread_id']}",
- f"Project: {project}",
- ]
- if result.get("compacted_before_return"):
- lines.append("Codex compacted the transferred context before opening the task.")
- lines.append(f'Open Codex and select "{title}" under {project}.')
- return "\n".join(lines)
-
-
-def _parse_args(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> argparse.Namespace:
- parser = argparse.ArgumentParser(description=__doc__)
- source = parser.add_mutually_exclusive_group(required=True)
- source.add_argument("--session", help="Native session transcript path (Claude also accepts its session ID)")
- parser.add_argument(
- "--source",
- choices=("claude-code", "cursor", "codex", "kimi", "antigravity", "openclaw", "pi-agent"),
- default=default_source,
- )
- parser.add_argument("--title", help="Override the imported task title")
- source.add_argument("--bundle", type=Path, help="Previously exported handoff bundle")
- parser.add_argument(
- "--claude-projects-dir",
- type=Path,
- default=Path.home() / ".claude" / "projects",
- )
- parser.add_argument("--export", type=Path, help="Write a private reusable handoff bundle")
- parser.add_argument(
- "--cwd",
- type=Path,
- help="Use this existing directory instead of the source session's directory",
- )
- parser.add_argument("--create", action="store_true", help="Create the Codex task")
- parser.add_argument(
- "--target",
- choices=("codex",),
- default="codex",
- help="Destination coding agent",
- )
- parser.add_argument(
- "--command-output",
- action="store_true",
- help="Print the short result used by Mem0's user-facing command",
- )
- parser.add_argument("--codex-bin", default="codex")
- parser.add_argument(
- "--codex-home",
- type=Path,
- default=DEFAULT_CODEX_HOME,
- )
- return parser.parse_args(argv)
-
-
-def main(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> int:
- args = _parse_args(argv, default_source)
- try:
- if args.bundle:
- plan = load_bundle(args.bundle)
- elif args.source == "claude-code":
- plan = build_plan(args.session, args.claude_projects_dir)
- else:
- if not args.source:
- raise HandoffError("--source is required with --session.")
- from handoff_sources import read_source
-
- plan = read_source(args.source, Path(args.session), cwd=args.cwd, title=args.title)
- if args.title:
- plan = replace(plan, source=replace(plan.source, title=args.title))
- plan = _with_cwd(plan, args.cwd)
- output: dict[str, Any] = {"plan": _summary(plan)}
- if args.export:
- output["bundle"] = str(write_bundle(plan, args.export))
- if args.create:
- try:
- output["codex"] = create_codex_thread(
- plan,
- codex_bin=args.codex_bin,
- codex_home=args.codex_home.expanduser(),
- )
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- fallback = args.export or _default_bundle_path(plan)
- saved = write_bundle(plan, fallback)
- raise HandoffError(f"{exc} The complete handoff was saved at {saved}.") from exc
- if args.command_output:
- if not args.create:
- raise HandoffError("--command-output requires --create.")
- print(_command_output(output["codex"]))
- else:
- print(json.dumps(output, indent=2, ensure_ascii=False))
- return 0
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- print(f"handoff failed: {exc}", file=sys.stderr)
- return 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/integrations/claude-code-plugin/core/handoff-runtime.json b/integrations/claude-code-plugin/core/handoff-runtime.json
new file mode 100644
index 000000000..b6b935e8d
--- /dev/null
+++ b/integrations/claude-code-plugin/core/handoff-runtime.json
@@ -0,0 +1,8 @@
+{
+ "revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
+ "files": {
+ "claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
+ "handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
+ },
+ "artifacts": ["session_handoff.py", "handoff-runtime.json"]
+}
diff --git a/integrations/claude-code-plugin/core/handoff_sources.py b/integrations/claude-code-plugin/core/handoff_sources.py
deleted file mode 100644
index 549c9969e..000000000
--- a/integrations/claude-code-plugin/core/handoff_sources.py
+++ /dev/null
@@ -1,452 +0,0 @@
-"""Native transcript readers; all destinations use the shared handoff importer.
-
-Formats: openai/codex rollout payloads; MoonshotAI/kimi-code contextMemory;
-Pi's session-manager.buildSessionContext; native Cursor/Antigravity transcripts.
-Unsupported state changes fail instead of silently dropping active context.
-"""
-
-from __future__ import annotations
-
-import json
-from pathlib import Path
-from typing import Any
-
-import claude_to_codex as engine
-
-
-def _message(role: str, text: str) -> dict:
- return {"role": role, "content": [{"type": "text", "text": text}]}
-
-
-def _parts(content: Any, role: str, warnings: list[str]) -> list[dict]:
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if not isinstance(part, dict):
- raise engine.HandoffError("Native message has an invalid content block.")
- kind = part.get("type")
- if kind in {"thinking", "redacted_thinking", "think"}:
- if "Hidden reasoning was excluded." not in warnings:
- warnings.append("Hidden reasoning was excluded.")
- elif kind in {"text", "input_text", "output_text"} and isinstance(part.get("text"), str):
- parts.append({"type": "input_text" if role == "user" else "output_text", "text": part["text"]})
- elif kind == "image":
- source = part.get("source") or {
- "type": "base64",
- "data": part.get("data"),
- "media_type": part.get("mimeType"),
- }
- media_type, data = engine._image_payload(source, "Native message image")
- parts.append({"type": "input_image", "image_url": f"data:{media_type};base64,{data}"})
- elif kind in {"image_url", "input_image"}:
- url = part.get("image_url")
- if isinstance(url, dict):
- url = url.get("url")
- engine._data_url_payload(url, "Native message image")
- parts.append({"type": "input_image", "image_url": url})
- elif kind not in {"toolCall", "tool_use"}:
- raise engine.HandoffError(f"Unsupported native content block: {kind!r}.")
- return parts
-
-
-def _call_item(call: dict) -> dict:
- function = call.get("function", call)
- arguments = function.get("arguments", "{}")
- return {
- "type": "function_call",
- "call_id": call.get("id"),
- "name": function.get("name"),
- "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments),
- }
-
-
-def _messages_items(messages: list[dict], warnings: list[str]) -> list[dict]:
- items = []
- for message in messages:
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid native message.")
- role = message.get("role")
- if role in {"system", "developer"}:
- if "Source harness instructions were excluded." not in warnings:
- warnings.append("Source harness instructions were excluded.")
- continue
- if role in {"tool", "toolResult"}:
- output_parts = _parts(message.get("content"), "assistant", warnings)
- output = []
- for part in output_parts:
- if part["type"] == "input_image":
- media_type, data = engine._data_url_payload(part["image_url"], "Tool result image")
- output.append(
- {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}}
- )
- else:
- output.append({"type": "text", "text": part["text"]})
- if message.get("isError"):
- output.insert(0, {"type": "text", "text": "Tool failed."})
- if message.get("note"):
- output.append({"type": "text", "text": str(message["note"])})
- item = {
- "type": "function_call_output",
- "call_id": message.get("toolCallId") or message.get("tool_call_id"),
- "output": output,
- }
- if message.get("toolName") or message.get("name"):
- item["name"] = message.get("toolName") or message["name"]
- items.append(item)
- continue
- if role not in {"user", "assistant"}:
- raise engine.HandoffError(f"Unsupported native message role: {role!r}.")
- if message.get("partial") or message.get("stopReason") in {"error", "aborted"}:
- raise engine.HandoffError("Native assistant response is incomplete; finish the source turn first.")
- content = message.get("content", [])
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if isinstance(part, dict) and part.get("type") in {"toolCall", "tool_use"}:
- if role != "assistant":
- raise engine.HandoffError("Native user message contains an assistant tool call.")
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- parts = []
- items.append(
- _call_item(
- {
- "id": part.get("id"),
- "name": part.get("name"),
- "arguments": json.dumps(part.get("arguments", part.get("input", {}))),
- }
- )
- )
- else:
- parts.extend(_parts([part], role, warnings))
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- for call in message.get("toolCalls") or message.get("tool_calls") or []:
- items.append(_call_item(call))
- return items
-
-
-def _codex(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Native Responses items are the authoritative history, event_msg is UI data.
- items, source = [], {}
- for record in records:
- kind, payload = record.get("type"), record.get("payload")
- if not isinstance(payload, dict):
- raise engine.HandoffError("Invalid Codex rollout payload.")
- if kind == "session_meta":
- source.update(session_id=payload.get("id"), cwd=payload.get("cwd"))
- elif kind == "compacted":
- replacement = payload.get("replacement_history")
- if not isinstance(replacement, list) or not replacement:
- raise engine.HandoffError(
- "Codex compaction is opaque; a complete plaintext replacement history is required."
- )
- items = list(replacement)
- elif kind == "response_item":
- items.append(payload)
- elif kind == "event_msg":
- if payload.get("type") == "thread_rolled_back":
- raise engine.HandoffError("Codex rollback requires a native active-context export.")
- elif kind != "turn_context":
- raise engine.HandoffError(f"Unsupported Codex rollout record: {kind!r}.")
- result = []
- for item in items:
- kind = item.get("type")
- if kind == "reasoning":
- warnings.append("Hidden reasoning was excluded.")
- elif kind == "message" and item.get("role") in {"system", "developer"}:
- warnings.append("Source harness instructions were excluded.")
- elif kind == "custom_tool_call":
- result.append(
- {
- "type": "function_call",
- "call_id": item.get("call_id"),
- "name": item.get("name"),
- "arguments": json.dumps({"input": item.get("input")}),
- }
- )
- elif kind == "custom_tool_call_output":
- result.append({**item, "type": "function_call_output"})
- elif kind == "compaction":
- raise engine.HandoffError(
- "Codex compaction contains opaque model state; it cannot be transferred losslessly."
- )
- else:
- result.append(dict(item))
- return result, source
-
-
-def _cursor(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Cursor's persisted transcript uses role + message.content, without Claude's parent chain.
- converted = []
- source = {}
- for index, record in enumerate(records):
- role = record.get("role") or record.get("type")
- if role not in {"user", "assistant"} or not isinstance(record.get("message"), dict):
- raise engine.HandoffError("Unsupported Cursor transcript record; provide a complete native JSONL export.")
- converted.append({**record, "type": role, "uuid": str(index)})
- if record.get("session_id"):
- source["session_id"] = record["session_id"]
- if record.get("cwd"):
- source["cwd"] = record["cwd"]
- items, skipped = engine._responses_items(converted)
- if skipped:
- warnings.append("Hidden reasoning was excluded.")
- return items, source
-
-
-def _antigravity(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- messages = []
- for step in records:
- if step.get("status") != "DONE":
- raise engine.HandoffError("Antigravity has an unfinished transcript step; finish the source turn first.")
- kind, content = step.get("type"), step.get("content")
- if not isinstance(content, str):
- raise engine.HandoffError("Antigravity transcript content is not transferable text.")
- if kind == "USER_INPUT":
- messages.append(_message("user", content))
- elif kind == "PLANNER_RESPONSE" and step.get("source") == "MODEL":
- messages.append(_message("assistant", content))
- else:
- raise engine.HandoffError(
- f"Unsupported Antigravity step {kind!r}; its visible conversation semantics are not verified."
- )
- return _messages_items(messages, warnings), {}
-
-
-def _kimi_compact(messages: list[dict], record: dict) -> list[dict]:
- summary = record.get("contextSummary", record.get("summary"))
- if isinstance(summary, dict):
- summary_message = summary
- elif isinstance(summary, str):
- summary_message = {**_message("user", summary), "origin": {"kind": "compaction_summary"}}
- else:
- raise engine.HandoffError("Kimi compaction has no transferable summary.")
- if record.get("legacyTail") or "keptUserMessageCount" not in record:
- count = record.get("compactedCount", record.get("count"))
- if not isinstance(count, int) or not 0 <= count <= len(messages):
- raise engine.HandoffError("Invalid Kimi compaction boundary.")
- return [summary_message, *messages[count:]]
- users = []
- for message in messages:
- origin = message.get("origin") or {}
- if message.get("role") == "user" and (
- origin.get("kind") in {None, "user"}
- or (origin.get("kind") in {"skill_activation", "plugin_command"} and origin.get("trigger") == "user-slash")
- ):
- users.append(message)
- # Kimi trims user inputs above this native budget. Do not approximate that destructive rewrite.
- tokens = 0
- for message in users:
- if message.get("toolCalls"):
- raise engine.HandoffError("Unsupported Kimi compaction user tool calls.")
- tokens += 1 # estimateTokens('user')
- for part in message.get("content", []):
- if part.get("type") not in {"text", "think"}:
- tokens += 2000
- else:
- text = part.get("text", part.get("think", ""))
- ascii_count = sum(ord(char) <= 127 for char in text)
- tokens += (ascii_count + 3) // 4 + len(text) - ascii_count
- if tokens > 20000 or record.get("keptHeadUserMessageCount"):
- raise engine.HandoffError("Kimi compaction elided user content; use a native active-context bundle export.")
- continuation = _message(
- "user",
- "\nContext compaction is complete — continue the work that was in progress when it began.\n",
- )
- return [*users, summary_message, continuation]
-
-
-def _kimi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Mirrors Kimi v2 context.append_message and completed loop events, not UI stream fragments.
- messages, source = [], {}
- opened, step_id = None, None
- for record in records:
- if record.get("agentId") not in {None, "main"}:
- continue
- kind = record.get("type", "")
- if kind in {"profile.bind", "config.update"}:
- cwd = (record.get("environmentDisclosure") or {}).get("cwd") or record.get("cwd")
- if cwd:
- source["cwd"] = cwd
- elif kind == "context.append_message":
- if opened is not None:
- raise engine.HandoffError("Kimi interleaved messages require a completed native context export.")
- messages.append(record.get("message"))
- elif kind == "context.append_loop_event":
- event = record.get("event") or {}
- event_type = event.get("type")
- if event_type == "step.begin":
- if opened is not None:
- raise engine.HandoffError("Kimi previous response did not complete.")
- step_id = event.get("uuid")
- opened = {"role": "assistant", "content": [], "toolCalls": []}
- messages.append(opened)
- elif event_type == "step.end":
- if event.get("uuid") != step_id or event.get("finishReason") in {"error", "interrupted"}:
- raise engine.HandoffError("Kimi response is incomplete or interrupted.")
- opened, step_id = None, None
- elif event_type in {"content.part", "tool.call"}:
- if opened is None or event.get("stepUuid") != step_id:
- raise engine.HandoffError("Kimi content has no matching active response.")
- if event_type == "content.part":
- opened["content"].append(event.get("part"))
- else:
- opened["toolCalls"].append(
- {
- "id": event.get("toolCallId"),
- "name": event.get("name"),
- "arguments": json.dumps(event.get("args", {})),
- }
- )
- elif event_type == "tool.result":
- result = event.get("result") or {}
- messages.append(
- {
- "role": "tool",
- "toolCallId": event.get("toolCallId"),
- "content": result.get("output"),
- "isError": result.get("isError"),
- "note": result.get("note"),
- }
- )
- else:
- raise engine.HandoffError(f"Unsupported Kimi loop event: {event_type!r}.")
- elif kind == "context.clear":
- messages, opened, step_id = [], None, None
- elif kind == "context.apply_compaction":
- if opened is not None:
- raise engine.HandoffError("Kimi compaction began during an unfinished response.")
- messages = _kimi_compact(messages, record)
- elif kind in {"context.undo", "micro_compaction.apply", "context.spliced"}:
- raise engine.HandoffError(f"Kimi {kind} needs a native active-context export to preserve its state.")
- elif kind.startswith("context.") and kind != "context.update_token_count":
- raise engine.HandoffError(f"Unsupported Kimi context event: {kind!r}.")
- # Remaining durable events configure Kimi's harness; they are not model messages.
- if opened is not None:
- raise engine.HandoffError("Kimi response is still streaming.")
- return _messages_items(messages, warnings), source
-
-
-def _pi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- header = records[0]
- if header.get("type") != "session":
- raise engine.HandoffError("Pi/OpenClaw transcript has no session header.")
- entries = [record for record in records[1:] if isinstance(record.get("id"), str)]
- if len(entries) != len(records) - 1:
- raise engine.HandoffError("Pi/OpenClaw transcript entry has no ID.")
- index = {entry["id"]: entry for entry in entries}
- if len(index) != len(entries):
- raise engine.HandoffError("Pi/OpenClaw transcript has duplicate entry IDs.")
- chain, seen = [], set()
- current = entries[-1] if entries else None
- while current:
- if current["id"] in seen:
- raise engine.HandoffError("Pi/OpenClaw transcript has a parent cycle.")
- seen.add(current["id"])
- chain.append(current)
- parent = current.get("parentId")
- if parent is not None and parent not in index:
- raise engine.HandoffError("Pi/OpenClaw transcript has a missing parent.")
- current = index.get(parent)
- chain.reverse()
- title = next((entry.get("name") for entry in reversed(chain) if entry.get("type") == "session_info"), None)
- messages = []
- boundary = next((i for i in range(len(chain) - 1, -1, -1) if chain[i].get("type") == "compaction"), None)
- if boundary is not None:
- compact = chain[boundary]
- if not isinstance(compact.get("summary"), str):
- raise engine.HandoffError("Pi/OpenClaw compaction has no summary.")
- messages.append(
- _message(
- "user",
- f"The conversation history before this point was compacted into the following summary:\n\n\n{compact['summary']}\n",
- )
- )
- kept = next((i for i in range(boundary) if chain[i]["id"] == compact.get("firstKeptEntryId")), boundary)
- chain = chain[kept:boundary] + chain[boundary + 1 :]
- for entry in chain:
- kind = entry.get("type")
- if kind == "message":
- message = entry.get("message")
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid Pi/OpenClaw message.")
- if message.get("role") == "bashExecution":
- if message.get("excludeFromContext"):
- continue
- if message.get("truncated"):
- raise engine.HandoffError("Pi/OpenClaw shell output is truncated; provide a complete bundle.")
- text = f"Ran `{message.get('command', '')}`\n"
- text += f"```\n{message['output']}\n```" if message.get("output") else "(no output)"
- if message.get("cancelled"):
- text += "\n\n(command cancelled)"
- elif message.get("exitCode") not in {None, 0}:
- text += f"\n\nCommand exited with code {message['exitCode']}"
- message = _message("user", text)
- messages.append(message)
- elif kind == "branch_summary":
- messages.append(
- _message(
- "user",
- f"The following is a summary of a branch that this conversation came back from:\n\n\n{entry['summary']}",
- )
- )
- elif kind == "custom_message":
- messages.append({"role": "user", "content": entry.get("content")})
- elif kind == "session_info":
- title = entry.get("name")
- elif kind not in {"model_change", "thinking_level_change", "custom", "label"}:
- raise engine.HandoffError(f"Unsupported Pi/OpenClaw entry: {kind!r}.")
- return _messages_items(messages, warnings), {
- "session_id": header.get("id"),
- "cwd": header.get("cwd"),
- "title": title,
- }
-
-
-def read_source(host: str, path: Path, *, cwd: Path | None = None, title: str | None = None) -> engine.HandoffPlan:
- path = path.expanduser().resolve()
- records, digest = engine._stable_jsonl(path)
- warnings: list[str] = []
- readers = {
- "cursor": _cursor,
- "codex": _codex,
- "kimi": _kimi,
- "antigravity": _antigravity,
- "openclaw": _pi,
- "pi-agent": _pi,
- }
- try:
- items, metadata = readers[host](records, warnings)
- except (TypeError, AttributeError, KeyError, ValueError) as exc:
- raise engine.HandoffError(f"Invalid {host} native transcript structure: {exc}") from exc
- if host == "kimi" and path.name == "wire.jsonl" and path.parent.name == "main":
- metadata.setdefault("session_id", path.parents[2].name)
- state = path.parents[2] / "state.json"
- if state.is_file():
- try:
- metadata.setdefault("title", json.loads(state.read_text()).get("title"))
- except (json.JSONDecodeError, AttributeError):
- pass
- if host == "antigravity" and path.name == "transcript.jsonl" and path.parent.name == "logs":
- metadata.setdefault("session_id", path.parents[2].name)
- source_cwd = str(cwd.expanduser().resolve()) if cwd else metadata.get("cwd")
- if not source_cwd:
- raise engine.HandoffError(f"{host} transcript has no working directory; provide --cwd.")
- source = {
- "host": host,
- "path": str(path),
- "sha256": digest,
- "session_id": metadata.get("session_id") or path.stem,
- "cwd": source_cwd,
- "title": title or metadata.get("title") or f"{host} session {path.stem[:12]}",
- }
- return engine.plan_from_bundle(
- {"format": engine.FORMAT_VERSION, "source": source, "items": items, "warnings": list(dict.fromkeys(warnings))}
- )
diff --git a/integrations/claude-code-plugin/core/memory_core.py b/integrations/claude-code-plugin/core/memory_core.py
index 1e99f99e7..93a3fde15 100644
--- a/integrations/claude-code-plugin/core/memory_core.py
+++ b/integrations/claude-code-plugin/core/memory_core.py
@@ -29,7 +29,7 @@ from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
-PLUGIN_VERSION = "0.4.0"
+PLUGIN_VERSION = "0.3.2"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
diff --git a/integrations/claude-code-plugin/core/session_handoff.py b/integrations/claude-code-plugin/core/session_handoff.py
index 4aada69e6..b7108f336 100644
--- a/integrations/claude-code-plugin/core/session_handoff.py
+++ b/integrations/claude-code-plugin/core/session_handoff.py
@@ -1,7 +1,81 @@
#!/usr/bin/env python3
-"""Import an explicit native session or neutral handoff bundle into Codex."""
+"""Run the shared handoff engine locally or from its verified immutable cache."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import sys
+import tempfile
+from pathlib import Path
+from urllib.request import urlopen
+
+ENGINE_FILES = {"claude_to_codex.py", "handoff_sources.py"}
+SOURCE_URL = "https://raw.githubusercontent.com/mem0ai/mem0"
+
+
+def _verified(path: Path, digest: str) -> bool:
+ try:
+ return hashlib.sha256(path.read_bytes()).hexdigest() == digest
+ except FileNotFoundError:
+ return False
+
+
+def runtime_root(launcher_dir: Path | None = None) -> Path:
+ here = launcher_dir or Path(__file__).resolve().parent
+ if all((here / name).is_file() for name in ENGINE_FILES):
+ return here # The canonical development checkout already has both engines.
+ manifest = json.loads((here / "handoff-runtime.json").read_text(encoding="utf-8"))
+ if not isinstance(manifest, dict):
+ raise ValueError("invalid handoff runtime manifest")
+ revision, files = manifest.get("revision"), manifest.get("files")
+ if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
+ raise ValueError("handoff runtime revision must be an immutable commit SHA")
+ if (
+ not isinstance(files, dict)
+ or set(files) != ENGINE_FILES
+ or any(not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) for digest in files.values())
+ ):
+ raise ValueError("invalid handoff runtime file hashes")
+ cache = Path.home() / ".mem0" / "handoff-runtime" / revision
+ missing = {name: digest for name, digest in files.items() if not _verified(cache / name, digest)}
+ if not missing:
+ return cache
+ cache.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with tempfile.TemporaryDirectory(prefix=f".{revision}-", dir=cache.parent) as temporary:
+ staged = Path(temporary)
+ for name, digest in missing.items():
+ url = f"{SOURCE_URL}/{revision}/integrations/agent-plugin-core/python/{name}"
+ try:
+ with urlopen(url, timeout=30) as response:
+ body = response.read()
+ except OSError as exc:
+ raise OSError(f"could not download pinned handoff runtime {name}: {exc}") from exc
+ if hashlib.sha256(body).hexdigest() != digest:
+ raise ValueError(f"SHA256 mismatch for pinned handoff runtime {name}; refusing to execute it")
+ (staged / name).write_bytes(body)
+ # All downloads are verified before publishing; each replacement is atomic.
+ cache.mkdir(exist_ok=True, mode=0o700)
+ for name in missing:
+ os.replace(staged / name, cache / name)
+ if not all(_verified(cache / name, digest) for name, digest in files.items()):
+ raise ValueError("handoff runtime cache changed during installation; refusing to execute it")
+ return cache
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ root = runtime_root()
+ except (OSError, ValueError) as exc:
+ print(f"handoff runtime unavailable: {exc}", file=sys.stderr)
+ return 1
+ sys.path.insert(0, str(root))
+ from claude_to_codex import main as engine_main
+
+ return engine_main(argv, default_source=None)
-from claude_to_codex import main
if __name__ == "__main__":
- raise SystemExit(main(default_source=None))
+ raise SystemExit(main())
diff --git a/integrations/claude-code-plugin/plugin-build.json b/integrations/claude-code-plugin/plugin-build.json
index 37cca036c..4b98c2919 100644
--- a/integrations/claude-code-plugin/plugin-build.json
+++ b/integrations/claude-code-plugin/plugin-build.json
@@ -1,6 +1,6 @@
{
"id": "mem0",
- "version": "0.4.0",
+ "version": "0.3.2",
"homepage": "https://docs.mem0.ai/integrations/claude-code",
"native": {
"pluginRoot": "${CLAUDE_PLUGIN_ROOT}",
diff --git a/integrations/claude-code-plugin/skills/handoff/SKILL.md b/integrations/claude-code-plugin/skills/handoff/SKILL.md
index 3440b2fc4..b7d24871f 100644
--- a/integrations/claude-code-plugin/skills/handoff/SKILL.md
+++ b/integrations/claude-code-plugin/skills/handoff/SKILL.md
@@ -9,7 +9,9 @@ allowed-tools: Bash(python3 ${CLAUDE_PLUGIN_ROOT}/core/session_handoff.py *)
All hosts share one local import engine. Native readers and SDK adapters supply
complete conversation items; Mem0 memory capture is not a transcript source.
-Requires Python 3.11+ and a Codex CLI with native session import support. The
+Requires Python 3.11+ and a Codex CLI with native session import support.
+First use downloads a pinned, hash-verified runtime from GitHub; later uses share
+the verified local cache. No transcript is sent to GitHub. The
supported destination is Codex. This does not transfer files or change branches.
Visible conversation, tool history, and supported source compaction summaries
diff --git a/integrations/claude-code-plugin/tests/test_memory_core.py b/integrations/claude-code-plugin/tests/test_memory_core.py
index 0219c7721..60b088fb1 100644
--- a/integrations/claude-code-plugin/tests/test_memory_core.py
+++ b/integrations/claude-code-plugin/tests/test_memory_core.py
@@ -3460,7 +3460,7 @@ def test_automatic_flush_can_be_disabled_for_external_harnesses(isolated_env):
def test_version_is_single_sourced():
manifest = json.loads((PLUGIN_ROOT / ".claude-plugin" / "plugin.json").read_text())
assert manifest["name"] == "mem0"
- assert manifest["version"] == memory_core.PLUGIN_VERSION == "0.4.0"
+ assert manifest["version"] == memory_core.PLUGIN_VERSION == "0.3.2"
root = REPOSITORY_ROOT
for mp in (root / "marketplace.json", root / ".claude-plugin" / "marketplace.json"):
entry = next(p for p in json.loads(mp.read_text())["plugins"] if p["name"] == "mem0")
diff --git a/integrations/codex-plugin/.codex-plugin/plugin.json b/integrations/codex-plugin/.codex-plugin/plugin.json
index fd379dcc2..0a80bf9e3 100644
--- a/integrations/codex-plugin/.codex-plugin/plugin.json
+++ b/integrations/codex-plugin/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "mem0",
- "version": "0.4.0",
+ "version": "0.3.2",
"description": "Cross-session memory and token savings for coding agents.",
"author": { "name": "Mem0", "email": "support@mem0.ai" },
"homepage": "https://docs.mem0.ai/integrations/codex",
diff --git a/integrations/codex-plugin/CHANGELOG.md b/integrations/codex-plugin/CHANGELOG.md
new file mode 100644
index 000000000..a42e0f980
--- /dev/null
+++ b/integrations/codex-plugin/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+## 0.3.2
+
+- The `handoff` skill accepts a completed Codex rollout with readable active context. Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
+- Lightened search prompts: search when prior work may help; repeat only for a specific gap.
diff --git a/integrations/codex-plugin/core/claude_to_codex.py b/integrations/codex-plugin/core/claude_to_codex.py
deleted file mode 100644
index fd372e198..000000000
--- a/integrations/codex-plugin/core/claude_to_codex.py
+++ /dev/null
@@ -1,1492 +0,0 @@
-#!/usr/bin/env python3
-"""Shared local session-handoff engine and backwards-compatible Claude CLI.
-
-Native readers and SDK adapters supply complete conversation items. This engine
-validates and exports their bundles, then uses Codex's native external-session
-importer to create a task with visible historical turns. The legacy command
-still defaults to reading a Claude Code transcript; session_handoff.py requires
-an explicit source host or a neutral bundle.
-
-No model generates a handoff summary. Large imports may use Codex's native
-compaction before the new task is returned.
-"""
-
-# Adapted from mem0ai/memo at aeeb1593284d1d2fca3b4bcf1e32ea10f71df549 (Apache-2.0).
-from __future__ import annotations
-
-import argparse
-import base64
-import binascii
-import hashlib
-import html
-import json
-import os
-import queue
-import re
-import shutil
-import subprocess
-import sys
-import tempfile
-import threading
-import time
-from dataclasses import asdict, dataclass, replace
-from pathlib import Path
-from typing import Any, Iterable
-
-FORMAT_VERSION = "mem0.session-handoff.v1"
-DEFAULT_CODEX_HOME = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
-DEFAULT_BUNDLE_DIR = Path.home() / ".mem0" / "handoffs"
-IMPORT_COMPLETED_NOTIFICATION = "externalAgentConfig/import/completed"
-IMAGE_EXTENSIONS = {
- "image/gif": "gif",
- "image/jpeg": "jpg",
- "image/png": "png",
- "image/webp": "webp",
-}
-
-
-class HandoffError(RuntimeError):
- """A source session cannot be transferred without losing state."""
-
-
-@dataclass(frozen=True)
-class SourceInfo:
- path: str
- sha256: str
- session_id: str
- title: str
- cwd: str
- leaf_uuid: str
- compact_boundary_uuid: str | None
- first_imported_uuid: str
- last_imported_uuid: str
- codex_cwd: str | None = None
- host: str = "claude-code"
-
-
-@dataclass
-class HandoffPlan:
- source: SourceInfo
- items: list[dict[str, Any]]
- source_records: int
- active_records: int
- imported_records: int
- hidden_reasoning_blocks_skipped: int
- approximate_tokens: int
- warnings: list[str]
-
- def bundle(self) -> dict[str, Any]:
- return {
- "format": FORMAT_VERSION,
- "source": asdict(self.source),
- "items": self.items,
- "counts": {
- "source_records": self.source_records,
- "active_records": self.active_records,
- "imported_records": self.imported_records,
- "responses_items": len(self.items),
- "hidden_reasoning_blocks_skipped": self.hidden_reasoning_blocks_skipped,
- "approximate_tokens": self.approximate_tokens,
- },
- "warnings": self.warnings,
- }
-
-
-@dataclass(frozen=True)
-class CodexContextLimits:
- model: str
- context_window: int
- usable_context_window: int
- auto_compact_token_limit: int
- max_context_window: int
- max_usable_context_window: int
- max_auto_compact_token_limit: int
-
-
-def _stable_jsonl(path: Path) -> tuple[list[dict[str, Any]], str]:
- before = path.stat()
- raw = path.read_bytes()
- after = path.stat()
- if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
- raise HandoffError(f"Source session changed while it was being read: {path}")
- if raw and not raw.endswith(b"\n"):
- raise HandoffError(
- "The final JSONL record is incomplete. Finish or stop the active source response before transferring it."
- )
-
- records: list[dict[str, Any]] = []
- for line_number, line in enumerate(raw.splitlines(), 1):
- if not line.strip():
- continue
- try:
- record = json.loads(line)
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid source JSONL at {path}:{line_number}: {exc}") from exc
- if not isinstance(record, dict):
- raise HandoffError(f"Source JSONL record is not an object at {path}:{line_number}.")
- records.append(record)
- if not records:
- raise HandoffError(f"Source session is empty: {path}")
- return records, hashlib.sha256(raw).hexdigest()
-
-
-def _resolve_session(value: str, projects_dir: Path) -> Path:
- supplied = Path(value).expanduser()
- if supplied.is_file():
- return supplied.resolve()
-
- matches = list(projects_dir.glob(f"*/{value}.jsonl"))
- if not matches:
- raise HandoffError(
- f"No Claude session named {value!r} exists below {projects_dir}. "
- "Pass the session ID or its full JSONL path."
- )
- if len(matches) != 1:
- joined = "\n".join(f" {path}" for path in matches)
- raise HandoffError(f"Session ID {value!r} is ambiguous:\n{joined}")
- return matches[0].resolve()
-
-
-def _active_chain(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
- with_uuid = [
- record for record in records if isinstance(record.get("uuid"), str) and record.get("isSidechain") is not True
- ]
- if not with_uuid:
- raise HandoffError("Claude session has no main-agent conversation records.")
-
- by_uuid = {record["uuid"]: record for record in with_uuid}
- leaf = with_uuid[-1]
- chain: list[dict[str, Any]] = []
- seen: set[str] = set()
- current: dict[str, Any] | None = leaf
- while current is not None:
- uuid = current["uuid"]
- if uuid in seen:
- raise HandoffError(f"Claude session contains a parent cycle at {uuid}.")
- seen.add(uuid)
- chain.append(current)
- parent_uuid = current.get("parentUuid")
- if parent_uuid is None:
- break
- current = by_uuid.get(parent_uuid)
- if current is None:
- raise HandoffError(f"Claude's active branch references missing parent {parent_uuid}.")
- chain.reverse()
- return chain
-
-
-def _after_latest_compaction(
- chain: list[dict[str, Any]],
-) -> tuple[list[dict[str, Any]], str | None]:
- compact_index: int | None = None
- for index, record in enumerate(chain):
- if record.get("type") == "system" and record.get("subtype") == "compact_boundary":
- compact_index = index
- if compact_index is None:
- imported = chain
- compact_uuid = None
- else:
- imported = chain[compact_index + 1 :]
- compact_uuid = chain[compact_index]["uuid"]
- if not imported or imported[0].get("isCompactSummary") is not True:
- raise HandoffError(f"Claude compaction {compact_uuid} has no following compact summary.")
- imported = [record for record in imported if record.get("type") != "system"]
- if not imported:
- raise HandoffError("Claude's active state contains no transferable records.")
- return imported, compact_uuid
-
-
-def _tool_result_ids(record: dict[str, Any]) -> set[str]:
- if record.get("type") != "user":
- return set()
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- return set()
- return {
- str(block["tool_use_id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("tool_use_id")
- }
-
-
-def _tool_call_ids(records: list[dict[str, Any]]) -> set[str]:
- call_ids: set[str] = set()
- for record in records:
- if record.get("type") != "assistant":
- continue
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- continue
- call_ids.update(
- str(block["id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id")
- )
- return call_ids
-
-
-def _merge_parallel_tool_results(
- active_records: list[dict[str, Any]], all_records: list[dict[str, Any]]
-) -> list[dict[str, Any]]:
- """Restore sibling tool results that Claude stores outside the parent chain.
-
- Parallel Claude tool calls form a fork: later calls remain on the parent
- chain, while earlier results can be sibling records. Claude sends all of
- those results back to the model. Insert them together immediately after the
- assistant response that issued the calls.
- """
- results_by_call: dict[str, list[tuple[int, dict[str, Any]]]] = {}
- for source_index, record in enumerate(all_records):
- for call_id in _tool_result_ids(record):
- results_by_call.setdefault(call_id, []).append((source_index, record))
-
- merged: list[dict[str, Any]] = []
- inserted_result_uuids: set[str] = set()
- index = 0
- while index < len(active_records):
- record = active_records[index]
- record_uuid = str(record.get("uuid") or "")
- if record_uuid in inserted_result_uuids:
- index += 1
- continue
- if record.get("type") != "assistant":
- merged.append(record)
- index += 1
- continue
-
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(active_records):
- candidate = active_records[index]
- candidate_id = (candidate.get("message") or {}).get("id")
- if candidate.get("type") != "assistant" or not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- merged.extend(group)
-
- matching_results: list[tuple[int, dict[str, Any]]] = []
- for call_id in _tool_call_ids(group):
- matching_results.extend(results_by_call.get(call_id, []))
- for _, result in sorted(matching_results, key=lambda pair: pair[0]):
- result_uuid = str(result.get("uuid") or "")
- if result_uuid and result_uuid not in inserted_result_uuids:
- merged.append(result)
- inserted_result_uuids.add(result_uuid)
- return merged
-
-
-def _image_payload(source: Any, context: str) -> tuple[str, str]:
- if not isinstance(source, dict) or source.get("type") != "base64":
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = str(source.get("media_type") or "").lower()
- data = source.get("data")
- if media_type not in IMAGE_EXTENSIONS or not isinstance(data, str) or not data:
- raise HandoffError(f"{context} has an unsupported or missing image type.")
- return media_type, data
-
-
-def _data_url_payload(image_url: Any, context: str) -> tuple[str, str]:
- if not isinstance(image_url, str):
- raise HandoffError(f"{context} has no transferable image data.")
- match = re.fullmatch(r"data:([^;,]+);base64,(.+)", image_url, flags=re.DOTALL)
- if not match:
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = match.group(1).lower()
- if media_type not in IMAGE_EXTENSIONS:
- raise HandoffError(f"{context} has unsupported image type {media_type!r}.")
- return media_type, match.group(2)
-
-
-def _save_image(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> Path:
- try:
- payload = base64.b64decode(encoded, validate=True)
- except (binascii.Error, ValueError) as exc:
- raise HandoffError(f"{context} contains invalid base64 image data.") from exc
- if not payload:
- raise HandoffError(f"{context} contains an empty image.")
-
- digest = hashlib.sha256(payload).hexdigest()
- asset_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
- path = asset_dir / f"{digest}.{IMAGE_EXTENSIONS[media_type]}"
- if path.exists():
- if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
- raise HandoffError(f"The existing handoff image is corrupted: {path}")
- return path
-
- descriptor, filename = tempfile.mkstemp(prefix=f".{path.name}.", dir=asset_dir)
- temporary = Path(filename)
- try:
- with os.fdopen(descriptor, "wb") as stream:
- stream.write(payload)
- os.replace(temporary, path)
- except OSError as exc:
- temporary.unlink(missing_ok=True)
- raise HandoffError(f"Could not save the handoff image at {path}: {exc}") from exc
- return path
-
-
-def _image_reference(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> str:
- path = _save_image(media_type, encoded, asset_dir, context)
- return f"[Image saved at {path}]"
-
-
-def _tool_result_text(value: Any, asset_dir: Path, context: str) -> str:
- if value is None:
- return ""
- if isinstance(value, str):
- return value
- if isinstance(value, (int, float, bool)):
- return str(value)
- if isinstance(value, list):
- parts: list[str] = []
- for part in value:
- if isinstance(part, dict) and part.get("type") == "text":
- parts.append(str(part.get("text", "")))
- elif isinstance(part, dict) and part.get("type") == "image":
- media_type, encoded = _image_payload(part.get("source"), context)
- parts.append(_image_reference(media_type, encoded, asset_dir, context))
- else:
- parts.append(json.dumps(part, ensure_ascii=False, separators=(",", ":")))
- return "\n".join(part for part in parts if part)
- if isinstance(value, dict) and value.get("type") == "image":
- media_type, encoded = _image_payload(value.get("source"), context)
- return _image_reference(media_type, encoded, asset_dir, context)
- return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
-
-
-def _message(role: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
- return {"type": "message", "role": role, "content": parts}
-
-
-def _attachment_item(record: dict[str, Any]) -> dict[str, Any] | None:
- attachment = record.get("attachment")
- if not isinstance(attachment, dict):
- raise HandoffError(f"Claude attachment {record.get('uuid')} has no payload.")
-
- attachment_type = attachment.get("type")
- filename = str(attachment.get("filename") or attachment.get("displayPath") or "unknown")
- content = attachment.get("content")
- if attachment_type == "file" and isinstance(content, dict):
- file_payload = content.get("file") if content.get("type") == "text" else None
- if isinstance(file_payload, dict) and isinstance(file_payload.get("content"), str):
- text = file_payload["content"]
- display = str(file_payload.get("filePath") or filename)
- wrapped = f'\n{text}\n'
- return _message("user", [{"type": "input_text", "text": wrapped}])
-
- if attachment_type == "image" and isinstance(content, dict):
- image_url = content.get("image_url") or content.get("data")
- if isinstance(image_url, str) and image_url.startswith("data:"):
- return _message("user", [{"type": "input_image", "image_url": image_url}])
-
- if attachment_type in {"file", "image"}:
- raise HandoffError(f"Claude {attachment_type} attachment {record.get('uuid')} has an unsupported payload.")
-
- # Claude also records its own skill list, tool availability, permissions,
- # token reminders, hooks, and task status as attachments. Those configure
- # Claude's harness; they are not part of the user's project conversation and
- # must not become user messages in Codex.
- return None
-
-
-def _assistant_items(records: list[dict[str, Any]], calls: dict[str, str]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- skipped_reasoning = 0
- text_parts: list[dict[str, Any]] = []
-
- def flush_text() -> None:
- if text_parts:
- items.append(_message("assistant", list(text_parts)))
- text_parts.clear()
-
- for record in records:
- content = (record.get("message") or {}).get("content", [])
- if isinstance(content, str):
- text_parts.append({"type": "output_text", "text": content})
- continue
- if not isinstance(content, list):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid content.")
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "thinking" or kind == "redacted_thinking":
- skipped_reasoning += 1
- continue
- if kind == "text":
- text_parts.append({"type": "output_text", "text": str(block.get("text", ""))})
- continue
- if kind == "tool_use":
- flush_text()
- call_id = str(block.get("id") or "")
- name = str(block.get("name") or "")
- if not call_id or not name:
- raise HandoffError(f"Claude tool call in {record.get('uuid')} has no ID or name.")
- if call_id in calls:
- raise HandoffError(f"Claude tool call ID is duplicated: {call_id}")
- calls[call_id] = name
- items.append(
- {
- "type": "function_call",
- "call_id": call_id,
- "name": name,
- "arguments": json.dumps(
- block.get("input", {}),
- ensure_ascii=False,
- separators=(",", ":"),
- ),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude assistant block {kind!r} in {record.get('uuid')}.")
- flush_text()
- return items, skipped_reasoning
-
-
-def _user_items(record: dict[str, Any], calls: dict[str, str], completed_calls: set[str]) -> list[dict[str, Any]]:
- if record.get("isMeta") is True:
- return []
- content = (record.get("message") or {}).get("content")
- if isinstance(content, str):
- return [_message("user", [{"type": "input_text", "text": content}])]
- if not isinstance(content, list):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid content.")
-
- items: list[dict[str, Any]] = []
- user_parts: list[dict[str, Any]] = []
-
- def flush_user() -> None:
- if user_parts:
- items.append(_message("user", list(user_parts)))
- user_parts.clear()
-
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "text":
- user_parts.append({"type": "input_text", "text": str(block.get("text", ""))})
- continue
- if kind == "image":
- source = block.get("source") or {}
- if source.get("type") == "base64" and source.get("data") and source.get("media_type"):
- user_parts.append(
- {
- "type": "input_image",
- "image_url": f"data:{source['media_type']};base64,{source['data']}",
- }
- )
- continue
- raise HandoffError(f"Claude image in {record.get('uuid')} is not stored as transferable base64 data.")
- if kind == "tool_result":
- flush_user()
- call_id = str(block.get("tool_use_id") or "")
- if not call_id:
- raise HandoffError(f"Claude tool result in {record.get('uuid')} has no call ID.")
- if call_id not in calls:
- raise HandoffError(f"Claude tool result {call_id} has no matching call in the active state.")
- if call_id in completed_calls:
- raise HandoffError(f"Claude tool result is duplicated: {call_id}")
- completed_calls.add(call_id)
- items.append(
- {
- "type": "function_call_output",
- "call_id": call_id,
- "name": calls[call_id],
- "output": block.get("content"),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude user block {kind!r} in {record.get('uuid')}.")
- flush_user()
- return items
-
-
-def _responses_items(records: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- calls: dict[str, str] = {}
- completed_calls: set[str] = set()
- skipped_reasoning = 0
-
- index = 0
- while index < len(records):
- record = records[index]
- record_type = record.get("type")
- if record_type == "assistant":
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(records):
- candidate = records[index]
- if candidate.get("type") != "assistant":
- break
- candidate_id = (candidate.get("message") or {}).get("id")
- if not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- assistant_items, skipped = _assistant_items(group, calls)
- items.extend(assistant_items)
- skipped_reasoning += skipped
- continue
- if record_type == "user":
- items.extend(_user_items(record, calls, completed_calls))
- elif record_type == "attachment":
- attachment_item = _attachment_item(record)
- if attachment_item is not None:
- items.append(attachment_item)
- elif record_type not in {"system"}:
- raise HandoffError(f"Unsupported model-visible Claude record {record_type!r} at {record.get('uuid')}.")
- index += 1
-
- unfinished = sorted(set(calls) - completed_calls)
- if unfinished:
- joined = ", ".join(unfinished[:5])
- raise HandoffError(
- f"Claude's active state ends with unfinished tool call(s): {joined}. "
- "Finish or stop the Claude turn before transferring it."
- )
- if not items:
- raise HandoffError("Claude's active state produced no Codex history items.")
- return items, skipped_reasoning
-
-
-def _without_image_payloads(value: Any) -> Any:
- if isinstance(value, list):
- return [_without_image_payloads(item) for item in value]
- if not isinstance(value, dict):
- return value
-
- cleaned = {key: _without_image_payloads(item) for key, item in value.items()}
- if cleaned.get("type") == "input_image" and isinstance(cleaned.get("image_url"), str):
- cleaned["image_url"] = "[Image saved locally during handoff]"
- if cleaned.get("type") == "image" and isinstance(cleaned.get("source"), dict):
- source = dict(cleaned["source"])
- if source.get("type") == "base64" and "data" in source:
- source["data"] = "[Image saved locally during handoff]"
- cleaned["source"] = source
- return cleaned
-
-
-def _token_count(value: Any) -> int:
- text = json.dumps(_without_image_payloads(value), ensure_ascii=False, separators=(",", ":"))
- try:
- import tiktoken
-
- return len(tiktoken.get_encoding("o200k_base").encode(text))
- except ImportError:
- return (len(text) + 3) // 4
-
-
-def build_plan(session: str, projects_dir: Path) -> HandoffPlan:
- path = _resolve_session(session, projects_dir)
- records, sha256 = _stable_jsonl(path)
- chain = _active_chain(records)
- imported, compact_uuid = _after_latest_compaction(chain)
- imported = _merge_parallel_tool_results(imported, records)
- items, skipped_reasoning = _responses_items(imported)
-
- session_id = next(
- (str(record["sessionId"]) for record in reversed(records) if record.get("sessionId")),
- path.stem,
- )
- title = next(
- (
- str(record["customTitle"])
- for record in reversed(records)
- if record.get("type") == "custom-title" and record.get("customTitle")
- ),
- f"Claude session {session_id[:8]}",
- )
- cwd = next(
- (str(record["cwd"]) for record in chain if record.get("cwd")),
- "",
- )
- if not cwd:
- raise HandoffError("Claude session does not record its working directory.")
-
- warnings: list[str] = []
- if skipped_reasoning:
- warnings.append(f"Skipped {skipped_reasoning} Claude hidden-reasoning block(s); they are not portable.")
-
- source = SourceInfo(
- path=str(path),
- sha256=sha256,
- session_id=session_id,
- title=title,
- cwd=str(Path(cwd).resolve()),
- leaf_uuid=chain[-1]["uuid"],
- compact_boundary_uuid=compact_uuid,
- first_imported_uuid=imported[0]["uuid"],
- last_imported_uuid=imported[-1]["uuid"],
- codex_cwd=_git_root(Path(cwd)),
- )
- return HandoffPlan(
- source=source,
- items=items,
- source_records=len(records),
- active_records=len(chain),
- imported_records=len(imported),
- hidden_reasoning_blocks_skipped=skipped_reasoning,
- approximate_tokens=_token_count(items),
- warnings=warnings,
- )
-
-
-def _write_private(path: Path, body: str) -> None:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
- try:
- with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
- stream.write(body)
- os.replace(temporary, path)
- finally:
- Path(temporary).unlink(missing_ok=True)
-
-
-def write_bundle(plan: HandoffPlan, path: Path) -> Path:
- path = path.expanduser().resolve()
- _write_private(path, json.dumps(plan.bundle(), ensure_ascii=False))
- return path
-
-
-def _validate_items(items: Any) -> None:
- if not isinstance(items, list) or not items:
- raise HandoffError("Handoff bundle contains no history items.")
- calls: dict[str, str] = {}
- completed: set[str] = set()
- saw_user = False
- for item in items:
- if not isinstance(item, dict):
- raise HandoffError("Invalid handoff history item.")
- kind = item.get("type")
- status = item.get("status")
- if (
- not isinstance(kind, str)
- or (status is not None and not isinstance(status, str))
- or status in {"incomplete", "in_progress"}
- ):
- raise HandoffError("Invalid or incomplete handoff history item.")
- if kind == "message":
- role = item.get("role")
- parts = item.get("content")
- if (
- not isinstance(role, str)
- or role not in {"user", "assistant"}
- or not isinstance(parts, list)
- or not parts
- ):
- raise HandoffError("Invalid handoff message role or content.")
- saw_user = saw_user or role == "user"
- for part in parts:
- if not isinstance(part, dict) or not isinstance(part.get("type"), str):
- raise HandoffError("Invalid handoff message part.")
- if part.get("type") in {"input_text", "output_text"} and isinstance(part.get("text"), str):
- continue
- if part.get("type") == "input_image":
- _, encoded = _data_url_payload(part.get("image_url"), "Handoff image")
- try:
- if not base64.b64decode(encoded, validate=True):
- raise ValueError("empty image")
- except (ValueError, binascii.Error) as exc:
- raise HandoffError("Invalid handoff image data.") from exc
- continue
- raise HandoffError("Unsupported handoff message part.")
- elif kind == "function_call":
- call_id, name, arguments = item.get("call_id"), item.get("name"), item.get("arguments")
- if not isinstance(call_id, str) or not call_id or not isinstance(name, str) or not name:
- raise HandoffError("Invalid handoff tool call ID or name.")
- if call_id in calls or not isinstance(arguments, str):
- raise HandoffError("Duplicate or invalid handoff tool call.")
- try:
- json.loads(arguments)
- except json.JSONDecodeError as exc:
- raise HandoffError("Handoff tool arguments are not JSON.") from exc
- calls[call_id] = name
- elif kind == "function_call_output":
- call_id = item.get("call_id")
- if not isinstance(call_id, str) or call_id not in calls or call_id in completed:
- raise HandoffError("Unmatched or duplicate handoff tool result.")
- if "output" not in item:
- raise HandoffError("Handoff tool result has no output.")
- completed.add(call_id)
- item.setdefault("name", calls[call_id])
- else:
- raise HandoffError(f"Unsupported handoff item type: {kind!r}.")
- if set(calls) != completed:
- raise HandoffError("Session has unfinished tool calls; finish or stop the source turn before handoff.")
- if not saw_user:
- raise HandoffError("Handoff contains no user message.")
-
-
-def plan_from_bundle(payload: Any) -> HandoffPlan:
- formats = {FORMAT_VERSION, "mem0.claude-to-codex.v1", "memo.claude-to-codex.v1"}
- if not isinstance(payload, dict) or not isinstance(payload.get("format"), str) or payload["format"] not in formats:
- raise HandoffError("Unsupported handoff bundle format.")
- source_payload = payload.get("source")
- items = payload.get("items")
- warnings = payload.get("warnings", [])
- if not isinstance(source_payload, dict) or not isinstance(warnings, list):
- raise HandoffError("Handoff bundle has no source or has invalid warnings.")
- _validate_items(items)
- try:
- fields = dict(source_payload)
- legacy = payload["format"] != FORMAT_VERSION
- fields.setdefault("host", "claude-code" if legacy else "")
- for key in ("host", "session_id", "title", "cwd"):
- if not isinstance(fields.get(key), str) or not fields[key].strip():
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[a-z][a-z0-9-]*", fields["host"]):
- raise ValueError("invalid source host")
- fields.setdefault("path", f"{fields['host']}:{fields['session_id']}")
- fields.setdefault("sha256", hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest())
- fields.setdefault("leaf_uuid", str(len(items)))
- fields.setdefault("first_imported_uuid", "1")
- fields.setdefault("last_imported_uuid", str(len(items)))
- fields.setdefault("compact_boundary_uuid", None)
- source = SourceInfo(**fields)
- for key, value in asdict(source).items():
- if value is None and key in {"codex_cwd", "compact_boundary_uuid"}:
- continue
- if not isinstance(value, str):
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[0-9a-f]{64}", source.sha256):
- raise ValueError("invalid source digest")
- counts = payload.get("counts", {})
- return HandoffPlan(
- source=source,
- items=items,
- source_records=int(counts.get("source_records", len(items))),
- active_records=int(counts.get("active_records", len(items))),
- imported_records=int(counts.get("imported_records", len(items))),
- hidden_reasoning_blocks_skipped=int(counts.get("hidden_reasoning_blocks_skipped", 0)),
- approximate_tokens=_token_count(items),
- warnings=[str(warning) for warning in warnings],
- )
- except (KeyError, TypeError, ValueError, AttributeError) as exc:
- raise HandoffError("Handoff bundle is incomplete or has invalid source fields.") from exc
-
-
-def load_bundle(path: Path) -> HandoffPlan:
- try:
- text = sys.stdin.read() if str(path) == "-" else path.expanduser().resolve().read_text(encoding="utf-8")
- return plan_from_bundle(json.loads(text))
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid handoff bundle JSON: {path}") from exc
-
-
-def _git_root(cwd: Path) -> str | None:
- completed = subprocess.run(
- ["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
- text=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- check=False,
- )
- if completed.returncode != 0:
- return None
- root = Path(completed.stdout.strip()).resolve()
- return str(root) if root.is_dir() else None
-
-
-def _with_cwd(plan: HandoffPlan, cwd: Path | None) -> HandoffPlan:
- source_cwd = Path(plan.source.cwd).expanduser().resolve()
- target = (
- cwd.expanduser().resolve()
- if cwd
- else Path(plan.source.codex_cwd).expanduser().resolve()
- if plan.source.codex_cwd
- else Path(_git_root(source_cwd) or source_cwd)
- )
- if not target.is_dir():
- raise HandoffError(f"Codex working directory does not exist: {target}")
- return replace(plan, source=replace(plan.source, cwd=str(source_cwd), codex_cwd=str(target)))
-
-
-def _codex_cwd(plan: HandoffPlan) -> str:
- return plan.source.codex_cwd or plan.source.cwd
-
-
-def _default_bundle_path(plan: HandoffPlan) -> Path:
- session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip(".-")[:80] or "session"
- name = f"{session}-{plan.source.sha256[:12]}.json"
- return DEFAULT_BUNDLE_DIR / name
-
-
-def _codex_context_limits(codex_home: Path) -> CodexContextLimits:
- try:
- import tomllib
- except ImportError as exc:
- raise HandoffError("Creating a Codex task requires Python 3.11 or newer; rerun with python3.11.") from exc
-
- codex_home = codex_home.expanduser().resolve()
- config_path = codex_home / "config.toml"
- cache_path = codex_home / "models_cache.json"
- try:
- config = tomllib.loads(config_path.read_text(encoding="utf-8"))
- except (OSError, tomllib.TOMLDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex configuration at {config_path}: {exc}") from exc
- try:
- cache = json.loads(cache_path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex model metadata at {cache_path}: {exc}") from exc
-
- model = str(config.get("model") or "")
- models = cache.get("models") if isinstance(cache, dict) else None
- if not isinstance(models, list):
- raise HandoffError(f"Codex model metadata has no model list: {cache_path}")
- model_info = next(
- (
- item
- for item in models
- if isinstance(item, dict)
- and (
- item.get("slug") == model
- or item.get("model") == model
- or (not model and item.get("is_default") is True)
- )
- ),
- None,
- )
- if not isinstance(model_info, dict):
- raise HandoffError(f"Codex model {model!r} is missing from {cache_path}; refresh Codex's model list.")
- model = str(model_info.get("slug") or model_info.get("model") or model)
-
- cached_context = model_info.get("context_window")
- cached_max = model_info.get("max_context_window") or cached_context
- if not isinstance(cached_context, int) or not isinstance(cached_max, int):
- raise HandoffError(f"Codex model {model!r} does not report its context limits.")
- configured_context = config.get("model_context_window")
- context_window = min(configured_context, cached_max) if isinstance(configured_context, int) else cached_context
- effective_percent = model_info.get("effective_context_window_percent", 95)
- if not isinstance(effective_percent, int) or not 1 <= effective_percent <= 100:
- raise HandoffError(f"Codex model {model!r} reports an invalid effective context percentage.")
-
- context_auto_limit = context_window * 9 // 10
- configured_auto_limit = config.get("model_auto_compact_token_limit")
- auto_compact_limit = (
- min(configured_auto_limit, context_auto_limit) if isinstance(configured_auto_limit, int) else context_auto_limit
- )
- return CodexContextLimits(
- model=model,
- context_window=context_window,
- usable_context_window=context_window * effective_percent // 100,
- auto_compact_token_limit=auto_compact_limit,
- max_context_window=cached_max,
- max_usable_context_window=cached_max * effective_percent // 100,
- max_auto_compact_token_limit=cached_max * 9 // 10,
- )
-
-
-class CodexAppServer:
- """Small JSON-RPC client for a one-off local Codex app-server process."""
-
- def __init__(
- self,
- codex_bin: str = "codex",
- context_window_override: int | None = None,
- codex_home: Path = DEFAULT_CODEX_HOME,
- ) -> None:
- resolved = shutil.which(codex_bin)
- if not resolved:
- raise HandoffError(f"Codex executable not found: {codex_bin}")
- command = [resolved]
- if context_window_override is not None:
- command.extend(["-c", f"model_context_window={context_window_override}"])
- command.extend(["app-server", "--stdio"])
- self.process = subprocess.Popen(
- command,
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- bufsize=1,
- env={**os.environ, "CODEX_HOME": str(codex_home.expanduser().resolve())},
- )
- self._responses: queue.Queue[dict[str, Any]] = queue.Queue()
- self._notifications: queue.Queue[dict[str, Any]] = queue.Queue()
- self._stderr: list[str] = []
- self._next_id = 1
- threading.Thread(target=self._read_stdout, daemon=True).start()
- threading.Thread(target=self._read_stderr, daemon=True).start()
- try:
- self.request(
- "initialize",
- {
- "clientInfo": {
- "name": "mem0_session_handoff",
- "title": "Mem0 local session handoff",
- "version": "0.1.0",
- }
- },
- )
- self.notify("initialized", {})
- except Exception:
- self.close()
- raise
-
- def _read_stdout(self) -> None:
- assert self.process.stdout is not None
- for line in self.process.stdout:
- try:
- message = json.loads(line)
- except json.JSONDecodeError:
- continue
- if not isinstance(message, dict):
- continue
- if "id" in message:
- self._responses.put(message)
- elif "method" in message:
- self._notifications.put(message)
-
- def _read_stderr(self) -> None:
- assert self.process.stderr is not None
- for line in self.process.stderr:
- self._stderr.append(line.rstrip())
-
- def _send(self, payload: dict[str, Any]) -> None:
- if self.process.poll() is not None:
- error = "\n".join(self._stderr[-20:])
- raise HandoffError(f"Codex app-server stopped unexpectedly.\n{error}")
- assert self.process.stdin is not None
- self.process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
- self.process.stdin.flush()
-
- def request(self, method: str, params: dict[str, Any], timeout: float = 30) -> Any:
- request_id = self._next_id
- self._next_id += 1
- self._send({"method": method, "id": request_id, "params": params})
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out on {method}.")
- try:
- response = self._responses.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out on {method}.") from exc
- if response.get("id") != request_id:
- deferred.append(response)
- continue
- if "error" in response:
- raise HandoffError(f"Codex {method} failed: {response['error']}")
- return response.get("result")
- finally:
- for response in deferred:
- self._responses.put(response)
-
- def notify(self, method: str, params: dict[str, Any]) -> None:
- self._send({"method": method, "params": params})
-
- def wait_for_notification(
- self,
- method: str,
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.") from exc
- if notification.get("method") != method:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def wait_for_any_notification(
- self,
- methods: set[str],
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.") from exc
- if notification.get("method") not in methods:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def close(self) -> None:
- if self.process.poll() is None:
- self.process.terminate()
- try:
- self.process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self.process.kill()
- self.process.wait(timeout=5)
-
- def __enter__(self) -> "CodexAppServer":
- return self
-
- def __exit__(self, *_: Any) -> None:
- self.close()
-
-
-def _item_text(item: dict[str, Any], asset_dir: Path) -> tuple[str, str]:
- """Convert one Responses item to a complete visible import message."""
- item_type = item.get("type")
- if item_type == "message":
- role = str(item.get("role") or "")
- if role not in {"user", "assistant"}:
- raise HandoffError(f"Codex's session importer cannot represent role {role!r}.")
- parts: list[str] = []
- for part in item.get("content") or []:
- if not isinstance(part, dict):
- raise HandoffError("A handoff message contains an invalid content item.")
- part_type = part.get("type")
- if part_type in {"input_text", "output_text"}:
- parts.append(str(part.get("text") or ""))
- elif part_type == "input_image":
- media_type, encoded = _data_url_payload(part.get("image_url"), "A Claude message image")
- parts.append(_image_reference(media_type, encoded, asset_dir, "A Claude message image"))
- else:
- raise HandoffError(f"Codex's session importer cannot represent content type {part_type!r}.")
- text = "\n\n".join(part for part in parts if part)
- if not text:
- raise HandoffError("A handoff message contains no transferable text.")
- return role, text
-
- if item_type == "function_call":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- arguments = str(item.get("arguments") or "{}")
- return (
- "assistant",
- f'\n{arguments}\n',
- )
-
- if item_type == "function_call_output":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- output = _tool_result_text(item.get("output"), asset_dir, f"Claude tool result {call_id}")
- return (
- "assistant",
- f'\n{output}\n',
- )
-
- raise HandoffError(f"Codex's session importer cannot represent item type {item_type!r}.")
-
-
-def _native_import_records(plan: HandoffPlan, asset_dir: Path) -> list[dict[str, Any]]:
- """Build the Claude-shaped history consumed by Codex's native importer."""
- cwd = _codex_cwd(plan)
- records: list[dict[str, Any]] = [
- {
- "type": "custom-title",
- "customTitle": plan.source.title,
- "sessionId": plan.source.session_id,
- }
- ]
- saw_user = False
- for index, item in enumerate(plan.items, 1):
- role, text = _item_text(item, asset_dir)
- saw_user = saw_user or role == "user"
- records.append(
- {
- "type": role,
- "sessionId": plan.source.session_id,
- "uuid": f"mem0-handoff-{index}",
- "cwd": cwd,
- "isSidechain": False,
- "message": {"role": role, "content": text},
- }
- )
- if not saw_user:
- raise HandoffError("The active Claude context contains no user message.")
- return records
-
-
-def _native_import_path(plan: HandoffPlan, claude_projects_dir: Path) -> Path:
- source_key = hashlib.sha256(plan.source.path.encode("utf-8")).hexdigest()[:24]
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- safe_session = safe_session[:80] or source_key
- return claude_projects_dir.expanduser().resolve() / ".mem0-handoffs" / f"{safe_session}-{source_key}.jsonl"
-
-
-def _write_native_import(plan: HandoffPlan, path: Path, asset_dir: Path) -> str:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- body = "".join(
- json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
- for record in _native_import_records(plan, asset_dir)
- )
- _write_private(path, body)
- return hashlib.sha256(body.encode("utf-8")).hexdigest()
-
-
-def _native_import_params(source_path: Path, cwd: str) -> dict[str, Any]:
- return {
- "migrationItems": [
- {
- "itemType": "SESSIONS",
- "description": f"Transfer Claude session {source_path.name}",
- "cwd": None,
- "details": {
- "plugins": [],
- "sessions": [{"path": str(source_path), "cwd": cwd, "title": None}],
- "mcpServers": [],
- "hooks": [],
- "subagents": [],
- "commands": [],
- },
- }
- ]
- }
-
-
-def _thread_id_from_completion(params: Any, source_path: Path) -> str | None:
- if not isinstance(params, dict):
- return None
- canonical = str(source_path.resolve())
- for result in params.get("itemTypeResults") or []:
- if not isinstance(result, dict) or result.get("itemType") != "SESSIONS":
- continue
- for success in result.get("successes") or []:
- if not isinstance(success, dict):
- continue
- if success.get("source") in {None, canonical} and success.get("target"):
- return str(success["target"])
- return None
-
-
-def _thread_id_from_ledger(codex_home: Path, source_path: Path, content_sha256: str) -> str | None:
- ledger_path = codex_home.expanduser() / "external_agent_session_imports.json"
- if not ledger_path.is_file():
- return None
- try:
- ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
- except json.JSONDecodeError:
- return None
- canonical = str(source_path.resolve())
- matches = [
- record
- for record in ledger.get("records", [])
- if isinstance(record, dict)
- and record.get("source_path") == canonical
- and record.get("content_sha256") == content_sha256
- and record.get("imported_thread_id")
- ]
- return str(matches[-1]["imported_thread_id"]) if matches else None
-
-
-def _notification_thread_id(params: Any) -> str | None:
- if not isinstance(params, dict):
- return None
- if params.get("threadId"):
- return str(params["threadId"])
- turn = params.get("turn")
- if isinstance(turn, dict) and turn.get("threadId"):
- return str(turn["threadId"])
- return None
-
-
-def _compact_imported_thread(
- server: CodexAppServer,
- thread_id: str,
-) -> dict[str, Any] | None:
- server.request("thread/resume", {"threadId": thread_id}, timeout=120)
- server.request("thread/compact/start", {"threadId": thread_id}, timeout=30)
-
- latest_usage: dict[str, Any] | None = None
- saw_compaction_item = False
- while True:
- notification = server.wait_for_any_notification(
- {"item/completed", "thread/tokenUsage/updated", "turn/completed", "error"},
- lambda params: _notification_thread_id(params) in {None, thread_id},
- timeout=600,
- )
- method = notification.get("method")
- params = notification.get("params")
- if method == "thread/tokenUsage/updated" and isinstance(params, dict):
- token_usage = params.get("tokenUsage")
- if isinstance(token_usage, dict):
- latest_usage = token_usage
- continue
- if method == "item/completed" and isinstance(params, dict):
- item = params.get("item")
- if isinstance(item, dict) and item.get("type") == "contextCompaction":
- saw_compaction_item = True
- continue
- if method == "error":
- error = params.get("error") if isinstance(params, dict) else params
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if method == "turn/completed" and isinstance(params, dict):
- turn = params.get("turn")
- if not isinstance(turn, dict):
- raise HandoffError("Codex returned an invalid compaction result.")
- if turn.get("status") != "completed":
- error = turn.get("error") or turn.get("status")
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if not saw_compaction_item:
- raise HandoffError("Codex completed the compaction turn without a compaction item.")
- return latest_usage
-
-
-def _set_thread_name(
- server: CodexAppServer,
- thread_id: str,
- name: str,
-) -> None:
- server.request(
- "thread/name/set",
- {"threadId": thread_id, "name": name},
- timeout=30,
- )
-
-
-def create_codex_thread(
- plan: HandoffPlan,
- codex_bin: str = "codex",
- codex_home: Path = DEFAULT_CODEX_HOME,
-) -> dict[str, Any]:
- limits = _codex_context_limits(codex_home)
- should_compact = plan.approximate_tokens >= limits.auto_compact_token_limit
- if should_compact and plan.approximate_tokens >= limits.max_auto_compact_token_limit:
- raise HandoffError(
- f"The active session state is approximately {plan.approximate_tokens:,} tokens. "
- f"Codex cannot safely compact more than approximately "
- f"{limits.max_auto_compact_token_limit:,} tokens in one request. "
- "Compact in the source host and retry the handoff."
- )
-
- # Codex only imports sources staged under its native Claude home.
- source_path = _native_import_path(plan, Path.home() / ".claude" / "projects")
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- asset_dir = (
- codex_home.expanduser().resolve()
- / "external-agent-assets"
- / plan.source.host
- / (safe_session[:80] or "session")
- )
- content_sha256 = _write_native_import(plan, source_path, asset_dir)
- try:
- context_override = limits.max_context_window if should_compact else None
- with CodexAppServer(codex_bin, context_override, codex_home=codex_home) as server:
- response = server.request(
- "externalAgentConfig/import",
- _native_import_params(source_path, _codex_cwd(plan)),
- timeout=120,
- )
- import_id = str((response or {}).get("importId") or "")
- if not import_id:
- raise HandoffError(f"Codex externalAgentConfig/import returned no import ID: {response!r}")
- completed = server.wait_for_notification(
- IMPORT_COMPLETED_NOTIFICATION,
- lambda params: isinstance(params, dict) and params.get("importId") == import_id,
- )
- completed_params = completed.get("params")
- thread_id = _thread_id_from_completion(completed_params, source_path)
- if not thread_id:
- thread_id = _thread_id_from_ledger(codex_home, source_path, content_sha256)
- if not thread_id:
- raise HandoffError(
- "Codex finished importing the session but did not report the new task ID. "
- f"Import result: {json.dumps(completed_params, ensure_ascii=False)}"
- )
-
- read = server.request(
- "thread/read",
- {"threadId": thread_id, "includeTurns": True},
- )
- thread = (read or {}).get("thread") if isinstance(read, dict) else None
- if not isinstance(thread, dict):
- raise HandoffError(f"Codex could not read imported task {thread_id}.")
- turns = thread.get("turns") or []
- preview = str(thread.get("preview") or "")
- if not turns or not preview:
- raise HandoffError(f"Codex imported task {thread_id}, but it has no visible history.")
-
- compaction_usage = _compact_imported_thread(server, thread_id) if should_compact else None
- _set_thread_name(server, thread_id, plan.source.title)
-
- return {
- "thread_id": thread_id,
- "title": plan.source.title,
- "cwd": _codex_cwd(plan),
- "source_session_id": plan.source.session_id,
- "source_host": plan.source.host,
- "visible_turns": len(turns),
- "preview": preview,
- "responses_items_converted": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "target_model": limits.model,
- "target_context_window": limits.context_window,
- "target_usable_context_window": limits.usable_context_window,
- "target_auto_compact_token_limit": limits.auto_compact_token_limit,
- "compacted_before_return": should_compact,
- "compaction_context_window": (limits.max_context_window if should_compact else None),
- "compaction_token_usage": compaction_usage,
- "model_invoked": should_compact,
- }
- finally:
- source_path.unlink(missing_ok=True)
- try:
- source_path.parent.rmdir()
- except OSError:
- pass
-
-
-def _summary(plan: HandoffPlan) -> dict[str, Any]:
- return {
- "source": plan.source.path,
- "source_host": plan.source.host,
- "session_id": plan.source.session_id,
- "title": plan.source.title,
- "source_cwd": plan.source.cwd,
- "codex_cwd": _codex_cwd(plan),
- "leaf_uuid": plan.source.leaf_uuid,
- "compact_boundary_uuid": plan.source.compact_boundary_uuid,
- "source_records": plan.source_records,
- "active_records": plan.active_records,
- "imported_records": plan.imported_records,
- "responses_items": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "warnings": plan.warnings,
- }
-
-
-def _command_output(result: dict[str, Any]) -> str:
- title = str(result["title"])
- cwd = str(result["cwd"])
- project = Path(cwd).name or cwd
- lines = [
- f'Created Codex task "{title}".',
- f"Task ID: {result['thread_id']}",
- f"Project: {project}",
- ]
- if result.get("compacted_before_return"):
- lines.append("Codex compacted the transferred context before opening the task.")
- lines.append(f'Open Codex and select "{title}" under {project}.')
- return "\n".join(lines)
-
-
-def _parse_args(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> argparse.Namespace:
- parser = argparse.ArgumentParser(description=__doc__)
- source = parser.add_mutually_exclusive_group(required=True)
- source.add_argument("--session", help="Native session transcript path (Claude also accepts its session ID)")
- parser.add_argument(
- "--source",
- choices=("claude-code", "cursor", "codex", "kimi", "antigravity", "openclaw", "pi-agent"),
- default=default_source,
- )
- parser.add_argument("--title", help="Override the imported task title")
- source.add_argument("--bundle", type=Path, help="Previously exported handoff bundle")
- parser.add_argument(
- "--claude-projects-dir",
- type=Path,
- default=Path.home() / ".claude" / "projects",
- )
- parser.add_argument("--export", type=Path, help="Write a private reusable handoff bundle")
- parser.add_argument(
- "--cwd",
- type=Path,
- help="Use this existing directory instead of the source session's directory",
- )
- parser.add_argument("--create", action="store_true", help="Create the Codex task")
- parser.add_argument(
- "--target",
- choices=("codex",),
- default="codex",
- help="Destination coding agent",
- )
- parser.add_argument(
- "--command-output",
- action="store_true",
- help="Print the short result used by Mem0's user-facing command",
- )
- parser.add_argument("--codex-bin", default="codex")
- parser.add_argument(
- "--codex-home",
- type=Path,
- default=DEFAULT_CODEX_HOME,
- )
- return parser.parse_args(argv)
-
-
-def main(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> int:
- args = _parse_args(argv, default_source)
- try:
- if args.bundle:
- plan = load_bundle(args.bundle)
- elif args.source == "claude-code":
- plan = build_plan(args.session, args.claude_projects_dir)
- else:
- if not args.source:
- raise HandoffError("--source is required with --session.")
- from handoff_sources import read_source
-
- plan = read_source(args.source, Path(args.session), cwd=args.cwd, title=args.title)
- if args.title:
- plan = replace(plan, source=replace(plan.source, title=args.title))
- plan = _with_cwd(plan, args.cwd)
- output: dict[str, Any] = {"plan": _summary(plan)}
- if args.export:
- output["bundle"] = str(write_bundle(plan, args.export))
- if args.create:
- try:
- output["codex"] = create_codex_thread(
- plan,
- codex_bin=args.codex_bin,
- codex_home=args.codex_home.expanduser(),
- )
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- fallback = args.export or _default_bundle_path(plan)
- saved = write_bundle(plan, fallback)
- raise HandoffError(f"{exc} The complete handoff was saved at {saved}.") from exc
- if args.command_output:
- if not args.create:
- raise HandoffError("--command-output requires --create.")
- print(_command_output(output["codex"]))
- else:
- print(json.dumps(output, indent=2, ensure_ascii=False))
- return 0
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- print(f"handoff failed: {exc}", file=sys.stderr)
- return 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/integrations/codex-plugin/core/handoff-runtime.json b/integrations/codex-plugin/core/handoff-runtime.json
new file mode 100644
index 000000000..b6b935e8d
--- /dev/null
+++ b/integrations/codex-plugin/core/handoff-runtime.json
@@ -0,0 +1,8 @@
+{
+ "revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
+ "files": {
+ "claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
+ "handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
+ },
+ "artifacts": ["session_handoff.py", "handoff-runtime.json"]
+}
diff --git a/integrations/codex-plugin/core/handoff_sources.py b/integrations/codex-plugin/core/handoff_sources.py
deleted file mode 100644
index 549c9969e..000000000
--- a/integrations/codex-plugin/core/handoff_sources.py
+++ /dev/null
@@ -1,452 +0,0 @@
-"""Native transcript readers; all destinations use the shared handoff importer.
-
-Formats: openai/codex rollout payloads; MoonshotAI/kimi-code contextMemory;
-Pi's session-manager.buildSessionContext; native Cursor/Antigravity transcripts.
-Unsupported state changes fail instead of silently dropping active context.
-"""
-
-from __future__ import annotations
-
-import json
-from pathlib import Path
-from typing import Any
-
-import claude_to_codex as engine
-
-
-def _message(role: str, text: str) -> dict:
- return {"role": role, "content": [{"type": "text", "text": text}]}
-
-
-def _parts(content: Any, role: str, warnings: list[str]) -> list[dict]:
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if not isinstance(part, dict):
- raise engine.HandoffError("Native message has an invalid content block.")
- kind = part.get("type")
- if kind in {"thinking", "redacted_thinking", "think"}:
- if "Hidden reasoning was excluded." not in warnings:
- warnings.append("Hidden reasoning was excluded.")
- elif kind in {"text", "input_text", "output_text"} and isinstance(part.get("text"), str):
- parts.append({"type": "input_text" if role == "user" else "output_text", "text": part["text"]})
- elif kind == "image":
- source = part.get("source") or {
- "type": "base64",
- "data": part.get("data"),
- "media_type": part.get("mimeType"),
- }
- media_type, data = engine._image_payload(source, "Native message image")
- parts.append({"type": "input_image", "image_url": f"data:{media_type};base64,{data}"})
- elif kind in {"image_url", "input_image"}:
- url = part.get("image_url")
- if isinstance(url, dict):
- url = url.get("url")
- engine._data_url_payload(url, "Native message image")
- parts.append({"type": "input_image", "image_url": url})
- elif kind not in {"toolCall", "tool_use"}:
- raise engine.HandoffError(f"Unsupported native content block: {kind!r}.")
- return parts
-
-
-def _call_item(call: dict) -> dict:
- function = call.get("function", call)
- arguments = function.get("arguments", "{}")
- return {
- "type": "function_call",
- "call_id": call.get("id"),
- "name": function.get("name"),
- "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments),
- }
-
-
-def _messages_items(messages: list[dict], warnings: list[str]) -> list[dict]:
- items = []
- for message in messages:
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid native message.")
- role = message.get("role")
- if role in {"system", "developer"}:
- if "Source harness instructions were excluded." not in warnings:
- warnings.append("Source harness instructions were excluded.")
- continue
- if role in {"tool", "toolResult"}:
- output_parts = _parts(message.get("content"), "assistant", warnings)
- output = []
- for part in output_parts:
- if part["type"] == "input_image":
- media_type, data = engine._data_url_payload(part["image_url"], "Tool result image")
- output.append(
- {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}}
- )
- else:
- output.append({"type": "text", "text": part["text"]})
- if message.get("isError"):
- output.insert(0, {"type": "text", "text": "Tool failed."})
- if message.get("note"):
- output.append({"type": "text", "text": str(message["note"])})
- item = {
- "type": "function_call_output",
- "call_id": message.get("toolCallId") or message.get("tool_call_id"),
- "output": output,
- }
- if message.get("toolName") or message.get("name"):
- item["name"] = message.get("toolName") or message["name"]
- items.append(item)
- continue
- if role not in {"user", "assistant"}:
- raise engine.HandoffError(f"Unsupported native message role: {role!r}.")
- if message.get("partial") or message.get("stopReason") in {"error", "aborted"}:
- raise engine.HandoffError("Native assistant response is incomplete; finish the source turn first.")
- content = message.get("content", [])
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if isinstance(part, dict) and part.get("type") in {"toolCall", "tool_use"}:
- if role != "assistant":
- raise engine.HandoffError("Native user message contains an assistant tool call.")
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- parts = []
- items.append(
- _call_item(
- {
- "id": part.get("id"),
- "name": part.get("name"),
- "arguments": json.dumps(part.get("arguments", part.get("input", {}))),
- }
- )
- )
- else:
- parts.extend(_parts([part], role, warnings))
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- for call in message.get("toolCalls") or message.get("tool_calls") or []:
- items.append(_call_item(call))
- return items
-
-
-def _codex(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Native Responses items are the authoritative history, event_msg is UI data.
- items, source = [], {}
- for record in records:
- kind, payload = record.get("type"), record.get("payload")
- if not isinstance(payload, dict):
- raise engine.HandoffError("Invalid Codex rollout payload.")
- if kind == "session_meta":
- source.update(session_id=payload.get("id"), cwd=payload.get("cwd"))
- elif kind == "compacted":
- replacement = payload.get("replacement_history")
- if not isinstance(replacement, list) or not replacement:
- raise engine.HandoffError(
- "Codex compaction is opaque; a complete plaintext replacement history is required."
- )
- items = list(replacement)
- elif kind == "response_item":
- items.append(payload)
- elif kind == "event_msg":
- if payload.get("type") == "thread_rolled_back":
- raise engine.HandoffError("Codex rollback requires a native active-context export.")
- elif kind != "turn_context":
- raise engine.HandoffError(f"Unsupported Codex rollout record: {kind!r}.")
- result = []
- for item in items:
- kind = item.get("type")
- if kind == "reasoning":
- warnings.append("Hidden reasoning was excluded.")
- elif kind == "message" and item.get("role") in {"system", "developer"}:
- warnings.append("Source harness instructions were excluded.")
- elif kind == "custom_tool_call":
- result.append(
- {
- "type": "function_call",
- "call_id": item.get("call_id"),
- "name": item.get("name"),
- "arguments": json.dumps({"input": item.get("input")}),
- }
- )
- elif kind == "custom_tool_call_output":
- result.append({**item, "type": "function_call_output"})
- elif kind == "compaction":
- raise engine.HandoffError(
- "Codex compaction contains opaque model state; it cannot be transferred losslessly."
- )
- else:
- result.append(dict(item))
- return result, source
-
-
-def _cursor(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Cursor's persisted transcript uses role + message.content, without Claude's parent chain.
- converted = []
- source = {}
- for index, record in enumerate(records):
- role = record.get("role") or record.get("type")
- if role not in {"user", "assistant"} or not isinstance(record.get("message"), dict):
- raise engine.HandoffError("Unsupported Cursor transcript record; provide a complete native JSONL export.")
- converted.append({**record, "type": role, "uuid": str(index)})
- if record.get("session_id"):
- source["session_id"] = record["session_id"]
- if record.get("cwd"):
- source["cwd"] = record["cwd"]
- items, skipped = engine._responses_items(converted)
- if skipped:
- warnings.append("Hidden reasoning was excluded.")
- return items, source
-
-
-def _antigravity(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- messages = []
- for step in records:
- if step.get("status") != "DONE":
- raise engine.HandoffError("Antigravity has an unfinished transcript step; finish the source turn first.")
- kind, content = step.get("type"), step.get("content")
- if not isinstance(content, str):
- raise engine.HandoffError("Antigravity transcript content is not transferable text.")
- if kind == "USER_INPUT":
- messages.append(_message("user", content))
- elif kind == "PLANNER_RESPONSE" and step.get("source") == "MODEL":
- messages.append(_message("assistant", content))
- else:
- raise engine.HandoffError(
- f"Unsupported Antigravity step {kind!r}; its visible conversation semantics are not verified."
- )
- return _messages_items(messages, warnings), {}
-
-
-def _kimi_compact(messages: list[dict], record: dict) -> list[dict]:
- summary = record.get("contextSummary", record.get("summary"))
- if isinstance(summary, dict):
- summary_message = summary
- elif isinstance(summary, str):
- summary_message = {**_message("user", summary), "origin": {"kind": "compaction_summary"}}
- else:
- raise engine.HandoffError("Kimi compaction has no transferable summary.")
- if record.get("legacyTail") or "keptUserMessageCount" not in record:
- count = record.get("compactedCount", record.get("count"))
- if not isinstance(count, int) or not 0 <= count <= len(messages):
- raise engine.HandoffError("Invalid Kimi compaction boundary.")
- return [summary_message, *messages[count:]]
- users = []
- for message in messages:
- origin = message.get("origin") or {}
- if message.get("role") == "user" and (
- origin.get("kind") in {None, "user"}
- or (origin.get("kind") in {"skill_activation", "plugin_command"} and origin.get("trigger") == "user-slash")
- ):
- users.append(message)
- # Kimi trims user inputs above this native budget. Do not approximate that destructive rewrite.
- tokens = 0
- for message in users:
- if message.get("toolCalls"):
- raise engine.HandoffError("Unsupported Kimi compaction user tool calls.")
- tokens += 1 # estimateTokens('user')
- for part in message.get("content", []):
- if part.get("type") not in {"text", "think"}:
- tokens += 2000
- else:
- text = part.get("text", part.get("think", ""))
- ascii_count = sum(ord(char) <= 127 for char in text)
- tokens += (ascii_count + 3) // 4 + len(text) - ascii_count
- if tokens > 20000 or record.get("keptHeadUserMessageCount"):
- raise engine.HandoffError("Kimi compaction elided user content; use a native active-context bundle export.")
- continuation = _message(
- "user",
- "\nContext compaction is complete — continue the work that was in progress when it began.\n",
- )
- return [*users, summary_message, continuation]
-
-
-def _kimi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Mirrors Kimi v2 context.append_message and completed loop events, not UI stream fragments.
- messages, source = [], {}
- opened, step_id = None, None
- for record in records:
- if record.get("agentId") not in {None, "main"}:
- continue
- kind = record.get("type", "")
- if kind in {"profile.bind", "config.update"}:
- cwd = (record.get("environmentDisclosure") or {}).get("cwd") or record.get("cwd")
- if cwd:
- source["cwd"] = cwd
- elif kind == "context.append_message":
- if opened is not None:
- raise engine.HandoffError("Kimi interleaved messages require a completed native context export.")
- messages.append(record.get("message"))
- elif kind == "context.append_loop_event":
- event = record.get("event") or {}
- event_type = event.get("type")
- if event_type == "step.begin":
- if opened is not None:
- raise engine.HandoffError("Kimi previous response did not complete.")
- step_id = event.get("uuid")
- opened = {"role": "assistant", "content": [], "toolCalls": []}
- messages.append(opened)
- elif event_type == "step.end":
- if event.get("uuid") != step_id or event.get("finishReason") in {"error", "interrupted"}:
- raise engine.HandoffError("Kimi response is incomplete or interrupted.")
- opened, step_id = None, None
- elif event_type in {"content.part", "tool.call"}:
- if opened is None or event.get("stepUuid") != step_id:
- raise engine.HandoffError("Kimi content has no matching active response.")
- if event_type == "content.part":
- opened["content"].append(event.get("part"))
- else:
- opened["toolCalls"].append(
- {
- "id": event.get("toolCallId"),
- "name": event.get("name"),
- "arguments": json.dumps(event.get("args", {})),
- }
- )
- elif event_type == "tool.result":
- result = event.get("result") or {}
- messages.append(
- {
- "role": "tool",
- "toolCallId": event.get("toolCallId"),
- "content": result.get("output"),
- "isError": result.get("isError"),
- "note": result.get("note"),
- }
- )
- else:
- raise engine.HandoffError(f"Unsupported Kimi loop event: {event_type!r}.")
- elif kind == "context.clear":
- messages, opened, step_id = [], None, None
- elif kind == "context.apply_compaction":
- if opened is not None:
- raise engine.HandoffError("Kimi compaction began during an unfinished response.")
- messages = _kimi_compact(messages, record)
- elif kind in {"context.undo", "micro_compaction.apply", "context.spliced"}:
- raise engine.HandoffError(f"Kimi {kind} needs a native active-context export to preserve its state.")
- elif kind.startswith("context.") and kind != "context.update_token_count":
- raise engine.HandoffError(f"Unsupported Kimi context event: {kind!r}.")
- # Remaining durable events configure Kimi's harness; they are not model messages.
- if opened is not None:
- raise engine.HandoffError("Kimi response is still streaming.")
- return _messages_items(messages, warnings), source
-
-
-def _pi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- header = records[0]
- if header.get("type") != "session":
- raise engine.HandoffError("Pi/OpenClaw transcript has no session header.")
- entries = [record for record in records[1:] if isinstance(record.get("id"), str)]
- if len(entries) != len(records) - 1:
- raise engine.HandoffError("Pi/OpenClaw transcript entry has no ID.")
- index = {entry["id"]: entry for entry in entries}
- if len(index) != len(entries):
- raise engine.HandoffError("Pi/OpenClaw transcript has duplicate entry IDs.")
- chain, seen = [], set()
- current = entries[-1] if entries else None
- while current:
- if current["id"] in seen:
- raise engine.HandoffError("Pi/OpenClaw transcript has a parent cycle.")
- seen.add(current["id"])
- chain.append(current)
- parent = current.get("parentId")
- if parent is not None and parent not in index:
- raise engine.HandoffError("Pi/OpenClaw transcript has a missing parent.")
- current = index.get(parent)
- chain.reverse()
- title = next((entry.get("name") for entry in reversed(chain) if entry.get("type") == "session_info"), None)
- messages = []
- boundary = next((i for i in range(len(chain) - 1, -1, -1) if chain[i].get("type") == "compaction"), None)
- if boundary is not None:
- compact = chain[boundary]
- if not isinstance(compact.get("summary"), str):
- raise engine.HandoffError("Pi/OpenClaw compaction has no summary.")
- messages.append(
- _message(
- "user",
- f"The conversation history before this point was compacted into the following summary:\n\n\n{compact['summary']}\n",
- )
- )
- kept = next((i for i in range(boundary) if chain[i]["id"] == compact.get("firstKeptEntryId")), boundary)
- chain = chain[kept:boundary] + chain[boundary + 1 :]
- for entry in chain:
- kind = entry.get("type")
- if kind == "message":
- message = entry.get("message")
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid Pi/OpenClaw message.")
- if message.get("role") == "bashExecution":
- if message.get("excludeFromContext"):
- continue
- if message.get("truncated"):
- raise engine.HandoffError("Pi/OpenClaw shell output is truncated; provide a complete bundle.")
- text = f"Ran `{message.get('command', '')}`\n"
- text += f"```\n{message['output']}\n```" if message.get("output") else "(no output)"
- if message.get("cancelled"):
- text += "\n\n(command cancelled)"
- elif message.get("exitCode") not in {None, 0}:
- text += f"\n\nCommand exited with code {message['exitCode']}"
- message = _message("user", text)
- messages.append(message)
- elif kind == "branch_summary":
- messages.append(
- _message(
- "user",
- f"The following is a summary of a branch that this conversation came back from:\n\n\n{entry['summary']}",
- )
- )
- elif kind == "custom_message":
- messages.append({"role": "user", "content": entry.get("content")})
- elif kind == "session_info":
- title = entry.get("name")
- elif kind not in {"model_change", "thinking_level_change", "custom", "label"}:
- raise engine.HandoffError(f"Unsupported Pi/OpenClaw entry: {kind!r}.")
- return _messages_items(messages, warnings), {
- "session_id": header.get("id"),
- "cwd": header.get("cwd"),
- "title": title,
- }
-
-
-def read_source(host: str, path: Path, *, cwd: Path | None = None, title: str | None = None) -> engine.HandoffPlan:
- path = path.expanduser().resolve()
- records, digest = engine._stable_jsonl(path)
- warnings: list[str] = []
- readers = {
- "cursor": _cursor,
- "codex": _codex,
- "kimi": _kimi,
- "antigravity": _antigravity,
- "openclaw": _pi,
- "pi-agent": _pi,
- }
- try:
- items, metadata = readers[host](records, warnings)
- except (TypeError, AttributeError, KeyError, ValueError) as exc:
- raise engine.HandoffError(f"Invalid {host} native transcript structure: {exc}") from exc
- if host == "kimi" and path.name == "wire.jsonl" and path.parent.name == "main":
- metadata.setdefault("session_id", path.parents[2].name)
- state = path.parents[2] / "state.json"
- if state.is_file():
- try:
- metadata.setdefault("title", json.loads(state.read_text()).get("title"))
- except (json.JSONDecodeError, AttributeError):
- pass
- if host == "antigravity" and path.name == "transcript.jsonl" and path.parent.name == "logs":
- metadata.setdefault("session_id", path.parents[2].name)
- source_cwd = str(cwd.expanduser().resolve()) if cwd else metadata.get("cwd")
- if not source_cwd:
- raise engine.HandoffError(f"{host} transcript has no working directory; provide --cwd.")
- source = {
- "host": host,
- "path": str(path),
- "sha256": digest,
- "session_id": metadata.get("session_id") or path.stem,
- "cwd": source_cwd,
- "title": title or metadata.get("title") or f"{host} session {path.stem[:12]}",
- }
- return engine.plan_from_bundle(
- {"format": engine.FORMAT_VERSION, "source": source, "items": items, "warnings": list(dict.fromkeys(warnings))}
- )
diff --git a/integrations/codex-plugin/core/memory_core.py b/integrations/codex-plugin/core/memory_core.py
index 1e99f99e7..93a3fde15 100644
--- a/integrations/codex-plugin/core/memory_core.py
+++ b/integrations/codex-plugin/core/memory_core.py
@@ -29,7 +29,7 @@ from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
-PLUGIN_VERSION = "0.4.0"
+PLUGIN_VERSION = "0.3.2"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
diff --git a/integrations/codex-plugin/core/session_handoff.py b/integrations/codex-plugin/core/session_handoff.py
index 4aada69e6..b7108f336 100644
--- a/integrations/codex-plugin/core/session_handoff.py
+++ b/integrations/codex-plugin/core/session_handoff.py
@@ -1,7 +1,81 @@
#!/usr/bin/env python3
-"""Import an explicit native session or neutral handoff bundle into Codex."""
+"""Run the shared handoff engine locally or from its verified immutable cache."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import sys
+import tempfile
+from pathlib import Path
+from urllib.request import urlopen
+
+ENGINE_FILES = {"claude_to_codex.py", "handoff_sources.py"}
+SOURCE_URL = "https://raw.githubusercontent.com/mem0ai/mem0"
+
+
+def _verified(path: Path, digest: str) -> bool:
+ try:
+ return hashlib.sha256(path.read_bytes()).hexdigest() == digest
+ except FileNotFoundError:
+ return False
+
+
+def runtime_root(launcher_dir: Path | None = None) -> Path:
+ here = launcher_dir or Path(__file__).resolve().parent
+ if all((here / name).is_file() for name in ENGINE_FILES):
+ return here # The canonical development checkout already has both engines.
+ manifest = json.loads((here / "handoff-runtime.json").read_text(encoding="utf-8"))
+ if not isinstance(manifest, dict):
+ raise ValueError("invalid handoff runtime manifest")
+ revision, files = manifest.get("revision"), manifest.get("files")
+ if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
+ raise ValueError("handoff runtime revision must be an immutable commit SHA")
+ if (
+ not isinstance(files, dict)
+ or set(files) != ENGINE_FILES
+ or any(not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) for digest in files.values())
+ ):
+ raise ValueError("invalid handoff runtime file hashes")
+ cache = Path.home() / ".mem0" / "handoff-runtime" / revision
+ missing = {name: digest for name, digest in files.items() if not _verified(cache / name, digest)}
+ if not missing:
+ return cache
+ cache.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with tempfile.TemporaryDirectory(prefix=f".{revision}-", dir=cache.parent) as temporary:
+ staged = Path(temporary)
+ for name, digest in missing.items():
+ url = f"{SOURCE_URL}/{revision}/integrations/agent-plugin-core/python/{name}"
+ try:
+ with urlopen(url, timeout=30) as response:
+ body = response.read()
+ except OSError as exc:
+ raise OSError(f"could not download pinned handoff runtime {name}: {exc}") from exc
+ if hashlib.sha256(body).hexdigest() != digest:
+ raise ValueError(f"SHA256 mismatch for pinned handoff runtime {name}; refusing to execute it")
+ (staged / name).write_bytes(body)
+ # All downloads are verified before publishing; each replacement is atomic.
+ cache.mkdir(exist_ok=True, mode=0o700)
+ for name in missing:
+ os.replace(staged / name, cache / name)
+ if not all(_verified(cache / name, digest) for name, digest in files.items()):
+ raise ValueError("handoff runtime cache changed during installation; refusing to execute it")
+ return cache
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ root = runtime_root()
+ except (OSError, ValueError) as exc:
+ print(f"handoff runtime unavailable: {exc}", file=sys.stderr)
+ return 1
+ sys.path.insert(0, str(root))
+ from claude_to_codex import main as engine_main
+
+ return engine_main(argv, default_source=None)
-from claude_to_codex import main
if __name__ == "__main__":
- raise SystemExit(main(default_source=None))
+ raise SystemExit(main())
diff --git a/integrations/codex-plugin/plugin-build.json b/integrations/codex-plugin/plugin-build.json
index a5f0538f0..7f5fc94f6 100644
--- a/integrations/codex-plugin/plugin-build.json
+++ b/integrations/codex-plugin/plugin-build.json
@@ -1,6 +1,6 @@
{
"id": "mem0",
- "version": "0.4.0",
+ "version": "0.3.2",
"homepage": "https://docs.mem0.ai/integrations/codex",
"native": {
"pluginRoot": "${PLUGIN_ROOT}",
diff --git a/integrations/codex-plugin/skills/handoff/SKILL.md b/integrations/codex-plugin/skills/handoff/SKILL.md
index fb3c51bc8..5b43e48f0 100644
--- a/integrations/codex-plugin/skills/handoff/SKILL.md
+++ b/integrations/codex-plugin/skills/handoff/SKILL.md
@@ -9,7 +9,9 @@ allowed-tools: Bash(python3 ${PLUGIN_ROOT}/core/session_handoff.py *)
All hosts share one local import engine. Native readers and SDK adapters supply
complete conversation items; Mem0 memory capture is not a transcript source.
-Requires Python 3.11+ and a Codex CLI with native session import support. The
+Requires Python 3.11+ and a Codex CLI with native session import support.
+First use downloads a pinned, hash-verified runtime from GitHub; later uses share
+the verified local cache. No transcript is sent to GitHub. The
supported destination is Codex. This does not transfer files or change branches.
Visible conversation, tool history, and supported source compaction summaries
diff --git a/integrations/cursor-plugin/.cursor-plugin/plugin.json b/integrations/cursor-plugin/.cursor-plugin/plugin.json
index c8e421e74..e40266924 100644
--- a/integrations/cursor-plugin/.cursor-plugin/plugin.json
+++ b/integrations/cursor-plugin/.cursor-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "mem0",
- "version": "0.4.0",
+ "version": "0.3.2",
"description": "Cross-session memory and token savings for coding agents.",
"author": { "name": "Mem0", "email": "support@mem0.ai" },
"homepage": "https://docs.mem0.ai/integrations/cursor",
diff --git a/integrations/cursor-plugin/CHANGELOG.md b/integrations/cursor-plugin/CHANGELOG.md
new file mode 100644
index 000000000..c7a1d65d0
--- /dev/null
+++ b/integrations/cursor-plugin/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+## 0.3.2
+
+- The `handoff` skill accepts a completed Cursor JSONL transcript and its project directory. Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
+- Lightened search prompts: search when prior work may help; repeat only for a specific gap.
diff --git a/integrations/cursor-plugin/core/claude_to_codex.py b/integrations/cursor-plugin/core/claude_to_codex.py
deleted file mode 100644
index fd372e198..000000000
--- a/integrations/cursor-plugin/core/claude_to_codex.py
+++ /dev/null
@@ -1,1492 +0,0 @@
-#!/usr/bin/env python3
-"""Shared local session-handoff engine and backwards-compatible Claude CLI.
-
-Native readers and SDK adapters supply complete conversation items. This engine
-validates and exports their bundles, then uses Codex's native external-session
-importer to create a task with visible historical turns. The legacy command
-still defaults to reading a Claude Code transcript; session_handoff.py requires
-an explicit source host or a neutral bundle.
-
-No model generates a handoff summary. Large imports may use Codex's native
-compaction before the new task is returned.
-"""
-
-# Adapted from mem0ai/memo at aeeb1593284d1d2fca3b4bcf1e32ea10f71df549 (Apache-2.0).
-from __future__ import annotations
-
-import argparse
-import base64
-import binascii
-import hashlib
-import html
-import json
-import os
-import queue
-import re
-import shutil
-import subprocess
-import sys
-import tempfile
-import threading
-import time
-from dataclasses import asdict, dataclass, replace
-from pathlib import Path
-from typing import Any, Iterable
-
-FORMAT_VERSION = "mem0.session-handoff.v1"
-DEFAULT_CODEX_HOME = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
-DEFAULT_BUNDLE_DIR = Path.home() / ".mem0" / "handoffs"
-IMPORT_COMPLETED_NOTIFICATION = "externalAgentConfig/import/completed"
-IMAGE_EXTENSIONS = {
- "image/gif": "gif",
- "image/jpeg": "jpg",
- "image/png": "png",
- "image/webp": "webp",
-}
-
-
-class HandoffError(RuntimeError):
- """A source session cannot be transferred without losing state."""
-
-
-@dataclass(frozen=True)
-class SourceInfo:
- path: str
- sha256: str
- session_id: str
- title: str
- cwd: str
- leaf_uuid: str
- compact_boundary_uuid: str | None
- first_imported_uuid: str
- last_imported_uuid: str
- codex_cwd: str | None = None
- host: str = "claude-code"
-
-
-@dataclass
-class HandoffPlan:
- source: SourceInfo
- items: list[dict[str, Any]]
- source_records: int
- active_records: int
- imported_records: int
- hidden_reasoning_blocks_skipped: int
- approximate_tokens: int
- warnings: list[str]
-
- def bundle(self) -> dict[str, Any]:
- return {
- "format": FORMAT_VERSION,
- "source": asdict(self.source),
- "items": self.items,
- "counts": {
- "source_records": self.source_records,
- "active_records": self.active_records,
- "imported_records": self.imported_records,
- "responses_items": len(self.items),
- "hidden_reasoning_blocks_skipped": self.hidden_reasoning_blocks_skipped,
- "approximate_tokens": self.approximate_tokens,
- },
- "warnings": self.warnings,
- }
-
-
-@dataclass(frozen=True)
-class CodexContextLimits:
- model: str
- context_window: int
- usable_context_window: int
- auto_compact_token_limit: int
- max_context_window: int
- max_usable_context_window: int
- max_auto_compact_token_limit: int
-
-
-def _stable_jsonl(path: Path) -> tuple[list[dict[str, Any]], str]:
- before = path.stat()
- raw = path.read_bytes()
- after = path.stat()
- if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
- raise HandoffError(f"Source session changed while it was being read: {path}")
- if raw and not raw.endswith(b"\n"):
- raise HandoffError(
- "The final JSONL record is incomplete. Finish or stop the active source response before transferring it."
- )
-
- records: list[dict[str, Any]] = []
- for line_number, line in enumerate(raw.splitlines(), 1):
- if not line.strip():
- continue
- try:
- record = json.loads(line)
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid source JSONL at {path}:{line_number}: {exc}") from exc
- if not isinstance(record, dict):
- raise HandoffError(f"Source JSONL record is not an object at {path}:{line_number}.")
- records.append(record)
- if not records:
- raise HandoffError(f"Source session is empty: {path}")
- return records, hashlib.sha256(raw).hexdigest()
-
-
-def _resolve_session(value: str, projects_dir: Path) -> Path:
- supplied = Path(value).expanduser()
- if supplied.is_file():
- return supplied.resolve()
-
- matches = list(projects_dir.glob(f"*/{value}.jsonl"))
- if not matches:
- raise HandoffError(
- f"No Claude session named {value!r} exists below {projects_dir}. "
- "Pass the session ID or its full JSONL path."
- )
- if len(matches) != 1:
- joined = "\n".join(f" {path}" for path in matches)
- raise HandoffError(f"Session ID {value!r} is ambiguous:\n{joined}")
- return matches[0].resolve()
-
-
-def _active_chain(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
- with_uuid = [
- record for record in records if isinstance(record.get("uuid"), str) and record.get("isSidechain") is not True
- ]
- if not with_uuid:
- raise HandoffError("Claude session has no main-agent conversation records.")
-
- by_uuid = {record["uuid"]: record for record in with_uuid}
- leaf = with_uuid[-1]
- chain: list[dict[str, Any]] = []
- seen: set[str] = set()
- current: dict[str, Any] | None = leaf
- while current is not None:
- uuid = current["uuid"]
- if uuid in seen:
- raise HandoffError(f"Claude session contains a parent cycle at {uuid}.")
- seen.add(uuid)
- chain.append(current)
- parent_uuid = current.get("parentUuid")
- if parent_uuid is None:
- break
- current = by_uuid.get(parent_uuid)
- if current is None:
- raise HandoffError(f"Claude's active branch references missing parent {parent_uuid}.")
- chain.reverse()
- return chain
-
-
-def _after_latest_compaction(
- chain: list[dict[str, Any]],
-) -> tuple[list[dict[str, Any]], str | None]:
- compact_index: int | None = None
- for index, record in enumerate(chain):
- if record.get("type") == "system" and record.get("subtype") == "compact_boundary":
- compact_index = index
- if compact_index is None:
- imported = chain
- compact_uuid = None
- else:
- imported = chain[compact_index + 1 :]
- compact_uuid = chain[compact_index]["uuid"]
- if not imported or imported[0].get("isCompactSummary") is not True:
- raise HandoffError(f"Claude compaction {compact_uuid} has no following compact summary.")
- imported = [record for record in imported if record.get("type") != "system"]
- if not imported:
- raise HandoffError("Claude's active state contains no transferable records.")
- return imported, compact_uuid
-
-
-def _tool_result_ids(record: dict[str, Any]) -> set[str]:
- if record.get("type") != "user":
- return set()
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- return set()
- return {
- str(block["tool_use_id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("tool_use_id")
- }
-
-
-def _tool_call_ids(records: list[dict[str, Any]]) -> set[str]:
- call_ids: set[str] = set()
- for record in records:
- if record.get("type") != "assistant":
- continue
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- continue
- call_ids.update(
- str(block["id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id")
- )
- return call_ids
-
-
-def _merge_parallel_tool_results(
- active_records: list[dict[str, Any]], all_records: list[dict[str, Any]]
-) -> list[dict[str, Any]]:
- """Restore sibling tool results that Claude stores outside the parent chain.
-
- Parallel Claude tool calls form a fork: later calls remain on the parent
- chain, while earlier results can be sibling records. Claude sends all of
- those results back to the model. Insert them together immediately after the
- assistant response that issued the calls.
- """
- results_by_call: dict[str, list[tuple[int, dict[str, Any]]]] = {}
- for source_index, record in enumerate(all_records):
- for call_id in _tool_result_ids(record):
- results_by_call.setdefault(call_id, []).append((source_index, record))
-
- merged: list[dict[str, Any]] = []
- inserted_result_uuids: set[str] = set()
- index = 0
- while index < len(active_records):
- record = active_records[index]
- record_uuid = str(record.get("uuid") or "")
- if record_uuid in inserted_result_uuids:
- index += 1
- continue
- if record.get("type") != "assistant":
- merged.append(record)
- index += 1
- continue
-
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(active_records):
- candidate = active_records[index]
- candidate_id = (candidate.get("message") or {}).get("id")
- if candidate.get("type") != "assistant" or not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- merged.extend(group)
-
- matching_results: list[tuple[int, dict[str, Any]]] = []
- for call_id in _tool_call_ids(group):
- matching_results.extend(results_by_call.get(call_id, []))
- for _, result in sorted(matching_results, key=lambda pair: pair[0]):
- result_uuid = str(result.get("uuid") or "")
- if result_uuid and result_uuid not in inserted_result_uuids:
- merged.append(result)
- inserted_result_uuids.add(result_uuid)
- return merged
-
-
-def _image_payload(source: Any, context: str) -> tuple[str, str]:
- if not isinstance(source, dict) or source.get("type") != "base64":
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = str(source.get("media_type") or "").lower()
- data = source.get("data")
- if media_type not in IMAGE_EXTENSIONS or not isinstance(data, str) or not data:
- raise HandoffError(f"{context} has an unsupported or missing image type.")
- return media_type, data
-
-
-def _data_url_payload(image_url: Any, context: str) -> tuple[str, str]:
- if not isinstance(image_url, str):
- raise HandoffError(f"{context} has no transferable image data.")
- match = re.fullmatch(r"data:([^;,]+);base64,(.+)", image_url, flags=re.DOTALL)
- if not match:
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = match.group(1).lower()
- if media_type not in IMAGE_EXTENSIONS:
- raise HandoffError(f"{context} has unsupported image type {media_type!r}.")
- return media_type, match.group(2)
-
-
-def _save_image(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> Path:
- try:
- payload = base64.b64decode(encoded, validate=True)
- except (binascii.Error, ValueError) as exc:
- raise HandoffError(f"{context} contains invalid base64 image data.") from exc
- if not payload:
- raise HandoffError(f"{context} contains an empty image.")
-
- digest = hashlib.sha256(payload).hexdigest()
- asset_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
- path = asset_dir / f"{digest}.{IMAGE_EXTENSIONS[media_type]}"
- if path.exists():
- if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
- raise HandoffError(f"The existing handoff image is corrupted: {path}")
- return path
-
- descriptor, filename = tempfile.mkstemp(prefix=f".{path.name}.", dir=asset_dir)
- temporary = Path(filename)
- try:
- with os.fdopen(descriptor, "wb") as stream:
- stream.write(payload)
- os.replace(temporary, path)
- except OSError as exc:
- temporary.unlink(missing_ok=True)
- raise HandoffError(f"Could not save the handoff image at {path}: {exc}") from exc
- return path
-
-
-def _image_reference(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> str:
- path = _save_image(media_type, encoded, asset_dir, context)
- return f"[Image saved at {path}]"
-
-
-def _tool_result_text(value: Any, asset_dir: Path, context: str) -> str:
- if value is None:
- return ""
- if isinstance(value, str):
- return value
- if isinstance(value, (int, float, bool)):
- return str(value)
- if isinstance(value, list):
- parts: list[str] = []
- for part in value:
- if isinstance(part, dict) and part.get("type") == "text":
- parts.append(str(part.get("text", "")))
- elif isinstance(part, dict) and part.get("type") == "image":
- media_type, encoded = _image_payload(part.get("source"), context)
- parts.append(_image_reference(media_type, encoded, asset_dir, context))
- else:
- parts.append(json.dumps(part, ensure_ascii=False, separators=(",", ":")))
- return "\n".join(part for part in parts if part)
- if isinstance(value, dict) and value.get("type") == "image":
- media_type, encoded = _image_payload(value.get("source"), context)
- return _image_reference(media_type, encoded, asset_dir, context)
- return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
-
-
-def _message(role: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
- return {"type": "message", "role": role, "content": parts}
-
-
-def _attachment_item(record: dict[str, Any]) -> dict[str, Any] | None:
- attachment = record.get("attachment")
- if not isinstance(attachment, dict):
- raise HandoffError(f"Claude attachment {record.get('uuid')} has no payload.")
-
- attachment_type = attachment.get("type")
- filename = str(attachment.get("filename") or attachment.get("displayPath") or "unknown")
- content = attachment.get("content")
- if attachment_type == "file" and isinstance(content, dict):
- file_payload = content.get("file") if content.get("type") == "text" else None
- if isinstance(file_payload, dict) and isinstance(file_payload.get("content"), str):
- text = file_payload["content"]
- display = str(file_payload.get("filePath") or filename)
- wrapped = f'\n{text}\n'
- return _message("user", [{"type": "input_text", "text": wrapped}])
-
- if attachment_type == "image" and isinstance(content, dict):
- image_url = content.get("image_url") or content.get("data")
- if isinstance(image_url, str) and image_url.startswith("data:"):
- return _message("user", [{"type": "input_image", "image_url": image_url}])
-
- if attachment_type in {"file", "image"}:
- raise HandoffError(f"Claude {attachment_type} attachment {record.get('uuid')} has an unsupported payload.")
-
- # Claude also records its own skill list, tool availability, permissions,
- # token reminders, hooks, and task status as attachments. Those configure
- # Claude's harness; they are not part of the user's project conversation and
- # must not become user messages in Codex.
- return None
-
-
-def _assistant_items(records: list[dict[str, Any]], calls: dict[str, str]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- skipped_reasoning = 0
- text_parts: list[dict[str, Any]] = []
-
- def flush_text() -> None:
- if text_parts:
- items.append(_message("assistant", list(text_parts)))
- text_parts.clear()
-
- for record in records:
- content = (record.get("message") or {}).get("content", [])
- if isinstance(content, str):
- text_parts.append({"type": "output_text", "text": content})
- continue
- if not isinstance(content, list):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid content.")
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "thinking" or kind == "redacted_thinking":
- skipped_reasoning += 1
- continue
- if kind == "text":
- text_parts.append({"type": "output_text", "text": str(block.get("text", ""))})
- continue
- if kind == "tool_use":
- flush_text()
- call_id = str(block.get("id") or "")
- name = str(block.get("name") or "")
- if not call_id or not name:
- raise HandoffError(f"Claude tool call in {record.get('uuid')} has no ID or name.")
- if call_id in calls:
- raise HandoffError(f"Claude tool call ID is duplicated: {call_id}")
- calls[call_id] = name
- items.append(
- {
- "type": "function_call",
- "call_id": call_id,
- "name": name,
- "arguments": json.dumps(
- block.get("input", {}),
- ensure_ascii=False,
- separators=(",", ":"),
- ),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude assistant block {kind!r} in {record.get('uuid')}.")
- flush_text()
- return items, skipped_reasoning
-
-
-def _user_items(record: dict[str, Any], calls: dict[str, str], completed_calls: set[str]) -> list[dict[str, Any]]:
- if record.get("isMeta") is True:
- return []
- content = (record.get("message") or {}).get("content")
- if isinstance(content, str):
- return [_message("user", [{"type": "input_text", "text": content}])]
- if not isinstance(content, list):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid content.")
-
- items: list[dict[str, Any]] = []
- user_parts: list[dict[str, Any]] = []
-
- def flush_user() -> None:
- if user_parts:
- items.append(_message("user", list(user_parts)))
- user_parts.clear()
-
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "text":
- user_parts.append({"type": "input_text", "text": str(block.get("text", ""))})
- continue
- if kind == "image":
- source = block.get("source") or {}
- if source.get("type") == "base64" and source.get("data") and source.get("media_type"):
- user_parts.append(
- {
- "type": "input_image",
- "image_url": f"data:{source['media_type']};base64,{source['data']}",
- }
- )
- continue
- raise HandoffError(f"Claude image in {record.get('uuid')} is not stored as transferable base64 data.")
- if kind == "tool_result":
- flush_user()
- call_id = str(block.get("tool_use_id") or "")
- if not call_id:
- raise HandoffError(f"Claude tool result in {record.get('uuid')} has no call ID.")
- if call_id not in calls:
- raise HandoffError(f"Claude tool result {call_id} has no matching call in the active state.")
- if call_id in completed_calls:
- raise HandoffError(f"Claude tool result is duplicated: {call_id}")
- completed_calls.add(call_id)
- items.append(
- {
- "type": "function_call_output",
- "call_id": call_id,
- "name": calls[call_id],
- "output": block.get("content"),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude user block {kind!r} in {record.get('uuid')}.")
- flush_user()
- return items
-
-
-def _responses_items(records: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- calls: dict[str, str] = {}
- completed_calls: set[str] = set()
- skipped_reasoning = 0
-
- index = 0
- while index < len(records):
- record = records[index]
- record_type = record.get("type")
- if record_type == "assistant":
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(records):
- candidate = records[index]
- if candidate.get("type") != "assistant":
- break
- candidate_id = (candidate.get("message") or {}).get("id")
- if not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- assistant_items, skipped = _assistant_items(group, calls)
- items.extend(assistant_items)
- skipped_reasoning += skipped
- continue
- if record_type == "user":
- items.extend(_user_items(record, calls, completed_calls))
- elif record_type == "attachment":
- attachment_item = _attachment_item(record)
- if attachment_item is not None:
- items.append(attachment_item)
- elif record_type not in {"system"}:
- raise HandoffError(f"Unsupported model-visible Claude record {record_type!r} at {record.get('uuid')}.")
- index += 1
-
- unfinished = sorted(set(calls) - completed_calls)
- if unfinished:
- joined = ", ".join(unfinished[:5])
- raise HandoffError(
- f"Claude's active state ends with unfinished tool call(s): {joined}. "
- "Finish or stop the Claude turn before transferring it."
- )
- if not items:
- raise HandoffError("Claude's active state produced no Codex history items.")
- return items, skipped_reasoning
-
-
-def _without_image_payloads(value: Any) -> Any:
- if isinstance(value, list):
- return [_without_image_payloads(item) for item in value]
- if not isinstance(value, dict):
- return value
-
- cleaned = {key: _without_image_payloads(item) for key, item in value.items()}
- if cleaned.get("type") == "input_image" and isinstance(cleaned.get("image_url"), str):
- cleaned["image_url"] = "[Image saved locally during handoff]"
- if cleaned.get("type") == "image" and isinstance(cleaned.get("source"), dict):
- source = dict(cleaned["source"])
- if source.get("type") == "base64" and "data" in source:
- source["data"] = "[Image saved locally during handoff]"
- cleaned["source"] = source
- return cleaned
-
-
-def _token_count(value: Any) -> int:
- text = json.dumps(_without_image_payloads(value), ensure_ascii=False, separators=(",", ":"))
- try:
- import tiktoken
-
- return len(tiktoken.get_encoding("o200k_base").encode(text))
- except ImportError:
- return (len(text) + 3) // 4
-
-
-def build_plan(session: str, projects_dir: Path) -> HandoffPlan:
- path = _resolve_session(session, projects_dir)
- records, sha256 = _stable_jsonl(path)
- chain = _active_chain(records)
- imported, compact_uuid = _after_latest_compaction(chain)
- imported = _merge_parallel_tool_results(imported, records)
- items, skipped_reasoning = _responses_items(imported)
-
- session_id = next(
- (str(record["sessionId"]) for record in reversed(records) if record.get("sessionId")),
- path.stem,
- )
- title = next(
- (
- str(record["customTitle"])
- for record in reversed(records)
- if record.get("type") == "custom-title" and record.get("customTitle")
- ),
- f"Claude session {session_id[:8]}",
- )
- cwd = next(
- (str(record["cwd"]) for record in chain if record.get("cwd")),
- "",
- )
- if not cwd:
- raise HandoffError("Claude session does not record its working directory.")
-
- warnings: list[str] = []
- if skipped_reasoning:
- warnings.append(f"Skipped {skipped_reasoning} Claude hidden-reasoning block(s); they are not portable.")
-
- source = SourceInfo(
- path=str(path),
- sha256=sha256,
- session_id=session_id,
- title=title,
- cwd=str(Path(cwd).resolve()),
- leaf_uuid=chain[-1]["uuid"],
- compact_boundary_uuid=compact_uuid,
- first_imported_uuid=imported[0]["uuid"],
- last_imported_uuid=imported[-1]["uuid"],
- codex_cwd=_git_root(Path(cwd)),
- )
- return HandoffPlan(
- source=source,
- items=items,
- source_records=len(records),
- active_records=len(chain),
- imported_records=len(imported),
- hidden_reasoning_blocks_skipped=skipped_reasoning,
- approximate_tokens=_token_count(items),
- warnings=warnings,
- )
-
-
-def _write_private(path: Path, body: str) -> None:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
- try:
- with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
- stream.write(body)
- os.replace(temporary, path)
- finally:
- Path(temporary).unlink(missing_ok=True)
-
-
-def write_bundle(plan: HandoffPlan, path: Path) -> Path:
- path = path.expanduser().resolve()
- _write_private(path, json.dumps(plan.bundle(), ensure_ascii=False))
- return path
-
-
-def _validate_items(items: Any) -> None:
- if not isinstance(items, list) or not items:
- raise HandoffError("Handoff bundle contains no history items.")
- calls: dict[str, str] = {}
- completed: set[str] = set()
- saw_user = False
- for item in items:
- if not isinstance(item, dict):
- raise HandoffError("Invalid handoff history item.")
- kind = item.get("type")
- status = item.get("status")
- if (
- not isinstance(kind, str)
- or (status is not None and not isinstance(status, str))
- or status in {"incomplete", "in_progress"}
- ):
- raise HandoffError("Invalid or incomplete handoff history item.")
- if kind == "message":
- role = item.get("role")
- parts = item.get("content")
- if (
- not isinstance(role, str)
- or role not in {"user", "assistant"}
- or not isinstance(parts, list)
- or not parts
- ):
- raise HandoffError("Invalid handoff message role or content.")
- saw_user = saw_user or role == "user"
- for part in parts:
- if not isinstance(part, dict) or not isinstance(part.get("type"), str):
- raise HandoffError("Invalid handoff message part.")
- if part.get("type") in {"input_text", "output_text"} and isinstance(part.get("text"), str):
- continue
- if part.get("type") == "input_image":
- _, encoded = _data_url_payload(part.get("image_url"), "Handoff image")
- try:
- if not base64.b64decode(encoded, validate=True):
- raise ValueError("empty image")
- except (ValueError, binascii.Error) as exc:
- raise HandoffError("Invalid handoff image data.") from exc
- continue
- raise HandoffError("Unsupported handoff message part.")
- elif kind == "function_call":
- call_id, name, arguments = item.get("call_id"), item.get("name"), item.get("arguments")
- if not isinstance(call_id, str) or not call_id or not isinstance(name, str) or not name:
- raise HandoffError("Invalid handoff tool call ID or name.")
- if call_id in calls or not isinstance(arguments, str):
- raise HandoffError("Duplicate or invalid handoff tool call.")
- try:
- json.loads(arguments)
- except json.JSONDecodeError as exc:
- raise HandoffError("Handoff tool arguments are not JSON.") from exc
- calls[call_id] = name
- elif kind == "function_call_output":
- call_id = item.get("call_id")
- if not isinstance(call_id, str) or call_id not in calls or call_id in completed:
- raise HandoffError("Unmatched or duplicate handoff tool result.")
- if "output" not in item:
- raise HandoffError("Handoff tool result has no output.")
- completed.add(call_id)
- item.setdefault("name", calls[call_id])
- else:
- raise HandoffError(f"Unsupported handoff item type: {kind!r}.")
- if set(calls) != completed:
- raise HandoffError("Session has unfinished tool calls; finish or stop the source turn before handoff.")
- if not saw_user:
- raise HandoffError("Handoff contains no user message.")
-
-
-def plan_from_bundle(payload: Any) -> HandoffPlan:
- formats = {FORMAT_VERSION, "mem0.claude-to-codex.v1", "memo.claude-to-codex.v1"}
- if not isinstance(payload, dict) or not isinstance(payload.get("format"), str) or payload["format"] not in formats:
- raise HandoffError("Unsupported handoff bundle format.")
- source_payload = payload.get("source")
- items = payload.get("items")
- warnings = payload.get("warnings", [])
- if not isinstance(source_payload, dict) or not isinstance(warnings, list):
- raise HandoffError("Handoff bundle has no source or has invalid warnings.")
- _validate_items(items)
- try:
- fields = dict(source_payload)
- legacy = payload["format"] != FORMAT_VERSION
- fields.setdefault("host", "claude-code" if legacy else "")
- for key in ("host", "session_id", "title", "cwd"):
- if not isinstance(fields.get(key), str) or not fields[key].strip():
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[a-z][a-z0-9-]*", fields["host"]):
- raise ValueError("invalid source host")
- fields.setdefault("path", f"{fields['host']}:{fields['session_id']}")
- fields.setdefault("sha256", hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest())
- fields.setdefault("leaf_uuid", str(len(items)))
- fields.setdefault("first_imported_uuid", "1")
- fields.setdefault("last_imported_uuid", str(len(items)))
- fields.setdefault("compact_boundary_uuid", None)
- source = SourceInfo(**fields)
- for key, value in asdict(source).items():
- if value is None and key in {"codex_cwd", "compact_boundary_uuid"}:
- continue
- if not isinstance(value, str):
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[0-9a-f]{64}", source.sha256):
- raise ValueError("invalid source digest")
- counts = payload.get("counts", {})
- return HandoffPlan(
- source=source,
- items=items,
- source_records=int(counts.get("source_records", len(items))),
- active_records=int(counts.get("active_records", len(items))),
- imported_records=int(counts.get("imported_records", len(items))),
- hidden_reasoning_blocks_skipped=int(counts.get("hidden_reasoning_blocks_skipped", 0)),
- approximate_tokens=_token_count(items),
- warnings=[str(warning) for warning in warnings],
- )
- except (KeyError, TypeError, ValueError, AttributeError) as exc:
- raise HandoffError("Handoff bundle is incomplete or has invalid source fields.") from exc
-
-
-def load_bundle(path: Path) -> HandoffPlan:
- try:
- text = sys.stdin.read() if str(path) == "-" else path.expanduser().resolve().read_text(encoding="utf-8")
- return plan_from_bundle(json.loads(text))
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid handoff bundle JSON: {path}") from exc
-
-
-def _git_root(cwd: Path) -> str | None:
- completed = subprocess.run(
- ["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
- text=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- check=False,
- )
- if completed.returncode != 0:
- return None
- root = Path(completed.stdout.strip()).resolve()
- return str(root) if root.is_dir() else None
-
-
-def _with_cwd(plan: HandoffPlan, cwd: Path | None) -> HandoffPlan:
- source_cwd = Path(plan.source.cwd).expanduser().resolve()
- target = (
- cwd.expanduser().resolve()
- if cwd
- else Path(plan.source.codex_cwd).expanduser().resolve()
- if plan.source.codex_cwd
- else Path(_git_root(source_cwd) or source_cwd)
- )
- if not target.is_dir():
- raise HandoffError(f"Codex working directory does not exist: {target}")
- return replace(plan, source=replace(plan.source, cwd=str(source_cwd), codex_cwd=str(target)))
-
-
-def _codex_cwd(plan: HandoffPlan) -> str:
- return plan.source.codex_cwd or plan.source.cwd
-
-
-def _default_bundle_path(plan: HandoffPlan) -> Path:
- session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip(".-")[:80] or "session"
- name = f"{session}-{plan.source.sha256[:12]}.json"
- return DEFAULT_BUNDLE_DIR / name
-
-
-def _codex_context_limits(codex_home: Path) -> CodexContextLimits:
- try:
- import tomllib
- except ImportError as exc:
- raise HandoffError("Creating a Codex task requires Python 3.11 or newer; rerun with python3.11.") from exc
-
- codex_home = codex_home.expanduser().resolve()
- config_path = codex_home / "config.toml"
- cache_path = codex_home / "models_cache.json"
- try:
- config = tomllib.loads(config_path.read_text(encoding="utf-8"))
- except (OSError, tomllib.TOMLDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex configuration at {config_path}: {exc}") from exc
- try:
- cache = json.loads(cache_path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex model metadata at {cache_path}: {exc}") from exc
-
- model = str(config.get("model") or "")
- models = cache.get("models") if isinstance(cache, dict) else None
- if not isinstance(models, list):
- raise HandoffError(f"Codex model metadata has no model list: {cache_path}")
- model_info = next(
- (
- item
- for item in models
- if isinstance(item, dict)
- and (
- item.get("slug") == model
- or item.get("model") == model
- or (not model and item.get("is_default") is True)
- )
- ),
- None,
- )
- if not isinstance(model_info, dict):
- raise HandoffError(f"Codex model {model!r} is missing from {cache_path}; refresh Codex's model list.")
- model = str(model_info.get("slug") or model_info.get("model") or model)
-
- cached_context = model_info.get("context_window")
- cached_max = model_info.get("max_context_window") or cached_context
- if not isinstance(cached_context, int) or not isinstance(cached_max, int):
- raise HandoffError(f"Codex model {model!r} does not report its context limits.")
- configured_context = config.get("model_context_window")
- context_window = min(configured_context, cached_max) if isinstance(configured_context, int) else cached_context
- effective_percent = model_info.get("effective_context_window_percent", 95)
- if not isinstance(effective_percent, int) or not 1 <= effective_percent <= 100:
- raise HandoffError(f"Codex model {model!r} reports an invalid effective context percentage.")
-
- context_auto_limit = context_window * 9 // 10
- configured_auto_limit = config.get("model_auto_compact_token_limit")
- auto_compact_limit = (
- min(configured_auto_limit, context_auto_limit) if isinstance(configured_auto_limit, int) else context_auto_limit
- )
- return CodexContextLimits(
- model=model,
- context_window=context_window,
- usable_context_window=context_window * effective_percent // 100,
- auto_compact_token_limit=auto_compact_limit,
- max_context_window=cached_max,
- max_usable_context_window=cached_max * effective_percent // 100,
- max_auto_compact_token_limit=cached_max * 9 // 10,
- )
-
-
-class CodexAppServer:
- """Small JSON-RPC client for a one-off local Codex app-server process."""
-
- def __init__(
- self,
- codex_bin: str = "codex",
- context_window_override: int | None = None,
- codex_home: Path = DEFAULT_CODEX_HOME,
- ) -> None:
- resolved = shutil.which(codex_bin)
- if not resolved:
- raise HandoffError(f"Codex executable not found: {codex_bin}")
- command = [resolved]
- if context_window_override is not None:
- command.extend(["-c", f"model_context_window={context_window_override}"])
- command.extend(["app-server", "--stdio"])
- self.process = subprocess.Popen(
- command,
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- bufsize=1,
- env={**os.environ, "CODEX_HOME": str(codex_home.expanduser().resolve())},
- )
- self._responses: queue.Queue[dict[str, Any]] = queue.Queue()
- self._notifications: queue.Queue[dict[str, Any]] = queue.Queue()
- self._stderr: list[str] = []
- self._next_id = 1
- threading.Thread(target=self._read_stdout, daemon=True).start()
- threading.Thread(target=self._read_stderr, daemon=True).start()
- try:
- self.request(
- "initialize",
- {
- "clientInfo": {
- "name": "mem0_session_handoff",
- "title": "Mem0 local session handoff",
- "version": "0.1.0",
- }
- },
- )
- self.notify("initialized", {})
- except Exception:
- self.close()
- raise
-
- def _read_stdout(self) -> None:
- assert self.process.stdout is not None
- for line in self.process.stdout:
- try:
- message = json.loads(line)
- except json.JSONDecodeError:
- continue
- if not isinstance(message, dict):
- continue
- if "id" in message:
- self._responses.put(message)
- elif "method" in message:
- self._notifications.put(message)
-
- def _read_stderr(self) -> None:
- assert self.process.stderr is not None
- for line in self.process.stderr:
- self._stderr.append(line.rstrip())
-
- def _send(self, payload: dict[str, Any]) -> None:
- if self.process.poll() is not None:
- error = "\n".join(self._stderr[-20:])
- raise HandoffError(f"Codex app-server stopped unexpectedly.\n{error}")
- assert self.process.stdin is not None
- self.process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
- self.process.stdin.flush()
-
- def request(self, method: str, params: dict[str, Any], timeout: float = 30) -> Any:
- request_id = self._next_id
- self._next_id += 1
- self._send({"method": method, "id": request_id, "params": params})
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out on {method}.")
- try:
- response = self._responses.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out on {method}.") from exc
- if response.get("id") != request_id:
- deferred.append(response)
- continue
- if "error" in response:
- raise HandoffError(f"Codex {method} failed: {response['error']}")
- return response.get("result")
- finally:
- for response in deferred:
- self._responses.put(response)
-
- def notify(self, method: str, params: dict[str, Any]) -> None:
- self._send({"method": method, "params": params})
-
- def wait_for_notification(
- self,
- method: str,
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.") from exc
- if notification.get("method") != method:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def wait_for_any_notification(
- self,
- methods: set[str],
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.") from exc
- if notification.get("method") not in methods:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def close(self) -> None:
- if self.process.poll() is None:
- self.process.terminate()
- try:
- self.process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self.process.kill()
- self.process.wait(timeout=5)
-
- def __enter__(self) -> "CodexAppServer":
- return self
-
- def __exit__(self, *_: Any) -> None:
- self.close()
-
-
-def _item_text(item: dict[str, Any], asset_dir: Path) -> tuple[str, str]:
- """Convert one Responses item to a complete visible import message."""
- item_type = item.get("type")
- if item_type == "message":
- role = str(item.get("role") or "")
- if role not in {"user", "assistant"}:
- raise HandoffError(f"Codex's session importer cannot represent role {role!r}.")
- parts: list[str] = []
- for part in item.get("content") or []:
- if not isinstance(part, dict):
- raise HandoffError("A handoff message contains an invalid content item.")
- part_type = part.get("type")
- if part_type in {"input_text", "output_text"}:
- parts.append(str(part.get("text") or ""))
- elif part_type == "input_image":
- media_type, encoded = _data_url_payload(part.get("image_url"), "A Claude message image")
- parts.append(_image_reference(media_type, encoded, asset_dir, "A Claude message image"))
- else:
- raise HandoffError(f"Codex's session importer cannot represent content type {part_type!r}.")
- text = "\n\n".join(part for part in parts if part)
- if not text:
- raise HandoffError("A handoff message contains no transferable text.")
- return role, text
-
- if item_type == "function_call":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- arguments = str(item.get("arguments") or "{}")
- return (
- "assistant",
- f'\n{arguments}\n',
- )
-
- if item_type == "function_call_output":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- output = _tool_result_text(item.get("output"), asset_dir, f"Claude tool result {call_id}")
- return (
- "assistant",
- f'\n{output}\n',
- )
-
- raise HandoffError(f"Codex's session importer cannot represent item type {item_type!r}.")
-
-
-def _native_import_records(plan: HandoffPlan, asset_dir: Path) -> list[dict[str, Any]]:
- """Build the Claude-shaped history consumed by Codex's native importer."""
- cwd = _codex_cwd(plan)
- records: list[dict[str, Any]] = [
- {
- "type": "custom-title",
- "customTitle": plan.source.title,
- "sessionId": plan.source.session_id,
- }
- ]
- saw_user = False
- for index, item in enumerate(plan.items, 1):
- role, text = _item_text(item, asset_dir)
- saw_user = saw_user or role == "user"
- records.append(
- {
- "type": role,
- "sessionId": plan.source.session_id,
- "uuid": f"mem0-handoff-{index}",
- "cwd": cwd,
- "isSidechain": False,
- "message": {"role": role, "content": text},
- }
- )
- if not saw_user:
- raise HandoffError("The active Claude context contains no user message.")
- return records
-
-
-def _native_import_path(plan: HandoffPlan, claude_projects_dir: Path) -> Path:
- source_key = hashlib.sha256(plan.source.path.encode("utf-8")).hexdigest()[:24]
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- safe_session = safe_session[:80] or source_key
- return claude_projects_dir.expanduser().resolve() / ".mem0-handoffs" / f"{safe_session}-{source_key}.jsonl"
-
-
-def _write_native_import(plan: HandoffPlan, path: Path, asset_dir: Path) -> str:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- body = "".join(
- json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
- for record in _native_import_records(plan, asset_dir)
- )
- _write_private(path, body)
- return hashlib.sha256(body.encode("utf-8")).hexdigest()
-
-
-def _native_import_params(source_path: Path, cwd: str) -> dict[str, Any]:
- return {
- "migrationItems": [
- {
- "itemType": "SESSIONS",
- "description": f"Transfer Claude session {source_path.name}",
- "cwd": None,
- "details": {
- "plugins": [],
- "sessions": [{"path": str(source_path), "cwd": cwd, "title": None}],
- "mcpServers": [],
- "hooks": [],
- "subagents": [],
- "commands": [],
- },
- }
- ]
- }
-
-
-def _thread_id_from_completion(params: Any, source_path: Path) -> str | None:
- if not isinstance(params, dict):
- return None
- canonical = str(source_path.resolve())
- for result in params.get("itemTypeResults") or []:
- if not isinstance(result, dict) or result.get("itemType") != "SESSIONS":
- continue
- for success in result.get("successes") or []:
- if not isinstance(success, dict):
- continue
- if success.get("source") in {None, canonical} and success.get("target"):
- return str(success["target"])
- return None
-
-
-def _thread_id_from_ledger(codex_home: Path, source_path: Path, content_sha256: str) -> str | None:
- ledger_path = codex_home.expanduser() / "external_agent_session_imports.json"
- if not ledger_path.is_file():
- return None
- try:
- ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
- except json.JSONDecodeError:
- return None
- canonical = str(source_path.resolve())
- matches = [
- record
- for record in ledger.get("records", [])
- if isinstance(record, dict)
- and record.get("source_path") == canonical
- and record.get("content_sha256") == content_sha256
- and record.get("imported_thread_id")
- ]
- return str(matches[-1]["imported_thread_id"]) if matches else None
-
-
-def _notification_thread_id(params: Any) -> str | None:
- if not isinstance(params, dict):
- return None
- if params.get("threadId"):
- return str(params["threadId"])
- turn = params.get("turn")
- if isinstance(turn, dict) and turn.get("threadId"):
- return str(turn["threadId"])
- return None
-
-
-def _compact_imported_thread(
- server: CodexAppServer,
- thread_id: str,
-) -> dict[str, Any] | None:
- server.request("thread/resume", {"threadId": thread_id}, timeout=120)
- server.request("thread/compact/start", {"threadId": thread_id}, timeout=30)
-
- latest_usage: dict[str, Any] | None = None
- saw_compaction_item = False
- while True:
- notification = server.wait_for_any_notification(
- {"item/completed", "thread/tokenUsage/updated", "turn/completed", "error"},
- lambda params: _notification_thread_id(params) in {None, thread_id},
- timeout=600,
- )
- method = notification.get("method")
- params = notification.get("params")
- if method == "thread/tokenUsage/updated" and isinstance(params, dict):
- token_usage = params.get("tokenUsage")
- if isinstance(token_usage, dict):
- latest_usage = token_usage
- continue
- if method == "item/completed" and isinstance(params, dict):
- item = params.get("item")
- if isinstance(item, dict) and item.get("type") == "contextCompaction":
- saw_compaction_item = True
- continue
- if method == "error":
- error = params.get("error") if isinstance(params, dict) else params
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if method == "turn/completed" and isinstance(params, dict):
- turn = params.get("turn")
- if not isinstance(turn, dict):
- raise HandoffError("Codex returned an invalid compaction result.")
- if turn.get("status") != "completed":
- error = turn.get("error") or turn.get("status")
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if not saw_compaction_item:
- raise HandoffError("Codex completed the compaction turn without a compaction item.")
- return latest_usage
-
-
-def _set_thread_name(
- server: CodexAppServer,
- thread_id: str,
- name: str,
-) -> None:
- server.request(
- "thread/name/set",
- {"threadId": thread_id, "name": name},
- timeout=30,
- )
-
-
-def create_codex_thread(
- plan: HandoffPlan,
- codex_bin: str = "codex",
- codex_home: Path = DEFAULT_CODEX_HOME,
-) -> dict[str, Any]:
- limits = _codex_context_limits(codex_home)
- should_compact = plan.approximate_tokens >= limits.auto_compact_token_limit
- if should_compact and plan.approximate_tokens >= limits.max_auto_compact_token_limit:
- raise HandoffError(
- f"The active session state is approximately {plan.approximate_tokens:,} tokens. "
- f"Codex cannot safely compact more than approximately "
- f"{limits.max_auto_compact_token_limit:,} tokens in one request. "
- "Compact in the source host and retry the handoff."
- )
-
- # Codex only imports sources staged under its native Claude home.
- source_path = _native_import_path(plan, Path.home() / ".claude" / "projects")
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- asset_dir = (
- codex_home.expanduser().resolve()
- / "external-agent-assets"
- / plan.source.host
- / (safe_session[:80] or "session")
- )
- content_sha256 = _write_native_import(plan, source_path, asset_dir)
- try:
- context_override = limits.max_context_window if should_compact else None
- with CodexAppServer(codex_bin, context_override, codex_home=codex_home) as server:
- response = server.request(
- "externalAgentConfig/import",
- _native_import_params(source_path, _codex_cwd(plan)),
- timeout=120,
- )
- import_id = str((response or {}).get("importId") or "")
- if not import_id:
- raise HandoffError(f"Codex externalAgentConfig/import returned no import ID: {response!r}")
- completed = server.wait_for_notification(
- IMPORT_COMPLETED_NOTIFICATION,
- lambda params: isinstance(params, dict) and params.get("importId") == import_id,
- )
- completed_params = completed.get("params")
- thread_id = _thread_id_from_completion(completed_params, source_path)
- if not thread_id:
- thread_id = _thread_id_from_ledger(codex_home, source_path, content_sha256)
- if not thread_id:
- raise HandoffError(
- "Codex finished importing the session but did not report the new task ID. "
- f"Import result: {json.dumps(completed_params, ensure_ascii=False)}"
- )
-
- read = server.request(
- "thread/read",
- {"threadId": thread_id, "includeTurns": True},
- )
- thread = (read or {}).get("thread") if isinstance(read, dict) else None
- if not isinstance(thread, dict):
- raise HandoffError(f"Codex could not read imported task {thread_id}.")
- turns = thread.get("turns") or []
- preview = str(thread.get("preview") or "")
- if not turns or not preview:
- raise HandoffError(f"Codex imported task {thread_id}, but it has no visible history.")
-
- compaction_usage = _compact_imported_thread(server, thread_id) if should_compact else None
- _set_thread_name(server, thread_id, plan.source.title)
-
- return {
- "thread_id": thread_id,
- "title": plan.source.title,
- "cwd": _codex_cwd(plan),
- "source_session_id": plan.source.session_id,
- "source_host": plan.source.host,
- "visible_turns": len(turns),
- "preview": preview,
- "responses_items_converted": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "target_model": limits.model,
- "target_context_window": limits.context_window,
- "target_usable_context_window": limits.usable_context_window,
- "target_auto_compact_token_limit": limits.auto_compact_token_limit,
- "compacted_before_return": should_compact,
- "compaction_context_window": (limits.max_context_window if should_compact else None),
- "compaction_token_usage": compaction_usage,
- "model_invoked": should_compact,
- }
- finally:
- source_path.unlink(missing_ok=True)
- try:
- source_path.parent.rmdir()
- except OSError:
- pass
-
-
-def _summary(plan: HandoffPlan) -> dict[str, Any]:
- return {
- "source": plan.source.path,
- "source_host": plan.source.host,
- "session_id": plan.source.session_id,
- "title": plan.source.title,
- "source_cwd": plan.source.cwd,
- "codex_cwd": _codex_cwd(plan),
- "leaf_uuid": plan.source.leaf_uuid,
- "compact_boundary_uuid": plan.source.compact_boundary_uuid,
- "source_records": plan.source_records,
- "active_records": plan.active_records,
- "imported_records": plan.imported_records,
- "responses_items": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "warnings": plan.warnings,
- }
-
-
-def _command_output(result: dict[str, Any]) -> str:
- title = str(result["title"])
- cwd = str(result["cwd"])
- project = Path(cwd).name or cwd
- lines = [
- f'Created Codex task "{title}".',
- f"Task ID: {result['thread_id']}",
- f"Project: {project}",
- ]
- if result.get("compacted_before_return"):
- lines.append("Codex compacted the transferred context before opening the task.")
- lines.append(f'Open Codex and select "{title}" under {project}.')
- return "\n".join(lines)
-
-
-def _parse_args(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> argparse.Namespace:
- parser = argparse.ArgumentParser(description=__doc__)
- source = parser.add_mutually_exclusive_group(required=True)
- source.add_argument("--session", help="Native session transcript path (Claude also accepts its session ID)")
- parser.add_argument(
- "--source",
- choices=("claude-code", "cursor", "codex", "kimi", "antigravity", "openclaw", "pi-agent"),
- default=default_source,
- )
- parser.add_argument("--title", help="Override the imported task title")
- source.add_argument("--bundle", type=Path, help="Previously exported handoff bundle")
- parser.add_argument(
- "--claude-projects-dir",
- type=Path,
- default=Path.home() / ".claude" / "projects",
- )
- parser.add_argument("--export", type=Path, help="Write a private reusable handoff bundle")
- parser.add_argument(
- "--cwd",
- type=Path,
- help="Use this existing directory instead of the source session's directory",
- )
- parser.add_argument("--create", action="store_true", help="Create the Codex task")
- parser.add_argument(
- "--target",
- choices=("codex",),
- default="codex",
- help="Destination coding agent",
- )
- parser.add_argument(
- "--command-output",
- action="store_true",
- help="Print the short result used by Mem0's user-facing command",
- )
- parser.add_argument("--codex-bin", default="codex")
- parser.add_argument(
- "--codex-home",
- type=Path,
- default=DEFAULT_CODEX_HOME,
- )
- return parser.parse_args(argv)
-
-
-def main(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> int:
- args = _parse_args(argv, default_source)
- try:
- if args.bundle:
- plan = load_bundle(args.bundle)
- elif args.source == "claude-code":
- plan = build_plan(args.session, args.claude_projects_dir)
- else:
- if not args.source:
- raise HandoffError("--source is required with --session.")
- from handoff_sources import read_source
-
- plan = read_source(args.source, Path(args.session), cwd=args.cwd, title=args.title)
- if args.title:
- plan = replace(plan, source=replace(plan.source, title=args.title))
- plan = _with_cwd(plan, args.cwd)
- output: dict[str, Any] = {"plan": _summary(plan)}
- if args.export:
- output["bundle"] = str(write_bundle(plan, args.export))
- if args.create:
- try:
- output["codex"] = create_codex_thread(
- plan,
- codex_bin=args.codex_bin,
- codex_home=args.codex_home.expanduser(),
- )
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- fallback = args.export or _default_bundle_path(plan)
- saved = write_bundle(plan, fallback)
- raise HandoffError(f"{exc} The complete handoff was saved at {saved}.") from exc
- if args.command_output:
- if not args.create:
- raise HandoffError("--command-output requires --create.")
- print(_command_output(output["codex"]))
- else:
- print(json.dumps(output, indent=2, ensure_ascii=False))
- return 0
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- print(f"handoff failed: {exc}", file=sys.stderr)
- return 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/integrations/cursor-plugin/core/handoff-runtime.json b/integrations/cursor-plugin/core/handoff-runtime.json
new file mode 100644
index 000000000..b6b935e8d
--- /dev/null
+++ b/integrations/cursor-plugin/core/handoff-runtime.json
@@ -0,0 +1,8 @@
+{
+ "revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
+ "files": {
+ "claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
+ "handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
+ },
+ "artifacts": ["session_handoff.py", "handoff-runtime.json"]
+}
diff --git a/integrations/cursor-plugin/core/handoff_sources.py b/integrations/cursor-plugin/core/handoff_sources.py
deleted file mode 100644
index 549c9969e..000000000
--- a/integrations/cursor-plugin/core/handoff_sources.py
+++ /dev/null
@@ -1,452 +0,0 @@
-"""Native transcript readers; all destinations use the shared handoff importer.
-
-Formats: openai/codex rollout payloads; MoonshotAI/kimi-code contextMemory;
-Pi's session-manager.buildSessionContext; native Cursor/Antigravity transcripts.
-Unsupported state changes fail instead of silently dropping active context.
-"""
-
-from __future__ import annotations
-
-import json
-from pathlib import Path
-from typing import Any
-
-import claude_to_codex as engine
-
-
-def _message(role: str, text: str) -> dict:
- return {"role": role, "content": [{"type": "text", "text": text}]}
-
-
-def _parts(content: Any, role: str, warnings: list[str]) -> list[dict]:
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if not isinstance(part, dict):
- raise engine.HandoffError("Native message has an invalid content block.")
- kind = part.get("type")
- if kind in {"thinking", "redacted_thinking", "think"}:
- if "Hidden reasoning was excluded." not in warnings:
- warnings.append("Hidden reasoning was excluded.")
- elif kind in {"text", "input_text", "output_text"} and isinstance(part.get("text"), str):
- parts.append({"type": "input_text" if role == "user" else "output_text", "text": part["text"]})
- elif kind == "image":
- source = part.get("source") or {
- "type": "base64",
- "data": part.get("data"),
- "media_type": part.get("mimeType"),
- }
- media_type, data = engine._image_payload(source, "Native message image")
- parts.append({"type": "input_image", "image_url": f"data:{media_type};base64,{data}"})
- elif kind in {"image_url", "input_image"}:
- url = part.get("image_url")
- if isinstance(url, dict):
- url = url.get("url")
- engine._data_url_payload(url, "Native message image")
- parts.append({"type": "input_image", "image_url": url})
- elif kind not in {"toolCall", "tool_use"}:
- raise engine.HandoffError(f"Unsupported native content block: {kind!r}.")
- return parts
-
-
-def _call_item(call: dict) -> dict:
- function = call.get("function", call)
- arguments = function.get("arguments", "{}")
- return {
- "type": "function_call",
- "call_id": call.get("id"),
- "name": function.get("name"),
- "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments),
- }
-
-
-def _messages_items(messages: list[dict], warnings: list[str]) -> list[dict]:
- items = []
- for message in messages:
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid native message.")
- role = message.get("role")
- if role in {"system", "developer"}:
- if "Source harness instructions were excluded." not in warnings:
- warnings.append("Source harness instructions were excluded.")
- continue
- if role in {"tool", "toolResult"}:
- output_parts = _parts(message.get("content"), "assistant", warnings)
- output = []
- for part in output_parts:
- if part["type"] == "input_image":
- media_type, data = engine._data_url_payload(part["image_url"], "Tool result image")
- output.append(
- {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}}
- )
- else:
- output.append({"type": "text", "text": part["text"]})
- if message.get("isError"):
- output.insert(0, {"type": "text", "text": "Tool failed."})
- if message.get("note"):
- output.append({"type": "text", "text": str(message["note"])})
- item = {
- "type": "function_call_output",
- "call_id": message.get("toolCallId") or message.get("tool_call_id"),
- "output": output,
- }
- if message.get("toolName") or message.get("name"):
- item["name"] = message.get("toolName") or message["name"]
- items.append(item)
- continue
- if role not in {"user", "assistant"}:
- raise engine.HandoffError(f"Unsupported native message role: {role!r}.")
- if message.get("partial") or message.get("stopReason") in {"error", "aborted"}:
- raise engine.HandoffError("Native assistant response is incomplete; finish the source turn first.")
- content = message.get("content", [])
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if isinstance(part, dict) and part.get("type") in {"toolCall", "tool_use"}:
- if role != "assistant":
- raise engine.HandoffError("Native user message contains an assistant tool call.")
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- parts = []
- items.append(
- _call_item(
- {
- "id": part.get("id"),
- "name": part.get("name"),
- "arguments": json.dumps(part.get("arguments", part.get("input", {}))),
- }
- )
- )
- else:
- parts.extend(_parts([part], role, warnings))
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- for call in message.get("toolCalls") or message.get("tool_calls") or []:
- items.append(_call_item(call))
- return items
-
-
-def _codex(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Native Responses items are the authoritative history, event_msg is UI data.
- items, source = [], {}
- for record in records:
- kind, payload = record.get("type"), record.get("payload")
- if not isinstance(payload, dict):
- raise engine.HandoffError("Invalid Codex rollout payload.")
- if kind == "session_meta":
- source.update(session_id=payload.get("id"), cwd=payload.get("cwd"))
- elif kind == "compacted":
- replacement = payload.get("replacement_history")
- if not isinstance(replacement, list) or not replacement:
- raise engine.HandoffError(
- "Codex compaction is opaque; a complete plaintext replacement history is required."
- )
- items = list(replacement)
- elif kind == "response_item":
- items.append(payload)
- elif kind == "event_msg":
- if payload.get("type") == "thread_rolled_back":
- raise engine.HandoffError("Codex rollback requires a native active-context export.")
- elif kind != "turn_context":
- raise engine.HandoffError(f"Unsupported Codex rollout record: {kind!r}.")
- result = []
- for item in items:
- kind = item.get("type")
- if kind == "reasoning":
- warnings.append("Hidden reasoning was excluded.")
- elif kind == "message" and item.get("role") in {"system", "developer"}:
- warnings.append("Source harness instructions were excluded.")
- elif kind == "custom_tool_call":
- result.append(
- {
- "type": "function_call",
- "call_id": item.get("call_id"),
- "name": item.get("name"),
- "arguments": json.dumps({"input": item.get("input")}),
- }
- )
- elif kind == "custom_tool_call_output":
- result.append({**item, "type": "function_call_output"})
- elif kind == "compaction":
- raise engine.HandoffError(
- "Codex compaction contains opaque model state; it cannot be transferred losslessly."
- )
- else:
- result.append(dict(item))
- return result, source
-
-
-def _cursor(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Cursor's persisted transcript uses role + message.content, without Claude's parent chain.
- converted = []
- source = {}
- for index, record in enumerate(records):
- role = record.get("role") or record.get("type")
- if role not in {"user", "assistant"} or not isinstance(record.get("message"), dict):
- raise engine.HandoffError("Unsupported Cursor transcript record; provide a complete native JSONL export.")
- converted.append({**record, "type": role, "uuid": str(index)})
- if record.get("session_id"):
- source["session_id"] = record["session_id"]
- if record.get("cwd"):
- source["cwd"] = record["cwd"]
- items, skipped = engine._responses_items(converted)
- if skipped:
- warnings.append("Hidden reasoning was excluded.")
- return items, source
-
-
-def _antigravity(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- messages = []
- for step in records:
- if step.get("status") != "DONE":
- raise engine.HandoffError("Antigravity has an unfinished transcript step; finish the source turn first.")
- kind, content = step.get("type"), step.get("content")
- if not isinstance(content, str):
- raise engine.HandoffError("Antigravity transcript content is not transferable text.")
- if kind == "USER_INPUT":
- messages.append(_message("user", content))
- elif kind == "PLANNER_RESPONSE" and step.get("source") == "MODEL":
- messages.append(_message("assistant", content))
- else:
- raise engine.HandoffError(
- f"Unsupported Antigravity step {kind!r}; its visible conversation semantics are not verified."
- )
- return _messages_items(messages, warnings), {}
-
-
-def _kimi_compact(messages: list[dict], record: dict) -> list[dict]:
- summary = record.get("contextSummary", record.get("summary"))
- if isinstance(summary, dict):
- summary_message = summary
- elif isinstance(summary, str):
- summary_message = {**_message("user", summary), "origin": {"kind": "compaction_summary"}}
- else:
- raise engine.HandoffError("Kimi compaction has no transferable summary.")
- if record.get("legacyTail") or "keptUserMessageCount" not in record:
- count = record.get("compactedCount", record.get("count"))
- if not isinstance(count, int) or not 0 <= count <= len(messages):
- raise engine.HandoffError("Invalid Kimi compaction boundary.")
- return [summary_message, *messages[count:]]
- users = []
- for message in messages:
- origin = message.get("origin") or {}
- if message.get("role") == "user" and (
- origin.get("kind") in {None, "user"}
- or (origin.get("kind") in {"skill_activation", "plugin_command"} and origin.get("trigger") == "user-slash")
- ):
- users.append(message)
- # Kimi trims user inputs above this native budget. Do not approximate that destructive rewrite.
- tokens = 0
- for message in users:
- if message.get("toolCalls"):
- raise engine.HandoffError("Unsupported Kimi compaction user tool calls.")
- tokens += 1 # estimateTokens('user')
- for part in message.get("content", []):
- if part.get("type") not in {"text", "think"}:
- tokens += 2000
- else:
- text = part.get("text", part.get("think", ""))
- ascii_count = sum(ord(char) <= 127 for char in text)
- tokens += (ascii_count + 3) // 4 + len(text) - ascii_count
- if tokens > 20000 or record.get("keptHeadUserMessageCount"):
- raise engine.HandoffError("Kimi compaction elided user content; use a native active-context bundle export.")
- continuation = _message(
- "user",
- "\nContext compaction is complete — continue the work that was in progress when it began.\n",
- )
- return [*users, summary_message, continuation]
-
-
-def _kimi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Mirrors Kimi v2 context.append_message and completed loop events, not UI stream fragments.
- messages, source = [], {}
- opened, step_id = None, None
- for record in records:
- if record.get("agentId") not in {None, "main"}:
- continue
- kind = record.get("type", "")
- if kind in {"profile.bind", "config.update"}:
- cwd = (record.get("environmentDisclosure") or {}).get("cwd") or record.get("cwd")
- if cwd:
- source["cwd"] = cwd
- elif kind == "context.append_message":
- if opened is not None:
- raise engine.HandoffError("Kimi interleaved messages require a completed native context export.")
- messages.append(record.get("message"))
- elif kind == "context.append_loop_event":
- event = record.get("event") or {}
- event_type = event.get("type")
- if event_type == "step.begin":
- if opened is not None:
- raise engine.HandoffError("Kimi previous response did not complete.")
- step_id = event.get("uuid")
- opened = {"role": "assistant", "content": [], "toolCalls": []}
- messages.append(opened)
- elif event_type == "step.end":
- if event.get("uuid") != step_id or event.get("finishReason") in {"error", "interrupted"}:
- raise engine.HandoffError("Kimi response is incomplete or interrupted.")
- opened, step_id = None, None
- elif event_type in {"content.part", "tool.call"}:
- if opened is None or event.get("stepUuid") != step_id:
- raise engine.HandoffError("Kimi content has no matching active response.")
- if event_type == "content.part":
- opened["content"].append(event.get("part"))
- else:
- opened["toolCalls"].append(
- {
- "id": event.get("toolCallId"),
- "name": event.get("name"),
- "arguments": json.dumps(event.get("args", {})),
- }
- )
- elif event_type == "tool.result":
- result = event.get("result") or {}
- messages.append(
- {
- "role": "tool",
- "toolCallId": event.get("toolCallId"),
- "content": result.get("output"),
- "isError": result.get("isError"),
- "note": result.get("note"),
- }
- )
- else:
- raise engine.HandoffError(f"Unsupported Kimi loop event: {event_type!r}.")
- elif kind == "context.clear":
- messages, opened, step_id = [], None, None
- elif kind == "context.apply_compaction":
- if opened is not None:
- raise engine.HandoffError("Kimi compaction began during an unfinished response.")
- messages = _kimi_compact(messages, record)
- elif kind in {"context.undo", "micro_compaction.apply", "context.spliced"}:
- raise engine.HandoffError(f"Kimi {kind} needs a native active-context export to preserve its state.")
- elif kind.startswith("context.") and kind != "context.update_token_count":
- raise engine.HandoffError(f"Unsupported Kimi context event: {kind!r}.")
- # Remaining durable events configure Kimi's harness; they are not model messages.
- if opened is not None:
- raise engine.HandoffError("Kimi response is still streaming.")
- return _messages_items(messages, warnings), source
-
-
-def _pi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- header = records[0]
- if header.get("type") != "session":
- raise engine.HandoffError("Pi/OpenClaw transcript has no session header.")
- entries = [record for record in records[1:] if isinstance(record.get("id"), str)]
- if len(entries) != len(records) - 1:
- raise engine.HandoffError("Pi/OpenClaw transcript entry has no ID.")
- index = {entry["id"]: entry for entry in entries}
- if len(index) != len(entries):
- raise engine.HandoffError("Pi/OpenClaw transcript has duplicate entry IDs.")
- chain, seen = [], set()
- current = entries[-1] if entries else None
- while current:
- if current["id"] in seen:
- raise engine.HandoffError("Pi/OpenClaw transcript has a parent cycle.")
- seen.add(current["id"])
- chain.append(current)
- parent = current.get("parentId")
- if parent is not None and parent not in index:
- raise engine.HandoffError("Pi/OpenClaw transcript has a missing parent.")
- current = index.get(parent)
- chain.reverse()
- title = next((entry.get("name") for entry in reversed(chain) if entry.get("type") == "session_info"), None)
- messages = []
- boundary = next((i for i in range(len(chain) - 1, -1, -1) if chain[i].get("type") == "compaction"), None)
- if boundary is not None:
- compact = chain[boundary]
- if not isinstance(compact.get("summary"), str):
- raise engine.HandoffError("Pi/OpenClaw compaction has no summary.")
- messages.append(
- _message(
- "user",
- f"The conversation history before this point was compacted into the following summary:\n\n\n{compact['summary']}\n",
- )
- )
- kept = next((i for i in range(boundary) if chain[i]["id"] == compact.get("firstKeptEntryId")), boundary)
- chain = chain[kept:boundary] + chain[boundary + 1 :]
- for entry in chain:
- kind = entry.get("type")
- if kind == "message":
- message = entry.get("message")
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid Pi/OpenClaw message.")
- if message.get("role") == "bashExecution":
- if message.get("excludeFromContext"):
- continue
- if message.get("truncated"):
- raise engine.HandoffError("Pi/OpenClaw shell output is truncated; provide a complete bundle.")
- text = f"Ran `{message.get('command', '')}`\n"
- text += f"```\n{message['output']}\n```" if message.get("output") else "(no output)"
- if message.get("cancelled"):
- text += "\n\n(command cancelled)"
- elif message.get("exitCode") not in {None, 0}:
- text += f"\n\nCommand exited with code {message['exitCode']}"
- message = _message("user", text)
- messages.append(message)
- elif kind == "branch_summary":
- messages.append(
- _message(
- "user",
- f"The following is a summary of a branch that this conversation came back from:\n\n\n{entry['summary']}",
- )
- )
- elif kind == "custom_message":
- messages.append({"role": "user", "content": entry.get("content")})
- elif kind == "session_info":
- title = entry.get("name")
- elif kind not in {"model_change", "thinking_level_change", "custom", "label"}:
- raise engine.HandoffError(f"Unsupported Pi/OpenClaw entry: {kind!r}.")
- return _messages_items(messages, warnings), {
- "session_id": header.get("id"),
- "cwd": header.get("cwd"),
- "title": title,
- }
-
-
-def read_source(host: str, path: Path, *, cwd: Path | None = None, title: str | None = None) -> engine.HandoffPlan:
- path = path.expanduser().resolve()
- records, digest = engine._stable_jsonl(path)
- warnings: list[str] = []
- readers = {
- "cursor": _cursor,
- "codex": _codex,
- "kimi": _kimi,
- "antigravity": _antigravity,
- "openclaw": _pi,
- "pi-agent": _pi,
- }
- try:
- items, metadata = readers[host](records, warnings)
- except (TypeError, AttributeError, KeyError, ValueError) as exc:
- raise engine.HandoffError(f"Invalid {host} native transcript structure: {exc}") from exc
- if host == "kimi" and path.name == "wire.jsonl" and path.parent.name == "main":
- metadata.setdefault("session_id", path.parents[2].name)
- state = path.parents[2] / "state.json"
- if state.is_file():
- try:
- metadata.setdefault("title", json.loads(state.read_text()).get("title"))
- except (json.JSONDecodeError, AttributeError):
- pass
- if host == "antigravity" and path.name == "transcript.jsonl" and path.parent.name == "logs":
- metadata.setdefault("session_id", path.parents[2].name)
- source_cwd = str(cwd.expanduser().resolve()) if cwd else metadata.get("cwd")
- if not source_cwd:
- raise engine.HandoffError(f"{host} transcript has no working directory; provide --cwd.")
- source = {
- "host": host,
- "path": str(path),
- "sha256": digest,
- "session_id": metadata.get("session_id") or path.stem,
- "cwd": source_cwd,
- "title": title or metadata.get("title") or f"{host} session {path.stem[:12]}",
- }
- return engine.plan_from_bundle(
- {"format": engine.FORMAT_VERSION, "source": source, "items": items, "warnings": list(dict.fromkeys(warnings))}
- )
diff --git a/integrations/cursor-plugin/core/memory_core.py b/integrations/cursor-plugin/core/memory_core.py
index 1e99f99e7..93a3fde15 100644
--- a/integrations/cursor-plugin/core/memory_core.py
+++ b/integrations/cursor-plugin/core/memory_core.py
@@ -29,7 +29,7 @@ from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
-PLUGIN_VERSION = "0.4.0"
+PLUGIN_VERSION = "0.3.2"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
diff --git a/integrations/cursor-plugin/core/session_handoff.py b/integrations/cursor-plugin/core/session_handoff.py
index 4aada69e6..b7108f336 100644
--- a/integrations/cursor-plugin/core/session_handoff.py
+++ b/integrations/cursor-plugin/core/session_handoff.py
@@ -1,7 +1,81 @@
#!/usr/bin/env python3
-"""Import an explicit native session or neutral handoff bundle into Codex."""
+"""Run the shared handoff engine locally or from its verified immutable cache."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import sys
+import tempfile
+from pathlib import Path
+from urllib.request import urlopen
+
+ENGINE_FILES = {"claude_to_codex.py", "handoff_sources.py"}
+SOURCE_URL = "https://raw.githubusercontent.com/mem0ai/mem0"
+
+
+def _verified(path: Path, digest: str) -> bool:
+ try:
+ return hashlib.sha256(path.read_bytes()).hexdigest() == digest
+ except FileNotFoundError:
+ return False
+
+
+def runtime_root(launcher_dir: Path | None = None) -> Path:
+ here = launcher_dir or Path(__file__).resolve().parent
+ if all((here / name).is_file() for name in ENGINE_FILES):
+ return here # The canonical development checkout already has both engines.
+ manifest = json.loads((here / "handoff-runtime.json").read_text(encoding="utf-8"))
+ if not isinstance(manifest, dict):
+ raise ValueError("invalid handoff runtime manifest")
+ revision, files = manifest.get("revision"), manifest.get("files")
+ if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
+ raise ValueError("handoff runtime revision must be an immutable commit SHA")
+ if (
+ not isinstance(files, dict)
+ or set(files) != ENGINE_FILES
+ or any(not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) for digest in files.values())
+ ):
+ raise ValueError("invalid handoff runtime file hashes")
+ cache = Path.home() / ".mem0" / "handoff-runtime" / revision
+ missing = {name: digest for name, digest in files.items() if not _verified(cache / name, digest)}
+ if not missing:
+ return cache
+ cache.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with tempfile.TemporaryDirectory(prefix=f".{revision}-", dir=cache.parent) as temporary:
+ staged = Path(temporary)
+ for name, digest in missing.items():
+ url = f"{SOURCE_URL}/{revision}/integrations/agent-plugin-core/python/{name}"
+ try:
+ with urlopen(url, timeout=30) as response:
+ body = response.read()
+ except OSError as exc:
+ raise OSError(f"could not download pinned handoff runtime {name}: {exc}") from exc
+ if hashlib.sha256(body).hexdigest() != digest:
+ raise ValueError(f"SHA256 mismatch for pinned handoff runtime {name}; refusing to execute it")
+ (staged / name).write_bytes(body)
+ # All downloads are verified before publishing; each replacement is atomic.
+ cache.mkdir(exist_ok=True, mode=0o700)
+ for name in missing:
+ os.replace(staged / name, cache / name)
+ if not all(_verified(cache / name, digest) for name, digest in files.items()):
+ raise ValueError("handoff runtime cache changed during installation; refusing to execute it")
+ return cache
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ root = runtime_root()
+ except (OSError, ValueError) as exc:
+ print(f"handoff runtime unavailable: {exc}", file=sys.stderr)
+ return 1
+ sys.path.insert(0, str(root))
+ from claude_to_codex import main as engine_main
+
+ return engine_main(argv, default_source=None)
-from claude_to_codex import main
if __name__ == "__main__":
- raise SystemExit(main(default_source=None))
+ raise SystemExit(main())
diff --git a/integrations/cursor-plugin/plugin-build.json b/integrations/cursor-plugin/plugin-build.json
index 3b09e90fb..bc810649c 100644
--- a/integrations/cursor-plugin/plugin-build.json
+++ b/integrations/cursor-plugin/plugin-build.json
@@ -1,6 +1,6 @@
{
"id": "mem0",
- "version": "0.4.0",
+ "version": "0.3.2",
"homepage": "https://docs.mem0.ai/integrations/cursor",
"native": {
"pluginRoot": "${CURSOR_PLUGIN_ROOT}",
diff --git a/integrations/cursor-plugin/skills/handoff/SKILL.md b/integrations/cursor-plugin/skills/handoff/SKILL.md
index 7421835b9..d731e3952 100644
--- a/integrations/cursor-plugin/skills/handoff/SKILL.md
+++ b/integrations/cursor-plugin/skills/handoff/SKILL.md
@@ -9,7 +9,9 @@ allowed-tools: Bash(python3 ${CURSOR_PLUGIN_ROOT}/core/session_handoff.py *)
All hosts share one local import engine. Native readers and SDK adapters supply
complete conversation items; Mem0 memory capture is not a transcript source.
-Requires Python 3.11+ and a Codex CLI with native session import support. The
+Requires Python 3.11+ and a Codex CLI with native session import support.
+First use downloads a pinned, hash-verified runtime from GitHub; later uses share
+the verified local cache. No transcript is sent to GitHub. The
supported destination is Codex. This does not transfer files or change branches.
Visible conversation, tool history, and supported source compaction summaries
diff --git a/integrations/deepseek-plugin/CHANGELOG.md b/integrations/deepseek-plugin/CHANGELOG.md
new file mode 100644
index 000000000..18370abad
--- /dev/null
+++ b/integrations/deepseek-plugin/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+## 0.3.2
+
+- An explicit `mem0_handoff` call uses the current DeepSeek session’s derived messages; invoke it outside nested code mode. Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
+- Lightened search prompts: search when prior work may help; repeat only for a specific gap.
diff --git a/integrations/deepseek-plugin/README.md b/integrations/deepseek-plugin/README.md
index 7a1df3093..7267b247f 100644
--- a/integrations/deepseek-plugin/README.md
+++ b/integrations/deepseek-plugin/README.md
@@ -14,7 +14,7 @@ It gives a Harness agent automatic long-term memory plus two explicit memory too
Unlike the local/file-based memory plugins in the ecosystem, Mem0 is a managed backend: server-side extraction, semantic dedup and conflict resolution, and memories that other agents can retrieve when their user and entity filters match.
-Current package version: `0.4.0`.
+Current package version: `0.3.2`.
## Session handoff
@@ -22,7 +22,7 @@ Explicitly request the `mem0_handoff` tool to continue the current DeepSeek sess
Requires **Python 3.11+** available as `python3` and an installed, signed-in Codex CLI with native session import support. Handoff works independently of Mem0 credentials and never sends the transcript through the Mem0 API. Unsupported content, missing results, and unrelated unfinished calls fail explicitly.
-The shared engine is packaged in `dist/` by [agent-plugin-core](../agent-plugin-core/README.md); it is not maintained separately in this plugin. Failed imports save a private recovery bundle under `~/.mem0/handoffs/`. See the [session handoff guide](../../docs/integrations/session-handoff.mdx) for supported formats, privacy, and recovery.
+The shared launcher and pinned runtime manifest are packaged in `dist/` by [agent-plugin-core](../agent-plugin-core/README.md); the importer is fetched once from its pinned GitHub commit, verified, and cached for all plugins. A valid cache works offline. Failed imports save a private recovery bundle under `~/.mem0/handoffs/`. See the [session handoff guide](../agent-plugin-core/README.md#session-handoff) for supported formats, privacy, and recovery.
## How it works
@@ -61,7 +61,7 @@ Cordis owns listener and tool cleanup when the plugin unmounts. Every automatic
3. Install it into a disposable Harness profile:
```sh
DSH_HOME=/tmp/mem0-dsh-dev pnpm dlx @deepseek-ai/dsh@0.1.1-rc.2 \
- plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.4.0.tgz
+ plugin --profile headless add /tmp/mem0-deepseek-plugin/mem0-deepseek-plugin-0.3.2.tgz
```
4. Copy `cordis.example.yml`, set its installed package path and your `userId`, then run Harness with the same profile:
```sh
diff --git a/integrations/deepseek-plugin/package.json b/integrations/deepseek-plugin/package.json
index aafd38f4f..41a8fe7a5 100644
--- a/integrations/deepseek-plugin/package.json
+++ b/integrations/deepseek-plugin/package.json
@@ -1,6 +1,6 @@
{
"name": "@mem0/deepseek-plugin",
- "version": "0.4.0",
+ "version": "0.3.2",
"description": "Mem0 long-term memory as a native DeepSeek Harness (Cordis) plugin.",
"type": "module",
"license": "Apache-2.0",
diff --git a/integrations/kimi-plugin/CHANGELOG.md b/integrations/kimi-plugin/CHANGELOG.md
new file mode 100644
index 000000000..bd97afad7
--- /dev/null
+++ b/integrations/kimi-plugin/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+## 0.3.2
+
+- The `handoff` skill accepts a completed Kimi v2 wire transcript. Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
+- Lightened search prompts: search when prior work may help; repeat only for a specific gap.
diff --git a/integrations/kimi-plugin/core/claude_to_codex.py b/integrations/kimi-plugin/core/claude_to_codex.py
deleted file mode 100644
index fd372e198..000000000
--- a/integrations/kimi-plugin/core/claude_to_codex.py
+++ /dev/null
@@ -1,1492 +0,0 @@
-#!/usr/bin/env python3
-"""Shared local session-handoff engine and backwards-compatible Claude CLI.
-
-Native readers and SDK adapters supply complete conversation items. This engine
-validates and exports their bundles, then uses Codex's native external-session
-importer to create a task with visible historical turns. The legacy command
-still defaults to reading a Claude Code transcript; session_handoff.py requires
-an explicit source host or a neutral bundle.
-
-No model generates a handoff summary. Large imports may use Codex's native
-compaction before the new task is returned.
-"""
-
-# Adapted from mem0ai/memo at aeeb1593284d1d2fca3b4bcf1e32ea10f71df549 (Apache-2.0).
-from __future__ import annotations
-
-import argparse
-import base64
-import binascii
-import hashlib
-import html
-import json
-import os
-import queue
-import re
-import shutil
-import subprocess
-import sys
-import tempfile
-import threading
-import time
-from dataclasses import asdict, dataclass, replace
-from pathlib import Path
-from typing import Any, Iterable
-
-FORMAT_VERSION = "mem0.session-handoff.v1"
-DEFAULT_CODEX_HOME = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
-DEFAULT_BUNDLE_DIR = Path.home() / ".mem0" / "handoffs"
-IMPORT_COMPLETED_NOTIFICATION = "externalAgentConfig/import/completed"
-IMAGE_EXTENSIONS = {
- "image/gif": "gif",
- "image/jpeg": "jpg",
- "image/png": "png",
- "image/webp": "webp",
-}
-
-
-class HandoffError(RuntimeError):
- """A source session cannot be transferred without losing state."""
-
-
-@dataclass(frozen=True)
-class SourceInfo:
- path: str
- sha256: str
- session_id: str
- title: str
- cwd: str
- leaf_uuid: str
- compact_boundary_uuid: str | None
- first_imported_uuid: str
- last_imported_uuid: str
- codex_cwd: str | None = None
- host: str = "claude-code"
-
-
-@dataclass
-class HandoffPlan:
- source: SourceInfo
- items: list[dict[str, Any]]
- source_records: int
- active_records: int
- imported_records: int
- hidden_reasoning_blocks_skipped: int
- approximate_tokens: int
- warnings: list[str]
-
- def bundle(self) -> dict[str, Any]:
- return {
- "format": FORMAT_VERSION,
- "source": asdict(self.source),
- "items": self.items,
- "counts": {
- "source_records": self.source_records,
- "active_records": self.active_records,
- "imported_records": self.imported_records,
- "responses_items": len(self.items),
- "hidden_reasoning_blocks_skipped": self.hidden_reasoning_blocks_skipped,
- "approximate_tokens": self.approximate_tokens,
- },
- "warnings": self.warnings,
- }
-
-
-@dataclass(frozen=True)
-class CodexContextLimits:
- model: str
- context_window: int
- usable_context_window: int
- auto_compact_token_limit: int
- max_context_window: int
- max_usable_context_window: int
- max_auto_compact_token_limit: int
-
-
-def _stable_jsonl(path: Path) -> tuple[list[dict[str, Any]], str]:
- before = path.stat()
- raw = path.read_bytes()
- after = path.stat()
- if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
- raise HandoffError(f"Source session changed while it was being read: {path}")
- if raw and not raw.endswith(b"\n"):
- raise HandoffError(
- "The final JSONL record is incomplete. Finish or stop the active source response before transferring it."
- )
-
- records: list[dict[str, Any]] = []
- for line_number, line in enumerate(raw.splitlines(), 1):
- if not line.strip():
- continue
- try:
- record = json.loads(line)
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid source JSONL at {path}:{line_number}: {exc}") from exc
- if not isinstance(record, dict):
- raise HandoffError(f"Source JSONL record is not an object at {path}:{line_number}.")
- records.append(record)
- if not records:
- raise HandoffError(f"Source session is empty: {path}")
- return records, hashlib.sha256(raw).hexdigest()
-
-
-def _resolve_session(value: str, projects_dir: Path) -> Path:
- supplied = Path(value).expanduser()
- if supplied.is_file():
- return supplied.resolve()
-
- matches = list(projects_dir.glob(f"*/{value}.jsonl"))
- if not matches:
- raise HandoffError(
- f"No Claude session named {value!r} exists below {projects_dir}. "
- "Pass the session ID or its full JSONL path."
- )
- if len(matches) != 1:
- joined = "\n".join(f" {path}" for path in matches)
- raise HandoffError(f"Session ID {value!r} is ambiguous:\n{joined}")
- return matches[0].resolve()
-
-
-def _active_chain(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
- with_uuid = [
- record for record in records if isinstance(record.get("uuid"), str) and record.get("isSidechain") is not True
- ]
- if not with_uuid:
- raise HandoffError("Claude session has no main-agent conversation records.")
-
- by_uuid = {record["uuid"]: record for record in with_uuid}
- leaf = with_uuid[-1]
- chain: list[dict[str, Any]] = []
- seen: set[str] = set()
- current: dict[str, Any] | None = leaf
- while current is not None:
- uuid = current["uuid"]
- if uuid in seen:
- raise HandoffError(f"Claude session contains a parent cycle at {uuid}.")
- seen.add(uuid)
- chain.append(current)
- parent_uuid = current.get("parentUuid")
- if parent_uuid is None:
- break
- current = by_uuid.get(parent_uuid)
- if current is None:
- raise HandoffError(f"Claude's active branch references missing parent {parent_uuid}.")
- chain.reverse()
- return chain
-
-
-def _after_latest_compaction(
- chain: list[dict[str, Any]],
-) -> tuple[list[dict[str, Any]], str | None]:
- compact_index: int | None = None
- for index, record in enumerate(chain):
- if record.get("type") == "system" and record.get("subtype") == "compact_boundary":
- compact_index = index
- if compact_index is None:
- imported = chain
- compact_uuid = None
- else:
- imported = chain[compact_index + 1 :]
- compact_uuid = chain[compact_index]["uuid"]
- if not imported or imported[0].get("isCompactSummary") is not True:
- raise HandoffError(f"Claude compaction {compact_uuid} has no following compact summary.")
- imported = [record for record in imported if record.get("type") != "system"]
- if not imported:
- raise HandoffError("Claude's active state contains no transferable records.")
- return imported, compact_uuid
-
-
-def _tool_result_ids(record: dict[str, Any]) -> set[str]:
- if record.get("type") != "user":
- return set()
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- return set()
- return {
- str(block["tool_use_id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("tool_use_id")
- }
-
-
-def _tool_call_ids(records: list[dict[str, Any]]) -> set[str]:
- call_ids: set[str] = set()
- for record in records:
- if record.get("type") != "assistant":
- continue
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- continue
- call_ids.update(
- str(block["id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id")
- )
- return call_ids
-
-
-def _merge_parallel_tool_results(
- active_records: list[dict[str, Any]], all_records: list[dict[str, Any]]
-) -> list[dict[str, Any]]:
- """Restore sibling tool results that Claude stores outside the parent chain.
-
- Parallel Claude tool calls form a fork: later calls remain on the parent
- chain, while earlier results can be sibling records. Claude sends all of
- those results back to the model. Insert them together immediately after the
- assistant response that issued the calls.
- """
- results_by_call: dict[str, list[tuple[int, dict[str, Any]]]] = {}
- for source_index, record in enumerate(all_records):
- for call_id in _tool_result_ids(record):
- results_by_call.setdefault(call_id, []).append((source_index, record))
-
- merged: list[dict[str, Any]] = []
- inserted_result_uuids: set[str] = set()
- index = 0
- while index < len(active_records):
- record = active_records[index]
- record_uuid = str(record.get("uuid") or "")
- if record_uuid in inserted_result_uuids:
- index += 1
- continue
- if record.get("type") != "assistant":
- merged.append(record)
- index += 1
- continue
-
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(active_records):
- candidate = active_records[index]
- candidate_id = (candidate.get("message") or {}).get("id")
- if candidate.get("type") != "assistant" or not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- merged.extend(group)
-
- matching_results: list[tuple[int, dict[str, Any]]] = []
- for call_id in _tool_call_ids(group):
- matching_results.extend(results_by_call.get(call_id, []))
- for _, result in sorted(matching_results, key=lambda pair: pair[0]):
- result_uuid = str(result.get("uuid") or "")
- if result_uuid and result_uuid not in inserted_result_uuids:
- merged.append(result)
- inserted_result_uuids.add(result_uuid)
- return merged
-
-
-def _image_payload(source: Any, context: str) -> tuple[str, str]:
- if not isinstance(source, dict) or source.get("type") != "base64":
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = str(source.get("media_type") or "").lower()
- data = source.get("data")
- if media_type not in IMAGE_EXTENSIONS or not isinstance(data, str) or not data:
- raise HandoffError(f"{context} has an unsupported or missing image type.")
- return media_type, data
-
-
-def _data_url_payload(image_url: Any, context: str) -> tuple[str, str]:
- if not isinstance(image_url, str):
- raise HandoffError(f"{context} has no transferable image data.")
- match = re.fullmatch(r"data:([^;,]+);base64,(.+)", image_url, flags=re.DOTALL)
- if not match:
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = match.group(1).lower()
- if media_type not in IMAGE_EXTENSIONS:
- raise HandoffError(f"{context} has unsupported image type {media_type!r}.")
- return media_type, match.group(2)
-
-
-def _save_image(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> Path:
- try:
- payload = base64.b64decode(encoded, validate=True)
- except (binascii.Error, ValueError) as exc:
- raise HandoffError(f"{context} contains invalid base64 image data.") from exc
- if not payload:
- raise HandoffError(f"{context} contains an empty image.")
-
- digest = hashlib.sha256(payload).hexdigest()
- asset_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
- path = asset_dir / f"{digest}.{IMAGE_EXTENSIONS[media_type]}"
- if path.exists():
- if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
- raise HandoffError(f"The existing handoff image is corrupted: {path}")
- return path
-
- descriptor, filename = tempfile.mkstemp(prefix=f".{path.name}.", dir=asset_dir)
- temporary = Path(filename)
- try:
- with os.fdopen(descriptor, "wb") as stream:
- stream.write(payload)
- os.replace(temporary, path)
- except OSError as exc:
- temporary.unlink(missing_ok=True)
- raise HandoffError(f"Could not save the handoff image at {path}: {exc}") from exc
- return path
-
-
-def _image_reference(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> str:
- path = _save_image(media_type, encoded, asset_dir, context)
- return f"[Image saved at {path}]"
-
-
-def _tool_result_text(value: Any, asset_dir: Path, context: str) -> str:
- if value is None:
- return ""
- if isinstance(value, str):
- return value
- if isinstance(value, (int, float, bool)):
- return str(value)
- if isinstance(value, list):
- parts: list[str] = []
- for part in value:
- if isinstance(part, dict) and part.get("type") == "text":
- parts.append(str(part.get("text", "")))
- elif isinstance(part, dict) and part.get("type") == "image":
- media_type, encoded = _image_payload(part.get("source"), context)
- parts.append(_image_reference(media_type, encoded, asset_dir, context))
- else:
- parts.append(json.dumps(part, ensure_ascii=False, separators=(",", ":")))
- return "\n".join(part for part in parts if part)
- if isinstance(value, dict) and value.get("type") == "image":
- media_type, encoded = _image_payload(value.get("source"), context)
- return _image_reference(media_type, encoded, asset_dir, context)
- return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
-
-
-def _message(role: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
- return {"type": "message", "role": role, "content": parts}
-
-
-def _attachment_item(record: dict[str, Any]) -> dict[str, Any] | None:
- attachment = record.get("attachment")
- if not isinstance(attachment, dict):
- raise HandoffError(f"Claude attachment {record.get('uuid')} has no payload.")
-
- attachment_type = attachment.get("type")
- filename = str(attachment.get("filename") or attachment.get("displayPath") or "unknown")
- content = attachment.get("content")
- if attachment_type == "file" and isinstance(content, dict):
- file_payload = content.get("file") if content.get("type") == "text" else None
- if isinstance(file_payload, dict) and isinstance(file_payload.get("content"), str):
- text = file_payload["content"]
- display = str(file_payload.get("filePath") or filename)
- wrapped = f'\n{text}\n'
- return _message("user", [{"type": "input_text", "text": wrapped}])
-
- if attachment_type == "image" and isinstance(content, dict):
- image_url = content.get("image_url") or content.get("data")
- if isinstance(image_url, str) and image_url.startswith("data:"):
- return _message("user", [{"type": "input_image", "image_url": image_url}])
-
- if attachment_type in {"file", "image"}:
- raise HandoffError(f"Claude {attachment_type} attachment {record.get('uuid')} has an unsupported payload.")
-
- # Claude also records its own skill list, tool availability, permissions,
- # token reminders, hooks, and task status as attachments. Those configure
- # Claude's harness; they are not part of the user's project conversation and
- # must not become user messages in Codex.
- return None
-
-
-def _assistant_items(records: list[dict[str, Any]], calls: dict[str, str]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- skipped_reasoning = 0
- text_parts: list[dict[str, Any]] = []
-
- def flush_text() -> None:
- if text_parts:
- items.append(_message("assistant", list(text_parts)))
- text_parts.clear()
-
- for record in records:
- content = (record.get("message") or {}).get("content", [])
- if isinstance(content, str):
- text_parts.append({"type": "output_text", "text": content})
- continue
- if not isinstance(content, list):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid content.")
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "thinking" or kind == "redacted_thinking":
- skipped_reasoning += 1
- continue
- if kind == "text":
- text_parts.append({"type": "output_text", "text": str(block.get("text", ""))})
- continue
- if kind == "tool_use":
- flush_text()
- call_id = str(block.get("id") or "")
- name = str(block.get("name") or "")
- if not call_id or not name:
- raise HandoffError(f"Claude tool call in {record.get('uuid')} has no ID or name.")
- if call_id in calls:
- raise HandoffError(f"Claude tool call ID is duplicated: {call_id}")
- calls[call_id] = name
- items.append(
- {
- "type": "function_call",
- "call_id": call_id,
- "name": name,
- "arguments": json.dumps(
- block.get("input", {}),
- ensure_ascii=False,
- separators=(",", ":"),
- ),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude assistant block {kind!r} in {record.get('uuid')}.")
- flush_text()
- return items, skipped_reasoning
-
-
-def _user_items(record: dict[str, Any], calls: dict[str, str], completed_calls: set[str]) -> list[dict[str, Any]]:
- if record.get("isMeta") is True:
- return []
- content = (record.get("message") or {}).get("content")
- if isinstance(content, str):
- return [_message("user", [{"type": "input_text", "text": content}])]
- if not isinstance(content, list):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid content.")
-
- items: list[dict[str, Any]] = []
- user_parts: list[dict[str, Any]] = []
-
- def flush_user() -> None:
- if user_parts:
- items.append(_message("user", list(user_parts)))
- user_parts.clear()
-
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "text":
- user_parts.append({"type": "input_text", "text": str(block.get("text", ""))})
- continue
- if kind == "image":
- source = block.get("source") or {}
- if source.get("type") == "base64" and source.get("data") and source.get("media_type"):
- user_parts.append(
- {
- "type": "input_image",
- "image_url": f"data:{source['media_type']};base64,{source['data']}",
- }
- )
- continue
- raise HandoffError(f"Claude image in {record.get('uuid')} is not stored as transferable base64 data.")
- if kind == "tool_result":
- flush_user()
- call_id = str(block.get("tool_use_id") or "")
- if not call_id:
- raise HandoffError(f"Claude tool result in {record.get('uuid')} has no call ID.")
- if call_id not in calls:
- raise HandoffError(f"Claude tool result {call_id} has no matching call in the active state.")
- if call_id in completed_calls:
- raise HandoffError(f"Claude tool result is duplicated: {call_id}")
- completed_calls.add(call_id)
- items.append(
- {
- "type": "function_call_output",
- "call_id": call_id,
- "name": calls[call_id],
- "output": block.get("content"),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude user block {kind!r} in {record.get('uuid')}.")
- flush_user()
- return items
-
-
-def _responses_items(records: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- calls: dict[str, str] = {}
- completed_calls: set[str] = set()
- skipped_reasoning = 0
-
- index = 0
- while index < len(records):
- record = records[index]
- record_type = record.get("type")
- if record_type == "assistant":
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(records):
- candidate = records[index]
- if candidate.get("type") != "assistant":
- break
- candidate_id = (candidate.get("message") or {}).get("id")
- if not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- assistant_items, skipped = _assistant_items(group, calls)
- items.extend(assistant_items)
- skipped_reasoning += skipped
- continue
- if record_type == "user":
- items.extend(_user_items(record, calls, completed_calls))
- elif record_type == "attachment":
- attachment_item = _attachment_item(record)
- if attachment_item is not None:
- items.append(attachment_item)
- elif record_type not in {"system"}:
- raise HandoffError(f"Unsupported model-visible Claude record {record_type!r} at {record.get('uuid')}.")
- index += 1
-
- unfinished = sorted(set(calls) - completed_calls)
- if unfinished:
- joined = ", ".join(unfinished[:5])
- raise HandoffError(
- f"Claude's active state ends with unfinished tool call(s): {joined}. "
- "Finish or stop the Claude turn before transferring it."
- )
- if not items:
- raise HandoffError("Claude's active state produced no Codex history items.")
- return items, skipped_reasoning
-
-
-def _without_image_payloads(value: Any) -> Any:
- if isinstance(value, list):
- return [_without_image_payloads(item) for item in value]
- if not isinstance(value, dict):
- return value
-
- cleaned = {key: _without_image_payloads(item) for key, item in value.items()}
- if cleaned.get("type") == "input_image" and isinstance(cleaned.get("image_url"), str):
- cleaned["image_url"] = "[Image saved locally during handoff]"
- if cleaned.get("type") == "image" and isinstance(cleaned.get("source"), dict):
- source = dict(cleaned["source"])
- if source.get("type") == "base64" and "data" in source:
- source["data"] = "[Image saved locally during handoff]"
- cleaned["source"] = source
- return cleaned
-
-
-def _token_count(value: Any) -> int:
- text = json.dumps(_without_image_payloads(value), ensure_ascii=False, separators=(",", ":"))
- try:
- import tiktoken
-
- return len(tiktoken.get_encoding("o200k_base").encode(text))
- except ImportError:
- return (len(text) + 3) // 4
-
-
-def build_plan(session: str, projects_dir: Path) -> HandoffPlan:
- path = _resolve_session(session, projects_dir)
- records, sha256 = _stable_jsonl(path)
- chain = _active_chain(records)
- imported, compact_uuid = _after_latest_compaction(chain)
- imported = _merge_parallel_tool_results(imported, records)
- items, skipped_reasoning = _responses_items(imported)
-
- session_id = next(
- (str(record["sessionId"]) for record in reversed(records) if record.get("sessionId")),
- path.stem,
- )
- title = next(
- (
- str(record["customTitle"])
- for record in reversed(records)
- if record.get("type") == "custom-title" and record.get("customTitle")
- ),
- f"Claude session {session_id[:8]}",
- )
- cwd = next(
- (str(record["cwd"]) for record in chain if record.get("cwd")),
- "",
- )
- if not cwd:
- raise HandoffError("Claude session does not record its working directory.")
-
- warnings: list[str] = []
- if skipped_reasoning:
- warnings.append(f"Skipped {skipped_reasoning} Claude hidden-reasoning block(s); they are not portable.")
-
- source = SourceInfo(
- path=str(path),
- sha256=sha256,
- session_id=session_id,
- title=title,
- cwd=str(Path(cwd).resolve()),
- leaf_uuid=chain[-1]["uuid"],
- compact_boundary_uuid=compact_uuid,
- first_imported_uuid=imported[0]["uuid"],
- last_imported_uuid=imported[-1]["uuid"],
- codex_cwd=_git_root(Path(cwd)),
- )
- return HandoffPlan(
- source=source,
- items=items,
- source_records=len(records),
- active_records=len(chain),
- imported_records=len(imported),
- hidden_reasoning_blocks_skipped=skipped_reasoning,
- approximate_tokens=_token_count(items),
- warnings=warnings,
- )
-
-
-def _write_private(path: Path, body: str) -> None:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
- try:
- with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
- stream.write(body)
- os.replace(temporary, path)
- finally:
- Path(temporary).unlink(missing_ok=True)
-
-
-def write_bundle(plan: HandoffPlan, path: Path) -> Path:
- path = path.expanduser().resolve()
- _write_private(path, json.dumps(plan.bundle(), ensure_ascii=False))
- return path
-
-
-def _validate_items(items: Any) -> None:
- if not isinstance(items, list) or not items:
- raise HandoffError("Handoff bundle contains no history items.")
- calls: dict[str, str] = {}
- completed: set[str] = set()
- saw_user = False
- for item in items:
- if not isinstance(item, dict):
- raise HandoffError("Invalid handoff history item.")
- kind = item.get("type")
- status = item.get("status")
- if (
- not isinstance(kind, str)
- or (status is not None and not isinstance(status, str))
- or status in {"incomplete", "in_progress"}
- ):
- raise HandoffError("Invalid or incomplete handoff history item.")
- if kind == "message":
- role = item.get("role")
- parts = item.get("content")
- if (
- not isinstance(role, str)
- or role not in {"user", "assistant"}
- or not isinstance(parts, list)
- or not parts
- ):
- raise HandoffError("Invalid handoff message role or content.")
- saw_user = saw_user or role == "user"
- for part in parts:
- if not isinstance(part, dict) or not isinstance(part.get("type"), str):
- raise HandoffError("Invalid handoff message part.")
- if part.get("type") in {"input_text", "output_text"} and isinstance(part.get("text"), str):
- continue
- if part.get("type") == "input_image":
- _, encoded = _data_url_payload(part.get("image_url"), "Handoff image")
- try:
- if not base64.b64decode(encoded, validate=True):
- raise ValueError("empty image")
- except (ValueError, binascii.Error) as exc:
- raise HandoffError("Invalid handoff image data.") from exc
- continue
- raise HandoffError("Unsupported handoff message part.")
- elif kind == "function_call":
- call_id, name, arguments = item.get("call_id"), item.get("name"), item.get("arguments")
- if not isinstance(call_id, str) or not call_id or not isinstance(name, str) or not name:
- raise HandoffError("Invalid handoff tool call ID or name.")
- if call_id in calls or not isinstance(arguments, str):
- raise HandoffError("Duplicate or invalid handoff tool call.")
- try:
- json.loads(arguments)
- except json.JSONDecodeError as exc:
- raise HandoffError("Handoff tool arguments are not JSON.") from exc
- calls[call_id] = name
- elif kind == "function_call_output":
- call_id = item.get("call_id")
- if not isinstance(call_id, str) or call_id not in calls or call_id in completed:
- raise HandoffError("Unmatched or duplicate handoff tool result.")
- if "output" not in item:
- raise HandoffError("Handoff tool result has no output.")
- completed.add(call_id)
- item.setdefault("name", calls[call_id])
- else:
- raise HandoffError(f"Unsupported handoff item type: {kind!r}.")
- if set(calls) != completed:
- raise HandoffError("Session has unfinished tool calls; finish or stop the source turn before handoff.")
- if not saw_user:
- raise HandoffError("Handoff contains no user message.")
-
-
-def plan_from_bundle(payload: Any) -> HandoffPlan:
- formats = {FORMAT_VERSION, "mem0.claude-to-codex.v1", "memo.claude-to-codex.v1"}
- if not isinstance(payload, dict) or not isinstance(payload.get("format"), str) or payload["format"] not in formats:
- raise HandoffError("Unsupported handoff bundle format.")
- source_payload = payload.get("source")
- items = payload.get("items")
- warnings = payload.get("warnings", [])
- if not isinstance(source_payload, dict) or not isinstance(warnings, list):
- raise HandoffError("Handoff bundle has no source or has invalid warnings.")
- _validate_items(items)
- try:
- fields = dict(source_payload)
- legacy = payload["format"] != FORMAT_VERSION
- fields.setdefault("host", "claude-code" if legacy else "")
- for key in ("host", "session_id", "title", "cwd"):
- if not isinstance(fields.get(key), str) or not fields[key].strip():
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[a-z][a-z0-9-]*", fields["host"]):
- raise ValueError("invalid source host")
- fields.setdefault("path", f"{fields['host']}:{fields['session_id']}")
- fields.setdefault("sha256", hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest())
- fields.setdefault("leaf_uuid", str(len(items)))
- fields.setdefault("first_imported_uuid", "1")
- fields.setdefault("last_imported_uuid", str(len(items)))
- fields.setdefault("compact_boundary_uuid", None)
- source = SourceInfo(**fields)
- for key, value in asdict(source).items():
- if value is None and key in {"codex_cwd", "compact_boundary_uuid"}:
- continue
- if not isinstance(value, str):
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[0-9a-f]{64}", source.sha256):
- raise ValueError("invalid source digest")
- counts = payload.get("counts", {})
- return HandoffPlan(
- source=source,
- items=items,
- source_records=int(counts.get("source_records", len(items))),
- active_records=int(counts.get("active_records", len(items))),
- imported_records=int(counts.get("imported_records", len(items))),
- hidden_reasoning_blocks_skipped=int(counts.get("hidden_reasoning_blocks_skipped", 0)),
- approximate_tokens=_token_count(items),
- warnings=[str(warning) for warning in warnings],
- )
- except (KeyError, TypeError, ValueError, AttributeError) as exc:
- raise HandoffError("Handoff bundle is incomplete or has invalid source fields.") from exc
-
-
-def load_bundle(path: Path) -> HandoffPlan:
- try:
- text = sys.stdin.read() if str(path) == "-" else path.expanduser().resolve().read_text(encoding="utf-8")
- return plan_from_bundle(json.loads(text))
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid handoff bundle JSON: {path}") from exc
-
-
-def _git_root(cwd: Path) -> str | None:
- completed = subprocess.run(
- ["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
- text=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- check=False,
- )
- if completed.returncode != 0:
- return None
- root = Path(completed.stdout.strip()).resolve()
- return str(root) if root.is_dir() else None
-
-
-def _with_cwd(plan: HandoffPlan, cwd: Path | None) -> HandoffPlan:
- source_cwd = Path(plan.source.cwd).expanduser().resolve()
- target = (
- cwd.expanduser().resolve()
- if cwd
- else Path(plan.source.codex_cwd).expanduser().resolve()
- if plan.source.codex_cwd
- else Path(_git_root(source_cwd) or source_cwd)
- )
- if not target.is_dir():
- raise HandoffError(f"Codex working directory does not exist: {target}")
- return replace(plan, source=replace(plan.source, cwd=str(source_cwd), codex_cwd=str(target)))
-
-
-def _codex_cwd(plan: HandoffPlan) -> str:
- return plan.source.codex_cwd or plan.source.cwd
-
-
-def _default_bundle_path(plan: HandoffPlan) -> Path:
- session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip(".-")[:80] or "session"
- name = f"{session}-{plan.source.sha256[:12]}.json"
- return DEFAULT_BUNDLE_DIR / name
-
-
-def _codex_context_limits(codex_home: Path) -> CodexContextLimits:
- try:
- import tomllib
- except ImportError as exc:
- raise HandoffError("Creating a Codex task requires Python 3.11 or newer; rerun with python3.11.") from exc
-
- codex_home = codex_home.expanduser().resolve()
- config_path = codex_home / "config.toml"
- cache_path = codex_home / "models_cache.json"
- try:
- config = tomllib.loads(config_path.read_text(encoding="utf-8"))
- except (OSError, tomllib.TOMLDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex configuration at {config_path}: {exc}") from exc
- try:
- cache = json.loads(cache_path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex model metadata at {cache_path}: {exc}") from exc
-
- model = str(config.get("model") or "")
- models = cache.get("models") if isinstance(cache, dict) else None
- if not isinstance(models, list):
- raise HandoffError(f"Codex model metadata has no model list: {cache_path}")
- model_info = next(
- (
- item
- for item in models
- if isinstance(item, dict)
- and (
- item.get("slug") == model
- or item.get("model") == model
- or (not model and item.get("is_default") is True)
- )
- ),
- None,
- )
- if not isinstance(model_info, dict):
- raise HandoffError(f"Codex model {model!r} is missing from {cache_path}; refresh Codex's model list.")
- model = str(model_info.get("slug") or model_info.get("model") or model)
-
- cached_context = model_info.get("context_window")
- cached_max = model_info.get("max_context_window") or cached_context
- if not isinstance(cached_context, int) or not isinstance(cached_max, int):
- raise HandoffError(f"Codex model {model!r} does not report its context limits.")
- configured_context = config.get("model_context_window")
- context_window = min(configured_context, cached_max) if isinstance(configured_context, int) else cached_context
- effective_percent = model_info.get("effective_context_window_percent", 95)
- if not isinstance(effective_percent, int) or not 1 <= effective_percent <= 100:
- raise HandoffError(f"Codex model {model!r} reports an invalid effective context percentage.")
-
- context_auto_limit = context_window * 9 // 10
- configured_auto_limit = config.get("model_auto_compact_token_limit")
- auto_compact_limit = (
- min(configured_auto_limit, context_auto_limit) if isinstance(configured_auto_limit, int) else context_auto_limit
- )
- return CodexContextLimits(
- model=model,
- context_window=context_window,
- usable_context_window=context_window * effective_percent // 100,
- auto_compact_token_limit=auto_compact_limit,
- max_context_window=cached_max,
- max_usable_context_window=cached_max * effective_percent // 100,
- max_auto_compact_token_limit=cached_max * 9 // 10,
- )
-
-
-class CodexAppServer:
- """Small JSON-RPC client for a one-off local Codex app-server process."""
-
- def __init__(
- self,
- codex_bin: str = "codex",
- context_window_override: int | None = None,
- codex_home: Path = DEFAULT_CODEX_HOME,
- ) -> None:
- resolved = shutil.which(codex_bin)
- if not resolved:
- raise HandoffError(f"Codex executable not found: {codex_bin}")
- command = [resolved]
- if context_window_override is not None:
- command.extend(["-c", f"model_context_window={context_window_override}"])
- command.extend(["app-server", "--stdio"])
- self.process = subprocess.Popen(
- command,
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- bufsize=1,
- env={**os.environ, "CODEX_HOME": str(codex_home.expanduser().resolve())},
- )
- self._responses: queue.Queue[dict[str, Any]] = queue.Queue()
- self._notifications: queue.Queue[dict[str, Any]] = queue.Queue()
- self._stderr: list[str] = []
- self._next_id = 1
- threading.Thread(target=self._read_stdout, daemon=True).start()
- threading.Thread(target=self._read_stderr, daemon=True).start()
- try:
- self.request(
- "initialize",
- {
- "clientInfo": {
- "name": "mem0_session_handoff",
- "title": "Mem0 local session handoff",
- "version": "0.1.0",
- }
- },
- )
- self.notify("initialized", {})
- except Exception:
- self.close()
- raise
-
- def _read_stdout(self) -> None:
- assert self.process.stdout is not None
- for line in self.process.stdout:
- try:
- message = json.loads(line)
- except json.JSONDecodeError:
- continue
- if not isinstance(message, dict):
- continue
- if "id" in message:
- self._responses.put(message)
- elif "method" in message:
- self._notifications.put(message)
-
- def _read_stderr(self) -> None:
- assert self.process.stderr is not None
- for line in self.process.stderr:
- self._stderr.append(line.rstrip())
-
- def _send(self, payload: dict[str, Any]) -> None:
- if self.process.poll() is not None:
- error = "\n".join(self._stderr[-20:])
- raise HandoffError(f"Codex app-server stopped unexpectedly.\n{error}")
- assert self.process.stdin is not None
- self.process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
- self.process.stdin.flush()
-
- def request(self, method: str, params: dict[str, Any], timeout: float = 30) -> Any:
- request_id = self._next_id
- self._next_id += 1
- self._send({"method": method, "id": request_id, "params": params})
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out on {method}.")
- try:
- response = self._responses.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out on {method}.") from exc
- if response.get("id") != request_id:
- deferred.append(response)
- continue
- if "error" in response:
- raise HandoffError(f"Codex {method} failed: {response['error']}")
- return response.get("result")
- finally:
- for response in deferred:
- self._responses.put(response)
-
- def notify(self, method: str, params: dict[str, Any]) -> None:
- self._send({"method": method, "params": params})
-
- def wait_for_notification(
- self,
- method: str,
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.") from exc
- if notification.get("method") != method:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def wait_for_any_notification(
- self,
- methods: set[str],
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.") from exc
- if notification.get("method") not in methods:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def close(self) -> None:
- if self.process.poll() is None:
- self.process.terminate()
- try:
- self.process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self.process.kill()
- self.process.wait(timeout=5)
-
- def __enter__(self) -> "CodexAppServer":
- return self
-
- def __exit__(self, *_: Any) -> None:
- self.close()
-
-
-def _item_text(item: dict[str, Any], asset_dir: Path) -> tuple[str, str]:
- """Convert one Responses item to a complete visible import message."""
- item_type = item.get("type")
- if item_type == "message":
- role = str(item.get("role") or "")
- if role not in {"user", "assistant"}:
- raise HandoffError(f"Codex's session importer cannot represent role {role!r}.")
- parts: list[str] = []
- for part in item.get("content") or []:
- if not isinstance(part, dict):
- raise HandoffError("A handoff message contains an invalid content item.")
- part_type = part.get("type")
- if part_type in {"input_text", "output_text"}:
- parts.append(str(part.get("text") or ""))
- elif part_type == "input_image":
- media_type, encoded = _data_url_payload(part.get("image_url"), "A Claude message image")
- parts.append(_image_reference(media_type, encoded, asset_dir, "A Claude message image"))
- else:
- raise HandoffError(f"Codex's session importer cannot represent content type {part_type!r}.")
- text = "\n\n".join(part for part in parts if part)
- if not text:
- raise HandoffError("A handoff message contains no transferable text.")
- return role, text
-
- if item_type == "function_call":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- arguments = str(item.get("arguments") or "{}")
- return (
- "assistant",
- f'\n{arguments}\n',
- )
-
- if item_type == "function_call_output":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- output = _tool_result_text(item.get("output"), asset_dir, f"Claude tool result {call_id}")
- return (
- "assistant",
- f'\n{output}\n',
- )
-
- raise HandoffError(f"Codex's session importer cannot represent item type {item_type!r}.")
-
-
-def _native_import_records(plan: HandoffPlan, asset_dir: Path) -> list[dict[str, Any]]:
- """Build the Claude-shaped history consumed by Codex's native importer."""
- cwd = _codex_cwd(plan)
- records: list[dict[str, Any]] = [
- {
- "type": "custom-title",
- "customTitle": plan.source.title,
- "sessionId": plan.source.session_id,
- }
- ]
- saw_user = False
- for index, item in enumerate(plan.items, 1):
- role, text = _item_text(item, asset_dir)
- saw_user = saw_user or role == "user"
- records.append(
- {
- "type": role,
- "sessionId": plan.source.session_id,
- "uuid": f"mem0-handoff-{index}",
- "cwd": cwd,
- "isSidechain": False,
- "message": {"role": role, "content": text},
- }
- )
- if not saw_user:
- raise HandoffError("The active Claude context contains no user message.")
- return records
-
-
-def _native_import_path(plan: HandoffPlan, claude_projects_dir: Path) -> Path:
- source_key = hashlib.sha256(plan.source.path.encode("utf-8")).hexdigest()[:24]
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- safe_session = safe_session[:80] or source_key
- return claude_projects_dir.expanduser().resolve() / ".mem0-handoffs" / f"{safe_session}-{source_key}.jsonl"
-
-
-def _write_native_import(plan: HandoffPlan, path: Path, asset_dir: Path) -> str:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- body = "".join(
- json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
- for record in _native_import_records(plan, asset_dir)
- )
- _write_private(path, body)
- return hashlib.sha256(body.encode("utf-8")).hexdigest()
-
-
-def _native_import_params(source_path: Path, cwd: str) -> dict[str, Any]:
- return {
- "migrationItems": [
- {
- "itemType": "SESSIONS",
- "description": f"Transfer Claude session {source_path.name}",
- "cwd": None,
- "details": {
- "plugins": [],
- "sessions": [{"path": str(source_path), "cwd": cwd, "title": None}],
- "mcpServers": [],
- "hooks": [],
- "subagents": [],
- "commands": [],
- },
- }
- ]
- }
-
-
-def _thread_id_from_completion(params: Any, source_path: Path) -> str | None:
- if not isinstance(params, dict):
- return None
- canonical = str(source_path.resolve())
- for result in params.get("itemTypeResults") or []:
- if not isinstance(result, dict) or result.get("itemType") != "SESSIONS":
- continue
- for success in result.get("successes") or []:
- if not isinstance(success, dict):
- continue
- if success.get("source") in {None, canonical} and success.get("target"):
- return str(success["target"])
- return None
-
-
-def _thread_id_from_ledger(codex_home: Path, source_path: Path, content_sha256: str) -> str | None:
- ledger_path = codex_home.expanduser() / "external_agent_session_imports.json"
- if not ledger_path.is_file():
- return None
- try:
- ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
- except json.JSONDecodeError:
- return None
- canonical = str(source_path.resolve())
- matches = [
- record
- for record in ledger.get("records", [])
- if isinstance(record, dict)
- and record.get("source_path") == canonical
- and record.get("content_sha256") == content_sha256
- and record.get("imported_thread_id")
- ]
- return str(matches[-1]["imported_thread_id"]) if matches else None
-
-
-def _notification_thread_id(params: Any) -> str | None:
- if not isinstance(params, dict):
- return None
- if params.get("threadId"):
- return str(params["threadId"])
- turn = params.get("turn")
- if isinstance(turn, dict) and turn.get("threadId"):
- return str(turn["threadId"])
- return None
-
-
-def _compact_imported_thread(
- server: CodexAppServer,
- thread_id: str,
-) -> dict[str, Any] | None:
- server.request("thread/resume", {"threadId": thread_id}, timeout=120)
- server.request("thread/compact/start", {"threadId": thread_id}, timeout=30)
-
- latest_usage: dict[str, Any] | None = None
- saw_compaction_item = False
- while True:
- notification = server.wait_for_any_notification(
- {"item/completed", "thread/tokenUsage/updated", "turn/completed", "error"},
- lambda params: _notification_thread_id(params) in {None, thread_id},
- timeout=600,
- )
- method = notification.get("method")
- params = notification.get("params")
- if method == "thread/tokenUsage/updated" and isinstance(params, dict):
- token_usage = params.get("tokenUsage")
- if isinstance(token_usage, dict):
- latest_usage = token_usage
- continue
- if method == "item/completed" and isinstance(params, dict):
- item = params.get("item")
- if isinstance(item, dict) and item.get("type") == "contextCompaction":
- saw_compaction_item = True
- continue
- if method == "error":
- error = params.get("error") if isinstance(params, dict) else params
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if method == "turn/completed" and isinstance(params, dict):
- turn = params.get("turn")
- if not isinstance(turn, dict):
- raise HandoffError("Codex returned an invalid compaction result.")
- if turn.get("status") != "completed":
- error = turn.get("error") or turn.get("status")
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if not saw_compaction_item:
- raise HandoffError("Codex completed the compaction turn without a compaction item.")
- return latest_usage
-
-
-def _set_thread_name(
- server: CodexAppServer,
- thread_id: str,
- name: str,
-) -> None:
- server.request(
- "thread/name/set",
- {"threadId": thread_id, "name": name},
- timeout=30,
- )
-
-
-def create_codex_thread(
- plan: HandoffPlan,
- codex_bin: str = "codex",
- codex_home: Path = DEFAULT_CODEX_HOME,
-) -> dict[str, Any]:
- limits = _codex_context_limits(codex_home)
- should_compact = plan.approximate_tokens >= limits.auto_compact_token_limit
- if should_compact and plan.approximate_tokens >= limits.max_auto_compact_token_limit:
- raise HandoffError(
- f"The active session state is approximately {plan.approximate_tokens:,} tokens. "
- f"Codex cannot safely compact more than approximately "
- f"{limits.max_auto_compact_token_limit:,} tokens in one request. "
- "Compact in the source host and retry the handoff."
- )
-
- # Codex only imports sources staged under its native Claude home.
- source_path = _native_import_path(plan, Path.home() / ".claude" / "projects")
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- asset_dir = (
- codex_home.expanduser().resolve()
- / "external-agent-assets"
- / plan.source.host
- / (safe_session[:80] or "session")
- )
- content_sha256 = _write_native_import(plan, source_path, asset_dir)
- try:
- context_override = limits.max_context_window if should_compact else None
- with CodexAppServer(codex_bin, context_override, codex_home=codex_home) as server:
- response = server.request(
- "externalAgentConfig/import",
- _native_import_params(source_path, _codex_cwd(plan)),
- timeout=120,
- )
- import_id = str((response or {}).get("importId") or "")
- if not import_id:
- raise HandoffError(f"Codex externalAgentConfig/import returned no import ID: {response!r}")
- completed = server.wait_for_notification(
- IMPORT_COMPLETED_NOTIFICATION,
- lambda params: isinstance(params, dict) and params.get("importId") == import_id,
- )
- completed_params = completed.get("params")
- thread_id = _thread_id_from_completion(completed_params, source_path)
- if not thread_id:
- thread_id = _thread_id_from_ledger(codex_home, source_path, content_sha256)
- if not thread_id:
- raise HandoffError(
- "Codex finished importing the session but did not report the new task ID. "
- f"Import result: {json.dumps(completed_params, ensure_ascii=False)}"
- )
-
- read = server.request(
- "thread/read",
- {"threadId": thread_id, "includeTurns": True},
- )
- thread = (read or {}).get("thread") if isinstance(read, dict) else None
- if not isinstance(thread, dict):
- raise HandoffError(f"Codex could not read imported task {thread_id}.")
- turns = thread.get("turns") or []
- preview = str(thread.get("preview") or "")
- if not turns or not preview:
- raise HandoffError(f"Codex imported task {thread_id}, but it has no visible history.")
-
- compaction_usage = _compact_imported_thread(server, thread_id) if should_compact else None
- _set_thread_name(server, thread_id, plan.source.title)
-
- return {
- "thread_id": thread_id,
- "title": plan.source.title,
- "cwd": _codex_cwd(plan),
- "source_session_id": plan.source.session_id,
- "source_host": plan.source.host,
- "visible_turns": len(turns),
- "preview": preview,
- "responses_items_converted": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "target_model": limits.model,
- "target_context_window": limits.context_window,
- "target_usable_context_window": limits.usable_context_window,
- "target_auto_compact_token_limit": limits.auto_compact_token_limit,
- "compacted_before_return": should_compact,
- "compaction_context_window": (limits.max_context_window if should_compact else None),
- "compaction_token_usage": compaction_usage,
- "model_invoked": should_compact,
- }
- finally:
- source_path.unlink(missing_ok=True)
- try:
- source_path.parent.rmdir()
- except OSError:
- pass
-
-
-def _summary(plan: HandoffPlan) -> dict[str, Any]:
- return {
- "source": plan.source.path,
- "source_host": plan.source.host,
- "session_id": plan.source.session_id,
- "title": plan.source.title,
- "source_cwd": plan.source.cwd,
- "codex_cwd": _codex_cwd(plan),
- "leaf_uuid": plan.source.leaf_uuid,
- "compact_boundary_uuid": plan.source.compact_boundary_uuid,
- "source_records": plan.source_records,
- "active_records": plan.active_records,
- "imported_records": plan.imported_records,
- "responses_items": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "warnings": plan.warnings,
- }
-
-
-def _command_output(result: dict[str, Any]) -> str:
- title = str(result["title"])
- cwd = str(result["cwd"])
- project = Path(cwd).name or cwd
- lines = [
- f'Created Codex task "{title}".',
- f"Task ID: {result['thread_id']}",
- f"Project: {project}",
- ]
- if result.get("compacted_before_return"):
- lines.append("Codex compacted the transferred context before opening the task.")
- lines.append(f'Open Codex and select "{title}" under {project}.')
- return "\n".join(lines)
-
-
-def _parse_args(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> argparse.Namespace:
- parser = argparse.ArgumentParser(description=__doc__)
- source = parser.add_mutually_exclusive_group(required=True)
- source.add_argument("--session", help="Native session transcript path (Claude also accepts its session ID)")
- parser.add_argument(
- "--source",
- choices=("claude-code", "cursor", "codex", "kimi", "antigravity", "openclaw", "pi-agent"),
- default=default_source,
- )
- parser.add_argument("--title", help="Override the imported task title")
- source.add_argument("--bundle", type=Path, help="Previously exported handoff bundle")
- parser.add_argument(
- "--claude-projects-dir",
- type=Path,
- default=Path.home() / ".claude" / "projects",
- )
- parser.add_argument("--export", type=Path, help="Write a private reusable handoff bundle")
- parser.add_argument(
- "--cwd",
- type=Path,
- help="Use this existing directory instead of the source session's directory",
- )
- parser.add_argument("--create", action="store_true", help="Create the Codex task")
- parser.add_argument(
- "--target",
- choices=("codex",),
- default="codex",
- help="Destination coding agent",
- )
- parser.add_argument(
- "--command-output",
- action="store_true",
- help="Print the short result used by Mem0's user-facing command",
- )
- parser.add_argument("--codex-bin", default="codex")
- parser.add_argument(
- "--codex-home",
- type=Path,
- default=DEFAULT_CODEX_HOME,
- )
- return parser.parse_args(argv)
-
-
-def main(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> int:
- args = _parse_args(argv, default_source)
- try:
- if args.bundle:
- plan = load_bundle(args.bundle)
- elif args.source == "claude-code":
- plan = build_plan(args.session, args.claude_projects_dir)
- else:
- if not args.source:
- raise HandoffError("--source is required with --session.")
- from handoff_sources import read_source
-
- plan = read_source(args.source, Path(args.session), cwd=args.cwd, title=args.title)
- if args.title:
- plan = replace(plan, source=replace(plan.source, title=args.title))
- plan = _with_cwd(plan, args.cwd)
- output: dict[str, Any] = {"plan": _summary(plan)}
- if args.export:
- output["bundle"] = str(write_bundle(plan, args.export))
- if args.create:
- try:
- output["codex"] = create_codex_thread(
- plan,
- codex_bin=args.codex_bin,
- codex_home=args.codex_home.expanduser(),
- )
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- fallback = args.export or _default_bundle_path(plan)
- saved = write_bundle(plan, fallback)
- raise HandoffError(f"{exc} The complete handoff was saved at {saved}.") from exc
- if args.command_output:
- if not args.create:
- raise HandoffError("--command-output requires --create.")
- print(_command_output(output["codex"]))
- else:
- print(json.dumps(output, indent=2, ensure_ascii=False))
- return 0
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- print(f"handoff failed: {exc}", file=sys.stderr)
- return 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/integrations/kimi-plugin/core/handoff-runtime.json b/integrations/kimi-plugin/core/handoff-runtime.json
new file mode 100644
index 000000000..b6b935e8d
--- /dev/null
+++ b/integrations/kimi-plugin/core/handoff-runtime.json
@@ -0,0 +1,8 @@
+{
+ "revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
+ "files": {
+ "claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
+ "handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
+ },
+ "artifacts": ["session_handoff.py", "handoff-runtime.json"]
+}
diff --git a/integrations/kimi-plugin/core/handoff_sources.py b/integrations/kimi-plugin/core/handoff_sources.py
deleted file mode 100644
index 549c9969e..000000000
--- a/integrations/kimi-plugin/core/handoff_sources.py
+++ /dev/null
@@ -1,452 +0,0 @@
-"""Native transcript readers; all destinations use the shared handoff importer.
-
-Formats: openai/codex rollout payloads; MoonshotAI/kimi-code contextMemory;
-Pi's session-manager.buildSessionContext; native Cursor/Antigravity transcripts.
-Unsupported state changes fail instead of silently dropping active context.
-"""
-
-from __future__ import annotations
-
-import json
-from pathlib import Path
-from typing import Any
-
-import claude_to_codex as engine
-
-
-def _message(role: str, text: str) -> dict:
- return {"role": role, "content": [{"type": "text", "text": text}]}
-
-
-def _parts(content: Any, role: str, warnings: list[str]) -> list[dict]:
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if not isinstance(part, dict):
- raise engine.HandoffError("Native message has an invalid content block.")
- kind = part.get("type")
- if kind in {"thinking", "redacted_thinking", "think"}:
- if "Hidden reasoning was excluded." not in warnings:
- warnings.append("Hidden reasoning was excluded.")
- elif kind in {"text", "input_text", "output_text"} and isinstance(part.get("text"), str):
- parts.append({"type": "input_text" if role == "user" else "output_text", "text": part["text"]})
- elif kind == "image":
- source = part.get("source") or {
- "type": "base64",
- "data": part.get("data"),
- "media_type": part.get("mimeType"),
- }
- media_type, data = engine._image_payload(source, "Native message image")
- parts.append({"type": "input_image", "image_url": f"data:{media_type};base64,{data}"})
- elif kind in {"image_url", "input_image"}:
- url = part.get("image_url")
- if isinstance(url, dict):
- url = url.get("url")
- engine._data_url_payload(url, "Native message image")
- parts.append({"type": "input_image", "image_url": url})
- elif kind not in {"toolCall", "tool_use"}:
- raise engine.HandoffError(f"Unsupported native content block: {kind!r}.")
- return parts
-
-
-def _call_item(call: dict) -> dict:
- function = call.get("function", call)
- arguments = function.get("arguments", "{}")
- return {
- "type": "function_call",
- "call_id": call.get("id"),
- "name": function.get("name"),
- "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments),
- }
-
-
-def _messages_items(messages: list[dict], warnings: list[str]) -> list[dict]:
- items = []
- for message in messages:
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid native message.")
- role = message.get("role")
- if role in {"system", "developer"}:
- if "Source harness instructions were excluded." not in warnings:
- warnings.append("Source harness instructions were excluded.")
- continue
- if role in {"tool", "toolResult"}:
- output_parts = _parts(message.get("content"), "assistant", warnings)
- output = []
- for part in output_parts:
- if part["type"] == "input_image":
- media_type, data = engine._data_url_payload(part["image_url"], "Tool result image")
- output.append(
- {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}}
- )
- else:
- output.append({"type": "text", "text": part["text"]})
- if message.get("isError"):
- output.insert(0, {"type": "text", "text": "Tool failed."})
- if message.get("note"):
- output.append({"type": "text", "text": str(message["note"])})
- item = {
- "type": "function_call_output",
- "call_id": message.get("toolCallId") or message.get("tool_call_id"),
- "output": output,
- }
- if message.get("toolName") or message.get("name"):
- item["name"] = message.get("toolName") or message["name"]
- items.append(item)
- continue
- if role not in {"user", "assistant"}:
- raise engine.HandoffError(f"Unsupported native message role: {role!r}.")
- if message.get("partial") or message.get("stopReason") in {"error", "aborted"}:
- raise engine.HandoffError("Native assistant response is incomplete; finish the source turn first.")
- content = message.get("content", [])
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if isinstance(part, dict) and part.get("type") in {"toolCall", "tool_use"}:
- if role != "assistant":
- raise engine.HandoffError("Native user message contains an assistant tool call.")
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- parts = []
- items.append(
- _call_item(
- {
- "id": part.get("id"),
- "name": part.get("name"),
- "arguments": json.dumps(part.get("arguments", part.get("input", {}))),
- }
- )
- )
- else:
- parts.extend(_parts([part], role, warnings))
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- for call in message.get("toolCalls") or message.get("tool_calls") or []:
- items.append(_call_item(call))
- return items
-
-
-def _codex(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Native Responses items are the authoritative history, event_msg is UI data.
- items, source = [], {}
- for record in records:
- kind, payload = record.get("type"), record.get("payload")
- if not isinstance(payload, dict):
- raise engine.HandoffError("Invalid Codex rollout payload.")
- if kind == "session_meta":
- source.update(session_id=payload.get("id"), cwd=payload.get("cwd"))
- elif kind == "compacted":
- replacement = payload.get("replacement_history")
- if not isinstance(replacement, list) or not replacement:
- raise engine.HandoffError(
- "Codex compaction is opaque; a complete plaintext replacement history is required."
- )
- items = list(replacement)
- elif kind == "response_item":
- items.append(payload)
- elif kind == "event_msg":
- if payload.get("type") == "thread_rolled_back":
- raise engine.HandoffError("Codex rollback requires a native active-context export.")
- elif kind != "turn_context":
- raise engine.HandoffError(f"Unsupported Codex rollout record: {kind!r}.")
- result = []
- for item in items:
- kind = item.get("type")
- if kind == "reasoning":
- warnings.append("Hidden reasoning was excluded.")
- elif kind == "message" and item.get("role") in {"system", "developer"}:
- warnings.append("Source harness instructions were excluded.")
- elif kind == "custom_tool_call":
- result.append(
- {
- "type": "function_call",
- "call_id": item.get("call_id"),
- "name": item.get("name"),
- "arguments": json.dumps({"input": item.get("input")}),
- }
- )
- elif kind == "custom_tool_call_output":
- result.append({**item, "type": "function_call_output"})
- elif kind == "compaction":
- raise engine.HandoffError(
- "Codex compaction contains opaque model state; it cannot be transferred losslessly."
- )
- else:
- result.append(dict(item))
- return result, source
-
-
-def _cursor(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Cursor's persisted transcript uses role + message.content, without Claude's parent chain.
- converted = []
- source = {}
- for index, record in enumerate(records):
- role = record.get("role") or record.get("type")
- if role not in {"user", "assistant"} or not isinstance(record.get("message"), dict):
- raise engine.HandoffError("Unsupported Cursor transcript record; provide a complete native JSONL export.")
- converted.append({**record, "type": role, "uuid": str(index)})
- if record.get("session_id"):
- source["session_id"] = record["session_id"]
- if record.get("cwd"):
- source["cwd"] = record["cwd"]
- items, skipped = engine._responses_items(converted)
- if skipped:
- warnings.append("Hidden reasoning was excluded.")
- return items, source
-
-
-def _antigravity(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- messages = []
- for step in records:
- if step.get("status") != "DONE":
- raise engine.HandoffError("Antigravity has an unfinished transcript step; finish the source turn first.")
- kind, content = step.get("type"), step.get("content")
- if not isinstance(content, str):
- raise engine.HandoffError("Antigravity transcript content is not transferable text.")
- if kind == "USER_INPUT":
- messages.append(_message("user", content))
- elif kind == "PLANNER_RESPONSE" and step.get("source") == "MODEL":
- messages.append(_message("assistant", content))
- else:
- raise engine.HandoffError(
- f"Unsupported Antigravity step {kind!r}; its visible conversation semantics are not verified."
- )
- return _messages_items(messages, warnings), {}
-
-
-def _kimi_compact(messages: list[dict], record: dict) -> list[dict]:
- summary = record.get("contextSummary", record.get("summary"))
- if isinstance(summary, dict):
- summary_message = summary
- elif isinstance(summary, str):
- summary_message = {**_message("user", summary), "origin": {"kind": "compaction_summary"}}
- else:
- raise engine.HandoffError("Kimi compaction has no transferable summary.")
- if record.get("legacyTail") or "keptUserMessageCount" not in record:
- count = record.get("compactedCount", record.get("count"))
- if not isinstance(count, int) or not 0 <= count <= len(messages):
- raise engine.HandoffError("Invalid Kimi compaction boundary.")
- return [summary_message, *messages[count:]]
- users = []
- for message in messages:
- origin = message.get("origin") or {}
- if message.get("role") == "user" and (
- origin.get("kind") in {None, "user"}
- or (origin.get("kind") in {"skill_activation", "plugin_command"} and origin.get("trigger") == "user-slash")
- ):
- users.append(message)
- # Kimi trims user inputs above this native budget. Do not approximate that destructive rewrite.
- tokens = 0
- for message in users:
- if message.get("toolCalls"):
- raise engine.HandoffError("Unsupported Kimi compaction user tool calls.")
- tokens += 1 # estimateTokens('user')
- for part in message.get("content", []):
- if part.get("type") not in {"text", "think"}:
- tokens += 2000
- else:
- text = part.get("text", part.get("think", ""))
- ascii_count = sum(ord(char) <= 127 for char in text)
- tokens += (ascii_count + 3) // 4 + len(text) - ascii_count
- if tokens > 20000 or record.get("keptHeadUserMessageCount"):
- raise engine.HandoffError("Kimi compaction elided user content; use a native active-context bundle export.")
- continuation = _message(
- "user",
- "\nContext compaction is complete — continue the work that was in progress when it began.\n",
- )
- return [*users, summary_message, continuation]
-
-
-def _kimi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Mirrors Kimi v2 context.append_message and completed loop events, not UI stream fragments.
- messages, source = [], {}
- opened, step_id = None, None
- for record in records:
- if record.get("agentId") not in {None, "main"}:
- continue
- kind = record.get("type", "")
- if kind in {"profile.bind", "config.update"}:
- cwd = (record.get("environmentDisclosure") or {}).get("cwd") or record.get("cwd")
- if cwd:
- source["cwd"] = cwd
- elif kind == "context.append_message":
- if opened is not None:
- raise engine.HandoffError("Kimi interleaved messages require a completed native context export.")
- messages.append(record.get("message"))
- elif kind == "context.append_loop_event":
- event = record.get("event") or {}
- event_type = event.get("type")
- if event_type == "step.begin":
- if opened is not None:
- raise engine.HandoffError("Kimi previous response did not complete.")
- step_id = event.get("uuid")
- opened = {"role": "assistant", "content": [], "toolCalls": []}
- messages.append(opened)
- elif event_type == "step.end":
- if event.get("uuid") != step_id or event.get("finishReason") in {"error", "interrupted"}:
- raise engine.HandoffError("Kimi response is incomplete or interrupted.")
- opened, step_id = None, None
- elif event_type in {"content.part", "tool.call"}:
- if opened is None or event.get("stepUuid") != step_id:
- raise engine.HandoffError("Kimi content has no matching active response.")
- if event_type == "content.part":
- opened["content"].append(event.get("part"))
- else:
- opened["toolCalls"].append(
- {
- "id": event.get("toolCallId"),
- "name": event.get("name"),
- "arguments": json.dumps(event.get("args", {})),
- }
- )
- elif event_type == "tool.result":
- result = event.get("result") or {}
- messages.append(
- {
- "role": "tool",
- "toolCallId": event.get("toolCallId"),
- "content": result.get("output"),
- "isError": result.get("isError"),
- "note": result.get("note"),
- }
- )
- else:
- raise engine.HandoffError(f"Unsupported Kimi loop event: {event_type!r}.")
- elif kind == "context.clear":
- messages, opened, step_id = [], None, None
- elif kind == "context.apply_compaction":
- if opened is not None:
- raise engine.HandoffError("Kimi compaction began during an unfinished response.")
- messages = _kimi_compact(messages, record)
- elif kind in {"context.undo", "micro_compaction.apply", "context.spliced"}:
- raise engine.HandoffError(f"Kimi {kind} needs a native active-context export to preserve its state.")
- elif kind.startswith("context.") and kind != "context.update_token_count":
- raise engine.HandoffError(f"Unsupported Kimi context event: {kind!r}.")
- # Remaining durable events configure Kimi's harness; they are not model messages.
- if opened is not None:
- raise engine.HandoffError("Kimi response is still streaming.")
- return _messages_items(messages, warnings), source
-
-
-def _pi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- header = records[0]
- if header.get("type") != "session":
- raise engine.HandoffError("Pi/OpenClaw transcript has no session header.")
- entries = [record for record in records[1:] if isinstance(record.get("id"), str)]
- if len(entries) != len(records) - 1:
- raise engine.HandoffError("Pi/OpenClaw transcript entry has no ID.")
- index = {entry["id"]: entry for entry in entries}
- if len(index) != len(entries):
- raise engine.HandoffError("Pi/OpenClaw transcript has duplicate entry IDs.")
- chain, seen = [], set()
- current = entries[-1] if entries else None
- while current:
- if current["id"] in seen:
- raise engine.HandoffError("Pi/OpenClaw transcript has a parent cycle.")
- seen.add(current["id"])
- chain.append(current)
- parent = current.get("parentId")
- if parent is not None and parent not in index:
- raise engine.HandoffError("Pi/OpenClaw transcript has a missing parent.")
- current = index.get(parent)
- chain.reverse()
- title = next((entry.get("name") for entry in reversed(chain) if entry.get("type") == "session_info"), None)
- messages = []
- boundary = next((i for i in range(len(chain) - 1, -1, -1) if chain[i].get("type") == "compaction"), None)
- if boundary is not None:
- compact = chain[boundary]
- if not isinstance(compact.get("summary"), str):
- raise engine.HandoffError("Pi/OpenClaw compaction has no summary.")
- messages.append(
- _message(
- "user",
- f"The conversation history before this point was compacted into the following summary:\n\n\n{compact['summary']}\n",
- )
- )
- kept = next((i for i in range(boundary) if chain[i]["id"] == compact.get("firstKeptEntryId")), boundary)
- chain = chain[kept:boundary] + chain[boundary + 1 :]
- for entry in chain:
- kind = entry.get("type")
- if kind == "message":
- message = entry.get("message")
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid Pi/OpenClaw message.")
- if message.get("role") == "bashExecution":
- if message.get("excludeFromContext"):
- continue
- if message.get("truncated"):
- raise engine.HandoffError("Pi/OpenClaw shell output is truncated; provide a complete bundle.")
- text = f"Ran `{message.get('command', '')}`\n"
- text += f"```\n{message['output']}\n```" if message.get("output") else "(no output)"
- if message.get("cancelled"):
- text += "\n\n(command cancelled)"
- elif message.get("exitCode") not in {None, 0}:
- text += f"\n\nCommand exited with code {message['exitCode']}"
- message = _message("user", text)
- messages.append(message)
- elif kind == "branch_summary":
- messages.append(
- _message(
- "user",
- f"The following is a summary of a branch that this conversation came back from:\n\n\n{entry['summary']}",
- )
- )
- elif kind == "custom_message":
- messages.append({"role": "user", "content": entry.get("content")})
- elif kind == "session_info":
- title = entry.get("name")
- elif kind not in {"model_change", "thinking_level_change", "custom", "label"}:
- raise engine.HandoffError(f"Unsupported Pi/OpenClaw entry: {kind!r}.")
- return _messages_items(messages, warnings), {
- "session_id": header.get("id"),
- "cwd": header.get("cwd"),
- "title": title,
- }
-
-
-def read_source(host: str, path: Path, *, cwd: Path | None = None, title: str | None = None) -> engine.HandoffPlan:
- path = path.expanduser().resolve()
- records, digest = engine._stable_jsonl(path)
- warnings: list[str] = []
- readers = {
- "cursor": _cursor,
- "codex": _codex,
- "kimi": _kimi,
- "antigravity": _antigravity,
- "openclaw": _pi,
- "pi-agent": _pi,
- }
- try:
- items, metadata = readers[host](records, warnings)
- except (TypeError, AttributeError, KeyError, ValueError) as exc:
- raise engine.HandoffError(f"Invalid {host} native transcript structure: {exc}") from exc
- if host == "kimi" and path.name == "wire.jsonl" and path.parent.name == "main":
- metadata.setdefault("session_id", path.parents[2].name)
- state = path.parents[2] / "state.json"
- if state.is_file():
- try:
- metadata.setdefault("title", json.loads(state.read_text()).get("title"))
- except (json.JSONDecodeError, AttributeError):
- pass
- if host == "antigravity" and path.name == "transcript.jsonl" and path.parent.name == "logs":
- metadata.setdefault("session_id", path.parents[2].name)
- source_cwd = str(cwd.expanduser().resolve()) if cwd else metadata.get("cwd")
- if not source_cwd:
- raise engine.HandoffError(f"{host} transcript has no working directory; provide --cwd.")
- source = {
- "host": host,
- "path": str(path),
- "sha256": digest,
- "session_id": metadata.get("session_id") or path.stem,
- "cwd": source_cwd,
- "title": title or metadata.get("title") or f"{host} session {path.stem[:12]}",
- }
- return engine.plan_from_bundle(
- {"format": engine.FORMAT_VERSION, "source": source, "items": items, "warnings": list(dict.fromkeys(warnings))}
- )
diff --git a/integrations/kimi-plugin/core/memory_core.py b/integrations/kimi-plugin/core/memory_core.py
index 1e99f99e7..93a3fde15 100644
--- a/integrations/kimi-plugin/core/memory_core.py
+++ b/integrations/kimi-plugin/core/memory_core.py
@@ -29,7 +29,7 @@ from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
-PLUGIN_VERSION = "0.4.0"
+PLUGIN_VERSION = "0.3.2"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
diff --git a/integrations/kimi-plugin/core/session_handoff.py b/integrations/kimi-plugin/core/session_handoff.py
index 4aada69e6..b7108f336 100644
--- a/integrations/kimi-plugin/core/session_handoff.py
+++ b/integrations/kimi-plugin/core/session_handoff.py
@@ -1,7 +1,81 @@
#!/usr/bin/env python3
-"""Import an explicit native session or neutral handoff bundle into Codex."""
+"""Run the shared handoff engine locally or from its verified immutable cache."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import sys
+import tempfile
+from pathlib import Path
+from urllib.request import urlopen
+
+ENGINE_FILES = {"claude_to_codex.py", "handoff_sources.py"}
+SOURCE_URL = "https://raw.githubusercontent.com/mem0ai/mem0"
+
+
+def _verified(path: Path, digest: str) -> bool:
+ try:
+ return hashlib.sha256(path.read_bytes()).hexdigest() == digest
+ except FileNotFoundError:
+ return False
+
+
+def runtime_root(launcher_dir: Path | None = None) -> Path:
+ here = launcher_dir or Path(__file__).resolve().parent
+ if all((here / name).is_file() for name in ENGINE_FILES):
+ return here # The canonical development checkout already has both engines.
+ manifest = json.loads((here / "handoff-runtime.json").read_text(encoding="utf-8"))
+ if not isinstance(manifest, dict):
+ raise ValueError("invalid handoff runtime manifest")
+ revision, files = manifest.get("revision"), manifest.get("files")
+ if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
+ raise ValueError("handoff runtime revision must be an immutable commit SHA")
+ if (
+ not isinstance(files, dict)
+ or set(files) != ENGINE_FILES
+ or any(not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) for digest in files.values())
+ ):
+ raise ValueError("invalid handoff runtime file hashes")
+ cache = Path.home() / ".mem0" / "handoff-runtime" / revision
+ missing = {name: digest for name, digest in files.items() if not _verified(cache / name, digest)}
+ if not missing:
+ return cache
+ cache.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with tempfile.TemporaryDirectory(prefix=f".{revision}-", dir=cache.parent) as temporary:
+ staged = Path(temporary)
+ for name, digest in missing.items():
+ url = f"{SOURCE_URL}/{revision}/integrations/agent-plugin-core/python/{name}"
+ try:
+ with urlopen(url, timeout=30) as response:
+ body = response.read()
+ except OSError as exc:
+ raise OSError(f"could not download pinned handoff runtime {name}: {exc}") from exc
+ if hashlib.sha256(body).hexdigest() != digest:
+ raise ValueError(f"SHA256 mismatch for pinned handoff runtime {name}; refusing to execute it")
+ (staged / name).write_bytes(body)
+ # All downloads are verified before publishing; each replacement is atomic.
+ cache.mkdir(exist_ok=True, mode=0o700)
+ for name in missing:
+ os.replace(staged / name, cache / name)
+ if not all(_verified(cache / name, digest) for name, digest in files.items()):
+ raise ValueError("handoff runtime cache changed during installation; refusing to execute it")
+ return cache
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ root = runtime_root()
+ except (OSError, ValueError) as exc:
+ print(f"handoff runtime unavailable: {exc}", file=sys.stderr)
+ return 1
+ sys.path.insert(0, str(root))
+ from claude_to_codex import main as engine_main
+
+ return engine_main(argv, default_source=None)
-from claude_to_codex import main
if __name__ == "__main__":
- raise SystemExit(main(default_source=None))
+ raise SystemExit(main())
diff --git a/integrations/kimi-plugin/kimi.plugin.json b/integrations/kimi-plugin/kimi.plugin.json
index d09c05c2c..b78f273fd 100644
--- a/integrations/kimi-plugin/kimi.plugin.json
+++ b/integrations/kimi-plugin/kimi.plugin.json
@@ -1,6 +1,6 @@
{
"name": "mem0",
- "version": "0.4.0",
+ "version": "0.3.2",
"description": "Cross-session memory and token savings for coding agents.",
"keywords": ["memory", "coding-agents", "continual-learning", "token-efficiency"],
"author": { "name": "Mem0", "email": "support@mem0.ai" },
diff --git a/integrations/kimi-plugin/plugin-build.json b/integrations/kimi-plugin/plugin-build.json
index 2cda91510..e497d5bcb 100644
--- a/integrations/kimi-plugin/plugin-build.json
+++ b/integrations/kimi-plugin/plugin-build.json
@@ -1,6 +1,6 @@
{
"id": "mem0",
- "version": "0.4.0",
+ "version": "0.3.2",
"homepage": "https://docs.mem0.ai/integrations/kimi",
"native": {
"pluginRoot": "${KIMI_PLUGIN_ROOT}",
diff --git a/integrations/kimi-plugin/skills/handoff/SKILL.md b/integrations/kimi-plugin/skills/handoff/SKILL.md
index 69340776f..184920c4f 100644
--- a/integrations/kimi-plugin/skills/handoff/SKILL.md
+++ b/integrations/kimi-plugin/skills/handoff/SKILL.md
@@ -9,7 +9,9 @@ allowed-tools: Bash(python3 ${KIMI_PLUGIN_ROOT}/core/session_handoff.py *)
All hosts share one local import engine. Native readers and SDK adapters supply
complete conversation items; Mem0 memory capture is not a transcript source.
-Requires Python 3.11+ and a Codex CLI with native session import support. The
+Requires Python 3.11+ and a Codex CLI with native session import support.
+First use downloads a pinned, hash-verified runtime from GitHub; later uses share
+the verified local cache. No transcript is sent to GitHub. The
supported destination is Codex. This does not transfer files or change branches.
Visible conversation, tool history, and supported source compaction summaries
diff --git a/integrations/mem0-agent-plugin/CHANGELOG.md b/integrations/mem0-agent-plugin/CHANGELOG.md
new file mode 100644
index 000000000..25ad94f5e
--- /dev/null
+++ b/integrations/mem0-agent-plugin/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+## 0.3.2
+
+- The portable `handoff` skill accepts an explicit supported source host and transcript, or the common handoff bundle. Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
+- Lightened search prompts: search when prior work may help; repeat only for a specific gap.
diff --git a/integrations/mem0-agent-plugin/core/claude_to_codex.py b/integrations/mem0-agent-plugin/core/claude_to_codex.py
deleted file mode 100644
index fd372e198..000000000
--- a/integrations/mem0-agent-plugin/core/claude_to_codex.py
+++ /dev/null
@@ -1,1492 +0,0 @@
-#!/usr/bin/env python3
-"""Shared local session-handoff engine and backwards-compatible Claude CLI.
-
-Native readers and SDK adapters supply complete conversation items. This engine
-validates and exports their bundles, then uses Codex's native external-session
-importer to create a task with visible historical turns. The legacy command
-still defaults to reading a Claude Code transcript; session_handoff.py requires
-an explicit source host or a neutral bundle.
-
-No model generates a handoff summary. Large imports may use Codex's native
-compaction before the new task is returned.
-"""
-
-# Adapted from mem0ai/memo at aeeb1593284d1d2fca3b4bcf1e32ea10f71df549 (Apache-2.0).
-from __future__ import annotations
-
-import argparse
-import base64
-import binascii
-import hashlib
-import html
-import json
-import os
-import queue
-import re
-import shutil
-import subprocess
-import sys
-import tempfile
-import threading
-import time
-from dataclasses import asdict, dataclass, replace
-from pathlib import Path
-from typing import Any, Iterable
-
-FORMAT_VERSION = "mem0.session-handoff.v1"
-DEFAULT_CODEX_HOME = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
-DEFAULT_BUNDLE_DIR = Path.home() / ".mem0" / "handoffs"
-IMPORT_COMPLETED_NOTIFICATION = "externalAgentConfig/import/completed"
-IMAGE_EXTENSIONS = {
- "image/gif": "gif",
- "image/jpeg": "jpg",
- "image/png": "png",
- "image/webp": "webp",
-}
-
-
-class HandoffError(RuntimeError):
- """A source session cannot be transferred without losing state."""
-
-
-@dataclass(frozen=True)
-class SourceInfo:
- path: str
- sha256: str
- session_id: str
- title: str
- cwd: str
- leaf_uuid: str
- compact_boundary_uuid: str | None
- first_imported_uuid: str
- last_imported_uuid: str
- codex_cwd: str | None = None
- host: str = "claude-code"
-
-
-@dataclass
-class HandoffPlan:
- source: SourceInfo
- items: list[dict[str, Any]]
- source_records: int
- active_records: int
- imported_records: int
- hidden_reasoning_blocks_skipped: int
- approximate_tokens: int
- warnings: list[str]
-
- def bundle(self) -> dict[str, Any]:
- return {
- "format": FORMAT_VERSION,
- "source": asdict(self.source),
- "items": self.items,
- "counts": {
- "source_records": self.source_records,
- "active_records": self.active_records,
- "imported_records": self.imported_records,
- "responses_items": len(self.items),
- "hidden_reasoning_blocks_skipped": self.hidden_reasoning_blocks_skipped,
- "approximate_tokens": self.approximate_tokens,
- },
- "warnings": self.warnings,
- }
-
-
-@dataclass(frozen=True)
-class CodexContextLimits:
- model: str
- context_window: int
- usable_context_window: int
- auto_compact_token_limit: int
- max_context_window: int
- max_usable_context_window: int
- max_auto_compact_token_limit: int
-
-
-def _stable_jsonl(path: Path) -> tuple[list[dict[str, Any]], str]:
- before = path.stat()
- raw = path.read_bytes()
- after = path.stat()
- if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
- raise HandoffError(f"Source session changed while it was being read: {path}")
- if raw and not raw.endswith(b"\n"):
- raise HandoffError(
- "The final JSONL record is incomplete. Finish or stop the active source response before transferring it."
- )
-
- records: list[dict[str, Any]] = []
- for line_number, line in enumerate(raw.splitlines(), 1):
- if not line.strip():
- continue
- try:
- record = json.loads(line)
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid source JSONL at {path}:{line_number}: {exc}") from exc
- if not isinstance(record, dict):
- raise HandoffError(f"Source JSONL record is not an object at {path}:{line_number}.")
- records.append(record)
- if not records:
- raise HandoffError(f"Source session is empty: {path}")
- return records, hashlib.sha256(raw).hexdigest()
-
-
-def _resolve_session(value: str, projects_dir: Path) -> Path:
- supplied = Path(value).expanduser()
- if supplied.is_file():
- return supplied.resolve()
-
- matches = list(projects_dir.glob(f"*/{value}.jsonl"))
- if not matches:
- raise HandoffError(
- f"No Claude session named {value!r} exists below {projects_dir}. "
- "Pass the session ID or its full JSONL path."
- )
- if len(matches) != 1:
- joined = "\n".join(f" {path}" for path in matches)
- raise HandoffError(f"Session ID {value!r} is ambiguous:\n{joined}")
- return matches[0].resolve()
-
-
-def _active_chain(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
- with_uuid = [
- record for record in records if isinstance(record.get("uuid"), str) and record.get("isSidechain") is not True
- ]
- if not with_uuid:
- raise HandoffError("Claude session has no main-agent conversation records.")
-
- by_uuid = {record["uuid"]: record for record in with_uuid}
- leaf = with_uuid[-1]
- chain: list[dict[str, Any]] = []
- seen: set[str] = set()
- current: dict[str, Any] | None = leaf
- while current is not None:
- uuid = current["uuid"]
- if uuid in seen:
- raise HandoffError(f"Claude session contains a parent cycle at {uuid}.")
- seen.add(uuid)
- chain.append(current)
- parent_uuid = current.get("parentUuid")
- if parent_uuid is None:
- break
- current = by_uuid.get(parent_uuid)
- if current is None:
- raise HandoffError(f"Claude's active branch references missing parent {parent_uuid}.")
- chain.reverse()
- return chain
-
-
-def _after_latest_compaction(
- chain: list[dict[str, Any]],
-) -> tuple[list[dict[str, Any]], str | None]:
- compact_index: int | None = None
- for index, record in enumerate(chain):
- if record.get("type") == "system" and record.get("subtype") == "compact_boundary":
- compact_index = index
- if compact_index is None:
- imported = chain
- compact_uuid = None
- else:
- imported = chain[compact_index + 1 :]
- compact_uuid = chain[compact_index]["uuid"]
- if not imported or imported[0].get("isCompactSummary") is not True:
- raise HandoffError(f"Claude compaction {compact_uuid} has no following compact summary.")
- imported = [record for record in imported if record.get("type") != "system"]
- if not imported:
- raise HandoffError("Claude's active state contains no transferable records.")
- return imported, compact_uuid
-
-
-def _tool_result_ids(record: dict[str, Any]) -> set[str]:
- if record.get("type") != "user":
- return set()
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- return set()
- return {
- str(block["tool_use_id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("tool_use_id")
- }
-
-
-def _tool_call_ids(records: list[dict[str, Any]]) -> set[str]:
- call_ids: set[str] = set()
- for record in records:
- if record.get("type") != "assistant":
- continue
- content = (record.get("message") or {}).get("content")
- if not isinstance(content, list):
- continue
- call_ids.update(
- str(block["id"])
- for block in content
- if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id")
- )
- return call_ids
-
-
-def _merge_parallel_tool_results(
- active_records: list[dict[str, Any]], all_records: list[dict[str, Any]]
-) -> list[dict[str, Any]]:
- """Restore sibling tool results that Claude stores outside the parent chain.
-
- Parallel Claude tool calls form a fork: later calls remain on the parent
- chain, while earlier results can be sibling records. Claude sends all of
- those results back to the model. Insert them together immediately after the
- assistant response that issued the calls.
- """
- results_by_call: dict[str, list[tuple[int, dict[str, Any]]]] = {}
- for source_index, record in enumerate(all_records):
- for call_id in _tool_result_ids(record):
- results_by_call.setdefault(call_id, []).append((source_index, record))
-
- merged: list[dict[str, Any]] = []
- inserted_result_uuids: set[str] = set()
- index = 0
- while index < len(active_records):
- record = active_records[index]
- record_uuid = str(record.get("uuid") or "")
- if record_uuid in inserted_result_uuids:
- index += 1
- continue
- if record.get("type") != "assistant":
- merged.append(record)
- index += 1
- continue
-
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(active_records):
- candidate = active_records[index]
- candidate_id = (candidate.get("message") or {}).get("id")
- if candidate.get("type") != "assistant" or not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- merged.extend(group)
-
- matching_results: list[tuple[int, dict[str, Any]]] = []
- for call_id in _tool_call_ids(group):
- matching_results.extend(results_by_call.get(call_id, []))
- for _, result in sorted(matching_results, key=lambda pair: pair[0]):
- result_uuid = str(result.get("uuid") or "")
- if result_uuid and result_uuid not in inserted_result_uuids:
- merged.append(result)
- inserted_result_uuids.add(result_uuid)
- return merged
-
-
-def _image_payload(source: Any, context: str) -> tuple[str, str]:
- if not isinstance(source, dict) or source.get("type") != "base64":
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = str(source.get("media_type") or "").lower()
- data = source.get("data")
- if media_type not in IMAGE_EXTENSIONS or not isinstance(data, str) or not data:
- raise HandoffError(f"{context} has an unsupported or missing image type.")
- return media_type, data
-
-
-def _data_url_payload(image_url: Any, context: str) -> tuple[str, str]:
- if not isinstance(image_url, str):
- raise HandoffError(f"{context} has no transferable image data.")
- match = re.fullmatch(r"data:([^;,]+);base64,(.+)", image_url, flags=re.DOTALL)
- if not match:
- raise HandoffError(f"{context} is not stored as transferable base64 data.")
- media_type = match.group(1).lower()
- if media_type not in IMAGE_EXTENSIONS:
- raise HandoffError(f"{context} has unsupported image type {media_type!r}.")
- return media_type, match.group(2)
-
-
-def _save_image(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> Path:
- try:
- payload = base64.b64decode(encoded, validate=True)
- except (binascii.Error, ValueError) as exc:
- raise HandoffError(f"{context} contains invalid base64 image data.") from exc
- if not payload:
- raise HandoffError(f"{context} contains an empty image.")
-
- digest = hashlib.sha256(payload).hexdigest()
- asset_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
- path = asset_dir / f"{digest}.{IMAGE_EXTENSIONS[media_type]}"
- if path.exists():
- if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
- raise HandoffError(f"The existing handoff image is corrupted: {path}")
- return path
-
- descriptor, filename = tempfile.mkstemp(prefix=f".{path.name}.", dir=asset_dir)
- temporary = Path(filename)
- try:
- with os.fdopen(descriptor, "wb") as stream:
- stream.write(payload)
- os.replace(temporary, path)
- except OSError as exc:
- temporary.unlink(missing_ok=True)
- raise HandoffError(f"Could not save the handoff image at {path}: {exc}") from exc
- return path
-
-
-def _image_reference(
- media_type: str,
- encoded: str,
- asset_dir: Path,
- context: str,
-) -> str:
- path = _save_image(media_type, encoded, asset_dir, context)
- return f"[Image saved at {path}]"
-
-
-def _tool_result_text(value: Any, asset_dir: Path, context: str) -> str:
- if value is None:
- return ""
- if isinstance(value, str):
- return value
- if isinstance(value, (int, float, bool)):
- return str(value)
- if isinstance(value, list):
- parts: list[str] = []
- for part in value:
- if isinstance(part, dict) and part.get("type") == "text":
- parts.append(str(part.get("text", "")))
- elif isinstance(part, dict) and part.get("type") == "image":
- media_type, encoded = _image_payload(part.get("source"), context)
- parts.append(_image_reference(media_type, encoded, asset_dir, context))
- else:
- parts.append(json.dumps(part, ensure_ascii=False, separators=(",", ":")))
- return "\n".join(part for part in parts if part)
- if isinstance(value, dict) and value.get("type") == "image":
- media_type, encoded = _image_payload(value.get("source"), context)
- return _image_reference(media_type, encoded, asset_dir, context)
- return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
-
-
-def _message(role: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
- return {"type": "message", "role": role, "content": parts}
-
-
-def _attachment_item(record: dict[str, Any]) -> dict[str, Any] | None:
- attachment = record.get("attachment")
- if not isinstance(attachment, dict):
- raise HandoffError(f"Claude attachment {record.get('uuid')} has no payload.")
-
- attachment_type = attachment.get("type")
- filename = str(attachment.get("filename") or attachment.get("displayPath") or "unknown")
- content = attachment.get("content")
- if attachment_type == "file" and isinstance(content, dict):
- file_payload = content.get("file") if content.get("type") == "text" else None
- if isinstance(file_payload, dict) and isinstance(file_payload.get("content"), str):
- text = file_payload["content"]
- display = str(file_payload.get("filePath") or filename)
- wrapped = f'\n{text}\n'
- return _message("user", [{"type": "input_text", "text": wrapped}])
-
- if attachment_type == "image" and isinstance(content, dict):
- image_url = content.get("image_url") or content.get("data")
- if isinstance(image_url, str) and image_url.startswith("data:"):
- return _message("user", [{"type": "input_image", "image_url": image_url}])
-
- if attachment_type in {"file", "image"}:
- raise HandoffError(f"Claude {attachment_type} attachment {record.get('uuid')} has an unsupported payload.")
-
- # Claude also records its own skill list, tool availability, permissions,
- # token reminders, hooks, and task status as attachments. Those configure
- # Claude's harness; they are not part of the user's project conversation and
- # must not become user messages in Codex.
- return None
-
-
-def _assistant_items(records: list[dict[str, Any]], calls: dict[str, str]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- skipped_reasoning = 0
- text_parts: list[dict[str, Any]] = []
-
- def flush_text() -> None:
- if text_parts:
- items.append(_message("assistant", list(text_parts)))
- text_parts.clear()
-
- for record in records:
- content = (record.get("message") or {}).get("content", [])
- if isinstance(content, str):
- text_parts.append({"type": "output_text", "text": content})
- continue
- if not isinstance(content, list):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid content.")
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "thinking" or kind == "redacted_thinking":
- skipped_reasoning += 1
- continue
- if kind == "text":
- text_parts.append({"type": "output_text", "text": str(block.get("text", ""))})
- continue
- if kind == "tool_use":
- flush_text()
- call_id = str(block.get("id") or "")
- name = str(block.get("name") or "")
- if not call_id or not name:
- raise HandoffError(f"Claude tool call in {record.get('uuid')} has no ID or name.")
- if call_id in calls:
- raise HandoffError(f"Claude tool call ID is duplicated: {call_id}")
- calls[call_id] = name
- items.append(
- {
- "type": "function_call",
- "call_id": call_id,
- "name": name,
- "arguments": json.dumps(
- block.get("input", {}),
- ensure_ascii=False,
- separators=(",", ":"),
- ),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude assistant block {kind!r} in {record.get('uuid')}.")
- flush_text()
- return items, skipped_reasoning
-
-
-def _user_items(record: dict[str, Any], calls: dict[str, str], completed_calls: set[str]) -> list[dict[str, Any]]:
- if record.get("isMeta") is True:
- return []
- content = (record.get("message") or {}).get("content")
- if isinstance(content, str):
- return [_message("user", [{"type": "input_text", "text": content}])]
- if not isinstance(content, list):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid content.")
-
- items: list[dict[str, Any]] = []
- user_parts: list[dict[str, Any]] = []
-
- def flush_user() -> None:
- if user_parts:
- items.append(_message("user", list(user_parts)))
- user_parts.clear()
-
- for block in content:
- if not isinstance(block, dict):
- raise HandoffError(f"Claude user record {record.get('uuid')} has invalid block.")
- kind = block.get("type")
- if kind == "text":
- user_parts.append({"type": "input_text", "text": str(block.get("text", ""))})
- continue
- if kind == "image":
- source = block.get("source") or {}
- if source.get("type") == "base64" and source.get("data") and source.get("media_type"):
- user_parts.append(
- {
- "type": "input_image",
- "image_url": f"data:{source['media_type']};base64,{source['data']}",
- }
- )
- continue
- raise HandoffError(f"Claude image in {record.get('uuid')} is not stored as transferable base64 data.")
- if kind == "tool_result":
- flush_user()
- call_id = str(block.get("tool_use_id") or "")
- if not call_id:
- raise HandoffError(f"Claude tool result in {record.get('uuid')} has no call ID.")
- if call_id not in calls:
- raise HandoffError(f"Claude tool result {call_id} has no matching call in the active state.")
- if call_id in completed_calls:
- raise HandoffError(f"Claude tool result is duplicated: {call_id}")
- completed_calls.add(call_id)
- items.append(
- {
- "type": "function_call_output",
- "call_id": call_id,
- "name": calls[call_id],
- "output": block.get("content"),
- }
- )
- continue
- raise HandoffError(f"Unsupported Claude user block {kind!r} in {record.get('uuid')}.")
- flush_user()
- return items
-
-
-def _responses_items(records: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
- items: list[dict[str, Any]] = []
- calls: dict[str, str] = {}
- completed_calls: set[str] = set()
- skipped_reasoning = 0
-
- index = 0
- while index < len(records):
- record = records[index]
- record_type = record.get("type")
- if record_type == "assistant":
- message_id = (record.get("message") or {}).get("id")
- group = [record]
- index += 1
- while index < len(records):
- candidate = records[index]
- if candidate.get("type") != "assistant":
- break
- candidate_id = (candidate.get("message") or {}).get("id")
- if not message_id or candidate_id != message_id:
- break
- group.append(candidate)
- index += 1
- assistant_items, skipped = _assistant_items(group, calls)
- items.extend(assistant_items)
- skipped_reasoning += skipped
- continue
- if record_type == "user":
- items.extend(_user_items(record, calls, completed_calls))
- elif record_type == "attachment":
- attachment_item = _attachment_item(record)
- if attachment_item is not None:
- items.append(attachment_item)
- elif record_type not in {"system"}:
- raise HandoffError(f"Unsupported model-visible Claude record {record_type!r} at {record.get('uuid')}.")
- index += 1
-
- unfinished = sorted(set(calls) - completed_calls)
- if unfinished:
- joined = ", ".join(unfinished[:5])
- raise HandoffError(
- f"Claude's active state ends with unfinished tool call(s): {joined}. "
- "Finish or stop the Claude turn before transferring it."
- )
- if not items:
- raise HandoffError("Claude's active state produced no Codex history items.")
- return items, skipped_reasoning
-
-
-def _without_image_payloads(value: Any) -> Any:
- if isinstance(value, list):
- return [_without_image_payloads(item) for item in value]
- if not isinstance(value, dict):
- return value
-
- cleaned = {key: _without_image_payloads(item) for key, item in value.items()}
- if cleaned.get("type") == "input_image" and isinstance(cleaned.get("image_url"), str):
- cleaned["image_url"] = "[Image saved locally during handoff]"
- if cleaned.get("type") == "image" and isinstance(cleaned.get("source"), dict):
- source = dict(cleaned["source"])
- if source.get("type") == "base64" and "data" in source:
- source["data"] = "[Image saved locally during handoff]"
- cleaned["source"] = source
- return cleaned
-
-
-def _token_count(value: Any) -> int:
- text = json.dumps(_without_image_payloads(value), ensure_ascii=False, separators=(",", ":"))
- try:
- import tiktoken
-
- return len(tiktoken.get_encoding("o200k_base").encode(text))
- except ImportError:
- return (len(text) + 3) // 4
-
-
-def build_plan(session: str, projects_dir: Path) -> HandoffPlan:
- path = _resolve_session(session, projects_dir)
- records, sha256 = _stable_jsonl(path)
- chain = _active_chain(records)
- imported, compact_uuid = _after_latest_compaction(chain)
- imported = _merge_parallel_tool_results(imported, records)
- items, skipped_reasoning = _responses_items(imported)
-
- session_id = next(
- (str(record["sessionId"]) for record in reversed(records) if record.get("sessionId")),
- path.stem,
- )
- title = next(
- (
- str(record["customTitle"])
- for record in reversed(records)
- if record.get("type") == "custom-title" and record.get("customTitle")
- ),
- f"Claude session {session_id[:8]}",
- )
- cwd = next(
- (str(record["cwd"]) for record in chain if record.get("cwd")),
- "",
- )
- if not cwd:
- raise HandoffError("Claude session does not record its working directory.")
-
- warnings: list[str] = []
- if skipped_reasoning:
- warnings.append(f"Skipped {skipped_reasoning} Claude hidden-reasoning block(s); they are not portable.")
-
- source = SourceInfo(
- path=str(path),
- sha256=sha256,
- session_id=session_id,
- title=title,
- cwd=str(Path(cwd).resolve()),
- leaf_uuid=chain[-1]["uuid"],
- compact_boundary_uuid=compact_uuid,
- first_imported_uuid=imported[0]["uuid"],
- last_imported_uuid=imported[-1]["uuid"],
- codex_cwd=_git_root(Path(cwd)),
- )
- return HandoffPlan(
- source=source,
- items=items,
- source_records=len(records),
- active_records=len(chain),
- imported_records=len(imported),
- hidden_reasoning_blocks_skipped=skipped_reasoning,
- approximate_tokens=_token_count(items),
- warnings=warnings,
- )
-
-
-def _write_private(path: Path, body: str) -> None:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
- try:
- with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
- stream.write(body)
- os.replace(temporary, path)
- finally:
- Path(temporary).unlink(missing_ok=True)
-
-
-def write_bundle(plan: HandoffPlan, path: Path) -> Path:
- path = path.expanduser().resolve()
- _write_private(path, json.dumps(plan.bundle(), ensure_ascii=False))
- return path
-
-
-def _validate_items(items: Any) -> None:
- if not isinstance(items, list) or not items:
- raise HandoffError("Handoff bundle contains no history items.")
- calls: dict[str, str] = {}
- completed: set[str] = set()
- saw_user = False
- for item in items:
- if not isinstance(item, dict):
- raise HandoffError("Invalid handoff history item.")
- kind = item.get("type")
- status = item.get("status")
- if (
- not isinstance(kind, str)
- or (status is not None and not isinstance(status, str))
- or status in {"incomplete", "in_progress"}
- ):
- raise HandoffError("Invalid or incomplete handoff history item.")
- if kind == "message":
- role = item.get("role")
- parts = item.get("content")
- if (
- not isinstance(role, str)
- or role not in {"user", "assistant"}
- or not isinstance(parts, list)
- or not parts
- ):
- raise HandoffError("Invalid handoff message role or content.")
- saw_user = saw_user or role == "user"
- for part in parts:
- if not isinstance(part, dict) or not isinstance(part.get("type"), str):
- raise HandoffError("Invalid handoff message part.")
- if part.get("type") in {"input_text", "output_text"} and isinstance(part.get("text"), str):
- continue
- if part.get("type") == "input_image":
- _, encoded = _data_url_payload(part.get("image_url"), "Handoff image")
- try:
- if not base64.b64decode(encoded, validate=True):
- raise ValueError("empty image")
- except (ValueError, binascii.Error) as exc:
- raise HandoffError("Invalid handoff image data.") from exc
- continue
- raise HandoffError("Unsupported handoff message part.")
- elif kind == "function_call":
- call_id, name, arguments = item.get("call_id"), item.get("name"), item.get("arguments")
- if not isinstance(call_id, str) or not call_id or not isinstance(name, str) or not name:
- raise HandoffError("Invalid handoff tool call ID or name.")
- if call_id in calls or not isinstance(arguments, str):
- raise HandoffError("Duplicate or invalid handoff tool call.")
- try:
- json.loads(arguments)
- except json.JSONDecodeError as exc:
- raise HandoffError("Handoff tool arguments are not JSON.") from exc
- calls[call_id] = name
- elif kind == "function_call_output":
- call_id = item.get("call_id")
- if not isinstance(call_id, str) or call_id not in calls or call_id in completed:
- raise HandoffError("Unmatched or duplicate handoff tool result.")
- if "output" not in item:
- raise HandoffError("Handoff tool result has no output.")
- completed.add(call_id)
- item.setdefault("name", calls[call_id])
- else:
- raise HandoffError(f"Unsupported handoff item type: {kind!r}.")
- if set(calls) != completed:
- raise HandoffError("Session has unfinished tool calls; finish or stop the source turn before handoff.")
- if not saw_user:
- raise HandoffError("Handoff contains no user message.")
-
-
-def plan_from_bundle(payload: Any) -> HandoffPlan:
- formats = {FORMAT_VERSION, "mem0.claude-to-codex.v1", "memo.claude-to-codex.v1"}
- if not isinstance(payload, dict) or not isinstance(payload.get("format"), str) or payload["format"] not in formats:
- raise HandoffError("Unsupported handoff bundle format.")
- source_payload = payload.get("source")
- items = payload.get("items")
- warnings = payload.get("warnings", [])
- if not isinstance(source_payload, dict) or not isinstance(warnings, list):
- raise HandoffError("Handoff bundle has no source or has invalid warnings.")
- _validate_items(items)
- try:
- fields = dict(source_payload)
- legacy = payload["format"] != FORMAT_VERSION
- fields.setdefault("host", "claude-code" if legacy else "")
- for key in ("host", "session_id", "title", "cwd"):
- if not isinstance(fields.get(key), str) or not fields[key].strip():
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[a-z][a-z0-9-]*", fields["host"]):
- raise ValueError("invalid source host")
- fields.setdefault("path", f"{fields['host']}:{fields['session_id']}")
- fields.setdefault("sha256", hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest())
- fields.setdefault("leaf_uuid", str(len(items)))
- fields.setdefault("first_imported_uuid", "1")
- fields.setdefault("last_imported_uuid", str(len(items)))
- fields.setdefault("compact_boundary_uuid", None)
- source = SourceInfo(**fields)
- for key, value in asdict(source).items():
- if value is None and key in {"codex_cwd", "compact_boundary_uuid"}:
- continue
- if not isinstance(value, str):
- raise ValueError(f"invalid source field: {key}")
- if not re.fullmatch(r"[0-9a-f]{64}", source.sha256):
- raise ValueError("invalid source digest")
- counts = payload.get("counts", {})
- return HandoffPlan(
- source=source,
- items=items,
- source_records=int(counts.get("source_records", len(items))),
- active_records=int(counts.get("active_records", len(items))),
- imported_records=int(counts.get("imported_records", len(items))),
- hidden_reasoning_blocks_skipped=int(counts.get("hidden_reasoning_blocks_skipped", 0)),
- approximate_tokens=_token_count(items),
- warnings=[str(warning) for warning in warnings],
- )
- except (KeyError, TypeError, ValueError, AttributeError) as exc:
- raise HandoffError("Handoff bundle is incomplete or has invalid source fields.") from exc
-
-
-def load_bundle(path: Path) -> HandoffPlan:
- try:
- text = sys.stdin.read() if str(path) == "-" else path.expanduser().resolve().read_text(encoding="utf-8")
- return plan_from_bundle(json.loads(text))
- except json.JSONDecodeError as exc:
- raise HandoffError(f"Invalid handoff bundle JSON: {path}") from exc
-
-
-def _git_root(cwd: Path) -> str | None:
- completed = subprocess.run(
- ["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
- text=True,
- stdout=subprocess.PIPE,
- stderr=subprocess.DEVNULL,
- check=False,
- )
- if completed.returncode != 0:
- return None
- root = Path(completed.stdout.strip()).resolve()
- return str(root) if root.is_dir() else None
-
-
-def _with_cwd(plan: HandoffPlan, cwd: Path | None) -> HandoffPlan:
- source_cwd = Path(plan.source.cwd).expanduser().resolve()
- target = (
- cwd.expanduser().resolve()
- if cwd
- else Path(plan.source.codex_cwd).expanduser().resolve()
- if plan.source.codex_cwd
- else Path(_git_root(source_cwd) or source_cwd)
- )
- if not target.is_dir():
- raise HandoffError(f"Codex working directory does not exist: {target}")
- return replace(plan, source=replace(plan.source, cwd=str(source_cwd), codex_cwd=str(target)))
-
-
-def _codex_cwd(plan: HandoffPlan) -> str:
- return plan.source.codex_cwd or plan.source.cwd
-
-
-def _default_bundle_path(plan: HandoffPlan) -> Path:
- session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip(".-")[:80] or "session"
- name = f"{session}-{plan.source.sha256[:12]}.json"
- return DEFAULT_BUNDLE_DIR / name
-
-
-def _codex_context_limits(codex_home: Path) -> CodexContextLimits:
- try:
- import tomllib
- except ImportError as exc:
- raise HandoffError("Creating a Codex task requires Python 3.11 or newer; rerun with python3.11.") from exc
-
- codex_home = codex_home.expanduser().resolve()
- config_path = codex_home / "config.toml"
- cache_path = codex_home / "models_cache.json"
- try:
- config = tomllib.loads(config_path.read_text(encoding="utf-8"))
- except (OSError, tomllib.TOMLDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex configuration at {config_path}: {exc}") from exc
- try:
- cache = json.loads(cache_path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError) as exc:
- raise HandoffError(f"Cannot read Codex model metadata at {cache_path}: {exc}") from exc
-
- model = str(config.get("model") or "")
- models = cache.get("models") if isinstance(cache, dict) else None
- if not isinstance(models, list):
- raise HandoffError(f"Codex model metadata has no model list: {cache_path}")
- model_info = next(
- (
- item
- for item in models
- if isinstance(item, dict)
- and (
- item.get("slug") == model
- or item.get("model") == model
- or (not model and item.get("is_default") is True)
- )
- ),
- None,
- )
- if not isinstance(model_info, dict):
- raise HandoffError(f"Codex model {model!r} is missing from {cache_path}; refresh Codex's model list.")
- model = str(model_info.get("slug") or model_info.get("model") or model)
-
- cached_context = model_info.get("context_window")
- cached_max = model_info.get("max_context_window") or cached_context
- if not isinstance(cached_context, int) or not isinstance(cached_max, int):
- raise HandoffError(f"Codex model {model!r} does not report its context limits.")
- configured_context = config.get("model_context_window")
- context_window = min(configured_context, cached_max) if isinstance(configured_context, int) else cached_context
- effective_percent = model_info.get("effective_context_window_percent", 95)
- if not isinstance(effective_percent, int) or not 1 <= effective_percent <= 100:
- raise HandoffError(f"Codex model {model!r} reports an invalid effective context percentage.")
-
- context_auto_limit = context_window * 9 // 10
- configured_auto_limit = config.get("model_auto_compact_token_limit")
- auto_compact_limit = (
- min(configured_auto_limit, context_auto_limit) if isinstance(configured_auto_limit, int) else context_auto_limit
- )
- return CodexContextLimits(
- model=model,
- context_window=context_window,
- usable_context_window=context_window * effective_percent // 100,
- auto_compact_token_limit=auto_compact_limit,
- max_context_window=cached_max,
- max_usable_context_window=cached_max * effective_percent // 100,
- max_auto_compact_token_limit=cached_max * 9 // 10,
- )
-
-
-class CodexAppServer:
- """Small JSON-RPC client for a one-off local Codex app-server process."""
-
- def __init__(
- self,
- codex_bin: str = "codex",
- context_window_override: int | None = None,
- codex_home: Path = DEFAULT_CODEX_HOME,
- ) -> None:
- resolved = shutil.which(codex_bin)
- if not resolved:
- raise HandoffError(f"Codex executable not found: {codex_bin}")
- command = [resolved]
- if context_window_override is not None:
- command.extend(["-c", f"model_context_window={context_window_override}"])
- command.extend(["app-server", "--stdio"])
- self.process = subprocess.Popen(
- command,
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- bufsize=1,
- env={**os.environ, "CODEX_HOME": str(codex_home.expanduser().resolve())},
- )
- self._responses: queue.Queue[dict[str, Any]] = queue.Queue()
- self._notifications: queue.Queue[dict[str, Any]] = queue.Queue()
- self._stderr: list[str] = []
- self._next_id = 1
- threading.Thread(target=self._read_stdout, daemon=True).start()
- threading.Thread(target=self._read_stderr, daemon=True).start()
- try:
- self.request(
- "initialize",
- {
- "clientInfo": {
- "name": "mem0_session_handoff",
- "title": "Mem0 local session handoff",
- "version": "0.1.0",
- }
- },
- )
- self.notify("initialized", {})
- except Exception:
- self.close()
- raise
-
- def _read_stdout(self) -> None:
- assert self.process.stdout is not None
- for line in self.process.stdout:
- try:
- message = json.loads(line)
- except json.JSONDecodeError:
- continue
- if not isinstance(message, dict):
- continue
- if "id" in message:
- self._responses.put(message)
- elif "method" in message:
- self._notifications.put(message)
-
- def _read_stderr(self) -> None:
- assert self.process.stderr is not None
- for line in self.process.stderr:
- self._stderr.append(line.rstrip())
-
- def _send(self, payload: dict[str, Any]) -> None:
- if self.process.poll() is not None:
- error = "\n".join(self._stderr[-20:])
- raise HandoffError(f"Codex app-server stopped unexpectedly.\n{error}")
- assert self.process.stdin is not None
- self.process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
- self.process.stdin.flush()
-
- def request(self, method: str, params: dict[str, Any], timeout: float = 30) -> Any:
- request_id = self._next_id
- self._next_id += 1
- self._send({"method": method, "id": request_id, "params": params})
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out on {method}.")
- try:
- response = self._responses.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out on {method}.") from exc
- if response.get("id") != request_id:
- deferred.append(response)
- continue
- if "error" in response:
- raise HandoffError(f"Codex {method} failed: {response['error']}")
- return response.get("result")
- finally:
- for response in deferred:
- self._responses.put(response)
-
- def notify(self, method: str, params: dict[str, Any]) -> None:
- self._send({"method": method, "params": params})
-
- def wait_for_notification(
- self,
- method: str,
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- raise HandoffError(f"Codex app-server timed out waiting for {method}.") from exc
- if notification.get("method") != method:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def wait_for_any_notification(
- self,
- methods: set[str],
- predicate: Any | None = None,
- timeout: float = 600,
- ) -> dict[str, Any]:
- deadline = time.monotonic() + timeout
- deferred: list[dict[str, Any]] = []
- try:
- while True:
- remaining = deadline - time.monotonic()
- if remaining <= 0:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.")
- try:
- notification = self._notifications.get(timeout=remaining)
- except queue.Empty as exc:
- joined = ", ".join(sorted(methods))
- raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.") from exc
- if notification.get("method") not in methods:
- deferred.append(notification)
- continue
- params = notification.get("params")
- if predicate is None or predicate(params):
- return notification
- deferred.append(notification)
- finally:
- for notification in deferred:
- self._notifications.put(notification)
-
- def close(self) -> None:
- if self.process.poll() is None:
- self.process.terminate()
- try:
- self.process.wait(timeout=5)
- except subprocess.TimeoutExpired:
- self.process.kill()
- self.process.wait(timeout=5)
-
- def __enter__(self) -> "CodexAppServer":
- return self
-
- def __exit__(self, *_: Any) -> None:
- self.close()
-
-
-def _item_text(item: dict[str, Any], asset_dir: Path) -> tuple[str, str]:
- """Convert one Responses item to a complete visible import message."""
- item_type = item.get("type")
- if item_type == "message":
- role = str(item.get("role") or "")
- if role not in {"user", "assistant"}:
- raise HandoffError(f"Codex's session importer cannot represent role {role!r}.")
- parts: list[str] = []
- for part in item.get("content") or []:
- if not isinstance(part, dict):
- raise HandoffError("A handoff message contains an invalid content item.")
- part_type = part.get("type")
- if part_type in {"input_text", "output_text"}:
- parts.append(str(part.get("text") or ""))
- elif part_type == "input_image":
- media_type, encoded = _data_url_payload(part.get("image_url"), "A Claude message image")
- parts.append(_image_reference(media_type, encoded, asset_dir, "A Claude message image"))
- else:
- raise HandoffError(f"Codex's session importer cannot represent content type {part_type!r}.")
- text = "\n\n".join(part for part in parts if part)
- if not text:
- raise HandoffError("A handoff message contains no transferable text.")
- return role, text
-
- if item_type == "function_call":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- arguments = str(item.get("arguments") or "{}")
- return (
- "assistant",
- f'\n{arguments}\n',
- )
-
- if item_type == "function_call_output":
- name = html.escape(str(item.get("name") or "unknown"), quote=True)
- call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
- output = _tool_result_text(item.get("output"), asset_dir, f"Claude tool result {call_id}")
- return (
- "assistant",
- f'\n{output}\n',
- )
-
- raise HandoffError(f"Codex's session importer cannot represent item type {item_type!r}.")
-
-
-def _native_import_records(plan: HandoffPlan, asset_dir: Path) -> list[dict[str, Any]]:
- """Build the Claude-shaped history consumed by Codex's native importer."""
- cwd = _codex_cwd(plan)
- records: list[dict[str, Any]] = [
- {
- "type": "custom-title",
- "customTitle": plan.source.title,
- "sessionId": plan.source.session_id,
- }
- ]
- saw_user = False
- for index, item in enumerate(plan.items, 1):
- role, text = _item_text(item, asset_dir)
- saw_user = saw_user or role == "user"
- records.append(
- {
- "type": role,
- "sessionId": plan.source.session_id,
- "uuid": f"mem0-handoff-{index}",
- "cwd": cwd,
- "isSidechain": False,
- "message": {"role": role, "content": text},
- }
- )
- if not saw_user:
- raise HandoffError("The active Claude context contains no user message.")
- return records
-
-
-def _native_import_path(plan: HandoffPlan, claude_projects_dir: Path) -> Path:
- source_key = hashlib.sha256(plan.source.path.encode("utf-8")).hexdigest()[:24]
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- safe_session = safe_session[:80] or source_key
- return claude_projects_dir.expanduser().resolve() / ".mem0-handoffs" / f"{safe_session}-{source_key}.jsonl"
-
-
-def _write_native_import(plan: HandoffPlan, path: Path, asset_dir: Path) -> str:
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- body = "".join(
- json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
- for record in _native_import_records(plan, asset_dir)
- )
- _write_private(path, body)
- return hashlib.sha256(body.encode("utf-8")).hexdigest()
-
-
-def _native_import_params(source_path: Path, cwd: str) -> dict[str, Any]:
- return {
- "migrationItems": [
- {
- "itemType": "SESSIONS",
- "description": f"Transfer Claude session {source_path.name}",
- "cwd": None,
- "details": {
- "plugins": [],
- "sessions": [{"path": str(source_path), "cwd": cwd, "title": None}],
- "mcpServers": [],
- "hooks": [],
- "subagents": [],
- "commands": [],
- },
- }
- ]
- }
-
-
-def _thread_id_from_completion(params: Any, source_path: Path) -> str | None:
- if not isinstance(params, dict):
- return None
- canonical = str(source_path.resolve())
- for result in params.get("itemTypeResults") or []:
- if not isinstance(result, dict) or result.get("itemType") != "SESSIONS":
- continue
- for success in result.get("successes") or []:
- if not isinstance(success, dict):
- continue
- if success.get("source") in {None, canonical} and success.get("target"):
- return str(success["target"])
- return None
-
-
-def _thread_id_from_ledger(codex_home: Path, source_path: Path, content_sha256: str) -> str | None:
- ledger_path = codex_home.expanduser() / "external_agent_session_imports.json"
- if not ledger_path.is_file():
- return None
- try:
- ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
- except json.JSONDecodeError:
- return None
- canonical = str(source_path.resolve())
- matches = [
- record
- for record in ledger.get("records", [])
- if isinstance(record, dict)
- and record.get("source_path") == canonical
- and record.get("content_sha256") == content_sha256
- and record.get("imported_thread_id")
- ]
- return str(matches[-1]["imported_thread_id"]) if matches else None
-
-
-def _notification_thread_id(params: Any) -> str | None:
- if not isinstance(params, dict):
- return None
- if params.get("threadId"):
- return str(params["threadId"])
- turn = params.get("turn")
- if isinstance(turn, dict) and turn.get("threadId"):
- return str(turn["threadId"])
- return None
-
-
-def _compact_imported_thread(
- server: CodexAppServer,
- thread_id: str,
-) -> dict[str, Any] | None:
- server.request("thread/resume", {"threadId": thread_id}, timeout=120)
- server.request("thread/compact/start", {"threadId": thread_id}, timeout=30)
-
- latest_usage: dict[str, Any] | None = None
- saw_compaction_item = False
- while True:
- notification = server.wait_for_any_notification(
- {"item/completed", "thread/tokenUsage/updated", "turn/completed", "error"},
- lambda params: _notification_thread_id(params) in {None, thread_id},
- timeout=600,
- )
- method = notification.get("method")
- params = notification.get("params")
- if method == "thread/tokenUsage/updated" and isinstance(params, dict):
- token_usage = params.get("tokenUsage")
- if isinstance(token_usage, dict):
- latest_usage = token_usage
- continue
- if method == "item/completed" and isinstance(params, dict):
- item = params.get("item")
- if isinstance(item, dict) and item.get("type") == "contextCompaction":
- saw_compaction_item = True
- continue
- if method == "error":
- error = params.get("error") if isinstance(params, dict) else params
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if method == "turn/completed" and isinstance(params, dict):
- turn = params.get("turn")
- if not isinstance(turn, dict):
- raise HandoffError("Codex returned an invalid compaction result.")
- if turn.get("status") != "completed":
- error = turn.get("error") or turn.get("status")
- raise HandoffError(f"Codex could not compact the imported task: {error}")
- if not saw_compaction_item:
- raise HandoffError("Codex completed the compaction turn without a compaction item.")
- return latest_usage
-
-
-def _set_thread_name(
- server: CodexAppServer,
- thread_id: str,
- name: str,
-) -> None:
- server.request(
- "thread/name/set",
- {"threadId": thread_id, "name": name},
- timeout=30,
- )
-
-
-def create_codex_thread(
- plan: HandoffPlan,
- codex_bin: str = "codex",
- codex_home: Path = DEFAULT_CODEX_HOME,
-) -> dict[str, Any]:
- limits = _codex_context_limits(codex_home)
- should_compact = plan.approximate_tokens >= limits.auto_compact_token_limit
- if should_compact and plan.approximate_tokens >= limits.max_auto_compact_token_limit:
- raise HandoffError(
- f"The active session state is approximately {plan.approximate_tokens:,} tokens. "
- f"Codex cannot safely compact more than approximately "
- f"{limits.max_auto_compact_token_limit:,} tokens in one request. "
- "Compact in the source host and retry the handoff."
- )
-
- # Codex only imports sources staged under its native Claude home.
- source_path = _native_import_path(plan, Path.home() / ".claude" / "projects")
- safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
- asset_dir = (
- codex_home.expanduser().resolve()
- / "external-agent-assets"
- / plan.source.host
- / (safe_session[:80] or "session")
- )
- content_sha256 = _write_native_import(plan, source_path, asset_dir)
- try:
- context_override = limits.max_context_window if should_compact else None
- with CodexAppServer(codex_bin, context_override, codex_home=codex_home) as server:
- response = server.request(
- "externalAgentConfig/import",
- _native_import_params(source_path, _codex_cwd(plan)),
- timeout=120,
- )
- import_id = str((response or {}).get("importId") or "")
- if not import_id:
- raise HandoffError(f"Codex externalAgentConfig/import returned no import ID: {response!r}")
- completed = server.wait_for_notification(
- IMPORT_COMPLETED_NOTIFICATION,
- lambda params: isinstance(params, dict) and params.get("importId") == import_id,
- )
- completed_params = completed.get("params")
- thread_id = _thread_id_from_completion(completed_params, source_path)
- if not thread_id:
- thread_id = _thread_id_from_ledger(codex_home, source_path, content_sha256)
- if not thread_id:
- raise HandoffError(
- "Codex finished importing the session but did not report the new task ID. "
- f"Import result: {json.dumps(completed_params, ensure_ascii=False)}"
- )
-
- read = server.request(
- "thread/read",
- {"threadId": thread_id, "includeTurns": True},
- )
- thread = (read or {}).get("thread") if isinstance(read, dict) else None
- if not isinstance(thread, dict):
- raise HandoffError(f"Codex could not read imported task {thread_id}.")
- turns = thread.get("turns") or []
- preview = str(thread.get("preview") or "")
- if not turns or not preview:
- raise HandoffError(f"Codex imported task {thread_id}, but it has no visible history.")
-
- compaction_usage = _compact_imported_thread(server, thread_id) if should_compact else None
- _set_thread_name(server, thread_id, plan.source.title)
-
- return {
- "thread_id": thread_id,
- "title": plan.source.title,
- "cwd": _codex_cwd(plan),
- "source_session_id": plan.source.session_id,
- "source_host": plan.source.host,
- "visible_turns": len(turns),
- "preview": preview,
- "responses_items_converted": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "target_model": limits.model,
- "target_context_window": limits.context_window,
- "target_usable_context_window": limits.usable_context_window,
- "target_auto_compact_token_limit": limits.auto_compact_token_limit,
- "compacted_before_return": should_compact,
- "compaction_context_window": (limits.max_context_window if should_compact else None),
- "compaction_token_usage": compaction_usage,
- "model_invoked": should_compact,
- }
- finally:
- source_path.unlink(missing_ok=True)
- try:
- source_path.parent.rmdir()
- except OSError:
- pass
-
-
-def _summary(plan: HandoffPlan) -> dict[str, Any]:
- return {
- "source": plan.source.path,
- "source_host": plan.source.host,
- "session_id": plan.source.session_id,
- "title": plan.source.title,
- "source_cwd": plan.source.cwd,
- "codex_cwd": _codex_cwd(plan),
- "leaf_uuid": plan.source.leaf_uuid,
- "compact_boundary_uuid": plan.source.compact_boundary_uuid,
- "source_records": plan.source_records,
- "active_records": plan.active_records,
- "imported_records": plan.imported_records,
- "responses_items": len(plan.items),
- "approximate_import_tokens": plan.approximate_tokens,
- "warnings": plan.warnings,
- }
-
-
-def _command_output(result: dict[str, Any]) -> str:
- title = str(result["title"])
- cwd = str(result["cwd"])
- project = Path(cwd).name or cwd
- lines = [
- f'Created Codex task "{title}".',
- f"Task ID: {result['thread_id']}",
- f"Project: {project}",
- ]
- if result.get("compacted_before_return"):
- lines.append("Codex compacted the transferred context before opening the task.")
- lines.append(f'Open Codex and select "{title}" under {project}.')
- return "\n".join(lines)
-
-
-def _parse_args(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> argparse.Namespace:
- parser = argparse.ArgumentParser(description=__doc__)
- source = parser.add_mutually_exclusive_group(required=True)
- source.add_argument("--session", help="Native session transcript path (Claude also accepts its session ID)")
- parser.add_argument(
- "--source",
- choices=("claude-code", "cursor", "codex", "kimi", "antigravity", "openclaw", "pi-agent"),
- default=default_source,
- )
- parser.add_argument("--title", help="Override the imported task title")
- source.add_argument("--bundle", type=Path, help="Previously exported handoff bundle")
- parser.add_argument(
- "--claude-projects-dir",
- type=Path,
- default=Path.home() / ".claude" / "projects",
- )
- parser.add_argument("--export", type=Path, help="Write a private reusable handoff bundle")
- parser.add_argument(
- "--cwd",
- type=Path,
- help="Use this existing directory instead of the source session's directory",
- )
- parser.add_argument("--create", action="store_true", help="Create the Codex task")
- parser.add_argument(
- "--target",
- choices=("codex",),
- default="codex",
- help="Destination coding agent",
- )
- parser.add_argument(
- "--command-output",
- action="store_true",
- help="Print the short result used by Mem0's user-facing command",
- )
- parser.add_argument("--codex-bin", default="codex")
- parser.add_argument(
- "--codex-home",
- type=Path,
- default=DEFAULT_CODEX_HOME,
- )
- return parser.parse_args(argv)
-
-
-def main(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> int:
- args = _parse_args(argv, default_source)
- try:
- if args.bundle:
- plan = load_bundle(args.bundle)
- elif args.source == "claude-code":
- plan = build_plan(args.session, args.claude_projects_dir)
- else:
- if not args.source:
- raise HandoffError("--source is required with --session.")
- from handoff_sources import read_source
-
- plan = read_source(args.source, Path(args.session), cwd=args.cwd, title=args.title)
- if args.title:
- plan = replace(plan, source=replace(plan.source, title=args.title))
- plan = _with_cwd(plan, args.cwd)
- output: dict[str, Any] = {"plan": _summary(plan)}
- if args.export:
- output["bundle"] = str(write_bundle(plan, args.export))
- if args.create:
- try:
- output["codex"] = create_codex_thread(
- plan,
- codex_bin=args.codex_bin,
- codex_home=args.codex_home.expanduser(),
- )
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- fallback = args.export or _default_bundle_path(plan)
- saved = write_bundle(plan, fallback)
- raise HandoffError(f"{exc} The complete handoff was saved at {saved}.") from exc
- if args.command_output:
- if not args.create:
- raise HandoffError("--command-output requires --create.")
- print(_command_output(output["codex"]))
- else:
- print(json.dumps(output, indent=2, ensure_ascii=False))
- return 0
- except (HandoffError, OSError, subprocess.SubprocessError) as exc:
- print(f"handoff failed: {exc}", file=sys.stderr)
- return 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/integrations/mem0-agent-plugin/core/handoff-runtime.json b/integrations/mem0-agent-plugin/core/handoff-runtime.json
new file mode 100644
index 000000000..b6b935e8d
--- /dev/null
+++ b/integrations/mem0-agent-plugin/core/handoff-runtime.json
@@ -0,0 +1,8 @@
+{
+ "revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
+ "files": {
+ "claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
+ "handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
+ },
+ "artifacts": ["session_handoff.py", "handoff-runtime.json"]
+}
diff --git a/integrations/mem0-agent-plugin/core/handoff_sources.py b/integrations/mem0-agent-plugin/core/handoff_sources.py
deleted file mode 100644
index 549c9969e..000000000
--- a/integrations/mem0-agent-plugin/core/handoff_sources.py
+++ /dev/null
@@ -1,452 +0,0 @@
-"""Native transcript readers; all destinations use the shared handoff importer.
-
-Formats: openai/codex rollout payloads; MoonshotAI/kimi-code contextMemory;
-Pi's session-manager.buildSessionContext; native Cursor/Antigravity transcripts.
-Unsupported state changes fail instead of silently dropping active context.
-"""
-
-from __future__ import annotations
-
-import json
-from pathlib import Path
-from typing import Any
-
-import claude_to_codex as engine
-
-
-def _message(role: str, text: str) -> dict:
- return {"role": role, "content": [{"type": "text", "text": text}]}
-
-
-def _parts(content: Any, role: str, warnings: list[str]) -> list[dict]:
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if not isinstance(part, dict):
- raise engine.HandoffError("Native message has an invalid content block.")
- kind = part.get("type")
- if kind in {"thinking", "redacted_thinking", "think"}:
- if "Hidden reasoning was excluded." not in warnings:
- warnings.append("Hidden reasoning was excluded.")
- elif kind in {"text", "input_text", "output_text"} and isinstance(part.get("text"), str):
- parts.append({"type": "input_text" if role == "user" else "output_text", "text": part["text"]})
- elif kind == "image":
- source = part.get("source") or {
- "type": "base64",
- "data": part.get("data"),
- "media_type": part.get("mimeType"),
- }
- media_type, data = engine._image_payload(source, "Native message image")
- parts.append({"type": "input_image", "image_url": f"data:{media_type};base64,{data}"})
- elif kind in {"image_url", "input_image"}:
- url = part.get("image_url")
- if isinstance(url, dict):
- url = url.get("url")
- engine._data_url_payload(url, "Native message image")
- parts.append({"type": "input_image", "image_url": url})
- elif kind not in {"toolCall", "tool_use"}:
- raise engine.HandoffError(f"Unsupported native content block: {kind!r}.")
- return parts
-
-
-def _call_item(call: dict) -> dict:
- function = call.get("function", call)
- arguments = function.get("arguments", "{}")
- return {
- "type": "function_call",
- "call_id": call.get("id"),
- "name": function.get("name"),
- "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments),
- }
-
-
-def _messages_items(messages: list[dict], warnings: list[str]) -> list[dict]:
- items = []
- for message in messages:
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid native message.")
- role = message.get("role")
- if role in {"system", "developer"}:
- if "Source harness instructions were excluded." not in warnings:
- warnings.append("Source harness instructions were excluded.")
- continue
- if role in {"tool", "toolResult"}:
- output_parts = _parts(message.get("content"), "assistant", warnings)
- output = []
- for part in output_parts:
- if part["type"] == "input_image":
- media_type, data = engine._data_url_payload(part["image_url"], "Tool result image")
- output.append(
- {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}}
- )
- else:
- output.append({"type": "text", "text": part["text"]})
- if message.get("isError"):
- output.insert(0, {"type": "text", "text": "Tool failed."})
- if message.get("note"):
- output.append({"type": "text", "text": str(message["note"])})
- item = {
- "type": "function_call_output",
- "call_id": message.get("toolCallId") or message.get("tool_call_id"),
- "output": output,
- }
- if message.get("toolName") or message.get("name"):
- item["name"] = message.get("toolName") or message["name"]
- items.append(item)
- continue
- if role not in {"user", "assistant"}:
- raise engine.HandoffError(f"Unsupported native message role: {role!r}.")
- if message.get("partial") or message.get("stopReason") in {"error", "aborted"}:
- raise engine.HandoffError("Native assistant response is incomplete; finish the source turn first.")
- content = message.get("content", [])
- if isinstance(content, str):
- content = [{"type": "text", "text": content}]
- if not isinstance(content, list):
- raise engine.HandoffError("Native message has invalid content.")
- parts = []
- for part in content:
- if isinstance(part, dict) and part.get("type") in {"toolCall", "tool_use"}:
- if role != "assistant":
- raise engine.HandoffError("Native user message contains an assistant tool call.")
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- parts = []
- items.append(
- _call_item(
- {
- "id": part.get("id"),
- "name": part.get("name"),
- "arguments": json.dumps(part.get("arguments", part.get("input", {}))),
- }
- )
- )
- else:
- parts.extend(_parts([part], role, warnings))
- if parts:
- items.append({"type": "message", "role": role, "content": parts})
- for call in message.get("toolCalls") or message.get("tool_calls") or []:
- items.append(_call_item(call))
- return items
-
-
-def _codex(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Native Responses items are the authoritative history, event_msg is UI data.
- items, source = [], {}
- for record in records:
- kind, payload = record.get("type"), record.get("payload")
- if not isinstance(payload, dict):
- raise engine.HandoffError("Invalid Codex rollout payload.")
- if kind == "session_meta":
- source.update(session_id=payload.get("id"), cwd=payload.get("cwd"))
- elif kind == "compacted":
- replacement = payload.get("replacement_history")
- if not isinstance(replacement, list) or not replacement:
- raise engine.HandoffError(
- "Codex compaction is opaque; a complete plaintext replacement history is required."
- )
- items = list(replacement)
- elif kind == "response_item":
- items.append(payload)
- elif kind == "event_msg":
- if payload.get("type") == "thread_rolled_back":
- raise engine.HandoffError("Codex rollback requires a native active-context export.")
- elif kind != "turn_context":
- raise engine.HandoffError(f"Unsupported Codex rollout record: {kind!r}.")
- result = []
- for item in items:
- kind = item.get("type")
- if kind == "reasoning":
- warnings.append("Hidden reasoning was excluded.")
- elif kind == "message" and item.get("role") in {"system", "developer"}:
- warnings.append("Source harness instructions were excluded.")
- elif kind == "custom_tool_call":
- result.append(
- {
- "type": "function_call",
- "call_id": item.get("call_id"),
- "name": item.get("name"),
- "arguments": json.dumps({"input": item.get("input")}),
- }
- )
- elif kind == "custom_tool_call_output":
- result.append({**item, "type": "function_call_output"})
- elif kind == "compaction":
- raise engine.HandoffError(
- "Codex compaction contains opaque model state; it cannot be transferred losslessly."
- )
- else:
- result.append(dict(item))
- return result, source
-
-
-def _cursor(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Cursor's persisted transcript uses role + message.content, without Claude's parent chain.
- converted = []
- source = {}
- for index, record in enumerate(records):
- role = record.get("role") or record.get("type")
- if role not in {"user", "assistant"} or not isinstance(record.get("message"), dict):
- raise engine.HandoffError("Unsupported Cursor transcript record; provide a complete native JSONL export.")
- converted.append({**record, "type": role, "uuid": str(index)})
- if record.get("session_id"):
- source["session_id"] = record["session_id"]
- if record.get("cwd"):
- source["cwd"] = record["cwd"]
- items, skipped = engine._responses_items(converted)
- if skipped:
- warnings.append("Hidden reasoning was excluded.")
- return items, source
-
-
-def _antigravity(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- messages = []
- for step in records:
- if step.get("status") != "DONE":
- raise engine.HandoffError("Antigravity has an unfinished transcript step; finish the source turn first.")
- kind, content = step.get("type"), step.get("content")
- if not isinstance(content, str):
- raise engine.HandoffError("Antigravity transcript content is not transferable text.")
- if kind == "USER_INPUT":
- messages.append(_message("user", content))
- elif kind == "PLANNER_RESPONSE" and step.get("source") == "MODEL":
- messages.append(_message("assistant", content))
- else:
- raise engine.HandoffError(
- f"Unsupported Antigravity step {kind!r}; its visible conversation semantics are not verified."
- )
- return _messages_items(messages, warnings), {}
-
-
-def _kimi_compact(messages: list[dict], record: dict) -> list[dict]:
- summary = record.get("contextSummary", record.get("summary"))
- if isinstance(summary, dict):
- summary_message = summary
- elif isinstance(summary, str):
- summary_message = {**_message("user", summary), "origin": {"kind": "compaction_summary"}}
- else:
- raise engine.HandoffError("Kimi compaction has no transferable summary.")
- if record.get("legacyTail") or "keptUserMessageCount" not in record:
- count = record.get("compactedCount", record.get("count"))
- if not isinstance(count, int) or not 0 <= count <= len(messages):
- raise engine.HandoffError("Invalid Kimi compaction boundary.")
- return [summary_message, *messages[count:]]
- users = []
- for message in messages:
- origin = message.get("origin") or {}
- if message.get("role") == "user" and (
- origin.get("kind") in {None, "user"}
- or (origin.get("kind") in {"skill_activation", "plugin_command"} and origin.get("trigger") == "user-slash")
- ):
- users.append(message)
- # Kimi trims user inputs above this native budget. Do not approximate that destructive rewrite.
- tokens = 0
- for message in users:
- if message.get("toolCalls"):
- raise engine.HandoffError("Unsupported Kimi compaction user tool calls.")
- tokens += 1 # estimateTokens('user')
- for part in message.get("content", []):
- if part.get("type") not in {"text", "think"}:
- tokens += 2000
- else:
- text = part.get("text", part.get("think", ""))
- ascii_count = sum(ord(char) <= 127 for char in text)
- tokens += (ascii_count + 3) // 4 + len(text) - ascii_count
- if tokens > 20000 or record.get("keptHeadUserMessageCount"):
- raise engine.HandoffError("Kimi compaction elided user content; use a native active-context bundle export.")
- continuation = _message(
- "user",
- "\nContext compaction is complete — continue the work that was in progress when it began.\n",
- )
- return [*users, summary_message, continuation]
-
-
-def _kimi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- # Mirrors Kimi v2 context.append_message and completed loop events, not UI stream fragments.
- messages, source = [], {}
- opened, step_id = None, None
- for record in records:
- if record.get("agentId") not in {None, "main"}:
- continue
- kind = record.get("type", "")
- if kind in {"profile.bind", "config.update"}:
- cwd = (record.get("environmentDisclosure") or {}).get("cwd") or record.get("cwd")
- if cwd:
- source["cwd"] = cwd
- elif kind == "context.append_message":
- if opened is not None:
- raise engine.HandoffError("Kimi interleaved messages require a completed native context export.")
- messages.append(record.get("message"))
- elif kind == "context.append_loop_event":
- event = record.get("event") or {}
- event_type = event.get("type")
- if event_type == "step.begin":
- if opened is not None:
- raise engine.HandoffError("Kimi previous response did not complete.")
- step_id = event.get("uuid")
- opened = {"role": "assistant", "content": [], "toolCalls": []}
- messages.append(opened)
- elif event_type == "step.end":
- if event.get("uuid") != step_id or event.get("finishReason") in {"error", "interrupted"}:
- raise engine.HandoffError("Kimi response is incomplete or interrupted.")
- opened, step_id = None, None
- elif event_type in {"content.part", "tool.call"}:
- if opened is None or event.get("stepUuid") != step_id:
- raise engine.HandoffError("Kimi content has no matching active response.")
- if event_type == "content.part":
- opened["content"].append(event.get("part"))
- else:
- opened["toolCalls"].append(
- {
- "id": event.get("toolCallId"),
- "name": event.get("name"),
- "arguments": json.dumps(event.get("args", {})),
- }
- )
- elif event_type == "tool.result":
- result = event.get("result") or {}
- messages.append(
- {
- "role": "tool",
- "toolCallId": event.get("toolCallId"),
- "content": result.get("output"),
- "isError": result.get("isError"),
- "note": result.get("note"),
- }
- )
- else:
- raise engine.HandoffError(f"Unsupported Kimi loop event: {event_type!r}.")
- elif kind == "context.clear":
- messages, opened, step_id = [], None, None
- elif kind == "context.apply_compaction":
- if opened is not None:
- raise engine.HandoffError("Kimi compaction began during an unfinished response.")
- messages = _kimi_compact(messages, record)
- elif kind in {"context.undo", "micro_compaction.apply", "context.spliced"}:
- raise engine.HandoffError(f"Kimi {kind} needs a native active-context export to preserve its state.")
- elif kind.startswith("context.") and kind != "context.update_token_count":
- raise engine.HandoffError(f"Unsupported Kimi context event: {kind!r}.")
- # Remaining durable events configure Kimi's harness; they are not model messages.
- if opened is not None:
- raise engine.HandoffError("Kimi response is still streaming.")
- return _messages_items(messages, warnings), source
-
-
-def _pi(records: list[dict], warnings: list[str]) -> tuple[list[dict], dict]:
- header = records[0]
- if header.get("type") != "session":
- raise engine.HandoffError("Pi/OpenClaw transcript has no session header.")
- entries = [record for record in records[1:] if isinstance(record.get("id"), str)]
- if len(entries) != len(records) - 1:
- raise engine.HandoffError("Pi/OpenClaw transcript entry has no ID.")
- index = {entry["id"]: entry for entry in entries}
- if len(index) != len(entries):
- raise engine.HandoffError("Pi/OpenClaw transcript has duplicate entry IDs.")
- chain, seen = [], set()
- current = entries[-1] if entries else None
- while current:
- if current["id"] in seen:
- raise engine.HandoffError("Pi/OpenClaw transcript has a parent cycle.")
- seen.add(current["id"])
- chain.append(current)
- parent = current.get("parentId")
- if parent is not None and parent not in index:
- raise engine.HandoffError("Pi/OpenClaw transcript has a missing parent.")
- current = index.get(parent)
- chain.reverse()
- title = next((entry.get("name") for entry in reversed(chain) if entry.get("type") == "session_info"), None)
- messages = []
- boundary = next((i for i in range(len(chain) - 1, -1, -1) if chain[i].get("type") == "compaction"), None)
- if boundary is not None:
- compact = chain[boundary]
- if not isinstance(compact.get("summary"), str):
- raise engine.HandoffError("Pi/OpenClaw compaction has no summary.")
- messages.append(
- _message(
- "user",
- f"The conversation history before this point was compacted into the following summary:\n\n\n{compact['summary']}\n",
- )
- )
- kept = next((i for i in range(boundary) if chain[i]["id"] == compact.get("firstKeptEntryId")), boundary)
- chain = chain[kept:boundary] + chain[boundary + 1 :]
- for entry in chain:
- kind = entry.get("type")
- if kind == "message":
- message = entry.get("message")
- if not isinstance(message, dict):
- raise engine.HandoffError("Invalid Pi/OpenClaw message.")
- if message.get("role") == "bashExecution":
- if message.get("excludeFromContext"):
- continue
- if message.get("truncated"):
- raise engine.HandoffError("Pi/OpenClaw shell output is truncated; provide a complete bundle.")
- text = f"Ran `{message.get('command', '')}`\n"
- text += f"```\n{message['output']}\n```" if message.get("output") else "(no output)"
- if message.get("cancelled"):
- text += "\n\n(command cancelled)"
- elif message.get("exitCode") not in {None, 0}:
- text += f"\n\nCommand exited with code {message['exitCode']}"
- message = _message("user", text)
- messages.append(message)
- elif kind == "branch_summary":
- messages.append(
- _message(
- "user",
- f"The following is a summary of a branch that this conversation came back from:\n\n\n{entry['summary']}",
- )
- )
- elif kind == "custom_message":
- messages.append({"role": "user", "content": entry.get("content")})
- elif kind == "session_info":
- title = entry.get("name")
- elif kind not in {"model_change", "thinking_level_change", "custom", "label"}:
- raise engine.HandoffError(f"Unsupported Pi/OpenClaw entry: {kind!r}.")
- return _messages_items(messages, warnings), {
- "session_id": header.get("id"),
- "cwd": header.get("cwd"),
- "title": title,
- }
-
-
-def read_source(host: str, path: Path, *, cwd: Path | None = None, title: str | None = None) -> engine.HandoffPlan:
- path = path.expanduser().resolve()
- records, digest = engine._stable_jsonl(path)
- warnings: list[str] = []
- readers = {
- "cursor": _cursor,
- "codex": _codex,
- "kimi": _kimi,
- "antigravity": _antigravity,
- "openclaw": _pi,
- "pi-agent": _pi,
- }
- try:
- items, metadata = readers[host](records, warnings)
- except (TypeError, AttributeError, KeyError, ValueError) as exc:
- raise engine.HandoffError(f"Invalid {host} native transcript structure: {exc}") from exc
- if host == "kimi" and path.name == "wire.jsonl" and path.parent.name == "main":
- metadata.setdefault("session_id", path.parents[2].name)
- state = path.parents[2] / "state.json"
- if state.is_file():
- try:
- metadata.setdefault("title", json.loads(state.read_text()).get("title"))
- except (json.JSONDecodeError, AttributeError):
- pass
- if host == "antigravity" and path.name == "transcript.jsonl" and path.parent.name == "logs":
- metadata.setdefault("session_id", path.parents[2].name)
- source_cwd = str(cwd.expanduser().resolve()) if cwd else metadata.get("cwd")
- if not source_cwd:
- raise engine.HandoffError(f"{host} transcript has no working directory; provide --cwd.")
- source = {
- "host": host,
- "path": str(path),
- "sha256": digest,
- "session_id": metadata.get("session_id") or path.stem,
- "cwd": source_cwd,
- "title": title or metadata.get("title") or f"{host} session {path.stem[:12]}",
- }
- return engine.plan_from_bundle(
- {"format": engine.FORMAT_VERSION, "source": source, "items": items, "warnings": list(dict.fromkeys(warnings))}
- )
diff --git a/integrations/mem0-agent-plugin/core/memory_core.py b/integrations/mem0-agent-plugin/core/memory_core.py
index 1e99f99e7..93a3fde15 100644
--- a/integrations/mem0-agent-plugin/core/memory_core.py
+++ b/integrations/mem0-agent-plugin/core/memory_core.py
@@ -29,7 +29,7 @@ from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
-PLUGIN_VERSION = "0.4.0"
+PLUGIN_VERSION = "0.3.2"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
diff --git a/integrations/mem0-agent-plugin/core/session_handoff.py b/integrations/mem0-agent-plugin/core/session_handoff.py
index 4aada69e6..b7108f336 100644
--- a/integrations/mem0-agent-plugin/core/session_handoff.py
+++ b/integrations/mem0-agent-plugin/core/session_handoff.py
@@ -1,7 +1,81 @@
#!/usr/bin/env python3
-"""Import an explicit native session or neutral handoff bundle into Codex."""
+"""Run the shared handoff engine locally or from its verified immutable cache."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import sys
+import tempfile
+from pathlib import Path
+from urllib.request import urlopen
+
+ENGINE_FILES = {"claude_to_codex.py", "handoff_sources.py"}
+SOURCE_URL = "https://raw.githubusercontent.com/mem0ai/mem0"
+
+
+def _verified(path: Path, digest: str) -> bool:
+ try:
+ return hashlib.sha256(path.read_bytes()).hexdigest() == digest
+ except FileNotFoundError:
+ return False
+
+
+def runtime_root(launcher_dir: Path | None = None) -> Path:
+ here = launcher_dir or Path(__file__).resolve().parent
+ if all((here / name).is_file() for name in ENGINE_FILES):
+ return here # The canonical development checkout already has both engines.
+ manifest = json.loads((here / "handoff-runtime.json").read_text(encoding="utf-8"))
+ if not isinstance(manifest, dict):
+ raise ValueError("invalid handoff runtime manifest")
+ revision, files = manifest.get("revision"), manifest.get("files")
+ if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
+ raise ValueError("handoff runtime revision must be an immutable commit SHA")
+ if (
+ not isinstance(files, dict)
+ or set(files) != ENGINE_FILES
+ or any(not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest) for digest in files.values())
+ ):
+ raise ValueError("invalid handoff runtime file hashes")
+ cache = Path.home() / ".mem0" / "handoff-runtime" / revision
+ missing = {name: digest for name, digest in files.items() if not _verified(cache / name, digest)}
+ if not missing:
+ return cache
+ cache.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with tempfile.TemporaryDirectory(prefix=f".{revision}-", dir=cache.parent) as temporary:
+ staged = Path(temporary)
+ for name, digest in missing.items():
+ url = f"{SOURCE_URL}/{revision}/integrations/agent-plugin-core/python/{name}"
+ try:
+ with urlopen(url, timeout=30) as response:
+ body = response.read()
+ except OSError as exc:
+ raise OSError(f"could not download pinned handoff runtime {name}: {exc}") from exc
+ if hashlib.sha256(body).hexdigest() != digest:
+ raise ValueError(f"SHA256 mismatch for pinned handoff runtime {name}; refusing to execute it")
+ (staged / name).write_bytes(body)
+ # All downloads are verified before publishing; each replacement is atomic.
+ cache.mkdir(exist_ok=True, mode=0o700)
+ for name in missing:
+ os.replace(staged / name, cache / name)
+ if not all(_verified(cache / name, digest) for name, digest in files.items()):
+ raise ValueError("handoff runtime cache changed during installation; refusing to execute it")
+ return cache
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ root = runtime_root()
+ except (OSError, ValueError) as exc:
+ print(f"handoff runtime unavailable: {exc}", file=sys.stderr)
+ return 1
+ sys.path.insert(0, str(root))
+ from claude_to_codex import main as engine_main
+
+ return engine_main(argv, default_source=None)
-from claude_to_codex import main
if __name__ == "__main__":
- raise SystemExit(main(default_source=None))
+ raise SystemExit(main())
diff --git a/integrations/mem0-agent-plugin/plugin.json b/integrations/mem0-agent-plugin/plugin.json
index 0cfecefc9..7a2baba80 100644
--- a/integrations/mem0-agent-plugin/plugin.json
+++ b/integrations/mem0-agent-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "mem0",
- "version": "0.4.0",
+ "version": "0.3.2",
"description": "Cross-session memory and token savings for coding agents.",
"author": {
"name": "Mem0",
diff --git a/integrations/mem0-agent-plugin/skills/handoff/SKILL.md b/integrations/mem0-agent-plugin/skills/handoff/SKILL.md
index 25a4586f0..457530b5c 100644
--- a/integrations/mem0-agent-plugin/skills/handoff/SKILL.md
+++ b/integrations/mem0-agent-plugin/skills/handoff/SKILL.md
@@ -8,7 +8,9 @@ allowed-tools: Bash(python3 ${PLUGIN_ROOT}/core/session_handoff.py *)
All hosts share one local import engine. Native readers and SDK adapters supply
complete conversation items; Mem0 memory capture is not a transcript source.
-Requires Python 3.11+ and a Codex CLI with native session import support. The
+Requires Python 3.11+ and a Codex CLI with native session import support.
+First use downloads a pinned, hash-verified runtime from GitHub; later uses share
+the verified local cache. No transcript is sent to GitHub. The
supported destination is Codex. This does not transfer files or change branches.
Visible conversation, tool history, and supported source compaction summaries
diff --git a/integrations/openclaw/CHANGELOG.md b/integrations/openclaw/CHANGELOG.md
new file mode 100644
index 000000000..3744a9bbf
--- /dev/null
+++ b/integrations/openclaw/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+## 0.3.2
+
+- `/mem0-handoff codex` uses the current session’s trusted transcript path. Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
+- Lightened search prompts: search when prior work may help; repeat only for a specific gap.
diff --git a/integrations/openclaw/README.md b/integrations/openclaw/README.md
index d04eca11a..c55c73041 100644
--- a/integrations/openclaw/README.md
+++ b/integrations/openclaw/README.md
@@ -6,7 +6,7 @@ Your agent forgets everything between sessions. This plugin fixes that — it st
By default, the plugin runs in **skills mode**: the agent controls what to remember (triage) and how to recall (recall). Skills mode, `autoRecall`, and `autoCapture` are all enabled by default during `openclaw mem0 init`.
-Current package version: `1.2.0`. Shared redaction and lifecycle utilities come from [agent-plugin-core](../agent-plugin-core/README.md); OpenClaw keeps its own tools, skills, and memory scopes.
+Current package version: `0.3.2`. Shared redaction and lifecycle utilities come from [agent-plugin-core](../agent-plugin-core/README.md); OpenClaw keeps its own tools, skills, and memory scopes.
## Session handoff
@@ -14,7 +14,7 @@ Run `/mem0-handoff codex` to continue the current OpenClaw session in Codex. The
Requires **Python 3.11+** available as `python3` and an installed, signed-in Codex CLI with native session import support. Handoff works independently of Mem0 credentials and never sends the transcript through the Mem0 API. Unsupported content, missing results, and unrelated unfinished calls fail explicitly.
-The shared engine is packaged in `dist/` by [agent-plugin-core](../agent-plugin-core/README.md); it is not maintained separately in this plugin. Failed imports save a private recovery bundle under `~/.mem0/handoffs/`. See the [session handoff guide](../../docs/integrations/session-handoff.mdx) for supported formats, privacy, and recovery.
+The shared launcher and pinned runtime manifest are packaged in `dist/` by [agent-plugin-core](../agent-plugin-core/README.md); the importer is fetched once from its pinned GitHub commit, verified, and cached for all plugins. A valid cache works offline. Failed imports save a private recovery bundle under `~/.mem0/handoffs/`. See the [session handoff guide](../agent-plugin-core/README.md#session-handoff) for supported formats, privacy, and recovery.
## Requirements
diff --git a/integrations/openclaw/openclaw.plugin.json b/integrations/openclaw/openclaw.plugin.json
index 6ae75638d..4b6f8b63a 100644
--- a/integrations/openclaw/openclaw.plugin.json
+++ b/integrations/openclaw/openclaw.plugin.json
@@ -2,7 +2,7 @@
"id": "openclaw-mem0",
"name": "Memory (Mem0)",
"description": "Mem0 memory backend for OpenClaw — platform (mem0.ai cloud) or self-hosted open-source. Auto-recall and auto-capture are opt-in (disabled by default). Supports OpenAI, Anthropic, Ollama (fully local), Qdrant, and PGVector providers.",
- "version": "1.2.0",
+ "version": "0.3.2",
"kind": "memory",
"skills": ["skills"],
"commandAliases": [
diff --git a/integrations/openclaw/package.json b/integrations/openclaw/package.json
index 165ed2de8..11c78859f 100644
--- a/integrations/openclaw/package.json
+++ b/integrations/openclaw/package.json
@@ -1,6 +1,6 @@
{
"name": "@mem0/openclaw-mem0",
- "version": "1.2.0",
+ "version": "0.3.2",
"type": "module",
"description": "Mem0 memory backend for OpenClaw — platform or self-hosted open-source",
"license": "Apache-2.0",
diff --git a/integrations/opencode-plugin/CHANGELOG.md b/integrations/opencode-plugin/CHANGELOG.md
new file mode 100644
index 000000000..c54a1f48c
--- /dev/null
+++ b/integrations/opencode-plugin/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+## 0.3.2
+
+- `/mem0-handoff codex` uses the current OpenCode session API and completed tool results. Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
+- Lightened search prompts: search when prior work may help; repeat only for a specific gap.
diff --git a/integrations/opencode-plugin/README.md b/integrations/opencode-plugin/README.md
index a50f26fe0..9559e6858 100644
--- a/integrations/opencode-plugin/README.md
+++ b/integrations/opencode-plugin/README.md
@@ -2,7 +2,7 @@
Persistent memory for [OpenCode](https://opencode.ai). Your agent remembers decisions, preferences, and learnings across sessions automatically.
-Current package version: `0.4.0`. This native TypeScript integration keeps its own tools and scopes while sharing redaction and lifecycle utilities with [agent-plugin-core](../agent-plugin-core/README.md).
+Current package version: `0.3.2`. This native TypeScript integration keeps its own tools and scopes while sharing redaction and lifecycle utilities with [agent-plugin-core](../agent-plugin-core/README.md).
## Install
@@ -41,11 +41,11 @@ Run `/mem0-handoff codex` to continue the current OpenCode session in Codex. The
Requires **Python 3.11+** available as `python3` and an installed, signed-in Codex CLI with native session import support. Handoff works independently of Mem0 credentials and never sends the transcript through the Mem0 API. Unsupported content, missing results, and unrelated unfinished calls fail explicitly.
-The shared engine is packaged in `dist/` by [agent-plugin-core](../agent-plugin-core/README.md); it is not maintained separately in this plugin. Failed imports save a private recovery bundle under `~/.mem0/handoffs/`. See the [session handoff guide](../../docs/integrations/session-handoff.mdx) for supported formats, privacy, and recovery.
+The shared launcher and pinned runtime manifest are packaged in `dist/` by [agent-plugin-core](../agent-plugin-core/README.md); the importer is fetched once from its pinned GitHub commit, verified, and cached for all plugins. A valid cache works offline. Failed imports save a private recovery bundle under `~/.mem0/handoffs/`. See the [session handoff guide](../agent-plugin-core/README.md#session-handoff) for supported formats, privacy, and recovery.
## Hooks
-Memory hooks use TypeScript. Session handoff uses the bundled Python importer. Memory operations are native OpenCode tools backed by the [mem0ai](https://www.npmjs.com/package/mem0ai) SDK directly.
+Memory hooks use TypeScript. Session handoff uses the shared cached Python importer. Memory operations are native OpenCode tools backed by the [mem0ai](https://www.npmjs.com/package/mem0ai) SDK directly.
| Hook | Event | What it does |
|------|-------|-------------|
diff --git a/integrations/opencode-plugin/package.json b/integrations/opencode-plugin/package.json
index 6454716a2..b028e11c2 100644
--- a/integrations/opencode-plugin/package.json
+++ b/integrations/opencode-plugin/package.json
@@ -1,6 +1,6 @@
{
"name": "@mem0/opencode-plugin",
- "version": "0.4.0",
+ "version": "0.3.2",
"type": "module",
"description": "Mem0 persistent memory plugin for OpenCode — add, search, and manage memories across sessions",
"main": "dist/index.js",
diff --git a/integrations/pi-agent-plugin/CHANGELOG.md b/integrations/pi-agent-plugin/CHANGELOG.md
new file mode 100644
index 000000000..827b7df6a
--- /dev/null
+++ b/integrations/pi-agent-plugin/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+## 0.3.2
+
+- `/mem0-handoff codex` uses Pi’s selected branch and native compaction context (Node.js 22.19+). Uses the [shared handoff logic](../agent-plugin-core/CHANGELOG.md#032).
+- Lightened search prompts: search when prior work may help; repeat only for a specific gap.
diff --git a/integrations/pi-agent-plugin/README.md b/integrations/pi-agent-plugin/README.md
index 7f023434d..40c5cab81 100644
--- a/integrations/pi-agent-plugin/README.md
+++ b/integrations/pi-agent-plugin/README.md
@@ -4,7 +4,7 @@ Persistent semantic memory for [Pi Agent](https://pi.dev), powered by [Mem0](htt
This extension gives Pi Agent long-term memory that persists across sessions, projects, and devices. Memories are automatically captured from conversations and can be searched and managed through slash commands and an agent-accessible tool.
-Current package version: `0.4.0`. Shared redaction and lifecycle utilities come from [agent-plugin-core](../agent-plugin-core/README.md); Pi keeps its own tools and scopes.
+Current package version: `0.3.2`. Shared redaction and lifecycle utilities come from [agent-plugin-core](../agent-plugin-core/README.md); Pi keeps its own tools and scopes.
## Features
@@ -60,7 +60,7 @@ Handoff requires **Node.js 22.19+**, matching the native Pi SDK. The SDK loads o
Requires **Python 3.11+** available as `python3` and an installed, signed-in Codex CLI with native session import support. Handoff works independently of Mem0 credentials and never sends the transcript through the Mem0 API. Unsupported content, missing results, and unrelated unfinished calls fail explicitly.
-The shared engine is packaged in `dist/` by [agent-plugin-core](../agent-plugin-core/README.md); it is not maintained separately in this plugin. Failed imports save a private recovery bundle under `~/.mem0/handoffs/`. See the [session handoff guide](../../docs/integrations/session-handoff.mdx) for supported formats, privacy, and recovery.
+The shared launcher and pinned runtime manifest are packaged in `dist/` by [agent-plugin-core](../agent-plugin-core/README.md); the importer is fetched once from its pinned GitHub commit, verified, and cached for all plugins. A valid cache works offline. Failed imports save a private recovery bundle under `~/.mem0/handoffs/`. See the [session handoff guide](../agent-plugin-core/README.md#session-handoff) for supported formats, privacy, and recovery.
## Commands
diff --git a/integrations/pi-agent-plugin/package.json b/integrations/pi-agent-plugin/package.json
index ec4c09c58..7c2b13e4f 100644
--- a/integrations/pi-agent-plugin/package.json
+++ b/integrations/pi-agent-plugin/package.json
@@ -1,6 +1,6 @@
{
"name": "@mem0/pi-agent-plugin",
- "version": "0.4.0",
+ "version": "0.3.2",
"type": "module",
"description": "Mem0 memory extension for Pi Agent persistent, scoped, semantic memory across sessions and projects",
"license": "Apache-2.0",
diff --git a/marketplace.json b/marketplace.json
index b9a290ab4..b1608c958 100644
--- a/marketplace.json
+++ b/marketplace.json
@@ -13,7 +13,7 @@
},
"category": "Productivity",
"description": "Cross-session memory and token savings for coding agents.",
- "version": "0.4.0"
+ "version": "0.3.2"
}
]
}