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
@@ -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",