fix(plugins): share one cached handoff runtime and use version 0.3.2
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -370,7 +370,6 @@
|
||||
"icon": "terminal",
|
||||
"pages": [
|
||||
"integrations/claude-code",
|
||||
"integrations/session-handoff",
|
||||
"integrations/claude-ai",
|
||||
"integrations/cursor",
|
||||
"integrations/codex",
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Info>Current plugin version: `0.4.0`.</Info>
|
||||
<Info>Current plugin version: `0.3.2`.</Info>
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Info>Current plugin version: `0.4.0`.</Info>
|
||||
<Info>Current plugin version: `0.3.2`.</Info>
|
||||
|
||||
## 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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Info>Current plugin version: `0.4.0`.</Info>
|
||||
<Info>Current plugin version: `0.3.2`.</Info>
|
||||
|
||||
## 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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Info>Current plugin version: `0.4.0`.</Info>
|
||||
<Info>Current plugin version: `0.3.2`.</Info>
|
||||
|
||||
## 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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Info>Current package version: `0.4.0`.</Info>
|
||||
<Info>Current package version: `0.3.2`.</Info>
|
||||
|
||||
## 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.
|
||||
</Note>
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Info>Current plugin version: `0.4.0`.</Info>
|
||||
<Info>Current plugin version: `0.3.2`.</Info>
|
||||
|
||||
## 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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Info>Current package version: `1.2.0`.</Info>
|
||||
<Info>Current package version: `0.3.2`.</Info>
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Info>Current package version: `0.4.0`.</Info>
|
||||
<Info>Current package version: `0.3.2`.</Info>
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Info>Current package version: `0.4.0`.</Info>
|
||||
<Info>Current package version: `0.3.2`.</Info>
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -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.
|
||||
+1
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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/<revision>`. 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -129,9 +129,3 @@ export function runHandoff(scriptUrl: URL, bundle: HandoffBundle): Promise<strin
|
||||
export function runNativeSession(scriptUrl: URL, host: string, session: string): Promise<string> {
|
||||
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<string> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
|
||||
"files": {
|
||||
"claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
|
||||
"handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
|
||||
},
|
||||
"artifacts": ["session_handoff.py", "handoff-runtime.json"]
|
||||
}
|
||||
@@ -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",
|
||||
"<system-reminder>\nContext compaction is complete — continue the work that was in progress when it began.\n</system-reminder>",
|
||||
)
|
||||
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<summary>\n{compact['summary']}\n</summary>",
|
||||
)
|
||||
)
|
||||
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<summary>\n{entry['summary']}</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))}
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
|
||||
"files": {
|
||||
"claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
|
||||
"handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
|
||||
},
|
||||
"artifacts": ["session_handoff.py", "handoff-runtime.json"]
|
||||
}
|
||||
@@ -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",
|
||||
"<system-reminder>\nContext compaction is complete — continue the work that was in progress when it began.\n</system-reminder>",
|
||||
)
|
||||
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<summary>\n{compact['summary']}\n</summary>",
|
||||
)
|
||||
)
|
||||
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<summary>\n{entry['summary']}</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))}
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
|
||||
"files": {
|
||||
"claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
|
||||
"handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
|
||||
},
|
||||
"artifacts": ["session_handoff.py", "handoff-runtime.json"]
|
||||
}
|
||||
@@ -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",
|
||||
"<system-reminder>\nContext compaction is complete — continue the work that was in progress when it began.\n</system-reminder>",
|
||||
)
|
||||
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<summary>\n{compact['summary']}\n</summary>",
|
||||
)
|
||||
)
|
||||
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<summary>\n{entry['summary']}</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))}
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
|
||||
"files": {
|
||||
"claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
|
||||
"handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
|
||||
},
|
||||
"artifacts": ["session_handoff.py", "handoff-runtime.json"]
|
||||
}
|
||||
@@ -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",
|
||||
"<system-reminder>\nContext compaction is complete — continue the work that was in progress when it began.\n</system-reminder>",
|
||||
)
|
||||
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<summary>\n{compact['summary']}\n</summary>",
|
||||
)
|
||||
)
|
||||
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<summary>\n{entry['summary']}</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))}
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
|
||||
"files": {
|
||||
"claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
|
||||
"handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
|
||||
},
|
||||
"artifacts": ["session_handoff.py", "handoff-runtime.json"]
|
||||
}
|
||||
@@ -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",
|
||||
"<system-reminder>\nContext compaction is complete — continue the work that was in progress when it began.\n</system-reminder>",
|
||||
)
|
||||
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<summary>\n{compact['summary']}\n</summary>",
|
||||
)
|
||||
)
|
||||
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<summary>\n{entry['summary']}</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))}
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"revision": "0fb924051fa876b5d6311fe3378794cc92fb6ff8",
|
||||
"files": {
|
||||
"claude_to_codex.py": "48b23958b890836f2c84b74de45a1f5a430c27dc238b80bf1a8fd67e4c7cd705",
|
||||
"handoff_sources.py": "9ae209949473a215de8424004c7ea296e775e11743b5e7bfb77acc9630346a3b"
|
||||
},
|
||||
"artifacts": ["session_handoff.py", "handoff-runtime.json"]
|
||||
}
|
||||
@@ -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",
|
||||
"<system-reminder>\nContext compaction is complete — continue the work that was in progress when it began.\n</system-reminder>",
|
||||
)
|
||||
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<summary>\n{compact['summary']}\n</summary>",
|
||||
)
|
||||
)
|
||||
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<summary>\n{entry['summary']}</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))}
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|------|-------|-------------|
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"category": "Productivity",
|
||||
"description": "Cross-session memory and token savings for coding agents.",
|
||||
"version": "0.4.0"
|
||||
"version": "0.3.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user