claude-skills/engineering/agent-memory/hooks/session_end.py
Claude a3b6a195cb
feat(agent-memory): implement the four-tier memory ladder
Turns DESIGN.md from a spec into a working plugin. Five stdlib scripts, three
hooks, agent, command, three references, plugin manifests.

The gates are the design:
  L1 -> L2  >= 3 distinct sessions spanning >= 2 distinct calendar days
            (`stated` = 2 sessions, day rule still applies; `verified` = 1
            observation and is the only day-exempt path)
  L2 -> L3  >= 2 distinct projects, >= 30 days, uncontested

Two gates refuse rather than guess. `redacted: true` blocks promotion on any
volume of evidence -- a durability-independent barrier, since a secret restated
across five sessions passes every recurrence gate; the flag firing means the
text was altered, a lexical filter finding one secret is not proof it found all
of them, and L2/L3 are committed to git. An open contradiction freezes both
claims, found by reverse join because the newer atom carries no flag.

All three hooks fail open: a broken memory system costs memory, never a session.
SessionEnd stages promotions to .memory/staged/ and never touches a CLAUDE.md;
only an explicit human adopt does, after backing both files up.

Verified, not asserted:
  - all three pinned atom ids from DESIGN.md reproduce exactly
  - both blocking gates demonstrated on sample input, named in the output
  - end-to-end: two transcripts across two calendar days -> merged L1 atom ->
    staged L2 promotion with the path prefix stripped
  - reverse join blocks the unflagged newer atom
  - cross-tier L2/L3 collision marked at injection time
  - recall p50 29ms / p95 31ms / max 35ms spawn-to-exit, scoring itself 2-3ms
    over 500 atoms -- interpreter cold start is the entire cost
  - validate_examples.py 69 checks 0 failures; SKILL.md 6/6 PASS
  - derive_counters --check, check_plugin_json --all, check_paths all clean

DESIGN.md 10.1's "+6" tool estimate corrected to +8 -- the delivered surface is
5 scripts + 3 hooks. README.md's deviations list is authoritative for that and
five other divergences from the pre-implementation spec.

Concept from TencentCloud/TencentDB-Agent-Memory (MIT). No upstream code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EM5xmJ7AmTMg31rq68BCym
2026-08-24 18:23:01 +00:00

83 lines
2.9 KiB
Python

#!/usr/bin/env python3
"""SessionEnd -- capture L0 -> L1, detect contradictions, stage promotions.
DESIGN.md 5.3. Runs `async: true` so it can never delay session teardown.
The pipeline, in order, because the order is the safety property:
1. extract rule-based, high-precision markers only (9.2 option (a))
2. REDACT every atom, before anything is written (6 rule 1)
3. merge increment observations, extend sessions, raise confidence to
the max -- never lower (4.1.3)
4. detect 4.2.1's two rules; mark the OLDER atom contested
5. write atomic os.replace under a 5s-bounded lock (5.4); on
contention the atoms are DROPPED and the loss is logged to
.memory/errors.log -- not stderr, which for an async hook
goes nowhere a human reads
6. promote L1->L2->L3 on recurrence, staged to .memory/staged/
Step 6 NEVER writes CLAUDE.md. Adoption is a separate, explicit, human step
(`/cs:memory adopt`), which backs both CLAUDE.md files up first.
A missing .memory/atoms.jsonl is the normal first-run state, not an error.
Disable with AGENT_MEMORY_SESSIONEND=0.
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"skills", "agent-memory", "scripts"))
def main():
if os.environ.get("AGENT_MEMORY_SESSIONEND") == "0":
return 0
try:
raw = "" if sys.stdin.isatty() else sys.stdin.read()
payload = json.loads(raw) if raw.strip() else {}
except Exception:
return 0
transcript = payload.get("transcript_path")
if not transcript or not os.path.exists(transcript):
return 0
try:
import memory_core as core
import memory_extract as extract
import memory_promote as promote
cwd = payload.get("cwd") or os.getcwd()
project = os.path.basename(os.path.abspath(cwd))
session = payload.get("session_id") or "unknown-session"
store = core.AtomStore(os.path.join(cwd, ".memory"))
new_atoms = extract.extract(transcript, project, session)
if not new_atoms:
return 0
atoms, _n_new, _n_merged = extract.merge_into_store(store, new_atoms)
core.mark_contradictions(atoms)
if not store.write(atoms):
# 5.4 -- the one place data disappears. It is logged inside write().
return 0
l2, _blocked = promote.promote_l1_to_l2(atoms)
l3, notes = promote.promote_l2_to_l3(atoms + l2)
warnings = promote.apply_caps(atoms + l2 + l3)
if l2 or l3:
promote.stage(store, l2, l3, notes, warnings)
except Exception:
# Never blocks teardown. A lost capture costs one re-observation; L1 is
# the recoverable tier by construction (5.4).
return 0
return 0
if __name__ == "__main__":
sys.exit(main())