feat(mem0-agent): read path, hooks, maintenance, CLI and skills

- pack.py: single-call context pack, budgeted, sanitized, cached, with
  served-id tracking that turns a citation into POSITIVE feedback
- assist.py: error-signature lookup (never raw stdout, which returned 0 results in v1)
- maintain.py: transactional consolidation (add merged -> verify -> delete sources)
- transcript.py: JSONL parsing into non-overlapping windows via a cursor
- cli.py: the twelve commands the hooks invoke; detached assist queues a block
  that the next prompt hook drains, keeping the hot path network-free
- hooks generated from one spec; plugin manifest, MCP wiring, five skills

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Deshraj Yadav
2026-07-28 01:56:38 -07:00
co-authored by Claude Fable 5
parent e102b21180
commit 3ea00cf88c
33 changed files with 5934 additions and 1 deletions
+348
View File
@@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""Generate editor hook manifests from hooks.spec.yaml.
v1 kept four hand-written hook manifests (Claude Code, Cursor, Codex, Antigravity).
They drifted apart -- events present in one file and missing in another, timeouts that
disagreed, and hooks that had been dead in two editors for months without anyone
noticing. This script makes the spec the only thing a human edits.
python3 hooks/generate.py # write manifests for every supported editor
python3 hooks/generate.py --check # exit 1 if the committed manifests differ
The YAML parser here is a deliberately tiny stdlib-only subset (maps, lists of maps,
quoted/bare scalars, comments) because the package has zero runtime dependencies and
the spec is kept simple enough to parse. The spec is still valid YAML, so an editor's
syntax highlighting and any real YAML parser agree with this one.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
HOOKS_DIR = Path(__file__).resolve().parent
SPEC_PATH = HOOKS_DIR / "hooks.spec.yaml"
OUT_DIR = HOOKS_DIR / "generated"
HOOK_FIELDS = {
"id", "event", "matcher", "command", "timeout",
"background", "blocking", "local_only", "status_message", "why",
}
REQUIRED_HOOK_FIELDS = {"id", "event", "command", "why"}
# Events that run on the hot path of every turn and therefore may never do network I/O.
LOCAL_ONLY_EVENTS = {"UserPromptSubmit"}
class SpecError(RuntimeError):
"""The spec is malformed or violates a wiring rule."""
# --------------------------------------------------------------------------- YAML
_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]*:")
def _strip_comment(line: str) -> str:
"""Drop a trailing `#` comment without touching `#` inside quotes."""
out: list[str] = []
quote: str | None = None
i = 0
while i < len(line):
ch = line[i]
if quote:
if ch == "\\" and i + 1 < len(line):
out.append(ch)
out.append(line[i + 1])
i += 2
continue
if ch == quote:
quote = None
out.append(ch)
elif ch in "\"'":
quote = ch
out.append(ch)
elif ch == "#" and (not out or out[-1] in " \t"):
break
else:
out.append(ch)
i += 1
return "".join(out).rstrip()
def _scalar(token: str) -> Any:
t = token.strip()
if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'":
return t[1:-1].replace('\\"', '"').replace("\\'", "'")
low = t.lower()
if low in ("", "null", "~"):
return None
if low == "true":
return True
if low == "false":
return False
try:
return int(t)
except ValueError:
pass
try:
return float(t)
except ValueError:
pass
return t
def _lines(text: str) -> list[tuple[int, str]]:
rows: list[tuple[int, str]] = []
for raw in text.splitlines():
if "\t" in raw[: len(raw) - len(raw.lstrip())]:
raise SpecError("tabs are not allowed for indentation")
stripped = _strip_comment(raw)
if not stripped.strip():
continue
rows.append((len(stripped) - len(stripped.lstrip(" ")), stripped.strip()))
return rows
def _parse_block(rows: list[tuple[int, str]], i: int, indent: int) -> tuple[Any, int]:
if rows[i][1].startswith("- "):
return _parse_list(rows, i, indent)
return _parse_map(rows, i, indent)
def _parse_map(rows: list[tuple[int, str]], i: int, indent: int) -> tuple[dict, int]:
obj: dict[str, Any] = {}
while i < len(rows):
ind, text = rows[i]
if ind < indent:
break
if ind > indent:
raise SpecError(f"unexpected indentation at: {text!r}")
if text.startswith("- "):
break
if not _KEY_RE.match(text):
raise SpecError(f"expected `key: value`, got: {text!r}")
key, _, rest = text.partition(":")
key = key.strip()
if rest.strip():
obj[key] = _scalar(rest)
i += 1
continue
# Block value: everything indented deeper than this key.
if i + 1 < len(rows) and rows[i + 1][0] > ind:
obj[key], i = _parse_block(rows, i + 1, rows[i + 1][0])
else:
obj[key] = None
i += 1
return obj, i
def _parse_list(rows: list[tuple[int, str]], i: int, indent: int) -> tuple[list, int]:
items: list[Any] = []
while i < len(rows) and rows[i][0] == indent and rows[i][1].startswith("- "):
head = rows[i][1][2:].strip()
children: list[tuple[int, str]] = []
j = i + 1
while j < len(rows) and rows[j][0] > indent:
children.append(rows[j])
j += 1
if _KEY_RE.match(head):
sub = [(indent + 2, head), *children]
value, _ = _parse_map(sub, 0, indent + 2)
items.append(value)
else:
if children:
raise SpecError(f"scalar list item cannot have children: {head!r}")
items.append(_scalar(head))
i = j
return items, i
def parse_yaml(text: str) -> dict:
rows = _lines(text)
if not rows:
return {}
value, _ = _parse_block(rows, 0, rows[0][0])
if not isinstance(value, dict):
raise SpecError("spec must be a mapping at the top level")
return value
# --------------------------------------------------------------------------- spec
def load_spec(path: Path = SPEC_PATH) -> dict:
spec = parse_yaml(path.read_text())
validate(spec)
return spec
def validate(spec: dict) -> dict:
if not isinstance(spec.get("editors"), list) or not spec["editors"]:
raise SpecError("spec needs a non-empty `editors:` list")
if not isinstance(spec.get("hooks"), list) or not spec["hooks"]:
raise SpecError("spec needs a non-empty `hooks:` list")
seen_editors = set()
for ed in spec["editors"]:
for field in ("id", "supported", "output", "why"):
if field not in ed:
raise SpecError(f"editor {ed.get('id')!r} is missing `{field}`")
if ed["id"] in seen_editors:
raise SpecError(f"duplicate editor id {ed['id']!r}")
seen_editors.add(ed["id"])
if not isinstance(ed["supported"], bool):
raise SpecError(f"editor {ed['id']!r}: `supported` must be a boolean")
seen_hooks = set()
for entry in spec["hooks"]:
missing = REQUIRED_HOOK_FIELDS - set(entry)
if missing:
raise SpecError(f"hook {entry.get('id')!r} is missing {sorted(missing)}")
unknown = set(entry) - HOOK_FIELDS
if unknown:
raise SpecError(f"hook {entry['id']!r} has unknown fields {sorted(unknown)}")
if entry["id"] in seen_hooks:
raise SpecError(f"duplicate hook id {entry['id']!r}")
seen_hooks.add(entry["id"])
for flag in ("background", "blocking", "local_only"):
value = entry.get(flag, spec.get("defaults", {}).get(flag))
if not isinstance(value, bool):
raise SpecError(f"hook {entry['id']!r}: `{flag}` must be a boolean")
if entry.get("background") and entry.get("blocking"):
raise SpecError(f"hook {entry['id']!r}: a backgrounded hook cannot also be blocking")
if entry["event"] in LOCAL_ONLY_EVENTS and not entry.get("local_only"):
raise SpecError(
f"hook {entry['id']!r} runs on {entry['event']} and must declare local_only: true"
)
if not str(entry["command"]).startswith("mem0-agent "):
raise SpecError(f"hook {entry['id']!r}: command must invoke the mem0-agent CLI")
return spec
def editor(spec: dict, editor_id: str) -> dict:
for ed in spec["editors"]:
if ed["id"] == editor_id:
return ed
raise SpecError(f"unknown editor {editor_id!r}")
def supported_editors(spec: dict) -> list[dict]:
return [ed for ed in spec["editors"] if ed["supported"]]
# ----------------------------------------------------------------------- rendering
def render_command(entry: dict, ed: dict, defaults: dict | None = None) -> str:
"""Spec command -> the exact shell string an editor will run.
Placeholders are editor-specific, MEM0_EDITOR is always pinned (so a hook cannot
be misattributed), MEM0_LOCAL_ONLY is the machine-enforced half of the local-only
contract, and background hooks are detached so a network write can never be on the
developer's critical path.
"""
defaults = defaults or {}
command = str(entry["command"])
for name, value in (ed.get("placeholders") or {}).items():
command = command.replace("{" + name + "}", f'"{value}"')
left = re.search(r"\{[a-z_]+\}", command)
if left:
raise SpecError(f"hook {entry['id']!r}: editor {ed['id']!r} has no value for {left.group()}")
env = dict(ed.get("env") or {})
if entry.get("local_only", defaults.get("local_only", False)):
env["MEM0_LOCAL_ONLY"] = "1"
prefix = "".join(f"{k}={v} " for k, v in env.items())
command = prefix + command
if entry.get("background", defaults.get("background", False)):
command = f"({command} >/dev/null 2>&1 &)"
return command
def build_manifest(spec: dict, editor_id: str = "claude-code") -> dict:
"""Emit the nested schema Claude Code expects.
{"hooks": {"<Event>": [{"matcher": ..., "hooks": [{"type": "command", ...}]}]}}
Entries sharing an event and matcher are merged into one group, in spec order.
"""
ed = editor(spec, editor_id)
if ed.get("dialect") != "claude-code":
raise SpecError(f"editor {editor_id!r} uses dialect {ed.get('dialect')!r}, not yet emitted")
defaults = spec.get("defaults") or {}
events: dict[str, list[dict]] = {}
for entry in spec["hooks"]:
step: dict[str, Any] = {
"type": "command",
"command": render_command(entry, ed, defaults),
"timeout": int(entry.get("timeout") or defaults.get("timeout") or 10),
}
if entry.get("status_message"):
step["statusMessage"] = entry["status_message"]
groups = events.setdefault(entry["event"], [])
matcher = entry.get("matcher")
for group in groups:
if group.get("matcher") == matcher:
group["hooks"].append(step)
break
else:
group = {}
if matcher:
group["matcher"] = matcher
group["hooks"] = [step]
groups.append(group)
return {"hooks": events}
def serialize(manifest: dict) -> str:
return json.dumps(manifest, indent=2) + "\n"
def output_path(spec: dict, editor_id: str) -> Path:
return (HOOKS_DIR / editor(spec, editor_id)["output"]).resolve()
# ----------------------------------------------------------------------------- CLI
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Generate editor hook manifests from hooks.spec.yaml")
ap.add_argument("--check", action="store_true",
help="regenerate in memory and exit non-zero if the committed manifest differs")
ap.add_argument("--editor", action="append", default=None,
help="limit to one editor id (default: every supported editor)")
ap.add_argument("--spec", type=Path, default=SPEC_PATH)
args = ap.parse_args(argv)
try:
spec = load_spec(args.spec)
except (SpecError, OSError) as exc:
print(f"spec error: {exc}", file=sys.stderr)
return 2
ids = args.editor or [ed["id"] for ed in supported_editors(spec)]
drift = 0
for editor_id in ids:
try:
manifest = build_manifest(spec, editor_id)
except SpecError as exc:
print(f"{editor_id}: {exc}", file=sys.stderr)
return 2
wanted = serialize(manifest)
path = output_path(spec, editor_id)
events = ", ".join(manifest["hooks"])
if args.check:
current = path.read_text() if path.exists() else None
if current != wanted:
what = "missing" if current is None else "out of date"
print(f"DRIFT {path.name} is {what}; run `python3 hooks/generate.py`", file=sys.stderr)
drift += 1
else:
print(f"ok {path.name} ({len(manifest['hooks'])} events: {events})")
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(wanted)
print(f"wrote {path} ({len(manifest['hooks'])} events: {events})")
return 1 if drift else 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,73 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|compact",
"hooks": [
{
"type": "command",
"command": "MEM0_EDITOR=claude-code mem0-agent context --session-id \"$CLAUDE_SESSION_ID\"",
"timeout": 10,
"statusMessage": "Loading mem0 context..."
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "MEM0_EDITOR=claude-code MEM0_LOCAL_ONLY=1 mem0-agent observe --source prompt",
"timeout": 3
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "(MEM0_EDITOR=claude-code mem0-agent assist-error >/dev/null 2>&1 &)",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "(MEM0_EDITOR=claude-code mem0-agent flush >/dev/null 2>&1 &)",
"timeout": 5
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "(MEM0_EDITOR=claude-code mem0-agent flush --reason precompact >/dev/null 2>&1 &)",
"timeout": 5
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "(MEM0_EDITOR=claude-code mem0-agent flush --reason end >/dev/null 2>&1 &)",
"timeout": 5
}
]
}
]
}
}
@@ -0,0 +1,135 @@
# mem0-agent hook wiring -- the single source of truth.
#
# v1 hand-maintained four editor dialects (Claude Code, Cursor, Codex, Antigravity).
# They drifted: an event existed in one file and not another, timeouts disagreed, and
# a hook was silently non-functional in two editors for months. Nothing here is edited
# per editor. Run `python3 hooks/generate.py` to emit the manifests into hooks/generated/,
# and `python3 hooks/generate.py --check` in CI to fail the build when they drift.
#
# Field reference for `hooks:` entries
# event editor lifecycle event the hook binds to
# matcher regex the editor matches against the event subject (null = all)
# command shell command; {placeholders} are filled per editor
# timeout seconds the editor waits before killing the hook
# background true = detach and return immediately; output is discarded
# blocking true = the editor waits for stdout and may inject it into the session
# local_only true = the hook MUST NOT touch the network (enforced via MEM0_LOCAL_ONLY=1)
# status_message spinner text while a blocking hook runs (omit for silent hooks)
# why one line: why this hook exists at all
version: 1
policy: "v2"
defaults:
timeout: 10
background: false
blocking: false
local_only: false
editors:
- id: claude-code
supported: true
output: "generated/claude-code.hooks.json"
dialect: "claude-code"
env:
MEM0_EDITOR: "claude-code"
placeholders:
session_id: "$CLAUDE_SESSION_ID"
why: "Reference target. Nested schema: hooks -> Event -> [{matcher, hooks:[{type,command,timeout}]}]."
- id: cursor
supported: false
output: "generated/cursor.hooks.json"
dialect: "flat-camel"
env:
MEM0_EDITOR: "cursor"
placeholders:
session_id: "$CURSOR_SESSION_ID"
why: "Flat camelCase events (sessionStart) with no inner hooks array, and no SessionEnd/PreCompact equivalent. Declared so the gap is visible; unsupported until the dialect is verified end to end."
- id: codex
supported: false
output: "generated/codex.hooks.json"
dialect: "claude-code"
env:
MEM0_EDITOR: "codex"
placeholders:
session_id: "$CODEX_SESSION_ID"
why: "Same nested schema as Claude Code but hooks are installed into ~/.codex by a script rather than discovered from the package. Unsupported until that installer is rewritten for v2."
- id: antigravity
supported: false
output: "generated/antigravity.hooks.json"
dialect: "claude-code"
env:
MEM0_EDITOR: "antigravity"
placeholders:
session_id: "$ANTIGRAVITY_SESSION_ID"
why: "Ships no stable hook contract yet. Declared so it is never re-added as a hand-written fifth file."
hooks:
- id: session-context
event: "SessionStart"
matcher: "startup|resume|compact"
command: "mem0-agent context --session-id {session_id}"
timeout: 10
background: false
blocking: true
local_only: false
status_message: "Loading mem0 context..."
why: "The one retrieval per session. Injects durable project knowledge plus the open-thread snapshot so the assistant starts where the last session ended."
- id: prompt-observe
event: "UserPromptSubmit"
matcher: null
command: "mem0-agent observe --source prompt"
timeout: 3
background: false
blocking: true
local_only: true
status_message: null
why: "Runs on the hot path of every keystroke-to-response, so it is local-only by contract: it scores the prompt for capture triggers and drains the pending assist buffer. v1 made a network search here and added latency to every single turn."
- id: bash-assist-error
event: "PostToolUse"
matcher: "Bash"
command: "mem0-agent assist-error"
timeout: 5
background: true
blocking: false
local_only: false
status_message: null
why: "A failing command is the highest-value retrieval moment. Detached so a lookup never stalls the tool loop; any hit is written to the session buffer and surfaced by the next UserPromptSubmit."
- id: stop-flush
event: "Stop"
matcher: null
command: "mem0-agent flush"
timeout: 5
background: true
blocking: false
local_only: false
status_message: null
why: "Turn boundary is the only place a candidate can be judged against how the turn actually ended. Batched so one turn costs at most one write."
- id: precompact-flush
event: "PreCompact"
matcher: null
command: "mem0-agent flush --reason precompact"
timeout: 5
background: true
blocking: false
local_only: false
status_message: null
why: "Compaction is where context dies. Flush the candidate buffer before it is summarized away."
- id: sessionend-flush
event: "SessionEnd"
matcher: null
command: "mem0-agent flush --reason end"
timeout: 5
background: true
blocking: false
local_only: false
status_message: null
why: "Last chance to persist the open-thread snapshot. Backgrounded so quitting the editor is never delayed by a network write."