Merge remote-tracking branch 'origin/main' into feat/session-handoff-plugins
# Conflicts: # docs/llms.txt # integrations/agent-plugin-core/tests/test_build.py # integrations/antigravity-plugin/agents/sidekick/agent.md # integrations/cursor-plugin/agents/sidekick.md # integrations/kimi-plugin/agents/sidekick.md # integrations/opencode-plugin/README.md # integrations/pi-agent-plugin/README.md
This commit is contained in:
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user