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:
co-authored by
Claude Fable 5
parent
e102b21180
commit
3ea00cf88c
@@ -0,0 +1,187 @@
|
||||
"""The hook manifests are generated, so these tests guard the generator and the spec.
|
||||
|
||||
v1's four hand-written manifests drifted; the drift test below is the mechanism that
|
||||
makes that impossible now.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PKG = Path(__file__).resolve().parents[1]
|
||||
HOOKS = PKG / "hooks"
|
||||
sys.path.insert(0, str(HOOKS))
|
||||
|
||||
import generate # noqa: E402
|
||||
|
||||
EXPECTED_EVENTS = {
|
||||
"SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "PreCompact", "SessionEnd",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def spec():
|
||||
return generate.load_spec()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def manifest(spec):
|
||||
return generate.build_manifest(spec, "claude-code")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ spec parsing
|
||||
def test_spec_parses_into_editors_and_hooks(spec):
|
||||
assert isinstance(spec["hooks"], list) and spec["hooks"]
|
||||
ids = [e["id"] for e in spec["editors"]]
|
||||
assert ids[0] == "claude-code", "claude-code is the reference dialect and comes first"
|
||||
assert {"cursor", "codex"} <= set(ids), "other editors stay declared so gaps are visible"
|
||||
assert [e["id"] for e in generate.supported_editors(spec)] == ["claude-code"]
|
||||
|
||||
|
||||
def test_every_hook_declares_its_contract(spec):
|
||||
for entry in spec["hooks"]:
|
||||
assert entry["why"], f"{entry['id']} must say why it exists"
|
||||
assert isinstance(entry["local_only"], bool)
|
||||
assert isinstance(entry["background"], bool)
|
||||
assert entry["command"].startswith("mem0-agent ")
|
||||
|
||||
|
||||
def test_tiny_parser_handles_quotes_comments_and_nesting():
|
||||
parsed = generate.parse_yaml(
|
||||
'top: 1 # trailing comment\n'
|
||||
'# whole line comment\n'
|
||||
'block:\n'
|
||||
' flag: true\n'
|
||||
' none: null\n'
|
||||
' text: "a: b # not a comment"\n'
|
||||
'items:\n'
|
||||
' - id: one\n'
|
||||
' n: 2\n'
|
||||
' - id: two\n'
|
||||
' nested:\n'
|
||||
' k: "v"\n'
|
||||
)
|
||||
assert parsed == {
|
||||
"top": 1,
|
||||
"block": {"flag": True, "none": None, "text": "a: b # not a comment"},
|
||||
"items": [{"id": "one", "n": 2}, {"id": "two", "nested": {"k": "v"}}],
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- manifest shape
|
||||
def test_manifest_contains_every_declared_event(spec, manifest):
|
||||
declared = {e["event"] for e in spec["hooks"]}
|
||||
assert declared == EXPECTED_EVENTS
|
||||
assert set(manifest["hooks"]) == declared
|
||||
for event, groups in manifest["hooks"].items():
|
||||
for group in groups:
|
||||
for step in group["hooks"]:
|
||||
assert step["type"] == "command", event
|
||||
assert "mem0-agent " in step["command"]
|
||||
assert isinstance(step["timeout"], int)
|
||||
|
||||
|
||||
def test_manifest_matches_claude_code_schema(manifest):
|
||||
start = manifest["hooks"]["SessionStart"][0]
|
||||
assert start["matcher"] == "startup|resume|compact"
|
||||
assert "--session-id" in start["hooks"][0]["command"]
|
||||
# Events without a matcher must omit the key rather than emit null.
|
||||
assert "matcher" not in manifest["hooks"]["Stop"][0]
|
||||
assert manifest["hooks"]["PostToolUse"][0]["matcher"] == "Bash"
|
||||
|
||||
|
||||
def test_user_prompt_submit_is_declared_local_only(spec, manifest):
|
||||
entry = next(e for e in spec["hooks"] if e["event"] == "UserPromptSubmit")
|
||||
assert entry["local_only"] is True
|
||||
command = manifest["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
|
||||
assert "MEM0_LOCAL_ONLY=1" in command, "the local-only contract must be machine-enforced"
|
||||
assert entry["background"] is False and entry["blocking"] is True
|
||||
|
||||
|
||||
def test_flush_and_assist_hooks_are_detached(spec, manifest):
|
||||
for event in ("PostToolUse", "Stop", "PreCompact", "SessionEnd"):
|
||||
command = manifest["hooks"][event][0]["hooks"][0]["command"]
|
||||
assert command.startswith("(") and command.endswith("&)"), event
|
||||
assert ">/dev/null" in command, event
|
||||
|
||||
|
||||
def test_editor_env_is_pinned(manifest):
|
||||
for groups in manifest["hooks"].values():
|
||||
for group in groups:
|
||||
for step in group["hooks"]:
|
||||
assert "MEM0_EDITOR=claude-code" in step["command"]
|
||||
|
||||
|
||||
def test_unsupported_editors_are_not_emitted(spec):
|
||||
for ed in spec["editors"]:
|
||||
if not ed["supported"]:
|
||||
assert not generate.output_path(spec, ed["id"]).exists(), ed["id"]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- validation
|
||||
def test_validate_rejects_networked_user_prompt_hook(spec):
|
||||
broken = json.loads(json.dumps(spec))
|
||||
entry = next(e for e in broken["hooks"] if e["event"] == "UserPromptSubmit")
|
||||
entry["local_only"] = False
|
||||
with pytest.raises(generate.SpecError, match="local_only"):
|
||||
generate.validate(broken)
|
||||
|
||||
|
||||
def test_validate_rejects_unknown_hook_field(spec):
|
||||
broken = json.loads(json.dumps(spec))
|
||||
broken["hooks"][0]["retries"] = 3
|
||||
with pytest.raises(generate.SpecError, match="unknown fields"):
|
||||
generate.validate(broken)
|
||||
|
||||
|
||||
def test_validate_rejects_background_and_blocking(spec):
|
||||
broken = json.loads(json.dumps(spec))
|
||||
broken["hooks"][0]["background"] = True
|
||||
broken["hooks"][0]["blocking"] = True
|
||||
with pytest.raises(generate.SpecError, match="cannot also be blocking"):
|
||||
generate.validate(broken)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ drift
|
||||
def test_committed_manifest_is_up_to_date(spec, manifest):
|
||||
path = generate.output_path(spec, "claude-code")
|
||||
assert path.exists(), "run `python3 hooks/generate.py`"
|
||||
assert json.loads(path.read_text()) == manifest
|
||||
|
||||
|
||||
def test_check_flag_passes_against_committed_file():
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(HOOKS / "generate.py"), "--check"],
|
||||
capture_output=True, text=True, cwd=PKG,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
|
||||
def test_check_flag_detects_drift(tmp_path, spec):
|
||||
path = generate.output_path(spec, "claude-code")
|
||||
original = path.read_text()
|
||||
try:
|
||||
path.write_text(original.replace('"timeout": 10', '"timeout": 99'))
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(HOOKS / "generate.py"), "--check"],
|
||||
capture_output=True, text=True, cwd=PKG,
|
||||
)
|
||||
assert proc.returncode == 1
|
||||
assert "DRIFT" in proc.stderr
|
||||
finally:
|
||||
path.write_text(original)
|
||||
|
||||
|
||||
def test_generate_is_idempotent(spec):
|
||||
path = generate.output_path(spec, "claude-code")
|
||||
before = path.read_text()
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(HOOKS / "generate.py")], capture_output=True, text=True, cwd=PKG
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert path.read_text() == before
|
||||
Reference in New Issue
Block a user