mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-08-28 04:24:58 +00:00
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:
parent
38a23f1911
commit
a3b6a195cb
23 changed files with 2444 additions and 28 deletions
|
|
@ -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
7
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
62
CLAUDE.md
62
CLAUDE.md
File diff suppressed because one or more lines are too long
18
README.md
18
README.md
|
|
@ -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>.
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](#skills-overview)
|
||||
[](#agents)
|
||||
[](#skills-overview)
|
||||
[](#agents)
|
||||
[](#personas)
|
||||
[](#commands)
|
||||
[](#commands)
|
||||
[](https://github.com/alirezarezvani/claude-skills/stargazers)
|
||||
[](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.
|
||||
|
|
|
|||
14
engineering/agent-memory/.claude-plugin/authoring-notes.json
Normal file
14
engineering/agent-memory/.claude-plugin/authoring-notes.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
15
engineering/agent-memory/.claude-plugin/plugin.json
Normal file
15
engineering/agent-memory/.claude-plugin/plugin.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
126
engineering/agent-memory/README.md
Normal file
126
engineering/agent-memory/README.md
Normal 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, 6–7 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 2–3 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).
|
||||
63
engineering/agent-memory/agents/cs-memory-curator.md
Normal file
63
engineering/agent-memory/agents/cs-memory-curator.md
Normal 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.
|
||||
101
engineering/agent-memory/commands/cs-memory.md
Normal file
101
engineering/agent-memory/commands/cs-memory.md
Normal 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.
|
||||
83
engineering/agent-memory/hooks/session_end.py
Normal file
83
engineering/agent-memory/hooks/session_end.py
Normal 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())
|
||||
136
engineering/agent-memory/hooks/session_start.py
Normal file
136
engineering/agent-memory/hooks/session_start.py
Normal 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())
|
||||
151
engineering/agent-memory/hooks/user_prompt_submit.py
Normal file
151
engineering/agent-memory/hooks/user_prompt_submit.py
Normal 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())
|
||||
100
engineering/agent-memory/skills/agent-memory/SKILL.md
Normal file
100
engineering/agent-memory/skills/agent-memory/SKILL.md
Normal 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).
|
||||
|
|
@ -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.",
|
||||
|
|
@ -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
|
||||
2–3 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
373
engineering/agent-memory/skills/agent-memory/scripts/memory_core.py
Executable file
373
engineering/agent-memory/skills/agent-memory/scripts/memory_core.py
Executable 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
|
||||
204
engineering/agent-memory/skills/agent-memory/scripts/memory_extract.py
Executable file
204
engineering/agent-memory/skills/agent-memory/scripts/memory_extract.py
Executable 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())
|
||||
251
engineering/agent-memory/skills/agent-memory/scripts/memory_inspect.py
Executable file
251
engineering/agent-memory/skills/agent-memory/scripts/memory_inspect.py
Executable 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())
|
||||
252
engineering/agent-memory/skills/agent-memory/scripts/memory_promote.py
Executable file
252
engineering/agent-memory/skills/agent-memory/scripts/memory_promote.py
Executable 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())
|
||||
Loading…
Add table
Reference in a new issue