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
This commit is contained in:
Claude 2026-08-24 18:23:01 +00:00
parent 38a23f1911
commit a3b6a195cb
No known key found for this signature in database
23 changed files with 2444 additions and 28 deletions

View file

@ -8,7 +8,7 @@
"homepage": "https://github.com/alirezarezvani/claude-skills",
"repository": "https://github.com/alirezarezvani/claude-skills",
"metadata": {
"description": "377 production-ready skills across 20 domains (engineering, engineering-core, marketing, product, c-level, c-level-agents, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 695 Python tools, 817 reference guides, 104 agents (cs-* + personas), 120 slash commands across 94 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.",
"description": "378 production-ready skills across 20 domains (engineering, engineering-core, marketing, product, c-level, c-level-agents, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 703 Python tools, 820 reference guides, 104 agents (cs-* + personas), 120 slash commands across 95 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.",
"version": "2.11.2"
},
"plugins": [
@ -1985,6 +1985,27 @@
"context-fork"
],
"category": "agent-development"
},
{
"name": "agent-memory",
"source": "./engineering/agent-memory",
"description": "A four-tier memory ladder for Claude Code where promotion is earned by recurrence, not asserted by confidence. L0 raw transcripts are never injected; L1 candidate atoms are gitignored and recalled on lexical relevance per prompt; L2 project context loads each session start; L3 stable persona is always loaded. L1 to L2 needs three distinct sessions spanning two distinct calendar days (a stated claim needs two; a verified claim is the only single-observation path); L2 to L3 needs two projects and thirty days. Two gates refuse rather than guess: a claim altered by the redaction pass never promotes on evidence alone, and a contradicted claim freezes until a human resolves it. Three hooks run the loop unattended and every one fails open. Nothing reaches a committed CLAUDE.md without an explicit human adopt. Ships 5 stdlib scripts, 3 hooks, a cs-memory-curator agent, /cs:memory, a JSON schema, and a 69-check validator. Concept from TencentCloud/TencentDB-Agent-Memory (MIT); no upstream code included.",
"version": "2.11.2",
"author": {
"name": "Alireza Rezvani"
},
"keywords": [
"agent-memory",
"memory-tiers",
"claude-md",
"hooks",
"session-memory",
"promotion-gates",
"provenance",
"redaction",
"engineering"
],
"category": "development"
}
]
}

7
.gitignore vendored
View file

@ -70,3 +70,10 @@ tests/
# reviewer's feedback and belongs next to the artifact in git.
*.review.html
.human-gate/
# engineering/agent-memory runtime store. L1 candidate atoms are raw-adjacent:
# they are extracted from session transcripts and redaction is a lexical filter,
# never a guarantee. Only the L2/L3 lines a human explicitly adopts into a
# CLAUDE.md are ever committed. errors.log records dropped writes; staged/ holds
# promotion proposals awaiting review. None of it belongs in git.
.memory/

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
# Claude Code Skills & Plugins — Agent Skills for Every Coding Tool
**377 production-ready Claude Code skills, plugins, and agent skills for 13 AI coding tools.**
**378 production-ready Claude Code skills, plugins, and agent skills for 13 AI coding tools.**
The most comprehensive open-source library of Claude Code skills and agent plugins — also works with OpenAI Codex, Gemini CLI, Cursor, and 9 more coding agents. Reusable expertise packages covering engineering, DevOps, marketing (incl. AEO — Answer Engine Optimization for LLM citation), security (PreToolUse hooks), compliance, C-level advisory (incl. founder-mode CFO/CMO/CRO/CPO/COO/CHRO/CISO/GC/CDO/CAIO/CCO/VPE personas + 21 /cs:* slash commands), productivity (capture/email/reflect/weekly-review/deep-work/meetings), an academic research stack (litreview/grants/dossier/patent/syllabus/pulse/notebooklm/deep-research + hybrid router), and enterprise Research Operations (clinical-research/research-finance/market-research/product-research, v2.9.0).
@ -10,10 +10,10 @@ The most comprehensive open-source library of Claude Code skills and agent plugi
[^vibe]: Mistral Vibe is also **BYO-sync tier**: the repo ships a pre-generated `.vibe/skills/claude-skills/` tree, run `./scripts/vibe-install.sh` once locally to install into `~/.vibe/skills/`. Same agentskills.io SKILL.md standard — no format conversion. Docs: <https://docs.mistral.ai/mistral-vibe/agents-skills>.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](https://opensource.org/licenses/MIT)
[![Skills](https://img.shields.io/badge/Skills-377-brightgreen?style=for-the-badge)](#skills-overview)
[![Agents](https://img.shields.io/badge/Agents-109-blue?style=for-the-badge)](#agents)
[![Skills](https://img.shields.io/badge/Skills-378-brightgreen?style=for-the-badge)](#skills-overview)
[![Agents](https://img.shields.io/badge/Agents-110-blue?style=for-the-badge)](#agents)
[![Personas](https://img.shields.io/badge/Personas-7-purple?style=for-the-badge)](#personas)
[![Commands](https://img.shields.io/badge/Commands-129-orange?style=for-the-badge)](#commands)
[![Commands](https://img.shields.io/badge/Commands-130-orange?style=for-the-badge)](#commands)
[![Stars](https://img.shields.io/github/stars/alirezarezvani/claude-skills?style=for-the-badge)](https://github.com/alirezarezvani/claude-skills/stargazers)
[![SkillCheck Validated](https://img.shields.io/badge/SkillCheck-Validated-4c1?style=for-the-badge)](https://getskillcheck.com)
@ -26,10 +26,10 @@ The most comprehensive open-source library of Claude Code skills and agent plugi
Claude Code skills (also called agent skills or coding agent plugins) are modular instruction packages that give AI coding agents domain expertise they don't have out of the box. Each skill includes:
- **SKILL.md** — structured instructions, workflows, and decision frameworks
- **Python tools**695 CLI scripts (all stdlib-only, zero pip installs)
- **Reference docs** — 817 templates, checklists, and domain-specific knowledge files
- **Python tools**703 CLI scripts (all stdlib-only, zero pip installs)
- **Reference docs** — 820 templates, checklists, and domain-specific knowledge files
**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 695 Python tools run anywhere Python runs.
**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 703 Python tools run anywhere Python runs.
### Skills vs Agents vs Personas
@ -157,7 +157,7 @@ Run `./scripts/convert.sh --tool all` to generate tool-specific outputs locally.
| Domain | Skills | Highlights | Details |
|--------|--------|------------|---------|
| **🔧 Engineering — Core** | 53 | Architecture, frontend, backend, fullstack, QA, DevOps, SecOps, AI/ML, data, Playwright Pro (test gen, flaky fix, migrations), self-improving agent (auto-memory curation), security suite, a11y audit, **named-persona-adversarial-review** (review via named engineering philosophies), **embedded-iot-mentor** (MCU/board selection, firmware-reuse-first, breadboard-MVP discipline) | [engineering-team/](engineering-team/) |
| **⚡ Engineering — POWERFUL** | 88 | Agent designer, RAG architect, database designer, CI/CD builder, security auditor, MCP builder, AgentHub, Helm charts, Terraform, self-eval, llm-wiki, tc-tracker, autoresearch-agent, **reliability portfolio** (feature-flags-architect, kubernetes-operator, chaos-engineering, slo-architect), ship-gate, security-guidance PreToolUse hook, **Matt Pocock skills** (write-a-skill, caveman, grill-me, handoff, grill-with-docs), **zero-hallucination-coder** (Discuss→Map→Decompose→Execute→Verify), **agent-harness** (goal→plan→execute→verify→close loops over any domain), **memory-engineering** (price the memory write path, pick which cost to pay, audit FACT/SKILL/LOG density, gate on a forgetting policy), **skillopt-sleep** (nightly gated self-evolution from real Claude Code sessions, vendored from microsoft/SkillOpt), **book-to-skill** (compile a book, docs folder, or spec collection into a knowledge-base skill, then package it as a plugin), **boost-asio-pro** (async C++ networking — version-gated coroutine/callback styles, strand discipline), **human-gate** (batched human review as a structured artifact + a gate that refuses to close on open blockers) | [engineering/](engineering/) |
| **⚡ Engineering — POWERFUL** | 89 | Agent designer, RAG architect, database designer, CI/CD builder, security auditor, MCP builder, AgentHub, Helm charts, Terraform, self-eval, llm-wiki, tc-tracker, autoresearch-agent, **reliability portfolio** (feature-flags-architect, kubernetes-operator, chaos-engineering, slo-architect), ship-gate, security-guidance PreToolUse hook, **Matt Pocock skills** (write-a-skill, caveman, grill-me, handoff, grill-with-docs), **zero-hallucination-coder** (Discuss→Map→Decompose→Execute→Verify), **agent-harness** (goal→plan→execute→verify→close loops over any domain), **memory-engineering** (price the memory write path, pick which cost to pay, audit FACT/SKILL/LOG density, gate on a forgetting policy), **skillopt-sleep** (nightly gated self-evolution from real Claude Code sessions, vendored from microsoft/SkillOpt), **book-to-skill** (compile a book, docs folder, or spec collection into a knowledge-base skill, then package it as a plugin), **boost-asio-pro** (async C++ networking — version-gated coroutine/callback styles, strand discipline), **human-gate** (batched human review as a structured artifact + a gate that refuses to close on open blockers), **agent-memory** (four-tier L0-L3 memory ladder over Claude Code hooks; promotion earned by recurrence across sessions and days, redacted or contested claims refuse to promote, nothing reaches CLAUDE.md without a human adopt) | [engineering/](engineering/) |
| **🎯 Product** | 17 | Product manager, agile PO, strategist, UX researcher, UI design, landing pages, SaaS scaffolder, analytics, experiment designer, discovery, roadmap communicator, code-to-prd, apple-hig-expert | [product-team/](product-team/) |
| **📣 Marketing** | 49 | 8 pods: Content, SEO + AEO (`aeo` — E-E-A-T audit, citation tracking across 5 LLMs) + local (`local-seo-manager` — GBP/NAP/Map-Pack), CRO, Channels, Growth, Intelligence, Sales + `business-name-fit` (cross-cultural naming) + context foundation + orchestration router | [marketing-skill/](marketing-skill/) |
| **🚀 Productivity** | 12 | `capture` (brain-dump-to-action), `email` pair (inbox-setup + inbox-triage), `reflect` (journal), `handoff` (Matt Pocock-inspired), `andreessen` (market-first decision mode), `roast` (5-angle idea panel → GO/RESHAPE/KILL), `fable-goal` (ramble → autonomous /goal prompt), `weekly-review` (GTD loop with refusal gate), `deep-work` (time-blocking + shallow-work budget), `meetings` (cost gate + agenda + action items), `swedish-mentor` (CEFR-leveled Swedish learning paths) | [productivity/](productivity/) |
@ -359,7 +359,7 @@ Yes. Skills work natively with 13 tools: Claude Code, OpenAI Codex, Gemini CLI,
No. We follow semantic versioning and maintain backward compatibility within patch releases. Existing script arguments, plugin source paths, and SKILL.md structures are never changed in patch versions. See the [CHANGELOG](CHANGELOG.md) for details on each release.
**Are the Python tools dependency-free?**
Yes. All 695 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly.
Yes. All 703 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly.
**How do I create my own Claude Code skill?**
Each skill is a folder with a `SKILL.md` (frontmatter + instructions), optional `scripts/`, `references/`, and `assets/`. See the [Skills & Agents Factory](https://github.com/alirezarezvani/claude-code-skills-agents-factory) for a step-by-step guide.

View file

@ -0,0 +1,14 @@
{
"attribution": {
"upstream": "TencentCloud/TencentDB-Agent-Memory",
"upstream_url": "https://github.com/TencentCloud/TencentDB-Agent-Memory",
"upstream_license": "MIT",
"relationship": "concept-derived, not vendored",
"derivation_note": "No upstream code is included. What was taken is the hierarchical-memory framing — a tier ladder in which a claim is promoted toward always-loaded context rather than written there directly — and the principle that such a system's value lies in what it refuses to promote. Everything else is a native rebuild on Claude Code's own hook surface. Three upstream choices were deliberately rejected: (1) the managed database, replaced by one append-oriented JSONL file per project capped at 500 candidate atoms, because a deployment dependency violates this repo's self-contained-package rule; (2) a proxy layer between agent and model, unnecessary because Claude Code already exposes SessionStart, UserPromptSubmit and SessionEnd, and adding a proxy to obtain hooks the platform provides buys nothing while adding a failure mode on the critical path; (3) LLM-based extraction, replaced by rule-based marker matching, both because root CLAUDE.md forbids LLM calls in skill scripts and because a model call cannot run inside an async teardown hook on a latency budget and its output is not reproducible from the transcript, which would break the cite-don't-invent rule. The full comparison, with the open decisions this design has not closed, is in DESIGN.md; the deviations from DESIGN.md's own planned tree are listed in README.md, which is authoritative for those.",
"other_precedents_reused_in_repo": [
"engineering/skillopt-sleep — the propose-never-apply staging discipline and async teardown work",
"engineering/agent-harness — atomic state writes via temp file plus os.replace",
"productivity/handoff — per-hook env-var disable convention and redaction-pattern coverage (re-implemented, not imported, per the no-cross-skill-dependency rule)"
]
}
}

View file

@ -0,0 +1,15 @@
{
"name": "agent-memory",
"description": "A four-tier memory ladder for Claude Code where promotion is earned by recurrence, not asserted by confidence. L0 raw transcripts are never injected; L1 candidate atoms are gitignored and recalled on lexical relevance per prompt; L2 project context loads once per session; L3 stable persona is always loaded. Promotion from L1 to L2 requires at least three distinct sessions spanning at least two distinct calendar days (a stated claim needs two sessions, still spread across days; a deterministically verified claim is the only single-observation path), and L2 to L3 requires two distinct projects and thirty days. Two gates refuse rather than guess: a claim whose text was altered by the seventeen-pattern redaction pass never promotes on evidence alone, and a claim with an open contradiction freezes until a human resolves it, found by reverse join because the newer atom carries no flag. Three hooks run the loop unattended and every one fails open. Nothing reaches a committed CLAUDE.md without an explicit human adopt that backs both files up first. Ships five stdlib scripts, three hooks, a cs-memory-curator agent, a /cs:memory command, a JSON schema, a sixty-nine-check example validator, and a design document with its open decisions ranked by what they gate.",
"version": "2.11.2",
"author": {
"name": "Alireza Rezvani",
"url": "https://alirezarezvani.com"
},
"homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/engineering/agent-memory",
"repository": "https://github.com/alirezarezvani/claude-skills",
"license": "MIT",
"skills": [
"./skills/agent-memory"
]
}

View file

@ -2,7 +2,7 @@
> **Editing this file? Run the checker first — nothing in CI will.**
> ```sh
> cp assets/validate_examples.py.txt assets/_t.py && python3 assets/_t.py; rm -f assets/_t.py
> python3 skills/agent-memory/scripts/validate_examples.py
> ```
> It ties this doc, `assets/memory_schema.json`, and the fixtures together
> (§10.1). Drift between those three was the dominant defect class during this
@ -326,7 +326,7 @@ rule instead of hiding it.
> there is no single incumbent id to carry forward — which is exactly why
> `promoted_from_projects` exists to preserve the link back.
Full JSON Schema: [`assets/memory_schema.json`](assets/memory_schema.json).
Full JSON Schema: [`assets/memory_schema.json`](skills/agent-memory/assets/memory_schema.json).
**Why the schema carries three fixtures, one per tier:** the `examples` array is
not illustration — it is the only thing that exercises the `allOf` branches.
@ -1182,7 +1182,7 @@ order, a `confidence` value that contradicted its own lifecycle narrative. Every
one was caught by a check, and hand-checking does not scale as the schema moves
toward implementation.
The validator is written and tested: **[`assets/validate_examples.py.txt`](assets/validate_examples.py.txt)**
The validator is written and tested: **[`assets/validate_examples.py.txt`](skills/agent-memory/scripts/validate_examples.py)**
— stdlib-only, **69 checks in seven families** (schema conformance · the
tier-dependent back-pointer · id reproduction from the doc's own published
`normalize()` · confidence monotonicity · document structure and links · prose
@ -1198,13 +1198,15 @@ its own paths by walking up from `__file__` — a `python3 <(cat …)` or `-c
"$(cat …)"` invocation gives it no real location and it will refuse to start):
```sh
cp assets/validate_examples.py.txt assets/_t.py && python3 assets/_t.py; rm -f assets/_t.py
python3 skills/agent-memory/scripts/validate_examples.py
```
**Run it before any further edit to this folder lands.** Nothing in CI gates the
drift class this section exists to prevent — the checker is only as good as the
habit of running it, and "a future editor tweaks a fixture in `DESIGN.md`
without knowing this file exists" is the failure that leaves.
without knowing this file exists" is the failure that leaves. **It is now a real
`scripts/validate_examples.py`**; the `.py.txt` parking described below was
undone when the plugin shipped, and §11's placement question no longer gates it.
**The `.txt` parking is not what blocks CI**: a workflow step can `cp` it to a
temp `.py` and run it exactly as above, and `derive_counters.py` never sees the
@ -1213,11 +1215,7 @@ temp file. The whole step, for whoever wires it:
```yaml
- name: agent-memory spec drift check
run: |
cd engineering/agent-memory
cp assets/validate_examples.py.txt assets/_ci.py
python3 assets/_ci.py; rc=$?
rm -f assets/_ci.py
exit $rc
python3 engineering/agent-memory/skills/agent-memory/scripts/validate_examples.py
```
Not added to `ci-quality-gate.yml` here: that workflow runs on every PR in the
@ -1282,7 +1280,7 @@ than one blocked by a naming workaround.
| Reference | Now | After the move |
|---|---|---|
| `DESIGN.md`'s link (§3.1) | `[…](assets/memory_schema.json)` | `[…](skills/agent-memory/assets/memory_schema.json)` |
| `DESIGN.md`'s link (§3.1) | `[…](skills/agent-memory/assets/memory_schema.json)` | `[…](skills/agent-memory/assets/memory_schema.json)` |
| The schema's own `$id` | `…/engineering/agent-memory/assets/…` | `…/engineering/agent-memory/skills/agent-memory/assets/…` |
`DESIGN.md` stays at the plugin root (it documents the plugin, not the skill), so
@ -1291,14 +1289,24 @@ other forward-looking wrinkle in this doc — the counter delta, the manifest-fo
follow-up — already is, and a silently-dead link in the file that *is* the
contract would be the wrong thing to discover later.
**Counters on ship: skills +1, tools +6, refs +3, commands +1, agents +1,
plugins +1.** Tools is **+6, not +3**`derive_counters.py` counts *every*
**Counters on ship: skills +1, tools +8, refs +3, commands +1, agents +1,
plugins +1.** Tools is **not +3**`derive_counters.py` counts *every*
`.py` outside repo-root `scripts/`, so the three `hooks/*.py` count alongside
the three `scripts/*.py`. Verified empirically against this tree: adding one
the `scripts/*.py`. Verified empirically against this tree: adding one
file under `hooks/` moves `python_tools` 663 → 664. `productivity/handoff` is
the confirming precedent — its 7 `scripts/*.py` + 2 `hooks/*.py` are 9 counted
tools, i.e. the `hooks/` files are counted alongside the `scripts/` ones. Verify with
`scripts/derive_counters.py --check` before opening the implementation PR.
tools, i.e. the `hooks/` files are counted alongside the `scripts/` ones.
> **Corrected at implementation time.** This paragraph originally said **+6**,
> derived from §10's four-script tree (3 scripts + 3 hooks, with
> `validate_examples.py` already counted). The delivered surface is **5 scripts
> + 3 hooks = +8**: the fifth script is `memory_core.py`, a shared module with
> no CLI, added because duplicating the redaction patterns, id algorithm and
> lock protocol across seven files is the drift class this document exists to
> prevent. Measured on merge: `python_tools` 695 → 703. `README.md`'s
> "Deviations from `DESIGN.md`" list is authoritative for this and every other
> divergence. Verify with `scripts/derive_counters.py --check` before opening
> the implementation PR.
**Follow-up for the maintainer (not this PR):** the identical on-disk layout is
declared two different ways across the repo, and only one of them is documented.

View file

@ -0,0 +1,126 @@
# agent-memory
A four-tier memory ladder for Claude Code. **Promotion is earned by recurrence,
never asserted by confidence**, and nothing reaches a committed file without a
human adopting it.
Concept derived from [TencentCloud/TencentDB-Agent-Memory][tencent] (MIT). No
upstream code is included — see
[`.claude-plugin/authoring-notes.json`](.claude-plugin/authoring-notes.json) for
what was taken and what was deliberately rejected.
[tencent]: https://github.com/TencentCloud/TencentDB-Agent-Memory
---
## What it does
| Tier | Holds | Injected | Committed |
|---|---|---|---|
| **L0** | raw session transcripts | never | already on disk |
| **L1** | candidate atoms | on relevance, per prompt | no (gitignored) |
| **L2** | this project's context | every session start | yes, after adopt |
| **L3** | stable cross-project persona | always | yes, after adopt |
**L1 → L2** needs ≥ 3 distinct sessions spanning ≥ 2 distinct calendar days. A
claim stated outright needs 2 sessions — the day rule still applies. A
deterministically verified claim promotes on one observation and is the only
day-exempt path. **L2 → L3** needs ≥ 2 distinct projects and ≥ 30 days.
Two gates refuse rather than guess: a **redacted** claim never promotes on
evidence alone, and a claim with an **open contradiction** freezes until a human
resolves it.
## Install and use
The three hooks in [`hooks/hooks.json`](hooks/hooks.json) run the loop
unattended. Each is disabled independently:
```bash
AGENT_MEMORY_SESSIONSTART=0 # stop injecting L2 + L3
AGENT_MEMORY_USERPROMPTSUBMIT=0 # stop L1 recall
AGENT_MEMORY_SESSIONEND=0 # stop capture
```
```bash
cd skills/agent-memory/scripts
python3 memory_inspect.py --tier L1 # what's stuck, and why
python3 memory_inspect.py --why "<claim>" # full provenance + source line
python3 memory_inspect.py --contested # disputed claims, both directions
python3 memory_promote.py # dry-run the gates; writes nothing
python3 validate_examples.py # 69 checks over the spec + schema
```
Every script takes `--sample` and `--output json`. `/cs:memory` wraps all of it;
`/cs:memory adopt` is the only path that writes to a `CLAUDE.md`, and it backs
both files up first.
## Contents
```
DESIGN.md the spec: schema, gates, hook contracts, open decisions
hooks/hooks.json three hook contracts
hooks/session_start.py inject L3 + L2, mark cross-tier conflicts
hooks/user_prompt_submit.py recall L1 on a 100 ms internal budget
hooks/session_end.py capture, redact, detect, merge, stage
skills/agent-memory/SKILL.md 6/6 PASS on the write-a-skill checklist
scripts/memory_core.py shared: ids, redaction, locking, contradictions
scripts/memory_extract.py L0 → L1, rule-based, no LLM
scripts/memory_promote.py L1 → L2 → L3, stages proposals
scripts/memory_inspect.py read-only: --tier / --contested / --why
scripts/validate_examples.py 69 checks in seven families
assets/memory_schema.json 13 required fields, five conditionals
references/ three references, 67 sources each
agents/cs-memory-curator.md refuses to adopt what it cannot cite
commands/cs-memory.md status | why | contested | adopt | forget
```
## Deviations from `DESIGN.md`
`DESIGN.md` was written before the implementation and reviewed at length. Where
the built thing differs from the planned thing, **this list is authoritative**.
1. **`memory_core.py` exists.** §10's planned tree lists four scripts and no
shared module. Building it that way would have duplicated the redaction
patterns, the id algorithm and the lock protocol across seven files — which
is precisely the drift class the spec exists to prevent. The module has no
CLI and is not a plugin-facing tool.
2. **The tool count in §10.1 is wrong.** It says `+6` (3 scripts + 3 hooks).
The delivered surface is 5 scripts + 3 hooks = **8**. Corrected in the
counters; §10.1's text is left as the historical record of the estimate.
3. **`mark_contradictions()` was added to the core.** §4.2.1 specifies detection
running at merge time in `SessionEnd` but assigns it to no file. It lives in
the core so the promoter and the inspector share one definition of "open
contradiction" rather than three.
4. **Cross-tier conflict marking marks both sides.** §5.1 requires that an L2
and an L3 claim colliding must not both appear as plain assertions, and says
all three candidate policies satisfy it. The implementation marks both and
picks no winner — the least committal option, and the one that does not have
to be undone if the open decision lands on specificity-wins.
5. **The 100 ms recall budget is met, on this machine.** §9.5 asserted the
budget without measurement and offered dropping the hook as option (c).
Measured: spawn-plus-recall p50 ≈ 29 ms, p95 ≈ 31 ms, max ≈ 35 ms against a
populated store; the scoring pass itself 23 ms over 500 atoms. Interpreter
start-up is the whole cost. **This does not close §9.5** — one machine is not
a portability claim, and a slower host may still force option (c).
6. **`--why` quotes the source line.** Not specified anywhere; added because
"cite, don't invent" is unfalsifiable if a human cannot see the cited line.
It prints nothing when the back-pointer resolves `ambiguous`.
## What would make this worth deleting
Stated plainly because §9.3 permits it and the trial is the point:
- **Recall is too low to matter.** Rule-based extraction is deliberately
high-precision. If a two-week trial produces a handful of atoms and none
reach L2, the honest response is to remove the folder — not to loosen gates
until something passes.
- **Nobody reviews the staged promotions.** The entire security argument rests
on a human at the `adopt` gate. If staged items are accepted unread, the gate
is theatre.
- **`--why` is never run.** Provenance nobody checks is provenance nobody needs.
## License
MIT. Concept attribution: TencentDB-Agent-Memory (MIT, © Tencent). See
[`../../LICENSE`](../../LICENSE).

View file

@ -0,0 +1,63 @@
---
name: cs-memory-curator
description: Curates the tiered agent-memory store. Use when reviewing what the agent has learned from past sessions, adopting staged promotions into CLAUDE.md, resolving a contested claim, or tracing where a remembered line came from. Refuses to adopt anything it cannot cite, and refuses to adopt a redacted claim at all.
---
# Memory Curator
You maintain a promotion ladder, not a database. Your bias is **refusal**: a
claim that stays at L1 costs the user one restatement; a wrong claim promoted to
always-loaded context costs them every future session until someone hunts down
where it came from.
## Your posture
**You are the human's instrument at the gate, not a replacement for them.** The
whole design rests on a person reviewing staged promotions. If you start
adopting things because they look fine, the security argument behind the whole
system collapses. Present, recommend, and wait.
Say what the evidence is, not what you think of the claim:
> "`PR base branch is dev` — 4 observations across 3 sessions, spanning 5 days,
> first seen in sessA.jsonl#L1. No contradiction open. Eligible for L2."
Not: "This looks like a good rule to remember."
## What you do
| Ask | You run |
|---|---|
| "what does it remember?" | `memory_inspect.py --tier L2` and `--tier L3` |
| "why does it think that?" | `memory_inspect.py --why "<claim>"` |
| "what's stuck?" | `memory_inspect.py --tier L1` — read the blocking reason on each |
| "what's disputed?" | `memory_inspect.py --contested` |
| "what's waiting?" | read `.memory/staged/promotions.json` |
| "adopt it" | walk the staged list one item at a time, then back up both `CLAUDE.md` files before writing |
## Hard rules
1. **Never adopt a redacted claim.** Not with more evidence, not with the user
saying it's fine in passing. The flag means the text was altered because it
looked like a secret, and the target file is committed to git. If the user
wants the underlying fact remembered, have them restate it in a form that
contains no secret — that restatement is a clean observation.
2. **Never adopt an atom whose citation does not resolve.** If `--why` reports
`ambiguous`, say so and stop: a wrong citation is worse than a missing one.
3. **Never resolve a contradiction yourself.** Present both claims, both dates,
both sources. The user picks.
4. **Back up before writing.** Both `CLAUDE.md` files, every time, before any
adopt.
5. **Never edit `.memory/atoms.jsonl` by hand to make something promotable.**
That is forging evidence. If a gate is wrong, change the gate in the open.
6. **Say when the store is thin.** Rule-based extraction has deliberately low
recall. If two weeks produce almost nothing, the honest report is "this is
not earning its keep — consider removing it," not a search for a looser
threshold.
## What you do not do
You do not summarize, rewrite, or "clean up" a claim's wording during adopt. The
wording *is* the evidence; changing it breaks the link to the transcript line
that produced it. If the wording is bad, reject it and let the user state the
rule properly — which becomes a new, better atom.

View file

@ -0,0 +1,101 @@
---
description: Inspect, trace, and adopt the tiered agent-memory store (status | why | contested | adopt | forget)
argument-hint: "status | why \"<claim>\" | contested | adopt | forget \"<claim>\""
---
# /cs:memory — curate what the agent remembers
Argument: `$ARGUMENTS` (default: `status`)
Scripts live at
`engineering/agent-memory/skills/agent-memory/scripts/`. All are stdlib-only and
read-only except where stated.
---
## `status` (default)
1. Run `memory_inspect.py --tier L3`, `--tier L2`, `--tier L1`.
2. Read `.memory/staged/promotions.json` if it exists.
3. Read the last 7 days of `.memory/errors.log` if it exists — **surface any
entry**. That file is where silently dropped writes are recorded, and a log
nobody is pointed at is the same as no log.
Report, in this order: what is always loaded (L3), what this project loads (L2),
how many candidates are waiting and what is blocking each, what is staged for
adoption, and any dropped writes.
**Do not adopt anything here.** `status` is read-only.
---
## `why "<claim>"`
Run `memory_inspect.py --why "<claim>"`.
Report the full provenance: observation count, distinct sessions, distinct
calendar days, first and latest transcript back-pointers, whether each resolves,
and the quoted source line when exactly one transcript matched.
If the resolution status is **`ambiguous`**, say so plainly and print no source
line. Two projects can hold a transcript of the same basename; guessing attaches
a real claim to the wrong session, and a wrong citation is worse than none.
---
## `contested`
Run `memory_inspect.py --contested`.
For each pair, present both claims with their dates and sources side by side and
ask the user which governs. **Do not pick.** Do not merge them. Do not mark one
resolved on your own judgement — resolution is a human decision by design.
---
## `adopt`
The only command in this file that writes. Six steps, in order, no skipping:
1. Run `memory_promote.py --stage` to refresh `.memory/staged/promotions.json`.
2. **Back up both `CLAUDE.md` files** (project and global) with a timestamped
copy. Do this before writing anything, every time.
3. Walk the staged list **one atom at a time**. For each, show the claim, the
evidence (sessions, days, sources), and the target file. Wait for the user.
4. **Refuse outright** any atom with `redacted: true` — no amount of evidence
substitutes for the human reading the original. Explain why and move on.
5. **Refuse** any atom whose citation does not resolve.
6. Append accepted atoms to the target `CLAUDE.md` under a clearly marked
`<!-- agent-memory: adopted -->` section, and log each to `.memory/adopted.log`.
Never write to a `CLAUDE.md` outside this flow. Never batch-accept.
---
## `forget "<claim>"`
1. Locate the atom with `memory_inspect.py --why "<claim>"`.
2. Show the user exactly what will be removed, from which tier, and whether it
was already adopted into a `CLAUDE.md`.
3. On confirmation, remove it from `.memory/atoms.jsonl` and, if it was adopted,
remove the corresponding line from the `CLAUDE.md` — after backing that file
up.
Removing an atom does **not** prevent re-learning. If the marker fires again in
a future session, it returns. That is correct: forgetting is not a permanent
veto, and saying so avoids a confusing surprise later. To stop it returning,
change the underlying fact or state the correction — a correction is itself a
high-confidence observation.
---
## Refuse and route
- No `.memory/` directory yet → say so. It is created on the first session end
with the hooks installed; nothing is wrong.
- User asks to lower a promotion threshold so something passes → refuse. Gates
are changed in the open, in `DESIGN.md`, not per-claim. Offer to record the
case as evidence the threshold is wrong.
- User asks to design or price a memory system generally → route to
`engineering/memory-engineering`. This skill *is* a memory system; that one
audits any of them, this one included.

View file

@ -0,0 +1,83 @@
#!/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())

View file

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""SessionStart -- inject L3 (persona) + L2 (this project). DESIGN.md 5.1.
Contract, in the order it matters:
* NEVER BLOCKS. Any failure exits 0 with no output. A memory system that can
break session start is worse than no memory system.
* Budgets: 2 KB L3, 4 KB L2. Over budget -> truncate by `last_seen` desc and
SAY SO in the block. "A memory system that silently drops is worse than
none" (5.1).
* Never emit two contradictory lines unmarked (5.1). The 4.2.1 detector
cannot reach L3, so nothing upstream guarantees L2 and L3 agree; a collision
is marked here at injection time.
* Disable with AGENT_MEMORY_SESSIONSTART=0.
No internal self-budget, unlike user_prompt_submit.py -- and 5.1 argues why:
this runs once per session, and its work is bounded by the byte caps above
rather than by a scan that grows with history.
"""
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"))
L3_BUDGET = 2048
L2_BUDGET = 4096
def _contested_tag(atom, atoms, core):
if not core.open_contradiction(atom, atoms):
return ""
return " [contested — newer evidence %s]" % atom["last_seen"][:10]
def _fit(lines, budget):
"""Take lines in priority order until the byte budget is spent. Returns
(kept, dropped) -- the caller must disclose `dropped`."""
kept, used = [], 0
for i, ln in enumerate(lines):
cost = len(ln.encode("utf-8")) + 1
if used + cost > budget:
return kept, len(lines) - i
kept.append(ln)
used += cost
return kept, 0
def _cross_tier_conflicts(l2, l3, core):
"""5.1's never-emit-contradictory-lines constraint. 4.2.1's detector is
project-scoped and so structurally cannot see an L2-vs-L3 pair; this is the
injection-time backstop.
This marks BOTH sides rather than shadowing one. That is the least
committal of the three options 5.1 says all satisfy the constraint -- it
surfaces the collision without deciding precedence, which is exactly what
the open decision on L3 contradictions has not yet decided. If that decision
lands on specificity-wins, this is the one function that changes.
"""
marks = {}
for a in l2:
for b in l3:
if core.contradicts(a["claim"], b["claim"], a["kind"], b["kind"]):
marks[a["id"]] = b["claim"]
marks[b["id"]] = a["claim"]
return marks
def build(atoms, project, core):
l3 = [a for a in atoms if a["tier"] == "L3"]
l2 = [a for a in atoms if a["tier"] == "L2" and a.get("project") == project]
l3.sort(key=lambda a: a["last_seen"], reverse=True)
l2.sort(key=lambda a: a["last_seen"], reverse=True)
if not l3 and not l2:
return ""
conflicts = _cross_tier_conflicts(l2, l3, core)
def render(a):
line = "- %s%s" % (a["claim"], _contested_tag(a, atoms, core))
if a["id"] in conflicts:
line += "\n [conflicts with another remembered rule: %s" \
"neither governs; ask before relying on either]" % conflicts[a["id"]]
return line
out = ["<agent_memory>",
"Remembered from previous sessions. Recurrence-gated, not verified fact;",
"correct anything wrong and the correction is itself remembered."]
if l3:
kept, dropped = _fit([render(a) for a in l3], L3_BUDGET)
out.append("")
out.append("## Stable (L3, all projects)")
out.extend(kept)
if dropped:
out.append("- [%d more L3 item(s) omitted for space, oldest-seen first]" % dropped)
if l2:
kept, dropped = _fit([render(a) for a in l2], L2_BUDGET)
out.append("")
out.append("## This project (L2: %s)" % project)
out.extend(kept)
if dropped:
out.append("- [%d more L2 item(s) omitted for space, oldest-seen first]" % dropped)
out.append("</agent_memory>")
return "\n".join(out)
def main():
if os.environ.get("AGENT_MEMORY_SESSIONSTART") == "0":
return 0
try:
payload = {}
if not sys.stdin.isatty():
raw = sys.stdin.read()
if raw.strip():
payload = json.loads(raw)
except Exception:
payload = {}
try:
import memory_core as core
cwd = payload.get("cwd") or os.getcwd()
project = os.path.basename(os.path.abspath(cwd))
block = build(core.AtomStore(os.path.join(cwd, ".memory")).read(), project, core)
if block:
sys.stdout.write(block + "\n")
except Exception:
# 5.1 -- never blocks. No memory this session is the failure mode.
return 0
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""UserPromptSubmit -- recall relevant L1 atoms. DESIGN.md 5.2.
PROVISIONAL. hooks.json says so and so does this file: 9.5 is an open decision
whose option (c) is to delete this hook, because the measured cost is dominated
by interpreter cold start (p50 ~12 ms, p95 ~31 ms just to reach `main`) rather
than by anything below. The scoring pass itself measured 2-3 ms over 500 atoms.
Shipping it does not close that decision.
Two limits, not one (5.2 is emphatic that conflating them misses the point):
internal self-budget 100 ms, enforced HERE against a monotonic clock.
Past budget: stop scoring, return what we have.
hook timeout backstop 1 second, enforced by Claude Code. Kills a wedged
process. Finishing under 1 s does NOT satisfy the spec.
* top 5 atoms, 1 KB max
* a `contested` atom renders with the 4.2 tag, never as a bare claim
* reads take NO lock (5.4) -- atomic os.replace on the writer side is what
makes a lock-free read safe, and blocking here on an async SessionEnd's
lock would blow the budget for a hook whose failure mode is "return nothing"
* disable with AGENT_MEMORY_USERPROMPTSUBMIT=0
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
BUDGET_S = 0.100
MAX_ATOMS = 5
MAX_BYTES = 1024
sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"skills", "agent-memory", "scripts"))
# Weighting: a constraint the user stated out loud is worth more at recall time
# than a passively observed preference.
KIND_WEIGHT = {"constraint": 1.4, "correction": 1.3, "failure": 1.2,
"preference": 1.0, "fact": 1.0}
CONF_WEIGHT = {"verified": 1.3, "stated": 1.1, "observed": 1.0}
_STOP = {
"the", "a", "an", "and", "or", "but", "if", "is", "are", "was", "were", "be",
"to", "of", "in", "on", "for", "with", "that", "this", "it", "as", "at", "by",
"do", "does", "did", "can", "you", "i", "we", "me", "my", "our", "please",
"what", "how", "why", "when", "where", "not", "no", "so", "up", "out", "then",
}
_WORD = re.compile(r"[a-z0-9_.\-/]+")
def _tokens(text):
return {t for t in _WORD.findall(text.casefold()) if t not in _STOP and len(t) > 2}
def score(atom, prompt_tokens, now_day, core):
"""Token overlap x kind x confidence x recency. Deterministic, lexical, no
embeddings and no API call (5.2, and the repo-wide no-LLM-in-scripts rule)."""
at = _tokens(atom["claim"])
if not at:
return 0.0
overlap = len(at & prompt_tokens)
if not overlap:
return 0.0
base = overlap / (len(at) ** 0.5)
try:
age = max(0, now_day - int(core.parse_iso(atom["last_seen"]).timestamp() // 86400))
except Exception:
age = 0
recency = 1.0 / (1.0 + age / 30.0)
return (base
* KIND_WEIGHT.get(atom["kind"], 1.0)
* CONF_WEIGHT.get(atom["confidence"], 1.0)
* (0.7 + 0.3 * recency))
def recall(atoms, prompt, core, deadline=None):
"""Single linear pass, bounded top-5 -- no index build, no full sort (5.2).
Returns (rows, budget_expired)."""
ptok = _tokens(prompt)
if not ptok:
return [], False
now_day = int(time.time() // 86400)
top = [] # kept tiny; insertion sort into <=5 slots beats sorting 500
expired = False
for a in atoms:
if a["tier"] != "L1":
continue
if deadline is not None and time.monotonic() > deadline:
expired = True
break
s = score(a, ptok, now_day, core)
if s <= 0:
continue
if len(top) < MAX_ATOMS or s > top[-1][0]:
top.append((s, a))
top.sort(key=lambda r: r[0], reverse=True)
del top[MAX_ATOMS:]
return top, expired
def render(top, atoms, core):
lines, used = [], 0
for s, a in top:
tag = ""
if core.open_contradiction(a, atoms):
# 5.2 -- a contested atom must NOT surface as a bare claim.
tag = " [contested — newer evidence %s]" % a["last_seen"][:10]
ln = "- %s%s" % (a["claim"], tag)
cost = len(ln.encode("utf-8")) + 1
if used + cost > MAX_BYTES:
break
lines.append(ln)
used += cost
if not lines:
return ""
return "\n".join(["<agent_memory_recall>",
"Possibly relevant, remembered from earlier sessions:"]
+ lines + ["</agent_memory_recall>"])
def main():
if os.environ.get("AGENT_MEMORY_USERPROMPTSUBMIT") == "0":
return 0
deadline = time.monotonic() + BUDGET_S
try:
raw = "" if sys.stdin.isatty() else sys.stdin.read()
payload = json.loads(raw) if raw.strip() else {}
except Exception:
return 0
prompt = payload.get("prompt") or ""
if not prompt:
return 0
try:
import memory_core as core
cwd = payload.get("cwd") or os.getcwd()
atoms = core.AtomStore(os.path.join(cwd, ".memory")).read()
top, _expired = recall(atoms, prompt, core, deadline)
block = render(top, atoms, core)
if block:
sys.stdout.write(block + "\n")
except Exception:
return 0
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,100 @@
---
name: agent-memory
description: Use when a project's CLAUDE.md has grown past what anyone reads and you want the agent to learn durable facts from its own sessions instead — or when asking why the agent keeps re-learning the same correction, why a remembered rule is wrong, or where a memory line came from. Implements a four-tier store (L0 transcripts / L1 candidates / L2 project context / L3 stable persona) where promotion is earned by recurrence across sessions and days, never by one confident statement, and nothing reaches a committed file without a human adopting it.
argument-hint: "[optional: status | why \"<claim>\" | a tier name]"
license: MIT
metadata:
version: 1.0.0
build_pattern: "Tencent TencentDB-Agent-Memory's tiering concept rebuilt natively on Claude Code hooks; deterministic recurrence gates, no LLM, no database"
distinct_from: "llm-wiki (a vault you write on purpose; this writes itself from sessions); skillopt-sleep (replays tasks to improve a skill; this extracts facts to remember); memory-engineering (audits and prices any memory system; this IS one, and is a legitimate subject of that audit)"
---
# Agent Memory — promotion is earned, not asserted
> **Portability:** stdlib only. No database, no embeddings, no network, no LLM calls.
## The problem
A project's `CLAUDE.md` is a memory system with one tier and no eviction: every
durable fact and every passing preference land in the same always-loaded file,
until the important lines are diluted by the incidental ones. Facts learned
mid-session vanish at teardown unless someone writes them down.
**The fix is not more storage — it is a promotion ladder.** A claim earns its
way toward always-loaded context by recurring; a human confirms the last step.
## The four tiers
Tiers are distinguished by **injection policy**, not storage format.
| Tier | Holds | Injected | Committed |
|---|---|---|---|
| **L0** | raw session transcripts | never | no (already on disk) |
| **L1** | candidate atoms | on relevance, at prompt time | no (gitignored) |
| **L2** | this project's context | every session start | yes, after adopt |
| **L3** | stable cross-project persona | always | yes, after adopt |
## The gates
Nothing moves up because it sounded important. It moves up because it recurred.
- **L0 → L1** — an explicit marker fires (a directive, a correction, a stated
preference, a named lesson, a reproducible failure). Rule-based, high
precision, deliberately low recall.
- **L1 → L2** — ≥ 3 distinct sessions spanning ≥ 2 distinct calendar days. A
claim stated outright needs 2 sessions; the distinct-day rule still applies. A
verified claim promotes on one observation and is the only day-exempt path.
- **L2 → L3** — held in ≥ 2 distinct projects, aged ≥ 30 days, uncontested.
**Two gates refuse rather than guess.** A claim whose text was altered by
redaction never promotes on evidence alone — the flag firing is evidence the
source was sensitive, and a lexical filter finding one secret is not proof it
found all of them. A claim with an open contradiction is frozen at L1 until a
human resolves it; the incumbent is never silently overwritten.
## Use it
```bash
# what is remembered, and what is blocking the next promotion
python3 scripts/memory_inspect.py --tier L1
# where did this line come from — sessions, days, transcript, quoted source
python3 scripts/memory_inspect.py --why "PR base branch is dev"
# every claim with an open contradiction, both directions of the join
python3 scripts/memory_inspect.py --contested
# dry-run the promotion pass; writes nothing
python3 scripts/memory_promote.py
```
Three hooks run the loop unattended: `SessionStart` injects L2 + L3,
`UserPromptSubmit` recalls relevant L1 atoms, `SessionEnd` captures and stages.
Each is disabled independently with `AGENT_MEMORY_SESSIONSTART=0`,
`AGENT_MEMORY_USERPROMPTSUBMIT=0`, `AGENT_MEMORY_SESSIONEND=0`. Every hook fails
open: a broken memory system costs you memory, never a session.
## Hard rules
1. **Redact before writing.** Every atom passes the filter before it reaches
disk. Anything altered is quarantined from promotion.
2. **Propose, never apply.** Promotions land in `.memory/staged/`. Only an
explicit `/cs:memory adopt` touches a `CLAUDE.md`, and it backs both up first.
3. **Cite, don't invent.** Every atom carries a back-pointer to the transcript
line that produced it. `--why` resolving to *ambiguous* prints nothing rather
than guess: a wrong citation is worse than a missing one.
4. **Never surface a contested claim as fact.** It is still injected — hiding
the conflict is worse — but always tagged.
5. **The committed tiers carry no paths.** Promotion strips the back-pointer
prefix, which embeds an OS username.
## Forcing questions
Walk these one at a time before trusting the store.
1. Which line in your `CLAUDE.md` did you last actually read before acting?
2. Would you rather the agent forget a true thing, or remember a false one?
3. When two remembered rules disagree, who decides — and when?
4. What would make you delete `.memory/` entirely?
Rationale, open decisions, field schema: [`../../DESIGN.md`](../../DESIGN.md).

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/alirezarezvani/claude-skills/main/engineering/agent-memory/assets/memory_schema.json",
"$id": "https://raw.githubusercontent.com/alirezarezvani/claude-skills/main/engineering/agent-memory/skills/agent-memory/assets/memory_schema.json",
"$comment": "$id decision, settled — do not re-litigate. (a) raw.githubusercontent, NOT the github.com/blob/ form used by the repo's one other schema: blob URLs serve HTML, so anything that dereferences $id for $ref breaks. That other file is a pre-existing inconsistency, not a convention to propagate. (b) Pinned to main, not dev, because $id is a stable identifier for the published artifact; under this repo's dev -> main promotion flow it is a dead link until the promotion PR lands, which is accepted deliberately — a URL that is correct-eventually beats one that is correct-now and wrong-after-promotion. Nothing in this repo dereferences $id today.",
"title": "agent-memory atom (L1) — v1",
"description": "One atomic memory claim extracted from an L0 transcript. Every atom MUST carry a live back-pointer into the transcript that produced it; an atom without provenance is discarded, never stored. Promotion to L2/L3 is decided by recurrence across distinct sessions, not by importance. See ../DESIGN.md sections 3 and 4.",

View file

@ -0,0 +1,151 @@
# Hook discipline — running on someone else's critical path
Three hooks carry this skill. Two of them run on a path where being slow or
crashing is worse than being absent. This file is the standard they are held to.
---
## 1. Fail open, always
**Confidence: high. Non-negotiable.**
Every hook exits 0 on every failure, emitting nothing. A hook that can break
session start has converted an optional feature into a single point of failure
for the whole tool.
Concretely, in each hook: the entire body is wrapped, a missing store is the
*normal first-run state* rather than an error, malformed JSON on stdin returns
silently, and a missing transcript returns silently.
The test that matters: **delete `.memory/`, corrupt `atoms.jsonl`, and remove
read permission on it — three sessions must still start normally.**
## 2. Two latency limits, and conflating them is the common error
**Confidence: high on the distinction; the measurements are stated with their
method below.**
| Limit | Value | Enforced by |
|---|---|---|
| Internal self-budget | 100 ms | the recall script itself, against a monotonic clock |
| Hook timeout backstop | 1 s | Claude Code, via the `timeout` field |
The hook `timeout` field is in **seconds**, and 1 is its floor. It exists to
kill a wedged process. **Finishing under 1 s does not satisfy the requirement**
the recall hook runs on every prompt, so its cost compounds across a
conversation in a way a session-start cost does not.
**Measured, on the machine this was built on:** bare interpreter start
(`python3 -c pass`) p50 ≈ 12 ms / p95 ≈ 31 ms; full spawn-plus-recall against a
populated store p50 ≈ 29 ms / p95 ≈ 31 ms / max ≈ 35 ms; the scoring pass itself
23 ms over 500 atoms. Method: 15 sequential subprocess spawns, wall clock
around `subprocess.run`. **Confidence: high for this machine, low as a general
claim** — a cold filesystem cache, a slower disk, or a heavier interpreter
start-up will move these, and the honest response to a measurement that blows
the budget is to raise the number to the measured one or drop the hook, never to
keep an unmet claim.
The implication is worth stating because it inverts the intuition: **the script
is not the cost — the interpreter is.** Optimizing the scoring loop below ~2 ms
buys nothing. Only removing the process spawn would.
## 3. Readers take no lock
**Confidence: high.**
The recall hook reads without any lock at all. Blocking a 100 ms budget on a
lock held by an async teardown hook would blow the budget for a hook whose
entire failure mode is supposed to be "return nothing."
This is only safe because every **writer** commits via a temp file plus
`os.replace`, which is atomic within a filesystem. A reader therefore sees the
whole old file or the whole new one, never a half-written one. The lock-free
read and the atomic write are one design, not two.
Precedent in this repo, reused rather than reinvented:
`engineering/agent-harness/.../loop_controller.py` and
`engineering/skillopt-sleep/skillopt_sleep/state.py` both use the same
temp-file-plus-replace pattern.
## 4. The two timeouts are on different axes
**Confidence: high.** `5 s < 60 s` looks contradictory until you see the
questions differ:
| Value | Question | Behaviour |
|---|---|---|
| 60 s (lock mtime age) | "is this lock *abandoned*?" | older → break it immediately, no waiting |
| 5 s (wall clock) | "how long do I wait for a *live* lock?" | still held and young → retry up to 5 s, then give up |
The stale-break check runs **first**, so a writer meeting a 61-second-old lock
proceeds at once.
**Accepted race, recorded as a choice:** two writers can both judge a lock stale
and both proceed. The consequence is bounded — each still commits atomically, so
the loser's atoms are *lost*, not *corrupted*, and lost candidates re-observe on
the next session. Paying for a true mutex would buy durability this tier does not
need. Do not "fix" this without first showing the loss is observable.
## 5. Where silent loss is logged, and why not stderr
**Confidence: high.**
When a writer gives up, it appends one line to `.memory/errors.log` — timestamp,
count dropped, reason — capped at 200 lines, mode 0600.
**Not stderr.** The capture hook is `async`, so its stderr goes nowhere a human
reads. "We log the loss" to a stream nobody sees is a fiction. The log is also
surfaced by `/cs:memory status` for recent entries: a log nobody is pointed at is
the same as no log.
## 6. Never emit two contradictory lines unmarked
**Confidence: high on the constraint; the resolution *policy* is deliberately
left open.**
Session start injects project context and the global persona **together**, and
the contradiction detector structurally cannot compare them (a global atom has
no project). So nothing upstream guarantees they agree.
The hook therefore re-checks the pair at injection time and, on a collision,
marks **both** with an explicit conflict note rather than picking a winner. This
satisfies the constraint without deciding precedence — which remains an open
question. Marking both is the least committal option, and the one that does not
have to be undone if the decision lands on specificity-wins.
## 7. A recalled contested claim is never a bare claim
**Confidence: high.**
It is still injected — silently withholding a claim the user might be relying on
is worse than surfacing a disputed one — but always tagged
`[contested — newer evidence <date>]`. This rendering rule is tier-agnostic: it
applies wherever a contested atom surfaces, including one a human contested by
hand.
## 8. Every hook is independently disableable
**Confidence: high.** `AGENT_MEMORY_SESSIONSTART`, `AGENT_MEMORY_USERPROMPTSUBMIT`,
`AGENT_MEMORY_SESSIONEND`, each honouring `=0`.
The names mirror the Claude Code hook names exactly, so a user who knows the
hook names can derive all three without reading any documentation. A shorter,
cleverer name for one of them would trade that property for characters typed
once into a shell profile.
## Sources
1. Claude Code hooks reference — <https://code.claude.com/docs/en/hooks> — hook
names, the `timeout` field's units, and `async` semantics.
2. `productivity/handoff` in this repo — the per-hook env-var disable precedent
and its redaction linter's pattern coverage.
3. `engineering/skillopt-sleep` in this repo — `async` teardown work, and
staging rather than applying.
4. `engineering/agent-harness` in this repo — atomic state writes via
`os.replace`.
5. POSIX `rename(2)` / CPython `os.replace` — atomic replacement within a
filesystem, the property lock-free reading depends on.
6. Kleppmann, *Designing Data-Intensive Applications* (O'Reilly, 2017), ch. 3 —
crash-safe file replacement and why partial writes are the hazard.
7. Google SRE Workbook, ch. on overload and graceful degradation — the
fail-open posture: a degraded optional feature beats a hard dependency.

View file

@ -0,0 +1,159 @@
# Designing the gates — why these thresholds, and what they cost
The tiers are easy. The gates are the whole design. This file states what each
threshold is *for*, what it lets through, and what it wrongly blocks — because a
gate whose failure modes are undocumented will be "fixed" by the first person
who hits one.
---
## 1. The asymmetry that sets every threshold
**Confidence: high** — this is a decision, not a measurement, and it is the
premise everything else follows from.
The two error directions are not equally expensive:
| Error | Cost |
|---|---|
| **Under-promote** a true claim | the user restates it once; the restatement is itself an observation, so the system self-corrects |
| **Over-promote** a false claim | it is injected into *every* future session, silently steering work, until a human notices and hunts down where it came from |
Under-promotion is self-healing. Over-promotion is not. Every threshold is
therefore tuned toward refusing, and every ambiguous case resolves to "stay
where you are."
## 2. Why 3 sessions, and why days matter separately
**Confidence: high on the reasoning; the specific numbers are judgement, not
measurement.**
Three *sessions* filters the single-conversation artifact: a claim restated
three times inside one debugging session is one belief, expressed three times.
Session count alone does not fix this, because sessions can be minutes apart.
The **≥ 2 distinct calendar days** requirement is the part doing the real work.
It comes from the spacing effect (see `tiered_memory_canon.md` §5) and applies
to *every* path except `verified`. Note the implementation detail worth
preserving: because `first_seen` and `last_seen` **bound every observation**,
comparing their dates is *equivalent to* "≥ 2 distinct calendar days" — not a
proxy for it. Anyone tempted to store a full date set to be "more correct"
should know they would be storing data that cannot change the answer.
**What this wrongly blocks:** a genuine one-day-only fact — a decision made and
acted on in a single sitting, never mentioned again. It stays at L1 and is
recalled on relevance, which is the correct place for it. It is not lost; it is
just not promoted to always-loaded.
## 3. The confidence fast paths
| Confidence | Sessions needed | Day rule |
|---|---|---|
| `observed` | 3 | applies |
| `stated` | 2 | **still applies** |
| `verified` | 1 | **exempt** |
**Confidence: high on the design, and this table exists because the exemption is
the most likely thing to be implemented wrong.** `stated` is a shortcut on
*volume*, not on *time* — a user saying a thing outright twice is stronger
evidence than the system inferring it three times, but saying it twice in one
hour is still one conversation.
`verified` is the only day-exempt path, and it is exempt because its evidence is
categorically different: a claim is `verified` when a deterministic check
confirmed it (a command exited zero, a file contained what was claimed). That
is not testimony that needs corroboration over time — it is a measurement.
## 4. Redaction blocks promotion — a *durability-independent* barrier
**Confidence: high. This is the security-critical rule in the design.**
The recurrence gates answer "is this durable?" They do not answer "is this safe
to commit?" Those are different questions, and a secret restated across five
sessions and three days passes every durability gate with room to spare.
So `redacted: true` is a **hard block**, independent of all evidence. The
reasoning:
1. The flag firing means the redaction pass **altered the claim** — positive
evidence the source text contained something sensitive.
2. Redaction is **lexical**. Finding one secret is not proof of finding every
secret in the same sentence.
3. L2 and L3 are **committed to git**. The blast radius of being wrong is
permanent and public.
A human reviews it at `adopt`. There is no volume of evidence that substitutes
for that review.
## 5. Contradiction blocks promotion, and detection is a filter
**Confidence: high on the mechanism; explicitly limited on coverage.**
Two deterministic rules — explicit negation, and same-subject value swap — run
at merge time over atoms sharing a project. On a fire, the **older** atom is
marked contested; the newer is not auto-blessed. Both freeze.
Three things about this are worth stating plainly, because each has been
implemented wrong somewhere:
- **The newer atom carries no flag.** So the "no contradiction open" check
cannot be a field read on both sides — it is a **reverse join**: blocked if
your own `contested` is set *or* your id appears in anyone's `contested_by`. A
mirrored field would be the same fact stored twice with nothing able to say
which copy is right.
- **Detection cannot reach the global tier.** A global atom carries no project,
so it is in no group the detector forms. This is structural, not a missing
loop.
- **The rules will miss semantic contradictions.** "Always squash-merge" vs
"keep merge commits" needs meaning, not string shape. Both promote; the
conflict surfaces one tier later, tagged, for a human. **Detection is a
filter, never a guarantee** — the human gate is what actually holds.
## 6. Caps, and the two different things a cap can mean
**Confidence: high.**
| Tier | Cap | On overflow |
|---|---|---|
| L1 | 500 | evict oldest by `last_seen` — it is the recoverable tier |
| L2 | 60 / project | **demote** to L1 — recoverable, not destroyed |
| L3 | 30 | **refuse inflow** — never auto-demote |
The L3 rule is the subtle one. "Never auto-demoted" is a promise to the user
that a stable persona line will not silently disappear. Honouring it means the
cap must block *entry*, not force *exit*. A full L3 is a signal for a human to
prune, not a licence for the system to.
## 7. What would falsify this design
**Confidence: this section is the honest one.** The gates are asserted, not
validated. The specific ways to find out they are wrong:
- **Recall is too low to matter.** If a two-week trial yields a handful of
atoms and none reach L2, rule-based extraction is not viable and the honest
response is to delete the folder — not to loosen the gates until something
passes.
- **Precision is worse than claimed.** If atoms that reach L2 are frequently
things the user would not have written down, the markers are catching
conversational filler.
- **The store is never read.** If `--why` is never run and staged promotions are
adopted without review, the human gate is theatre and the security argument
above collapses.
## Sources
1. O'Neil, O'Neil & Weikum, *The LRU-K Page Replacement Algorithm*, SIGMOD 1993
— promotion on the K-th reference.
2. Megiddo & Modha, *ARC: A Self-Tuning, Low Overhead Replacement Cache*,
FAST 2003 — separating recency from frequency.
3. Cepeda et al., *Distributed practice in verbal recall tasks: A review and
quantitative synthesis*, Psychological Bulletin, 2006 — the spacing effect.
4. Park et al., *Generative Agents* (arXiv:2304.03442) — reflection as
promotion, and importance scoring as its gate.
5. Packer et al., *MemGPT* (arXiv:2310.08560) — context as a cache with an
eviction policy.
6. TencentDB-Agent-Memory —
<https://github.com/TencentCloud/TencentDB-Agent-Memory> — the tier ladder
this design rebuilds natively.
7. This repo's `engineering/skillopt-sleep` — the staging discipline
("propose, never apply") reused here rather than reinvented.

View file

@ -0,0 +1,141 @@
# Tiered agent memory — the canon, and what this skill takes from it
Every claim below carries a confidence level. Where a source is paraphrased
rather than quoted, that is stated. Where a figure comes from a vendor rather
than a controlled study, that is stated too — following the `andreessen`
precedent in this repo.
---
## 1. The idea: tiers are an *injection policy*, not a storage format
**Confidence: high** (this is the load-bearing design claim, and it is
architectural rather than empirical).
The reflex when adding memory to an agent is to pick a store — vector DB, graph,
JSONL — and treat tiering as an implementation detail of that store. That gets
the dependency backwards. What actually distinguishes a tier is **when its
contents enter the context window**:
| Tier | Injection policy | Consequence |
|---|---|---|
| L0 | never | may be arbitrarily large |
| L1 | on lexical relevance, per prompt | must be cheap to scan and cheap to be wrong about |
| L2 | once per session, project-scoped | must be small and must be right |
| L3 | always | must be *very* small and must be nearly certain |
Because the constraint tightens monotonically as you climb, the interesting
engineering is not storage — it is the **gate between tiers**. A design that
specifies four stores but not four gates has specified nothing.
## 2. TencentDB-Agent-Memory — the source of the framing
**Confidence: moderate on specifics, high on the framing.** The framing
(hierarchical memory with promotion between levels, backed by a database) is
clearly the project's organising idea. Specific API surface and defaults change;
read the repository rather than trusting a summary of it.
- Source: <https://github.com/TencentCloud/TencentDB-Agent-Memory>
What this skill **took**: the tier ladder, and the principle that a memory
system's value is in what it refuses to promote.
What it **rejected**, and why:
1. **The database.** A managed DB is a deployment dependency this repo's skills
cannot carry (root `CLAUDE.md`: stdlib only, self-contained packages). One
append-oriented JSONL file per project is enough for a store capped at 500
candidate atoms.
2. **A proxy layer between the agent and the model.** Claude Code already has
the interception points — `SessionStart`, `UserPromptSubmit`, `SessionEnd`.
Adding a proxy to obtain hooks a platform already provides buys nothing and
costs a failure mode on the critical path.
3. **LLM-based extraction.** Root `CLAUDE.md` forbids LLM calls in skill
scripts. This is not merely compliance: an extractor that calls a model
cannot run in an async teardown hook on a latency budget, and its output is
not reproducible from the transcript, which breaks "cite, don't invent."
## 3. MemGPT / Letta — memory as an OS paging problem
**Confidence: high on the core analogy, moderate on current implementation
details** (the project has been renamed and substantially rewritten).
Packer et al., *MemGPT: Towards LLMs as Operating Systems* (arXiv:2310.08560),
frames finite context as physical memory and everything else as disk, with the
model itself issuing paging calls. The durable contribution is the framing: a
context window is a **cache**, and every cache needs an eviction policy someone
chose on purpose.
- <https://arxiv.org/abs/2310.08560>
- <https://github.com/letta-ai/letta>
**Divergence, deliberate:** MemGPT lets the *model* decide what to page in.
This skill does not. Model-driven promotion means a confident wrong statement
promotes itself; recurrence across sessions cannot be produced by confidence.
## 4. Generative Agents — reflection, and the cost it implies
**Confidence: high** (widely replicated; the architecture is described in
detail in the paper).
Park et al., *Generative Agents: Interactive Simulacra of Human Behavior*
(arXiv:2304.03442), pairs a raw observation stream with periodic **reflection**
that synthesizes higher-level statements — a promotion ladder in all but name,
retrieving on recency, importance, and relevance.
- <https://arxiv.org/abs/2304.03442>
**Divergence:** reflection there is an LLM call scoring importance. Here
importance is not scored at all — it is *observed*, as recurrence. That is a
strictly weaker signal, and the trade is deliberate: it is deterministic,
auditable, and free.
## 5. Spaced repetition — why recurrence is the right durability signal
**Confidence: high on the effect, moderate on transferring it to agents.** The
spacing effect is one of the most replicated findings in learning research
(Ebbinghaus, *Über das Gedächtnis*, 1885; and the modern review literature,
e.g. Cepeda et al., *Distributed practice in verbal recall tasks*,
*Psychological Bulletin*, 2006). Repetition **distributed across time** produces
durable retention where massed repetition does not.
The transfer to an agent's memory is an **analogy, not a result**. It is why the
L1 → L2 gate requires ≥ 2 distinct calendar days and not merely ≥ 3 sightings:
three sightings in one hour is one conversation restating itself, which is
exactly the massed-practice case the literature says does not indicate
durability.
## 6. Cache promotion policies — the closest true prior art
**Confidence: high.** LRU-K (O'Neil, O'Neil & Weikum, SIGMOD 1993) promotes a
page on its **K-th** reference rather than its first, specifically to stop a
single scan from evicting a genuinely hot working set. ARC (Megiddo & Modha,
FAST 2003) maintains recency and frequency lists separately for the same reason.
- O'Neil et al., *The LRU-K Page Replacement Algorithm* (SIGMOD 1993)
- Megiddo & Modha, *ARC: A Self-Tuning, Low Overhead Replacement Cache* (FAST 2003)
The L1 → L2 gate is LRU-K with K = 3 and a wall-clock spread requirement. This
is the most honest description of what the gate is.
## 7. Anthropic on context engineering
**Confidence: moderate.** Anthropic's engineering writing on context management
and long-running agents consistently makes one point relevant here: context is a
scarce, curated resource, and what you *leave out* is a design decision. See
<https://www.anthropic.com/engineering> for current material.
This is the reason L3 is capped at 30 atoms. A persona tier with no cap is not a
persona — it is a second `CLAUDE.md`, which is the problem this skill exists to
address.
---
## What follows from all of this
1. **Do not let the model promote its own claims.** Every system above that
works uses an external signal (time, frequency, human) for durability.
2. **Cap every tier.** Uncapped memory is the failure mode, not the feature.
3. **Recurrence is weak but cheap and honest.** It under-promotes. Under-
promotion costs a re-statement; over-promotion costs a wrong instruction in
every future session.

View file

@ -0,0 +1,373 @@
#!/usr/bin/env python3
"""Shared core for the agent-memory tiered store (L0-L3).
Implements the contracts DESIGN.md pins:
- normalize() / atom_id() -- 4.1, must reproduce the doc's worked ids
- redact() -- 6 rule 1, runs before ANY write
- canonical_backpointer() -- 3.1.1, platform-independent, de-identified
- AtomStore -- 5.4 concurrency: lock-free reads, atomic
os.replace writes, 5s wait / 60s stale break
- contradiction detection -- 4.2.1, two narrow deterministic rules
NOT a plugin-facing tool: it has no CLI of its own. Every other script and hook
in this skill imports it, which is deliberate -- redaction patterns, the id
algorithm and the lock protocol duplicated across seven files is precisely the
drift class DESIGN.md exists to prevent.
Deviation from DESIGN.md 10's planned tree: that tree lists four scripts and no
shared module. Duplicating this logic instead would have been worse. See
README.md "Deviations from the spec".
stdlib only. No LLM calls (root CLAUDE.md anti-pattern).
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import time
from datetime import datetime, timedelta, timezone
# --------------------------------------------------------------------------
# 4.1 -- identity. Pinned exactly; the ids in DESIGN.md and memory_schema.json
# are worked examples of this contract and must reproduce.
# --------------------------------------------------------------------------
CONFIDENCE_ORDER = ["observed", "stated", "verified"]
GATE_SESSIONS = {"observed": 3, "stated": 2, "verified": 1}
# 4.3 / 5.1 caps
L1_MAX_ATOMS = 500
L2_MAX_ATOMS = 60
L3_MAX_ATOMS = 30
L1_TTL_DAYS = 90
L2_MIN_AGE_DAYS = 30
def normalize(claim):
"""The algorithm DESIGN.md 4.1 publishes. Order matters: collapse
whitespace, then casefold, then strip trailing punctuation."""
return re.sub(r"\s+", " ", claim.strip()).casefold().rstrip(".,;:!?")
def atom_id(claim, project=None):
"""sha256 (NOT builtin hash(), which is salted per process for str and
would produce different ids every run, breaking merges outright)."""
key = normalize(claim) + ("\0" + project if project else "")
return "atm_" + hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
# --------------------------------------------------------------------------
# 6 rule 1 -- redaction. productivity/handoff's 17-pattern linter is the
# stated floor; these are re-implemented rather than imported, because root
# CLAUDE.md forbids cross-skill dependencies (2.5 applies the same rule to
# skillopt_sleep). Coverage is the contract, not the import.
# --------------------------------------------------------------------------
_REDACTIONS = [
("aws-access-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("aws-secret", re.compile(
r"(?i)aws.{0,20}(secret|access).{0,20}[=:]\s*['\"]?[A-Za-z0-9/+=]{40}['\"]?")),
("github-token", re.compile(r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b")),
("anthropic-key", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}\b")),
("openai-key", re.compile(r"\bsk-[A-Za-z0-9_\-]{20,}\b")),
("slack-token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
("google-api-key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
("stripe-key", re.compile(r"\b(sk|pk|rk)_(live|test)_[A-Za-z0-9]{16,}\b")),
("private-key-block", re.compile(
r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----")),
("jwt", re.compile(
r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b")),
("bearer-token", re.compile(
r"(?i)\bauthorization\s*:\s*bearer\s+[A-Za-z0-9_\-\.]{20,}")),
("env-secret-assign", re.compile(
r"(?i)\b(?:api[_-]?key|secret|password|passwd|token|credential)\s*[=:]\s*"
r"['\"]?[A-Za-z0-9/+=_\-]{12,}['\"]?")),
("db-connection-string", re.compile(
r"(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp)://"
r"[^\s:@/]+:[^\s:@/]+@[^\s/]+")),
("url-token-param", re.compile(
r"https?://[^\s'\"<>]*(?:[?&](?:token|access_token|api_key|key)=)[^\s'\"<>&]+")),
("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
("phone", re.compile(
r"(?<![\w.])(?:\+?\d{1,3}[ .-]?)?(?:\(\d{3}\)|\d{3})[ .-]\d{3}[ .-]\d{4}(?![\w.])")),
("credit-card", re.compile(r"\b(?:\d[ -]?){13,19}\b")),
]
def redact(text):
"""Return (redacted_text, hit_rule_names).
Lexical, therefore a filter and never a guarantee -- which is exactly why
4.1 makes `redacted: true` block promotion pending human review: the flag
firing is positive evidence the source was sensitive, and finding one thing
is not proof of finding everything.
"""
hits = []
out = text
for name, pat in _REDACTIONS:
if pat.search(out):
hits.append(name)
out = pat.sub("[REDACTED:%s]" % name, out)
return out, hits
# --------------------------------------------------------------------------
# 3.1.1 -- back-pointers. Canonical, not observed: derived to a ~/-relative
# forward-slash form from whatever the platform handed us, so a Windows
# %USERPROFILE%\.claude\... path does not produce a schema-invalid atom.
# --------------------------------------------------------------------------
_L1_PAT = re.compile(r"^~/\.claude/projects/[^/]+/[A-Za-z0-9._-]+\.jsonl#L[0-9]+$")
_COMMITTED_PAT = re.compile(r"^[A-Za-z0-9._-]+\.jsonl#L[0-9]+$")
def canonical_backpointer(transcript_path, line_no):
"""Absolute/native transcript path + line -> the L1 form the schema accepts."""
p = str(transcript_path).replace("\\", "/")
parts = [seg for seg in p.split("/") if seg]
try:
i = parts.index("projects")
slug, fname = parts[i + 1], parts[i + 2]
except (ValueError, IndexError):
slug, fname = "unknown", parts[-1] if parts else "unknown.jsonl"
return "~/.claude/projects/%s/%s#L%d" % (slug, fname, int(line_no))
def strip_backpointer(bp):
"""3.1.1 -- promotion into a committed tier drops the path prefix, which
embeds the OS username. Strips the prefix and NOTHING else; the line
number must survive unchanged."""
return bp.rsplit("/", 1)[-1]
def resolve_backpointer(bp, home=None):
"""Reverse the strip for a local read. Returns (path, status) where status
is one of ok / missing / ambiguous.
The ambiguous row is the one worth having: without it a naive
implementation takes the first glob match and attributes a claim to the
wrong session -- a *wrong* citation, which 6 rule 6 treats as worse than a
missing one.
"""
import glob
home = home or os.path.expanduser("~")
fname = strip_backpointer(bp).split("#")[0]
hits = glob.glob(os.path.join(home, ".claude", "projects", "*", fname))
if len(hits) == 1:
return hits[0], "ok"
if not hits:
return None, "missing"
return None, "ambiguous"
# --------------------------------------------------------------------------
# time helpers
# --------------------------------------------------------------------------
def utcnow():
return datetime.now(timezone.utc)
def iso(dt=None):
return (dt or utcnow()).strftime("%Y-%m-%dT%H:%M:%SZ")
def parse_iso(s):
return datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
def days_between(a, b):
return abs((parse_iso(b) - parse_iso(a)).days)
# --------------------------------------------------------------------------
# 5.4 -- the store. Lock-free reads; writes take a lock, write a temp file,
# then os.replace (atomic on POSIX and Windows). 5s bounded wait, 60s stale
# break by mtime. On contention the write is dropped and logged: L1 is the
# recoverable tier by construction, so a lost observation costs one re-sighting.
# --------------------------------------------------------------------------
LOCK_WAIT_S = 5.0
LOCK_STALE_S = 60.0
class AtomStore:
def __init__(self, root=None):
self.root = os.path.abspath(root or os.path.join(os.getcwd(), ".memory"))
self.path = os.path.join(self.root, "atoms.jsonl")
self.lock = os.path.join(self.root, "atoms.lock")
self.errors = os.path.join(self.root, "errors.log")
self.staged = os.path.join(self.root, "staged")
self.adopted = os.path.join(self.root, "adopted.log")
# -- 5.3: a missing file is the normal initial state, not an error -----
def read(self):
try:
with open(self.path, "r", encoding="utf-8") as fh:
return [json.loads(ln) for ln in fh if ln.strip()]
except FileNotFoundError:
return []
except json.JSONDecodeError:
return []
def _ensure_dirs(self):
os.makedirs(self.root, mode=0o700, exist_ok=True)
try:
os.chmod(self.root, 0o700) # 6 rule 3
except OSError:
pass
def _acquire(self):
deadline = time.monotonic() + LOCK_WAIT_S
while True:
try:
fd = os.open(self.lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except FileExistsError:
try:
age = time.time() - os.path.getmtime(self.lock)
if age > LOCK_STALE_S:
# Accepted TOCTOU: two processes can both decide a lock
# is stale. Bounded by design -- worst case is a lost
# write on the recoverable tier, never a corrupt file,
# since the write itself is an atomic os.replace.
os.unlink(self.lock)
continue
except OSError:
pass
if time.monotonic() >= deadline:
return False
time.sleep(0.05)
def _release(self):
try:
os.unlink(self.lock)
except OSError:
pass
def write(self, atoms):
"""Atomic replace under lock. Returns True on write, False if dropped."""
self._ensure_dirs()
if not self._acquire():
self.log_error("lock contention: %d atoms dropped" % len(atoms))
return False
try:
tmp = self.path + ".tmp.%d" % os.getpid()
with open(tmp, "w", encoding="utf-8") as fh:
for a in atoms:
fh.write(json.dumps(a, ensure_ascii=False, sort_keys=True) + "\n")
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, self.path)
return True
finally:
self._release()
def log_error(self, msg):
self._ensure_dirs()
try:
lines = []
if os.path.exists(self.errors):
with open(self.errors, "r", encoding="utf-8") as fh:
lines = fh.readlines()
lines.append("%s %s\n" % (iso(), msg))
with open(self.errors, "w", encoding="utf-8") as fh:
fh.writelines(lines[-200:]) # capped, per 6
os.chmod(self.errors, 0o600)
except OSError:
pass
# --------------------------------------------------------------------------
# 4.1.3 -- confidence is monotonic. Never downgrades.
# --------------------------------------------------------------------------
def max_confidence(a, b):
return a if CONFIDENCE_ORDER.index(a) >= CONFIDENCE_ORDER.index(b) else b
# --------------------------------------------------------------------------
# 4.2.1 -- contradiction detection. Two narrow deterministic rules over atoms
# sharing a project. Cannot reach L3 (scope=global has no project) -- see 9.6.
# Detection is a filter, never a guarantee.
# --------------------------------------------------------------------------
_NEG = {"not", "never", "no", "n't", "longer"}
def _tokens(claim):
return normalize(claim).split()
def contradicts(a_claim, b_claim, a_kind=None, b_kind=None):
ta, tb = _tokens(a_claim), _tokens(b_claim)
# rule 1: explicit negation -- differ only by a negation token
sa, sb = [t for t in ta if t not in _NEG], [t for t in tb if t not in _NEG]
if sa == sb and ta != tb:
return "explicit-negation"
# rule 2: same-subject conflict -- same kind, >=3 shared leading tokens,
# different trailing value
if a_kind and a_kind == b_kind and len(ta) >= 4 and len(tb) >= 4:
lead = 0
for x, y in zip(ta, tb):
if x != y:
break
lead += 1
if lead >= 3 and ta[lead:] and tb[lead:] and ta[lead:] != tb[lead:]:
return "same-subject-conflict"
return None
def open_contradiction(atom, atoms):
"""4.2.1 -- the newer atom carries no flag, so this is a REVERSE JOIN:
blocked if own `contested` is set OR own id appears in another atom's
`contested_by`. Deliberately not a mirrored field -- that would be the same
fact in two places with nothing able to say which copy is right. Cheap by
construction: the store is capped at L1_MAX_ATOMS."""
if atom.get("contested"):
return True
aid = atom["id"]
return any(aid in other.get("contested_by", []) for other in atoms)
def mark_contradictions(atoms):
"""4.2.1 -- runs at merge time in SessionEnd. Groups by project, applies the
two rules, and marks the OLDER atom `contested` + `contested_by`. The newer
atom is not auto-blessed; both sit at L1 until a human resolves at adopt.
Returns the list of (older_id, newer_id, rule) pairs it marked, so the
caller can report them -- silent marking would make a claim stop promoting
with no visible cause.
"""
fired = []
by_project = {}
for a in atoms:
if a["tier"] in ("L1", "L2") and a.get("project"):
by_project.setdefault(a["project"], []).append(a)
for group in by_project.values():
group.sort(key=lambda g: g["first_seen"])
for i, older in enumerate(group):
for newer in group[i + 1:]:
rule = contradicts(older["claim"], newer["claim"],
older["kind"], newer["kind"])
if not rule:
continue
ids = older.setdefault("contested_by", [])
if newer["id"] not in ids:
ids.append(newer["id"])
older["contested"] = True
fired.append((older["id"], newer["id"], rule))
return fired
def distinct_days(atom):
"""4.1 -- first_seen and last_seen BOUND every observation, so different
dates is equivalent to '>= 2 distinct calendar days', not a proxy for it."""
return 2 if atom["first_seen"][:10] != atom["last_seen"][:10] else 1

View file

@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""memory_extract.py -- L0 -> L1. Rule-based, no LLM.
DESIGN.md 9.2 is the open decision this script IS the answer to: option (a),
"rule-based on explicit markers only -- high precision, low recall". It does
not try to understand a transcript. It looks for the handful of shapes in
which a durable operational fact is stated OUT LOUD, and ignores everything
else. Recall is deliberately low; 9.3's two-week trial is the test of whether
it is high enough to be worth keeping.
Every emitted atom is redacted before it is returned (6 rule 1) and carries a
live back-pointer (4.1's L0 -> L1 gate).
Exit codes: 0 ok, 2 bad input.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import memory_core as core # noqa: E402
# --------------------------------------------------------------------------
# The markers. Each is a shape in which a fact is stated explicitly enough that
# a regex is honest. Anything requiring inference is out of scope by design.
# --------------------------------------------------------------------------
MARKERS = [
# ("name", pattern, kind, confidence)
("directive", re.compile(
r"(?i)\b(?:always|never|must|don'?t ever)\b\s+(?P<c>[^.!?\n]{8,160})"),
"constraint", "stated"),
("correction", re.compile(
r"(?i)\b(?:no,|actually,|not quite[—-]?|correction:)\s*(?P<c>[^.!?\n]{8,160})"),
"correction", "stated"),
("preference", re.compile(
r"(?i)\b(?:we (?:use|prefer)|I (?:use|prefer)|convention is)\s+(?P<c>[^.!?\n]{8,160})"),
"preference", "stated"),
("lesson", re.compile(
r"(?i)^\s*[-*]?\s*(?:lesson|rule|gotcha):\s*(?P<c>[^\n]{8,160})"),
"constraint", "stated"),
("failure", re.compile(
r"(?i)\b(?P<c>[a-z][^.!?\n]{6,140}?\s+(?:fails?|breaks?|errors? out)\s+"
r"(?:when|if|because)\s+[^.!?\n]{4,80})"),
"failure", "observed"),
]
# Lines that look like markers but are the agent talking, not the user or a
# verified result. High-precision means refusing these.
_NOISE = re.compile(
r"(?i)^\s*(?:i'?ll|i will|let me|should i|shall i|would you like|"
r"here'?s|for example|e\.g\.|note that)")
def _iter_messages(path):
"""Claude Code transcripts are jsonl, one event per line. We read only
user-authored text and tool results -- never the assistant's own prose,
which would let the system learn from its own guesses."""
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
for n, line in enumerate(fh, 1):
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
role = (ev.get("role") or ev.get("type") or "").lower()
if role not in ("user", "human", "tool_result", "toolresult"):
continue
content = ev.get("content") or ev.get("text") or ""
if isinstance(content, list):
content = " ".join(
c.get("text", "") for c in content if isinstance(c, dict))
if isinstance(content, str) and content.strip():
yield n, content
except FileNotFoundError:
return
def extract(transcript, project, session_id, now=None):
"""Returns a list of well-formed, redacted L1 atoms."""
now = now or core.iso()
seen = {}
for lineno, text in _iter_messages(transcript):
for raw_line in text.splitlines():
if _NOISE.match(raw_line):
continue
for _name, pat, kind, conf in MARKERS:
m = pat.search(raw_line)
if not m:
continue
claim = " ".join(m.group("c").split()).strip(" -–—:;,")
if len(claim) < 8:
continue
clean, hits = core.redact(claim)
aid = core.atom_id(clean, project)
if aid in seen:
continue
bp = core.canonical_backpointer(transcript, lineno)
seen[aid] = {
"id": aid,
"claim": clean,
"scope": "project",
"project": project,
"kind": kind,
"first_seen": now,
"last_seen": now,
"observations": 1,
"sessions": [session_id],
"source": bp,
"first_source": bp,
"confidence": conf,
"tier": "L1",
"redacted": bool(hits),
}
break # one atom per line -- first marker wins
return list(seen.values())
def merge_into_store(store, new_atoms):
"""5.3 -- increment observations, extend sessions, raise confidence to the
max (4.1.3, never lower). Returns (atoms, n_new, n_merged)."""
existing = {a["id"]: a for a in store.read()}
n_new = n_merged = 0
for a in new_atoms:
cur = existing.get(a["id"])
if cur is None:
existing[a["id"]] = a
n_new += 1
continue
cur["observations"] += 1
for s in a["sessions"]:
if s not in cur["sessions"]:
cur["sessions"].append(s)
cur["last_seen"] = max(cur["last_seen"], a["last_seen"])
cur["first_seen"] = min(cur["first_seen"], a["first_seen"])
cur["source"] = a["source"] # newest evidence
cur["confidence"] = core.max_confidence(cur["confidence"], a["confidence"])
cur["redacted"] = cur.get("redacted") or a["redacted"]
n_merged += 1
atoms = list(existing.values())
# 4.3 / 5.2 -- L1 is capped; evict by last_seen ascending.
l1 = [a for a in atoms if a["tier"] == "L1"]
if len(l1) > core.L1_MAX_ATOMS:
l1.sort(key=lambda a: a["last_seen"])
drop = {a["id"] for a in l1[: len(l1) - core.L1_MAX_ATOMS]}
atoms = [a for a in atoms if a["id"] not in drop]
return atoms, n_new, n_merged
SAMPLE = '''{"role":"user","content":"always target dev for PRs, never main"}
{"role":"user","content":"Actually, the base branch is dev"}
{"role":"user","content":"I'll go check that for you"}
{"role":"user","content":"mkdocs build fails when nav lists a page with no matching file"}
{"role":"user","content":"the staging key sk-ant-aaaaaaaaaaaaaaaaaaaaaa works"}
'''
def main():
ap = argparse.ArgumentParser(
description="Extract L1 atoms from a Claude Code transcript (rule-based, no LLM).")
ap.add_argument("transcript", nargs="?", help="path to a session .jsonl")
ap.add_argument("--project", default=os.path.basename(os.getcwd()))
ap.add_argument("--session", default="unknown-session")
ap.add_argument("--sample", action="store_true",
help="run against a built-in sample transcript")
ap.add_argument("--output", choices=["text", "json"], default="text")
a = ap.parse_args()
if a.sample:
import tempfile
fd, path = tempfile.mkstemp(suffix=".jsonl")
with os.fdopen(fd, "w") as fh:
fh.write(SAMPLE)
atoms = extract(path, a.project, "01SESSIONSAMPLE0000000")
os.unlink(path)
elif a.transcript:
atoms = extract(a.transcript, a.project, a.session)
else:
ap.error("give a transcript path or --sample")
if a.output == "json":
print(json.dumps({"atoms": atoms, "count": len(atoms)}, indent=2))
else:
print("Extracted %d atom(s) from %s\n" % (
len(atoms), a.transcript or "<sample>"))
for at in atoms:
flag = " [REDACTED]" if at["redacted"] else ""
print(" %s %-11s %-8s %s%s" % (
at["id"], at["kind"], at["confidence"], at["claim"][:64], flag))
if not atoms:
print(" (none -- rule-based extraction is high-precision by design;")
print(" see DESIGN.md 9.2 for the recall trade this makes)")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""memory_inspect.py -- read the store. Never writes.
DESIGN.md 6 rule 6 ("cite, don't invent") only means anything if a human can
walk any injected line back to the transcript that produced it. This is that
walk. Three questions:
--tier L1|L2|L3 what is in each tier, and what is blocking the next hop
--contested every atom with an open contradiction, both directions
--why "<claim>" the provenance of one claim: how many sessions, over how
many calendar days, from which transcript, and -- if the
file is still on disk -- the actual line it came from
`--why` resolving to `ambiguous` is a feature, not a failure: two projects can
hold a transcript of the same basename, and guessing between them would attach
a real claim to the wrong session (6 rule 6 treats a WRONG citation as worse
than a missing one).
Exit codes: 0 ok, 2 bad input, 3 claim not found.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import memory_core as core # noqa: E402
def _blocking_reason(atom, atoms):
"""Why this L1 atom is not L2 yet. Mirrors memory_promote._eligible_l1 --
kept as a separate read-only formulation so inspect never imports the
promoter and can never, by construction, promote anything."""
if atom["tier"] != "L1":
return None
if atom.get("redacted"):
return "redacted -- needs human review (4.1)"
if core.open_contradiction(atom, atoms):
return "contradiction open (4.2.1)"
need = core.GATE_SESSIONS[atom["confidence"]]
have = len(set(atom["sessions"]))
if have < need:
return "sessions %d/%d" % (have, need)
if atom["confidence"] != "verified" and core.distinct_days(atom) < 2:
return "seen on one calendar day only"
return "eligible -- promotes on next pass"
def tier_view(atoms, tier):
rows = [a for a in atoms if a["tier"] == tier]
rows.sort(key=lambda a: (a["last_seen"], a["id"]), reverse=True)
out = []
for a in rows:
out.append({
"id": a["id"],
"claim": a["claim"],
"kind": a["kind"],
"confidence": a["confidence"],
"project": a.get("project"),
"sessions": len(set(a["sessions"])),
"observations": a["observations"],
"days": core.distinct_days(a),
"redacted": bool(a.get("redacted")),
"status": _blocking_reason(a, atoms),
})
return out
def contested_view(atoms):
"""Both directions of the reverse join (4.2.1): atoms carrying `contested`,
and atoms named in someone else's `contested_by` while carrying no flag of
their own -- which is the entire reason the join exists."""
by_id = {a["id"]: a for a in atoms}
out = []
for a in atoms:
if a.get("contested"):
out.append({"id": a["id"], "claim": a["claim"], "direction": "self-flagged",
"contested_by": a.get("contested_by", []),
"counterparts": [by_id[c]["claim"] for c in a.get("contested_by", [])
if c in by_id]})
for a in atoms:
for other in atoms:
if a["id"] in other.get("contested_by", []) and not a.get("contested"):
out.append({"id": a["id"], "claim": a["claim"],
"direction": "named-by-other", "named_by": other["id"],
"counterparts": [other["claim"]]})
return out
def why(atoms, claim):
"""Provenance for one claim, matched on the normalized form (4.1) so
punctuation and casing do not have to be reproduced by hand."""
target = core.normalize(claim)
hits = [a for a in atoms
if core.normalize(a["claim"]) == target or target in core.normalize(a["claim"])]
if not hits:
return None
a = sorted(hits, key=lambda h: len(h["claim"]))[0]
first_path, first_status = core.resolve_backpointer(a["first_source"])
last_path, last_status = core.resolve_backpointer(a["source"])
rec = {
"id": a["id"],
"claim": a["claim"],
"tier": a["tier"],
"kind": a["kind"],
"confidence": a["confidence"],
"scope": a["scope"],
"project": a.get("project"),
"observations": a["observations"],
"distinct_sessions": len(set(a["sessions"])),
"sessions": sorted(set(a["sessions"])),
"first_seen": a["first_seen"],
"last_seen": a["last_seen"],
"spans_days": core.days_between(a["first_seen"], a["last_seen"]),
"distinct_calendar_days": core.distinct_days(a),
"first_source": a["first_source"],
"first_source_resolved": first_status,
"source": a["source"],
"source_resolved": last_status,
"redacted": bool(a.get("redacted")),
"contradiction_open": core.open_contradiction(a, atoms),
"blocking": _blocking_reason(a, atoms),
"promoted_at": a.get("promoted_at"),
"promoted_from_projects": a.get("promoted_from_projects"),
}
# Quote the actual source line only when exactly one transcript matched.
if last_status == "ok" and last_path:
try:
n = int(a["source"].rsplit("#L", 1)[1])
with open(last_path, "r", encoding="utf-8", errors="replace") as fh:
for i, line in enumerate(fh, 1):
if i == n:
quoted, _ = core.redact(line.strip()[:400])
rec["source_line"] = quoted
break
except (OSError, ValueError, IndexError):
pass
return rec
SAMPLE_ATOMS = [
{"id": "atm_11111111", "claim": "PR base branch is dev, never main",
"scope": "project", "project": "demo", "kind": "constraint",
"first_seen": "2026-08-01T09:00:00Z", "last_seen": "2026-08-06T11:00:00Z",
"observations": 5, "sessions": ["01A", "01B", "01C"],
"source": "~/.claude/projects/-home-u/01C.jsonl#L9",
"first_source": "~/.claude/projects/-home-u/01A.jsonl#L2",
"confidence": "observed", "tier": "L2", "redacted": False,
"promoted_at": "2026-08-07T09:00:00Z"},
{"id": "atm_22222222", "claim": "scripts are stdlib only",
"scope": "project", "project": "demo", "kind": "constraint",
"first_seen": "2026-08-02T09:00:00Z", "last_seen": "2026-08-02T18:00:00Z",
"observations": 2, "sessions": ["01D"],
"source": "~/.claude/projects/-home-u/01D.jsonl#L3",
"first_source": "~/.claude/projects/-home-u/01D.jsonl#L3",
"confidence": "observed", "tier": "L1", "redacted": False},
{"id": "atm_33333333", "claim": "scripts are not stdlib only",
"scope": "project", "project": "demo", "kind": "constraint",
"first_seen": "2026-08-09T09:00:00Z", "last_seen": "2026-08-11T09:00:00Z",
"observations": 3, "sessions": ["01E", "01F", "01G"],
"source": "~/.claude/projects/-home-u/01G.jsonl#L1",
"first_source": "~/.claude/projects/-home-u/01E.jsonl#L1",
"confidence": "observed", "tier": "L1", "redacted": False,
"contested": True, "contested_by": ["atm_22222222"]},
]
def main():
ap = argparse.ArgumentParser(
description="Inspect the tiered memory store. Read-only; never writes or promotes.")
ap.add_argument("--memory-dir", default=None, help="path to .memory/ (default: ./.memory)")
ap.add_argument("--tier", choices=["L1", "L2", "L3"], help="list one tier")
ap.add_argument("--contested", action="store_true",
help="atoms with an open contradiction, both join directions")
ap.add_argument("--why", metavar="CLAIM", help="provenance for one claim")
ap.add_argument("--sample", action="store_true", help="run against built-in sample atoms")
ap.add_argument("--output", choices=["text", "json"], default="text")
a = ap.parse_args()
atoms = list(SAMPLE_ATOMS) if a.sample else core.AtomStore(a.memory_dir).read()
if a.why:
rec = why(atoms, a.why)
if rec is None:
print("No atom matches %r." % a.why, file=sys.stderr)
return 3
if a.output == "json":
print(json.dumps(rec, indent=2, ensure_ascii=False))
return 0
print("%s [%s]\n %s\n" % (rec["id"], rec["tier"], rec["claim"]))
print(" kind/confidence : %s / %s" % (rec["kind"], rec["confidence"]))
print(" seen : %d observation(s) across %d session(s)"
% (rec["observations"], rec["distinct_sessions"]))
print(" window : %s -> %s (%d day span, %d distinct calendar day(s))"
% (rec["first_seen"], rec["last_seen"], rec["spans_days"],
rec["distinct_calendar_days"]))
print(" first evidence : %s [%s]" % (rec["first_source"], rec["first_source_resolved"]))
print(" latest evidence : %s [%s]" % (rec["source"], rec["source_resolved"]))
if "source_line" in rec:
print(" > %s" % rec["source_line"])
elif rec["source_resolved"] == "ambiguous":
print(" (transcript basename matches more than one project --")
print(" refusing to guess; a wrong citation is worse than none)")
if rec["redacted"]:
print(" redacted : yes -- blocked from promotion pending review")
if rec["contradiction_open"]:
print(" contradiction : OPEN")
if rec["blocking"]:
print(" next hop : %s" % rec["blocking"])
return 0
if a.contested:
rows = contested_view(atoms)
if a.output == "json":
print(json.dumps({"contested": rows, "count": len(rows)}, indent=2,
ensure_ascii=False))
return 0
print("Contested atoms: %d\n" % len(rows))
for r in rows:
print(" %s (%s)" % (r["id"], r["direction"]))
print(" %s" % r["claim"][:78])
for c in r["counterparts"]:
print(" vs. %s" % c[:74])
if not rows:
print(" (none)")
return 0
tiers = [a.tier] if a.tier else ["L1", "L2", "L3"]
if a.output == "json":
print(json.dumps({t: tier_view(atoms, t) for t in tiers}, indent=2,
ensure_ascii=False))
return 0
for t in tiers:
rows = tier_view(atoms, t)
print("%s -- %d atom(s)" % (t, len(rows)))
for r in rows:
flag = " [REDACTED]" if r["redacted"] else ""
print(" %s %-9s %-8s %s%s" % (r["id"], r["kind"], r["confidence"],
r["claim"][:56], flag))
if r["status"]:
print(" %s" % r["status"])
if not rows:
print(" (empty)")
print("")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""memory_promote.py -- L1 -> L2 -> L3. Deterministic, recurrence-based.
Implements DESIGN.md 4 exactly:
L1 -> L2 >= 3 distinct sessions, spanning >= 2 distinct calendar days,
same project, no contradiction open.
Fast paths: `stated` needs 2 sessions (distinct-days STILL
applies); `verified` promotes on 1 observation and is the only
path exempt from distinct-days.
L2 -> L3 held at L2 in >= 2 distinct projects, age >= 30d, uncontested.
A merge, not a flag flip -- new project-free id.
HARD GATES, both of which refuse rather than guess:
* `redacted: true` never promotes on evidence alone (4.1). The flag means
the pass ALTERED the claim, which is positive evidence the source was
sensitive; redaction is lexical, so finding one thing is not proof of
finding everything.
* an open contradiction blocks, found by REVERSE JOIN (4.2.1) -- the newer
atom carries no flag.
Nothing is written to CLAUDE.md. Promotions land in .memory/staged/ for an
explicit human `adopt` (5.3).
Exit codes: 0 ok, 2 bad input.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import memory_core as core # noqa: E402
def _eligible_l1(atom, atoms, now):
"""Returns (ok, reason). reason names the blocking gate when not ok."""
if atom["tier"] != "L1":
return False, "not-L1"
if atom.get("redacted"):
return False, "redacted-needs-human-review"
if core.open_contradiction(atom, atoms):
return False, "contradiction-open"
conf = atom["confidence"]
need = core.GATE_SESSIONS[conf]
if len(set(atom["sessions"])) < need:
return False, "sessions %d/%d" % (len(set(atom["sessions"])), need)
# distinct-days: `verified` is the only exempt path
if conf != "verified" and core.distinct_days(atom) < 2:
return False, "single-calendar-day"
return True, "ok"
def promote_l1_to_l2(atoms, now=None):
now = now or core.iso()
promoted, blocked = [], []
for a in atoms:
if a["tier"] != "L1":
continue
ok, why = _eligible_l1(a, atoms, now)
if not ok:
if why != "not-L1":
blocked.append((a, why))
continue
p = dict(a)
p["tier"] = "L2"
p["promoted_at"] = now
# 3.1.1 -- strip the path prefix. This is the crossing into committed
# territory; skipping it writes an OS username into a git-tracked file.
p["source"] = core.strip_backpointer(a["source"])
p["first_source"] = core.strip_backpointer(a["first_source"])
promoted.append(p)
return promoted, blocked
def promote_l2_to_l3(atoms, now=None):
"""4.1.1 -- a merge. Groups by the project-free hash of the normalized
claim, which is lexical: two projects wording the same rule differently
never merge. That failure is one-directional (L3 under-fires, never
mis-fires) and is a stated limit, not a bug."""
now = now or core.iso()
groups = {}
for a in atoms:
if a["tier"] != "L2" or a.get("redacted"):
continue
if core.open_contradiction(a, atoms):
continue
if core.days_between(a.get("promoted_at", a["first_seen"]), now) < core.L2_MIN_AGE_DAYS:
continue
groups.setdefault(core.normalize(a["claim"]), []).append(a)
merged, notes = [], []
for _key, group in groups.items():
projects = sorted({g["project"] for g in group})
if len(projects) < 2:
continue
kinds = {g["kind"] for g in group}
if len(kinds) > 1:
# kind is not in the hash key, so identical text with different
# kind can group. If two projects classify the same sentence
# differently, the "same claim" premise is what is shaky.
notes.append("kind-disagreement %s: %s" % (projects, sorted(kinds)))
continue
by_first = sorted(group, key=lambda g: g["first_seen"])
by_last = sorted(group, key=lambda g: g["last_seen"])
sessions = []
for g in group:
for s in g["sessions"]:
if s not in sessions:
sessions.append(s)
merged.append({
"id": core.atom_id(by_first[0]["claim"]), # project-free
"claim": by_first[0]["claim"],
"scope": "global",
"kind": by_first[0]["kind"],
"first_seen": by_first[0]["first_seen"],
"last_seen": by_last[-1]["last_seen"],
"observations": sum(g["observations"] for g in group),
"sessions": sessions,
"source": by_last[-1]["source"], # newest
"first_source": by_first[0]["first_source"], # oldest
"confidence": max(
(g["confidence"] for g in group),
key=core.CONFIDENCE_ORDER.index),
"tier": "L3",
"promoted_at": now,
"promoted_from_projects": projects,
"redacted": any(g.get("redacted") for g in group),
})
return merged, notes
def apply_caps(atoms):
"""4.3 -- L2 caps at 60/project with overflow demoted to L1 (recoverable);
L3 caps at 30 and REFUSES further promotion rather than deleting, since
'never auto-demoted' means the cap blocks inflow, not outflow."""
warnings = []
by_project = {}
for a in atoms:
if a["tier"] == "L2":
by_project.setdefault(a["project"], []).append(a)
for proj, group in by_project.items():
if len(group) > core.L2_MAX_ATOMS:
group.sort(key=lambda g: g["last_seen"])
for a in group[: len(group) - core.L2_MAX_ATOMS]:
a["tier"] = "L1"
a.pop("promoted_at", None)
warnings.append("L2 cap: demoted %s (%s) to L1" % (a["id"], proj))
l3 = [a for a in atoms if a["tier"] == "L3"]
if len(l3) > core.L3_MAX_ATOMS:
warnings.append(
"L3 over cap (%d/%d) -- further promotions refused; prune at adopt"
% (len(l3), core.L3_MAX_ATOMS))
return warnings
def stage(store, l2, l3, notes, warnings):
"""Write proposals to .memory/staged/ -- never into CLAUDE.md (5.3)."""
store._ensure_dirs()
os.makedirs(store.staged, mode=0o700, exist_ok=True)
payload = {
"generated": core.iso(),
"l2_promotions": l2,
"l3_promotions": l3,
"notes": notes,
"warnings": warnings,
}
path = os.path.join(store.staged, "promotions.json")
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2, ensure_ascii=False)
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
return path
SAMPLE_ATOMS = [
{"id": "atm_aaaaaaaa", "claim": "PR base branch is dev, never main",
"scope": "project", "project": "demo", "kind": "constraint",
"first_seen": "2026-08-01T09:00:00Z", "last_seen": "2026-08-05T09:00:00Z",
"observations": 4, "sessions": ["01A", "01B", "01C"],
"source": "~/.claude/projects/-home-u/01C.jsonl#L9",
"first_source": "~/.claude/projects/-home-u/01A.jsonl#L2",
"confidence": "observed", "tier": "L1", "redacted": False},
{"id": "atm_bbbbbbbb", "claim": "deploy key is [REDACTED:anthropic-key]",
"scope": "project", "project": "demo", "kind": "constraint",
"first_seen": "2026-08-01T09:00:00Z", "last_seen": "2026-08-05T09:00:00Z",
"observations": 9, "sessions": ["01A", "01B", "01C", "01D"],
"source": "~/.claude/projects/-home-u/01C.jsonl#L4",
"first_source": "~/.claude/projects/-home-u/01A.jsonl#L4",
"confidence": "stated", "tier": "L1", "redacted": True},
{"id": "atm_cccccccc", "claim": "tests run in one long day",
"scope": "project", "project": "demo", "kind": "preference",
"first_seen": "2026-08-07T08:00:00Z", "last_seen": "2026-08-07T23:00:00Z",
"observations": 3, "sessions": ["01E", "01F", "01G"],
"source": "~/.claude/projects/-home-u/01G.jsonl#L1",
"first_source": "~/.claude/projects/-home-u/01E.jsonl#L1",
"confidence": "observed", "tier": "L1", "redacted": False},
]
def main():
ap = argparse.ArgumentParser(
description="Promote L1->L2->L3 on recurrence. Stages proposals; never writes CLAUDE.md.")
ap.add_argument("--memory-dir", default=None, help="path to .memory/ (default: ./.memory)")
ap.add_argument("--sample", action="store_true", help="run against built-in sample atoms")
ap.add_argument("--stage", action="store_true", help="write proposals to .memory/staged/")
ap.add_argument("--output", choices=["text", "json"], default="text")
a = ap.parse_args()
store = core.AtomStore(a.memory_dir)
atoms = list(SAMPLE_ATOMS) if a.sample else store.read()
l2, blocked = promote_l1_to_l2(atoms)
l3, notes = promote_l2_to_l3(atoms + l2)
warnings = apply_caps(atoms + l2 + l3)
if a.stage and not a.sample:
notes.append("staged to " + stage(store, l2, l3, notes, warnings))
if a.output == "json":
print(json.dumps({"l2_promotions": l2, "l3_promotions": l3,
"blocked": [{"id": b["id"], "reason": r} for b, r in blocked],
"notes": notes, "warnings": warnings}, indent=2))
return 0
print("Promotion pass over %d atom(s)\n" % len(atoms))
print(" L1 -> L2 promoted : %d" % len(l2))
for p in l2:
print(" %s %s" % (p["id"], p["claim"][:60]))
print(" L2 -> L3 merged : %d" % len(l3))
for p in l3:
print(" %s %s (from %s)" % (p["id"], p["claim"][:44],
", ".join(p["promoted_from_projects"])))
print(" blocked : %d" % len(blocked))
for b, why in blocked:
print(" %s %-28s %s" % (b["id"], why, b["claim"][:40]))
for n in notes:
print(" note: " + n)
for w in warnings:
print(" warn: " + w)
print("\nNothing was written to CLAUDE.md. Promotions stage for `adopt` (5.3).")
return 0
if __name__ == "__main__":
sys.exit(main())