refactor(integrations): shared agent plugin runtimes and native adapters (#7203)
This commit is contained in:
@@ -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/
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user