refactor(integrations): shared agent plugin runtimes and native adapters (#7203)

This commit is contained in:
Kartik
2026-09-08 23:32:25 +05:30
committed by GitHub
parent dae67f74f5
commit 73e7b8763a
369 changed files with 31056 additions and 20464 deletions
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.3.0",
"version": "0.3.1",
"description": "Cross-session memory and token savings for coding agents.",
"author": {
"name": "Mem0"
@@ -1,11 +0,0 @@
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.venv/
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
flush-worker.log
plugin-errors.log
pending/
+12 -7
View File
@@ -34,10 +34,11 @@ To remove:
claude plugin uninstall mem0@mem0-plugins
```
For local development, load the current checkout directly:
For local development, verify and load the self-contained plugin directory:
```bash
claude --plugin-dir .
python3 integrations/agent-plugin-core/build/build.py claude-code --kind native --check
claude --plugin-dir integrations/claude-code-plugin
```
## How it works
@@ -104,7 +105,11 @@ Categories for `--category`: `project_knowledge`, `decisions_and_constraints`, `
| `dir` | Project memory from the current directory (and children), plus your preferences |
| `mine` | Your personal preferences only |
Set the default with the `search_scope` setting or `MEM0_CODE_SEARCH_SCOPE`. Pass `--run-id <session-id>` to see only what one specific session recorded.
Set the default with the `search_scope` setting or `MEM0_CODE_SEARCH_SCOPE`. Search spans earlier sessions without a session ID; `run_id` remains internal metadata.
New Git repository memories use a hash of the remote identity in `agent_id`. Searches also include the previous unhashed ID under the same repository `app_id`, so shared memories remain available after upgrading. Older IDs retain their original limitation: matching owner/repository names on different Git hosts share that legacy namespace. Local folders keep their path-based namespaces.
Explicit shared-memory deletion with `--include-project-memory` covers both repository IDs. Default deletion preserves shared memories.
## Settings
@@ -181,10 +186,10 @@ Breaking update. Memories carry over, most local config does not.
## Development checks
Run from `integrations/claude-code-plugin/`:
Run from the repository root:
```bash
python3 -m pytest tests -q
python3 -m ruff check .
claude plugin validate --strict .
python3 -m pytest integrations/claude-code-plugin/tests -q --ignore=integrations/claude-code-plugin/tests/integration
python3 -m ruff check integrations/agent-plugin-core/python integrations/claude-code-plugin
claude plugin validate --strict integrations/claude-code-plugin
```
@@ -3,367 +3,52 @@
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "core"))
_here = Path(__file__).resolve()
_bundled_core = _here.parents[2] / "core"
_core_dir = _bundled_core if (_bundled_core / "memory_core.py").is_file() else _bundled_core / "python"
sys.path.insert(0, str(_core_dir))
sys.path.insert(0, str(_here.parent))
import telemetry
from memory_core import (
EvidenceStore,
api_key,
cache_plugin_api_key,
checkpoint_session,
clear_stale_api_key_cache,
data_dir,
detached_process_kwargs,
format_context,
record_session_start,
import telemetry # noqa: E402
from memory_core import ( # noqa: E402
configure_harness,
record_sidekick_start,
record_sidekick_stop,
record_stop,
record_tool,
record_user_prompt,
search_memories,
)
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")
STALE_RUNNING_SECONDS = 300
PENDING_EXPIRY_SECONDS = 7 * 24 * 60 * 60
PENDING_LAUNCH_LIMIT = 5
def _sidekick_start(store, hook_input):
context = record_sidekick_start(store, hook_input)
if context:
return {
"hookSpecificOutput": {
"hookEventName": "SubagentStart",
"additionalContext": context,
},
}
def read_hook_input() -> dict:
try:
value = json.load(sys.stdin)
return value if isinstance(value, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
def first_prompt_memory_output(store: EvidenceStore, hook_input: dict) -> dict:
"""Search once before Claude handles the first prompt in a session."""
repo, session_id, prompt, is_first_prompt = record_user_prompt(store, hook_input)
if not is_first_prompt:
return {}
try:
minimum_query_chars = int(os.environ.get("MEM0_CODE_MIN_QUERY_CHARS", "20"))
except ValueError:
minimum_query_chars = 20
if len(prompt.strip()) < max(minimum_query_chars, 1):
return {}
result = search_memories(
store,
repo,
session_id,
prompt,
top_k=5,
operation="first-prompt-search",
timeout=2,
)
if not result.memories:
return {}
context = format_context(
result.memories,
"Mem0 found these relevant memories from earlier work in this repository:",
)
telemetry.record(
"context_injected",
repo=repo,
session_id=session_id,
trigger="first-prompt",
memory_count=len(result.memories),
context_chars=len(context),
prompt_chars=len(prompt),
)
return {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context,
},
}
def _launch_handoff(handoff_path: Path) -> bool:
running_path = handoff_path.with_suffix(".running")
try:
handoff_path.replace(running_path)
except OSError:
return False
worker = Path(__file__).resolve().parents[2] / "core" / "flush_worker.py"
log_path = data_dir() / "flush-worker.log"
log_handle = open(log_path, "a", encoding="utf-8")
try:
subprocess.Popen(
[sys.executable, str(worker), str(running_path)],
stdin=subprocess.DEVNULL,
stdout=log_handle,
stderr=log_handle,
close_fds=True,
**detached_process_kwargs(),
)
finally:
log_handle.close()
return True
def recover_pending_handoffs() -> int:
pending_dir = data_dir() / "pending"
pending_dir.mkdir(parents=True, exist_ok=True)
now = time.time()
for running in pending_dir.glob("*.running"):
try:
if now - running.stat().st_mtime > STALE_RUNNING_SECONDS:
running.replace(running.with_suffix(".json"))
except OSError:
continue
recoverable = []
for handoff in pending_dir.glob("*.json"):
try:
age = now - handoff.stat().st_mtime
except OSError:
continue
if age > PENDING_EXPIRY_SECONDS:
handoff.unlink(missing_ok=True)
continue
recoverable.append((age, handoff))
recoverable.sort(key=lambda item: item[0], reverse=True)
launched = 0
for _, handoff in recoverable[:PENDING_LAUNCH_LIMIT]:
launched += int(_launch_handoff(handoff))
return launched
def refresh_pending_handoffs() -> None:
"""Hold unsent packets while paused instead of letting them expire."""
pending_dir = data_dir() / "pending"
if not pending_dir.is_dir():
return
for pattern in ("*.json", "*.running"):
for handoff in pending_dir.glob(pattern):
try:
os.utime(handoff)
except OSError:
continue
def hand_off_flush(
hook_input: dict, reason: str, *, wait_for_inflight: bool = False
) -> None:
"""Persist hook input and detach delivery from Claude's shutdown lifecycle."""
pending_dir = data_dir() / "pending"
pending_dir.mkdir(parents=True, exist_ok=True)
material = (
f"{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}\0{reason}"
)
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
handoff_path = pending_dir / f"{digest}-{uuid.uuid4().hex[:8]}.json"
temporary_path = handoff_path.with_suffix(".tmp")
temporary_path.write_text(
json.dumps(
{
"hook_input": hook_input,
"reason": reason,
"wait_for_inflight": wait_for_inflight,
}
),
encoding="utf-8",
)
temporary_path.replace(handoff_path)
_launch_handoff(handoff_path)
def automatic_flush_enabled() -> bool:
return os.environ.get("MEM0_CODE_AUTO_FLUSH", "true").lower() in {
"1",
"true",
"yes",
"on",
}
def schedule_periodic_checkpoint(
store: EvidenceStore,
hook_input: dict,
repo,
session_id: str,
) -> bool:
"""Start one background extraction when a complete block is ready."""
if (
not automatic_flush_enabled()
or not api_key()
or not store.checkpoint_due(repo.identity, session_id)
):
return False
if store.prepare_flush(repo, session_id, "periodic") is None:
return False
hand_off_flush(hook_input, "periodic")
return True
DEFAULT_IDLE_FLUSH_SECONDS = 300
def _idle_flush_seconds() -> int:
try:
return max(
int(os.environ.get("MEM0_CODE_IDLE_FLUSH_SECONDS", str(DEFAULT_IDLE_FLUSH_SECONDS))),
0,
)
except ValueError:
return DEFAULT_IDLE_FLUSH_SECONDS
def schedule_idle_flush(
store: EvidenceStore,
hook_input: dict,
repo,
session_id: str,
) -> bool:
"""Launch a delayed background flush for sessions that may never end."""
delay = _idle_flush_seconds()
if delay <= 0 or not automatic_flush_enabled() or not api_key():
return False
if store.has_inflight_flush(repo.identity, session_id):
return False
if not store.has_unflushed_events(repo.identity, session_id):
return False
pending_dir = data_dir() / "pending"
pending_dir.mkdir(parents=True, exist_ok=True)
material = f"idle\0{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}"
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
for old in pending_dir.glob(f"idle-{digest}*"):
old.unlink(missing_ok=True)
handoff_path = pending_dir / f"idle-{digest}-{uuid.uuid4().hex[:8]}.json"
temporary_path = handoff_path.with_suffix(".tmp")
temporary_path.write_text(
json.dumps({
"hook_input": hook_input,
"reason": "idle",
"delay_seconds": delay,
}),
encoding="utf-8",
)
temporary_path.replace(handoff_path)
_launch_handoff(handoff_path)
return True
def log_failure(exc: Exception) -> None:
try:
log_path = data_dir() / "plugin-errors.log"
with log_path.open("a", encoding="utf-8") as handle:
handle.write(f"{time.time():.3f} {type(exc).__name__}: {exc}\n")
except OSError:
pass
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"action",
choices=[
"session-start",
"user-prompt",
"post-tool",
"post-tool-failure",
"sidekick-start",
"sidekick-stop",
"stop",
"flush",
],
)
parser.add_argument("--reason", default="manual")
parser.add_argument("--plugin-data-dir", default="")
args = parser.parse_args()
if args.plugin_data_dir:
os.environ["MEM0_CODE_DATA_DIR"] = args.plugin_data_dir
cache_plugin_api_key()
if args.action == "session-start":
clear_stale_api_key_cache()
hook_input = read_hook_input()
store = EvidenceStore()
try:
if store.is_paused():
if args.action == "session-start":
refresh_pending_handoffs()
telemetry.record("session_start", paused=True)
telemetry.spawn_flush()
return 0
if args.action == "session-start":
if telemetry.is_first_run():
telemetry.record("install")
recovered = recover_pending_handoffs()
record_session_start(store, hook_input)
if recovered:
telemetry.record("handoff_recovered", count=recovered)
telemetry.spawn_flush()
elif args.action == "user-prompt":
output = first_prompt_memory_output(store, hook_input)
if output:
print(json.dumps(output))
elif args.action == "post-tool":
record_tool(store, hook_input)
elif args.action == "post-tool-failure":
record_tool(store, hook_input, failed=True)
elif args.action == "sidekick-start":
context = record_sidekick_start(store, hook_input)
if context:
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "SubagentStart",
"additionalContext": context,
}
}
)
)
elif args.action == "sidekick-stop":
record_sidekick_stop(store, hook_input)
elif args.action == "stop":
repo, session_id = record_stop(store, hook_input)
if not schedule_periodic_checkpoint(store, hook_input, repo, session_id):
schedule_idle_flush(store, hook_input, repo, session_id)
elif args.action == "flush":
automatic = args.reason in {"session-end", "pre-compact"}
if automatic and not automatic_flush_enabled():
return 0
if args.reason == "session-end":
# In print mode, SessionEnd can arrive before the Stop hook has
# recorded Claude's final response. Read any remaining visible
# transcript messages before preparing the final extraction.
record_stop(store, hook_input)
if os.environ.get("MEM0_CODE_SYNC_FLUSH") == "1":
print(json.dumps(checkpoint_session(store, hook_input, args.reason)))
else:
session_id = str(hook_input.get("session_id") or "unknown-session")
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
already_running = store.has_inflight_flush(repo.identity, session_id)
if already_running and args.reason == "session-end":
hand_off_flush(
hook_input, args.reason, wait_for_inflight=True
)
elif not already_running and store.prepare_flush(
repo, session_id, args.reason
) is not None:
hand_off_flush(hook_input, args.reason)
finally:
store.close()
return 0
def _sidekick_stop(store, hook_input):
record_sidekick_stop(store, hook_input)
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
# Memory must never prevent the coding agent from continuing.
log_failure(exc)
raise SystemExit(0)
hook_runner.entry_point(
record_stop_fn=record_stop,
extra_actions={
"post-tool-failure": lambda s, h: record_tool(s, h, failed=True),
"sidekick-start": _sidekick_start,
"sidekick-stop": _sidekick_stop,
},
data_dir_env="MEM0_CODE_DATA_DIR",
automatic_flush_reasons={"session-end", "pre-compact"},
)
@@ -0,0 +1,362 @@
"""Claude Code transcript parsing for Mem0 memory extraction."""
from __future__ import annotations
import html
import json
import re
import sys
from pathlib import Path
from typing import Any
_here = Path(__file__).resolve()
sys.path.insert(0, str(_here.parents[2] / "core" / "python"))
from memory_core import ( # noqa: E402
EvidenceStore,
RepoContext,
_session_id,
redact,
)
def _message_content_text(content: Any) -> str:
"""Return visible text from one Claude transcript message."""
if isinstance(content, str):
return redact(content).strip()
if not isinstance(content, list):
return ""
parts = []
for block in content:
if not isinstance(block, dict) or block.get("type") != "text":
continue
text = redact(block.get("text", "")).strip()
if text:
parts.append(text)
return "\n\n".join(parts)
def _transcript_rows(
path: str, offset: int = 0
) -> tuple[list[dict[str, Any]], int, bool]:
"""Parse transcript rows from a byte offset, returning rows, end offset, and whether the offset was honored."""
if not path:
return [], 0, False
rows = []
try:
resolved = Path(path).expanduser()
if not 0 <= offset <= resolved.stat().st_size:
offset = 0
end = offset
with resolved.open("rb") as handle:
handle.seek(offset)
for line in handle:
if not line.endswith(b"\n"):
break
end += len(line)
try:
row = json.loads(line.decode("utf-8", errors="replace"))
except json.JSONDecodeError:
continue
if isinstance(row, dict) and row.get("uuid"):
rows.append(row)
except OSError:
return [], offset, False
return rows, end, offset > 0
def _active_transcript_chain(
rows: list[dict[str, Any]], session_id: str
) -> list[dict[str, Any]]:
"""Follow the current Claude conversation branch from its latest record."""
by_uuid = {str(row["uuid"]): row for row in rows if row.get("uuid")}
leaf = next(
(
row
for row in reversed(rows)
if not row.get("isSidechain")
and str(row.get("sessionId") or "") == session_id
),
None,
)
if leaf is None:
return []
chain = []
seen = set()
current = leaf
while current is not None:
uuid = str(current.get("uuid") or "")
if not uuid or uuid in seen:
break
seen.add(uuid)
chain.append(current)
current = by_uuid.get(str(current.get("parentUuid") or ""))
chain.reverse()
return chain
def _human_prompt_text(row: dict[str, Any]) -> str:
if row.get("type") != "user":
return ""
origin = row.get("origin") or {}
if isinstance(origin, dict) and origin.get("kind") not in {None, "human"}:
return ""
message = row.get("message") or {}
content = message.get("content") if isinstance(message, dict) else None
if not isinstance(content, str):
return ""
text = redact(content).strip()
if text.startswith("<!-- attach -->"):
text = text.removeprefix("<!-- attach -->").strip()
ignored_prefixes = (
"<local-command-caveat>",
"<local-command-stdout>",
"<local-command-stderr>",
"<command-name>",
"<system-reminder>",
"<task-notification>",
)
return "" if text.startswith(ignored_prefixes) else text
def _xml_value(text: str, tag: str) -> str:
match = re.search(fr"<{tag}>(.*?)</{tag}>", text, re.DOTALL)
return html.unescape(match.group(1).strip()) if match else ""
def _agent_assignment(tool_input: dict[str, Any]) -> str:
prompt = redact(tool_input.get("prompt", "")).strip()
if not prompt:
return ""
agent_type = redact(tool_input.get("subagent_type", "agent")).strip() or "agent"
description = redact(tool_input.get("description", "")).strip()
heading = f"Subagent assignment ({agent_type}"
if description:
heading += f": {description}"
return f"{heading}):\n{prompt}"
def _agent_response(tool_input: dict[str, Any], result: str) -> str:
result = redact(result).strip()
if not result or result.startswith("Async agent launched successfully."):
return ""
agent_type = redact(tool_input.get("subagent_type", "agent")).strip() or "agent"
description = redact(tool_input.get("description", "")).strip()
heading = f"Subagent response ({agent_type}"
if description:
heading += f": {description}"
return f"{heading}):\n{result}"
def _tool_result_text(block: dict[str, Any]) -> str:
return _message_content_text(block.get("content"))
def transcript_extraction_messages(
transcript_path: str,
session_id: str,
*,
previous_leaf_uuid: str = "",
prompt_hint: str = "",
fallback_assistant_message: str = "",
label_final_response: bool = False,
start_offset: int = 0,
) -> tuple[list[dict[str, str]], str, int]:
"""Read the meaningful part of the current Claude exchange."""
rows, end_offset, resumed = _transcript_rows(transcript_path, start_offset)
chain = _active_transcript_chain(rows, session_id)
if not chain:
if resumed:
return [], previous_leaf_uuid, end_offset
fallback = redact(fallback_assistant_message).strip()
return (
([{"role": "assistant", "content": f"Main Claude response:\n{fallback}"}]
if fallback
else []),
"",
end_offset,
)
leaf_uuid = str(chain[-1].get("uuid") or "")
if previous_leaf_uuid and leaf_uuid == previous_leaf_uuid:
return [], leaf_uuid, end_offset
start = 0
if previous_leaf_uuid:
for index, row in enumerate(chain):
if str(row.get("uuid") or "") == previous_leaf_uuid:
start = index + 1
break
else:
previous_leaf_uuid = ""
if not previous_leaf_uuid and not resumed:
prompt_hint = redact(prompt_hint).strip()
candidates = [
index
for index, row in enumerate(chain)
if _human_prompt_text(row)
and (
not prompt_hint
or _human_prompt_text(row) == prompt_hint
)
]
task_notifications = [
index
for index, row in enumerate(chain)
if isinstance((row.get("message") or {}).get("content"), str)
and (row.get("message") or {})["content"].startswith("<task-notification>")
]
if candidates:
start = candidates[-1]
elif task_notifications:
start = task_notifications[-1]
tool_uses: dict[str, tuple[str, dict[str, Any]]] = {}
for row in chain:
message = row.get("message") or {}
content = message.get("content") if isinstance(message, dict) else None
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_use":
continue
tool_id = str(block.get("id") or "")
tool_input = block.get("input") or {}
if tool_id and isinstance(tool_input, dict):
tool_uses[tool_id] = (str(block.get("name") or ""), tool_input)
output: list[dict[str, str]] = []
def append(role: str, content: str) -> None:
content = redact(content).strip()
if content:
output.append({"role": role, "content": content})
for row in chain[start:]:
message = row.get("message") or {}
if not isinstance(message, dict):
continue
role = str(message.get("role") or "")
content = message.get("content")
if role == "user" and isinstance(content, str):
if content.startswith("<task-notification>"):
if _xml_value(content, "status") != "completed":
continue
tool_id = _xml_value(content, "tool-use-id")
tool = tool_uses.get(tool_id)
result = _xml_value(content, "result")
if tool and tool[0] == "Agent" and result:
assignment = _agent_assignment(tool[1])
response = _agent_response(tool[1], result)
append("assistant", assignment)
append("assistant", response)
continue
human = _human_prompt_text(row)
if human:
append("user", human)
continue
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type")
if role == "assistant" and block_type == "text":
append("assistant", str(block.get("text") or ""))
continue
if role != "user" or block_type != "tool_result":
continue
tool_id = str(block.get("tool_use_id") or "")
tool = tool_uses.get(tool_id)
if not tool:
continue
name, tool_input = tool
result = _tool_result_text(block)
failed = bool(block.get("is_error"))
if name == "Agent" and not failed:
response = _agent_response(tool_input, result)
if response:
append("assistant", _agent_assignment(tool_input))
append("assistant", response)
elif name == "AskUserQuestion" and result and not failed:
append("user", f"User answers to Claude's questions:\n{result}")
elif name == "ExitPlanMode" and not failed:
plan = redact(tool_input.get("plan", "")).strip()
if plan:
append("assistant", f"Approved implementation plan:\n{plan}")
fallback = redact(fallback_assistant_message).strip()
if fallback:
labeled = f"Main Claude response:\n{fallback}"
for message in reversed(output):
if message["role"] == "assistant" and message["content"] == fallback:
message["content"] = labeled
break
else:
append("assistant", labeled)
elif label_final_response:
last_message = chain[-1].get("message") or {}
last_content = (
last_message.get("content") if isinstance(last_message, dict) else None
)
final_parts = [
redact(block.get("text", "")).strip()
for block in (last_content if isinstance(last_content, list) else [])
if isinstance(block, dict)
and block.get("type") == "text"
and redact(block.get("text", "")).strip()
]
for start in range(len(output) - len(final_parts), -1, -1):
candidate = output[start : start + len(final_parts)]
if final_parts and [item["content"] for item in candidate] == final_parts:
candidate[0]["content"] = (
f"Main Claude response:\n{candidate[0]['content']}"
)
break
return output, leaf_uuid, end_offset
def record_stop(
store: EvidenceStore, hook_input: dict[str, Any]
) -> tuple[RepoContext, str]:
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
message = redact(hook_input.get("last_assistant_message", "")).strip()
previous_stop = store.latest_event_payload(
repo.identity, session_id, "assistant_stop"
)
latest_prompt = store.latest_event_payload(repo.identity, session_id, "user_prompt")
transcript_path = str(hook_input.get("transcript_path") or "")
previous_offset = previous_stop.get("transcript_offset")
start_offset = (
previous_offset
if isinstance(previous_offset, int)
and str(previous_stop.get("transcript_path") or "") == transcript_path
else 0
)
transcript_messages, leaf_uuid, end_offset = transcript_extraction_messages(
transcript_path,
session_id,
previous_leaf_uuid=str(previous_stop.get("transcript_leaf_uuid") or ""),
prompt_hint=str(latest_prompt.get("text") or ""),
fallback_assistant_message=message,
label_final_response=True,
start_offset=start_offset,
)
if transcript_messages:
payload: dict[str, Any] = {
"text": message,
"transcript_messages": transcript_messages,
}
if leaf_uuid:
payload["transcript_leaf_uuid"] = leaf_uuid
if transcript_path:
payload["transcript_path"] = transcript_path
payload["transcript_offset"] = end_offset
store.record_event(
repo, session_id, "assistant_stop", payload
)
return repo, session_id
@@ -17,7 +17,7 @@ import telemetry
from memory_core import (
EvidenceStore,
checkpoint_session,
record_stop,
configure_harness,
touch_handoff_heartbeat,
)
@@ -27,15 +27,28 @@ def main() -> int:
return 2
handoff_path = Path(sys.argv[1])
os.environ["MEM0_CODE_HANDOFF_PATH"] = str(handoff_path)
harness = os.environ.get("MEM0_PLUGIN_HARNESS")
if harness:
source_tag = os.environ.get("MEM0_PLUGIN_SOURCE_TAG", "")
configure_harness(
harness,
env_prefix=os.environ.get("MEM0_PLUGIN_ENV_PREFIX", ""),
data_dir_name=os.environ.get("MEM0_PLUGIN_DATA_DIR_NAME", ""),
source_tag=source_tag,
)
telemetry.init(harness=harness, source_tag=source_tag.upper())
completed = False
try:
payload = json.loads(handoff_path.read_text(encoding="utf-8"))
delay = float(payload.get("delay_seconds") or 0)
if delay > 0:
payload.pop("delay_seconds", None)
handoff_path.write_text(
json.dumps(payload), encoding="utf-8"
)
temporary = handoff_path.with_suffix(f".{os.getpid()}.tmp")
try:
temporary.write_text(json.dumps(payload), encoding="utf-8")
temporary.replace(handoff_path)
finally:
temporary.unlink(missing_ok=True)
time.sleep(delay)
if not handoff_path.exists():
return 0
@@ -58,8 +71,7 @@ def main() -> int:
):
touch_handoff_heartbeat()
time.sleep(0.25)
if reason == "session-end":
record_stop(store, hook_input)
# Hooks capture the conversation before handoff; the worker only flushes it.
result = checkpoint_session(store, hook_input, reason)
print(json.dumps(result, sort_keys=True), flush=True)
completed = result.get("status") in {
@@ -0,0 +1,372 @@
"""Shared hook orchestration for all Mem0 agent plugins."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
import uuid
from pathlib import Path
import telemetry
from memory_core import (
EvidenceStore,
_session_id,
api_key,
bounded,
cache_plugin_api_key,
checkpoint_session,
clear_stale_api_key_cache,
configure_harness,
data_dir,
detached_process_kwargs,
format_context,
harness_config,
record_session_start,
record_tool,
record_user_prompt,
redact,
search_memories,
)
STALE_RUNNING_SECONDS = 300
PENDING_EXPIRY_SECONDS = 7 * 24 * 60 * 60
PENDING_LAUNCH_LIMIT = 5
DEFAULT_IDLE_FLUSH_SECONDS = 300
_core_dir: Path = Path(__file__).resolve().parent
def read_hook_input() -> dict:
try:
value = json.load(sys.stdin)
return value if isinstance(value, dict) else {}
except (json.JSONDecodeError, OSError):
return {}
def default_record_stop(store: EvidenceStore, hook_input: dict):
"""Record the assistant's response without transcript parsing."""
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
message = redact(hook_input.get("last_assistant_message", "")).strip()
if message:
store.record_assistant_response(repo, session_id, message)
return repo, session_id
def first_prompt_memory_output(store: EvidenceStore, hook_input: dict) -> dict:
"""Search once before the agent handles the first prompt in a session."""
repo, session_id, prompt, is_first_prompt = record_user_prompt(store, hook_input)
if not is_first_prompt:
return {}
try:
minimum_query_chars = int(os.environ.get("MEM0_CODE_MIN_QUERY_CHARS", "20"))
except ValueError:
minimum_query_chars = 20
if len(prompt.strip()) < max(minimum_query_chars, 1):
return {}
result = search_memories(
store, repo, session_id, bounded(prompt, 6000),
top_k=5, operation="first-prompt-search", timeout=2,
)
if not result.memories:
return {}
context = format_context(
result.memories,
"Mem0 found these relevant memories from earlier work in this repository:",
)
telemetry.record(
"context_injected",
repo=repo, session_id=session_id, trigger="first-prompt",
memory_count=len(result.memories), context_chars=len(context),
prompt_chars=len(prompt),
)
return {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context,
},
}
def _launch_handoff(handoff_path: Path) -> bool:
running_path = handoff_path.with_suffix(".running")
try:
handoff_path.replace(running_path)
except OSError:
return False
worker = _core_dir / "flush_worker.py"
log_path = data_dir() / "flush-worker.log"
log_handle = open(log_path, "a", encoding="utf-8")
harness = harness_config()
child_env = os.environ.copy()
child_env.update(
{
"MEM0_CODE_DATA_DIR": str(data_dir()),
"MEM0_PLUGIN_HARNESS": harness["name"],
"MEM0_PLUGIN_ENV_PREFIX": harness["env_prefix"],
"MEM0_PLUGIN_DATA_DIR_NAME": harness["data_dir_name"],
"MEM0_PLUGIN_SOURCE_TAG": harness["source_tag"],
}
)
try:
subprocess.Popen(
[sys.executable, str(worker), str(running_path)],
stdin=subprocess.DEVNULL,
stdout=log_handle, stderr=log_handle,
close_fds=True,
env=child_env,
**detached_process_kwargs(),
)
finally:
log_handle.close()
return True
def recover_pending_handoffs() -> int:
pending_dir = data_dir() / "pending"
pending_dir.mkdir(parents=True, exist_ok=True)
now = time.time()
for running in pending_dir.glob("*.running"):
try:
if now - running.stat().st_mtime > STALE_RUNNING_SECONDS:
running.replace(running.with_suffix(".json"))
except OSError:
continue
recoverable = []
for handoff in pending_dir.glob("*.json"):
try:
age = now - handoff.stat().st_mtime
except OSError:
continue
if age > PENDING_EXPIRY_SECONDS:
handoff.unlink(missing_ok=True)
continue
recoverable.append((age, handoff))
recoverable.sort(key=lambda item: item[0], reverse=True)
launched = 0
for _, handoff in recoverable[:PENDING_LAUNCH_LIMIT]:
launched += int(_launch_handoff(handoff))
return launched
def refresh_pending_handoffs() -> None:
pending_dir = data_dir() / "pending"
if not pending_dir.is_dir():
return
for pattern in ("*.json", "*.running"):
for handoff in pending_dir.glob(pattern):
try:
os.utime(handoff)
except OSError:
continue
def hand_off_flush(
hook_input: dict, reason: str, *, wait_for_inflight: bool = False,
) -> None:
pending_dir = data_dir() / "pending"
pending_dir.mkdir(parents=True, exist_ok=True)
material = (
f"{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}\0{reason}"
)
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
handoff_path = pending_dir / f"{digest}-{uuid.uuid4().hex[:8]}.json"
temporary_path = handoff_path.with_suffix(".tmp")
temporary_path.write_text(
json.dumps({
"hook_input": hook_input,
"reason": reason,
"wait_for_inflight": wait_for_inflight,
}),
encoding="utf-8",
)
temporary_path.replace(handoff_path)
_launch_handoff(handoff_path)
def automatic_flush_enabled() -> bool:
return os.environ.get("MEM0_CODE_AUTO_FLUSH", "true").lower() in {
"1", "true", "yes", "on",
}
def schedule_periodic_checkpoint(
store: EvidenceStore, hook_input: dict, repo, session_id: str,
) -> bool:
if (
not automatic_flush_enabled()
or not api_key()
or not store.checkpoint_due(repo.identity, session_id)
):
return False
if store.prepare_flush(repo, session_id, "periodic") is None:
return False
hand_off_flush(hook_input, "periodic")
return True
def _idle_flush_seconds() -> int:
try:
return max(
int(os.environ.get("MEM0_CODE_IDLE_FLUSH_SECONDS", str(DEFAULT_IDLE_FLUSH_SECONDS))),
0,
)
except ValueError:
return DEFAULT_IDLE_FLUSH_SECONDS
def schedule_idle_flush(
store: EvidenceStore, hook_input: dict, repo, session_id: str,
) -> bool:
delay = _idle_flush_seconds()
if delay <= 0 or not automatic_flush_enabled() or not api_key():
return False
if store.has_inflight_flush(repo.identity, session_id):
return False
if not store.has_unflushed_events(repo.identity, session_id):
return False
pending_dir = data_dir() / "pending"
pending_dir.mkdir(parents=True, exist_ok=True)
material = f"idle\0{hook_input.get('cwd', '')}\0{hook_input.get('session_id', '')}"
digest = hashlib.sha256(material.encode()).hexdigest()[:24]
for old in pending_dir.glob(f"idle-{digest}*"):
old.unlink(missing_ok=True)
handoff_path = pending_dir / f"idle-{digest}-{uuid.uuid4().hex[:8]}.json"
temporary_path = handoff_path.with_suffix(".tmp")
temporary_path.write_text(
json.dumps({
"hook_input": hook_input,
"reason": "idle",
"delay_seconds": delay,
}),
encoding="utf-8",
)
temporary_path.replace(handoff_path)
_launch_handoff(handoff_path)
return True
def log_failure(exc: Exception) -> None:
try:
log_path = data_dir() / "plugin-errors.log"
with log_path.open("a", encoding="utf-8") as handle:
handle.write(f"{time.time():.3f} {type(exc).__name__}: {exc}\n")
except OSError:
pass
def run(
*,
record_stop_fn=None,
extra_actions: dict | None = None,
data_dir_env: str = "MEM0_PLUGIN_DATA_DIR",
automatic_flush_reasons: set | None = None,
) -> int:
if record_stop_fn is None:
record_stop_fn = default_record_stop
if automatic_flush_reasons is None:
automatic_flush_reasons = {"session-end"}
base_actions = ["session-start", "user-prompt", "post-tool", "stop", "flush"]
all_actions = base_actions + list((extra_actions or {}).keys())
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=all_actions)
parser.add_argument("--reason", default="manual")
parser.add_argument("--plugin-data-dir", default="")
parser.add_argument("--harness", default="")
args = parser.parse_args()
if args.harness:
configure_harness(args.harness)
telemetry.init(harness=args.harness)
if args.plugin_data_dir:
os.environ[data_dir_env] = args.plugin_data_dir
cache_plugin_api_key()
if args.action == "session-start":
clear_stale_api_key_cache()
hook_input = read_hook_input()
store = EvidenceStore()
try:
if store.is_paused():
if args.action == "session-start":
refresh_pending_handoffs()
telemetry.record("session_start", paused=True)
telemetry.spawn_flush()
return 0
if args.action == "session-start":
if telemetry.is_first_run():
telemetry.record("install")
recovered = recover_pending_handoffs()
record_session_start(store, hook_input)
if recovered:
telemetry.record("handoff_recovered", count=recovered)
telemetry.spawn_flush()
elif args.action == "user-prompt":
output = first_prompt_memory_output(store, hook_input)
if output:
print(json.dumps(output))
elif args.action == "post-tool":
record_tool(store, hook_input)
elif args.action == "stop":
repo, session_id = record_stop_fn(store, hook_input)
if not schedule_periodic_checkpoint(store, hook_input, repo, session_id):
schedule_idle_flush(store, hook_input, repo, session_id)
elif args.action == "flush":
automatic = args.reason in automatic_flush_reasons
if automatic and not automatic_flush_enabled():
return 0
if args.reason == "session-end":
record_stop_fn(store, hook_input)
if os.environ.get("MEM0_CODE_SYNC_FLUSH") == "1":
print(json.dumps(checkpoint_session(store, hook_input, args.reason)))
else:
session_id = str(hook_input.get("session_id") or "unknown-session")
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
already_running = store.has_inflight_flush(repo.identity, session_id)
if already_running and args.reason == "session-end":
hand_off_flush(hook_input, args.reason, wait_for_inflight=True)
elif not already_running and store.prepare_flush(
repo, session_id, args.reason,
) is not None:
hand_off_flush(hook_input, args.reason)
elif extra_actions and args.action in extra_actions:
result = extra_actions[args.action](store, hook_input)
if result:
print(json.dumps(result))
finally:
store.close()
return 0
def entry_point(
*,
record_stop_fn=None,
extra_actions: dict | None = None,
data_dir_env: str = "MEM0_PLUGIN_DATA_DIR",
automatic_flush_reasons: set | None = None,
) -> None:
try:
raise SystemExit(run(
record_stop_fn=record_stop_fn,
extra_actions=extra_actions,
data_dir_env=data_dir_env,
automatic_flush_reasons=automatic_flush_reasons,
))
except Exception as exc:
log_failure(exc)
raise SystemExit(0)
if __name__ == "__main__":
entry_point()
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Expose Mem0's memory search as one local Claude Code tool."""
"""Expose Mem0's memory search as one local coding-agent tool."""
from __future__ import annotations
@@ -11,13 +11,13 @@ from typing import Any
import telemetry
from memory_core import (
CODING_MEMORY_CATEGORY_NAMES,
PLUGIN_VERSION,
SEARCH_SCOPES,
format_search_result,
resolve_repo,
SEARCH_SCOPES,
search_memories,
)
PROTOCOL_VERSION = "2024-11-05"
TOOL_NAME = "search_memories"
TOOL_DESCRIPTION = (
@@ -30,8 +30,7 @@ TOOL_DESCRIPTION = (
"invocation works. The scope argument changes what is searched: 'repo' "
"(default) is the whole repository's shared memory plus your own "
"preferences, 'dir' narrows the shared part to the directory you are "
"working in, and 'mine' is your preferences alone. Pass run_id to look "
"at one earlier Claude Code session only."
"working in, and 'mine' is your preferences alone."
)
TOOL_SCHEMA = {
"type": "object",
@@ -65,8 +64,10 @@ TOOL_SCHEMA = {
"run_id": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Optional Claude Code session ID. Restricts the search to memories written from that session.",
"description": (
"Optional coding-agent session ID. With any scope, restricts results to memories "
"saved in that session. Omit to recall memories across sessions."
),
},
},
"required": ["query"],
@@ -110,16 +111,17 @@ def _validate_arguments(
raise ToolInputError(f"scope must be one of {list(SEARCH_SCOPES)}.")
run_id = arguments.get("run_id")
if run_id is not None and (
not isinstance(run_id, str) or not run_id.strip() or len(run_id) > 200
):
raise ToolInputError("run_id must be a non-empty string of at most 200 characters.")
if run_id is not None:
if not isinstance(run_id, str) or not run_id.strip():
raise ToolInputError("run_id must be a non-empty string.")
run_id = run_id.strip()
return query, top_k, category, scope, run_id
def call_search_memories(arguments: Any) -> str:
def call_search_memories(arguments: Any, cwd: str | None = None) -> str:
query, top_k, category, scope, run_id = _validate_arguments(arguments)
repo = resolve_repo(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
repo = resolve_repo(cwd or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
result = search_memories(
None,
repo,
@@ -134,6 +136,19 @@ def call_search_memories(arguments: Any) -> str:
return format_search_result(result)
def _workspace_cwd(params: dict[str, Any]) -> str | None:
meta = params.get("_meta")
if not isinstance(meta, dict):
return None
metadata = meta.get("x-codex-turn-metadata")
if not isinstance(metadata, dict):
return None
workspaces = metadata.get("workspaces") or {}
if isinstance(workspaces, dict):
return next((path for path in workspaces if isinstance(path, str) and path), None)
return None
def _tool_response(text: str, *, is_error: bool = False) -> dict[str, Any]:
return {
"content": [{"type": "text", "text": text}],
@@ -157,7 +172,7 @@ def handle_request(message: Any) -> dict[str, Any] | None:
"result": {
"protocolVersion": requested or PROTOCOL_VERSION,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "mem0", "version": "0.3.0"},
"serverInfo": {"name": "mem0", "version": PLUGIN_VERSION},
},
}
if method == "ping":
@@ -187,7 +202,9 @@ def handle_request(message: Any) -> dict[str, Any] | None:
result = _tool_response("Unknown Mem0 tool.", is_error=True)
else:
try:
result = _tool_response(call_search_memories(params.get("arguments")))
result = _tool_response(
call_search_memories(params.get("arguments"), _workspace_cwd(params))
)
except ToolInputError as exc:
result = _tool_response(str(exc), is_error=True)
except Exception:
@@ -14,6 +14,7 @@ from memory_core import (
data_dir,
doctor,
forget_remote_repo,
configure_harness,
resolve_repo,
user_id,
)
@@ -60,6 +61,7 @@ def _print_status(value: dict) -> None:
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--plugin-data-dir", default="")
parser.add_argument("--harness", default="")
subparsers = parser.add_subparsers(dest="command", required=True)
status = subparsers.add_parser("status")
@@ -77,6 +79,10 @@ def main() -> int:
forget.add_argument("--include-project-memory", action="store_true")
args = parser.parse_args()
if args.harness:
source_tag = f"{args.harness.replace('-', '_')}_plugin"
configure_harness(args.harness, source_tag=source_tag)
telemetry.init(harness=args.harness, source_tag=source_tag.upper())
if args.plugin_data_dir:
os.environ["MEM0_CODE_DATA_DIR"] = args.plugin_data_dir
store = EvidenceStore()
@@ -1,16 +1,15 @@
#!/usr/bin/env python3
"""Save useful coding memories and search them in later Claude Code sessions.
"""Shared core for Mem0 agent plugins.
Hooks record small session details locally. When Claude compacts or ends the
session, Mem0 sends the useful parts of the session to Mem0 so it can create
memories. Claude can search those memories during later work in the repository.
Hooks record small session details locally. When the agent compacts or ends the
session, Mem0 sends the useful parts to the platform so it can create memories.
The agent can search those memories during later work in the repository.
"""
from __future__ import annotations
import functools
import hashlib
import html
import json
import math
import os
@@ -20,20 +19,46 @@ import subprocess
import sys
import time
import urllib.error
import urllib.request
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import timezone, datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
import telemetry
DEFAULT_API_URL = "https://api.mem0.ai"
PLUGIN_VERSION = "0.3.0"
MAX_PROMPT_CHARS = 6000
MAX_ASSISTANT_CHARS = 6000
PLUGIN_VERSION = "0.3.1"
_harness_name: str = "generic"
_harness_env_prefix: str = "MEM0_PLUGIN"
_harness_data_dir_name: str = "mem0-plugin"
_harness_source_tag: str = "mem0_plugin"
def configure_harness(
name: str,
env_prefix: str = "",
data_dir_name: str = "",
source_tag: str = "",
) -> None:
global _harness_name, _harness_env_prefix, _harness_data_dir_name, _harness_source_tag
_harness_name = name
_harness_env_prefix = env_prefix or f"MEM0_{name.upper().replace('-', '_')}"
_harness_data_dir_name = data_dir_name or f"{name}-plugin"
_harness_source_tag = source_tag or f"{name.replace('-', '_')}_plugin"
def harness_config() -> dict[str, str]:
return {
"name": _harness_name,
"env_prefix": _harness_env_prefix,
"data_dir_name": _harness_data_dir_name,
"source_tag": _harness_source_tag,
}
MAX_COMMAND_CHARS = 2000
MAX_RESULT_CHARS = 2500
MAX_EPISODE_CHARS = 12000
@@ -52,7 +77,7 @@ A completed change should produce one memory explaining the resulting behavior,
A command that failed and was then made to work should produce one memory naming the failing invocation, the error it returned, and the invocation that succeeded. Do not save one-off errors caused by an edit still in progress, transient network failures, or anything a rerun would fix on its own.
Use Claude's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or Claude completed them. Treat subagent responses as supporting repository evidence, not as decisions.
Use the current coding agent's final response for conclusions about current repository behavior. Do not save proposed or recommended changes unless the user accepted them or the coding agent completed them. Treat subagent responses as supporting repository evidence, not as decisions.
Write about the repository, not the user, assistant, session, or task. Do not save personal preferences. Do not save a memory that only states which repository, branch, or directory the session worked in. Do not include test results, documentation updates, release notes, or temporary state.
@@ -130,6 +155,11 @@ SECRET_PATTERNS = [
r"-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----",
re.DOTALL,
),
re.compile(
r'(?i)("(?:api[_-]?key|password|secret(?:[_-]?access[_-]?key)?'
r'|(?:access|refresh|session)[_-]?token|token|authorization|credential'
r')"\s*:\s*")(?:\\.|[^"\\])*'
),
]
@@ -213,13 +243,21 @@ def directory_chain(repo: RepoContext) -> list[str]:
return ["/".join(parts[: index + 1]) for index in range(len(parts))]
def _shared_project_ids(repo: RepoContext) -> list[str]:
"""Current and pre-upgrade namespaces, shared by recall and explicit deletion."""
if not repo.identity.startswith("local:") and repo.project_id != repo.app_id:
return [repo.project_id, repo.app_id]
return [repo.project_id]
def _search_filters(user: str, repo: RepoContext, scope: str) -> dict[str, Any]:
"""Build the scope filter: app_id scopes to the repo, then union shared and personal lanes."""
app_scope = {"app_id": repo.app_id}
mine = {"AND": [{"user_id": user}, app_scope]}
if scope == "mine":
return mine
shared: dict[str, Any] = {"AND": [{"agent_id": repo.project_id}, app_scope]}
projects = [{"AND": [{"agent_id": project_id}, app_scope]} for project_id in _shared_project_ids(repo)]
shared: dict[str, Any] = projects[0] if len(projects) == 1 else {"OR": projects}
if scope == "dir" and repo.directory:
shared = {"AND": [shared, {"metadata": {"dirs": {"contains": repo.directory}}}]}
return {"OR": [shared, mine]}
@@ -297,9 +335,9 @@ class RepoContext:
def _project_id(root: str, identity: str, app_id: str) -> str:
"""The shared namespace: the repository, or a folder path hashed so same-named folders stay apart."""
"""The shared namespace: includes a host hash so repos with the same owner/name on different hosts stay apart."""
if not identity.startswith("local:"):
return app_id
return f"{app_id}-{hashlib.sha256(identity.encode()).hexdigest()[:10]}"
return f"local-{app_id}-{hashlib.sha256(root.encode()).hexdigest()[:10]}"
@@ -345,8 +383,8 @@ def resolve_repo(cwd: str | None) -> RepoContext:
def api_key() -> str:
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
# Compatibility with the pre-marketplace development harness.
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
@@ -359,9 +397,10 @@ def api_key() -> str:
def cache_plugin_api_key() -> bool:
"""Bridge Claude's hook-only sensitive config into plugin-owned storage."""
"""Bridge host's hook-only sensitive config into plugin-owned storage."""
configured = (
os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
).strip()
@@ -394,6 +433,7 @@ def clear_stale_api_key_cache() -> bool:
"""Drop the cached key file once every configured key source is gone."""
configured = (
os.environ.get("MEM0_API_KEY")
or os.environ.get("PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_API_KEY")
or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY")
or ""
@@ -411,7 +451,7 @@ def clear_stale_api_key_cache() -> bool:
def detached_process_kwargs(platform: str | None = None) -> dict:
"""Keep a spawned worker alive after Claude Code exits, on POSIX and Windows."""
"""Keep a spawned worker alive after the coding agent exits, on POSIX and Windows."""
if (platform or sys.platform) == "win32":
return {
"creationflags": subprocess.DETACHED_PROCESS
@@ -422,7 +462,8 @@ def detached_process_kwargs(platform: str | None = None) -> dict:
def _plugin_option(name: str, fallback: str = "") -> str:
return (
os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
os.environ.get(f"PLUGIN_OPTION_{name.upper()}")
or os.environ.get(f"CLAUDE_PLUGIN_OPTION_{name.upper()}")
or os.environ.get(fallback)
or ""
).strip()
@@ -440,11 +481,14 @@ def user_id() -> str:
def data_dir() -> Path:
configured = os.environ.get("MEM0_CODE_DATA_DIR") or os.environ.get(
"CLAUDE_PLUGIN_DATA"
configured = (
os.environ.get("MEM0_CODE_DATA_DIR")
or os.environ.get("MEM0_PLUGIN_DATA_DIR")
or os.environ.get("PLUGIN_DATA")
or os.environ.get("CLAUDE_PLUGIN_DATA")
)
return (
Path(configured).expanduser() if configured else Path.home() / ".mem0" / "claude-code-plugin"
Path(configured).expanduser() if configured else Path.home() / ".mem0" / _harness_data_dir_name
)
@@ -464,316 +508,11 @@ def _int_option(name: str, fallback: str, default: int) -> int:
def _message_content_text(content: Any) -> str:
"""Return visible text from one Claude transcript message."""
if isinstance(content, str):
return redact(content).strip()
if not isinstance(content, list):
return ""
parts = []
for block in content:
if not isinstance(block, dict) or block.get("type") != "text":
continue
text = redact(block.get("text", "")).strip()
if text:
parts.append(text)
return "\n\n".join(parts)
def _transcript_rows(
path: str, offset: int = 0
) -> tuple[list[dict[str, Any]], int, bool]:
"""Parse transcript rows from a byte offset, returning rows, end offset, and whether the offset was honored."""
if not path:
return [], 0, False
rows = []
try:
resolved = Path(path).expanduser()
if not 0 <= offset <= resolved.stat().st_size:
offset = 0
end = offset
with resolved.open("rb") as handle:
handle.seek(offset)
for line in handle:
if not line.endswith(b"\n"):
break
end += len(line)
try:
row = json.loads(line.decode("utf-8", errors="replace"))
except json.JSONDecodeError:
continue
if isinstance(row, dict) and row.get("uuid"):
rows.append(row)
except OSError:
return [], offset, False
return rows, end, offset > 0
def _active_transcript_chain(
rows: list[dict[str, Any]], session_id: str
) -> list[dict[str, Any]]:
"""Follow the current Claude conversation branch from its latest record."""
by_uuid = {str(row["uuid"]): row for row in rows if row.get("uuid")}
leaf = next(
(
row
for row in reversed(rows)
if not row.get("isSidechain")
and str(row.get("sessionId") or "") == session_id
),
None,
)
if leaf is None:
return []
chain = []
seen = set()
current = leaf
while current is not None:
uuid = str(current.get("uuid") or "")
if not uuid or uuid in seen:
break
seen.add(uuid)
chain.append(current)
current = by_uuid.get(str(current.get("parentUuid") or ""))
chain.reverse()
return chain
def _human_prompt_text(row: dict[str, Any]) -> str:
if row.get("type") != "user":
return ""
origin = row.get("origin") or {}
if isinstance(origin, dict) and origin.get("kind") not in {None, "human"}:
return ""
message = row.get("message") or {}
content = message.get("content") if isinstance(message, dict) else None
if not isinstance(content, str):
return ""
text = redact(content).strip()
if text.startswith("<!-- attach -->"):
text = text.removeprefix("<!-- attach -->").strip()
ignored_prefixes = (
"<local-command-caveat>",
"<local-command-stdout>",
"<local-command-stderr>",
"<command-name>",
"<system-reminder>",
"<task-notification>",
)
return "" if text.startswith(ignored_prefixes) else text
def _xml_value(text: str, tag: str) -> str:
match = re.search(fr"<{tag}>(.*?)</{tag}>", text, re.DOTALL)
return html.unescape(match.group(1).strip()) if match else ""
def _agent_assignment(tool_input: dict[str, Any]) -> str:
prompt = redact(tool_input.get("prompt", "")).strip()
if not prompt:
return ""
agent_type = redact(tool_input.get("subagent_type", "agent")).strip() or "agent"
description = redact(tool_input.get("description", "")).strip()
heading = f"Subagent assignment ({agent_type}"
if description:
heading += f": {description}"
return f"{heading}):\n{prompt}"
def _agent_response(tool_input: dict[str, Any], result: str) -> str:
result = redact(result).strip()
if not result or result.startswith("Async agent launched successfully."):
return ""
agent_type = redact(tool_input.get("subagent_type", "agent")).strip() or "agent"
description = redact(tool_input.get("description", "")).strip()
heading = f"Subagent response ({agent_type}"
if description:
heading += f": {description}"
return f"{heading}):\n{result}"
def _tool_result_text(block: dict[str, Any]) -> str:
return _message_content_text(block.get("content"))
def transcript_extraction_messages(
transcript_path: str,
session_id: str,
*,
previous_leaf_uuid: str = "",
prompt_hint: str = "",
fallback_assistant_message: str = "",
label_final_response: bool = False,
start_offset: int = 0,
) -> tuple[list[dict[str, str]], str, int]:
"""Read the meaningful part of the current Claude exchange.
The returned messages contain human prompts, visible Claude text, accepted
plans, answers collected through AskUserQuestion, and completed native
subagent assignments and responses. Raw tool output and hidden reasoning
are deliberately excluded.
"""
rows, end_offset, resumed = _transcript_rows(transcript_path, start_offset)
chain = _active_transcript_chain(rows, session_id)
if not chain:
if resumed:
return [], previous_leaf_uuid, end_offset
fallback = redact(fallback_assistant_message).strip()
return (
([{"role": "assistant", "content": f"Main Claude response:\n{fallback}"}]
if fallback
else []),
"",
end_offset,
)
leaf_uuid = str(chain[-1].get("uuid") or "")
if previous_leaf_uuid and leaf_uuid == previous_leaf_uuid:
return [], leaf_uuid, end_offset
start = 0
if previous_leaf_uuid:
for index, row in enumerate(chain):
if str(row.get("uuid") or "") == previous_leaf_uuid:
start = index + 1
break
else:
previous_leaf_uuid = ""
if not previous_leaf_uuid and not resumed:
prompt_hint = redact(prompt_hint).strip()
candidates = [
index
for index, row in enumerate(chain)
if _human_prompt_text(row)
and (
not prompt_hint
or _human_prompt_text(row) == prompt_hint
)
]
task_notifications = [
index
for index, row in enumerate(chain)
if isinstance((row.get("message") or {}).get("content"), str)
and (row.get("message") or {})["content"].startswith("<task-notification>")
]
if candidates:
start = candidates[-1]
elif task_notifications:
start = task_notifications[-1]
tool_uses: dict[str, tuple[str, dict[str, Any]]] = {}
for row in chain:
message = row.get("message") or {}
content = message.get("content") if isinstance(message, dict) else None
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict) or block.get("type") != "tool_use":
continue
tool_id = str(block.get("id") or "")
tool_input = block.get("input") or {}
if tool_id and isinstance(tool_input, dict):
tool_uses[tool_id] = (str(block.get("name") or ""), tool_input)
output: list[dict[str, str]] = []
def append(role: str, content: str) -> None:
content = redact(content).strip()
if content:
output.append({"role": role, "content": content})
for row in chain[start:]:
message = row.get("message") or {}
if not isinstance(message, dict):
continue
role = str(message.get("role") or "")
content = message.get("content")
if role == "user" and isinstance(content, str):
if content.startswith("<task-notification>"):
if _xml_value(content, "status") != "completed":
continue
tool_id = _xml_value(content, "tool-use-id")
tool = tool_uses.get(tool_id)
result = _xml_value(content, "result")
if tool and tool[0] == "Agent" and result:
assignment = _agent_assignment(tool[1])
response = _agent_response(tool[1], result)
append("assistant", assignment)
append("assistant", response)
continue
human = _human_prompt_text(row)
if human:
append("user", human)
continue
if not isinstance(content, list):
continue
for block in content:
if not isinstance(block, dict):
continue
block_type = block.get("type")
if role == "assistant" and block_type == "text":
append("assistant", str(block.get("text") or ""))
continue
if role != "user" or block_type != "tool_result":
continue
tool_id = str(block.get("tool_use_id") or "")
tool = tool_uses.get(tool_id)
if not tool:
continue
name, tool_input = tool
result = _tool_result_text(block)
failed = bool(block.get("is_error"))
if name == "Agent" and not failed:
response = _agent_response(tool_input, result)
if response:
append("assistant", _agent_assignment(tool_input))
append("assistant", response)
elif name == "AskUserQuestion" and result and not failed:
append("user", f"User answers to Claude's questions:\n{result}")
elif name == "ExitPlanMode" and not failed:
plan = redact(tool_input.get("plan", "")).strip()
if plan:
append("assistant", f"Approved implementation plan:\n{plan}")
fallback = redact(fallback_assistant_message).strip()
if fallback:
labeled = f"Main Claude response:\n{fallback}"
for message in reversed(output):
if message["role"] == "assistant" and message["content"] == fallback:
message["content"] = labeled
break
else:
append("assistant", labeled)
elif label_final_response:
last_message = chain[-1].get("message") or {}
last_content = (
last_message.get("content") if isinstance(last_message, dict) else None
)
final_parts = [
redact(block.get("text", "")).strip()
for block in (last_content if isinstance(last_content, list) else [])
if isinstance(block, dict)
and block.get("type") == "text"
and redact(block.get("text", "")).strip()
]
for start in range(len(output) - len(final_parts), -1, -1):
candidate = output[start : start + len(final_parts)]
if final_parts and [item["content"] for item in candidate] == final_parts:
candidate[0]["content"] = (
f"Main Claude response:\n{candidate[0]['content']}"
)
break
return output, leaf_uuid, end_offset
def _checkpoint_message(event: dict[str, Any]) -> str:
kind = event.get("kind")
payload = event.get("payload") or {}
if kind == "user_prompt":
return bounded(payload.get("text", ""), MAX_PROMPT_CHARS)
return redact(payload.get("text", "")).strip()
if kind == "assistant_stop":
transcript_messages = payload.get("transcript_messages") or []
if isinstance(transcript_messages, list):
@@ -784,9 +523,9 @@ def _checkpoint_message(event: dict[str, Any]) -> str:
)
if text:
return text
return bounded(payload.get("text", ""), MAX_ASSISTANT_CHARS)
return redact(payload.get("text", "")).strip()
if kind == "sidekick_stop":
return bounded(payload.get("final_message", ""), MAX_ASSISTANT_CHARS)
return redact(payload.get("final_message", "")).strip()
return ""
@@ -998,8 +737,27 @@ class EvidenceStore:
self.conn.commit()
return int(cursor.lastrowid)
def record_assistant_response(self, repo: RepoContext, session_id: str, message: str) -> None:
"""Ignore repeated response hooks until another prompt or a different answer arrives."""
with self.conn:
# Serialize the check and insert across concurrent Stop and SessionEnd hooks.
self.conn.execute("BEGIN IMMEDIATE")
previous = self.conn.execute(
"""SELECT kind, payload_json FROM events
WHERE repo_id = ? AND session_id = ? AND kind IN ('user_prompt', 'assistant_stop')
ORDER BY id DESC LIMIT 1""",
(repo.identity, session_id),
).fetchone()
if (
previous is not None
and previous["kind"] == "assistant_stop"
and json.loads(previous["payload_json"]).get("text") == message
):
return
self.record_event(repo, session_id, "assistant_stop", {"text": message})
def repo_for_session(self, session_id: str, cwd: str | None) -> RepoContext:
"""Keep one project scope for every hook in a Claude Code session."""
"""Keep one project scope for every hook in a coding-agent session."""
current = resolve_repo(cwd)
if session_id == "unknown-session":
return current
@@ -1041,78 +799,77 @@ class EvidenceStore:
def prepare_flush(
self, repo: RepoContext, session_id: str, reason: str
) -> tuple[str, list[dict[str, Any]]] | None:
existing = self.conn.execute(
"""SELECT * FROM flushes
WHERE repo_id = ? AND session_id = ?
AND status NOT IN ('semantic-succeeded', 'explicitly-stored', 'gave-up')
ORDER BY created_at LIMIT 1""",
(repo.identity, session_id),
).fetchone()
if existing and int(existing["attempts"] or 0) >= MAX_FLUSH_ATTEMPTS:
with self.conn:
with self.conn:
self.conn.execute("BEGIN IMMEDIATE")
existing = self.conn.execute(
"""SELECT * FROM flushes
WHERE repo_id = ? AND session_id = ?
AND status NOT IN ('semantic-succeeded', 'explicitly-stored', 'gave-up')
ORDER BY created_at LIMIT 1""",
(repo.identity, session_id),
).fetchone()
if existing and int(existing["attempts"] or 0) >= MAX_FLUSH_ATTEMPTS:
self.conn.execute(
"UPDATE flushes SET status = 'gave-up', updated_at = ? WHERE packet_id = ?",
(utc_now(), existing["packet_id"]),
)
telemetry.record(
"flush",
repo=repo,
session_id=session_id,
reason=reason,
status="gave-up",
success=False,
attempts=int(existing["attempts"] or 0),
)
existing = None
if existing:
if reason != "periodic" and existing["reason"] == "periodic":
with self.conn:
telemetry.record(
"flush",
repo=repo,
session_id=session_id,
reason=reason,
status="gave-up",
success=False,
attempts=int(existing["attempts"] or 0),
)
existing = None
if existing:
if reason != "periodic" and existing["reason"] == "periodic":
self.conn.execute(
"UPDATE flushes SET reason = ?, updated_at = ? WHERE packet_id = ?",
(reason, utc_now(), existing["packet_id"]),
)
existing_rows = self.conn.execute(
"SELECT * FROM events WHERE flush_id = ? ORDER BY id",
(existing["packet_id"],),
existing_rows = self.conn.execute(
"SELECT * FROM events WHERE flush_id = ? ORDER BY id",
(existing["packet_id"],),
).fetchall()
if existing_rows:
return str(existing["packet_id"]), [
{
"id": row["id"],
"created_at": row["created_at"],
"kind": row["kind"],
"payload": json.loads(row["payload_json"]),
}
for row in existing_rows
]
rows = self.conn.execute(
"""SELECT * FROM events
WHERE repo_id = ? AND session_id = ? AND flush_id IS NULL
ORDER BY id""",
(repo.identity, session_id),
).fetchall()
if existing_rows:
return str(existing["packet_id"]), [
{
"id": row["id"],
"created_at": row["created_at"],
"kind": row["kind"],
"payload": json.loads(row["payload_json"]),
}
for row in existing_rows
]
if not rows:
return None
rows = self.conn.execute(
"""SELECT * FROM events
WHERE repo_id = ? AND session_id = ? AND flush_id IS NULL
ORDER BY id""",
(repo.identity, session_id),
).fetchall()
if not rows:
return None
events = [
{
"id": row["id"],
"created_at": row["created_at"],
"kind": row["kind"],
"payload": json.loads(row["payload_json"]),
}
for row in rows
]
events = select_checkpoint_events(events, force=reason != "periodic")
if not events:
return None
event_start, event_end = events[0]["id"], events[-1]["id"]
packet_material = f"{repo.identity}\0{session_id}\0{event_start}\0{event_end}"
packet_id = hashlib.sha256(packet_material.encode()).hexdigest()[:32]
now = utc_now()
events = [
{
"id": row["id"],
"created_at": row["created_at"],
"kind": row["kind"],
"payload": json.loads(row["payload_json"]),
}
for row in rows
]
events = select_checkpoint_events(events, force=reason != "periodic")
if not events:
return None
event_start, event_end = events[0]["id"], events[-1]["id"]
packet_material = f"{repo.identity}\0{session_id}\0{event_start}\0{event_end}"
packet_id = hashlib.sha256(packet_material.encode()).hexdigest()[:32]
now = utc_now()
with self.conn:
self.conn.execute(
"""INSERT OR IGNORE INTO flushes
(packet_id, repo_id, app_id, session_id, reason, event_start,
@@ -1137,7 +894,7 @@ class EvidenceStore:
f"WHERE id IN ({placeholders}) AND flush_id IS NULL",
(packet_id, *event_ids),
)
return packet_id, events
return packet_id, events
def checkpoint_due(self, repo_id: str, session_id: str) -> bool:
if self.has_inflight_flush(repo_id, session_id):
@@ -1318,9 +1075,19 @@ class EvidenceStore:
agent_type: str,
transcript_path: str,
final_message: str,
) -> None:
) -> str:
now = utc_now()
with self.conn:
self.conn.execute("BEGIN IMMEDIATE")
try:
if not agent_id:
rows = self.conn.execute(
"""SELECT agent_id FROM sidekick_runs
WHERE repo_id = ? AND session_id = ? AND agent_type = ? AND stopped_at IS NULL
LIMIT 2""",
(repo.identity, session_id, agent_type),
).fetchall()
# Without a host ID, overlapping runs cannot be correlated reliably.
agent_id = rows[0]["agent_id"] if len(rows) == 1 else f"unknown-agent-{time.time_ns()}"
self.conn.execute(
"""INSERT INTO sidekick_runs
(repo_id, session_id, agent_id, agent_type, started_at,
@@ -1338,9 +1105,14 @@ class EvidenceStore:
now,
now,
bounded(transcript_path, 2000),
bounded(final_message, MAX_ASSISTANT_CHARS),
redact(final_message).strip(),
),
)
self.conn.commit()
except Exception:
self.conn.rollback()
raise
return agent_id
def operation(
self,
@@ -1518,7 +1290,7 @@ def record_user_prompt(
) -> tuple[RepoContext, str, str, bool]:
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
prompt = bounded(hook_input.get("prompt", ""), MAX_PROMPT_CHARS)
prompt = redact(hook_input.get("prompt", "")).strip()
is_first_prompt = not store.has_event(repo.identity, session_id, "user_prompt")
store.record_event(repo, session_id, "user_prompt", {"text": prompt})
return repo, session_id, prompt, is_first_prompt
@@ -1543,7 +1315,7 @@ def _tool_result_preview(response: Any) -> str:
return bounded(response, MAX_RESULT_CHARS)
def tool_payload(hook_input: dict[str, Any], *, failed: bool = False) -> dict[str, Any]:
def tool_payload(hook_input: dict[str, Any], *, failed: bool | None = False) -> dict[str, Any]:
name = str(hook_input.get("tool_name") or "unknown")
tool_input = hook_input.get("tool_input") or {}
if not isinstance(tool_input, dict):
@@ -1597,7 +1369,7 @@ def tool_payload(hook_input: dict[str, Any], *, failed: bool = False) -> dict[st
def record_tool(
store: EvidenceStore, hook_input: dict[str, Any], *, failed: bool = False
store: EvidenceStore, hook_input: dict[str, Any], *, failed: bool | None = False
) -> None:
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
@@ -1612,51 +1384,8 @@ def record_tool(
)
def record_stop(
store: EvidenceStore, hook_input: dict[str, Any]
) -> tuple[RepoContext, str]:
session_id = _session_id(hook_input)
repo = store.repo_for_session(session_id, hook_input.get("cwd"))
message = bounded(hook_input.get("last_assistant_message", ""), MAX_ASSISTANT_CHARS)
previous_stop = store.latest_event_payload(
repo.identity, session_id, "assistant_stop"
)
latest_prompt = store.latest_event_payload(repo.identity, session_id, "user_prompt")
transcript_path = str(hook_input.get("transcript_path") or "")
previous_offset = previous_stop.get("transcript_offset")
start_offset = (
previous_offset
if isinstance(previous_offset, int)
and str(previous_stop.get("transcript_path") or "") == transcript_path
else 0
)
transcript_messages, leaf_uuid, end_offset = transcript_extraction_messages(
transcript_path,
session_id,
previous_leaf_uuid=str(previous_stop.get("transcript_leaf_uuid") or ""),
prompt_hint=str(latest_prompt.get("text") or ""),
fallback_assistant_message=message,
label_final_response=True,
start_offset=start_offset,
)
if transcript_messages:
payload: dict[str, Any] = {
"text": message,
"transcript_messages": transcript_messages,
}
if leaf_uuid:
payload["transcript_leaf_uuid"] = leaf_uuid
if transcript_path:
payload["transcript_path"] = transcript_path
payload["transcript_offset"] = end_offset
store.record_event(
repo, session_id, "assistant_stop", payload
)
return repo, session_id
def record_sidekick_start(
store: EvidenceStore, hook_input: dict[str, Any]
store: EvidenceStore, hook_input: dict[str, Any], *, inject_context: bool = True
) -> str:
"""Record a native sidekick and reuse the main turn's retrieved memories."""
session_id = _session_id(hook_input)
@@ -1666,6 +1395,8 @@ def record_sidekick_start(
context = combine_context(
format_context(store.injected_memories(session_id, repo.identity))
)
if not inject_context:
context = ""
first_start = store.start_sidekick(
repo, session_id, agent_id, agent_type, len(context)
)
@@ -1694,13 +1425,11 @@ def record_sidekick_start(
def record_sidekick_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_id = bounded(hook_input.get("agent_id", "unknown-agent"), 200)
agent_type = bounded(hook_input.get("agent_type", "mem0:sidekick"), 200)
final_message = bounded(
hook_input.get("last_assistant_message", ""), MAX_ASSISTANT_CHARS
)
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)
store.stop_sidekick(
agent_id = store.stop_sidekick(
repo,
session_id,
agent_id,
@@ -1775,17 +1504,17 @@ def build_episode(
task_outcome: str = "",
) -> tuple[str, dict[str, Any]]:
prompts = [
bounded(e["payload"].get("text", ""), MAX_PROMPT_CHARS)
redact(e["payload"].get("text", "")).strip()
for e in events
if e["kind"] == "user_prompt" and e["payload"].get("text")
]
assistant_conclusions = [
bounded(e["payload"].get("text", ""), MAX_ASSISTANT_CHARS)
redact(e["payload"].get("text", "")).strip()
for e in events
if e["kind"] == "assistant_stop" and e["payload"].get("text")
]
sidekick_outcomes = [
bounded(e["payload"].get("final_message", ""), MAX_ASSISTANT_CHARS)
redact(e["payload"].get("final_message", "")).strip()
for e in events
if e["kind"] == "sidekick_stop" and e["payload"].get("final_message")
]
@@ -1812,7 +1541,7 @@ def build_episode(
{
"command": t.get("command", ""),
"kind": t.get("command_kind", "shell"),
"status": "failed" if t.get("failed") else "succeeded",
"status": "unknown" if t.get("failed", False) is None else "failed" if t.get("failed") else "succeeded",
"result": t.get("result_preview", ""),
}
for t in tools
@@ -1820,10 +1549,7 @@ def build_episode(
]
task = bounded(canonical_task or (prompts[0] if prompts else ""), 4000)
conclusion = bounded(
assistant_conclusions[-1] if assistant_conclusions else "",
MAX_ASSISTANT_CHARS,
)
conclusion = redact(assistant_conclusions[-1] if assistant_conclusions else "").strip()
outcome = bounded(task_outcome, 2000)
extraction_messages: list[dict[str, str]] = []
@@ -1835,16 +1561,14 @@ def build_episode(
pending_user_messages.append(
{
"role": "user",
"content": bounded(
event["payload"].get("text", ""), MAX_PROMPT_CHARS
),
"content": redact(event["payload"].get("text", "")).strip(),
}
)
elif event["kind"] == "assistant_stop":
transcript_messages = event["payload"].get("transcript_messages") or []
if isinstance(transcript_messages, list) and transcript_messages:
transcript_users = {
str(message.get("content") or "").strip()
redact(message.get("content") or "").strip()
for message in transcript_messages
if isinstance(message, dict) and message.get("role") == "user"
}
@@ -1856,7 +1580,7 @@ def build_episode(
extraction_messages.extend(
{
"role": str(message.get("role") or ""),
"content": str(message.get("content") or ""),
"content": redact(message.get("content") or "").strip(),
}
for message in transcript_messages
if isinstance(message, dict)
@@ -1869,10 +1593,7 @@ def build_episode(
extraction_messages.append(
{
"role": "assistant",
"content": bounded(
event["payload"].get("text", ""),
MAX_ASSISTANT_CHARS,
),
"content": redact(event["payload"].get("text", "")).strip(),
}
)
pending_user_messages = []
@@ -1972,7 +1693,7 @@ def build_extraction_messages(structured: dict[str, Any]) -> list[dict[str, str]
"""Build the session messages sent to Mem0 for memory extraction."""
evidence = build_semantic_evidence(structured)
messages = [
{"role": message["role"], "content": message["content"]}
{"role": message["role"], "content": redact(message["content"]).strip()}
for message in structured.get("extraction_messages", [])
if message.get("role") in {"user", "assistant"} and message.get("content")
]
@@ -2014,7 +1735,7 @@ def extraction_message_batches(
*,
max_tokens: int = MAX_EXTRACTION_INPUT_TOKENS,
) -> list[list[dict[str, str]]]:
"""Split large extraction input without cutting messages or agent pairs."""
"""Keep exchanges together when possible; split oversized messages to enforce the request budget."""
if not messages or _message_tokens(messages) <= max_tokens:
return [messages]
@@ -2047,9 +1768,29 @@ def extraction_message_batches(
units.append([message])
index += 1
bounded_units: list[list[dict[str, str]]] = []
for unit in units:
if _message_tokens(unit) <= max_tokens:
bounded_units.append(unit)
continue
for message in unit:
remaining = message["content"]
while remaining:
low, high = 0, len(remaining)
while low < high:
middle = (low + high + 1) // 2
if _message_tokens([{**message, "content": remaining[:middle]}]) <= max_tokens:
low = middle
else:
high = middle - 1
if low == 0:
raise ValueError("Extraction token budget cannot fit a message")
bounded_units.append([{**message, "content": remaining[:low]}])
remaining = remaining[low:]
batches: list[list[dict[str, str]]] = []
batch: list[dict[str, str]] = []
for unit in units:
for unit in bounded_units:
candidate = [*batch, *unit]
if batch and _message_tokens(candidate) > max_tokens:
batches.append(batch)
@@ -2152,6 +1893,11 @@ def _wait_for_event(api_url: str, key: str, event_id: str) -> tuple[str, int, in
key,
min(10, poll_seconds + 5),
)
except urllib.error.HTTPError as exc:
if exc.code not in {408, 429} and exc.code < 500:
raise
time.sleep(poll_seconds)
continue
except (urllib.error.URLError, TimeoutError, OSError):
# The extraction job is durable server-side. A transient polling
# failure must not discard a job that may still complete normally.
@@ -2217,7 +1963,7 @@ def flush_session(
task_outcome=bounded(hook_input.get("task_outcome", ""), 2000),
)
metadata = {"source": "claude_code_plugin"}
metadata = {"source": _harness_source_tag}
if repo.branch and repo.branch not in {"detached", "unknown"}:
metadata["branch"] = repo.branch
if repo.head_sha:
@@ -2684,7 +2430,7 @@ def format_context(
def format_search_result(result: MemorySearchResult) -> str:
"""Return only the text Claude needs from an explicit memory search."""
"""Return only the text the coding agent needs from an explicit memory search."""
if not result.succeeded:
return "Memory search failed."
if result.memories:
@@ -2731,7 +2477,11 @@ def _scoped_memory_ids(
app_id_prefix=prefix,
)
if include_project:
_collect_memory_ids(api_url, key, {"agent_id": repo.project_id}, ids, seen)
for project_id in _shared_project_ids(repo):
_collect_memory_ids(
api_url, key, {"agent_id": project_id}, ids, seen,
app_id_prefix=prefix,
)
return ids
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Anonymous usage telemetry for the Mem0 Claude Code plugin.
"""Anonymous usage telemetry for Mem0 agent plugins.
Hooks run on a 3-6 second budget and fire on every tool call, so recording never
touches the network: `record` appends one JSON line to a local spool and returns.
@@ -29,6 +29,38 @@ from typing import Any
import memory_core
_harness: str = "generic"
_source_tag: str = "MEM0_PLUGIN"
_PRIVATE_KEYS = {
"apikey",
"authorization",
"password",
"query",
"secret",
"prompt",
"token",
"text",
"memory",
"message",
"error",
"path",
"cwd",
"userid",
"agentid",
"runid",
"repoid",
"repositoryid",
"projectid",
"appid",
"filters",
}
def init(harness: str = "generic", source_tag: str = "") -> None:
global _harness, _source_tag
_harness = harness
_source_tag = source_tag or f"MEM0_{harness.upper().replace('-', '_')}_PLUGIN"
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_CAPTURE_URL = "https://us.i.posthog.com/i/v0/e/"
POSTHOG_BATCH_URL = "https://us.i.posthog.com/batch/"
@@ -54,6 +86,23 @@ def _digest(value: str, length: int = 16) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:length]
def _safe_value(value: Any) -> Any:
if isinstance(value, str):
return memory_core.redact(value)
if isinstance(value, dict):
return {
key: _safe_value(item)
for key, item in value.items()
if "".join(character for character in str(key).lower() if character.isalnum())
not in _PRIVATE_KEYS
}
if isinstance(value, (list, tuple)):
return [_safe_value(item) for item in value]
if value is None or isinstance(value, (bool, int, float)):
return value
return memory_core.redact(value)
def _spool_path() -> Path:
return memory_core.data_dir() / "telemetry.jsonl"
@@ -118,8 +167,9 @@ def record(
return
except OSError:
pass
properties = _safe_value(properties)
properties.update(
harness="claude-code",
harness=_harness,
plugin_version=memory_core.PLUGIN_VERSION,
os=sys.platform,
python_version=platform.python_version(),
@@ -316,7 +366,7 @@ def flush() -> int:
"distinct_id": distinct_id,
"timestamp": event.get("timestamp"),
"properties": {
"source": "CLAUDE_CODE_PLUGIN",
"source": _source_tag,
"language": "python",
"$process_person_profile": False,
"$lib": "posthog-python",
@@ -0,0 +1,17 @@
{
"id": "mem0",
"version": "0.3.1",
"homepage": "https://docs.mem0.ai/integrations/claude-code",
"native": {
"pluginRoot": "${CLAUDE_PLUGIN_ROOT}",
"pluginData": "${CLAUDE_PLUGIN_DATA}",
"files": {
".claude-plugin/plugin.json": ".claude-plugin/plugin.json",
".mcp.json": ".mcp.json",
"hooks/hooks.json": "hooks/hooks.json",
"adapters/claude/hook.py": "adapters/claude/hook.py",
"adapters/claude/transcript.py": "adapters/claude/transcript.py",
"agents/sidekick.md": "agents/sidekick.md"
}
}
}
@@ -14,7 +14,7 @@ so it stays unless the user explicitly asks to delete that too.
After the user confirms, run:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" forget --remote --yes
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" --harness "claude-code" --plugin-data-dir "${CLAUDE_PLUGIN_DATA}" forget --remote --yes
```
If the user also asked to delete the repository's shared project memory, add
@@ -11,7 +11,7 @@ anonymous telemetry ping still fires at session start unless
`MEM0_TELEMETRY=false`):
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" pause
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" --harness "claude-code" --plugin-data-dir "${CLAUDE_PLUGIN_DATA}" pause
```
Confirm the new state back to the user, and remind them that already-created
@@ -11,7 +11,7 @@ Resume memory capture for this machine.
Run:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" resume
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" --harness "claude-code" --plugin-data-dir "${CLAUDE_PLUGIN_DATA}" resume
```
Confirm to the user that capture is active again. New sessions record evidence and
@@ -20,7 +20,9 @@ preferences.
Pass `scope` when the question needs something else: `dir` to narrow the
shared memory to the directory you are working in (a package inside a
monorepo), `mine` for your own preferences alone. Pass `run_id` with a Claude
Code session ID to look at what one earlier session recorded, for example to
pick up where a compacted or closed session left off. Return the tool's result
directly.
monorepo), `mine` for your own preferences alone.
Pass `run_id` with any scope to retrieve memories saved in a specific coding-agent
session. Omit `run_id` to search across sessions. It filters the memories returned;
it does not identify the session making the search request. Use a known session ID,
never invent one. Return the tool's result directly.
@@ -9,8 +9,8 @@ disable-model-invocation: false
Run both commands and report the combined result in plain language:
```bash
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" status --json
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" doctor
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" --harness "claude-code" --plugin-data-dir "${CLAUDE_PLUGIN_DATA}" status --json
python3 "${CLAUDE_PLUGIN_ROOT}/core/memory_cli.py" --harness "claude-code" --plugin-data-dir "${CLAUDE_PLUGIN_DATA}" doctor
```
Summarize, using only fields the JSON actually reports: whether capture is
@@ -1,7 +1,20 @@
"""Keep the test suite from sending usage telemetry to the live PostHog project."""
"""Configure harness identity and disable live telemetry for the test suite."""
from __future__ import annotations
import os
import sys
from pathlib import Path
os.environ["MEM0_TELEMETRY"] = "false"
HOST_ROOT = Path(__file__).resolve().parents[1]
_core = HOST_ROOT / "core"
sys.path.insert(0, str(_core))
import hook_runner # noqa: E402,F401
from memory_core import configure_harness # noqa: E402
import telemetry # 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")
@@ -13,10 +13,11 @@ from pathlib import Path
import pytest
PLUGIN_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PLUGIN_ROOT / "core"))
sys.path.insert(0, str(PLUGIN_ROOT / "adapters" / "claude"))
_core_dir = PLUGIN_ROOT / "core"
sys.path.insert(0, str(_core_dir))
sys.path.insert(0, str(PLUGIN_ROOT))
import hook # noqa: E402
import hook_runner # noqa: E402
import memory_core # noqa: E402
pytestmark = pytest.mark.skipif(
@@ -177,15 +178,17 @@ def test_shared_project_memory_reaches_a_teammate_who_never_wrote(ns):
def test_repo_scope_spans_every_subdirectory(ns):
found = ns.search("erin", ns.root, "how many times are Stripe webhooks retried", scope="repo")
assert "stripe" in _text(found)
app_ids = {m.get("app_id") for m in found}
assert any(app.endswith("/services/billing") for app in app_ids)
assert any("services/billing" in (memory.get("metadata") or {}).get("dirs", []) for memory in found)
def test_dir_scope_narrows_shared_memory_to_the_directory(ns):
billing = ns.root / "services" / "billing"
found = ns.search("erin", billing, "how do I run the tests here", scope="dir")
assert "billing-test" in _text(found)
assert {m.get("app_id") for m in found if m.get("agent_id")} == {memory_core.directory_app_id(memory_core.resolve_repo(str(billing)))}
shared = [memory for memory in found if memory.get("agent_id")]
assert shared
assert {memory.get("app_id") for memory in shared} == {memory_core.resolve_repo(str(billing)).app_id}
assert all("services/billing" in (memory.get("metadata") or {}).get("dirs", []) for memory in shared)
web = ns.root / "apps" / "web"
elsewhere = ns.search("erin", web, "Stripe webhook retries exponential backoff", scope="dir", tries=1)
@@ -232,7 +235,7 @@ def test_a_pending_packet_is_recovered_and_delivered_by_the_worker(ns):
stale.write_text(json.dumps({"hook_input": {"session_id": sid, "cwd": str(ns.root)}, "reason": "session-end"}))
os.utime(stale, (time.time() - 3600, time.time() - 3600))
assert hook.recover_pending_handoffs() == 1
assert hook_runner.recover_pending_handoffs() == 1
deadline = time.time() + 240
while time.time() < deadline and list(pending.iterdir()):
time.sleep(3)
@@ -0,0 +1,35 @@
from __future__ import annotations
import sys
from pathlib import Path
HOST = Path(__file__).resolve().parents[1]
CORE_ROOT = HOST.parent / "agent-plugin-core"
sys.path.insert(0, str(CORE_ROOT))
from build.build import SHARED_SKILLS, build, render_template # noqa: E402
def test_native_claude_bundle_preserves_working_contract(tmp_path: Path) -> None:
root = build("claude-code", "native", tmp_path / "claude-code")
assert (root / ".claude-plugin" / "plugin.json").read_bytes() == (
HOST / ".claude-plugin" / "plugin.json"
).read_bytes()
assert (root / ".mcp.json").read_bytes() == (HOST / ".mcp.json").read_bytes()
assert (root / "hooks" / "hooks.json").read_bytes() == (HOST / "hooks" / "hooks.json").read_bytes()
assert (root / "agents" / "sidekick.md").read_bytes() == (HOST / "agents" / "sidekick.md").read_bytes()
values = {
"PLUGIN_ROOT": "${CLAUDE_PLUGIN_ROOT}",
"PLUGIN_DATA": "${PLUGIN_DATA}",
"PLUGIN_DATA_ARG": '--plugin-data-dir "${CLAUDE_PLUGIN_DATA}"',
"COMMAND_PREFIX": "mem0",
"HARNESS_ID": "claude-code",
"HARNESS_NAME": "Claude Code",
}
for skill in SHARED_SKILLS.glob("*/SKILL.md.tmpl"):
rendered = render_template(skill.read_text(encoding="utf-8"), values)
assert (root / "skills" / skill.parent.name / "SKILL.md").read_text(encoding="utf-8") == rendered
assert (root / "adapters" / "claude" / "hook.py").is_file()
assert (root / "core" / "mcp_server.py").is_file()
assert not any(path.is_symlink() for path in root.rglob("*"))
File diff suppressed because it is too large Load Diff
@@ -7,9 +7,8 @@ from unittest.mock import patch
import pytest
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
CORE = PLUGIN_ROOT / "core"
HOST_ROOT = Path(__file__).resolve().parents[1]
CORE = HOST_ROOT / "core"
sys.path.insert(0, str(CORE))
import memory_core # noqa: E402
@@ -77,6 +76,46 @@ def test_record_hashes_identifiers_and_keeps_no_content(isolated_env):
assert "session-abcdef" not in serialized
def test_record_rejects_sensitive_properties_at_the_shared_boundary(isolated_env):
secret = "sk-eval-12345678901234567890"
telemetry.record(
"search",
prompt=f"remember {secret}",
query=secret,
api_key=secret,
user_id="private-user",
note=f"failure contained {secret}",
memory_count=2,
)
(event,) = spool_lines()
assert event["properties"]["memory_count"] == 2
serialized = json.dumps(event)
assert secret not in serialized
assert "private-user" not in serialized
assert not {"prompt", "query", "api_key", "user_id"} & event["properties"].keys()
@pytest.mark.parametrize("key", ["password", "token", "secret", "authorization"])
def test_record_removes_sensitive_keys_from_nested_lists(isolated_env, key):
secret = "sk-eval-12345678901234567890"
telemetry.record(
"search",
details=[
{key: "plain-value", "count": 2},
{"nested": {key.upper(): "plain-value", "ok": True}},
[f"failure contained {secret}"],
],
)
(event,) = spool_lines()
assert event["properties"]["details"] == [
{"count": 2},
{"nested": {"ok": True}},
["failure contained [REDACTED]"],
]
def test_record_stops_appending_past_the_spool_cap(isolated_env):
spool = memory_core.data_dir() / "telemetry.jsonl"
spool.parent.mkdir(parents=True, exist_ok=True)