feat(plugins)!: make Sidekick exclusive to Claude Code (#7278)

This commit is contained in:
Kartik
2026-09-10 20:51:50 +05:30
committed by GitHub
parent 02f7a9b2c4
commit d873892dad
53 changed files with 483 additions and 577 deletions
+36
View File
@@ -2371,6 +2371,12 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="Claude Code">
<Update label="Unreleased" description="Sidekick availability">
Sidekick is now available only in Claude Code, with Sonnet, worktree isolation, and parent memories.
</Update>
<Update label="2026-09-08" description="Claude Code plugin v0.3.1">
**Changed:**
@@ -2390,6 +2396,12 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="Cursor">
<Update label="Unreleased" description="Sidekick availability">
Removes Sidekick and its start/stop hooks. Memory capture, search, and six skills remain available.
</Update>
<Update label="2026-09-08" description="Cursor plugin v0.3.1">
**Changed:**
@@ -2409,6 +2421,12 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="Codex">
<Update label="Unreleased" description="Sidekick availability">
Renames shared tracking to use subagent terminology. Native subagent memory support remains available.
</Update>
<Update label="2026-09-08" description="Codex plugin v0.3.1">
**Changed:**
@@ -2427,6 +2445,12 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="Agent Plugins v1">
<Update label="Unreleased" description="Sidekick availability">
Sidekick is available only in Claude Code, not in the portable package.
</Update>
<Update label="2026-09-08" description="Portable Mem0 plugin v0.3.1">
**Added:**
@@ -2543,6 +2567,12 @@ Initial release of the Mem0 plugin for Claude Code and Cursor, followed by Codex
<Tab title="Antigravity">
<Update label="Unreleased" description="Sidekick availability">
Removes Sidekick. Memory capture, search, and six skills remain available.
</Update>
<Update label="2026-09-08" description="Antigravity plugin v0.3.1">
**Changed:**
@@ -2635,6 +2665,12 @@ Existing memories written by the previous versions are not rewritten. If your me
<Tab title="Kimi">
<Update label="Unreleased" description="Sidekick availability">
Removes Sidekick and its start/stop hooks. Memory capture, recall, and six skills remain available.
</Update>
<Update label="2026-09-08" description="Kimi Code plugin v0.3.1">
**Changed:**
+2
View File
@@ -17,6 +17,8 @@ Mem0 seamlessly integrates with popular AI frameworks and tools to enhance your
**Universal Integration**: Use <Link href="/platform/mem0-mcp">Mem0 MCP</Link> for a standardized protocol that works with ANY AI client.
</Callout>
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent). Other plugins, including the portable package, provide memory without Sidekick.
Here are the available integrations for Mem0:
## Integrations
+3 -2
View File
@@ -7,6 +7,8 @@ Add persistent memory to [**Google Antigravity**](https://antigravity.google) (`
<Info>Current plugin version: `0.3.1`.</Info>
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent).
## Prerequisites
1. A Mem0 API key (starts with `m0-`):
@@ -48,11 +50,10 @@ This installs the generated Antigravity bundle with the Mem0 server, lifecycle h
| Local MCP Server (`search_memories`) | Yes |
| Lifecycle Hooks | Yes |
| 6 Memory Skills | Yes |
| Sidekick Agent | Yes |
## Memory tools and skills
The full plugin exposes a focused `search_memories` tool and captures completed work through lifecycle hooks. Six skills provide search, status, remember, forget, pause, and resume workflows. A native sidekick can handle a bounded task in the workspace assigned by Antigravity; it does not claim Claude Code worktree isolation.
The full plugin exposes a focused `search_memories` tool and captures completed work through lifecycle hooks. Six skills provide search, status, remember, forget, pause, and resume workflows.
If you need the full set of direct CRUD tools, connect the [hosted Mem0 MCP server](/platform/mem0-mcp) separately instead of installing both configurations under the same server name.
+2
View File
@@ -64,6 +64,8 @@ After the automatic first-prompt search, Claude can also call `search_memories`
### Sidekick agent
Sidekick is available only in Claude Code.
`mem0:sidekick` is a Sonnet coding agent that runs in a separate Git worktree. Use it to offload investigation, implementation, testing, or review without burning main-session context.
```text
+2
View File
@@ -7,6 +7,8 @@ Add persistent memory to [**OpenAI Codex**](https://openai.com/index/codex/) wit
<Info>Current plugin version: `0.3.1`.</Info>
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent).
## Prerequisites
Before setting up Mem0 with Codex, ensure you have:
+5 -5
View File
@@ -7,6 +7,8 @@ Add persistent memory to [**Cursor**](https://cursor.com) with the Mem0 plugin.
<Info>Current plugin version: `0.3.1`.</Info>
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent).
## Prerequisites
Before setting up Mem0 with Cursor, ensure you have:
@@ -49,7 +51,7 @@ cursor-agent plugin marketplace add https://github.com/mem0ai/mem0
Open Cursor's **Customize** page or run `/plugins`, select the **Mem0 Plugins** marketplace, and install **Mem0** at user or project scope. Enter your Mem0 API key when Cursor asks for the plugin configuration, then restart Cursor.
The full plugin includes automatic capture, the `search_memories` recall tool, lifecycle hooks, a sidekick agent, and six memory skills.
The full plugin includes automatic capture, the `search_memories` recall tool, lifecycle hooks, and six memory skills.
### Option B: One-Click Deeplink (MCP Only)
@@ -98,7 +100,6 @@ Add the following to your `.cursor/mcp.json`:
| Recall | `search_memories` | MCP search tools |
| Lifecycle hooks | Yes | No |
| Memory skills | 6 | No |
| Sidekick agent | Yes | No |
## MCP-only tools
@@ -125,15 +126,14 @@ The full plugin translates Cursor's native events into the shared Mem0 memory li
| `sessionStart` | Initializes the project session and recovers pending capture |
| `beforeSubmitPrompt` | Observes the submitted prompt; recall stays tool-driven because this event cannot inject context |
| `postToolUse` / `postToolUseFailure` | Records useful tool results and failures |
| `subagentStart` / `subagentStop` | Records the Mem0 Sidekick lifecycle and completed result |
| `afterAgentResponse` / `stop` | Captures the completed exchange |
| `preCompact` / `sessionEnd` | Flushes pending capture in the background |
<Note>
Cursor's `beforeSubmitPrompt` hook can allow or block a prompt, but it cannot add context to that prompt. The plugin therefore gives the agent `search_memories` and a memory-aware sidekick for recall instead of claiming automatic per-prompt injection.
Cursor's `beforeSubmitPrompt` hook cannot add context to a prompt. Use `search_memories` to recall memories.
</Note>
Cursor's `subagentStart` hook also cannot inject parent context. The bundled Sidekick searches Mem0 itself, while the lifecycle hooks correlate its start and completion for later capture.
The plugin does not register subagent start or stop hooks.
## Example Workflow
+2
View File
@@ -7,6 +7,8 @@ Add persistent memory to the [**DeepSeek Harness**](https://github.com/deepseek-
<Info>Current package version: `0.3.0`.</Info>
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent).
## Overview
The plugin provides automatic memory plus two agent-callable tools:
+5 -3
View File
@@ -7,6 +7,8 @@ Kimi Code forgets project decisions between sessions. The Mem0 plugin captures c
<Info>Current plugin version: `0.3.1`.</Info>
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent).
## Prerequisites
1. A Mem0 Platform account and API key:
@@ -33,7 +35,7 @@ Inside Kimi Code, install the native plugin bundle and reload the session:
/reload
```
Run `/plugins info mem0` to confirm that the plugin, MCP server, skills, hooks, and sidekick agent loaded.
Run `/plugins info mem0` to confirm that the plugin, MCP server, skills, and hooks loaded.
## What you get
@@ -42,7 +44,6 @@ Run `/plugins info mem0` to confirm that the plugin, MCP server, skills, hooks,
- **Explicit search:** Kimi can call `search_memories` when it needs a more specific answer.
- **Six memory skills:** Search, status, remember, forget, pause, and resume use the same memory behavior as the other Mem0 coding-agent plugins.
- **Project scoping:** Memories stay attached to the repository, with separate personal and shared project lanes.
- **Kimi sidekick:** A focused subagent can investigate or implement a bounded task in a separate context. Filesystem isolation depends on the environment Kimi provides.
Credentials are redacted before memory capture. If Mem0 is unavailable, hooks fail open so Kimi can continue its normal work.
@@ -57,7 +58,8 @@ Kimi's native lifecycle events are translated into the shared Mem0 memory lifecy
| `PostToolUse` / `PostToolUseFailure` | Records useful tool results and failures |
| `Stop` | Captures the completed exchange |
| `PreCompact` / `SessionEnd` | Flushes pending capture in the background |
| `SubagentStart` / `SubagentStop` | Passes parent context to the Mem0 sidekick and records its lifecycle |
The plugin does not register subagent start or stop hooks or supply parent memories to child agents.
## Verify the plugin
+2
View File
@@ -7,6 +7,8 @@ Add long-term memory to [OpenClaw](https://github.com/openclaw/openclaw) agents
<Info>Current package version: `1.1.0`.</Info>
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent). OpenClaw subagents can recall parent memories. Mem0 does not capture their sessions.
## Overview
<Frame>
+2
View File
@@ -7,6 +7,8 @@ Add persistent memory to [**OpenCode**](https://opencode.ai) with the Mem0 plugi
<Info>Current package version: `0.3.0`.</Info>
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent).
## Prerequisites
1. A Mem0 API key (starts with `m0-`):
+2
View File
@@ -7,6 +7,8 @@ Add persistent memory to [**Pi Agent**](https://pi.dev) with `@mem0/pi-agent-plu
<Info>Current package version: `0.3.0`.</Info>
Sidekick is available only in the [Claude Code plugin](/integrations/claude-code#sidekick-agent).
## Overview
The plugin provides:
+4 -4
View File
@@ -269,11 +269,11 @@ If the user is on a pre-current major (Python < 2, TS < 3, or a Platform call st
### AI Coding Tools
- [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.
- [Cursor](https://docs.mem0.ai/integrations/cursor) [Platform]: Use when adding lifecycle capture, explicit memory recall, and six memory skills to Cursor.
- [Codex](https://docs.mem0.ai/integrations/codex) [Platform]: Use when adding automatic capture and recall, six memory skills, and a search tool to OpenAI Codex.
- [Kimi Code](https://docs.mem0.ai/integrations/kimi) [Platform]: Use when adding persistent project memory to Kimi Code through its native plugin lifecycle.
- [OpenCode](https://docs.mem0.ai/integrations/opencode) [Platform]: Use when adding ten native SDK memory tools, automatic context, seven skills, and project scoping to OpenCode.
- [Antigravity](https://docs.mem0.ai/integrations/antigravity) [Platform]: Use when adding lifecycle capture, explicit recall, six memory skills, and a native sidekick to Google Antigravity.
- [Antigravity](https://docs.mem0.ai/integrations/antigravity) [Platform]: Use when adding lifecycle capture, explicit recall, and six memory skills to Google Antigravity.
### Voice & Real-time
- [LiveKit](https://docs.mem0.ai/integrations/livekit) [Platform]: Use when building real-time voice/video with memory.
@@ -418,13 +418,13 @@ 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.3.1, 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 `/mem0:*` skills 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.1, 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 `/mem0:*` skills and `mem0:sidekick`, available only in Claude Code. Pure-stdlib Python, nothing to install.
### Coding-Agent Plugin Sources
Source: https://github.com/mem0ai/mem0/tree/main/integrations/agent-plugin-core
The `integrations/agent-plugin-core/` directory is the single source for shared Python and TypeScript memory behavior. Native plugins live in their own sibling directories, while `integrations/mem0-agent-plugin/` is the single portable Agent Plugins v1 package. The portable package provides search and skills but no automatic capture; its remember skill cannot save a new memory on its own. Shared Python search accepts optional `run_id` for session-specific recall in every scope. TypeScript integrations reuse the core's lifecycle, formatting, identity, scoping, and telemetry utilities while keeping their public packages and native host APIs unchanged.
The `integrations/agent-plugin-core/` directory is the single source for shared Python and TypeScript memory behavior. Native plugins live in their own sibling directories, while `integrations/mem0-agent-plugin/` is the single portable Agent Plugins v1 package. Sidekick is available only in Claude Code. The portable package provides search and skills but no automatic capture; its remember skill cannot save a new memory on its own. Shared Python search accepts optional `run_id` for session-specific recall in every scope. TypeScript integrations reuse the core's lifecycle, formatting, identity, scoping, and telemetry utilities while keeping their public packages and native host APIs unchanged.
Editor-specific setup docs (already listed above under `## Integrations > AI Coding Tools`):
+7 -5
View File
@@ -23,7 +23,7 @@ integrations/
Each native directory owns only its manifest, native hooks or adapter, tests, and `plugin-build.json`. Its `core/` and `skills/` directories are generated from this module. They are committed because clients install a self-contained plugin directory and the Agent Plugins specification forbids package files from resolving outside the plugin root.
Claude Code remains the behavioral source of truth. Its sidekick stays at `claude-code-plugin/agents/sidekick.md` and is not generated or copied to hosts without a compatible native subagent interface.
Sidekick belongs only to Claude Code. Its agent definition is in `claude-code-plugin/agents/sidekick.md`; its hooks are in `claude-code-plugin/adapters/claude/hook.py`. Other plugins must not register Sidekick. The shared core handles memory and native subagent tracking for Claude Code and Codex.
TypeScript integrations (`openclaw`, `opencode-plugin`, `pi-agent-plugin`, and `deepseek-plugin`) import `typescript/src/` at build time. Their package builders include the shared implementation in their normal output; they do not carry checked-in copies.
@@ -106,19 +106,21 @@ For another native Python host:
Keep capture, recall, memory scoping, redaction, skill text, and telemetry in this shared module. Host directories should contain only behavior required by their native SDK.
For a TypeScript host, import the shared lifecycle modules directly and keep only native SDK registration in the integration. Do not advertise capture, compaction, or sidekick behavior unless the host exposes the necessary lifecycle seam.
For a TypeScript host, import the shared lifecycle modules directly and keep only native SDK registration in the integration. Do not advertise capture or compaction behavior unless the host exposes the necessary lifecycle seam. Sidekick is limited to Claude Code.
## Host capture capabilities
| Host | Conversation capture | Tool outcomes | Subagent context and correlation |
| --- | --- | --- | --- |
| Claude Code | Incremental active transcript branch | Native success/failure hooks | Parent context; native agent ID |
| Cursor | Prompt and response hooks; duplicate responses suppressed | Native success/failure hooks | Sidekick searches itself; no parent-injection claim |
| Cursor | Prompt and response hooks; duplicate responses suppressed | Native success/failure hooks | No plugin subagent hooks or agent declaration |
| Codex | Native prompt and final-response fields | Structured failure indicators when present; otherwise unknown | Parent context; native agent ID |
| Kimi | Prompt hooks and completed v2 wire output | Native success/failure hooks | Parent context; without an ID, a stop matches only one unambiguous active run |
| Antigravity | Incremental completed transcript messages, including later prompts | Native tool errors | Sidekick searches itself; no worktree-isolation claim |
| Kimi | Prompt hooks and completed v2 wire output | Native success/failure hooks | No plugin subagent hooks or agent declaration |
| Antigravity | Incremental completed transcript messages, including later prompts | Native tool errors | No plugin subagent hooks or agent declaration |
| Portable v1 | Explicit memory skills | No native lifecycle hooks | No native subagent declaration |
Python status uses `subagent_runs` and `last_subagent`. Legacy SQLite names and event handling keep existing records and running workers compatible.
An uncorrelated subagent completion is kept as its own record; the plugin never guesses which overlapping run completed. Codex's documented hook fields already match the shared input contract, so no speculative field aliases or unsupported failure event are registered.
Offline conformance exercises the adapters and MCP servers with native-shaped payloads and builds each distributable package. It does not establish that every installed editor or Harness version loads the plugin correctly; those checks require smoke tests in the actual hosts.
@@ -11,10 +11,10 @@ import telemetry
from memory_core import (
EvidenceStore,
api_key,
configure_harness,
data_dir,
doctor,
forget_remote_repo,
configure_harness,
resolve_repo,
user_id,
)
@@ -32,7 +32,7 @@ def _print_status(value: dict) -> None:
)
print(
f"Used in this repository: {value['retrievals']} memories returned, "
f"{value['sidekick_runs']} sidekick runs"
f"{value['subagent_runs']} subagent runs"
)
if last:
item_label = ""
@@ -48,13 +48,13 @@ def _print_status(value: dict) -> None:
f"{'succeeded' if last['success'] else 'failed'} "
f"({last['duration_ms']:.1f} ms{item_label})"
)
sidekick = value.get("last_sidekick") or {}
if sidekick:
state = "finished" if sidekick.get("stopped_at") else "started"
subagent = value.get("last_subagent") or {}
if subagent:
state = "finished" if subagent.get("stopped_at") else "started"
print(
"Last sidekick: "
f"{state}, received {sidekick['context_chars']} characters of memory, "
f"agent {sidekick['agent_id']}"
"Last subagent: "
f"{state}, received {subagent['context_chars']} characters of memory, "
f"agent {subagent['agent_id']}"
)
@@ -524,7 +524,7 @@ def _checkpoint_message(event: dict[str, Any]) -> str:
if text:
return text
return redact(payload.get("text", "")).strip()
if kind == "sidekick_stop":
if kind in {"subagent_stop", "sidekick_stop"}:
return redact(payload.get("final_message", "")).strip()
return ""
@@ -596,6 +596,7 @@ class EvidenceStore:
self.conn.close()
def _migrate(self) -> None:
# Keep the legacy table name so existing databases and in-flight workers remain compatible.
self.conn.executescript(
"""
CREATE TABLE IF NOT EXISTS events (
@@ -688,8 +689,7 @@ class EvidenceStore:
"""
)
# Remove the pre-0.1.1 no-tools snapshot implementation. The real coding
# sidekick is a native Claude Code agent and stores no state in this DB.
# Remove the pre-0.1.1 snapshot implementation.
self.conn.executescript(
"""
DROP TABLE IF EXISTS sidekick_calls;
@@ -1041,7 +1041,7 @@ class EvidenceStore:
if row["memory_text"]
]
def start_sidekick(
def start_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1049,7 +1049,7 @@ class EvidenceStore:
agent_type: str,
context_chars: int,
) -> bool:
"""Record one native sidekick instance and whether context was first sent."""
"""Record one native subagent instance and whether context was first sent."""
with self.conn:
cursor = self.conn.execute(
"""INSERT OR IGNORE INTO sidekick_runs
@@ -1067,7 +1067,7 @@ class EvidenceStore:
)
return int(cursor.rowcount) > 0
def stop_sidekick(
def stop_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1222,7 +1222,7 @@ class EvidenceStore:
cursor = self.conn.execute(
f"DELETE FROM {table} WHERE {column} = ?", (repo_id,)
)
removed[table] = max(int(cursor.rowcount), 0)
removed["subagent_runs" if table == "sidekick_runs" else table] = max(int(cursor.rowcount), 0)
return removed
def status(self, repo_id: str) -> dict[str, Any]:
@@ -1238,7 +1238,7 @@ class EvidenceStore:
FROM operations WHERE repo_id = ? ORDER BY id DESC LIMIT 1""",
(repo_id,),
).fetchone()
last_sidekick = self.conn.execute(
last_subagent = self.conn.execute(
"""SELECT session_id, agent_id, agent_type, started_at, stopped_at,
context_chars
FROM sidekick_runs WHERE repo_id = ?
@@ -1250,9 +1250,9 @@ class EvidenceStore:
"events": count("events"),
"flushes": count("flushes"),
"retrievals": count("retrievals"),
"sidekick_runs": count("sidekick_runs"),
"subagent_runs": count("sidekick_runs"),
"last_operation": dict(last_operation) if last_operation else None,
"last_sidekick": dict(last_sidekick) if last_sidekick else None,
"last_subagent": dict(last_subagent) if last_subagent else None,
}
@@ -1324,7 +1324,7 @@ def tool_payload(hook_input: dict[str, Any], *, failed: bool | None = False) ->
"tool": name,
"failed": failed,
"duration_ms": hook_input.get("duration_ms"),
"agent_role": "sidekick" if hook_input.get("agent_id") else "main",
"agent_role": "subagent" if hook_input.get("agent_id") else "main",
}
if hook_input.get("agent_id"):
payload["agent_id"] = bounded(hook_input["agent_id"], 200)
@@ -1384,35 +1384,33 @@ def record_tool(
)
def record_sidekick_start(
store: EvidenceStore, hook_input: dict[str, Any], *, inject_context: bool = True
def record_subagent_start(
store: EvidenceStore, hook_input: dict[str, Any]
) -> str:
"""Record a native sidekick and reuse the main turn's retrieved memories."""
"""Record a native subagent and reuse the main turn's retrieved memories."""
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200)
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
context = combine_context(
format_context(store.injected_memories(session_id, repo.identity))
)
if not inject_context:
context = ""
first_start = store.start_sidekick(
first_start = store.start_subagent(
repo, session_id, agent_id, agent_type, len(context)
)
store.record_event(
repo,
session_id,
"sidekick_start",
"subagent_start",
{
"agent_id": agent_id,
"agent_type": agent_type,
"context_chars": len(context) if first_start else 0,
"worktree_root": bounded(repo.root, 2000),
"repo_root": bounded(repo.root, 2000),
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="start",
@@ -1422,14 +1420,14 @@ def record_sidekick_start(
return context if first_start else ""
def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
def record_subagent_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
agent_id = bounded(hook_input.get("agent_id", ""), 200)
final_message = redact(hook_input.get("last_assistant_message", "")).strip()
transcript_path = bounded(hook_input.get("agent_transcript_path", ""), 2000)
agent_id = store.stop_sidekick(
agent_id = store.stop_subagent(
repo,
session_id,
agent_id,
@@ -1440,7 +1438,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
store.record_event(
repo,
session_id,
"sidekick_stop",
"subagent_stop",
{
"agent_id": agent_id,
"agent_type": agent_type,
@@ -1449,7 +1447,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="stop",
@@ -1513,10 +1511,10 @@ def build_episode(
for e in events
if e["kind"] == "assistant_stop" and e["payload"].get("text")
]
sidekick_outcomes = [
subagent_outcomes = [
redact(e["payload"].get("final_message", "")).strip()
for e in events
if e["kind"] == "sidekick_stop" and e["payload"].get("final_message")
if e["kind"] in {"subagent_stop", "sidekick_stop"} and e["payload"].get("final_message")
]
tools = [
e["payload"] for e in events if e["kind"] in {"tool_result", "tool_failure"}
@@ -1597,7 +1595,7 @@ def build_episode(
}
)
pending_user_messages = []
elif event["kind"] == "sidekick_stop":
elif event["kind"] in {"subagent_stop", "sidekick_stop"}:
pass
extraction_messages.extend(pending_user_messages)
@@ -1613,7 +1611,7 @@ def build_episode(
"assistant_conclusion": conclusion,
"user_messages": prompts,
"assistant_outcomes": assistant_conclusions,
"sidekick_outcomes": sidekick_outcomes,
"subagent_outcomes": subagent_outcomes,
"extraction_messages": extraction_messages,
"files_read": read_paths[:50],
"files_modified": modified_paths[:50],
@@ -1,7 +1,7 @@
from __future__ import annotations
import sys
import json
import sys
from pathlib import Path
import pytest
@@ -10,7 +10,12 @@ ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = ROOT.parents[1]
sys.path.insert(0, str(ROOT))
from build.build import build, bundle_drift, render_template, replace_output # noqa: E402
from build.build import ( # noqa: E402
build,
bundle_drift,
render_template,
replace_output,
)
from build.validate import validate_bundle # noqa: E402
@@ -57,6 +62,7 @@ def test_portable_bundle_is_conformant_and_self_contained(tmp_path: Path) -> Non
assert "env" not in server
assert not (root / "core" / "hook_runner.py").exists()
assert not (root / "core" / "flush_worker.py").exists()
assert not (root / "agents").exists()
for skill in (root / "skills").glob("*/SKILL.md"):
frontmatter = skill.read_text(encoding="utf-8").split("---", 2)[1]
keys = {line.split(":", 1)[0] for line in frontmatter.splitlines() if ":" in line}
@@ -73,6 +79,19 @@ def test_native_bundle_is_self_contained(host: str, tmp_path: Path) -> None:
assert not any(path.is_symlink() for path in root.rglob("*"))
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
def test_only_claude_bundles_sidekick(host: str, tmp_path: Path) -> None:
root = build(host, "native", tmp_path / host)
if host == "claude-code":
agent = (root / "agents" / "sidekick.md").read_text()
assert "model: sonnet" in agent
assert "isolation: worktree" in agent
else:
assert not (root / "agents").exists()
for path in root.rglob("*.json"):
assert "sidekick" not in path.read_text().lower()
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
def test_native_control_skills_select_the_host_store(host: str, tmp_path: Path) -> None:
root = build(host, "native", tmp_path / host)
@@ -2,6 +2,7 @@
import json
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
@@ -11,11 +12,43 @@ import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "python"))
from memory_core import EvidenceStore # noqa: E402
from memory_core import EvidenceStore, RepoContext, checkpoint_stats # noqa: E402
@pytest.mark.parametrize("host", ["claude-code", "codex", "kimi", "cursor"])
def test_sidekick_hooks_preserve_parent_scope_and_correlate_completion(tmp_path, host):
def test_generic_tracking_preserves_legacy_runs_and_can_finish_them(tmp_path):
database = tmp_path / "evidence.sqlite3"
with sqlite3.connect(database) as connection:
connection.executescript("""
CREATE TABLE sidekick_runs (
repo_id TEXT NOT NULL, session_id TEXT NOT NULL,
agent_id TEXT NOT NULL, agent_type TEXT NOT NULL,
started_at TEXT NOT NULL, stopped_at TEXT, transcript_path TEXT,
context_chars INTEGER NOT NULL DEFAULT 0, final_message TEXT,
PRIMARY KEY(repo_id, session_id, agent_id)
);
INSERT INTO sidekick_runs VALUES
('repo', 'session', 'worker', 'default', '2026-09-10', NULL, NULL, 123, NULL);
""")
for _ in range(2):
store = EvidenceStore(database)
try:
assert store.status("repo")["subagent_runs"] == 1
row = store.conn.execute("SELECT * FROM sidekick_runs").fetchone()
assert row["agent_id"] == "worker"
assert row["context_chars"] == 123
repo = RepoContext(str(tmp_path), str(tmp_path), "repo", "app", "main", "")
store.stop_subagent(repo, "session", "worker", "default", "", "Completed after upgrade.")
assert store.status("repo")["last_subagent"]["stopped_at"]
assert store.conn.execute("SELECT final_message FROM sidekick_runs").fetchone()[0] == (
"Completed after upgrade."
)
finally:
store.close()
assert checkpoint_stats([{"kind": "sidekick_stop", "payload": {"final_message": "Legacy result."}}]) == (0, 1, 14)
@pytest.mark.parametrize("host", ["claude-code", "codex"])
def test_subagent_hooks_preserve_parent_scope_and_correlate_completion(tmp_path, host):
parent = tmp_path / "parent"
child = tmp_path / "child-worktree"
parent.mkdir()
@@ -30,21 +63,10 @@ def test_sidekick_hooks_preserve_parent_scope_and_correlate_completion(tmp_path,
plugin = ROOT.parent / f"{host}-plugin"
adapter = plugin / ("adapters/claude/hook.py" if host == "claude-code" else "hooks/adapter.py")
start, stop = "sidekick-start", "sidekick-stop"
payload = {"session_id": "parent-session", "cwd": str(child), "agent_id": "worker", "agent_type": "sidekick"}
payload = {"session_id": "parent-session", "cwd": str(child), "agent_id": "worker", "agent_type": "default"}
response_key = "last_assistant_message"
if host == "kimi":
start, stop = "SubagentStart", "SubagentStop"
payload["agent_name"] = payload.pop("agent_type")
response_key = "response"
elif host == "cursor":
start, stop = "subagentStart", "subagentStop"
payload = {
"conversation_id": "parent-session",
"workspace_roots": [str(child)],
"subagent_id": "worker",
"subagent_type": "sidekick",
}
response_key = "summary"
if host == "codex":
start, stop = "subagent-start", "subagent-stop"
env = {key: value for key, value in os.environ.items() if not key.startswith(("MEM0_", "CLAUDE_PLUGIN_"))}
env.update(MEM0_CODE_DATA_DIR=str(database.parent), MEM0_TELEMETRY="false", MEM0_API_URL="http://127.0.0.1:1")
@@ -62,10 +84,7 @@ def test_sidekick_hooks_preserve_parent_scope_and_correlate_completion(tmp_path,
return result.stdout
output = invoke(start, payload)
if host == "cursor":
assert json.loads(output) == {"permission": "allow"}
else:
context = output if host == "kimi" else json.loads(output)["hookSpecificOutput"]["additionalContext"]
context = json.loads(output)["hookSpecificOutput"]["additionalContext"]
assert "Parent memory marker." in context
assert "Foreign memory marker." not in output
assert "Parent memory marker." not in invoke(start, payload)
@@ -80,4 +99,4 @@ def test_sidekick_hooks_preserve_parent_scope_and_correlate_completion(tmp_path,
)
assert runs[0]["stopped_at"]
assert runs[0]["final_message"] == "Finished the delegated task."
assert (runs[0]["context_chars"] > 0) == (host != "cursor")
assert runs[0]["context_chars"] > 0
@@ -1,17 +0,0 @@
---
name: sidekick
description: Coding subagent for focused implementation, investigation, testing, debugging, or review work.
subagent: true
---
You are Mem0's coding sidekick. Complete only the bounded task the main agent
delegates to you and return a concise, self-contained result.
Search Mem0 before work that may depend on prior repository decisions or user
preferences. Inspect the relevant repository rules and code, make changes when
asked, and run the smallest decisive validation. Use only the workspace the
caller assigned; do not assume a separate Git worktree.
Your final response must state the outcome, changed files, validation, and any
remaining risk. Do not commit, push, or open a pull request unless the caller
explicitly asks.
@@ -11,10 +11,10 @@ import telemetry
from memory_core import (
EvidenceStore,
api_key,
configure_harness,
data_dir,
doctor,
forget_remote_repo,
configure_harness,
resolve_repo,
user_id,
)
@@ -32,7 +32,7 @@ def _print_status(value: dict) -> None:
)
print(
f"Used in this repository: {value['retrievals']} memories returned, "
f"{value['sidekick_runs']} sidekick runs"
f"{value['subagent_runs']} subagent runs"
)
if last:
item_label = ""
@@ -48,13 +48,13 @@ def _print_status(value: dict) -> None:
f"{'succeeded' if last['success'] else 'failed'} "
f"({last['duration_ms']:.1f} ms{item_label})"
)
sidekick = value.get("last_sidekick") or {}
if sidekick:
state = "finished" if sidekick.get("stopped_at") else "started"
subagent = value.get("last_subagent") or {}
if subagent:
state = "finished" if subagent.get("stopped_at") else "started"
print(
"Last sidekick: "
f"{state}, received {sidekick['context_chars']} characters of memory, "
f"agent {sidekick['agent_id']}"
"Last subagent: "
f"{state}, received {subagent['context_chars']} characters of memory, "
f"agent {subagent['agent_id']}"
)
@@ -524,7 +524,7 @@ def _checkpoint_message(event: dict[str, Any]) -> str:
if text:
return text
return redact(payload.get("text", "")).strip()
if kind == "sidekick_stop":
if kind in {"subagent_stop", "sidekick_stop"}:
return redact(payload.get("final_message", "")).strip()
return ""
@@ -596,6 +596,7 @@ class EvidenceStore:
self.conn.close()
def _migrate(self) -> None:
# Keep the legacy table name so existing databases and in-flight workers remain compatible.
self.conn.executescript(
"""
CREATE TABLE IF NOT EXISTS events (
@@ -688,8 +689,7 @@ class EvidenceStore:
"""
)
# Remove the pre-0.1.1 no-tools snapshot implementation. The real coding
# sidekick is a native Claude Code agent and stores no state in this DB.
# Remove the pre-0.1.1 snapshot implementation.
self.conn.executescript(
"""
DROP TABLE IF EXISTS sidekick_calls;
@@ -1041,7 +1041,7 @@ class EvidenceStore:
if row["memory_text"]
]
def start_sidekick(
def start_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1049,7 +1049,7 @@ class EvidenceStore:
agent_type: str,
context_chars: int,
) -> bool:
"""Record one native sidekick instance and whether context was first sent."""
"""Record one native subagent instance and whether context was first sent."""
with self.conn:
cursor = self.conn.execute(
"""INSERT OR IGNORE INTO sidekick_runs
@@ -1067,7 +1067,7 @@ class EvidenceStore:
)
return int(cursor.rowcount) > 0
def stop_sidekick(
def stop_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1222,7 +1222,7 @@ class EvidenceStore:
cursor = self.conn.execute(
f"DELETE FROM {table} WHERE {column} = ?", (repo_id,)
)
removed[table] = max(int(cursor.rowcount), 0)
removed["subagent_runs" if table == "sidekick_runs" else table] = max(int(cursor.rowcount), 0)
return removed
def status(self, repo_id: str) -> dict[str, Any]:
@@ -1238,7 +1238,7 @@ class EvidenceStore:
FROM operations WHERE repo_id = ? ORDER BY id DESC LIMIT 1""",
(repo_id,),
).fetchone()
last_sidekick = self.conn.execute(
last_subagent = self.conn.execute(
"""SELECT session_id, agent_id, agent_type, started_at, stopped_at,
context_chars
FROM sidekick_runs WHERE repo_id = ?
@@ -1250,9 +1250,9 @@ class EvidenceStore:
"events": count("events"),
"flushes": count("flushes"),
"retrievals": count("retrievals"),
"sidekick_runs": count("sidekick_runs"),
"subagent_runs": count("sidekick_runs"),
"last_operation": dict(last_operation) if last_operation else None,
"last_sidekick": dict(last_sidekick) if last_sidekick else None,
"last_subagent": dict(last_subagent) if last_subagent else None,
}
@@ -1324,7 +1324,7 @@ def tool_payload(hook_input: dict[str, Any], *, failed: bool | None = False) ->
"tool": name,
"failed": failed,
"duration_ms": hook_input.get("duration_ms"),
"agent_role": "sidekick" if hook_input.get("agent_id") else "main",
"agent_role": "subagent" if hook_input.get("agent_id") else "main",
}
if hook_input.get("agent_id"):
payload["agent_id"] = bounded(hook_input["agent_id"], 200)
@@ -1384,35 +1384,33 @@ def record_tool(
)
def record_sidekick_start(
store: EvidenceStore, hook_input: dict[str, Any], *, inject_context: bool = True
def record_subagent_start(
store: EvidenceStore, hook_input: dict[str, Any]
) -> str:
"""Record a native sidekick and reuse the main turn's retrieved memories."""
"""Record a native subagent and reuse the main turn's retrieved memories."""
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200)
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
context = combine_context(
format_context(store.injected_memories(session_id, repo.identity))
)
if not inject_context:
context = ""
first_start = store.start_sidekick(
first_start = store.start_subagent(
repo, session_id, agent_id, agent_type, len(context)
)
store.record_event(
repo,
session_id,
"sidekick_start",
"subagent_start",
{
"agent_id": agent_id,
"agent_type": agent_type,
"context_chars": len(context) if first_start else 0,
"worktree_root": bounded(repo.root, 2000),
"repo_root": bounded(repo.root, 2000),
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="start",
@@ -1422,14 +1420,14 @@ def record_sidekick_start(
return context if first_start else ""
def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
def record_subagent_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
agent_id = bounded(hook_input.get("agent_id", ""), 200)
final_message = redact(hook_input.get("last_assistant_message", "")).strip()
transcript_path = bounded(hook_input.get("agent_transcript_path", ""), 2000)
agent_id = store.stop_sidekick(
agent_id = store.stop_subagent(
repo,
session_id,
agent_id,
@@ -1440,7 +1438,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
store.record_event(
repo,
session_id,
"sidekick_stop",
"subagent_stop",
{
"agent_id": agent_id,
"agent_type": agent_type,
@@ -1449,7 +1447,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="stop",
@@ -1513,10 +1511,10 @@ def build_episode(
for e in events
if e["kind"] == "assistant_stop" and e["payload"].get("text")
]
sidekick_outcomes = [
subagent_outcomes = [
redact(e["payload"].get("final_message", "")).strip()
for e in events
if e["kind"] == "sidekick_stop" and e["payload"].get("final_message")
if e["kind"] in {"subagent_stop", "sidekick_stop"} and e["payload"].get("final_message")
]
tools = [
e["payload"] for e in events if e["kind"] in {"tool_result", "tool_failure"}
@@ -1597,7 +1595,7 @@ def build_episode(
}
)
pending_user_messages = []
elif event["kind"] == "sidekick_stop":
elif event["kind"] in {"subagent_stop", "sidekick_stop"}:
pass
extraction_messages.extend(pending_user_messages)
@@ -1613,7 +1611,7 @@ def build_episode(
"assistant_conclusion": conclusion,
"user_messages": prompts,
"assistant_outcomes": assistant_conclusions,
"sidekick_outcomes": sidekick_outcomes,
"subagent_outcomes": subagent_outcomes,
"extraction_messages": extraction_messages,
"files_read": read_paths[:50],
"files_modified": modified_paths[:50],
@@ -8,8 +8,7 @@
"plugin.json": "plugin.json",
"hooks.json": "hooks.json",
"mcp_config.json": "mcp_config.json",
"hooks/adapter.py": "hooks/adapter.py",
"agents/sidekick/agent.md": "agents/sidekick/agent.md"
"hooks/adapter.py": "hooks/adapter.py"
}
}
}
@@ -115,7 +115,7 @@ def test_native_antigravity_bundle_uses_supported_events(tmp_path: Path) -> None
assert manifest["$schema"] == "https://antigravity.google/schemas/v1/plugin.json"
assert set(hooks) == {"PreInvocation", "PostToolUse", "Stop"}
assert (root / "mcp_config.json").is_file()
assert (root / "agents" / "sidekick" / "agent.md").is_file()
assert not (root / "agents").exists()
assert not any(path.is_symlink() for path in root.rglob("*"))
@@ -63,6 +63,8 @@ After that first search, Claude can call `search_memories` with a specific quest
### Sonnet sidekick agent
Sidekick is available only in this plugin. The shared core handles memory and subagent tracking.
`mem0:sidekick` is a Sonnet coding agent that runs in a separate Git worktree. It can investigate, implement, test, debug, or review something instead of the main (Opus/Fable) session doing the same work, reducing cost when the main agent doesn't need to repeat it.
The main agent reviews the result. Corrections go back to the same sidekick so it keeps what it learned. Changes stay in the sidekick's worktree until the main agent reviews and copies them over.
@@ -12,22 +12,22 @@ _core_dir = _bundled_core if (_bundled_core / "memory_core.py").is_file() else _
sys.path.insert(0, str(_core_dir))
sys.path.insert(0, str(_here.parent))
import hook_runner # noqa: E402
import telemetry # noqa: E402
from memory_core import ( # noqa: E402
configure_harness,
record_sidekick_start,
record_sidekick_stop,
record_subagent_start,
record_subagent_stop,
record_tool,
)
from transcript import record_stop # noqa: E402
import hook_runner # noqa: E402
configure_harness("claude-code", data_dir_name="claude-code-plugin", source_tag="claude_code_plugin")
telemetry.init(harness="claude-code", source_tag="CLAUDE_CODE_PLUGIN")
def _sidekick_start(store, hook_input):
context = record_sidekick_start(store, hook_input)
context = record_subagent_start(store, {"agent_type": "mem0:sidekick", **hook_input})
if context:
return {
"hookSpecificOutput": {
@@ -38,7 +38,7 @@ def _sidekick_start(store, hook_input):
def _sidekick_stop(store, hook_input):
record_sidekick_stop(store, hook_input)
record_subagent_stop(store, {"agent_type": "mem0:sidekick", **hook_input})
if __name__ == "__main__":
@@ -11,10 +11,10 @@ import telemetry
from memory_core import (
EvidenceStore,
api_key,
configure_harness,
data_dir,
doctor,
forget_remote_repo,
configure_harness,
resolve_repo,
user_id,
)
@@ -32,7 +32,7 @@ def _print_status(value: dict) -> None:
)
print(
f"Used in this repository: {value['retrievals']} memories returned, "
f"{value['sidekick_runs']} sidekick runs"
f"{value['subagent_runs']} subagent runs"
)
if last:
item_label = ""
@@ -48,13 +48,13 @@ def _print_status(value: dict) -> None:
f"{'succeeded' if last['success'] else 'failed'} "
f"({last['duration_ms']:.1f} ms{item_label})"
)
sidekick = value.get("last_sidekick") or {}
if sidekick:
state = "finished" if sidekick.get("stopped_at") else "started"
subagent = value.get("last_subagent") or {}
if subagent:
state = "finished" if subagent.get("stopped_at") else "started"
print(
"Last sidekick: "
f"{state}, received {sidekick['context_chars']} characters of memory, "
f"agent {sidekick['agent_id']}"
"Last subagent: "
f"{state}, received {subagent['context_chars']} characters of memory, "
f"agent {subagent['agent_id']}"
)
@@ -524,7 +524,7 @@ def _checkpoint_message(event: dict[str, Any]) -> str:
if text:
return text
return redact(payload.get("text", "")).strip()
if kind == "sidekick_stop":
if kind in {"subagent_stop", "sidekick_stop"}:
return redact(payload.get("final_message", "")).strip()
return ""
@@ -596,6 +596,7 @@ class EvidenceStore:
self.conn.close()
def _migrate(self) -> None:
# Keep the legacy table name so existing databases and in-flight workers remain compatible.
self.conn.executescript(
"""
CREATE TABLE IF NOT EXISTS events (
@@ -688,8 +689,7 @@ class EvidenceStore:
"""
)
# Remove the pre-0.1.1 no-tools snapshot implementation. The real coding
# sidekick is a native Claude Code agent and stores no state in this DB.
# Remove the pre-0.1.1 snapshot implementation.
self.conn.executescript(
"""
DROP TABLE IF EXISTS sidekick_calls;
@@ -1041,7 +1041,7 @@ class EvidenceStore:
if row["memory_text"]
]
def start_sidekick(
def start_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1049,7 +1049,7 @@ class EvidenceStore:
agent_type: str,
context_chars: int,
) -> bool:
"""Record one native sidekick instance and whether context was first sent."""
"""Record one native subagent instance and whether context was first sent."""
with self.conn:
cursor = self.conn.execute(
"""INSERT OR IGNORE INTO sidekick_runs
@@ -1067,7 +1067,7 @@ class EvidenceStore:
)
return int(cursor.rowcount) > 0
def stop_sidekick(
def stop_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1222,7 +1222,7 @@ class EvidenceStore:
cursor = self.conn.execute(
f"DELETE FROM {table} WHERE {column} = ?", (repo_id,)
)
removed[table] = max(int(cursor.rowcount), 0)
removed["subagent_runs" if table == "sidekick_runs" else table] = max(int(cursor.rowcount), 0)
return removed
def status(self, repo_id: str) -> dict[str, Any]:
@@ -1238,7 +1238,7 @@ class EvidenceStore:
FROM operations WHERE repo_id = ? ORDER BY id DESC LIMIT 1""",
(repo_id,),
).fetchone()
last_sidekick = self.conn.execute(
last_subagent = self.conn.execute(
"""SELECT session_id, agent_id, agent_type, started_at, stopped_at,
context_chars
FROM sidekick_runs WHERE repo_id = ?
@@ -1250,9 +1250,9 @@ class EvidenceStore:
"events": count("events"),
"flushes": count("flushes"),
"retrievals": count("retrievals"),
"sidekick_runs": count("sidekick_runs"),
"subagent_runs": count("sidekick_runs"),
"last_operation": dict(last_operation) if last_operation else None,
"last_sidekick": dict(last_sidekick) if last_sidekick else None,
"last_subagent": dict(last_subagent) if last_subagent else None,
}
@@ -1324,7 +1324,7 @@ def tool_payload(hook_input: dict[str, Any], *, failed: bool | None = False) ->
"tool": name,
"failed": failed,
"duration_ms": hook_input.get("duration_ms"),
"agent_role": "sidekick" if hook_input.get("agent_id") else "main",
"agent_role": "subagent" if hook_input.get("agent_id") else "main",
}
if hook_input.get("agent_id"):
payload["agent_id"] = bounded(hook_input["agent_id"], 200)
@@ -1384,35 +1384,33 @@ def record_tool(
)
def record_sidekick_start(
store: EvidenceStore, hook_input: dict[str, Any], *, inject_context: bool = True
def record_subagent_start(
store: EvidenceStore, hook_input: dict[str, Any]
) -> str:
"""Record a native sidekick and reuse the main turn's retrieved memories."""
"""Record a native subagent and reuse the main turn's retrieved memories."""
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200)
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
context = combine_context(
format_context(store.injected_memories(session_id, repo.identity))
)
if not inject_context:
context = ""
first_start = store.start_sidekick(
first_start = store.start_subagent(
repo, session_id, agent_id, agent_type, len(context)
)
store.record_event(
repo,
session_id,
"sidekick_start",
"subagent_start",
{
"agent_id": agent_id,
"agent_type": agent_type,
"context_chars": len(context) if first_start else 0,
"worktree_root": bounded(repo.root, 2000),
"repo_root": bounded(repo.root, 2000),
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="start",
@@ -1422,14 +1420,14 @@ def record_sidekick_start(
return context if first_start else ""
def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
def record_subagent_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
agent_id = bounded(hook_input.get("agent_id", ""), 200)
final_message = redact(hook_input.get("last_assistant_message", "")).strip()
transcript_path = bounded(hook_input.get("agent_transcript_path", ""), 2000)
agent_id = store.stop_sidekick(
agent_id = store.stop_subagent(
repo,
session_id,
agent_id,
@@ -1440,7 +1438,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
store.record_event(
repo,
session_id,
"sidekick_stop",
"subagent_stop",
{
"agent_id": agent_id,
"agent_type": agent_type,
@@ -1449,7 +1447,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="stop",
@@ -1513,10 +1511,10 @@ def build_episode(
for e in events
if e["kind"] == "assistant_stop" and e["payload"].get("text")
]
sidekick_outcomes = [
subagent_outcomes = [
redact(e["payload"].get("final_message", "")).strip()
for e in events
if e["kind"] == "sidekick_stop" and e["payload"].get("final_message")
if e["kind"] in {"subagent_stop", "sidekick_stop"} and e["payload"].get("final_message")
]
tools = [
e["payload"] for e in events if e["kind"] in {"tool_result", "tool_failure"}
@@ -1597,7 +1595,7 @@ def build_episode(
}
)
pending_user_messages = []
elif event["kind"] == "sidekick_stop":
elif event["kind"] in {"subagent_stop", "sidekick_stop"}:
pass
extraction_messages.extend(pending_user_messages)
@@ -1613,7 +1611,7 @@ def build_episode(
"assistant_conclusion": conclusion,
"user_messages": prompts,
"assistant_outcomes": assistant_conclusions,
"sidekick_outcomes": sidekick_outcomes,
"subagent_outcomes": subagent_outcomes,
"extraction_messages": extraction_messages,
"files_read": read_paths[:50],
"files_modified": modified_paths[:50],
@@ -803,7 +803,7 @@ def test_tool_capture_identifies_main_and_sidekick_roles():
)
assert main["agent_role"] == "main"
assert sidekick["agent_role"] == "sidekick"
assert sidekick["agent_role"] == "subagent"
assert sidekick["agent_id"] == "agent-123"
assert sidekick["agent_type"] == "mem0:sidekick"
@@ -1665,7 +1665,7 @@ def test_checkpoint_queues_only_prod_extraction_with_canonical_evidence(
store.record_event(
repo(),
"s1",
"sidekick_stop",
"subagent_stop",
{
"agent_id": "agent-1",
"agent_type": "mem0:sidekick",
@@ -2337,9 +2337,9 @@ def test_sidekick_reuses_parent_memory_once_and_records_lifecycle(
}
with patch.object(memory_core, "resolve_repo", return_value=repo()):
first = memory_core.record_sidekick_start(store, start_input)
repeated = memory_core.record_sidekick_start(store, start_input)
memory_core.record_sidekick_stop(
first = memory_core.record_subagent_start(store, start_input)
repeated = memory_core.record_subagent_start(store, start_input)
memory_core.record_subagent_stop(
store,
{
**start_input,
@@ -2359,11 +2359,11 @@ def test_sidekick_reuses_parent_memory_once_and_records_lifecycle(
row[0]
for row in store.conn.execute("SELECT kind FROM events ORDER BY id").fetchall()
]
assert events == ["sidekick_start", "sidekick_start", "sidekick_stop"]
assert events == ["subagent_start", "subagent_start", "subagent_stop"]
store.close()
def test_sidekick_stop_without_agent_id_closes_latest_matching_run(isolated_env):
def test_subagent_stop_without_agent_id_closes_latest_matching_run(isolated_env):
store = memory_core.EvidenceStore()
start_input = {
"session_id": "s1",
@@ -2373,8 +2373,8 @@ def test_sidekick_stop_without_agent_id_closes_latest_matching_run(isolated_env)
}
with patch.object(memory_core, "resolve_repo", return_value=repo()):
memory_core.record_sidekick_start(store, start_input)
memory_core.record_sidekick_stop(
memory_core.record_subagent_start(store, start_input)
memory_core.record_subagent_stop(
store,
{
"session_id": "s1",
@@ -2836,14 +2836,14 @@ def test_status_output_explains_memory_activity_in_plain_language(capsys):
"events": 8,
"flushes": 2,
"retrievals": 3,
"sidekick_runs": 1,
"subagent_runs": 1,
"last_operation": {
"operation": "flush",
"success": 1,
"duration_ms": 123.4,
"item_count": 2,
},
"last_sidekick": {
"last_subagent": {
"stopped_at": "now",
"context_chars": 240,
"agent_id": "agent-1",
@@ -2853,9 +2853,9 @@ def test_status_output_explains_memory_activity_in_plain_language(capsys):
output = capsys.readouterr().out
assert "Saved on this computer: 8 session details, 2 memory updates" in output
assert "3 memories returned, 1 sidekick runs" in output
assert "3 memories returned, 1 subagent runs" in output
assert "Last memory update: succeeded" in output
assert "Last sidekick: finished, received 240 characters of memory" in output
assert "Last subagent: finished, received 240 characters of memory" in output
assert "checkpoint" not in output.lower()
assert "injected" not in output.lower()
@@ -3391,7 +3391,7 @@ def test_plugin_entrypoints_share_explicit_claude_data_dir(tmp_path, monkeypatch
status_payload = json.loads(status.stdout)
assert status_payload["data_dir"] == str(canonical_data)
assert status_payload["retrievals"] == 1
assert status_payload["sidekick_runs"] == 1
assert status_payload["subagent_runs"] == 1
assert not (conflicting_data / "evidence.sqlite3").exists()
@@ -4593,9 +4593,9 @@ def test_extraction_batches_bound_individual_messages_without_losing_text():
def test_overlapping_subagent_stop_without_id_does_not_guess(isolated_env):
store = memory_core.EvidenceStore()
store.start_sidekick(repo(), "s1", "first", "sidekick", 0)
store.start_sidekick(repo(), "s1", "second", "sidekick", 0)
stopped = store.stop_sidekick(repo(), "s1", "", "sidekick", "", "Uncorrelated response")
store.start_subagent(repo(), "s1", "first", "sidekick", 0)
store.start_subagent(repo(), "s1", "second", "sidekick", 0)
stopped = store.stop_subagent(repo(), "s1", "", "sidekick", "", "Uncorrelated response")
assert stopped not in {"first", "second"}
rows = store.conn.execute("SELECT agent_id, stopped_at, final_message FROM sidekick_runs").fetchall()
assert all(row["stopped_at"] is None for row in rows if row["agent_id"] in {"first", "second"})
+8 -8
View File
@@ -11,10 +11,10 @@ import telemetry
from memory_core import (
EvidenceStore,
api_key,
configure_harness,
data_dir,
doctor,
forget_remote_repo,
configure_harness,
resolve_repo,
user_id,
)
@@ -32,7 +32,7 @@ def _print_status(value: dict) -> None:
)
print(
f"Used in this repository: {value['retrievals']} memories returned, "
f"{value['sidekick_runs']} sidekick runs"
f"{value['subagent_runs']} subagent runs"
)
if last:
item_label = ""
@@ -48,13 +48,13 @@ def _print_status(value: dict) -> None:
f"{'succeeded' if last['success'] else 'failed'} "
f"({last['duration_ms']:.1f} ms{item_label})"
)
sidekick = value.get("last_sidekick") or {}
if sidekick:
state = "finished" if sidekick.get("stopped_at") else "started"
subagent = value.get("last_subagent") or {}
if subagent:
state = "finished" if subagent.get("stopped_at") else "started"
print(
"Last sidekick: "
f"{state}, received {sidekick['context_chars']} characters of memory, "
f"agent {sidekick['agent_id']}"
"Last subagent: "
f"{state}, received {subagent['context_chars']} characters of memory, "
f"agent {subagent['agent_id']}"
)
+28 -30
View File
@@ -524,7 +524,7 @@ def _checkpoint_message(event: dict[str, Any]) -> str:
if text:
return text
return redact(payload.get("text", "")).strip()
if kind == "sidekick_stop":
if kind in {"subagent_stop", "sidekick_stop"}:
return redact(payload.get("final_message", "")).strip()
return ""
@@ -596,6 +596,7 @@ class EvidenceStore:
self.conn.close()
def _migrate(self) -> None:
# Keep the legacy table name so existing databases and in-flight workers remain compatible.
self.conn.executescript(
"""
CREATE TABLE IF NOT EXISTS events (
@@ -688,8 +689,7 @@ class EvidenceStore:
"""
)
# Remove the pre-0.1.1 no-tools snapshot implementation. The real coding
# sidekick is a native Claude Code agent and stores no state in this DB.
# Remove the pre-0.1.1 snapshot implementation.
self.conn.executescript(
"""
DROP TABLE IF EXISTS sidekick_calls;
@@ -1041,7 +1041,7 @@ class EvidenceStore:
if row["memory_text"]
]
def start_sidekick(
def start_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1049,7 +1049,7 @@ class EvidenceStore:
agent_type: str,
context_chars: int,
) -> bool:
"""Record one native sidekick instance and whether context was first sent."""
"""Record one native subagent instance and whether context was first sent."""
with self.conn:
cursor = self.conn.execute(
"""INSERT OR IGNORE INTO sidekick_runs
@@ -1067,7 +1067,7 @@ class EvidenceStore:
)
return int(cursor.rowcount) > 0
def stop_sidekick(
def stop_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1222,7 +1222,7 @@ class EvidenceStore:
cursor = self.conn.execute(
f"DELETE FROM {table} WHERE {column} = ?", (repo_id,)
)
removed[table] = max(int(cursor.rowcount), 0)
removed["subagent_runs" if table == "sidekick_runs" else table] = max(int(cursor.rowcount), 0)
return removed
def status(self, repo_id: str) -> dict[str, Any]:
@@ -1238,7 +1238,7 @@ class EvidenceStore:
FROM operations WHERE repo_id = ? ORDER BY id DESC LIMIT 1""",
(repo_id,),
).fetchone()
last_sidekick = self.conn.execute(
last_subagent = self.conn.execute(
"""SELECT session_id, agent_id, agent_type, started_at, stopped_at,
context_chars
FROM sidekick_runs WHERE repo_id = ?
@@ -1250,9 +1250,9 @@ class EvidenceStore:
"events": count("events"),
"flushes": count("flushes"),
"retrievals": count("retrievals"),
"sidekick_runs": count("sidekick_runs"),
"subagent_runs": count("sidekick_runs"),
"last_operation": dict(last_operation) if last_operation else None,
"last_sidekick": dict(last_sidekick) if last_sidekick else None,
"last_subagent": dict(last_subagent) if last_subagent else None,
}
@@ -1324,7 +1324,7 @@ def tool_payload(hook_input: dict[str, Any], *, failed: bool | None = False) ->
"tool": name,
"failed": failed,
"duration_ms": hook_input.get("duration_ms"),
"agent_role": "sidekick" if hook_input.get("agent_id") else "main",
"agent_role": "subagent" if hook_input.get("agent_id") else "main",
}
if hook_input.get("agent_id"):
payload["agent_id"] = bounded(hook_input["agent_id"], 200)
@@ -1384,35 +1384,33 @@ def record_tool(
)
def record_sidekick_start(
store: EvidenceStore, hook_input: dict[str, Any], *, inject_context: bool = True
def record_subagent_start(
store: EvidenceStore, hook_input: dict[str, Any]
) -> str:
"""Record a native sidekick and reuse the main turn's retrieved memories."""
"""Record a native subagent and reuse the main turn's retrieved memories."""
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200)
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
context = combine_context(
format_context(store.injected_memories(session_id, repo.identity))
)
if not inject_context:
context = ""
first_start = store.start_sidekick(
first_start = store.start_subagent(
repo, session_id, agent_id, agent_type, len(context)
)
store.record_event(
repo,
session_id,
"sidekick_start",
"subagent_start",
{
"agent_id": agent_id,
"agent_type": agent_type,
"context_chars": len(context) if first_start else 0,
"worktree_root": bounded(repo.root, 2000),
"repo_root": bounded(repo.root, 2000),
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="start",
@@ -1422,14 +1420,14 @@ def record_sidekick_start(
return context if first_start else ""
def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
def record_subagent_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
agent_id = bounded(hook_input.get("agent_id", ""), 200)
final_message = redact(hook_input.get("last_assistant_message", "")).strip()
transcript_path = bounded(hook_input.get("agent_transcript_path", ""), 2000)
agent_id = store.stop_sidekick(
agent_id = store.stop_subagent(
repo,
session_id,
agent_id,
@@ -1440,7 +1438,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
store.record_event(
repo,
session_id,
"sidekick_stop",
"subagent_stop",
{
"agent_id": agent_id,
"agent_type": agent_type,
@@ -1449,7 +1447,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="stop",
@@ -1513,10 +1511,10 @@ def build_episode(
for e in events
if e["kind"] == "assistant_stop" and e["payload"].get("text")
]
sidekick_outcomes = [
subagent_outcomes = [
redact(e["payload"].get("final_message", "")).strip()
for e in events
if e["kind"] == "sidekick_stop" and e["payload"].get("final_message")
if e["kind"] in {"subagent_stop", "sidekick_stop"} and e["payload"].get("final_message")
]
tools = [
e["payload"] for e in events if e["kind"] in {"tool_result", "tool_failure"}
@@ -1597,7 +1595,7 @@ def build_episode(
}
)
pending_user_messages = []
elif event["kind"] == "sidekick_stop":
elif event["kind"] in {"subagent_stop", "sidekick_stop"}:
pass
extraction_messages.extend(pending_user_messages)
@@ -1613,7 +1611,7 @@ def build_episode(
"assistant_conclusion": conclusion,
"user_messages": prompts,
"assistant_outcomes": assistant_conclusions,
"sidekick_outcomes": sidekick_outcomes,
"subagent_outcomes": subagent_outcomes,
"extraction_messages": extraction_messages,
"files_read": read_paths[:50],
"files_modified": modified_paths[:50],
+7 -7
View File
@@ -15,8 +15,8 @@ import hook_runner # noqa: E402
import telemetry # noqa: E402
from memory_core import ( # noqa: E402
configure_harness,
record_sidekick_start,
record_sidekick_stop,
record_subagent_start,
record_subagent_stop,
record_tool,
)
@@ -24,8 +24,8 @@ configure_harness("codex", data_dir_name="codex-plugin", source_tag="codex_plugi
telemetry.init(harness="codex", source_tag="CODEX_PLUGIN")
def _sidekick_start(store, hook_input):
context = record_sidekick_start(store, hook_input)
def _subagent_start(store, hook_input):
context = record_subagent_start(store, hook_input)
if context:
return {
"hookSpecificOutput": {
@@ -35,8 +35,8 @@ def _sidekick_start(store, hook_input):
}
def _sidekick_stop(store, hook_input):
record_sidekick_stop(store, hook_input)
def _subagent_stop(store, hook_input):
record_subagent_stop(store, hook_input)
def _post_tool(store, payload):
@@ -59,6 +59,6 @@ if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "post-tool":
sys.argv[1] = "codex-post-tool"
hook_runner.entry_point(
extra_actions={"sidekick-start": _sidekick_start, "sidekick-stop": _sidekick_stop, "codex-post-tool": _post_tool},
extra_actions={"subagent-start": _subagent_start, "subagent-stop": _subagent_stop, "codex-post-tool": _post_tool},
automatic_flush_reasons={"session-end", "pre-compact"},
)
+2 -2
View File
@@ -27,14 +27,14 @@
"SubagentStart": [
{
"hooks": [
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" sidekick-start --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" subagent-start --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
]
}
],
"SubagentStop": [
{
"hooks": [
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" sidekick-stop --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
{ "type": "command", "command": "python3 \"${PLUGIN_ROOT}/hooks/adapter.py\" subagent-stop --plugin-data-dir \"${PLUGIN_DATA}\"", "timeout": 3 }
]
}
],
@@ -35,7 +35,7 @@ def test_codex_hooks_use_native_events_and_plugin_paths() -> None:
assert all(hook.get("timeout", 0) <= 3 for groups in hooks.values() for group in groups for hook in group["hooks"])
def test_codex_sidekick_records_lifecycle(tmp_path: Path) -> None:
def test_codex_subagent_records_lifecycle(tmp_path: Path) -> None:
adapter = HOST / "hooks" / "adapter.py"
payload = {
"session_id": "session-1",
@@ -45,8 +45,8 @@ def test_codex_sidekick_records_lifecycle(tmp_path: Path) -> None:
}
for action, extra in (
("sidekick-start", {}),
("sidekick-stop", {"last_assistant_message": "SIDEKICK_OK"}),
("subagent-start", {}),
("subagent-stop", {"last_assistant_message": "SUBAGENT_OK"}),
):
result = subprocess.run(
[sys.executable, str(adapter), action, "--plugin-data-dir", str(tmp_path / "data")],
@@ -65,7 +65,7 @@ def test_codex_sidekick_records_lifecycle(tmp_path: Path) -> None:
assert row is not None
assert row[0] == "agent-1"
assert row[1]
assert row[2] == "SIDEKICK_OK"
assert row[2] == "SUBAGENT_OK"
def test_codex_mcp_uses_host_relative_paths() -> None:
@@ -8,7 +8,6 @@
"license": "Apache-2.0",
"keywords": ["memory", "coding-agents", "continual-learning", "token-efficiency"],
"skills": "./skills/",
"agents": "./agents/",
"hooks": "./hooks/hooks.json",
"mcpServers": "./mcp.json",
"variables": {
@@ -1,16 +0,0 @@
---
name: sidekick
description: Coding agent with an isolated context. Delegate focused investigation, implementation, testing, debugging, or review work to it, then review its result.
model: inherit
---
You are Mem0's Cursor sidekick. Complete only the work the parent agent assigns.
Return a tested result the parent can review without repeating your investigation.
Always call `search_memories` before work that can depend on earlier decisions,
repository history, or user preferences. Inspect the relevant code and repository
rules, make requested edits, and run the smallest decisive checks.
Do not make adjacent improvements. Ask the parent one concise question only when
a material decision or unsafe ambiguity blocks progress. Otherwise proceed and
report the outcome, changed files, validation, and remaining risk.
@@ -11,10 +11,10 @@ import telemetry
from memory_core import (
EvidenceStore,
api_key,
configure_harness,
data_dir,
doctor,
forget_remote_repo,
configure_harness,
resolve_repo,
user_id,
)
@@ -32,7 +32,7 @@ def _print_status(value: dict) -> None:
)
print(
f"Used in this repository: {value['retrievals']} memories returned, "
f"{value['sidekick_runs']} sidekick runs"
f"{value['subagent_runs']} subagent runs"
)
if last:
item_label = ""
@@ -48,13 +48,13 @@ def _print_status(value: dict) -> None:
f"{'succeeded' if last['success'] else 'failed'} "
f"({last['duration_ms']:.1f} ms{item_label})"
)
sidekick = value.get("last_sidekick") or {}
if sidekick:
state = "finished" if sidekick.get("stopped_at") else "started"
subagent = value.get("last_subagent") or {}
if subagent:
state = "finished" if subagent.get("stopped_at") else "started"
print(
"Last sidekick: "
f"{state}, received {sidekick['context_chars']} characters of memory, "
f"agent {sidekick['agent_id']}"
"Last subagent: "
f"{state}, received {subagent['context_chars']} characters of memory, "
f"agent {subagent['agent_id']}"
)
+28 -30
View File
@@ -524,7 +524,7 @@ def _checkpoint_message(event: dict[str, Any]) -> str:
if text:
return text
return redact(payload.get("text", "")).strip()
if kind == "sidekick_stop":
if kind in {"subagent_stop", "sidekick_stop"}:
return redact(payload.get("final_message", "")).strip()
return ""
@@ -596,6 +596,7 @@ class EvidenceStore:
self.conn.close()
def _migrate(self) -> None:
# Keep the legacy table name so existing databases and in-flight workers remain compatible.
self.conn.executescript(
"""
CREATE TABLE IF NOT EXISTS events (
@@ -688,8 +689,7 @@ class EvidenceStore:
"""
)
# Remove the pre-0.1.1 no-tools snapshot implementation. The real coding
# sidekick is a native Claude Code agent and stores no state in this DB.
# Remove the pre-0.1.1 snapshot implementation.
self.conn.executescript(
"""
DROP TABLE IF EXISTS sidekick_calls;
@@ -1041,7 +1041,7 @@ class EvidenceStore:
if row["memory_text"]
]
def start_sidekick(
def start_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1049,7 +1049,7 @@ class EvidenceStore:
agent_type: str,
context_chars: int,
) -> bool:
"""Record one native sidekick instance and whether context was first sent."""
"""Record one native subagent instance and whether context was first sent."""
with self.conn:
cursor = self.conn.execute(
"""INSERT OR IGNORE INTO sidekick_runs
@@ -1067,7 +1067,7 @@ class EvidenceStore:
)
return int(cursor.rowcount) > 0
def stop_sidekick(
def stop_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1222,7 +1222,7 @@ class EvidenceStore:
cursor = self.conn.execute(
f"DELETE FROM {table} WHERE {column} = ?", (repo_id,)
)
removed[table] = max(int(cursor.rowcount), 0)
removed["subagent_runs" if table == "sidekick_runs" else table] = max(int(cursor.rowcount), 0)
return removed
def status(self, repo_id: str) -> dict[str, Any]:
@@ -1238,7 +1238,7 @@ class EvidenceStore:
FROM operations WHERE repo_id = ? ORDER BY id DESC LIMIT 1""",
(repo_id,),
).fetchone()
last_sidekick = self.conn.execute(
last_subagent = self.conn.execute(
"""SELECT session_id, agent_id, agent_type, started_at, stopped_at,
context_chars
FROM sidekick_runs WHERE repo_id = ?
@@ -1250,9 +1250,9 @@ class EvidenceStore:
"events": count("events"),
"flushes": count("flushes"),
"retrievals": count("retrievals"),
"sidekick_runs": count("sidekick_runs"),
"subagent_runs": count("sidekick_runs"),
"last_operation": dict(last_operation) if last_operation else None,
"last_sidekick": dict(last_sidekick) if last_sidekick else None,
"last_subagent": dict(last_subagent) if last_subagent else None,
}
@@ -1324,7 +1324,7 @@ def tool_payload(hook_input: dict[str, Any], *, failed: bool | None = False) ->
"tool": name,
"failed": failed,
"duration_ms": hook_input.get("duration_ms"),
"agent_role": "sidekick" if hook_input.get("agent_id") else "main",
"agent_role": "subagent" if hook_input.get("agent_id") else "main",
}
if hook_input.get("agent_id"):
payload["agent_id"] = bounded(hook_input["agent_id"], 200)
@@ -1384,35 +1384,33 @@ def record_tool(
)
def record_sidekick_start(
store: EvidenceStore, hook_input: dict[str, Any], *, inject_context: bool = True
def record_subagent_start(
store: EvidenceStore, hook_input: dict[str, Any]
) -> str:
"""Record a native sidekick and reuse the main turn's retrieved memories."""
"""Record a native subagent and reuse the main turn's retrieved memories."""
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200)
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
context = combine_context(
format_context(store.injected_memories(session_id, repo.identity))
)
if not inject_context:
context = ""
first_start = store.start_sidekick(
first_start = store.start_subagent(
repo, session_id, agent_id, agent_type, len(context)
)
store.record_event(
repo,
session_id,
"sidekick_start",
"subagent_start",
{
"agent_id": agent_id,
"agent_type": agent_type,
"context_chars": len(context) if first_start else 0,
"worktree_root": bounded(repo.root, 2000),
"repo_root": bounded(repo.root, 2000),
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="start",
@@ -1422,14 +1420,14 @@ def record_sidekick_start(
return context if first_start else ""
def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
def record_subagent_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
agent_id = bounded(hook_input.get("agent_id", ""), 200)
final_message = redact(hook_input.get("last_assistant_message", "")).strip()
transcript_path = bounded(hook_input.get("agent_transcript_path", ""), 2000)
agent_id = store.stop_sidekick(
agent_id = store.stop_subagent(
repo,
session_id,
agent_id,
@@ -1440,7 +1438,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
store.record_event(
repo,
session_id,
"sidekick_stop",
"subagent_stop",
{
"agent_id": agent_id,
"agent_type": agent_type,
@@ -1449,7 +1447,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="stop",
@@ -1513,10 +1511,10 @@ def build_episode(
for e in events
if e["kind"] == "assistant_stop" and e["payload"].get("text")
]
sidekick_outcomes = [
subagent_outcomes = [
redact(e["payload"].get("final_message", "")).strip()
for e in events
if e["kind"] == "sidekick_stop" and e["payload"].get("final_message")
if e["kind"] in {"subagent_stop", "sidekick_stop"} and e["payload"].get("final_message")
]
tools = [
e["payload"] for e in events if e["kind"] in {"tool_result", "tool_failure"}
@@ -1597,7 +1595,7 @@ def build_episode(
}
)
pending_user_messages = []
elif event["kind"] == "sidekick_stop":
elif event["kind"] in {"subagent_stop", "sidekick_stop"}:
pass
extraction_messages.extend(pending_user_messages)
@@ -1613,7 +1611,7 @@ def build_episode(
"assistant_conclusion": conclusion,
"user_messages": prompts,
"assistant_outcomes": assistant_conclusions,
"sidekick_outcomes": sidekick_outcomes,
"subagent_outcomes": subagent_outcomes,
"extraction_messages": extraction_messages,
"files_read": read_paths[:50],
"files_modified": modified_paths[:50],
@@ -19,8 +19,6 @@ import hook_runner # noqa: E402
import telemetry # noqa: E402
from memory_core import ( # noqa: E402
configure_harness,
record_sidekick_start,
record_sidekick_stop,
record_tool,
)
@@ -30,8 +28,6 @@ EVENTS = {
"postToolUse": "post-tool",
"postToolUseFailure": "post-tool-failure",
"afterAgentResponse": "assistant-stop",
"subagentStart": "sidekick-start",
"subagentStop": "sidekick-stop",
"stop": "stop",
"sessionEnd": "session-end",
"preCompact": "pre-compact",
@@ -52,10 +48,6 @@ def normalize(payload: dict, event: str) -> dict:
value.setdefault("last_assistant_message", value["text"])
if "summary" in value:
value.setdefault("last_assistant_message", value["summary"])
if "subagent_id" in value:
value.setdefault("agent_id", value["subagent_id"])
if "subagent_type" in value:
value.setdefault("agent_type", value["subagent_type"])
return {"action": EVENTS[event], "payload": value}
@@ -67,15 +59,6 @@ def _record_response(store, payload):
hook_runner.default_record_stop(store, payload)
def _record_sidekick_start(store, payload):
record_sidekick_start(store, payload, inject_context=False)
return {"permission": "allow"}
def _record_sidekick_stop(store, payload):
record_sidekick_stop(store, payload)
def main() -> int:
if len(sys.argv) != 2 or sys.argv[1] not in EVENTS:
return 2
@@ -100,8 +83,6 @@ def main() -> int:
extra_actions={
"post-tool-failure": _record_failure,
"assistant-stop": _record_response,
"sidekick-start": _record_sidekick_start,
"sidekick-stop": _record_sidekick_stop,
},
automatic_flush_reasons={"session-end", "pre-compact"},
)
@@ -119,8 +100,6 @@ def main() -> int:
}
if environment:
print(json.dumps({"env": environment}))
elif event == "subagentStart":
print(json.dumps({"permission": "allow"}))
return result
@@ -6,8 +6,6 @@
"postToolUse": [{ "command": "python3 \"${CURSOR_PLUGIN_ROOT}/hooks/adapter.py\" postToolUse", "timeout": 3 }],
"postToolUseFailure": [{ "command": "python3 \"${CURSOR_PLUGIN_ROOT}/hooks/adapter.py\" postToolUseFailure", "timeout": 3 }],
"afterAgentResponse": [{ "command": "python3 \"${CURSOR_PLUGIN_ROOT}/hooks/adapter.py\" afterAgentResponse", "timeout": 3 }],
"subagentStart": [{ "command": "python3 \"${CURSOR_PLUGIN_ROOT}/hooks/adapter.py\" subagentStart", "matcher": "^sidekick$", "timeout": 5 }],
"subagentStop": [{ "command": "python3 \"${CURSOR_PLUGIN_ROOT}/hooks/adapter.py\" subagentStop", "matcher": "^sidekick$", "timeout": 5 }],
"stop": [{ "command": "python3 \"${CURSOR_PLUGIN_ROOT}/hooks/adapter.py\" stop", "timeout": 3 }],
"preCompact": [{ "command": "python3 \"${CURSOR_PLUGIN_ROOT}/hooks/adapter.py\" preCompact", "timeout": 5 }],
"sessionEnd": [{ "command": "python3 \"${CURSOR_PLUGIN_ROOT}/hooks/adapter.py\" sessionEnd", "timeout": 5 }]
@@ -8,7 +8,6 @@
".cursor-plugin/plugin.json": ".cursor-plugin/plugin.json",
"hooks/hooks.json": "hooks/hooks.json",
"hooks/adapter.py": "hooks/adapter.py",
"agents/sidekick.md": "agents/sidekick.md",
"mcp.json": "mcp.json"
}
}
@@ -26,8 +26,6 @@ SPEC.loader.exec_module(adapter)
("postToolUse", "post-tool"),
("postToolUseFailure", "post-tool-failure"),
("afterAgentResponse", "assistant-stop"),
("subagentStart", "sidekick-start"),
("subagentStop", "sidekick-stop"),
("stop", "stop"),
("sessionEnd", "session-end"),
("preCompact", "pre-compact"),
@@ -57,48 +55,18 @@ def test_after_agent_response_does_not_return_internal_context(monkeypatch: pyte
assert adapter._record_response(object(), {}) is None
def test_normalizes_cursor_sidekick_fields() -> None:
started = adapter.normalize(
{
"subagent_id": "agent-1",
"subagent_type": "sidekick",
"parent_conversation_id": "parent-1",
},
"subagentStart",
)["payload"]
stopped = adapter.normalize({"summary": "done"}, "subagentStop")["payload"]
assert started["agent_id"] == "agent-1"
assert started["agent_type"] == "sidekick"
assert started["session_id"] == "parent-1"
assert stopped["last_assistant_message"] == "done"
def test_cursor_sidekick_records_lifecycle_without_blocking(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(adapter, "record_sidekick_start", lambda store, payload, *, inject_context: "" if not inject_context else pytest.fail("cannot inject context"))
stopped = []
monkeypatch.setattr(adapter, "record_sidekick_stop", lambda store, payload: stopped.append(payload))
assert adapter._record_sidekick_start(object(), {}) == {"permission": "allow"}
assert adapter._record_sidekick_stop(object(), {"summary": "done"}) is None
assert stopped == [{"summary": "done"}]
def test_cursor_hooks_use_native_flat_entries() -> None:
hooks = json.loads((HOST / "hooks" / "hooks.json").read_text(encoding="utf-8"))
assert hooks["version"] == 1
assert not {"subagentStart", "subagentStop"} & hooks["hooks"].keys()
assert set(hooks["hooks"]) >= {
"sessionStart",
"beforeSubmitPrompt",
"postToolUse",
"subagentStart",
"subagentStop",
"stop",
"sessionEnd",
}
assert hooks["hooks"]["subagentStart"][0]["matcher"] == "^sidekick$"
assert hooks["hooks"]["subagentStop"][0]["matcher"] == "^sidekick$"
assert all("hooks" not in entry for entries in hooks["hooks"].values() for entry in entries)
@@ -107,6 +75,7 @@ def test_native_cursor_bundle_is_self_contained(tmp_path: Path) -> None:
manifest = json.loads((root / ".cursor-plugin" / "plugin.json").read_text(encoding="utf-8"))
assert manifest["hooks"] == "./hooks/hooks.json"
assert "agents" not in manifest
assert (root / "hooks" / "adapter.py").is_file()
assert (root / "agents" / "sidekick.md").is_file()
assert not (root / "agents").exists()
assert not any(path.is_symlink() for path in root.rglob("*"))
+3 -1
View File
@@ -15,6 +15,8 @@ Unlike the local/file-based memory plugins in the ecosystem, Mem0 is a managed b
Current package version: `0.3.0`.
Sidekick is available only in the [Claude Code plugin](../claude-code-plugin/README.md#sonnet-sidekick-agent).
## How it works
A Cordis plugin is a module exporting `apply(ctx, config)`. This one waits for the Harness tool and system-prompt services, then uses the native extension points:
@@ -61,7 +63,7 @@ Cordis owns listener and tool cleanup when the plugin unmounts. Every automatic
```
5. Open http://127.0.0.1:3080 and ask the agent to remember something, then recall it in a later turn.
For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that base URL (defaults to `api.mem0.ai`). `host` is a Platform base-URL override — it is not a switch to self-hosted Mem0 OSS, whose server exposes a different API surface.
For a Mem0 Platform on-prem or dedicated deployment, point `config.host` at that base URL (defaults to `api.mem0.ai`). `host` overrides the Platform base URL. It does not support the self-hosted Mem0 OSS API.
## Configuration
@@ -1,18 +0,0 @@
---
name: sidekick
description: Coding subagent for focused implementation, investigation, testing, debugging, or review work.
whenToUse: Delegate a bounded engineering task that benefits from its own isolated context.
---
You are Mem0's coding sidekick. Complete only the bounded task the main agent
delegates to you and return a concise, self-contained result.
Search Mem0 before work that may depend on prior repository decisions or user
preferences. Inspect the relevant repository rules and code, make changes when
asked, and run the smallest decisive validation. Do not claim Git worktree
isolation: Kimi provides a separate context, while filesystem isolation depends
on the caller's environment.
Your final response must state the outcome, changed files, validation, and any
remaining risk. Do not commit, push, or open a pull request unless the caller
explicitly asks.
+8 -8
View File
@@ -11,10 +11,10 @@ import telemetry
from memory_core import (
EvidenceStore,
api_key,
configure_harness,
data_dir,
doctor,
forget_remote_repo,
configure_harness,
resolve_repo,
user_id,
)
@@ -32,7 +32,7 @@ def _print_status(value: dict) -> None:
)
print(
f"Used in this repository: {value['retrievals']} memories returned, "
f"{value['sidekick_runs']} sidekick runs"
f"{value['subagent_runs']} subagent runs"
)
if last:
item_label = ""
@@ -48,13 +48,13 @@ def _print_status(value: dict) -> None:
f"{'succeeded' if last['success'] else 'failed'} "
f"({last['duration_ms']:.1f} ms{item_label})"
)
sidekick = value.get("last_sidekick") or {}
if sidekick:
state = "finished" if sidekick.get("stopped_at") else "started"
subagent = value.get("last_subagent") or {}
if subagent:
state = "finished" if subagent.get("stopped_at") else "started"
print(
"Last sidekick: "
f"{state}, received {sidekick['context_chars']} characters of memory, "
f"agent {sidekick['agent_id']}"
"Last subagent: "
f"{state}, received {subagent['context_chars']} characters of memory, "
f"agent {subagent['agent_id']}"
)
+28 -30
View File
@@ -524,7 +524,7 @@ def _checkpoint_message(event: dict[str, Any]) -> str:
if text:
return text
return redact(payload.get("text", "")).strip()
if kind == "sidekick_stop":
if kind in {"subagent_stop", "sidekick_stop"}:
return redact(payload.get("final_message", "")).strip()
return ""
@@ -596,6 +596,7 @@ class EvidenceStore:
self.conn.close()
def _migrate(self) -> None:
# Keep the legacy table name so existing databases and in-flight workers remain compatible.
self.conn.executescript(
"""
CREATE TABLE IF NOT EXISTS events (
@@ -688,8 +689,7 @@ class EvidenceStore:
"""
)
# Remove the pre-0.1.1 no-tools snapshot implementation. The real coding
# sidekick is a native Claude Code agent and stores no state in this DB.
# Remove the pre-0.1.1 snapshot implementation.
self.conn.executescript(
"""
DROP TABLE IF EXISTS sidekick_calls;
@@ -1041,7 +1041,7 @@ class EvidenceStore:
if row["memory_text"]
]
def start_sidekick(
def start_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1049,7 +1049,7 @@ class EvidenceStore:
agent_type: str,
context_chars: int,
) -> bool:
"""Record one native sidekick instance and whether context was first sent."""
"""Record one native subagent instance and whether context was first sent."""
with self.conn:
cursor = self.conn.execute(
"""INSERT OR IGNORE INTO sidekick_runs
@@ -1067,7 +1067,7 @@ class EvidenceStore:
)
return int(cursor.rowcount) > 0
def stop_sidekick(
def stop_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1222,7 +1222,7 @@ class EvidenceStore:
cursor = self.conn.execute(
f"DELETE FROM {table} WHERE {column} = ?", (repo_id,)
)
removed[table] = max(int(cursor.rowcount), 0)
removed["subagent_runs" if table == "sidekick_runs" else table] = max(int(cursor.rowcount), 0)
return removed
def status(self, repo_id: str) -> dict[str, Any]:
@@ -1238,7 +1238,7 @@ class EvidenceStore:
FROM operations WHERE repo_id = ? ORDER BY id DESC LIMIT 1""",
(repo_id,),
).fetchone()
last_sidekick = self.conn.execute(
last_subagent = self.conn.execute(
"""SELECT session_id, agent_id, agent_type, started_at, stopped_at,
context_chars
FROM sidekick_runs WHERE repo_id = ?
@@ -1250,9 +1250,9 @@ class EvidenceStore:
"events": count("events"),
"flushes": count("flushes"),
"retrievals": count("retrievals"),
"sidekick_runs": count("sidekick_runs"),
"subagent_runs": count("sidekick_runs"),
"last_operation": dict(last_operation) if last_operation else None,
"last_sidekick": dict(last_sidekick) if last_sidekick else None,
"last_subagent": dict(last_subagent) if last_subagent else None,
}
@@ -1324,7 +1324,7 @@ def tool_payload(hook_input: dict[str, Any], *, failed: bool | None = False) ->
"tool": name,
"failed": failed,
"duration_ms": hook_input.get("duration_ms"),
"agent_role": "sidekick" if hook_input.get("agent_id") else "main",
"agent_role": "subagent" if hook_input.get("agent_id") else "main",
}
if hook_input.get("agent_id"):
payload["agent_id"] = bounded(hook_input["agent_id"], 200)
@@ -1384,35 +1384,33 @@ def record_tool(
)
def record_sidekick_start(
store: EvidenceStore, hook_input: dict[str, Any], *, inject_context: bool = True
def record_subagent_start(
store: EvidenceStore, hook_input: dict[str, Any]
) -> str:
"""Record a native sidekick and reuse the main turn's retrieved memories."""
"""Record a native subagent and reuse the main turn's retrieved memories."""
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200)
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
context = combine_context(
format_context(store.injected_memories(session_id, repo.identity))
)
if not inject_context:
context = ""
first_start = store.start_sidekick(
first_start = store.start_subagent(
repo, session_id, agent_id, agent_type, len(context)
)
store.record_event(
repo,
session_id,
"sidekick_start",
"subagent_start",
{
"agent_id": agent_id,
"agent_type": agent_type,
"context_chars": len(context) if first_start else 0,
"worktree_root": bounded(repo.root, 2000),
"repo_root": bounded(repo.root, 2000),
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="start",
@@ -1422,14 +1420,14 @@ def record_sidekick_start(
return context if first_start else ""
def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
def record_subagent_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
agent_id = bounded(hook_input.get("agent_id", ""), 200)
final_message = redact(hook_input.get("last_assistant_message", "")).strip()
transcript_path = bounded(hook_input.get("agent_transcript_path", ""), 2000)
agent_id = store.stop_sidekick(
agent_id = store.stop_subagent(
repo,
session_id,
agent_id,
@@ -1440,7 +1438,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
store.record_event(
repo,
session_id,
"sidekick_stop",
"subagent_stop",
{
"agent_id": agent_id,
"agent_type": agent_type,
@@ -1449,7 +1447,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="stop",
@@ -1513,10 +1511,10 @@ def build_episode(
for e in events
if e["kind"] == "assistant_stop" and e["payload"].get("text")
]
sidekick_outcomes = [
subagent_outcomes = [
redact(e["payload"].get("final_message", "")).strip()
for e in events
if e["kind"] == "sidekick_stop" and e["payload"].get("final_message")
if e["kind"] in {"subagent_stop", "sidekick_stop"} and e["payload"].get("final_message")
]
tools = [
e["payload"] for e in events if e["kind"] in {"tool_result", "tool_failure"}
@@ -1597,7 +1595,7 @@ def build_episode(
}
)
pending_user_messages = []
elif event["kind"] == "sidekick_stop":
elif event["kind"] in {"subagent_stop", "sidekick_stop"}:
pass
extraction_messages.extend(pending_user_messages)
@@ -1613,7 +1611,7 @@ def build_episode(
"assistant_conclusion": conclusion,
"user_messages": prompts,
"assistant_outcomes": assistant_conclusions,
"sidekick_outcomes": sidekick_outcomes,
"subagent_outcomes": subagent_outcomes,
"extraction_messages": extraction_messages,
"files_read": read_paths[:50],
"files_modified": modified_paths[:50],
+2 -20
View File
@@ -8,7 +8,6 @@ import io
import json
import os
import sys
import uuid
from pathlib import Path
HERE = Path(__file__).resolve()
@@ -18,7 +17,7 @@ sys.path.insert(0, str(CORE))
import hook_runner # noqa: E402
import telemetry # noqa: E402
from memory_core import configure_harness, record_sidekick_start, record_sidekick_stop, record_tool # noqa: E402
from memory_core import configure_harness, record_tool # noqa: E402
EVENTS = {
"SessionStart": ["session-start"],
@@ -28,8 +27,6 @@ EVENTS = {
"Stop": ["stop"],
"PreCompact": ["flush", "--reason", "pre-compact"],
"SessionEnd": ["flush", "--reason", "session-end"],
"SubagentStart": ["sidekick-start"],
"SubagentStop": ["sidekick-stop"],
}
@@ -93,8 +90,6 @@ def normalize(payload: dict) -> dict:
value.setdefault("tool_response", value["error"])
if "response" in value:
value.setdefault("last_assistant_message", value["response"])
if "agent_name" in value:
value.setdefault("agent_type", value["agent_name"])
if value.get("hook_event_name") == "Stop":
session = _session_dir(str(value.get("session_id") or ""))
transcript = session / "agents" / "main" / "wire.jsonl" if session else None
@@ -105,17 +100,6 @@ def normalize(payload: dict) -> dict:
return value
def _sidekick_start(store, payload):
payload = dict(payload)
payload.setdefault("agent_id", f"kimi-{uuid.uuid4().hex}")
context = record_sidekick_start(store, payload)
return {"hookSpecificOutput": {"additionalContext": context}} if context else None
def _sidekick_stop(store, payload):
record_sidekick_stop(store, payload)
def main() -> int:
if len(sys.argv) != 2 or sys.argv[1] not in EVENTS:
return 2
@@ -133,12 +117,10 @@ def main() -> int:
result = hook_runner.run(
extra_actions={
"post-tool-failure": lambda store, payload: record_tool(store, payload, failed=True),
"sidekick-start": _sidekick_start,
"sidekick-stop": _sidekick_stop,
},
automatic_flush_reasons={"session-end", "pre-compact"},
)
if event in {"UserPromptSubmit", "SubagentStart"} and (raw_output := output.getvalue().strip()):
if event == "UserPromptSubmit" and (raw_output := output.getvalue().strip()):
parsed = json.loads(raw_output)
context = parsed.get("hookSpecificOutput", {}).get("additionalContext", "")
if context:
+1 -4
View File
@@ -11,7 +11,6 @@
"shortDescription": "Cross-session memory for Kimi Code"
},
"skills": "./skills/",
"agents": "./agents/",
"mcpServers": {
"mem0": {
"command": "python3",
@@ -25,8 +24,6 @@
{ "event": "PostToolUseFailure", "matcher": ".*", "command": "python3 ./hooks/adapter.py PostToolUseFailure", "timeout": 3 },
{ "event": "Stop", "command": "python3 ./hooks/adapter.py Stop", "timeout": 3 },
{ "event": "PreCompact", "command": "python3 ./hooks/adapter.py PreCompact", "timeout": 5 },
{ "event": "SessionEnd", "command": "python3 ./hooks/adapter.py SessionEnd", "timeout": 5 },
{ "event": "SubagentStart", "matcher": "^sidekick$", "command": "python3 ./hooks/adapter.py SubagentStart", "timeout": 5 },
{ "event": "SubagentStop", "matcher": "^sidekick$", "command": "python3 ./hooks/adapter.py SubagentStop", "timeout": 5 }
{ "event": "SessionEnd", "command": "python3 ./hooks/adapter.py SessionEnd", "timeout": 5 }
]
}
+1 -2
View File
@@ -6,8 +6,7 @@
"pluginRoot": "${KIMI_PLUGIN_ROOT}",
"files": {
"kimi.plugin.json": "kimi.plugin.json",
"hooks/adapter.py": "hooks/adapter.py",
"agents/sidekick.md": "agents/sidekick.md"
"hooks/adapter.py": "hooks/adapter.py"
}
}
}
+3 -56
View File
@@ -118,73 +118,20 @@ def test_stop_reads_main_response_from_kimi_session_transcript(tmp_path: Path, m
assert value["transcript_path"] == str(transcript)
def test_repeated_sidekick_invocations_get_distinct_run_ids(tmp_path: Path) -> None:
store = adapter.hook_runner.EvidenceStore(tmp_path / "evidence.sqlite3")
payload = {
"hook_event_name": "SubagentStart",
"session_id": "session_abc",
"session_title": "Fix Kimi capture",
"client_type": "kimi_code_cli",
"cwd": str(tmp_path),
"agent_name": "sidekick",
"prompt": "Fix the adapter",
}
try:
adapter._sidekick_start(store, adapter.normalize({**payload, "agent_id": "first"}))
adapter._sidekick_start(store, adapter.normalize({**payload, "agent_id": "second"}))
adapter._sidekick_stop(
store,
adapter.normalize(
{
**payload,
"hook_event_name": "SubagentStop",
"response": "First run complete.",
"agent_id": "first",
}
),
)
adapter._sidekick_stop(
store,
adapter.normalize(
{
**payload,
"hook_event_name": "SubagentStop",
"response": "Second run complete.",
"agent_id": "second",
}
),
)
rows = store.conn.execute(
"SELECT agent_id, stopped_at, final_message FROM sidekick_runs ORDER BY started_at, agent_id"
).fetchall()
finally:
store.close()
assert len(rows) == 2
assert rows[0]["agent_id"] != rows[1]["agent_id"]
assert all(row["stopped_at"] for row in rows)
assert {row["agent_id"]: row["final_message"] for row in rows} == {
"first": "First run complete.", "second": "Second run complete."
}
def test_native_kimi_bundle_uses_inline_native_contract(tmp_path: Path) -> None:
root = build("kimi", "native", tmp_path / "kimi")
manifest = json.loads((root / "kimi.plugin.json").read_text(encoding="utf-8"))
assert manifest["skills"] == "./skills/"
assert manifest["agents"] == "./agents/"
assert "agents" not in manifest
assert manifest["mcpServers"]["mem0"]["args"] == ["./core/mcp_server.py"]
assert not {"SubagentStart", "SubagentStop"} & {hook["event"] for hook in manifest["hooks"]}
assert {hook["event"] for hook in manifest["hooks"]} >= {
"SessionStart",
"UserPromptSubmit",
"PostToolUse",
"SubagentStart",
"SubagentStop",
"PreCompact",
"SessionEnd",
}
assert (root / "agents" / "sidekick.md").is_file()
assert not (root / "agents").exists()
assert not any(path.is_symlink() for path in root.rglob("*"))
@@ -11,10 +11,10 @@ import telemetry
from memory_core import (
EvidenceStore,
api_key,
configure_harness,
data_dir,
doctor,
forget_remote_repo,
configure_harness,
resolve_repo,
user_id,
)
@@ -32,7 +32,7 @@ def _print_status(value: dict) -> None:
)
print(
f"Used in this repository: {value['retrievals']} memories returned, "
f"{value['sidekick_runs']} sidekick runs"
f"{value['subagent_runs']} subagent runs"
)
if last:
item_label = ""
@@ -48,13 +48,13 @@ def _print_status(value: dict) -> None:
f"{'succeeded' if last['success'] else 'failed'} "
f"({last['duration_ms']:.1f} ms{item_label})"
)
sidekick = value.get("last_sidekick") or {}
if sidekick:
state = "finished" if sidekick.get("stopped_at") else "started"
subagent = value.get("last_subagent") or {}
if subagent:
state = "finished" if subagent.get("stopped_at") else "started"
print(
"Last sidekick: "
f"{state}, received {sidekick['context_chars']} characters of memory, "
f"agent {sidekick['agent_id']}"
"Last subagent: "
f"{state}, received {subagent['context_chars']} characters of memory, "
f"agent {subagent['agent_id']}"
)
@@ -524,7 +524,7 @@ def _checkpoint_message(event: dict[str, Any]) -> str:
if text:
return text
return redact(payload.get("text", "")).strip()
if kind == "sidekick_stop":
if kind in {"subagent_stop", "sidekick_stop"}:
return redact(payload.get("final_message", "")).strip()
return ""
@@ -596,6 +596,7 @@ class EvidenceStore:
self.conn.close()
def _migrate(self) -> None:
# Keep the legacy table name so existing databases and in-flight workers remain compatible.
self.conn.executescript(
"""
CREATE TABLE IF NOT EXISTS events (
@@ -688,8 +689,7 @@ class EvidenceStore:
"""
)
# Remove the pre-0.1.1 no-tools snapshot implementation. The real coding
# sidekick is a native Claude Code agent and stores no state in this DB.
# Remove the pre-0.1.1 snapshot implementation.
self.conn.executescript(
"""
DROP TABLE IF EXISTS sidekick_calls;
@@ -1041,7 +1041,7 @@ class EvidenceStore:
if row["memory_text"]
]
def start_sidekick(
def start_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1049,7 +1049,7 @@ class EvidenceStore:
agent_type: str,
context_chars: int,
) -> bool:
"""Record one native sidekick instance and whether context was first sent."""
"""Record one native subagent instance and whether context was first sent."""
with self.conn:
cursor = self.conn.execute(
"""INSERT OR IGNORE INTO sidekick_runs
@@ -1067,7 +1067,7 @@ class EvidenceStore:
)
return int(cursor.rowcount) > 0
def stop_sidekick(
def stop_subagent(
self,
repo: RepoContext,
session_id: str,
@@ -1222,7 +1222,7 @@ class EvidenceStore:
cursor = self.conn.execute(
f"DELETE FROM {table} WHERE {column} = ?", (repo_id,)
)
removed[table] = max(int(cursor.rowcount), 0)
removed["subagent_runs" if table == "sidekick_runs" else table] = max(int(cursor.rowcount), 0)
return removed
def status(self, repo_id: str) -> dict[str, Any]:
@@ -1238,7 +1238,7 @@ class EvidenceStore:
FROM operations WHERE repo_id = ? ORDER BY id DESC LIMIT 1""",
(repo_id,),
).fetchone()
last_sidekick = self.conn.execute(
last_subagent = self.conn.execute(
"""SELECT session_id, agent_id, agent_type, started_at, stopped_at,
context_chars
FROM sidekick_runs WHERE repo_id = ?
@@ -1250,9 +1250,9 @@ class EvidenceStore:
"events": count("events"),
"flushes": count("flushes"),
"retrievals": count("retrievals"),
"sidekick_runs": count("sidekick_runs"),
"subagent_runs": count("sidekick_runs"),
"last_operation": dict(last_operation) if last_operation else None,
"last_sidekick": dict(last_sidekick) if last_sidekick else None,
"last_subagent": dict(last_subagent) if last_subagent else None,
}
@@ -1324,7 +1324,7 @@ def tool_payload(hook_input: dict[str, Any], *, failed: bool | None = False) ->
"tool": name,
"failed": failed,
"duration_ms": hook_input.get("duration_ms"),
"agent_role": "sidekick" if hook_input.get("agent_id") else "main",
"agent_role": "subagent" if hook_input.get("agent_id") else "main",
}
if hook_input.get("agent_id"):
payload["agent_id"] = bounded(hook_input["agent_id"], 200)
@@ -1384,35 +1384,33 @@ def record_tool(
)
def record_sidekick_start(
store: EvidenceStore, hook_input: dict[str, Any], *, inject_context: bool = True
def record_subagent_start(
store: EvidenceStore, hook_input: dict[str, Any]
) -> str:
"""Record a native sidekick and reuse the main turn's retrieved memories."""
"""Record a native subagent and reuse the main turn's retrieved memories."""
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200)
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
context = combine_context(
format_context(store.injected_memories(session_id, repo.identity))
)
if not inject_context:
context = ""
first_start = store.start_sidekick(
first_start = store.start_subagent(
repo, session_id, agent_id, agent_type, len(context)
)
store.record_event(
repo,
session_id,
"sidekick_start",
"subagent_start",
{
"agent_id": agent_id,
"agent_type": agent_type,
"context_chars": len(context) if first_start else 0,
"worktree_root": bounded(repo.root, 2000),
"repo_root": bounded(repo.root, 2000),
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="start",
@@ -1422,14 +1420,14 @@ def record_sidekick_start(
return context if first_start else ""
def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
def record_subagent_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> None:
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
agent_type = bounded(hook_input.get("agent_type", "unknown-agent"), 200)
agent_id = bounded(hook_input.get("agent_id", ""), 200)
final_message = redact(hook_input.get("last_assistant_message", "")).strip()
transcript_path = bounded(hook_input.get("agent_transcript_path", ""), 2000)
agent_id = store.stop_sidekick(
agent_id = store.stop_subagent(
repo,
session_id,
agent_id,
@@ -1440,7 +1438,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
store.record_event(
repo,
session_id,
"sidekick_stop",
"subagent_stop",
{
"agent_id": agent_id,
"agent_type": agent_type,
@@ -1449,7 +1447,7 @@ def record_sidekick_stop(store: EvidenceStore, hook_input: dict[str, Any]) -> No
},
)
telemetry.record(
"sidekick",
"subagent",
repo=repo,
session_id=session_id,
phase="stop",
@@ -1513,10 +1511,10 @@ def build_episode(
for e in events
if e["kind"] == "assistant_stop" and e["payload"].get("text")
]
sidekick_outcomes = [
subagent_outcomes = [
redact(e["payload"].get("final_message", "")).strip()
for e in events
if e["kind"] == "sidekick_stop" and e["payload"].get("final_message")
if e["kind"] in {"subagent_stop", "sidekick_stop"} and e["payload"].get("final_message")
]
tools = [
e["payload"] for e in events if e["kind"] in {"tool_result", "tool_failure"}
@@ -1597,7 +1595,7 @@ def build_episode(
}
)
pending_user_messages = []
elif event["kind"] == "sidekick_stop":
elif event["kind"] in {"subagent_stop", "sidekick_stop"}:
pass
extraction_messages.extend(pending_user_messages)
@@ -1613,7 +1611,7 @@ def build_episode(
"assistant_conclusion": conclusion,
"user_messages": prompts,
"assistant_outcomes": assistant_conclusions,
"sidekick_outcomes": sidekick_outcomes,
"subagent_outcomes": subagent_outcomes,
"extraction_messages": extraction_messages,
"files_read": read_paths[:50],
"files_modified": modified_paths[:50],
+24 -22
View File
@@ -2,12 +2,14 @@
Long-term memory for [OpenClaw](https://github.com/openclaw/openclaw) agents, powered by [Mem0](https://mem0.ai).
Your agent forgets everything between sessions. This plugin fixes that — it stores conversations, extracts what matters, and brings it back when relevant.
Your agent forgets everything between sessions. This plugin stores conversations and recalls relevant memories.
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.1.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.
Sidekick is available only in the [Claude Code plugin](../claude-code-plugin/README.md#sonnet-sidekick-agent).
## Requirements
Check your OpenClaw version:
@@ -29,7 +31,7 @@ If you're an AI agent setting up Mem0 autonomously (no human dashboard signup),
mem0 init --agent --json
```
No email, no OTP, no browser. The key is written to your Mem0 CLI config and exported as `MEM0_API_KEY` — pass it to `openclaw mem0 init --api-key $MEM0_API_KEY --user-id <id>` to wire OpenClaw up immediately. The human owner can later run `mem0 init --email <their-email>` to claim ownership: memories transfer, same API key keeps working, no agent disruption.
No email, no OTP, no browser. The key is written to your Mem0 CLI config and exported as `MEM0_API_KEY`. Pass it to `openclaw mem0 init --api-key $MEM0_API_KEY --user-id <id>` to wire OpenClaw up immediately. The human owner can later run `mem0 init --email <their-email>` to claim ownership: memories transfer, same API key keeps working, no agent disruption.
Humans should follow the Quick Start below.
@@ -95,9 +97,9 @@ openclaw plugins update openclaw-mem0
### Open-Source (Self-hosted)
No Mem0 key needed. Vectors are stored locally in SQLite at `~/.mem0/vector_store.db` — no external database required.
No Mem0 key needed. Vectors are stored locally in SQLite at `~/.mem0/vector_store.db`. No external database is required.
Defaults: `text-embedding-3-small` (OpenAI) for embeddings, `gpt-5-mini` (OpenAI) for fact extraction — requires `OPENAI_API_KEY`. For a fully local setup, use Ollama for both LLM and embeddings.
Defaults: `text-embedding-3-small` (OpenAI) for embeddings, `gpt-5-mini` (OpenAI) for fact extraction. Both require `OPENAI_API_KEY`. For a fully local setup, use Ollama for both LLM and embeddings.
#### Interactive Setup (Recommended)
@@ -108,16 +110,16 @@ openclaw mem0 init --mode open-source
```
The wizard walks you through:
1. **LLM provider** — OpenAI (`gpt-5-mini`), Ollama (`llama3.1:8b`, local), or Anthropic (`claude-sonnet-4-5-20250514`)
2. **Embedding provider** — OpenAI (`text-embedding-3-small`) or Ollama (`nomic-embed-text`, local)
3. **Vector store** — Qdrant (`http://localhost:6333`) or PGVector (PostgreSQL)
4. **User ID** — your memory namespace identifier
1. **LLM provider**: OpenAI (`gpt-5-mini`), Ollama (`llama3.1:8b`, local), or Anthropic (`claude-sonnet-4-5-20250514`)
2. **Embedding provider**: OpenAI (`text-embedding-3-small`) or Ollama (`nomic-embed-text`, local)
3. **Vector store**: Qdrant (`http://localhost:6333`) or PGVector (PostgreSQL)
4. **User ID**: your memory namespace identifier
Each step tests connectivity (Ollama, Qdrant, PGVector) before proceeding.
#### Non-Interactive Setup
For CI/CD, scripts, or agent-driven setup — pass all options as flags:
For automated setup, pass all options as flags:
```bash
# Fully local with Ollama + Qdrant
@@ -166,7 +168,7 @@ openclaw mem0 init --mode open-source --oss-llm ollama --oss-embedder ollama --o
#### Manual Config
Minimal config — uses OpenAI defaults:
Minimal config using OpenAI defaults:
```json5
{
@@ -213,8 +215,8 @@ All `oss` fields are optional. See the [Mem0 OSS docs](https://docs.mem0.ai/open
Enabled automatically during `openclaw mem0 init`. The agent controls memory through two skills:
- **Triage** — Extracts durable facts from conversations using a structured protocol. Categories, importance gates, and domain overlays control what gets stored.
- **Recall** — Before each turn, rewrites the user message into search queries, retrieves relevant memories with reranking, and injects them into context.
- **Triage**: Extracts durable facts from conversations using a structured protocol. Categories, importance gates, and domain overlays control what gets stored.
- **Recall**: Before each turn, rewrites the user message into search queries, retrieves relevant memories with reranking, and injects them into context.
When skills mode is active, the skills handle memory operations. `autoRecall` and `autoCapture` remain `true` by default alongside skills mode. The built-in `session-memory` hook is disabled to avoid conflicts.
@@ -222,8 +224,8 @@ When skills mode is active, the skills handle memory operations. `autoRecall` an
The plugin also registers `autoRecall` and `autoCapture` when their flags are enabled (both default to `true`), including alongside skills mode:
- **Auto-Recall** — Before the agent responds, the plugin searches Mem0 for relevant memories and injects them into context.
- **Auto-Capture** — After the agent responds, the conversation is filtered through a noise-removal pipeline and sent to Mem0. New facts get stored, stale ones updated, duplicates merged.
- **Auto-Recall**: Before the agent responds, the plugin searches Mem0 for relevant memories and injects them into context.
- **Auto-Capture**: After the agent responds, the conversation is filtered through a noise-removal pipeline and sent to Mem0. New facts get stored, stale ones updated, duplicates merged.
Automatic capture selects a recent-message window and earlier assistant summaries, removes injected context and noise, and redacts secrets. Selected message text is no longer cut off at 2,000 characters. This preserves full redacted text for selected messages, not every message in the session. Capture skips non-interactive triggers, subagent sessions, and turns that already used memory mutation tools.
@@ -231,8 +233,8 @@ Set `autoRecall: false` or `autoCapture: false` to disable these automatic hooks
### Memory Scopes
- **Session (short-term)** — Scoped to the current conversation via `run_id`. Recalled alongside long-term memories.
- **User (long-term)** — Persistent across all sessions. Default for `memory_add`.
- **Session (short-term)**: Scoped to the current conversation via `run_id`. Recalled alongside long-term memories.
- **User (long-term)**: Persistent across all sessions. Default for `memory_add`.
### Multi-Agent Isolation
@@ -320,7 +322,7 @@ Enabled by default during `openclaw mem0 init`. `autoRecall` and `autoCapture` a
| Key | Type | Default | Description |
| --- | ---- | ------- | ----------- |
| `apiKey` | `string` | — | **Required.** Mem0 API key (supports `${MEM0_API_KEY}`) |
| `apiKey` | `string` | Not set | **Required.** Mem0 API key (supports `${MEM0_API_KEY}`) |
| `customInstructions` | `string` | *(built-in)* | Custom extraction rules |
| `customCategories` | `object` | *(12 defaults)* | Category name to description map |
@@ -332,12 +334,12 @@ All fields optional. Defaults: `text-embedding-3-small` embeddings, local SQLite
| --- | ---- | ------- | ----------- |
| `customPrompt` | `string` | *(built-in)* | Extraction prompt |
| `oss.embedder.provider` | `string` | `"openai"` | Embedding provider |
| `oss.embedder.config` | `object` | — | Provider config (`apiKey`, `model`, `baseURL`) |
| `oss.embedder.config` | `object` | Not set | Provider config (`apiKey`, `model`, `baseURL`) |
| `oss.vectorStore.provider` | `string` | `"memory"` | Vector store provider (see list above) |
| `oss.vectorStore.config` | `object` | — | Provider config (`host`, `port`, `collectionName`, `dbPath`) |
| `oss.vectorStore.config` | `object` | Not set | Provider config (`host`, `port`, `collectionName`, `dbPath`) |
| `oss.llm.provider` | `string` | `"openai"` | LLM provider |
| `oss.llm.config` | `object` | — | Provider config (`apiKey`, `model`, `baseURL`) |
| `oss.historyDbPath` | `string` | — | SQLite path for edit history |
| `oss.llm.config` | `object` | Not set | Provider config (`apiKey`, `model`, `baseURL`) |
| `oss.historyDbPath` | `string` | Not set | SQLite path for edit history |
## Privacy & Security
@@ -347,7 +349,7 @@ All fields optional. Defaults: `text-embedding-3-small` embeddings, local SQLite
|------|----------------|-------------------|
| **Platform** | Conversations sent to `api.mem0.ai` for memory extraction and retrieval | `MEM0_API_KEY` |
| **Open-Source (OpenAI)** | LLM/embedding calls to OpenAI API; vectors stored locally at `~/.mem0/vector_store.db` | `OPENAI_API_KEY` |
| **Open-Source (Ollama)** | Fully local — LLM, embeddings, and vectors all on your machine | None |
| **Open-Source (Ollama)** | LLM, embeddings, and vectors run locally | None |
### Credential Storage
+9 -7
View File
@@ -4,15 +4,17 @@ Persistent memory for [OpenCode](https://opencode.ai). Your agent remembers deci
Current package version: `0.3.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).
Sidekick is available only in the [Claude Code plugin](../claude-code-plugin/README.md#sonnet-sidekick-agent).
## Install
```bash
opencode plugin @mem0/opencode-plugin
```
This adds the plugin to your `~/.config/opencode/opencode.json`. The plugin registers its memory tools and skills itself — there is no MCP server to configure.
This adds the plugin to your `~/.config/opencode/opencode.json`. The plugin registers its memory tools and skills. No MCP server configuration is needed.
**Or let your agent do it** — paste this into OpenCode:
**Or let your agent do it**: paste this into OpenCode:
```
Install @mem0/opencode-plugin by following https://raw.githubusercontent.com/mem0ai/mem0/main/integrations/opencode-plugin/README.md
@@ -30,17 +32,17 @@ Restart OpenCode.
| Component | Description |
|-----------|-------------|
| **10 Native Memory Tools** | `add_memory`, `search_memories`, `get_memories`, `update_memory`, `delete_memory`, and more — registered as OpenCode tools, backed by the `mem0ai` SDK (no MCP server required) |
| **10 Native Memory Tools** | `add_memory`, `search_memories`, `get_memories`, `update_memory`, `delete_memory`, and more, backed by the `mem0ai` SDK |
| **Lifecycle Hooks** | Auto-search on session start and every prompt, error memory lookup, compaction context, secret redaction |
| **7 Skills** | `/mem0-remember`, `/mem0-tour`, `/mem0-search`, `/mem0-status`, `/mem0-scope`, `/mem0-forget`, `/mem0-context-loader` — discovered in place from the plugin via OpenCode's `skills.paths` |
| **7 Skills** | `/mem0-remember`, `/mem0-tour`, `/mem0-search`, `/mem0-status`, `/mem0-scope`, `/mem0-forget`, `/mem0-context-loader`. Discovered through OpenCode's `skills.paths` |
## Hooks
Pure TypeScript — no Python, no shell scripts. Memory operations are native OpenCode tools backed by the [mem0ai](https://www.npmjs.com/package/mem0ai) SDK directly.
Written in TypeScript. Memory operations are native OpenCode tools backed by the [mem0ai](https://www.npmjs.com/package/mem0ai) SDK directly.
| Hook | Event | What it does |
|------|-------|-------------|
| **Config** | `config` | Registers the `/mem0-*` slash commands (via `config.command`) and adds the plugin's own `opencode-skills/` dir to OpenCode's `skills.paths` for in-place skill discovery — no copying into `~/.config/opencode/skills` |
| **Config** | `config` | Registers the `/mem0-*` slash commands (via `config.command`) and adds the plugin's own `opencode-skills/` dir to OpenCode's `skills.paths` for skill discovery without copying files |
| **Chat message** | `chat.message` | Loads prior memories on session start, searches relevant memories before each prompt, auto-captures learnings periodically |
| **Pre-tool** | `tool.execute.before` | Blocks MEMORY.md writes, steering them to the `add_memory` tool |
| **Post-tool** | `tool.execute.after` | Scans bash errors and pre-fetches related memories |
@@ -81,7 +83,7 @@ scope (used when none is passed) with the `/mem0-scope` skill:
```
The default persists in `~/.mem0/settings.json` (`default_scope`) and is read
fresh on each memory operation, so a change applies immediately — no restart.
fresh on each memory operation, so changes apply without a restart.
`delete_all_memories` always requires an explicit `scope="global"` to delete
user-wide, so changing the default can't trigger a cross-project wipe.
+9 -7
View File
@@ -6,15 +6,17 @@ This extension gives Pi Agent long-term memory that persists across sessions, pr
Current package version: `0.3.0`. Shared redaction and lifecycle utilities come from [agent-plugin-core](../agent-plugin-core/README.md); Pi keeps its own tools and scopes.
Sidekick is available only in the [Claude Code plugin](../claude-code-plugin/README.md#sonnet-sidekick-agent).
## Features
- **Automatic memory capture** — learns from every conversation (both user and assistant messages)
- **Semantic search** — find memories by meaning, not just keywords
- **Scoped memory** — project, session, or global scope
- **Monorepo-aware** — uses git root for project detection, consistent app_id across subdirectories
- **Confirmation dialogs** — destructive commands ask before acting
- **6 slash commands** — essential memory management from the command line
- **Agent tool** — `mem0_memory` tool lets the agent search and store memories autonomously
- **Automatic memory capture**: learns from every conversation (both user and assistant messages)
- **Semantic search**: find memories by meaning, not just keywords
- **Scoped memory**: project, session, or global scope
- **Monorepo-aware**: uses git root for project detection, consistent app_id across subdirectories
- **Confirmation dialogs**: destructive commands ask before acting
- **6 slash commands**: essential memory management from the command line
- **Agent tool**: `mem0_memory` tool lets the agent search and store memories autonomously
## Setup