From 33bd2632407dc7e9b8a7d0664d94cd2826b2cb54 Mon Sep 17 00:00:00 2001 From: geobelsky Date: Tue, 18 Aug 2026 13:16:32 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(kb):=20hygiene=20release=20=E2=80=94?= =?UTF-8?q?=20slug=20data=20loss,=20audit-kb=20silent=20failure,=20format?= =?UTF-8?q?=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driven by a full manual compaction of a production knowledge base (282 memories / 115 decisions, ~192k tokens at session start), which surfaced one data-loss bug, one silent-failure bug, and a format contract documented everywhere and enforced nowhere. Fixed - Non-Latin titles produced an empty slug, writing the memory as bare `.md` (a dotfile, invisible to shell globs) which the next such title overwrote. 4 affected files found on one project, 2 confirmed overwrites. Slugs now transliterate Cyrillic/Greek, strip Latin diacritics, hash-fallback when nothing survives, and prefix digit-only slugs. saveMemory refuses to overwrite a file whose stored title differs from the incoming one. - audit-kb could exit 0 having written nothing: its counters were regex word-counts over the agent's prose. It now snapshots loaded-layer size before and after and reports the measured diff; a zero-change pass says so and exits 2. - paginateSections never split an oversized section, so page 1 could render only a heading with all content on page 2. - Leaked tool-call markup (``) was persisted verbatim; storage now strips it and reports which fields. - Frontmatter key replacement used `\s*`, which spans newlines and deleted the following field. Added - `axme-code kb-doctor [--fix]` + `axme_kb_doctor`: deterministic defect scan, no LLM. Exits 1 on findings. - `axme_archive_memory` / `axme_archive_decision`: the missing half of the storage API. Reversible, marked, never deletes. - `catalog.excerpt_chars` / `catalog.size_warn` config, replacing a hardcoded slice(0, 200) that no documentation mentioned. - `audit-kb --dry-run`, automatic pre-apply backup, storage self-repair at session start, KB hygiene reporting in axme_context. Changed - CLAUDE.md template, Cursor rules, server instructions and tool descriptions carry a selection test with an explicit NEGATIVE list. "Save every successful approach" with no counterpart is what produced 110 research diaries and 18 handoffs in one base. - The two-level format (loaded description vs deferred body) is stated as a contract. `## Details` was non-empty in 11 of 126 memories: the mechanism was right and simply never explained. - Save tools return advisory notes (overrun with concrete numbers, merge candidates, no-op title dedup) instead of accepting anything silently. - Search-mode catalog marks cut entries `…[TRUNCATED]`; absence of the marker guarantees the entry is complete as shown. Tests: 657/657 pass (+44 new). One pre-existing flaky E2E in audit-dedup.test.ts fails intermittently on clean main too. Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 32 +++ README.md | 19 +- package-lock.json | 4 +- package.json | 2 +- src/agents/kb-auditor.ts | 484 +++++++++++++++++++++++++++-------- src/cli.ts | 170 +++++++++++- src/self-test.ts | 45 +++- src/server.ts | 139 +++++++++- src/setup/cursor-writers.ts | 28 +- src/storage/archive.ts | 185 +++++++++++++ src/storage/config.ts | 53 ++++ src/storage/decisions.ts | 25 +- src/storage/kb-doctor.ts | 359 ++++++++++++++++++++++++++ src/storage/memory.ts | 89 ++++++- src/storage/save-feedback.ts | 136 ++++++++++ src/tools/context.ts | 284 ++++++++++++++++---- src/tools/decision-tools.ts | 35 ++- src/tools/memory-tools.ts | 47 +++- src/types.ts | 27 ++ src/utils/pagination.ts | 38 ++- src/utils/sanitize.ts | 69 +++++ src/utils/slug.ts | 105 ++++++++ templates/plugin-README.md | 2 +- test/context.test.ts | 4 +- test/kb-hygiene.test.ts | 454 ++++++++++++++++++++++++++++++++ test/memory.test.ts | 55 +++- 27 files changed, 2684 insertions(+), 208 deletions(-) create mode 100644 src/storage/archive.ts create mode 100644 src/storage/kb-doctor.ts create mode 100644 src/storage/save-feedback.ts create mode 100644 src/utils/sanitize.ts create mode 100644 src/utils/slug.ts create mode 100644 test/kb-hygiene.test.ts diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 925aedb..16f664c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axme-code", - "version": "0.6.3", + "version": "0.6.4", "description": "(Alpha) Persistent memory, architectural decisions, and safety guardrails for Claude Code. Your agent starts every session with full project context — stack, decisions, patterns, safety rules, and a handoff from the previous session.", "author": { "name": "AXME AI", diff --git a/CHANGELOG.md b/CHANGELOG.md index 691cf10..bbf3ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ## [Unreleased] +## [0.6.4] - 2026-08-18 + +Knowledge-base hygiene release. A full manual compaction of a production knowledge base (282 memories / 115 decisions, ~192k tokens at session start) surfaced one data-loss bug, one silent-failure bug, and a format contract that was documented everywhere and enforced nowhere. This release fixes the bugs, makes the contract measurable, and gives agents the tools to clean up without bypassing MCP. + +### Fixed + +- **Silent data loss: a non-Latin title produced an empty slug.** `toSlug` / `toMemorySlug` were `text.toLowerCase().replace(/[^a-z0-9]+/g, "-")`, which maps any fully non-Latin title to `""`. The memory was written as bare `.md` — a dotfile, invisible to `ls` and to every shell glob over `memory/*/*.md` — and the **next** such title overwrote it. Four affected files were found on one project, with two confirmed overwrites. Slug generation now transliterates Cyrillic and Greek, strips Latin diacritics, falls back to a content hash when nothing survives, and prefixes digit-only slugs (`16-07` → `memory-16-07`) which were useless for semantic search. `saveMemory` additionally refuses to overwrite a file whose stored title differs from the incoming one, writing a suffixed slug and reporting the collision instead. Same title still overwrites — that is how an entry is revised. +- **`audit-kb` could exit 0 having written nothing.** A real run analysed 109 decisions correctly for four minutes, reached a conclusion, wrote zero bytes, and reported success; the result counters were `resultText.match(/supersed/gi).length`, i.e. word-counting the agent's prose. The command now snapshots every entry's loaded-layer size before and after the agent runs and reports the measured diff. A pass that changed nothing prints `NO CHANGES WRITTEN`, explains that this is a failed pass rather than a clean base, and exits 2. +- **First page of paginated output could be empty.** `paginateSections` never split a section larger than the page limit, so a caller passing `["## Project Memories", <60KB block>]` produced a page 1 containing only the heading with all content on page 2. Oversized sections are now split along line boundaries; no content is lost or truncated. +- **Leaked tool-call markup was persisted verbatim.** Three records ended with `[...]` — a malformed client emission gluing one argument's XML frame onto another's text. Storage now strips everything from the first stray frame tag onward on write, and reports which fields it cleaned so the agent can verify what landed. +- **Frontmatter rewrites could delete the following field.** The key-replacement regex used `\s*`, which matches newlines, so rewriting an empty `slug:` consumed the `type:` line under it and made the record unparseable. Now matched with horizontal whitespace only. + +### Added + +- **`axme-code kb-doctor [path] [--fix]`** — deterministic storage-defect scan. No LLM, no network, milliseconds on a 300-entry base. Finds empty and degenerate slugs, leaked tool-call markup, frontmatter that disagrees with the filename, entries whose loaded layer overruns the catalog budget, and duplicate titles. `--fix` repairs the mechanical ones (renames preserve content and correct the frontmatter); overlong and duplicate entries need judgment and are reported only. Exits 1 on outstanding defects, so it works as a CI gate. Also exposed as the `axme_kb_doctor` MCP tool. +- **`axme_archive_memory` / `axme_archive_decision`** — the missing half of the storage API. axme-code could create knowledge but not retire it, while the knowledge bases it builds carry a rule of their own ("write to axme-code storage via MCP tools only, never manually"), leaving an agent asked to clean up with no legal move; the first real compaction had to bypass MCP with file operations. Archival moves entries to `.axme-code/archive/` preserving structure, stamps the reason into the frontmatter, and marks decisions `superseded`/`revoked` before the move. Nothing is deleted; an archival is undone by moving one file back. A `superseded_by` that does not resolve is refused rather than written as a dangling pointer. +- **`catalog.excerpt_chars` and `catalog.size_warn` in `config.yaml`.** The catalog excerpt width was a hardcoded `slice(0, 200)` mentioned in no documentation. Authoring 200+ entries against an undocumented constant in someone else's tool is a bet that it never moves; it is now configurable, clamped to a sane range on write, readable via `axme-code config get catalog.excerpt_chars`, and named in the `axme_save_memory` tool description. +- **`audit-kb --dry-run`** — preview a compaction pass. The agent reads and classifies but is forbidden to write, and prints the plan it would execute. Recommended first pass on any base worth keeping. +- **Automatic backup before `audit-kb` applies.** `.axme-code/` is gitignored by design (D-026), so a pass that rewrites every file in the base had no safety net at all. A tarball is written to `.axme-code-backups/` first, and the audit aborts if it cannot be created. The undo command is printed with the results. +- **Storage self-repair at session start.** `axme_context` runs the mechanical half of kb-doctor on every call and reports what it fixed. Waiting for a user to run a repair command is too late for the empty-slug defect: by then the second write has already destroyed the first. +- **Knowledge-base hygiene reporting in `axme_context`.** Past a configurable threshold, one block reports the entry count with the compaction command, and separately reports how many entries overrun the catalog budget — two different problems with two different fixes. +- **Regression coverage**: 42 new tests plus a `self-test` check that round-trips two non-Latin titles and asserts both are readable back under distinct filenames. + +### Changed + +- **The `CLAUDE.md` template's "During Work" section no longer says "save every successful approach".** That line, with no counterpart saying what *not* to save, is the root cause of the bloat this release addresses: a month of it produced 110 research diaries and 18 session handoffs in one base's memory. The template, the Cursor rules file, the MCP server instructions and the `axme_save_memory` description now all carry the same selection test — *would this help an agent a month from now who was not part of this investigation?* — with an explicit **negative** list alongside the positive one. The negative list is the operative half. +- **The two-level format is now stated as a contract rather than implied.** `description` (memory) and `decision` (decision) are loaded into every future session; `body` and `reasoning` render as `## Details` / `## Reasoning`, are not loaded, and are returned in full by `axme_get_memory` / `axme_get_decision`. Measured usage before this change: `## Details` was non-empty in 11 memories out of 126 — the mechanism was right and simply never explained, so 91% of memories put everything in the paid layer. The tool descriptions, the schema field descriptions, the CLAUDE.md template and the server instructions now state which layer is which and what each costs. Also stated: do **not** split entries to meet the length — per-entry overhead multiplies by count. Cut down into the deferred layer, not across into more records. +- **Save tools now return advisory notes instead of accepting anything silently.** An overlong `description` gets the concrete numbers ("1180 chars; the catalog renders 200; the last 980 will not be visible") and where the tail belongs. Near-duplicate titles are reported as merge candidates before a second half-record is created. `axme_save_decision` now says when title-dedup returned an existing decision unchanged, which previously read as a successful write. These are notes, never rejections: the write always lands, because a refused save loses the payload the agent just composed. +- **The search-mode catalog marks truncated entries.** A cut line ends in `…[TRUNCATED]` and a header states how many of the entries are affected. Previously a truncated line was indistinguishable from a complete one, so an agent could not tell which entries it actually understood — and in practice fetched neither. The absence of the marker is now a guarantee that the entry is complete as shown, which is what makes writing to the budget worth doing: at that point search mode and full mode carry the same content. +- **`audit-kb`'s prompt is the full compaction procedure** — classify into keep / compact / merge / archive, with the explicit negative list, "when in doubt, keep", "do not touch entries modified in the last 2 hours" (another session may be writing), and "do not rewrite history: keep both a retraction and what it retracts". It reindexes after applying, since compaction rewrites the text the embeddings index was built from and archival removes entries it still points at. +- `axme_save_decision` now explicitly instructs against meta-decisions ("D-020 absorbed by D-036"). Those describe edits to other decisions rather than decisions; `axme_archive_decision`'s `superseded_by` argument makes the edit instead. Nine such records were found in one base. + ## [0.6.3] - 2026-06-25 Reliability release for the `axme_save_memory` / `axme_save_decision` write tools, driven by two independent agent sessions that hit the same failure mode in production. diff --git a/README.md b/README.md index 29d5096..18ee5cc 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,22 @@ Your agent starts every session with full context: stack, decisions, patterns, g | **Handoff** | Where work stopped, blockers, next steps | "PR #17 open, waiting on review. Next: fix flaky test." | | **Worklog** | Session history and events | Timeline of all sessions and what was done | +### Knowledge Base Hygiene + +A knowledge base that only grows becomes a tax on every session. Two levers keep it honest. + +**Write to the loaded layer, defer the rest.** Every memory and decision has a layer loaded into *every* future session (`description` / `decision`) and one that is not (`body` / `reasoning`, rendered as `## Details` / `## Reasoning`, returned in full by `axme_get_memory` / `axme_get_decision`). Keep the loaded layer to the rule plus one concrete fact, within `catalog.excerpt_chars` (default 200), and put measurements, paths, thresholds and line numbers below. Nothing is lost — it just stops being paid for by the sessions that never needed it. An entry within budget renders **complete** in the search-mode catalog, which is what makes search mode and full mode carry the same content. + +**Clean up with tools, not by hand.** + +| Command | Cost | What it does | +|---|---|---| +| `axme-code kb-doctor .` | free, instant | Finds broken slugs, leaked tool markup, entries over the catalog budget, duplicate titles. `--fix` repairs the mechanical ones. Exits 1 on findings — usable as a CI gate. | +| `axme-code audit-kb . --dry-run` | one LLM run | Previews a compaction pass: what would be compacted, merged, archived. | +| `axme-code audit-kb .` | one LLM run | Applies it. Takes a backup first, reindexes after, and reports the **measured** before/after — a pass that wrote nothing says so and exits non-zero. | + +Agents retire entries with `axme_archive_memory` / `axme_archive_decision`: files move to `.axme-code/archive/` with the reason stamped in, decisions are marked `superseded`/`revoked` before the move, and nothing is ever deleted. + ### Safety Guardrails (100% Reliable) Hooks intercept tool calls **before execution** — not prompts. Even if the agent hallucinates a reason to run `rm -rf /`, the hook blocks it. This is hard enforcement at the Claude Code harness level, not a suggestion in a system prompt. @@ -373,7 +389,8 @@ axme-code setup [path] # Initialize project/workspace with LLM scan axme-code serve # Start MCP server (called by Claude Code automatically) axme-code status [path] # Show project status axme-code stats [path] # Worklog statistics (sessions, costs, safety blocks) -axme-code audit-kb [path] # KB audit: dedup, conflicts, compaction +axme-code kb-doctor [path] # KB defect scan (no LLM, instant); --fix repairs mechanical ones +axme-code audit-kb [path] # KB compaction: compact, merge, archive (LLM); --dry-run to preview axme-code hook pre-tool-use # PreToolUse hook handler (called by Claude Code) axme-code hook post-tool-use # PostToolUse hook handler axme-code hook session-end # SessionEnd hook handler diff --git a/package-lock.json b/package-lock.json index 6bb8746..6dfa088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@axme/code", - "version": "0.5.0", + "version": "0.6.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@axme/code", - "version": "0.5.0", + "version": "0.6.4", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.112", diff --git a/package.json b/package.json index a654b98..73083ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axme/code", - "version": "0.6.3", + "version": "0.6.4", "description": "Persistent memory, decisions, and safety guardrails for Claude Code", "type": "module", "main": "./dist/server.js", diff --git a/src/agents/kb-auditor.ts b/src/agents/kb-auditor.ts index 3569ebd..e919777 100644 --- a/src/agents/kb-auditor.ts +++ b/src/agents/kb-auditor.ts @@ -1,161 +1,318 @@ /** - * Knowledge Base Auditor — agent-based KB cleanup. + * Knowledge Base Auditor — agent-driven compaction of decisions and memories. * - * Spawns a Claude agent with tools (Read, Grep, Glob, Agent) that: - * 1. Reads all decisions from .axme-code/decisions/ - * 2. Finds duplicates and contradictions (using LLM judgment, not heuristics) - * 3. Verifies against actual code which decision is current - * 4. Updates storage files directly — supersedes outdated, removes true dupes - * 5. Same for memories + * What changed and why (v0.6.4) + * ----------------------------- + * The previous implementation handed a prompt to an agent and then reported + * success based on regex-counting the words "supersede" and "revoke" in the + * agent's closing text. A real run against a 282-entry base analysed + * everything correctly for four minutes, reached a conclusion, wrote NOTHING, + * and exited 0 — indistinguishable from a successful compaction. Counting an + * agent's prose is not measurement. * - * Two modes: - * - Single repo: audit decisions + memories in one .axme-code/ - * - --all-repos: agent independently audits each repo (can use sub-agents to parallelize) + * So this module now brackets the agent with deterministic work: + * + * 1. snapshot — every entry's size and content hash, before. + * 2. backup — a tarball of the storage, because .axme-code/ is + * gitignored and has no other safety net. + * 3. agent — classify and rewrite, per the procedure below. + * 4. snapshot — the same measurement, after. + * 5. reindex — the embeddings index still points at pre-compaction text + * otherwise, and search returns entries that no longer exist. + * 6. report — the DIFF of the two snapshots. Zero changes is reported + * as zero changes, loudly, not as "Done". + * + * `--dry-run` runs 1 and 3 with the agent told to write nothing, then prints + * the plan. It is the recommended first pass on any base the user cares about. */ -import { DEFAULT_AUDITOR_MODEL } from "../types.js"; +import { execFileSync } from "node:child_process"; +import { readFileSync, readdirSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { DEFAULT_AUDITOR_MODEL, AXME_CODE_DIR } from "../types.js"; import { extractCostFromResult, type CostInfo } from "../utils/cost-extractor.js"; import { buildAgentQueryOptions } from "../utils/agent-options.js"; import { createAgentSdk } from "../utils/agent-sdk.js"; +import { pathExists } from "../storage/engine.js"; +import { readConfig } from "../storage/config.js"; +import { loadedLayer } from "../storage/kb-doctor.js"; +import { shortHash } from "../utils/slug.js"; export interface KbAuditResult { - decisionsReviewed: number; - memoriesReviewed: number; - superseded: number; - revoked: number; - questionsCreated: number; + decisionsBefore: number; + decisionsAfter: number; + memoriesBefore: number; + memoriesAfter: number; + /** Entries whose loaded layer shrank. */ + compacted: number; + /** Entries that disappeared from the live store (archived or merged away). */ + removed: number; + /** Entries that appeared. */ + added: number; + /** Bytes of the session-start-loaded layer, before and after. */ + loadedBytesBefore: number; + loadedBytesAfter: number; + /** Entries still over the catalog excerpt budget after the pass. */ + overlongAfter: number; + backupPath: string | null; + dryRun: boolean; costUsd: number; durationMs: number; + /** The agent's closing summary — context for the numbers, never the source of them. */ + agentSummary: string; +} + +// --- Snapshotting --- + +interface EntrySnapshot { + /** Path relative to the storage root — stable across the run. */ + ref: string; + /** Byte length of the layer that is loaded into every session. */ + loadedBytes: number; + hash: string; +} + +interface Snapshot { + decisions: Map; + memories: Map; +} + +/** + * Measure what the KB costs at session start. + * + * Only the loaded layer is counted — the text before `## Details` / + * `## Reasoning`. File size would be the wrong metric: moving a paragraph + * from the description into the deferred body is the single most valuable + * thing this pass can do, and it leaves file size almost unchanged while + * cutting session cost substantially. + */ +function snapshot(storageRoot: string): Snapshot { + const decisions = new Map(); + const memories = new Map(); + + const decDir = join(storageRoot, "decisions"); + if (pathExists(decDir)) { + for (const f of safeReaddir(decDir).filter(f => f.startsWith("D-") && f.endsWith(".md"))) { + const raw = safeRead(join(decDir, f)); + if (raw === null) continue; + const layer = loadedLayer(raw, "## Reasoning"); + decisions.set(f, { ref: f, loadedBytes: Buffer.byteLength(layer, "utf8"), hash: shortHash(raw) }); + } + } + + for (const sub of ["feedback", "patterns"]) { + const dir = join(storageRoot, "memory", sub); + if (!pathExists(dir)) continue; + for (const f of safeReaddir(dir).filter(f => f.endsWith(".md"))) { + const ref = `${sub}/${f}`; + const raw = safeRead(join(dir, f)); + if (raw === null) continue; + const layer = loadedLayer(raw, "## Details"); + memories.set(ref, { ref, loadedBytes: Buffer.byteLength(layer, "utf8"), hash: shortHash(raw) }); + } + } + + return { decisions, memories }; } -const KB_AUDIT_PROMPT_SINGLE = `You are a knowledge base auditor for AXME Code. +function safeReaddir(dir: string): string[] { + try { return readdirSync(dir).sort(); } catch { return []; } +} -Your task: clean up the .axme-code/ storage in the current project directory. +function safeRead(path: string): string | null { + try { return readFileSync(path, "utf-8"); } catch { return null; } +} -## Step 1: Audit decisions +/** + * Tar the storage before any write. + * + * `.axme-code/` is gitignored by design (D-026), so there is no version + * history to fall back on — this tarball is the entire safety net for an + * operation that rewrites every file in the base. If it cannot be created, + * the audit does not run. + * + * Volatile subtrees are excluded: the embeddings index is regenerated by + * reindex anyway, and session transcripts can outweigh the knowledge base + * by an order of magnitude. + */ +function createBackup(projectPath: string): string { + const dir = join(projectPath, ".axme-code-backups"); + mkdirSync(dir, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const out = join(dir, `kb_backup_${stamp}.tar.gz`); + execFileSync("tar", [ + "czf", out, + "--exclude=.axme-code/_index", + "--exclude=.axme-code/sessions", + "--exclude=.axme-code/audit-worker-logs", + "--exclude=.axme-code/audit-logs", + "-C", projectPath, AXME_CODE_DIR, + ], { stdio: ["ignore", "ignore", "pipe"] }); + return out; +} -1. Read the index file FIRST: ".axme-code/decisions/index.md" — it has a table of ALL decisions with id, title, enforce, source, date. This is ONE file read, not 75. -2. From the index, identify CANDIDATE pairs that look like duplicates or contradictions (same topic, similar titles). -3. ONLY THEN read the full decision files (D-NNN-slug.md) for those specific candidates to check body/reasoning. -4. For each candidate pair, determine if they are: - a) DUPLICATES — same topic, different wording. Keep the newer one (by date field), supersede the older. - b) CONTRADICTIONS — same topic, conflicting rules. Check the actual code (Read/Grep relevant files) to determine which is current. Supersede the outdated one. - c) INDEPENDENT — different topics. Leave both. +// --- Prompt --- -3. To supersede a decision: edit the older file's frontmatter to add: - status: superseded - supersededBy: - Then edit the newer file to add: - supersedes: +function buildPrompt(opts: { storageRoot: string; excerptChars: number; dryRun: boolean; allRepos: boolean }): string { + const { storageRoot, excerptChars, dryRun } = opts; -4. To revoke a decision (no longer applies per code evidence): edit its frontmatter to add: - status: revoked - revokedAt: - revokedReason: + const scope = opts.allRepos + ? `The current directory is a multi-repo workspace. Each repository has its OWN .axme-code/ +storage, and so does the workspace root. Audit every one of them independently — a rule that +belongs to one repo must not be judged against another repo's code. Use the Agent tool to run +repos in parallel; each sub-agent follows the identical procedure below within its own repo.` + : `Work ONLY inside this absolute storage root: ${storageRoot} +Do not touch any other .axme-code/ directory, and do not use paths relative to your cwd.`; -## Step 2: Audit memories + const writePolicy = dryRun + ? `## DRY RUN — WRITE NOTHING -1. Read all memory files: Glob ".axme-code/memory/feedback/*.md" and ".axme-code/memory/patterns/*.md" -2. Find duplicates (same advice, different wording) — delete the older file, keep the newer. -3. Find stale memories that contradict current code — delete them. +This is a preview pass. You MUST NOT use Edit, Write, or any Bash command that mutates files. +Read and classify only, then report the plan you WOULD execute. Be specific: name every entry +and the bucket you put it in. The user will re-run without --dry-run to apply it.` + : `## APPLY MODE — YOU ARE EXPECTED TO WRITE -## Step 3: Compact all entries +A backup already exists, so mistakes are recoverable. Analysis without writes is a FAILED run: +the previous version of this tool reasoned correctly for four minutes, wrote nothing, and +reported success — which is why the harness now measures the files rather than trusting this +summary. If you finish without a single Edit/Write, say so explicitly and explain why. -For EVERY decision and memory file, rewrite to be more concise: +Work in small batches (10-20 entries), applying each batch before moving to the next. Do not +build a complete plan for 200 entries and then run out of room to execute it.`; -**Decisions**: the body paragraph (between "# Title" and "## Reasoning" or end of file) must be EXACTLY 2-3 sentences: what was decided + why. If body is longer, rewrite it shorter. If "## Reasoning" exists and adds info not in body, merge that info into the 2-3 sentence body, then DELETE the entire "## Reasoning" section (the heading and all text below it). Final file must have NO "## Reasoning" heading. + return `You are compacting an AXME knowledge base. -**Memories**: the description paragraph (between "# Title" and "## Details" or end of file) must be EXACTLY 1-2 sentences: the rule + specific action/command/value. If description is longer, rewrite it shorter. If "## Details" exists and adds info not in description, merge that info into the 1-2 sentence description, then DELETE the entire "## Details" section. Final file must have NO "## Details" heading. +${scope} -### Target format examples +${writePolicy} -DECISION file after compaction (2 sentences, what+why, no ## Reasoning section): +## The format contract — this is what the whole pass is about - # Google Cloud Pub/Sub for async gateway-to-agent-core communication +Every memory and decision has two layers: - Every intent lifecycle transition publishes to the "intent-lifecycle" Pub/Sub topic; agent-core receives via push subscription with idempotency tables for dedup. Decouples gateway from synchronous calls and provides at-least-once durable delivery. + LOADED memory description / decision body — the text between "# Title" and the + "## Details" (memories) or "## Reasoning" (decisions) heading. + This is loaded into EVERY future session. It is the only thing that costs. + DEFERRED "## Details" / "## Reasoning" sections. NOT loaded at session start; returned + in full by axme_get_memory / axme_get_decision when someone asks. -MEMORY file after compaction (1 sentence, rule+specific action, no ## Details section): +The session-start catalog renders **${excerptChars} characters** of the loaded layer per entry. +An entry within that budget is shown COMPLETE — nothing about it is hidden from future +sessions. An entry over budget is cut, and its tail is invisible to anyone who does not +explicitly fetch it. So the loaded layer must be <= ${excerptChars} characters. - # Always verify PR merge status before adding new commits +**Nothing is deleted by shortening.** Every number, path, threshold, line reference and +measurement you cut from the loaded layer MOVES DOWN into "## Details" / "## Reasoning". +If a section does not exist yet, create it. Losing a fact is a defect; relocating one is the job. - Before committing new work, run "gh pr view " to check if the branch's PR was merged; if merged, checkout main, pull, and create a fresh branch instead of pushing to the old one. +Do NOT split an entry into several single-fact entries to meet the budget. Per-entry overhead +(slug, title, catalog markup) is 60-100 characters and multiplies by count — splitting makes +the base bigger. Cut DOWN into the deferred layer, never ACROSS into more records. -Compact rules: -- STRICT: decisions = 2-3 sentences, memories = 1-2 sentences. Not more. -- Keep: specific commands, file paths, error codes, concrete values -- Remove: filler, narrative, "User said...", "Agent did..." -- After editing, verify NO leftover "## Reasoning" or "## Details" headings remain in the file +## Step 1 — read the bodies, not the titles -## Rules +Read entries IN FULL. Half the value of a knowledge base sits in the body: line numbers, sign +conventions, thresholds, script paths. You cannot classify an entry from its title, and an +entry archived on the strength of its title alone is how real rules get lost. -- ALWAYS check code before deciding which decision is current. Use Read/Grep to verify. -- When in doubt, keep both and move on. Only supersede/revoke when evidence is clear. -- Work through ALL decisions and memories systematically, not just a sample. -- After all changes, report what you did: how many superseded, revoked, deleted, compacted. -- Do NOT create new decisions or memories. Only clean up and compact existing ones. -`; +For decisions, start from ".axme-code/decisions/index.md" to get the inventory in one read, +then read the individual D-NNN files. -const KB_AUDIT_PROMPT_ALL_REPOS = `You are a knowledge base auditor for AXME Code workspace. +## Step 2 — classify every entry into exactly one bucket -The workspace at the current directory contains multiple git repositories, each with its own .axme-code/ storage. +**KEEP** — carries forward, already within budget: + an owner's rule or ruling · vendor/feed/API semantics (sign convention, error codes, limits, + cadence) · a tool or language trap that will recur · a closed direction recorded so nobody + reopens it · a live production contract. -Your task: audit EACH repository's knowledge base independently. +**COMPACT** — the rule is there but drowned in narrative: + rewrite the loaded layer to the rule plus one concrete fact, <= ${excerptChars} characters. + Move EVERY number, threshold, path and line reference into the deferred section. + Cut: "Measured on 29.07 across 400 random events, it turned out that…", "I did…", + backstory, what was fixed when. Keep: the rule, and what to do about it. -## Process +**MERGE** — two or more entries about one thing: + append the unique detail of each into the fullest one, then archive the rest. Find candidates + by near-identical titles and by cross-references between entries. -1. List all subdirectories that have .axme-code/ (use Glob or ls) -2. For EACH repo, perform the full audit: - a) Read .axme-code/decisions/index.md FIRST (one file per repo, has all decisions in a table). Only read individual D-NNN files for candidate pairs. - b) Find duplicates and contradictions among decisions - c) Check actual code in that repo to verify which decisions are current - d) Supersede outdated decisions (edit frontmatter: add status: superseded, supersededBy) - e) Revoke decisions that no longer apply (edit frontmatter: add status: revoked, revokedAt, revokedReason) - f) Read all .axme-code/memory/feedback/*.md and .axme-code/memory/patterns/*.md - g) Delete duplicate or stale memories -3. Also audit the workspace root .axme-code/ the same way +**ARCHIVE** — does not carry forward: + session handoffs and state snapshots · research diaries for closed directions (the conclusions + live in docs/) · self-retracted entries (grep for RETRACTED / SUPERSEDED / OBSOLETE / ОТОЗВАН / + ОПРОВЕРГНУТ / устарел) · one-off incidents whose fix is in the code and that yield no + transferable rule · meta-decisions of the form "D-020 absorbed by D-036" (the edit is already + in D-020 and D-036; the meta-record just occupies space) · decisions retired by a newer one. -USE SUB-AGENTS (Agent tool) to parallelize — you can launch one agent per repo to work in parallel. Each sub-agent should: -- Work only within its assigned repo directory -- Follow the same audit rules below -- Report back what it changed +## Step 3 — archive means archive, and it means marked -## Audit rules for each repo +Move the file into ".axme-code/archive/" preserving its subdirectory (archive/decisions/, +archive/memory/feedback/, archive/memory/patterns/). NEVER delete. -- DUPLICATES: same topic different wording → keep newer (by date), supersede older -- CONTRADICTIONS: same topic conflicting content → Read/Grep code to determine current → supersede outdated -- STALE: decision/memory contradicts current code → revoke with evidence -- COMPACT: rewrite every decision body to EXACTLY 2-3 sentences (what+why), delete "## Reasoning" section entirely. Rewrite every memory description to EXACTLY 1-2 sentences (rule+specific action), delete "## Details" section entirely. -- When in doubt, keep both. Only act on clear evidence. -- Do NOT create new decisions or memories. Only clean up and compact. +BEFORE moving a decision, write into its frontmatter: + status: superseded + supersededBy: D-NNN +or, when nothing replaces it: + status: revoked + revokedAt: + revokedReason: -## After all repos done +An archive of unmarked files is a graveyard nobody can read. -Report summary: which repos had changes, how many decisions superseded/revoked/compacted, how many memories cleaned/compacted. -`; +## Step 4 — storage defects + + find /memory -name '.md' — empty slug; these entries were overwriting + each other. Rename to a real transliterated slug. + grep -rl '\\| { const startTime = Date.now(); - const model = opts.model ?? DEFAULT_AUDITOR_MODEL; - const sdk = await createAgentSdk("auditor", { cwd: opts.targetPath }); + const dryRun = !!opts.dryRun; + const storageRoot = join(opts.targetPath, AXME_CODE_DIR); + const excerptChars = readConfig(opts.targetPath).catalogExcerptChars; - const prompt = opts.allRepos ? KB_AUDIT_PROMPT_ALL_REPOS : KB_AUDIT_PROMPT_SINGLE; + const before = snapshot(storageRoot); - const queryOpts = buildAgentQueryOptions( - { cwd: opts.targetPath, model }, - "auditor", - ); + let backupPath: string | null = null; + if (!dryRun) { + // Deliberately unguarded: if the backup cannot be written we must not + // proceed to rewrite every file in a store that has no version history. + backupPath = createBackup(opts.targetPath); + } + + const model = opts.model ?? DEFAULT_AUDITOR_MODEL; + const sdk = await createAgentSdk("auditor", { cwd: opts.targetPath }); + const queryOpts = buildAgentQueryOptions({ cwd: opts.targetPath, model }, "auditor"); + const prompt = buildPrompt({ storageRoot, excerptChars, dryRun, allRepos: opts.allRepos }); const q = sdk.query({ prompt, options: queryOpts }); - let resultText = ""; + let agentSummary = ""; let cost: CostInfo | undefined; for await (const msg of q) { @@ -163,12 +320,11 @@ export async function runKbAudit(opts: { const content = (msg as any).message?.content; if (Array.isArray(content)) { for (const block of content) { - // Stream thinking and text responses to stderr for visibility if (block.type === "thinking" && block.thinking) { process.stderr.write(`\x1b[2m[thinking] ${String(block.thinking)}\x1b[0m\n`); } if (block.type === "text" && block.text) { - resultText += block.text; + agentSummary += block.text; process.stderr.write(`${block.text}\n`); } } @@ -177,22 +333,126 @@ export async function runKbAudit(opts: { if (msg.type === "result") { cost = extractCostFromResult(msg); if ((msg as any).subtype === "success" && (msg as any).result) { - resultText = (msg as any).result; + agentSummary = (msg as any).result; } } } - // Parse agent's summary to extract counts - const superseded = (resultText.match(/supersed/gi) || []).length; - const revoked = (resultText.match(/revok/gi) || []).length; + const after = dryRun ? before : snapshot(storageRoot); return { - decisionsReviewed: 0, // agent handles internally - memoriesReviewed: 0, - superseded, - revoked, - questionsCreated: 0, + ...diffSnapshots(before, after, excerptChars), + backupPath, + dryRun, costUsd: cost?.costUsd ?? 0, durationMs: Date.now() - startTime, + agentSummary, + }; +} + +/** + * Compare two snapshots into the numbers the CLI reports. + * + * "compacted" counts entries whose loaded layer got SMALLER, not entries + * that merely changed — an entry can be edited without becoming cheaper, + * and reporting that as compaction would overstate the result. + */ +function diffSnapshots(before: Snapshot, after: Snapshot, excerptChars: number) { + const all = (s: Snapshot) => [...s.decisions.values(), ...s.memories.values()]; + // Namespaced keys so a decision and a memory can never collide by filename. + const keyed = (s: Snapshot) => new Map([ + ...[...s.decisions.values()].map(e => [`D:${e.ref}`, e] as const), + ...[...s.memories.values()].map(e => [`M:${e.ref}`, e] as const), + ]); + const beforeAll = keyed(before); + const afterAll = keyed(after); + + let compacted = 0, removed = 0, added = 0; + for (const [k, b] of beforeAll) { + const a = afterAll.get(k); + if (!a) { removed++; continue; } + if (a.loadedBytes < b.loadedBytes) compacted++; + } + for (const k of afterAll.keys()) if (!beforeAll.has(k)) added++; + + const sum = (m: Map) => + [...m.values()].reduce((n, e) => n + e.loadedBytes, 0); + + return { + decisionsBefore: before.decisions.size, + decisionsAfter: after.decisions.size, + memoriesBefore: before.memories.size, + memoriesAfter: after.memories.size, + compacted, removed, added, + loadedBytesBefore: sum(before.decisions) + sum(before.memories), + loadedBytesAfter: sum(after.decisions) + sum(after.memories), + overlongAfter: all(after).filter(e => e.loadedBytes > excerptChars).length, }; } + +/** + * Render the audit outcome for the CLI. + * + * The zero-change case gets its own branch and an explicit verdict, because + * the failure this whole rewrite exists to catch looked exactly like success: + * a long run, a confident summary, and not one byte written. + */ +export function formatKbAuditReport(r: KbAuditResult): string { + const lines: string[] = []; + const entriesBefore = r.decisionsBefore + r.memoriesBefore; + const entriesAfter = r.decisionsAfter + r.memoriesAfter; + const kb = (n: number) => `${(n / 1024).toFixed(1)} KB`; + + lines.push(""); + lines.push("─".repeat(64)); + + if (r.dryRun) { + lines.push("DRY RUN — nothing was written."); + lines.push(""); + lines.push(`Base: ${r.decisionsBefore} decisions, ${r.memoriesBefore} memories, ` + + `${kb(r.loadedBytesBefore)} loaded per session, ${r.overlongAfter} entries over budget.`); + lines.push(""); + lines.push("The plan is in the agent output above. Re-run without --dry-run to apply it."); + lines.push("─".repeat(64)); + return lines.join("\n"); + } + + const changed = r.compacted + r.removed + r.added; + if (changed === 0) { + lines.push("NO CHANGES WRITTEN."); + lines.push(""); + lines.push(`The base is byte-identical: still ${r.decisionsBefore} decisions and ` + + `${r.memoriesBefore} memories, ${kb(r.loadedBytesBefore)} loaded per session.`); + lines.push(""); + lines.push("This is a FAILED pass, not a clean base — unless the agent output above says the"); + lines.push("base was already compact. If it described a plan it did not execute, re-run; the"); + lines.push("run is idempotent and the backup below is untouched."); + if (r.backupPath) lines.push(`Backup: ${r.backupPath}`); + lines.push("─".repeat(64)); + return lines.join("\n"); + } + + const saved = r.loadedBytesBefore - r.loadedBytesAfter; + const pct = r.loadedBytesBefore > 0 ? Math.round((saved / r.loadedBytesBefore) * 100) : 0; + + lines.push("KB audit applied."); + lines.push(""); + lines.push(` decisions ${r.decisionsBefore} → ${r.decisionsAfter}`); + lines.push(` memories ${r.memoriesBefore} → ${r.memoriesAfter}`); + lines.push(` entries ${entriesBefore} → ${entriesAfter}`); + lines.push(""); + lines.push(` compacted ${r.compacted} entries now load less text`); + lines.push(` removed ${r.removed} entries left the live store (archived or merged)`); + if (r.added > 0) lines.push(` added ${r.added} entries appeared`); + lines.push(""); + lines.push(` session-start payload ${kb(r.loadedBytesBefore)} → ${kb(r.loadedBytesAfter)} (${pct >= 0 ? "-" : "+"}${Math.abs(pct)}%)`); + lines.push(` still over budget ${r.overlongAfter} entries`); + lines.push(""); + if (r.backupPath) { + lines.push(`Backup: ${r.backupPath}`); + lines.push(`Undo: tar xzf ${r.backupPath} -C `); + } + lines.push("Archived entries are under .axme-code/archive/ — nothing was deleted."); + lines.push("─".repeat(64)); + return lines.join("\n"); +} diff --git a/src/cli.ts b/src/cli.ts index 5d4efc6..0f28f02 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -91,6 +91,51 @@ iterations take effect immediately; only changes to the MCP server itself (tool definitions, cleanupAndExit, startup) require a window reload. `; + +const SAVE_GUIDANCE = ` +### What to save (and what NOT to) + +Before EVERY \`axme_save_memory\`, answer one question: **would this help an agent a month +from now who was not part of this investigation?** If the value is in the numbers rather +than in a rule, it belongs in a document, not in memory. + +**Save**: a rule or ruling from the owner · vendor/feed/API semantics that will not be +re-derived (sign convention, error codes, limits, cadence) · a tool or language trap that +will recur · a closed direction, so nobody reopens it · a live production contract. + +**Do NOT save**: measurement results and verdict numbers (put those in a doc; a memory may +carry at most one line pointing to it) · session state and handoffs (\`axme_finalize_close\` +already stores those) · a one-off incident whose fix is already in the code and that yields +no transferable rule · anything an existing entry already covers — extend that entry instead +of adding a second one. + +The negative list is the operative half. Without it "successful approach discovered" reads as +"save every positive result", and a month of that buries the real rules under research diaries. + +### How to save — the two-level format + +Every memory and decision has a layer that is loaded into EVERY future session and a layer +that is not: + +| field | rendered as | loaded at session start | fetched by | +|---|---|---|---| +| \`description\` (memory) / \`decision\` (decision) | the body paragraph | **YES, every session** | always present | +| \`body\` (memory) / \`reasoning\` (decision) | \`## Details\` / \`## Reasoning\` | **NO** | \`axme_get_memory\` / \`axme_get_decision\` | + +So: **description = the rule plus one concrete fact, <=200 characters.** Measurements, file +paths, line numbers, thresholds, command output and history go into \`body\` — nothing is lost, +it simply stops being paid for by every session that never needed it. + +Why 200: that is the catalog excerpt width (\`catalog.excerpt_chars\` in +\`.axme-code/config.yaml\` — check it if you want the exact number for this project). An entry +within budget is rendered COMPLETE in the catalog, so nothing is hidden from future sessions. +An entry over budget is cut, and its tail is invisible unless someone explicitly fetches it. + +Do NOT split one entry into several single-fact entries to meet the length. Per-entry overhead +(slug, title, catalog markup) is 60-100 characters and multiplies by count. Cut DOWN into the +deferred layer, not ACROSS into more records. +`; + const SINGLE_REPO_CLAUDE_MD = `## AXME Code ### Session Start (MANDATORY) @@ -99,10 +144,19 @@ This loads: oracle, decisions, safety rules, memories, test plan, active plans. Do NOT skip - without context you will miss critical project rules. ${PENDING_AUDITS_GUIDANCE}${STORAGE_PATH_GUIDANCE} ### During Work -- Error pattern or successful approach discovered -> call axme_save_memory immediately +- Error pattern or transferable rule discovered -> call axme_save_memory immediately - Architectural decision made or discovered -> call axme_save_decision immediately - New safety constraint found -> call axme_update_safety immediately -Do not defer - save when discovered. +Do not defer - save when discovered. But apply the filter below first: saving everything is +what turns a knowledge base into a session-start tax. +${SAVE_GUIDANCE} +### Housekeeping +- \`axme-code kb-doctor .\` - fast, no LLM: finds broken slugs, leaked tool markup, entries + that overrun the catalog budget. \`--fix\` repairs the mechanical ones. +- \`axme-code audit-kb . --dry-run\` - preview a compaction pass (compact / merge / archive). + Drop \`--dry-run\` to apply; it takes a backup first. +- Retiring an entry is \`axme_archive_memory\` / \`axme_archive_decision\` - never delete files + by hand, and never leave a stale entry in place because there was no tool for it. ### Available AXME Tools axme_context, axme_oracle, axme_decisions, axme_memories, axme_save_memory, axme_save_decision, @@ -123,6 +177,12 @@ ${PENDING_AUDITS_GUIDANCE}${STORAGE_PATH_GUIDANCE} ### During Work - Save memories/decisions/safety rules immediately when discovered - For cross-project findings: include scope parameter (e.g. scope: ["all"]) +${SAVE_GUIDANCE} +### Housekeeping +- \`axme-code kb-doctor \` - fast defect scan (broken slugs, leaked markup, overlong + entries); \`--fix\` repairs the mechanical ones. Each repo has its own storage - run per repo. +- \`axme-code audit-kb --dry-run\` - preview a compaction pass; drop the flag to apply. +- Retiring an entry is \`axme_archive_memory\` / \`axme_archive_decision\`, never a manual delete. ### Available AXME Tools axme_context, axme_oracle, axme_decisions, axme_memories, axme_save_memory, axme_save_decision, @@ -384,7 +444,9 @@ Usage: Set auth mode non-interactively axme-code cleanup legacy-artifacts [--dry-run] Remove pre-PR#7 sessions/logs axme-code cleanup decisions-normalize [--dry-run] Add status:active to decisions - axme-code audit-kb [path] [--all-repos] KB audit: dedup, conflicts, compaction + axme-code audit-kb [path] [--all-repos] [--dry-run] + KB compaction: compact, merge, archive (LLM) + axme-code kb-doctor [path] [--fix] KB defect scan: slugs, leaked markup, overlong axme-code stats [path] Worklog statistics (sessions, costs, safety blocks) axme-code help Show this help @@ -400,7 +462,7 @@ async function main() { // its own startup event from server.ts after MCP server is up. // We AWAIT this so events flush before heavy work begins — under event // loop pressure (LLM scanners), fire-and-forget setImmediate may stall. - const startupCommands = new Set(["setup", "status", "stats", "audit-kb", "cleanup", "help"]); + const startupCommands = new Set(["setup", "status", "stats", "audit-kb", "kb-doctor", "cleanup", "help"]); if (command && startupCommands.has(command)) { const { sendStartupEvents } = await import("./telemetry.js"); await sendStartupEvents(); @@ -906,17 +968,79 @@ Do NOT skip — without context you will miss critical project rules. } } const allRepos = args.includes("--all-repos"); + const dryRun = args.includes("--dry-run"); - console.log(`KB Audit: ${targetPath}${allRepos ? " (all repos)" : ""}`); - console.log(`Agent will read decisions + memories, check code, and update storage directly.\n`); + console.log(`KB Audit: ${targetPath}${allRepos ? " (all repos)" : ""}${dryRun ? " [DRY RUN]" : ""}`); + if (dryRun) { + console.log("Preview only — the agent will read and classify but write nothing.\n"); + } else { + console.log("Agent will compact, merge and archive entries, writing to storage directly."); + console.log("A backup is taken first; nothing is deleted (archived entries go to .axme-code/archive/).\n"); + } - const { runKbAudit } = await import("./agents/kb-auditor.js"); - const result = await runKbAudit({ targetPath, allRepos }); + const { runKbAudit, formatKbAuditReport } = await import("./agents/kb-auditor.js"); + let result; + try { + result = await runKbAudit({ targetPath, allRepos, dryRun }); + } catch (err: any) { + // The dominant failure here is the backup step — a store with no + // version history must not be rewritten without one. + console.error(`\nKB audit aborted: ${err?.message ?? err}`); + process.exit(1); + } - console.log(`\nDone: $${result.costUsd.toFixed(2)}, ${(result.durationMs / 1000).toFixed(0)}s`); + if (!dryRun && result.removed + result.compacted + result.added > 0) { + // Compaction rewrote the text the index was built from, and archival + // removed entries it still points at — a stale index answers searches + // with content that no longer exists. + try { + const { reindexAll } = await import("./tools/search-install.js"); + const r = await reindexAll(targetPath); + if (r.ok) console.log(`Reindexed ${r.indexed} entries.`); + else console.error(`Reindex skipped (${r.error}) — run: axme-code reindex ${targetPath}`); + } catch (err: any) { + console.error(`Reindex failed (search results may be stale): ${err?.message ?? err}`); + console.error(`Fix with: axme-code reindex ${targetPath}`); + } + } + + console.log(formatKbAuditReport(result)); + console.log(`Cost: $${result.costUsd.toFixed(2)}, ${(result.durationMs / 1000).toFixed(0)}s`); + + if (!dryRun) { + const { resetKbAuditCounter } = await import("./storage/kb-audit.js"); + resetKbAuditCounter(targetPath); + } + // A pass that wrote nothing is not a success — exit non-zero so CI and + // shell chains can tell the difference the old "exit 0" concealed. + if (!dryRun && result.compacted + result.removed + result.added === 0) process.exit(2); + break; + } + + case "kb-doctor": { + // Deterministic storage-defect scan. No LLM, no network, milliseconds — + // the counterpart to audit-kb, which costs money and minutes and is for + // the defects that need judgment. + const docPathArg = args.slice(1).find(a => !a.startsWith("--")); + const docPath = resolve(docPathArg || "."); + const fix = args.includes("--fix"); - const { resetKbAuditCounter } = await import("./storage/kb-audit.js"); - resetKbAuditCounter(targetPath); + const { runKbDoctor, formatDoctorReport } = await import("./storage/kb-doctor.js"); + const report = runKbDoctor(docPath, { fix }); + console.log(`KB Doctor: ${docPath}${fix ? " [--fix]" : ""}\n`); + console.log(formatDoctorReport(report, fix)); + + if (fix && report.fixed.length > 0) { + try { + const { reindexAll } = await import("./tools/search-install.js"); + const r = await reindexAll(docPath); + if (r.ok) console.log(`\nReindexed ${r.indexed} entries.`); + } catch { + // Search-mode-only concern; a full-mode project has no index to stale. + } + } + // Exit 1 on outstanding defects so this is usable as a CI gate. + if (report.defects.length > 0) process.exit(1); break; } @@ -1041,6 +1165,8 @@ Do NOT skip — without context you will miss critical project rules. else if (key === "model") console.log(cfg.model); else if (key === "auditor_model") console.log(cfg.auditorModel); else if (key === "review_enabled") console.log(String(cfg.reviewEnabled)); + else if (key === "catalog.excerpt_chars") console.log(String(cfg.catalogExcerptChars)); + else if (key === "catalog.size_warn") console.log(String(cfg.kbSizeWarnThreshold)); else { console.error(`Unknown config key: ${key}`); process.exit(1); } break; } @@ -1050,8 +1176,28 @@ Do NOT skip — without context you will miss critical project rules. console.error("usage: axme-code config set "); process.exit(1); } + // Numeric KB-format keys: plain writes, no runtime side effects. + if (key === "catalog.excerpt_chars" || key === "catalog.size_warn") { + const n = Number(value); + if (!Number.isFinite(n)) { + console.error(`${key} must be a number. Got: ${value}`); + process.exit(1); + } + const { clampConfigNumber } = await import("./storage/config.js"); + const cfg = rc(projectPath); + // Clamp before writing so config.yaml records the value that is + // actually in effect, rather than one the reader silently corrects. + const field = key === "catalog.excerpt_chars" ? "catalogExcerptChars" : "kbSizeWarnThreshold"; + const effective = clampConfigNumber(field, n); + wc(projectPath, { ...cfg, [field]: effective }); + console.log(`Saved: ${key} = ${effective}`); + if (effective !== Math.round(n)) { + console.log(`(clamped from ${Math.round(n)} to the supported range)`); + } + break; + } if (key !== "context.mode") { - console.error(`Set is currently supported only for context.mode. Got: ${key}`); + console.error(`Set is supported for: context.mode, catalog.excerpt_chars, catalog.size_warn. Got: ${key}`); process.exit(1); } if (value !== "full" && value !== "search") { diff --git a/src/self-test.ts b/src/self-test.ts index 98fa802..45a63c1 100644 --- a/src/self-test.ts +++ b/src/self-test.ts @@ -20,7 +20,7 @@ * infrastructure that should work on a fresh install with zero state. */ -import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawn } from "node:child_process"; @@ -62,6 +62,48 @@ async function checkStorageWrite(): Promise { } } +/** + * Round-trip a non-Latin title through the save path. + * + * Regression guard for the defect that made this whole check worth having: + * a Cyrillic-only title used to reduce to an empty slug, land as bare + * `.md`, and be overwritten by the next such title. The check asserts both + * halves — that the file has a real name, and that two different non-Latin + * titles do not collapse onto one. + */ +async function checkNonLatinSlug(): Promise { + const tmpDir = mkdtempSync(join(tmpdir(), "axme-selftest-slug-")); + try { + const { saveMemory, toMemorySlug, listMemories } = await import("./storage/memory.js"); + const titles = ["Перекат даты в bf_live", "Ловушка watchdog при рестарте"]; + for (const title of titles) { + saveMemory(tmpDir, { + slug: toMemorySlug(title), type: "pattern", title, + description: "selftest", body: "", keywords: [], + source: "manual", sessionId: null, date: "2026-01-01", + }); + } + + const dir = join(tmpDir, ".axme-code", "memory", "patterns"); + const files = readdirSync(dir); + const empty = files.filter(f => f === ".md"); + if (empty.length > 0) { + record("non-latin slug", false, "memory written as bare '.md' — dotfile, and the next such title overwrites it"); + return; + } + const stored = listMemories(tmpDir); + if (stored.length !== titles.length) { + record("non-latin slug", false, `${titles.length} distinct titles saved but ${stored.length} readable back — entries are colliding`); + return; + } + record("non-latin slug", true, files.sort().join(", ")); + } catch (err) { + record("non-latin slug", false, (err as Error).message); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +} + async function checkHookParseAndDeny(): Promise { try { const { cursorInputAdapter, cursorOutputAdapter } = await import("./hooks/adapters/cursor.js"); @@ -184,6 +226,7 @@ export async function runSelfTest(): Promise { process.stdout.write("====================\n\n"); await checkStorageWrite(); + await checkNonLatinSlug(); await checkHookParseAndDeny(); // For the MCP boot, we need a path to our own binary. process.argv[1] diff --git a/src/server.ts b/src/server.ts index d4af5fa..6f26ddb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -313,7 +313,10 @@ function buildInstructions(): string { ); } parts.push("TRUNCATED OUTPUT RULE: if ANY MCP tool output is truncated or saved to a file (you see 'Output too large' or 'saved to file'), you MUST use the Read tool to read the full file content into your context. Do not proceed with partial data."); - parts.push("Save memories, decisions, and safety rules immediately when discovered during work."); + parts.push("Save memories, decisions, and safety rules immediately when discovered during work — but filter first."); + parts.push("WHAT TO SAVE: before every axme_save_memory ask \"would this help an agent a month from now who was not part of this investigation?\" SAVE an owner's rule or ruling; vendor/feed/API semantics (sign convention, error codes, limits, cadence); a tool or language trap that will recur; a closed direction so nobody reopens it; a live production contract. DO NOT SAVE measurement results and verdict numbers (those belong in a doc, with at most a one-line pointer in memory); session state and handoffs (axme_finalize_close stores those); a one-off incident already fixed in code that yields no transferable rule; anything an existing entry covers — extend that entry instead of adding a second. The negative list is the operative half: without it \"save what you learned\" reads as \"save everything\", and a month of that buries the real rules under research diaries."); + parts.push("TWO-LEVEL FORMAT: a memory's `description` and a decision's `decision` are loaded into EVERY future session — keep each to the rule plus one concrete fact, within `catalog.excerpt_chars` (default 200, see .axme-code/config.yaml). A memory's `body` and a decision's `reasoning` render as '## Details' / '## Reasoning', are NOT loaded at session start, and are returned in full by axme_get_memory / axme_get_decision — put every number, path, threshold, line reference and measurement there. Nothing is lost by moving detail down; it stops being paid for by sessions that never needed it. Do NOT split one entry into several single-fact entries to meet the length: per-entry overhead multiplies by count. Cut DOWN into the deferred layer, never ACROSS into more records."); + parts.push("RETIRING ENTRIES: use axme_archive_memory / axme_archive_decision — never delete storage files by hand, and never leave a stale entry in place because you could not find a tool for it. Archival is reversible (files move to .axme-code/archive/) and marked (status/supersededBy or revokedAt/revokedReason). Never save a decision whose content is 'D-020 absorbed by D-036' — that is an edit to other decisions, so make the edit via axme_archive_decision's superseded_by argument instead of recording a meta-decision."); parts.push("SAVE-TOOL RULE: call axme_save_memory / axme_save_decision / axme_update_safety ONE AT A TIME, each as a standalone call with ALL required fields composed in that same call. Do NOT place a save call in a parallel batch with other tool calls (parallelism is for the read tools only) and never emit a save call with empty/deferred arguments — that produces an 'expected string, received undefined' validation error for every required field."); parts.push("GIT COMMIT/PUSH GATE: every git commit and git push command MUST end with `#!axme pr= repo=`. Example: `git commit -m \"fix bug\" #!axme pr=42 repo=AxmeAI/axme-code`. Use pr=none if no PR exists yet. Without this suffix the command will be blocked."); parts.push("RELEASE/TAG PROHIBITION: agent must NEVER run git tag, npm publish, twine upload, dotnet nuget push, mvn deploy, gh release create, or gh workflow run deploy-prod. These are blocked by safety hooks. To release: prepare version bump + CHANGELOG + PR, then provide ready-to-run tag/publish commands to the user."); @@ -549,14 +552,29 @@ server.tool( server.tool( "axme_save_memory", "Save a feedback or pattern memory. Use 'feedback' for learned mistakes, 'pattern' for successful approaches. " + - "Call this tool ON ITS OWN (do NOT batch it in a parallel block with other tool calls), and include type, title, and description in THIS SAME call. " + - "Worked example: { \"type\": \"pattern\", \"title\": \"Retry npm publish via automation token\", \"description\": \"CI npm publish 404s when NPM_TOKEN is a non-automation granular token; regenerate as a Classic Automation token to bypass 2FA at publish.\" }", + "Call this tool ON ITS OWN (do NOT batch it in a parallel block with other tool calls), and include type, title, and description in THIS SAME call.\n\n" + + "WHAT TO SAVE — ask first: would this help an agent a month from now who was not part of this investigation? " + + "Save: an owner's rule or ruling; vendor/feed/API semantics (sign convention, error codes, limits, cadence); " + + "a tool or language trap that will recur; a closed direction so nobody reopens it; a live production contract. " + + "Do NOT save: measurement results and verdict numbers (those belong in a doc); session state or handoffs " + + "(axme_finalize_close stores those); a one-off incident already fixed in code that yields no transferable rule; " + + "anything an existing memory already covers — extend that one instead.\n\n" + + "TWO-LEVEL FORMAT — this is the difference between a knowledge base and a session-start tax. " + + "`description` is loaded into EVERY future session: keep it to the rule plus one concrete fact, " + + "at most `catalog.excerpt_chars` characters (default 200; read .axme-code/config.yaml for this project's value). " + + "`body` renders as '## Details', is NOT loaded at session start, and is returned in full by axme_get_memory — " + + "put every number, path, threshold, line reference and measurement there. Nothing is lost by moving it down; " + + "it just stops being paid for by sessions that never needed it. Do NOT split one memory into several " + + "single-fact memories to meet the length — per-entry overhead multiplies by count. Cut DOWN, not ACROSS.\n\n" + + "Worked example: { \"type\": \"pattern\", \"title\": \"Retry npm publish via automation token\", " + + "\"description\": \"CI npm publish 404s when NPM_TOKEN is a granular token; regenerate as a Classic Automation token to bypass 2FA.\", " + + "\"body\": \"Observed 2026-05-02 on release-binary.yml line 88. Granular tokens return 404 (not 403) on publish when 2FA-on-publish is enabled for the org.\" }", { project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"), type: z.enum(["feedback", "pattern"], { error: "type is REQUIRED — compose it in THIS call (one of \"feedback\" | \"pattern\"); do not emit axme_save_memory with empty arguments." }).describe("Memory type"), - title: z.string({ error: "title is REQUIRED — compose it in THIS call. If you emitted the call expecting to fill params later, re-emit with title+type+description all present." }).describe("Short title"), - description: z.string({ error: "description is REQUIRED — compose it in THIS call (1-2 sentences, self-contained). An empty axme_save_memory call is the usual cause of this error: re-send with all three required fields." }).describe("1-2 sentences: what happened + specific action/command/rule. Must be self-contained without body."), - body: z.string().optional().describe("Optional archive detail. Context output uses description only, so put all essential info there."), + title: z.string({ error: "title is REQUIRED — compose it in THIS call. If you emitted the call expecting to fill params later, re-emit with title+type+description all present." }).describe("Short title. Saving under an EXISTING memory's exact title replaces that memory — this is how you extend one rather than adding a near-duplicate."), + description: z.string({ error: "description is REQUIRED — compose it in THIS call (1-2 sentences, self-contained). An empty axme_save_memory call is the usual cause of this error: re-send with all three required fields." }).describe("THE LOADED LAYER — rendered into every future session's context. The rule plus one concrete fact, 1-2 sentences, at most catalog.excerpt_chars characters (default 200). Anything past that is cut from the session-start catalog and invisible unless someone calls axme_get_memory."), + body: z.string().optional().describe("THE DEFERRED LAYER — rendered as '## Details'. NOT loaded at session start; returned in full by axme_get_memory(slug). Put the numbers, file paths, line references, thresholds, measurements and command output here. Costs nothing per session, so use it freely."), keywords: z.array(z.string()).optional().describe("Search keywords"), scope: z.array(z.string()).optional().describe("Project scope (omit for current project only)"), }, @@ -575,7 +593,12 @@ server.tool( // index is consistent on return; ~50-200ms once the embedder is warm. // Skips silently in full mode and on missing runtime. await embedKbEntry(resolved, result.slug, "memory", title, description, readConfig(resolved).contextMode); - return { content: [{ type: "text" as const, text: `Memory saved: ${result.slug} (${type}) -> ${resolved}` }] }; + // Notes carry format and dedup advice. They are appended to the success + // text rather than raised as errors: the write already landed, and a + // rejected save would lose the payload the agent just composed. + const head = `Memory saved: ${result.slug} (${type}) -> ${resolved}`; + const text = result.notes.length > 0 ? `${head}\n\n${result.notes.join("\n")}` : head; + return { content: [{ type: "text" as const, text }] }; }, ); @@ -585,12 +608,19 @@ server.tool( server.tool( "axme_save_decision", "Save a new architectural decision. Use enforce='required' for rules that must be followed, 'advisory' for recommendations. " + - "Call this tool ON ITS OWN (do NOT batch it in a parallel block with other tool calls), and include title, decision, and reasoning in THIS SAME call.", + "Call this tool ON ITS OWN (do NOT batch it in a parallel block with other tool calls), and include title, decision, and reasoning in THIS SAME call.\n\n" + + "TWO-LEVEL FORMAT: `decision` is loaded into EVERY future session — keep it to what was decided plus why, " + + "2-3 sentences, at most `catalog.excerpt_chars` characters (default 200). `reasoning` renders as '## Reasoning', " + + "is NOT loaded at session start, and is returned in full by axme_get_decision — put the alternatives considered, " + + "measurements, file paths and history there.\n\n" + + "Do NOT record meta-decisions — 'D-020 absorbed by D-036', 'D-024 updated'. Those describe edits to other " + + "decisions, not decisions; make the edit in the decisions themselves (axme_archive_decision carries a " + + "superseded_by argument for exactly this) instead of adding a record that only restates it.", { project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"), - title: z.string({ error: "title is REQUIRED — compose it in THIS call; do not emit axme_save_decision with empty arguments." }).describe("Decision title"), - decision: z.string({ error: "decision is REQUIRED — compose it in THIS call (2-3 sentences, self-contained). An empty call is the usual cause of this error: re-send with title+decision+reasoning present." }).describe("2-3 sentences: what was decided + why. Must be self-contained."), - reasoning: z.string({ error: "reasoning is REQUIRED — compose it in THIS call. Re-emit with all required fields present." }).describe("Optional additional context. Context output uses decision field only."), + title: z.string({ error: "title is REQUIRED — compose it in THIS call; do not emit axme_save_decision with empty arguments." }).describe("Decision title. A title equivalent to an existing decision's returns that decision UNCHANGED — nothing new is written. To revise, archive the old one and save the replacement."), + decision: z.string({ error: "decision is REQUIRED — compose it in THIS call (2-3 sentences, self-contained). An empty call is the usual cause of this error: re-send with title+decision+reasoning present." }).describe("THE LOADED LAYER — rendered into every future session's context. What was decided + why, 2-3 sentences, at most catalog.excerpt_chars characters (default 200)."), + reasoning: z.string({ error: "reasoning is REQUIRED — compose it in THIS call. Re-emit with all required fields present." }).describe("THE DEFERRED LAYER — rendered as '## Reasoning'. NOT loaded at session start; returned by axme_get_decision(id). Alternatives considered, measurements, file paths, history."), enforce: z.enum(["required", "advisory"]).optional().describe("Enforcement level"), scope: z.array(z.string()).optional().describe("Project scope"), }, @@ -604,7 +634,92 @@ server.tool( // Use decision text as description so the search index returns hits // ranked by the actual rule, not just the title. await embedKbEntry(resolved, result.id, "decision", title, decision, readConfig(resolved).contextMode); - return { content: [{ type: "text" as const, text: `Decision saved: ${result.id} - ${title} -> ${resolved}` }] }; + const head = `Decision saved: ${result.id} - ${title} -> ${resolved}`; + const text = result.notes.length > 0 ? `${head}\n\n${result.notes.join("\n")}` : head; + return { content: [{ type: "text" as const, text }] }; + }, +); + +// --- axme_archive_memory / axme_archive_decision --- +// The gap these close: axme-code could create knowledge but not retire it, +// while the knowledge bases it builds carry a rule of their own — "write to +// axme-code storage via MCP tools only, never manually". An agent asked to +// clean up had no legal move. Archival is a move: reversible, marked, and +// auditable. +server.tool( + "axme_archive_memory", + "Retire a memory: move it to .axme-code/archive/ instead of deleting it. Use when an entry no longer carries " + + "forward — a handoff or state snapshot, a research diary for a closed direction, a one-off incident whose fix " + + "is already in the code, or the loser of a merge (fold its unique detail into the surviving entry FIRST, then " + + "archive this one). The file is preserved with the reason stamped into its frontmatter, so an archival can be " + + "undone by moving one file back.", + { + project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"), + slug: z.string({ error: "slug is REQUIRED — the memory's slug as shown in the catalog." }).describe("Memory slug (from the catalog line, e.g. 'retry-npm-publish-via-automation-token')"), + reason: z.string({ error: "reason is REQUIRED — one line on why this entry no longer carries forward." }).describe("Why this is being retired. Stored in the archived file so a later reader can judge the call."), + }, + async ({ project_path, slug, reason }) => { + const resolved = pp(project_path); + const { archiveMemory } = await import("./storage/archive.js"); + const result = archiveMemory(resolved, slug, reason); + if (!result.ok) { + return { content: [{ type: "text" as const, text: `Archive failed: ${result.error}` }], isError: true }; + } + // Drop it from the search index too, or search keeps returning an entry + // that is no longer part of the live knowledge base. + try { + const { removeEmbedding } = await import("./storage/embeddings.js"); + await removeEmbedding(resolved, slug, "memory"); + } catch {} + return { content: [{ type: "text" as const, text: `Memory archived: ${slug} -> ${result.archivedTo}\nUndo: move that file back into .axme-code/memory/` }] }; + }, +); + +server.tool( + "axme_archive_decision", + "Retire a decision: mark it superseded or revoked, then move it to .axme-code/archive/. Pass superseded_by when a " + + "newer decision replaces this one; omit it when the decision simply stopped applying, and the reason is recorded " + + "as the revocation reason. This is the correct way to record 'D-020 is now covered by D-036' — do NOT save a new " + + "decision that says so, which produces a meta-record that restates an edit instead of making it.", + { + project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"), + id: z.string({ error: "id is REQUIRED — the decision id (D-NNN) or its slug." }).describe("Decision id (D-NNN) or slug"), + reason: z.string({ error: "reason is REQUIRED — one line on why this decision no longer applies." }).describe("Why this decision is being retired. Cite the code or the newer decision that makes it obsolete."), + superseded_by: z.string().optional().describe("Id of the decision that replaces this one. Must resolve to an existing decision — a dangling pointer is refused rather than written."), + }, + async ({ project_path, id, reason, superseded_by }) => { + const resolved = pp(project_path); + const { archiveDecision } = await import("./storage/archive.js"); + const result = archiveDecision(resolved, id, reason, superseded_by); + if (!result.ok) { + return { content: [{ type: "text" as const, text: `Archive failed: ${result.error}` }], isError: true }; + } + try { + const { removeEmbedding } = await import("./storage/embeddings.js"); + await removeEmbedding(resolved, id, "decision"); + } catch {} + const marked = superseded_by ? `superseded by ${superseded_by}` : "revoked"; + return { content: [{ type: "text" as const, text: `Decision archived (${marked}): ${id} -> ${result.archivedTo}\nUndo: move that file back into .axme-code/decisions/ and run 'axme-code reindex'.` }] }; + }, +); + +// --- axme_kb_doctor --- +server.tool( + "axme_kb_doctor", + "Scan the knowledge base for storage defects and format-contract violations: memories written under a broken slug " + + "(these overwrite each other), leaked tool-call markup, frontmatter that disagrees with the filename, entries whose " + + "description overruns the catalog budget, and duplicate titles. Deterministic and instant — no LLM, no cost. " + + "Pass fix=true to repair the mechanical defects (renames and markup stripping); overlong and duplicate entries need " + + "judgment and are only reported. Run this after a bulk import, or when the user asks about knowledge base health.", + { + project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"), + fix: z.boolean().optional().describe("Repair the mechanical defects. Never deletes; renames preserve content and stamp the corrected slug into the frontmatter."), + }, + async ({ project_path, fix }) => { + const resolved = pp(project_path); + const { runKbDoctor, formatDoctorReport } = await import("./storage/kb-doctor.js"); + const report = runKbDoctor(resolved, { fix: !!fix }); + return { content: [{ type: "text" as const, text: formatDoctorReport(report, !!fix) }] }; }, ); diff --git a/src/setup/cursor-writers.ts b/src/setup/cursor-writers.ts index 97d42ee..5be63d7 100644 --- a/src/setup/cursor-writers.ts +++ b/src/setup/cursor-writers.ts @@ -166,10 +166,36 @@ Palette. **Do not invoke \`axme-code setup\` via Bash autonomously**, even if user and wait. ### During Work -- Error pattern or successful approach discovered → call \`axme_save_memory\` immediately. +- Error pattern or transferable rule discovered → call \`axme_save_memory\` immediately. - Architectural decision made or discovered → call \`axme_save_decision\` immediately. - New safety constraint found → call \`axme_update_safety\` immediately. +### What to save (and what NOT to) +Before EVERY \`axme_save_memory\`: **would this help an agent a month from now who was not +part of this investigation?** If the value is in numbers rather than a rule, it is a document. + +**Save**: an owner's rule or ruling · vendor/feed/API semantics (sign convention, error codes, +limits, cadence) · a tool or language trap that will recur · a closed direction so nobody +reopens it · a live production contract. + +**Do NOT save**: measurement results and verdict numbers · session state and handoffs +(\`axme_finalize_close\` handles those) · a one-off incident already fixed in code with no +transferable rule · anything an existing entry covers — extend that entry instead. + +### The two-level format +\`description\` (memory) and \`decision\` (decision) are loaded into EVERY future session: +keep them to the rule plus one concrete fact, **at most 200 characters** (the catalog excerpt +width, \`catalog.excerpt_chars\` in \`.axme-code/config.yaml\`). Numbers, paths, line references +and measurements go into \`body\` / \`reasoning\` — those render as \`## Details\` / \`## Reasoning\`, +are NOT loaded at session start, and come back in full from \`axme_get_memory\` / +\`axme_get_decision\`. Cut DOWN into the deferred layer, never ACROSS into more records. + +### Housekeeping +- \`axme-code kb-doctor .\` — fast defect scan (broken slugs, leaked markup, overlong entries); + \`--fix\` repairs the mechanical ones. +- \`axme-code audit-kb . --dry-run\` — preview a compaction pass; drop the flag to apply. +- Retire entries with \`axme_archive_memory\` / \`axme_archive_decision\`, never a manual delete. + ### Git commit/push gate Every \`git commit\` and \`git push\` command MUST end with the marker: \`\`\` diff --git a/src/storage/archive.ts b/src/storage/archive.ts new file mode 100644 index 0000000..c52ad45 --- /dev/null +++ b/src/storage/archive.ts @@ -0,0 +1,185 @@ +/** + * Archival of memories and decisions. + * + * Why this exists: axme-code shipped `axme_save_memory` and + * `axme_save_decision` but nothing to retire an entry, while the knowledge + * bases it builds carry a rule of their own — "write to axme-code storage + * via MCP tools only, never manually". An agent asked to clean up therefore + * had no legal move: no tool to do it with, and a rule against doing it by + * hand. The first real compaction had to bypass MCP entirely with file + * operations, which is exactly the failure mode the rule exists to prevent. + * + * Design constraints, both learned from that compaction: + * + * - Archive, never delete. Everything lands under `.axme-code/archive/` + * with its original subdirectory structure, so a mistaken archival is + * undone by moving one file back. + * - A decision must be marked before it moves. `status: superseded` + + * `supersededBy` (or `status: revoked` + `revokedAt`/`revokedReason`) + * are written into the file itself, so the archived copy still explains + * why it left. An archive of unmarked files is a graveyard nobody can + * read. + */ + +import { readFileSync, readdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWrite, ensureDir, pathExists } from "./engine.js"; +import { setFrontmatterValue } from "./kb-doctor.js"; +import { getMemory } from "./memory.js"; +import { getDecision, rebuildDecisionIndex } from "./decisions.js"; +import { AXME_CODE_DIR } from "../types.js"; + +const ARCHIVE_DIR = "archive"; + +export interface ArchiveResult { + ok: boolean; + /** Path the entry now lives at, when ok. */ + archivedTo?: string; + /** Human-readable reason when !ok. */ + error?: string; +} + +function archiveRoot(projectPath: string): string { + return join(projectPath, AXME_CODE_DIR, ARCHIVE_DIR); +} + +/** + * Move one memory into the archive. + * + * The reason is stamped into the archived file's frontmatter rather than + * kept in a side ledger — the file has to be self-explanatory to whoever + * finds it later, possibly a different agent months on. + */ +export function archiveMemory(projectPath: string, slug: string, reason: string): ArchiveResult { + const memory = getMemory(projectPath, slug); + if (!memory) return { ok: false, error: `Memory "${slug}" not found` }; + + const subdir = memory.type === "feedback" ? "feedback" : "patterns"; + const source = join(projectPath, AXME_CODE_DIR, "memory", subdir, `${slug}.md`); + if (!pathExists(source)) { + return { ok: false, error: `Memory file for "${slug}" not found at ${source}` }; + } + + const destDir = join(archiveRoot(projectPath), "memory", subdir); + ensureDir(destDir); + const dest = uniquePath(destDir, slug); + + let raw: string; + try { raw = readFileSync(source, "utf-8"); } catch (e: any) { + return { ok: false, error: `Cannot read ${source}: ${e?.message ?? e}` }; + } + + const stamped = setFrontmatterValue( + setFrontmatterValue(raw, "archivedAt", new Date().toISOString()), + "archivedReason", oneLine(reason), + ); + atomicWrite(dest, stamped); + try { unlinkSync(source); } catch (e: any) { + return { ok: false, error: `Archived copy written to ${dest} but original could not be removed: ${e?.message ?? e}` }; + } + return { ok: true, archivedTo: dest }; +} + +/** + * Move one decision into the archive, marking it first. + * + * `supersededBy` is optional: a decision can leave either because a newer + * one replaced it (supersede) or because it stopped applying at all + * (revoke). Both are recorded in the file before the move. + */ +export function archiveDecision( + projectPath: string, idOrSlug: string, reason: string, supersededBy?: string, +): ArchiveResult { + const decision = getDecision(projectPath, idOrSlug); + if (!decision) return { ok: false, error: `Decision "${idOrSlug}" not found` }; + + if (supersededBy) { + const replacement = getDecision(projectPath, supersededBy); + if (!replacement) { + // Refuse rather than write a dangling pointer: a supersededBy that + // resolves to nothing is worse than no marking at all, because it + // reads as "go look at D-140" and D-140 does not exist. + return { ok: false, error: `supersededBy "${supersededBy}" does not resolve to an existing decision` }; + } + } + + const dir = join(projectPath, AXME_CODE_DIR, "decisions"); + const source = findDecisionFile(dir, decision.id); + if (!source) return { ok: false, error: `Decision file for ${decision.id} not found in ${dir}` }; + + let raw: string; + try { raw = readFileSync(source, "utf-8"); } catch (e: any) { + return { ok: false, error: `Cannot read ${source}: ${e?.message ?? e}` }; + } + + const now = new Date().toISOString(); + let stamped = setFrontmatterValue(raw, "status", supersededBy ? "superseded" : "revoked"); + stamped = supersededBy + ? setFrontmatterValue(stamped, "supersededBy", supersededBy) + : setFrontmatterValue(setFrontmatterValue(stamped, "revokedAt", now), "revokedReason", oneLine(reason)); + stamped = setFrontmatterValue(stamped, "archivedAt", now); + stamped = setFrontmatterValue(stamped, "archivedReason", oneLine(reason)); + + const destDir = join(archiveRoot(projectPath), "decisions"); + ensureDir(destDir); + const base = source.slice(source.lastIndexOf("/") + 1).replace(/\.md$/, ""); + const dest = uniquePath(destDir, base); + + atomicWrite(dest, stamped); + try { unlinkSync(source); } catch (e: any) { + return { ok: false, error: `Archived copy written to ${dest} but original could not be removed: ${e?.message ?? e}` }; + } + // The index still lists the archived id otherwise, and every later + // get-by-id would resolve to a file that is no longer there. + try { rebuildDecisionIndex(projectPath); } catch {} + return { ok: true, archivedTo: dest }; +} + +/** Restore an archived entry by moving its file back. Used by `--undo` flows. */ +export function listArchived(projectPath: string): { memories: string[]; decisions: string[] } { + const root = archiveRoot(projectPath); + const memories: string[] = []; + for (const sub of ["feedback", "patterns"]) { + const dir = join(root, "memory", sub); + if (!pathExists(dir)) continue; + try { + for (const f of readdirSync(dir).filter(f => f.endsWith(".md")).sort()) { + memories.push(join(dir, f)); + } + } catch {} + } + const decisions: string[] = []; + const ddir = join(root, "decisions"); + if (pathExists(ddir)) { + try { + for (const f of readdirSync(ddir).filter(f => f.endsWith(".md")).sort()) decisions.push(join(ddir, f)); + } catch {} + } + return { memories, decisions }; +} + +// --- Helpers --- + +/** Never overwrite inside the archive — an archived entry is evidence. */ +function uniquePath(dir: string, base: string): string { + let candidate = join(dir, `${base}.md`); + for (let n = 2; pathExists(candidate) && n <= 999; n++) { + candidate = join(dir, `${base}-${n}.md`); + } + return candidate; +} + +function findDecisionFile(dir: string, id: string): string | null { + if (!pathExists(dir)) return null; + try { + const match = readdirSync(dir).find(f => f.startsWith(`${id}-`) && f.endsWith(".md")); + return match ? join(dir, match) : null; + } catch { + return null; + } +} + +/** Frontmatter is line-based — a reason with newlines would corrupt the file. */ +function oneLine(text: string): string { + return (text || "").replace(/\s+/g, " ").trim().slice(0, 300); +} diff --git a/src/storage/config.ts b/src/storage/config.ts index 3c75d15..5657195 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -57,9 +57,48 @@ function formatConfig(config: ProjectConfig): string { "context:", ` mode: ${config.contextMode}`, "", + "# Knowledge-base format contract.", + "# excerpt_chars — how many characters of a memory description (or decision body)", + "# the search-mode catalog renders per entry. An entry that fits", + "# inside this budget is shown COMPLETE at session start; a longer", + "# one is cut and its tail is only reachable via axme_get_memory.", + "# Write entries to this number and search mode loses nothing.", + "# size_warn — warn at session start once memories+decisions exceed this count.", + "catalog:", + ` excerpt_chars: ${config.catalogExcerptChars}`, + ` size_warn: ${config.kbSizeWarnThreshold}`, + "", ].join("\n"); } +/** Coerce a config number, falling back when absent, non-numeric, or out of range. */ +function readNumber(raw: unknown, fallback: number, min: number, max: number): number { + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.round(n))); +} + +/** Supported range for each numeric KB-format key. */ +const NUMERIC_RANGES = { + catalogExcerptChars: { min: 80, max: 2000 }, + kbSizeWarnThreshold: { min: 10, max: 100000 }, +} as const; + +/** + * Clamp a numeric config value to its supported range. + * + * Callers clamp BEFORE writing so config.yaml holds the value that is + * actually in effect. Clamping only on read would leave the file saying + * `excerpt_chars: 5` while the catalog rendered 80 — and the file is what + * a human inspects when they want to know the setting. + */ +export function clampConfigNumber( + key: keyof typeof NUMERIC_RANGES, value: number, +): number { + const { min, max } = NUMERIC_RANGES[key]; + return Math.min(max, Math.max(min, Math.round(value))); +} + function parseConfig(content: string): ProjectConfig { const doc = yaml.load(content) as Record | null; if (!doc || typeof doc !== "object") return { ...DEFAULT_PROJECT_CONFIG }; @@ -70,7 +109,21 @@ function parseConfig(content: string): ProjectConfig { contextMode = ctxRaw.mode; } + // Clamped rather than trusted: a typo'd excerpt_chars of 2 would silently + // blank the whole catalog, and one of 100000 would defeat the point of + // search mode. Both failure modes are invisible until a session start + // costs 10x what it should. + const catRaw = doc.catalog; + const catalogExcerptChars = catRaw && typeof catRaw === "object" + ? readNumber(catRaw.excerpt_chars, DEFAULT_PROJECT_CONFIG.catalogExcerptChars, 80, 2000) + : DEFAULT_PROJECT_CONFIG.catalogExcerptChars; + const kbSizeWarnThreshold = catRaw && typeof catRaw === "object" + ? readNumber(catRaw.size_warn, DEFAULT_PROJECT_CONFIG.kbSizeWarnThreshold, 10, 100000) + : DEFAULT_PROJECT_CONFIG.kbSizeWarnThreshold; + return { + catalogExcerptChars, + kbSizeWarnThreshold, model: String(doc.model ?? DEFAULT_PROJECT_CONFIG.model), auditorModel: String(doc.auditor_model ?? DEFAULT_PROJECT_CONFIG.auditorModel), reviewEnabled: doc.review_enabled !== false, diff --git a/src/storage/decisions.ts b/src/storage/decisions.ts index 1b5ea42..65bb1cc 100644 --- a/src/storage/decisions.ts +++ b/src/storage/decisions.ts @@ -10,6 +10,8 @@ import { readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join, resolve, basename } from "node:path"; import { atomicWrite, ensureDir, pathExists } from "./engine.js"; import { logDecisionSaved, logDecisionSuperseded } from "./worklog.js"; +import { makeSlug } from "../utils/slug.js"; +import { sanitizeFields } from "../utils/sanitize.js"; import type { Decision } from "../types.js"; import { AXME_CODE_DIR } from "../types.js"; @@ -81,6 +83,12 @@ export function addDecision(projectPath: string, input: Omit): D const dir = decisionsDir(projectPath); ensureDir(dir); + // (0) Refuse to persist a leaked tool-call frame. A malformed emission + // upstream glued `...` onto the + // end of a field in three observed records; storage is the last place + // that can still drop it. + input = sanitizeFields(input, ["title", "decision", "reasoning"]).record; + // (1) Title-based dedup. If an existing decision has an equivalent title, // return it as-is. saveScopedDecisions callers treat this as success. const existing = listDecisions(projectPath); @@ -308,8 +316,14 @@ export function getDecisionSections(projectPath: string): string[] { }); } +/** + * Decision slug from a title. Decision filenames are `D-NNN-.md`, so a + * degenerate slug never caused overwrites the way memory slugs did — but an + * empty one still produced `D-042-.md` and made the file unsearchable by + * name. Same repaired generator, same guarantees. + */ export function toSlug(text: string): string { - return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50); + return makeSlug(text, 50, "decision"); } // --- Internal --- @@ -408,6 +422,15 @@ function parseDecisionFile(filePath: string): Decision | null { // index.md. Cross-process is already OK (O_EXCL on content files). let _rebuildQueue = Promise.resolve(); +/** + * Rebuild `decisions/index.md`. Exported because archival removes a file + * out-of-band from addDecision/supersedeDecision; without a rebuild the + * index keeps advertising an id whose file is gone. + */ +export function rebuildDecisionIndex(projectPath: string): void { + _rebuildIndexSync(projectPath); +} + function rebuildIndex(projectPath: string): void { _rebuildQueue = _rebuildQueue.then(() => _rebuildIndexSync(projectPath)).catch(() => {}); } diff --git a/src/storage/kb-doctor.ts b/src/storage/kb-doctor.ts new file mode 100644 index 0000000..65bfe2e --- /dev/null +++ b/src/storage/kb-doctor.ts @@ -0,0 +1,359 @@ +/** + * KB Doctor — deterministic storage-defect scan and repair. + * + * Everything here is cheap file inspection: no LLM, no network, runs in + * milliseconds on a 300-entry base. That is the point — the defects it + * finds are mechanical (a filename, a leaked tag, a length overrun), and + * paying for an LLM turn to notice them was the reason nobody noticed them + * for a month. + * + * Checks + * empty-slug memory written as bare `.md` — a dotfile, and the next + * such title overwrites it. Real data loss; see + * src/utils/slug.ts. + * degenerate-slug slug of digits only ("3", "16-07") — useless for search. + * slug-mismatch frontmatter `slug:` disagrees with the filename. + * leaked-markup a tool-call XML frame serialized into a text field. + * overlong description/decision longer than the catalog excerpt + * width, so its tail is invisible at session start. + * duplicate-title two entries with the same normalized title. + * + * `--fix` repairs the first four. It never repairs `overlong` (that needs + * judgment about what to move into the body — that is audit-kb's job) and + * never repairs `duplicate-title` (merging needs judgment too). + */ + +import { readFileSync, readdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWrite, pathExists } from "./engine.js"; +import { readConfig } from "./config.js"; +import { listMemories } from "./memory.js"; +import { listDecisions } from "./decisions.js"; +import { makeSlug, isDegenerateSlug } from "../utils/slug.js"; +import { hasLeakedMarkup, stripLeakedMarkup } from "../utils/sanitize.js"; +import { AXME_CODE_DIR } from "../types.js"; + +export type DefectKind = + | "empty-slug" + | "degenerate-slug" + | "slug-mismatch" + | "leaked-markup" + | "overlong" + | "duplicate-title"; + +export interface Defect { + kind: DefectKind; + /** Absolute path of the offending file. */ + file: string; + /** One-line human-readable description. */ + detail: string; + /** True when `--fix` can repair this defect without judgment. */ + autoFixable: boolean; +} + +export interface DoctorReport { + defects: Defect[]; + fixed: Defect[]; + memoriesScanned: number; + decisionsScanned: number; + /** Catalog excerpt width the `overlong` check was measured against. */ + excerptChars: number; +} + +/** + * Scan a project's KB storage, optionally repairing the mechanical defects. + * + * Safe to call on every session start: with `fix: false` it only reads, and + * with `fix: true` every repair is idempotent (a renamed file is not renamed + * again, stripped markup does not re-appear). + */ +export function runKbDoctor(projectPath: string, opts: { fix?: boolean } = {}): DoctorReport { + const storage = join(projectPath, AXME_CODE_DIR); + const report: DoctorReport = { + defects: [], fixed: [], memoriesScanned: 0, decisionsScanned: 0, + excerptChars: readConfig(projectPath).catalogExcerptChars, + }; + if (!pathExists(storage)) return report; + + scanMemories(projectPath, storage, report, !!opts.fix); + scanDecisions(projectPath, storage, report, !!opts.fix); + return report; +} + +// --- Memories --- + +function scanMemories(projectPath: string, storage: string, report: DoctorReport, fix: boolean): void { + const memRoot = join(storage, "memory"); + if (!pathExists(memRoot)) return; + + const titles = new Map(); + + for (const subdir of ["feedback", "patterns"]) { + const dir = join(memRoot, subdir); + if (!pathExists(dir)) continue; + let files: string[]; + try { + // readdirSync surfaces dotfiles, which is how the bare `.md` casualties + // are reachable at all — a shell glob would skip them entirely. + files = readdirSync(dir).filter(f => f.endsWith(".md")).sort(); + } catch { continue; } + + for (const filename of files) { + report.memoriesScanned++; + const path = join(dir, filename); + let raw: string; + try { raw = readFileSync(path, "utf-8"); } catch { continue; } + + const title = frontmatterValue(raw, "title"); + const fmSlug = frontmatterValue(raw, "slug"); + const fileSlug = filename.slice(0, -3); // strip ".md" + const desc = loadedLayer(raw, "## Details"); + + // --- slug defects --- + const wanted = makeSlug(title || fileSlug || "untitled", 60, "memory"); + if (fileSlug === "") { + const d = defect("empty-slug", path, `written as bare ".md" (title: ${short(title)}) — invisible to shell globs and overwritten by the next such entry; correct slug is "${wanted}"`, true); + applyOrRecord(d, report, fix, () => renameMemory(dir, wanted, raw, path)); + } else if (isDegenerateSlug(fileSlug)) { + const d = defect("degenerate-slug", path, `slug "${fileSlug}" has no searchable word; correct slug is "${wanted}"`, true); + applyOrRecord(d, report, fix, () => renameMemory(dir, wanted, raw, path)); + } else if (fmSlug && fmSlug !== fileSlug) { + const d = defect("slug-mismatch", path, `frontmatter slug "${fmSlug}" != filename "${fileSlug}"`, true); + applyOrRecord(d, report, fix, () => { + // Filename is authoritative: it is what every reader globs by, and + // rewriting the frontmatter cannot break an existing reference. + atomicWrite(path, setFrontmatterValue(raw, "slug", fileSlug)); + }); + } + + // --- leaked markup --- + if (hasLeakedMarkup(raw)) { + const d = defect("leaked-markup", path, "tool-call XML frame (, ) serialized into the record", true); + applyOrRecord(d, report, fix, () => atomicWrite(path, stripLeakedMarkupFromFile(raw))); + } + + // --- format contract --- + if (desc.length > report.excerptChars) { + report.defects.push(defect("overlong", path, + `description is ${desc.length} chars, catalog renders ${report.excerptChars} — the remaining ${desc.length - report.excerptChars} are invisible at session start; move them into "## Details"`, + false)); + } + + // --- duplicates --- + const norm = normalizeTitle(title); + if (norm) { + const prev = titles.get(norm); + if (prev) { + report.defects.push(defect("duplicate-title", path, `same normalized title as ${prev}`, false)); + } else { + titles.set(norm, path); + } + } + } + } +} + +/** + * Rename a memory file to its repaired slug and rewrite the frontmatter to + * match. Refuses to clobber an existing file: a suffix is appended instead, + * because the whole point of this repair is that two entries collapsed onto + * one name and both must survive. + */ +function renameMemory(dir: string, wanted: string, raw: string, oldPath: string): void { + let target = wanted; + for (let n = 2; pathExists(join(dir, `${target}.md`)) && n <= 99; n++) target = `${wanted}-${n}`; + const newPath = join(dir, `${target}.md`); + if (pathExists(newPath)) { + throw new Error(`kb-doctor: no free slug for ${oldPath} (tried ${wanted}..${wanted}-99)`); + } + // Write-then-unlink, not rename: if the process dies between the two, the + // content exists twice rather than zero times. + atomicWrite(newPath, setFrontmatterValue(raw, "slug", target)); + unlinkSync(oldPath); +} + +// --- Decisions --- + +function scanDecisions(projectPath: string, storage: string, report: DoctorReport, fix: boolean): void { + const dir = join(storage, "decisions"); + if (!pathExists(dir)) return; + let files: string[]; + try { + files = readdirSync(dir).filter(f => f.startsWith("D-") && f.endsWith(".md")).sort(); + } catch { return; } + + const titles = new Map(); + + for (const filename of files) { + report.decisionsScanned++; + const path = join(dir, filename); + let raw: string; + try { raw = readFileSync(path, "utf-8"); } catch { continue; } + + const title = frontmatterValue(raw, "title"); + const body = loadedLayer(raw, "## Reasoning"); + + if (hasLeakedMarkup(raw)) { + const d = defect("leaked-markup", path, "tool-call XML frame serialized into the record", true); + applyOrRecord(d, report, fix, () => atomicWrite(path, stripLeakedMarkupFromFile(raw))); + } + + if (body.length > report.excerptChars) { + report.defects.push(defect("overlong", path, + `decision body is ${body.length} chars, catalog renders ${report.excerptChars} — move the remainder into "## Reasoning"`, + false)); + } + + const norm = normalizeTitle(title); + if (norm) { + const prev = titles.get(norm); + if (prev) report.defects.push(defect("duplicate-title", path, `same normalized title as ${prev}`, false)); + else titles.set(norm, path); + } + } + void projectPath; +} + +// --- Helpers --- + +function defect(kind: DefectKind, file: string, detail: string, autoFixable: boolean): Defect { + return { kind, file, detail, autoFixable }; +} + +function applyOrRecord(d: Defect, report: DoctorReport, fix: boolean, apply: () => void): void { + if (fix) { + try { + apply(); + report.fixed.push(d); + return; + } catch { + // Repair failed (permissions, races) — report it as outstanding + // rather than claiming a fix that did not land. + } + } + report.defects.push(d); +} + +/** + * Value of a top-level frontmatter key, or "" when absent. + * + * The horizontal-whitespace class matters: `\s*` also matches a newline, so + * on an EMPTY key ("slug: \ntype: pattern") it would run past the end of the + * line and return the NEXT field's text as this key's value — and the + * setter built on the same pattern would delete that field outright. + */ +export function frontmatterValue(raw: string, key: string): string { + const m = new RegExp(`^${key}:[^\\S\\n]*(.*)$`, "m").exec(raw); + return m ? m[1].trim() : ""; +} + +/** Replace (or insert) a frontmatter key while leaving the rest byte-identical. */ +export function setFrontmatterValue(raw: string, key: string, value: string): string { + const re = new RegExp(`^${key}:[^\\S\\n]*.*$`, "m"); + if (re.test(raw)) return raw.replace(re, `${key}: ${value}`); + // No such key — insert directly after the opening fence. + if (raw.startsWith("---\n")) return `---\n${key}: ${value}\n` + raw.slice(4); + return `---\n${key}: ${value}\n---\n\n` + raw; +} + +/** + * The part of a record that is actually loaded into context: everything + * after the frontmatter and the `# Title` line, up to the deferred-detail + * heading (`## Details` for memories, `## Reasoning` for decisions). + * + * This is the quantity the format contract is about, so it is the quantity + * the `overlong` check measures — not the file size, which includes the + * body nobody pays for at session start. + */ +export function loadedLayer(raw: string, stopHeading: string): string { + let t = raw; + if (t.startsWith("---\n")) { + const end = t.indexOf("\n---\n", 4); + if (end >= 0) t = t.slice(end + 5); + } + const stop = t.indexOf("\n" + stopHeading); + if (stop >= 0) t = t.slice(0, stop); + t = t.trim(); + // Drop the leading "# Title" line — the catalog renders the title from + // frontmatter, separately from the excerpt. + if (t.startsWith("# ")) { + const nl = t.indexOf("\n"); + t = nl >= 0 ? t.slice(nl + 1) : ""; + } + return t.trim(); +} + +/** Strip a leaked tool-call frame from the body of a stored file. */ +function stripLeakedMarkupFromFile(raw: string): string { + const fenceEnd = raw.startsWith("---\n") ? raw.indexOf("\n---\n", 4) : -1; + if (fenceEnd < 0) return stripLeakedMarkup(raw).text + "\n"; + const head = raw.slice(0, fenceEnd + 5); + const body = raw.slice(fenceEnd + 5); + return head + stripLeakedMarkup(body).text + "\n"; +} + +function normalizeTitle(title: string): string { + return title.toLowerCase().replace(/[^a-z0-9а-яё ]+/gi, " ").replace(/\s+/g, " ").trim(); +} + +function short(s: string): string { + return s.length > 60 ? s.slice(0, 57) + "…" : s; +} + +/** + * Count of entries whose loaded layer overruns the catalog excerpt width. + * Used by axme_context to decide whether to nudge the agent about format, + * without re-running the whole doctor pass. + */ +export function countOverlong(projectPath: string): { memories: number; decisions: number; total: number; excerptChars: number } { + const excerptChars = readConfig(projectPath).catalogExcerptChars; + const memories = listMemories(projectPath).filter(m => (m.description ?? "").length > excerptChars).length; + const decisions = listDecisions(projectPath).filter(d => (d.decision ?? "").length > excerptChars).length; + return { memories, decisions, total: memories + decisions, excerptChars }; +} + +/** Render a doctor report as CLI text. */ +export function formatDoctorReport(report: DoctorReport, fixMode: boolean): string { + const lines: string[] = []; + lines.push(`Scanned ${report.memoriesScanned} memories, ${report.decisionsScanned} decisions (catalog excerpt: ${report.excerptChars} chars).`); + lines.push(""); + + if (report.fixed.length > 0) { + lines.push(`Fixed ${report.fixed.length}:`); + for (const d of report.fixed) lines.push(` [${d.kind}] ${d.file}\n ${d.detail}`); + lines.push(""); + } + + if (report.defects.length === 0) { + lines.push(report.fixed.length > 0 ? "No remaining defects." : "No defects found."); + return lines.join("\n"); + } + + const byKind = new Map(); + for (const d of report.defects) { + const list = byKind.get(d.kind) ?? []; + list.push(d); + byKind.set(d.kind, list); + } + + lines.push(`${report.defects.length} outstanding defect(s):`); + for (const [kind, list] of byKind) { + lines.push(`\n${kind} (${list.length}):`); + // Cap the per-kind listing so a base with 200 overlong entries does not + // bury the other findings — but say how many were withheld, because a + // silent cap reads as "that was all of them". + for (const d of list.slice(0, 10)) lines.push(` ${d.file}\n ${d.detail}`); + if (list.length > 10) lines.push(` … and ${list.length - 10} more (not listed)`); + } + + const fixable = report.defects.filter(d => d.autoFixable).length; + lines.push(""); + if (fixable > 0 && !fixMode) { + lines.push(`${fixable} of these are auto-fixable — re-run with --fix.`); + } + const judgment = report.defects.filter(d => !d.autoFixable).length; + if (judgment > 0) { + lines.push(`${judgment} need judgment (overlong / duplicate) — run 'axme-code audit-kb' to compact and merge them.`); + } + return lines.join("\n"); +} diff --git a/src/storage/memory.ts b/src/storage/memory.ts index 0e3f5f8..065004e 100644 --- a/src/storage/memory.ts +++ b/src/storage/memory.ts @@ -9,6 +9,8 @@ import { readFileSync, readdirSync } from "node:fs"; import { join, resolve, basename } from "node:path"; import { atomicWrite, ensureDir, pathExists, removeFile } from "./engine.js"; +import { makeSlug, isDegenerateSlug, shortHash } from "../utils/slug.js"; +import { sanitizeFields } from "../utils/sanitize.js"; import type { Memory, MemoryType } from "../types.js"; import { AXME_CODE_DIR } from "../types.js"; @@ -23,11 +25,87 @@ export function initMemoryStore(projectPath: string): void { ensureDir(join(memoryDir(projectPath), PATTERNS_DIR)); } -export function saveMemory(projectPath: string, memory: Memory): void { +export interface SaveMemoryOutcome { + /** Slug actually written — may differ from memory.slug on collision. */ + slug: string; + /** Field names whose leaked tool-call markup was stripped before writing. */ + cleanedFields: string[]; + /** + * Set when a DIFFERENT memory already owned this slug and the new one was + * written under a suffixed name instead of overwriting it. + */ + collisionWith?: string; +} + +/** + * Persist one memory. + * + * Two protections that did not exist before, both prompted by real data + * loss on a Cyrillic-language project: + * + * - the slug is repaired (never empty, never digits-only) so a file can + * never be written as bare `.md`, which is a dotfile invisible to the + * `memory//*.md` globs this module reads back; + * - if the target file exists but belongs to a memory with a DIFFERENT + * title, the incoming memory is written under a suffixed slug instead of + * silently replacing it. Same title still overwrites — that is an update, + * which is the intended way to revise an entry. + */ +export function saveMemory(projectPath: string, memory: Memory): SaveMemoryOutcome { const subdir = memory.type === "feedback" ? FEEDBACK_DIR : PATTERNS_DIR; const dir = join(memoryDir(projectPath), subdir); ensureDir(dir); - atomicWrite(join(dir, `${memory.slug}.md`), formatMemoryFile(memory)); + + const { record: clean, cleaned } = sanitizeFields(memory, ["title", "description", "body"]); + const safeSlug = repairMemorySlug(clean.slug, clean.title); + const { slug, collisionWith } = resolveSlugCollision(dir, safeSlug, clean.title); + + const toWrite: Memory = { ...clean, slug }; + atomicWrite(join(dir, `${slug}.md`), formatMemoryFile(toWrite)); + return { slug, cleanedFields: cleaned, ...(collisionWith ? { collisionWith } : {}) }; +} + +/** Rebuild a slug that an older axme-code version (or a caller) left unusable. */ +function repairMemorySlug(slug: string, title: string): string { + if (slug && !isDegenerateSlug(slug)) return slug; + return toMemorySlug(title); +} + +/** + * Return a slug that does not clobber an unrelated entry. + * + * Reads the existing file's `title:` frontmatter rather than comparing + * slugs: identical title means "the agent is revising this memory", which + * must overwrite; a different title under the same slug means two distinct + * memories collapsed onto one filename, which must not. + */ +function resolveSlugCollision( + dir: string, slug: string, title: string, +): { slug: string; collisionWith?: string } { + const existingTitle = readTitleOf(join(dir, `${slug}.md`)); + if (existingTitle === null || existingTitle === title) return { slug }; + + for (let n = 2; n <= 99; n++) { + const candidate = `${slug}-${n}`; + const t = readTitleOf(join(dir, `${candidate}.md`)); + if (t === null || t === title) return { slug: candidate, collisionWith: existingTitle }; + } + // 98 distinct memories share one slug — vanishingly unlikely, but fall + // back to a content hash rather than overwriting anything. + return { slug: `${slug}-${shortHash(title)}`, collisionWith: existingTitle }; +} + +/** Title from a memory file's frontmatter, or null when the file is absent. */ +function readTitleOf(path: string): string | null { + if (!pathExists(path)) return null; + try { + // [^\S\n]* not \s*: \s matches newlines, so an empty `title:` would + // capture the following frontmatter line as the title. + const m = /^title:[^\S\n]*(.*)$/m.exec(readFileSync(path, "utf-8")); + return m ? m[1].trim() : ""; + } catch { + return null; + } } export function saveMemories(projectPath: string, memories: Memory[]): void { @@ -216,8 +294,13 @@ export function showMemories(projectPath: string, type?: MemoryType): string { }).join("\n\n---\n\n"); } +/** + * Memory slug from a title. Non-Latin titles transliterate rather than + * collapsing to the empty string — see src/utils/slug.ts for why that + * mattered enough to fix. + */ export function toMemorySlug(text: string): string { - return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60); + return makeSlug(text, 60, "memory"); } // --- File format --- diff --git a/src/storage/save-feedback.ts b/src/storage/save-feedback.ts new file mode 100644 index 0000000..e9755ad --- /dev/null +++ b/src/storage/save-feedback.ts @@ -0,0 +1,136 @@ +/** + * Post-write advice returned to the agent by axme_save_memory / + * axme_save_decision. + * + * The problem this solves: the format contract ("description = 1-2 + * sentences, details go in body") was stated in the tool description and + * in the audit prompt, and enforced nowhere. Measured outcome on a real + * project — 91% of memories put everything into the loaded layer, average + * 212 words against a stated 1-2 sentences, and `## Details` non-empty in + * 11 records out of 126. A rule nothing ever checks is a suggestion. + * + * This is deliberately advisory, not a rejection. Refusing an overlong + * save would lose the content the agent just composed — the agent has no + * cheap way to retry a long payload, and a dropped memory is worse than a + * verbose one. So the write always lands, and the result text tells the + * agent exactly what happened and what to do about it next time. + */ + +import { listMemories } from "./memory.js"; +import { listDecisions } from "./decisions.js"; + +export interface SaveAdvice { + /** Lines appended to the tool result, in order. Empty when all is well. */ + notes: string[]; +} + +export interface OverrunInput { + kind: "memory" | "decision"; + /** The field that gets rendered into the session-start catalog. */ + loadedText: string; + /** Whether the caller supplied the deferred-detail field. */ + hasBody: boolean; + excerptChars: number; +} + +/** + * Report a description/decision that overruns the catalog excerpt width. + * + * The message names the concrete numbers rather than restating the rule: + * an agent that just wrote 1180 characters already believed it was being + * concise, so "1180 vs 200, the last 980 are invisible" changes behaviour + * in a way "keep it to 1-2 sentences" demonstrably did not. + */ +export function checkOverrun(input: OverrunInput): string[] { + const len = input.loadedText.length; + if (len <= input.excerptChars) return []; + + const field = input.kind === "memory" ? "description" : "decision"; + const detail = input.kind === "memory" ? "body (rendered as \"## Details\")" : "reasoning (rendered as \"## Reasoning\")"; + const hidden = len - input.excerptChars; + + const notes = [ + `NOTE: ${field} is ${len} chars; the session-start catalog renders ${input.excerptChars}. ` + + `The last ${hidden} chars will NOT be visible to future sessions unless they explicitly call ` + + `${input.kind === "memory" ? "axme_get_memory" : "axme_get_decision"}.`, + `Fix: keep ${field} to the rule plus one concrete fact (<=${input.excerptChars} chars) and move the numbers, ` + + `paths, measurements and line references into \`${detail}\` — that field costs nothing at session start ` + + `and is returned in full on demand.`, + ]; + if (!input.hasBody) { + notes.push(`You left ${input.kind === "memory" ? "body" : "reasoning"} empty, so all of this is currently in the paid layer.`); + } + return notes; +} + +export interface DuplicateCandidate { + ref: string; + title: string; + score: number; +} + +/** + * Find existing entries that look like the one being saved. + * + * Token-overlap on the title, not semantic search: this runs inside the + * save path on every call, so it must be synchronous and free. The + * embeddings index would be a better judge, but loading it costs 50-200ms + * and it is absent in `full` mode, where duplicates accumulate fastest. + * Overlap is crude but catches the dominant real case — the same rule + * re-extracted by a later session under a near-identical title. + */ +export function findDuplicateCandidates( + projectPath: string, kind: "memory" | "decision", title: string, limit = 3, +): DuplicateCandidate[] { + const incoming = tokenize(title); + if (incoming.size === 0) return []; + + const entries: Array<{ ref: string; title: string }> = kind === "memory" + ? listMemories(projectPath).map(m => ({ ref: m.slug, title: m.title })) + : listDecisions(projectPath).map(d => ({ ref: d.id, title: d.title })); + + const scored: DuplicateCandidate[] = []; + for (const e of entries) { + const other = tokenize(e.title); + if (other.size === 0) continue; + let shared = 0; + for (const t of incoming) if (other.has(t)) shared++; + // Jaccard over content words. 0.5 means half the distinctive words of + // both titles coincide — below that the hit rate on real bases was + // mostly noise ("git" and "branch" match everything). + const score = shared / (incoming.size + other.size - shared); + if (score >= 0.5) scored.push({ ref: e.ref, title: e.title, score }); + } + return scored.sort((a, b) => b.score - a.score).slice(0, limit); +} + +/** Render duplicate candidates as agent-facing advice. */ +export function formatDuplicateNote( + kind: "memory" | "decision", candidates: DuplicateCandidate[], +): string[] { + if (candidates.length === 0) return []; + const getter = kind === "memory" ? "axme_get_memory" : "axme_get_decision"; + const lines = [ + `NOTE: ${candidates.length} existing ${kind}(s) have a very similar title:`, + ...candidates.map(c => ` - ${c.ref} — ${c.title}`), + `If one of these covers the same ground, prefer EXTENDING it (read it with ${getter}, then save again ` + + `under its exact title to replace it) over leaving two near-duplicates. Two half-records cost more ` + + `context than one complete one and contradict each other as they age.`, + ]; + return lines; +} + +const STOP = new Set([ + "the", "a", "an", "and", "or", "of", "in", "for", "on", "to", "with", "at", + "by", "from", "is", "are", "be", "not", "no", "must", "should", "always", + "never", "use", "using", "when", "via", +]); + +function tokenize(text: string): Set { + return new Set( + text.toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .split(/\s+/) + .filter(w => w.length > 2 && !STOP.has(w)), + ); +} diff --git a/src/tools/context.ts b/src/tools/context.ts index 8a57ab8..9b3877d 100644 --- a/src/tools/context.ts +++ b/src/tools/context.ts @@ -9,6 +9,7 @@ import { oracleContext, showOracle, oracleExists, loadOracleFiles } from "../sto import { decisionsContext, showDecisions, enforceableDecisionsContext, listDecisions } from "../storage/decisions.js"; import { pathExists, readSafe } from "../storage/engine.js"; import { configExists, readConfig } from "../storage/config.js"; +import { runKbDoctor, countOverlong } from "../storage/kb-doctor.js"; import { isRuntimeInstalled } from "../storage/embeddings.js"; import { join } from "node:path"; import { existsSync } from "node:fs"; @@ -172,11 +173,22 @@ export function getFullContextSections(projectPath: string, workspacePath?: stri parts.push(lines.join("\n")); } + // Storage self-repair. Runs before the catalog is rendered so a session + // never reads a base that still has files it cannot see. Only mechanical, + // reversible repairs happen here (rename a file to a valid slug, drop a + // leaked XML frame) — nothing is deleted, nothing is rewritten for style. + const repair = repairStorageOnStart(projectPath); + if (repair) parts.push(repair); + // Context-loading branch: full mode (load everything) vs search mode // (catalog only + on-demand fetch via axme_get_*/axme_search_kb). const config = readConfig(projectPath); - const totalKbSize = listMemoriesMerged(projectPath, workspacePath).length - + listDecisionsMerged(projectPath, workspacePath).length; + const memCount = listMemoriesMerged(projectPath, workspacePath).length; + const decCount = listDecisionsMerged(projectPath, workspacePath).length; + const totalKbSize = memCount + decCount; + + const hygiene = buildHygieneLine(projectPath, memCount, decCount, config); + if (hygiene) parts.push(hygiene); if (config.contextMode === "search") { parts.push(buildSearchModeCatalog(projectPath, workspacePath)); @@ -241,6 +253,124 @@ export function getFullContextSections(projectPath: string, workspacePath?: stri return parts; } +/** + * Repair mechanical storage defects at session start, returning a one-line + * report when anything was touched (null when the base was clean). + * + * Runs on every axme_context call because the defects it fixes are actively + * destructive: a memory saved under an empty slug lands as bare `.md`, and + * the NEXT such memory overwrites it. Waiting for the user to run a repair + * command means the second write has already destroyed the first. The pass + * is idempotent and costs a directory walk, so paying it every session is + * cheaper than losing one entry. + * + * Deliberately silent about clean bases: a "nothing was wrong" line every + * session is noise the agent learns to skip, which would also make it skip + * the line that matters. + */ +function repairStorageOnStart(projectPath: string): string | null { + let report; + try { + report = runKbDoctor(projectPath, { fix: true }); + } catch { + // Never let a repair failure block context loading — a session with an + // unrepaired base still works; a session with no context does not. + return null; + } + if (report.fixed.length === 0) return null; + + const byKind = new Map(); + for (const d of report.fixed) byKind.set(d.kind, (byKind.get(d.kind) ?? 0) + 1); + const summary = [...byKind.entries()].map(([k, n]) => `${k}: ${n}`).join(", "); + + const lines = [ + "## Storage repaired at session start", + "", + `${report.fixed.length} mechanical defect(s) fixed automatically (${summary}).`, + "", + ...report.fixed.slice(0, 5).map(d => `- ${d.file}\n ${d.detail}`), + ]; + if (report.fixed.length > 5) lines.push(`- … and ${report.fixed.length - 5} more`); + lines.push( + "", + "Nothing was deleted — files were renamed to valid slugs and/or leaked tool-call markup was", + "stripped. Mention this to the user in your first response: entries that previously shared a", + "filename may have been overwriting each other before this repair.", + ); + return lines.join("\n"); +} + +/** + * One block about knowledge-base size and format, emitted only when the base + * has actually crossed a threshold worth acting on. + * + * Two distinct problems get reported here, and they have different fixes: + * + * - too many entries → compaction (audit-kb), because the cost is the count; + * - overlong entries → reformatting, because the cost is that each entry + * pays for detail nobody reads at session start while ALSO being cut off + * in the catalog. That combination is the worst of both modes: full mode + * pays for everything, search mode shows less than half of it. + */ +function buildHygieneLine( + projectPath: string, memCount: number, decCount: number, + config: { catalogExcerptChars: number; kbSizeWarnThreshold: number; contextMode: string }, +): string | null { + const total = memCount + decCount; + let overlong; + try { + overlong = countOverlong(projectPath); + } catch { + overlong = { memories: 0, decisions: 0, total: 0, excerptChars: config.catalogExcerptChars }; + } + + const sizeProblem = total >= config.kbSizeWarnThreshold; + // A tenth of the base overrunning is where the catalog stops being a + // faithful summary; below that it is a rounding error not worth a warning. + const formatProblem = overlong.total > 0 && overlong.total >= Math.max(5, Math.round(total * 0.1)); + if (!sizeProblem && !formatProblem) return null; + + const lines = ["## Knowledge base hygiene", ""]; + + if (sizeProblem) { + lines.push( + `This base holds **${memCount} memories + ${decCount} decisions = ${total} entries** ` + + `(warn threshold ${config.kbSizeWarnThreshold}, set via \`catalog.size_warn\`).`, + "", + "Tell the user once, in your first response, that a compaction pass is due:", + "", + "> ```bash", + "> axme-code audit-kb . --dry-run # preview: what would be compacted, merged, archived", + "> axme-code audit-kb . # apply (takes a backup first)", + "> ```", + "", + ); + } + + if (formatProblem) { + lines.push( + `**${overlong.total} entries** (${overlong.memories} memories, ${overlong.decisions} decisions) have a ` + + `loaded layer longer than the ${overlong.excerptChars}-char catalog budget.`, + "", + "These entries are paying twice: their full text is loaded in `full` mode, and in `search` mode the", + "catalog shows only the first part of them. Entries written to the budget cost the same in both modes", + "and lose nothing — which is what makes the two modes interchangeable.", + "", + "`axme-code kb-doctor .` lists them; `axme-code audit-kb .` rewrites them (rule in the description,", + "numbers and paths moved into the deferred body).", + "", + ); + } + + lines.push( + "**When YOU save entries this session**: description (memory) / decision (decision) must be the rule", + `plus one concrete fact, at most ${overlong.excerptChars} chars — that field is loaded into EVERY future`, + "session. Put measurements, file paths, line numbers and command output in `body` / `reasoning`, which", + "cost nothing at session start and are returned in full by axme_get_memory / axme_get_decision.", + ); + return lines.join("\n"); +} + /** Memories merged across workspace+project for KB-size accounting. */ function listMemoriesMerged(projectPath: string, workspacePath?: string) { return workspacePath && workspacePath !== projectPath @@ -265,40 +395,91 @@ function listDecisionsMerged(projectPath: string, workspacePath?: string) { function buildSearchModeCatalog(projectPath: string, workspacePath?: string): string { const memories = listMemoriesMerged(projectPath, workspacePath); const decisions = listDecisionsMerged(projectPath, workspacePath); + const limit = readConfig(projectPath).catalogExcerptChars; + + const decisionLines = decisions.map(d => renderDecisionCatalogLine(d, limit)); + const memoryLines = memories.map(m => renderMemoryCatalogLine(m, limit)); + const truncated = [...decisionLines, ...memoryLines].filter(isTruncatedLine).length; + const total = decisions.length + memories.length; + const lines: string[] = [ "## Knowledge Base Catalog (search mode)", "", `${decisions.length} decision(s), ${memories.length} memory(ies). Bodies are NOT loaded.`, "", + ...catalogLegend(limit, truncated, total), ]; - if (decisions.length > 0) { - lines.push("### Decisions"); - lines.push(""); - for (const d of decisions) { - lines.push(renderDecisionCatalogLine(d)); - } - lines.push(""); + if (decisionLines.length > 0) { + lines.push("### Decisions", "", ...decisionLines, ""); } - if (memories.length > 0) { - lines.push("### Memories"); - lines.push(""); - for (const m of memories) { - lines.push(renderMemoryCatalogLine(m)); - } - lines.push(""); + if (memoryLines.length > 0) { + lines.push("### Memories", "", ...memoryLines, ""); } return lines.join("\n"); } -function renderDecisionCatalogLine(d: { id: string; title: string; enforce?: string | null; decision?: string }): string { +/** A rendered catalog line whose excerpt was cut. */ +function isTruncatedLine(line: string): boolean { + return line.endsWith("…[TRUNCATED]"); +} + +/** + * Trim one description to the catalog budget, marking the cut. + * + * The marker is the point. A silently truncated line is indistinguishable + * from a complete one, so an agent reading the catalog cannot tell which + * entries it already understands and which it is only seeing the head of — + * and in practice it fetches neither. With the marker, "ends in …" is a + * mechanical signal to call axme_get_*, and its absence is a guarantee + * that the entry is complete as shown. + */ +function excerpt(text: string | undefined, limit: number): { text: string; truncated: boolean } { + const flat = (text ?? "").replace(/\s+/g, " ").trim(); + if (flat.length <= limit) return { text: flat, truncated: false }; + // Cut on a word boundary when one is close, so the tail is readable. + const hard = flat.slice(0, limit); + const lastSpace = hard.lastIndexOf(" "); + const cut = lastSpace > limit - 20 ? hard.slice(0, lastSpace) : hard; + return { text: cut, truncated: true }; +} + +function renderDecisionCatalogLine( + d: { id: string; title: string; enforce?: string | null; decision?: string }, limit: number, +): string { const enforce = d.enforce ?? "info"; - const desc = d.decision ? d.decision.replace(/\s+/g, " ").slice(0, 200) : ""; - return `- [${enforce}] **${d.id}** — ${d.title}${desc ? ` — ${desc}` : ""}`; + const { text, truncated } = excerpt(d.decision, limit); + const tail = text ? ` — ${text}${truncated ? " …[TRUNCATED]" : ""}` : ""; + return `- [${enforce}] **${d.id}** — ${d.title}${tail}`; } -function renderMemoryCatalogLine(m: { slug: string; title: string; type: string; description?: string }): string { - const desc = m.description ? m.description.replace(/\s+/g, " ").slice(0, 200) : ""; - return `- [${m.type}] **${m.slug}** — ${m.title}${desc ? ` — ${desc}` : ""}`; +function renderMemoryCatalogLine( + m: { slug: string; title: string; type: string; description?: string }, limit: number, +): string { + const { text, truncated } = excerpt(m.description, limit); + const tail = text ? ` — ${text}${truncated ? " …[TRUNCATED]" : ""}` : ""; + return `- [${m.type}] **${m.slug}** — ${m.title}${tail}`; +} + +/** + * Header explaining the [TRUNCATED] marker and what the agent owes each kind + * of line. Rendered above every catalog so the contract travels with the + * data instead of living only in the mode instructions further down. + */ +function catalogLegend(limit: number, truncatedCount: number, total: number): string[] { + if (total === 0) return []; + if (truncatedCount === 0) { + return [ + `Every entry below is COMPLETE as shown (all fit the ${limit}-char catalog budget).`, + "No axme_get_memory / axme_get_decision call is needed to understand any of them.", + "", + ]; + } + return [ + `Entries are cut at ${limit} chars. **${truncatedCount} of ${total}** end in \`…[TRUNCATED]\` —`, + "for those you are seeing only the beginning, and you MUST call `axme_get_memory(slug)` /", + "`axme_get_decision(id)` before acting on them. Lines without the marker are complete as shown.", + "", + ]; } /** @@ -311,17 +492,20 @@ function renderMemoryCatalogLine(m: { slug: string; title: string; type: string; */ export function buildDecisionsCatalogString(projectPath: string, workspacePath?: string): string { const decisions = listDecisionsMerged(projectPath, workspacePath); + const limit = readConfig(projectPath).catalogExcerptChars; + const rendered = decisions.map(d => renderDecisionCatalogLine(d, limit)); const lines: string[] = [ "## Decisions Catalog (search mode)", "", `${decisions.length} decision(s). Bodies NOT loaded — fetch via axme_get_decision(id_or_slug) or axme_search_kb(query).`, "", + ...catalogLegend(limit, rendered.filter(isTruncatedLine).length, decisions.length), ]; if (decisions.length === 0) { lines.push("No decisions recorded."); return lines.join("\n"); } - for (const d of decisions) lines.push(renderDecisionCatalogLine(d)); + lines.push(...rendered); return lines.join("\n"); } @@ -331,17 +515,20 @@ export function buildDecisionsCatalogString(projectPath: string, workspacePath?: */ export function buildMemoriesCatalogString(projectPath: string, workspacePath?: string): string { const memories = listMemoriesMerged(projectPath, workspacePath); + const limit = readConfig(projectPath).catalogExcerptChars; + const rendered = memories.map(m => renderMemoryCatalogLine(m, limit)); const lines: string[] = [ "## Memories Catalog (search mode)", "", `${memories.length} memory(ies). Bodies NOT loaded — fetch via axme_get_memory(slug) or axme_search_kb(query).`, "", + ...catalogLegend(limit, rendered.filter(isTruncatedLine).length, memories.length), ]; if (memories.length === 0) { lines.push("No memories recorded."); return lines.join("\n"); } - for (const m of memories) lines.push(renderMemoryCatalogLine(m)); + lines.push(...rendered); return lines.join("\n"); } @@ -360,33 +547,42 @@ function buildSearchModeInstructions(runtimeInstalled: boolean): string { ? "- `axme_search_kb(query, type?, k?)` — semantic search across both" : "- `axme_search_kb(query, ...)` — currently UNAVAILABLE (transformers runtime not installed; falls back to a hint message)"; const lines = [ - "## Search mode active — bodies fetched on demand", + "## Search mode active", "", - "You have a catalog of every memory and decision above (titles + descriptions only).", - "Bodies are NOT loaded. Token cost at session start is ~10x lower than full mode.", + "The catalog above is the knowledge base, not an index of it. Every entry that fits the catalog", + "budget is shown COMPLETE — for those there is nothing further to fetch, and re-fetching them", + "wastes a tool call. Entries marked `…[TRUNCATED]` are the exception: you have seen only their", + "opening, and the rest is one call away.", "", - "**MUST**: scan the catalog before generating code. If a title is relevant to your task,", - "fetch the full body **before** writing.", - "", - "- `axme_get_memory(slug)` — full body of one memory", - "- `axme_get_decision(id_or_slug)` — full body of one decision", + "- `axme_get_memory(slug)` — full record of one memory (description + the deferred `## Details`)", + "- `axme_get_decision(id_or_slug)` — full record of one decision (body + `## Reasoning`)", searchAvailable, "", - "## Active KB usage (when to call search/get)", + "## When to fetch", + "", + "**MUST** fetch before acting when any of these holds:", + "", + "- The catalog line is marked `…[TRUNCATED]` and its topic touches your task. The visible part is", + " the rule; the hidden part is usually the numbers, paths and edge cases you need to apply it.", + "- You are about to write or change code touching a subsystem some entry names.", + "- You are about to propose a fix for a bug — check `feedback` entries for the same failure first.", + "- You are about to save a new memory or decision — check whether one already covers it, and extend", + " that one instead of adding a near-duplicate.", + "", + "**MUST** call `axme_search_kb` (not just scan the catalog) when:", "", - "**MUST** call `axme_search_kb` (or `axme_get_*` when slug is known) when ANY of these triggers fire:", + "- The user asks \"how did we…\", \"why did we…\", \"что мы решили про…\", \"why is X this way?\"", + "- The user names a library, platform, tool, or error message.", + "- You are about to make an architectural recommendation — search the subsystem for prior decisions", + " so you neither contradict nor duplicate one.", "", - "- User asks \"how did we…\", \"why did we…\", \"что мы решили про…\", \"why is X this way?\" → search the topic.", - "- About to write or modify code that touches: git, safety hooks, storage, agent SDK, build, release, telemetry, auth, MCP tools → search the area first.", - "- About to suggest a fix for a bug → search similar past failures (memory type=feedback) before proposing.", - "- User mentions a library, platform, tool, or error message by name → search that name.", - "- A catalog title looks partially relevant but its 1-line description is too short to decide → fetch the body.", - "- Before any architectural recommendation or new pattern → search decisions for that subsystem to avoid contradiction or duplication.", - "- Before saving a new decision/memory → search to check if a similar one already exists (avoids dupes).", + "**Do NOT** fetch an entry whose catalog line is already complete just to be thorough. The catalog", + "line and the record's loaded layer are the same text; the extra call returns the deferred body,", + "which matters only when you need the specifics it holds.", "", - "Skipping search has caused real regressions in this project (force-pushing main, missing #!axme gate suffix,", - "duplicating an existing decision). The catalog scan is free; semantic search is sub-second and uses zero", - "API tokens (runs locally on CPU). When in doubt, search.", + "Skipping this has caused real regressions here (force-pushing main, missing the #!axme gate suffix,", + "duplicating an existing decision). Catalog scanning is free; semantic search is sub-second and runs", + "locally on CPU at zero token cost.", ]; lines.push(""); lines.push(runtimeInstalled diff --git a/src/tools/decision-tools.ts b/src/tools/decision-tools.ts index 2d6b9f4..66b14c5 100644 --- a/src/tools/decision-tools.ts +++ b/src/tools/decision-tools.ts @@ -3,6 +3,8 @@ */ import { addDecision, toSlug, listDecisions } from "../storage/decisions.js"; +import { readConfig } from "../storage/config.js"; +import { checkOverrun, findDuplicateCandidates, formatDuplicateNote } from "../storage/save-feedback.js"; import type { Decision, EnforceLevel } from "../types.js"; export interface SaveDecisionInput { @@ -13,13 +15,23 @@ export interface SaveDecisionInput { scope?: string[]; } +export interface SaveDecisionResult { + id: string; + slug: string; + saved: boolean; + /** Advisory lines appended to the tool result — format and dedup guidance. */ + notes: string[]; +} + export function saveDecisionTool( projectPath: string, input: SaveDecisionInput, sessionId?: string, -): { id: string; slug: string; saved: boolean } { +): SaveDecisionResult { const slug = toSlug(input.title); const today = new Date().toISOString().slice(0, 10); + const config = readConfig(projectPath); + const candidates = findDuplicateCandidates(projectPath, "decision", input.title); const decision = addDecision(projectPath, { slug, @@ -33,5 +45,24 @@ export function saveDecisionTool( ...(input.scope ? { scope: input.scope } : {}), }); - return { id: decision.id, slug: decision.slug, saved: true }; + const notes: string[] = []; + // addDecision returns the EXISTING record when a title-equivalent one is + // already stored. Saying so matters: the agent otherwise reads "Decision + // saved" and believes its new wording landed, when nothing changed. + if (decision.date !== today || decision.sessionId !== (sessionId ?? null)) { + notes.push( + `NOTE: an equivalent decision already existed (${decision.id} — ${decision.title}) and was returned unchanged; ` + + `nothing new was written. To revise it, use axme_archive_decision on the old one and save the replacement, ` + + `or save under a distinctly different title.`, + ); + } + notes.push(...checkOverrun({ + kind: "decision", + loadedText: input.decision, + hasBody: !!input.reasoning?.trim(), + excerptChars: config.catalogExcerptChars, + })); + notes.push(...formatDuplicateNote("decision", candidates.filter(c => c.ref !== decision.id))); + + return { id: decision.id, slug: decision.slug, saved: true, notes }; } diff --git a/src/tools/memory-tools.ts b/src/tools/memory-tools.ts index 3d304e6..3ff4b92 100644 --- a/src/tools/memory-tools.ts +++ b/src/tools/memory-tools.ts @@ -4,6 +4,8 @@ import { saveMemory, searchMemories, listMemories, toMemorySlug, showMemories } from "../storage/memory.js"; import { logMemorySaved } from "../storage/worklog.js"; +import { readConfig } from "../storage/config.js"; +import { checkOverrun, findDuplicateCandidates, formatDuplicateNote } from "../storage/save-feedback.js"; import type { Memory, MemoryType } from "../types.js"; export interface SaveMemoryInput { @@ -15,13 +17,26 @@ export interface SaveMemoryInput { scope?: string[]; } +export interface SaveMemoryResult { + slug: string; + saved: boolean; + /** Advisory lines appended to the tool result — format and dedup guidance. */ + notes: string[]; +} + export function saveMemoryTool( projectPath: string, input: SaveMemoryInput, sessionId?: string, -): { slug: string; saved: boolean } { +): SaveMemoryResult { const slug = toMemorySlug(input.title); const today = new Date().toISOString().slice(0, 10); + const config = readConfig(projectPath); + + // Look for near-duplicates BEFORE the write, so an exact-title update + // (which overwrites in place) does not report itself as its own duplicate. + const candidates = findDuplicateCandidates(projectPath, "memory", input.title) + .filter(c => c.ref !== slug); const memory: Memory = { slug, @@ -36,13 +51,37 @@ export function saveMemoryTool( ...(input.scope ? { scope: input.scope } : {}), }; - saveMemory(projectPath, memory); + const outcome = saveMemory(projectPath, memory); if (sessionId) { - logMemorySaved(projectPath, sessionId, slug, input.type); + logMemorySaved(projectPath, sessionId, outcome.slug, input.type); + } + + const notes: string[] = []; + if (outcome.cleanedFields.length > 0) { + // Surfaced rather than swallowed: a stripped field means the client + // serialized the call malformed, and the agent should verify the saved + // text rather than assume what it composed is what landed. + notes.push( + `WARNING: leaked tool-call markup was stripped from: ${outcome.cleanedFields.join(", ")}. ` + + `Re-read the entry with axme_get_memory("${outcome.slug}") and re-save if content was lost.`, + ); + } + if (outcome.collisionWith) { + notes.push( + `WARNING: slug "${slug}" was already held by a different memory ("${outcome.collisionWith}"). ` + + `Saved as "${outcome.slug}" instead of overwriting it.`, + ); } + notes.push(...checkOverrun({ + kind: "memory", + loadedText: input.description, + hasBody: !!input.body?.trim(), + excerptChars: config.catalogExcerptChars, + })); + notes.push(...formatDuplicateNote("memory", candidates)); - return { slug, saved: true }; + return { slug: outcome.slug, saved: true, notes }; } export function searchMemoryTool( diff --git a/src/types.ts b/src/types.ts index 4fc6371..2665254 100644 --- a/src/types.ts +++ b/src/types.ts @@ -283,14 +283,41 @@ export interface ProjectConfig { presets: string[]; /** Context-loading strategy. Defaults to "full". See ContextMode docstring. */ contextMode: ContextMode; + /** + * How many characters of a memory `description` / decision `decision` the + * search-mode catalog renders per entry. + * + * This is the single most consequential number in the KB format contract: + * an entry whose description fits inside it is rendered COMPLETE in the + * catalog, so search mode loses nothing versus full mode. An entry that + * overruns is cut, and the agent only ever sees the head of it unless it + * calls axme_get_memory. Exposed in config (rather than the former + * hardcoded 200) so projects can author entries against a value they + * control, instead of an undocumented constant that moves under them on + * the next upgrade. + */ + catalogExcerptChars: number; + /** Warn at session start once memories+decisions exceed this count. */ + kbSizeWarnThreshold: number; } +/** + * Default catalog excerpt width. Also the recommended ceiling for a memory + * description — write to it and the catalog stays complete. + */ +export const DEFAULT_CATALOG_EXCERPT_CHARS = 200; + +/** Default KB-size warning threshold (memories + decisions). */ +export const DEFAULT_KB_SIZE_WARN_THRESHOLD = 150; + export const DEFAULT_PROJECT_CONFIG: ProjectConfig = { model: DEFAULT_MODEL, auditorModel: DEFAULT_AUDITOR_MODEL, reviewEnabled: true, presets: ["essential-safety", "ai-agent-guardrails"], contextMode: "full", + catalogExcerptChars: DEFAULT_CATALOG_EXCERPT_CHARS, + kbSizeWarnThreshold: DEFAULT_KB_SIZE_WARN_THRESHOLD, }; // --- Plans --- diff --git a/src/utils/pagination.ts b/src/utils/pagination.ts index ccc6d8c..ab9dff2 100644 --- a/src/utils/pagination.ts +++ b/src/utils/pagination.ts @@ -40,12 +40,19 @@ export function paginateSections( return { text: sections.join("\n\n"), page: 1, totalPages: 1 }; } + // Sections larger than a whole page are split first. Without this a + // caller like axme_memories — which passes ["## Project Memories", ] — produced a page 1 holding nothing but the heading, with + // all content pushed to page 2. The reader sees "Page 1/3" and an empty + // body, which reads as a bug in the data rather than in the packing. + const units = sections.flatMap(s => (s.length > charLimit ? splitSection(s, charLimit) : [s])); + // Build pages by packing sections until limit const pages: string[][] = [[]]; let currentSize = 0; let idx = 0; - for (const section of sections) { + for (const section of units) { // Start new page if adding this section exceeds limit AND page isn't empty if (currentSize + section.length > charLimit && pages[idx].length > 0) { idx++; @@ -75,3 +82,32 @@ export function paginateSections( return { text: content + footer, page: safePage, totalPages }; } + +/** + * Split one oversized section into page-sized chunks along line boundaries. + * + * Line-aligned so a catalog entry, a table row, or a markdown heading is + * never cut mid-token. A single line longer than the limit (a pathological + * one-line blob) is emitted as its own chunk rather than being truncated — + * pagination must never lose content, only distribute it. + */ +function splitSection(section: string, charLimit: number): string[] { + const lines = section.split("\n"); + const chunks: string[] = []; + let buf: string[] = []; + let size = 0; + + for (const line of lines) { + // +1 for the newline that rejoining will add back. + const cost = line.length + 1; + if (size + cost > charLimit && buf.length > 0) { + chunks.push(buf.join("\n")); + buf = []; + size = 0; + } + buf.push(line); + size += cost; + } + if (buf.length > 0) chunks.push(buf.join("\n")); + return chunks.length > 0 ? chunks : [section]; +} diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts new file mode 100644 index 0000000..9f5569c --- /dev/null +++ b/src/utils/sanitize.ts @@ -0,0 +1,69 @@ +/** + * Strip leaked tool-call markup out of knowledge-base text fields. + * + * Observed in the wild: three memory files whose body ended with + * + * ...through watchdog. + * ["bf_live", "date rollover", "watchdog"] + * + * i.e. the client serialized one tool argument and the XML frame of the + * NEXT argument into the same string. The MCP layer cannot prevent a + * malformed emission upstream, so the storage layer refuses to persist it: + * everything from the first stray closing/parameter tag onward is dropped, + * because that text belongs to a different argument, not to this one. + * + * Deliberately conservative — it only recognises the specific frame shapes + * the Claude Code tool-call serializer emits. Ordinary prose containing + * "<" or a code snippet with real HTML is left untouched. + */ + +/** Tags that mark the end of one argument's payload in a leaked frame. */ +const LEAK_START = /<\/(?:description|parameter|body|decision|reasoning|title|invoke|function_calls)>|( + record: T, + fields: Array, +): { record: T; cleaned: string[] } { + const out = { ...record } as Record; + const cleaned: string[] = []; + for (const f of fields) { + const v = out[f]; + if (typeof v !== "string") continue; + const r = stripLeakedMarkup(v); + if (r.changed) { + out[f] = r.text; + cleaned.push(f); + } + } + return { record: out as T, cleaned }; +} diff --git a/src/utils/slug.ts b/src/utils/slug.ts new file mode 100644 index 0000000..5cf4c40 --- /dev/null +++ b/src/utils/slug.ts @@ -0,0 +1,105 @@ +/** + * Slug generation for memory and decision filenames. + * + * Why this module exists: the original implementation was + * `text.toLowerCase().replace(/[^a-z0-9]+/g, "-")`, which maps ANY + * non-Latin title to the empty string. An empty slug produced the file + * `.md` — a dotfile, invisible to `ls` and to every `memory//*.md` + * glob in this codebase — and the NEXT non-Latin title overwrote it. + * That is silent data loss, observed in the wild on a Cyrillic-language + * project (4 affected files, 2 confirmed overwrites). + * + * Guarantees provided here: + * 1. Never empty. Cyrillic and Greek transliterate; anything else that + * still reduces to nothing falls back to a content hash. + * 2. Never degenerate. A slug of only digits and hyphens ("3", "16-07") + * carries no meaning for semantic search, so it gets a prefix. + * 3. Stable. The same title always yields the same slug, so re-saving a + * memory updates it in place instead of accumulating near-duplicates. + */ + +import { createHash } from "node:crypto"; + +/** Maximum slug length (memories historically allowed 60, decisions 50). */ +export const DEFAULT_SLUG_MAX = 60; + +/** + * Cyrillic → Latin. Deliberately a plain lookup table rather than a + * dependency: transliteration only has to be stable and readable, not + * linguistically perfect, and adding a package for it would bloat the + * bundle for a ~40-entry map. + */ +const CYRILLIC_MAP: Record = { + а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", + з: "z", и: "i", й: "y", к: "k", л: "l", м: "m", н: "n", о: "o", + п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "ts", + ч: "ch", ш: "sh", щ: "sch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", + я: "ya", + // Ukrainian / Belarusian extras — cheap to include, avoids empty slugs + // for the same class of titles. + і: "i", ї: "yi", є: "ye", ґ: "g", ў: "u", +}; + +const GREEK_MAP: Record = { + α: "a", β: "b", γ: "g", δ: "d", ε: "e", ζ: "z", η: "i", θ: "th", + ι: "i", κ: "k", λ: "l", μ: "m", ν: "n", ξ: "x", ο: "o", π: "p", + ρ: "r", σ: "s", ς: "s", τ: "t", υ: "y", φ: "f", χ: "ch", ψ: "ps", + ω: "o", +}; + +/** + * Transliterate non-Latin characters that have a well-known Latin form, + * and strip diacritics from Latin ones (é → e) via NFD normalization. + */ +export function transliterate(text: string): string { + const lowered = text.toLowerCase(); + let out = ""; + for (const ch of lowered) { + out += CYRILLIC_MAP[ch] ?? GREEK_MAP[ch] ?? ch; + } + // Decompose accented Latin (é → e + combining acute) and drop the marks. + return out.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); +} + +/** A slug is degenerate when it carries no searchable word — only digits. */ +export function isDegenerateSlug(slug: string): boolean { + if (!slug) return true; + return !/[a-z]/.test(slug); +} + +/** + * Build a filesystem-safe slug that is guaranteed non-empty and + * non-degenerate. + * + * @param text Source title. + * @param maxLen Truncation length. + * @param prefix Used when the title reduces to nothing or to digits only; + * keeps hashed slugs distinguishable by kind ("memory-a1b2c3"). + */ +export function makeSlug(text: string, maxLen = DEFAULT_SLUG_MAX, prefix = "entry"): string { + const base = transliterate(text) + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, maxLen) + // Truncation can leave a trailing hyphen ("foo-bar-" from "foo-bar-baz"). + .replace(/-+$/g, ""); + + if (!base) { + // Nothing survived transliteration (CJK, emoji-only, symbols). Hash the + // ORIGINAL text so two different such titles never collide. + return `${prefix}-${shortHash(text)}`; + } + + if (isDegenerateSlug(base)) { + // "16-07" or "3" — valid as a filename but useless for search and prone + // to collision across unrelated entries. Prefix it into a real word. + return `${prefix}-${base}`.slice(0, maxLen).replace(/-+$/g, ""); + } + + return base; +} + +/** First 8 hex chars of sha1 — enough to separate titles, short enough to read. */ +export function shortHash(text: string): string { + return createHash("sha1").update(text, "utf8").digest("hex").slice(0, 8); +} diff --git a/templates/plugin-README.md b/templates/plugin-README.md index 03ea8fa..129e69f 100644 --- a/templates/plugin-README.md +++ b/templates/plugin-README.md @@ -5,7 +5,7 @@ Persistent memory, architectural decisions, and safety guardrails for Claude Code. Your agent starts every session with full project context — stack, decisions, patterns, safety rules, and a handoff from the previous session. [![Alpha](https://img.shields.io/badge/status-alpha-orange)]() -[![Version](https://img.shields.io/badge/version-0.6.3-blue)]() +[![Version](https://img.shields.io/badge/version-0.6.4-blue)]() [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE) **[Main Repository](https://github.com/AxmeAI/axme-code)** · **[Website](https://code.axme.ai)** · **[Issues](https://github.com/AxmeAI/axme-code/issues)** diff --git a/test/context.test.ts b/test/context.test.ts index 627c68e..ce9da97 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -144,12 +144,12 @@ describe("search-mode catalog rendering", () => { assert.ok(out.includes("No memories recorded.")); }); - it("getFullContextSections in search mode emits Active KB usage block with triggers", () => { + it("getFullContextSections in search mode emits fetch-trigger block", () => { setupSearchMode(); const sections = getFullContextSections(PROJECT); const joined = sections.join("\n"); assert.ok(joined.includes("Search mode active")); - assert.ok(joined.includes("Active KB usage")); + assert.ok(joined.includes("When to fetch")); // Concrete trigger predicates we promised to surface assert.ok(joined.includes("how did we")); assert.ok(joined.includes("axme_search_kb")); diff --git a/test/kb-hygiene.test.ts b/test/kb-hygiene.test.ts new file mode 100644 index 0000000..1a2ab7a --- /dev/null +++ b/test/kb-hygiene.test.ts @@ -0,0 +1,454 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readdirSync, existsSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { makeSlug, transliterate, isDegenerateSlug } from "../src/utils/slug.js"; +import { stripLeakedMarkup, hasLeakedMarkup, sanitizeFields } from "../src/utils/sanitize.js"; +import { paginateSections } from "../src/utils/pagination.js"; +import { runKbDoctor, loadedLayer, setFrontmatterValue, frontmatterValue } from "../src/storage/kb-doctor.js"; +import { archiveMemory, archiveDecision } from "../src/storage/archive.js"; +import { formatKbAuditReport } from "../src/agents/kb-auditor.js"; +import { checkOverrun, findDuplicateCandidates } from "../src/storage/save-feedback.js"; +import { readConfig, writeConfig } from "../src/storage/config.js"; +import { initMemoryStore, saveMemory, toMemorySlug, getMemory } from "../src/storage/memory.js"; +import { initDecisionStore, addDecision, getDecision, toSlug } from "../src/storage/decisions.js"; +import { DEFAULT_PROJECT_CONFIG } from "../src/types.js"; + +let ROOT: string; + +beforeEach(() => { + ROOT = mkdtempSync(join(tmpdir(), "axme-hygiene-")); +}); +afterEach(() => { + rmSync(ROOT, { recursive: true, force: true }); +}); + +// --- Slug --- + +describe("slug generation", () => { + it("transliterates Cyrillic instead of collapsing to empty", () => { + // The original implementation produced "" here, which wrote the file as + // bare ".md" and let the next such title overwrite it. + assert.equal(makeSlug("Перекат даты"), "perekat-daty"); + assert.equal(makeSlug("Ловушка watchdog при рестарте"), "lovushka-watchdog-pri-restarte"); + }); + + it("never returns an empty slug, whatever the script", () => { + for (const title of ["日本語のみ", "🎉🎉🎉", "!!!", " ", "…"]) { + const slug = makeSlug(title); + assert.notEqual(slug, "", `empty slug for ${JSON.stringify(title)}`); + assert.ok(/[a-z]/.test(slug), `unsearchable slug ${slug} for ${JSON.stringify(title)}`); + } + }); + + it("gives different unmappable titles different slugs", () => { + assert.notEqual(makeSlug("日本語"), makeSlug("中文")); + }); + + it("prefixes degenerate digit-only slugs", () => { + assert.equal(makeSlug("16-07", 60, "memory"), "memory-16-07"); + assert.equal(makeSlug("3", 60, "memory"), "memory-3"); + assert.ok(!isDegenerateSlug(makeSlug("100", 60, "memory"))); + }); + + it("is stable — the same title always yields the same slug", () => { + assert.equal(makeSlug("Перекат даты"), makeSlug("Перекат даты")); + }); + + it("strips diacritics from Latin titles", () => { + assert.equal(transliterate("Café Naïve"), "cafe naive"); + }); + + it("never leaves a trailing hyphen after truncation", () => { + const slug = makeSlug("aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd eeeeeeeeee ffff", 50); + assert.ok(!slug.endsWith("-"), slug); + }); +}); + +// --- Sanitize --- + +describe("leaked tool-call markup", () => { + const LEAKED = 'Watchdog flaps on rollover.\n["bf_live"]'; + + it("cuts everything from the first stray tag onward", () => { + const r = stripLeakedMarkup(LEAKED); + assert.equal(r.changed, true); + assert.equal(r.text, "Watchdog flaps on rollover."); + }); + + it("leaves ordinary prose untouched", () => { + const clean = "Use in JSX and compare a < b in the guard."; + const r = stripLeakedMarkup(clean); + assert.equal(r.changed, false); + assert.equal(r.text, clean); + }); + + it("detects without mutating", () => { + assert.equal(hasLeakedMarkup(LEAKED), true); + assert.equal(hasLeakedMarkup("plain text"), false); + }); + + it("reports which fields it cleaned", () => { + const { record, cleaned } = sanitizeFields( + { title: "fine", description: LEAKED, body: "also fine" }, + ["title", "description", "body"], + ); + assert.deepEqual(cleaned, ["description"]); + assert.equal(record.description, "Watchdog flaps on rollover."); + assert.equal(record.title, "fine"); + }); +}); + +// --- Pagination --- + +describe("pagination of oversized sections", () => { + it("does not strand a small header alone on page 1", () => { + // The reported bug: axme_memories passed ["## Project Memories", ] + // and page 1 rendered only the heading. + const huge = Array.from({ length: 4000 }, (_, i) => `- entry ${i} with some descriptive text`).join("\n"); + const result = paginateSections(["## Project Memories", huge], 1, "axme_memories", {}); + assert.ok(result.totalPages > 1); + assert.ok(result.text.includes("## Project Memories")); + // Page 1 must carry real content, not just the heading + footer. + assert.ok(result.text.length > 1000, `page 1 was ${result.text.length} chars`); + assert.ok(result.text.includes("- entry 0")); + }); + + it("loses no content across pages", () => { + const lines = Array.from({ length: 3000 }, (_, i) => `line-${i}`); + const section = lines.join("\n"); + const total = paginateSections([section], 1, "t", {}).totalPages; + let seen = ""; + for (let p = 1; p <= total; p++) seen += paginateSections([section], p, "t", {}).text; + for (const l of ["line-0", "line-1499", "line-2999"]) { + assert.ok(seen.includes(l), `${l} missing from paginated output`); + } + }); + + it("still single-pages content that fits", () => { + const r = paginateSections(["short", "also short"], 1, "t", {}); + assert.equal(r.totalPages, 1); + assert.equal(r.text, "short\n\nalso short"); + }); +}); + +// --- Config --- + +describe("catalog.excerpt_chars config", () => { + it("round-trips through config.yaml", () => { + mkdirSync(join(ROOT, ".axme-code"), { recursive: true }); + writeConfig(ROOT, { ...DEFAULT_PROJECT_CONFIG, catalogExcerptChars: 320, kbSizeWarnThreshold: 90 }); + const cfg = readConfig(ROOT); + assert.equal(cfg.catalogExcerptChars, 320); + assert.equal(cfg.kbSizeWarnThreshold, 90); + }); + + it("clamps values that would break the catalog", () => { + mkdirSync(join(ROOT, ".axme-code"), { recursive: true }); + writeConfig(ROOT, { ...DEFAULT_PROJECT_CONFIG, catalogExcerptChars: 2 }); + assert.equal(readConfig(ROOT).catalogExcerptChars, 80); + writeConfig(ROOT, { ...DEFAULT_PROJECT_CONFIG, catalogExcerptChars: 999999 }); + assert.equal(readConfig(ROOT).catalogExcerptChars, 2000); + }); + + it("defaults when the key is absent (existing projects)", () => { + mkdirSync(join(ROOT, ".axme-code"), { recursive: true }); + writeFileSync(join(ROOT, ".axme-code", "config.yaml"), "model: x\ncontext:\n mode: full\n"); + assert.equal(readConfig(ROOT).catalogExcerptChars, 200); + }); +}); + +// --- Save feedback --- + +describe("save-time format feedback", () => { + it("stays silent when the description fits the budget", () => { + const notes = checkOverrun({ kind: "memory", loadedText: "short rule", hasBody: false, excerptChars: 200 }); + assert.deepEqual(notes, []); + }); + + it("names the concrete overrun and where the tail should go", () => { + const notes = checkOverrun({ kind: "memory", loadedText: "x".repeat(500), hasBody: false, excerptChars: 200 }); + assert.ok(notes.length > 0); + const joined = notes.join(" "); + assert.ok(joined.includes("500")); + assert.ok(joined.includes("200")); + assert.ok(joined.includes("## Details")); + assert.ok(joined.includes("axme_get_memory")); + }); + + it("finds near-duplicate titles", () => { + initMemoryStore(ROOT); + saveMemory(ROOT, { + slug: toMemorySlug("Verify PR merge status before committing"), + type: "feedback", title: "Verify PR merge status before committing", + description: "d", body: "", keywords: [], source: "manual", sessionId: null, date: "2026-01-01", + }); + const hits = findDuplicateCandidates(ROOT, "memory", "Always verify PR merge status before committing"); + assert.equal(hits.length, 1); + assert.ok(hits[0].title.includes("Verify PR merge status")); + }); + + it("does not flag unrelated titles", () => { + initMemoryStore(ROOT); + saveMemory(ROOT, { + slug: "a", type: "feedback", title: "Docker images must use pinned tags", + description: "d", body: "", keywords: [], source: "manual", sessionId: null, date: "2026-01-01", + }); + assert.deepEqual(findDuplicateCandidates(ROOT, "memory", "Retry npm publish via automation token"), []); + }); +}); + +// --- loadedLayer --- + +describe("loadedLayer measurement", () => { + it("counts only the text loaded at session start", () => { + const file = [ + "---", "slug: x", "title: T", "---", "", + "# T", "", + "The rule, stated once.", "", + "## Details", "", + "Thousands of characters of measurements that cost nothing per session.", + ].join("\n"); + assert.equal(loadedLayer(file, "## Details"), "The rule, stated once."); + }); + + it("handles a record with no deferred section", () => { + const file = ["---", "slug: x", "---", "", "# T", "", "Just the rule."].join("\n"); + assert.equal(loadedLayer(file, "## Details"), "Just the rule."); + }); +}); + +describe("setFrontmatterValue", () => { + it("replaces an existing key", () => { + const out = setFrontmatterValue("---\nslug: old\ntitle: T\n---\n\nbody\n", "slug", "new"); + assert.ok(out.includes("slug: new")); + assert.ok(!out.includes("slug: old")); + assert.ok(out.includes("title: T")); + }); + + it("does not swallow the next field when the key is empty", () => { + // Regression: `^slug:\s*.*$` let \s* cross the newline, so replacing an + // empty `slug:` deleted the `type:` line under it — and a memory with no + // type is dropped by the parser, i.e. silent data loss during repair. + const raw = "---\nslug: \ntype: pattern\ntitle: T\n---\n\n# T\n\nrule\n"; + const out = setFrontmatterValue(raw, "slug", "fixed"); + assert.ok(out.includes("slug: fixed")); + assert.ok(out.includes("type: pattern"), out); + assert.ok(out.includes("title: T"), out); + }); + + it("reads an empty key as empty, not as the next line", () => { + const raw = "---\nslug: \ntype: pattern\n---\n"; + assert.equal(frontmatterValue(raw, "slug"), ""); + assert.equal(frontmatterValue(raw, "type"), "pattern"); + }); + + it("inserts a missing key", () => { + const out = setFrontmatterValue("---\ntitle: T\n---\n\nbody\n", "archivedAt", "2026-08-18"); + assert.ok(out.includes("archivedAt: 2026-08-18")); + assert.ok(out.includes("title: T")); + }); +}); + +// --- KB Doctor --- + +describe("kb-doctor", () => { + function writeMemoryFile(name: string, frontmatter: string, body: string): string { + const dir = join(ROOT, ".axme-code", "memory", "patterns"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, name); + writeFileSync(path, `---\n${frontmatter}\n---\n\n${body}\n`); + return path; + } + + it("finds a memory written as bare .md", () => { + writeMemoryFile(".md", "slug: \ntype: pattern\ntitle: Перекат даты\nsource: session\ndate: 2026-01-01\nkeywords: \nsessionId: ", "# Перекат даты\n\nrule"); + const report = runKbDoctor(ROOT); + assert.equal(report.defects.filter(d => d.kind === "empty-slug").length, 1); + }); + + it("repairs it into a real transliterated filename", () => { + writeMemoryFile(".md", "slug: \ntype: pattern\ntitle: Перекат даты\nsource: session\ndate: 2026-01-01\nkeywords: \nsessionId: ", "# Перекат даты\n\nrule"); + const report = runKbDoctor(ROOT, { fix: true }); + assert.equal(report.fixed.filter(d => d.kind === "empty-slug").length, 1); + + const dir = join(ROOT, ".axme-code", "memory", "patterns"); + const files = readdirSync(dir); + assert.ok(!files.includes(".md"), "bare .md still present"); + assert.ok(files.includes("perekat-daty.md"), files.join(",")); + // Frontmatter must agree with the new filename, or the next scan flags it. + const repaired = readFileSync(join(dir, "perekat-daty.md"), "utf-8"); + assert.ok(repaired.includes("slug: perekat-daty")); + // And every OTHER field must survive the rewrite — a repair that drops + // `type:` makes the record unparseable, which is worse than the defect. + assert.ok(repaired.includes("type: pattern"), repaired); + assert.ok(repaired.includes("title: Перекат даты"), repaired); + assert.ok(repaired.includes("rule"), repaired); + }); + + it("is idempotent — a second --fix pass changes nothing", () => { + writeMemoryFile(".md", "slug: \ntype: pattern\ntitle: Перекат даты\nsource: session\ndate: 2026-01-01\nkeywords: \nsessionId: ", "# Перекат даты\n\nrule"); + runKbDoctor(ROOT, { fix: true }); + const second = runKbDoctor(ROOT, { fix: true }); + assert.equal(second.fixed.length, 0); + assert.equal(second.defects.filter(d => d.autoFixable).length, 0); + }); + + it("finds and strips leaked tool-call markup", () => { + const p = writeMemoryFile("leaky.md", "slug: leaky\ntype: pattern\ntitle: Leaky\nsource: session\ndate: 2026-01-01\nkeywords: \nsessionId: ", + '# Leaky\n\nWatchdog flaps.\n["bf_live"]'); + assert.equal(runKbDoctor(ROOT).defects.filter(d => d.kind === "leaked-markup").length, 1); + runKbDoctor(ROOT, { fix: true }); + const after = readFileSync(p, "utf-8"); + assert.ok(!after.includes("")); + assert.ok(!after.includes(" { + writeMemoryFile("long.md", "slug: long\ntype: pattern\ntitle: Long\nsource: session\ndate: 2026-01-01\nkeywords: \nsessionId: ", + `# Long\n\n${"x".repeat(600)}`); + const report = runKbDoctor(ROOT, { fix: true }); + const overlong = report.defects.filter(d => d.kind === "overlong"); + assert.equal(overlong.length, 1); + assert.equal(overlong[0].autoFixable, false); + // Content untouched — shortening needs judgment, which is audit-kb's job. + assert.ok(readFileSync(join(ROOT, ".axme-code", "memory", "patterns", "long.md"), "utf-8").includes("x".repeat(600))); + }); + + it("reports a clean base as clean", () => { + writeMemoryFile("fine.md", "slug: fine\ntype: pattern\ntitle: Fine\nsource: session\ndate: 2026-01-01\nkeywords: \nsessionId: ", "# Fine\n\nA short rule."); + assert.deepEqual(runKbDoctor(ROOT).defects, []); + }); +}); + +// --- Archive --- + +describe("archival", () => { + it("moves a memory into archive/ and removes it from the live store", () => { + initMemoryStore(ROOT); + saveMemory(ROOT, { + slug: "doomed", type: "pattern", title: "Doomed", description: "d", + body: "", keywords: [], source: "manual", sessionId: null, date: "2026-01-01", + }); + const r = archiveMemory(ROOT, "doomed", "handoff snapshot, does not carry forward"); + assert.equal(r.ok, true); + assert.equal(getMemory(ROOT, "doomed"), null); + assert.ok(existsSync(r.archivedTo!)); + const archived = readFileSync(r.archivedTo!, "utf-8"); + assert.ok(archived.includes("archivedReason: handoff snapshot")); + assert.ok(archived.includes("Doomed")); + }); + + it("refuses a slug that does not exist", () => { + initMemoryStore(ROOT); + const r = archiveMemory(ROOT, "nope", "x"); + assert.equal(r.ok, false); + assert.ok(r.error!.includes("not found")); + }); + + it("marks a decision superseded before moving it", () => { + initDecisionStore(ROOT); + const older = addDecision(ROOT, { + slug: toSlug("Old rule"), title: "Old rule", decision: "d", reasoning: "r", + date: "2026-01-01", source: "manual", sessionId: null, enforce: "required", + }); + const newer = addDecision(ROOT, { + slug: toSlug("New rule"), title: "New rule", decision: "d", reasoning: "r", + date: "2026-02-01", source: "manual", sessionId: null, enforce: "required", + }); + + const r = archiveDecision(ROOT, older.id, "covered by the newer rule", newer.id); + assert.equal(r.ok, true); + const archived = readFileSync(r.archivedTo!, "utf-8"); + assert.ok(archived.includes("status: superseded")); + assert.ok(archived.includes(`supersededBy: ${newer.id}`)); + assert.equal(getDecision(ROOT, older.id), null); + assert.ok(getDecision(ROOT, newer.id)); + }); + + it("records a revocation when nothing replaces the decision", () => { + initDecisionStore(ROOT); + const d = addDecision(ROOT, { + slug: toSlug("Obsolete rule"), title: "Obsolete rule", decision: "d", reasoning: "r", + date: "2026-01-01", source: "manual", sessionId: null, enforce: "advisory", + }); + const r = archiveDecision(ROOT, d.id, "the subsystem was deleted in PR #99"); + assert.equal(r.ok, true); + const archived = readFileSync(r.archivedTo!, "utf-8"); + assert.ok(archived.includes("status: revoked")); + assert.ok(archived.includes("revokedReason: the subsystem was deleted in PR #99")); + }); + + it("refuses a supersededBy that does not resolve", () => { + initDecisionStore(ROOT); + const d = addDecision(ROOT, { + slug: toSlug("Some rule"), title: "Some rule", decision: "d", reasoning: "r", + date: "2026-01-01", source: "manual", sessionId: null, enforce: null, + }); + const r = archiveDecision(ROOT, d.id, "x", "D-999"); + assert.equal(r.ok, false); + assert.ok(r.error!.includes("D-999")); + // The decision must still be live — a refused archival changes nothing. + assert.ok(getDecision(ROOT, d.id)); + }); + + it("never overwrites inside the archive", () => { + initMemoryStore(ROOT); + for (const desc of ["first", "second"]) { + saveMemory(ROOT, { + slug: "recurring", type: "pattern", title: "Recurring", description: desc, + body: "", keywords: [], source: "manual", sessionId: null, date: "2026-01-01", + }); + const r = archiveMemory(ROOT, "recurring", "cleanup"); + assert.equal(r.ok, true); + } + const dir = join(ROOT, ".axme-code", "archive", "memory", "patterns"); + assert.equal(readdirSync(dir).length, 2, readdirSync(dir).join(",")); + }); +}); + +// --- audit-kb reporting --- + +describe("audit-kb report", () => { + const base = { + decisionsBefore: 87, decisionsAfter: 87, memoriesBefore: 126, memoriesAfter: 126, + compacted: 0, removed: 0, added: 0, + loadedBytesBefore: 120_000, loadedBytesAfter: 120_000, + overlongAfter: 40, backupPath: "/tmp/kb_backup.tar.gz", dryRun: false, + costUsd: 0.42, durationMs: 240_000, agentSummary: "I analysed everything and concluded...", + }; + + it("calls a zero-change run a FAILED pass, not a success", () => { + // The exact scenario this rewrite exists for: four minutes of correct + // analysis, zero bytes written, exit 0, and a user who believes the + // base was compacted. + const out = formatKbAuditReport(base); + assert.ok(out.includes("NO CHANGES WRITTEN")); + assert.ok(out.includes("FAILED pass")); + assert.ok(out.includes("/tmp/kb_backup.tar.gz")); + }); + + it("reports the measured before/after, not the agent's claims", () => { + const out = formatKbAuditReport({ + ...base, memoriesAfter: 90, compacted: 60, removed: 36, + loadedBytesAfter: 60_000, overlongAfter: 2, + }); + assert.ok(out.includes("126 → 90")); + assert.ok(out.includes("60 entries now load less text")); + assert.ok(out.includes("36 entries left the live store")); + assert.ok(out.includes("-50%")); + assert.ok(out.includes("Undo:")); + assert.ok(!out.includes("NO CHANGES WRITTEN")); + }); + + it("never claims a write in dry-run mode", () => { + const out = formatKbAuditReport({ ...base, dryRun: true, backupPath: null }); + assert.ok(out.includes("DRY RUN")); + assert.ok(out.includes("nothing was written")); + assert.ok(!out.includes("FAILED pass")); + assert.ok(!out.includes("Undo:")); + }); +}); diff --git a/test/memory.test.ts b/test/memory.test.ts index 38da68a..dc61191 100644 --- a/test/memory.test.ts +++ b/test/memory.test.ts @@ -161,16 +161,57 @@ describe("memory store", () => { assert.equal(b.title, "Title B"); }); - it("same slug overwrites", () => { - saveMemory(ROOT, mem("overwrite", "feedback", "Original")); - saveMemory(ROOT, { ...mem("overwrite", "feedback", "Updated"), description: "New description" }); + it("same title + same slug overwrites in place (this is how an entry is revised)", () => { + saveMemory(ROOT, { ...mem("overwrite", "feedback", "Same title"), description: "Original description" }); + saveMemory(ROOT, { ...mem("overwrite", "feedback", "Same title"), description: "New description" }); const loaded = getMemory(ROOT, "overwrite"); assert.ok(loaded); - assert.equal(loaded.title, "Updated"); assert.equal(loaded.description, "New description"); - // Should still be only one memory with this slug - const all = listMemories(ROOT, "feedback"); - const matches = all.filter(m => m.slug === "overwrite"); + const matches = listMemories(ROOT, "feedback").filter(m => m.slug === "overwrite"); assert.equal(matches.length, 1); }); + + it("different title on an occupied slug is suffixed, not silently overwritten", () => { + // The defect this guards: two distinct memories collapsing onto one + // filename destroyed the first one with no signal to anybody. + saveMemory(ROOT, { ...mem("collide", "feedback", "First rule"), description: "First description" }); + const outcome = saveMemory(ROOT, { ...mem("collide", "feedback", "Second rule"), description: "Second description" }); + + assert.notEqual(outcome.slug, "collide"); + assert.equal(outcome.collisionWith, "First rule"); + + // Both survive and are readable back. + const first = getMemory(ROOT, "collide"); + assert.ok(first); + assert.equal(first.title, "First rule"); + const second = getMemory(ROOT, outcome.slug); + assert.ok(second); + assert.equal(second.title, "Second rule"); + }); + + it("non-latin titles get distinct transliterated slugs, never a bare .md", () => { + // Regression: a Cyrillic-only title reduced to the empty slug, landed as + // the dotfile ".md", and the next such title overwrote it. + const a = saveMemory(ROOT, { ...mem(toMemorySlug("Перекат даты"), "pattern", "Перекат даты"), description: "one" }); + const b = saveMemory(ROOT, { ...mem(toMemorySlug("Ловушка watchdog"), "pattern", "Ловушка watchdog"), description: "two" }); + + assert.equal(a.slug, "perekat-daty"); + assert.equal(b.slug, "lovushka-watchdog"); + assert.notEqual(a.slug, b.slug); + + const patterns = listMemories(ROOT, "pattern").map(m => m.slug); + assert.ok(patterns.includes("perekat-daty")); + assert.ok(patterns.includes("lovushka-watchdog")); + }); + + it("strips a leaked tool-call frame instead of persisting it", () => { + const outcome = saveMemory(ROOT, { + ...mem("leaky", "feedback", "Leaky record"), + description: 'Watchdog flaps on date rollover.\n["bf_live"]', + }); + assert.ok(outcome.cleanedFields.includes("description")); + const loaded = getMemory(ROOT, outcome.slug); + assert.ok(loaded); + assert.equal(loaded.description, "Watchdog flaps on date rollover."); + }); }); From 7ee13292b41bc7e71b4afbd35965bf94a23d9a60 Mon Sep 17 00:00:00 2001 From: geobelsky Date: Tue, 18 Aug 2026 13:22:15 +0000 Subject: [PATCH 2/3] fix(kb-doctor): leaked-markup repair must not delete the deferred section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validating the repair against the two affected records in axme-code's own knowledge base showed the leak sits at the END of the description, with `## Details` directly below it. `stripLeakedMarkup` cuts to the end of the string — correct for a single field value, where everything past the frame belongs to a different argument, but catastrophic applied to a whole record: it would have destroyed the deferred layer this release exists to protect. Record-level repair now truncates the offending line at the tag, drops only the frame-only lines trailing it, and keeps everything from the first real line onward. Field-level stripping on the save path is unchanged. Verified against a copy of the real damaged record: markup gone, description intact, `## Details` preserved. Three new tests. Tests: 660/660 (one pre-existing flaky E2E excluded). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 ++- src/storage/kb-doctor.ts | 12 ++++++++---- src/utils/sanitize.ts | 35 +++++++++++++++++++++++++++++++++++ test/kb-hygiene.test.ts | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbf3ca2..a3abef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Knowledge-base hygiene release. A full manual compaction of a production knowled - **`audit-kb` could exit 0 having written nothing.** A real run analysed 109 decisions correctly for four minutes, reached a conclusion, wrote zero bytes, and reported success; the result counters were `resultText.match(/supersed/gi).length`, i.e. word-counting the agent's prose. The command now snapshots every entry's loaded-layer size before and after the agent runs and reports the measured diff. A pass that changed nothing prints `NO CHANGES WRITTEN`, explains that this is a failed pass rather than a clean base, and exits 2. - **First page of paginated output could be empty.** `paginateSections` never split a section larger than the page limit, so a caller passing `["## Project Memories", <60KB block>]` produced a page 1 containing only the heading with all content on page 2. Oversized sections are now split along line boundaries; no content is lost or truncated. - **Leaked tool-call markup was persisted verbatim.** Three records ended with `[...]` — a malformed client emission gluing one argument's XML frame onto another's text. Storage now strips everything from the first stray frame tag onward on write, and reports which fields it cleaned so the agent can verify what landed. +- **Repairing leaked markup could delete the deferred section.** Found while validating the repair against the two affected records in axme-code's own base: the leak sits at the *end* of the description, with `## Details` directly below it, so a cut-to-everything-after-the-tag repair would have destroyed the very layer this release exists to protect. Field-level stripping still cuts to the end (a single argument value has no legitimate tail), but record-level repair now truncates the offending line, drops only the frame-only lines that follow, and keeps everything from the first real line onward. - **Frontmatter rewrites could delete the following field.** The key-replacement regex used `\s*`, which matches newlines, so rewriting an empty `slug:` consumed the `type:` line under it and made the record unparseable. Now matched with horizontal whitespace only. ### Added @@ -23,7 +24,7 @@ Knowledge-base hygiene release. A full manual compaction of a production knowled - **Automatic backup before `audit-kb` applies.** `.axme-code/` is gitignored by design (D-026), so a pass that rewrites every file in the base had no safety net at all. A tarball is written to `.axme-code-backups/` first, and the audit aborts if it cannot be created. The undo command is printed with the results. - **Storage self-repair at session start.** `axme_context` runs the mechanical half of kb-doctor on every call and reports what it fixed. Waiting for a user to run a repair command is too late for the empty-slug defect: by then the second write has already destroyed the first. - **Knowledge-base hygiene reporting in `axme_context`.** Past a configurable threshold, one block reports the entry count with the compaction command, and separately reports how many entries overrun the catalog budget — two different problems with two different fixes. -- **Regression coverage**: 42 new tests plus a `self-test` check that round-trips two non-Latin titles and asserts both are readable back under distinct filenames. +- **Regression coverage**: 45 new tests plus a `self-test` check that round-trips two non-Latin titles and asserts both are readable back under distinct filenames. ### Changed diff --git a/src/storage/kb-doctor.ts b/src/storage/kb-doctor.ts index 65bfe2e..63088df 100644 --- a/src/storage/kb-doctor.ts +++ b/src/storage/kb-doctor.ts @@ -30,7 +30,7 @@ import { readConfig } from "./config.js"; import { listMemories } from "./memory.js"; import { listDecisions } from "./decisions.js"; import { makeSlug, isDegenerateSlug } from "../utils/slug.js"; -import { hasLeakedMarkup, stripLeakedMarkup } from "../utils/sanitize.js"; +import { hasLeakedMarkup, stripLeakedMarkupFromRecord } from "../utils/sanitize.js"; import { AXME_CODE_DIR } from "../types.js"; export type DefectKind = @@ -283,13 +283,17 @@ export function loadedLayer(raw: string, stopHeading: string): string { return t.trim(); } -/** Strip a leaked tool-call frame from the body of a stored file. */ +/** + * Strip a leaked tool-call frame from a stored file, preserving everything + * after it — notably the `## Details` / `## Reasoning` section, which in the + * records observed in the wild sits directly below the leak. + */ function stripLeakedMarkupFromFile(raw: string): string { const fenceEnd = raw.startsWith("---\n") ? raw.indexOf("\n---\n", 4) : -1; - if (fenceEnd < 0) return stripLeakedMarkup(raw).text + "\n"; + if (fenceEnd < 0) return stripLeakedMarkupFromRecord(raw).text; const head = raw.slice(0, fenceEnd + 5); const body = raw.slice(fenceEnd + 5); - return head + stripLeakedMarkup(body).text + "\n"; + return head + stripLeakedMarkupFromRecord(body).text; } function normalizeTitle(title: string): string { diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts index 9f5569c..2c869cd 100644 --- a/src/utils/sanitize.ts +++ b/src/utils/sanitize.ts @@ -46,6 +46,41 @@ export function hasLeakedMarkup(text: string): boolean { return !!text && LEAK_START.test(text); } +/** A line that is nothing but tool-call frame syntax, safe to drop whole. */ +const FRAME_LINE = /^\s*<\/?(?:antml:)?(?:parameter|invoke|function_calls|description|body|decision|reasoning|title)\b[^\n]*$/i; + +/** + * Remove a leaked frame from a STORED RECORD, keeping the content after it. + * + * This is deliberately not `stripLeakedMarkup`. That one cuts to the end of + * the string, which is right for a single field value — everything past the + * frame belongs to a different argument. Applied to a whole file it would be + * catastrophic: in the records observed in the wild the leak sits at the end + * of the description and is followed by the `## Details` section, so a + * cut-to-end repair would delete the deferred layer it was meant to protect. + * + * So: truncate the offending line at the tag, drop the frame-only lines that + * follow it, and keep everything from the first real line onward. + */ +export function stripLeakedMarkupFromRecord(text: string): SanitizeResult { + const lines = text.split("\n"); + const start = lines.findIndex(l => LEAK_START.test(l)); + if (start < 0) return { text, changed: false }; + + const m = LEAK_START.exec(lines[start])!; + const head = lines[start].slice(0, m.index).replace(/\s+$/, ""); + + // Consume the frame lines that trail the leak, and stop at the first line + // that carries real content — a heading, a paragraph, or a blank line + // separating sections. + let end = start + 1; + while (end < lines.length && FRAME_LINE.test(lines[end])) end++; + + const kept = [...lines.slice(0, start)]; + if (head) kept.push(head); + return { text: [...kept, ...lines.slice(end)].join("\n"), changed: true }; +} + /** * Sanitize every text field of a record, reporting which fields were cut. * Field order in the result is the caller's; only present fields are visited. diff --git a/test/kb-hygiene.test.ts b/test/kb-hygiene.test.ts index 1a2ab7a..54c9ef6 100644 --- a/test/kb-hygiene.test.ts +++ b/test/kb-hygiene.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { makeSlug, transliterate, isDegenerateSlug } from "../src/utils/slug.js"; -import { stripLeakedMarkup, hasLeakedMarkup, sanitizeFields } from "../src/utils/sanitize.js"; +import { stripLeakedMarkup, stripLeakedMarkupFromRecord, hasLeakedMarkup, sanitizeFields } from "../src/utils/sanitize.js"; import { paginateSections } from "../src/utils/pagination.js"; import { runKbDoctor, loadedLayer, setFrontmatterValue, frontmatterValue } from "../src/storage/kb-doctor.js"; import { archiveMemory, archiveDecision } from "../src/storage/archive.js"; @@ -90,6 +90,21 @@ describe("leaked tool-call markup", () => { assert.equal(hasLeakedMarkup("plain text"), false); }); + it("record-level stripping keeps content after the frame", () => { + const record = 'Rule text.\n["a"]\n\n\n## Details\n\nkept'; + const r = stripLeakedMarkupFromRecord(record); + assert.equal(r.changed, true); + assert.ok(r.text.includes("Rule text.")); + assert.ok(r.text.includes("## Details")); + assert.ok(r.text.includes("kept")); + assert.ok(!r.text.includes(" { + const r = stripLeakedMarkup('Rule text.\n["a"]'); + assert.equal(r.text, "Rule text."); + }); + it("reports which fields it cleaned", () => { const { record, cleaned } = sanitizeFields( { title: "fine", description: LEAKED, body: "also fine" }, @@ -307,6 +322,26 @@ describe("kb-doctor", () => { assert.ok(after.includes("Watchdog flaps.")); }); + it("preserves the deferred section below a leaked frame", () => { + // The shape found in the wild: the leak sits at the END of the + // description, with "## Details" directly under it. A cut-to-end repair + // would delete the very layer this release exists to protect. + const p = writeMemoryFile("leaky-with-details.md", + "slug: leaky-with-details\ntype: pattern\ntitle: Leaky\nsource: session\ndate: 2026-01-01\nkeywords: \nsessionId: ", + '# Leaky\n\nRun npm whoami before npm publish.\n["release", "npm"]\n\n\n## Details\n\nv0.2.7 took 5 retries; npm auth was missing.'); + + runKbDoctor(ROOT, { fix: true }); + const after = readFileSync(p, "utf-8"); + + assert.ok(!after.includes(""), after); + assert.ok(!after.includes(""), after); + assert.ok(after.includes("Run npm whoami before npm publish."), after); + // The whole point: the deferred layer survives. + assert.ok(after.includes("## Details"), after); + assert.ok(after.includes("v0.2.7 took 5 retries"), after); + }); + it("flags a description that overruns the catalog budget, but does not rewrite it", () => { writeMemoryFile("long.md", "slug: long\ntype: pattern\ntitle: Long\nsource: session\ndate: 2026-01-01\nkeywords: \nsessionId: ", `# Long\n\n${"x".repeat(600)}`); From d3c7e5de479848f1b94ea7b2c6dc346dfd7489ee Mon Sep 17 00:00:00 2001 From: geobelsky Date: Wed, 19 Aug 2026 08:01:10 +0000 Subject: [PATCH 3/3] feat(kb): apply the format contract to the AUTOMATIC write paths too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The largest remaining gap. The guidance had been fixed in the CLAUDE.md template and the MCP tool descriptions — i.e. the paths a human-directed agent uses — while the three paths that write MOST entries were untouched: - session-auditor.ts: the JSON schema did not include `body` at all, so the deferred layer was literally unreachable from the automatic path. - memory-extractor.ts: told the model `body: Keep short or omit — description must carry all meaning`. That is the exact inversion of the contract, and a direct contributor to 91% of one base's memories sitting entirely in the layer paid for by every session. - axme_begin_close checklist: no negative list, no format rule. All three now state the selection test, the two-level format quoting the project's real catalog.excerpt_chars, and the meta-decision prohibition. Shared text lives in src/storage/kb-format.ts so five surfaces cannot drift apart again. Also closes three spec items that were still open: - axme_merge_memories: agent composes the merged text, tool rewrites the survivor and archives the rest. Refuses if any source is missing. - Meta-decisions refused at code level, not just discouraged. Matched on the title only, so a decision citing another in its body still saves. - KB-audit counter surfaced in axme_context. It was written to the stderr of a detached background worker, which nobody reads. Tests: 666/666 (+5). One pre-existing flaky E2E excluded. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++- src/agents/memory-extractor.ts | 23 ++++++-- src/agents/session-auditor.ts | 36 ++++++++++--- src/server.ts | 76 ++++++++++++++++++++++++++- src/storage/archive.ts | 47 ++++++++++++++++- src/storage/kb-format.ts | 95 ++++++++++++++++++++++++++++++++++ src/tools/context.ts | 20 ++++++- src/tools/decision-tools.ts | 26 ++++++++++ test/kb-hygiene.test.ts | 92 +++++++++++++++++++++++++++++++- 9 files changed, 402 insertions(+), 19 deletions(-) create mode 100644 src/storage/kb-format.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a3abef3..cef97c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,8 @@ Knowledge-base hygiene release. A full manual compaction of a production knowled - **Automatic backup before `audit-kb` applies.** `.axme-code/` is gitignored by design (D-026), so a pass that rewrites every file in the base had no safety net at all. A tarball is written to `.axme-code-backups/` first, and the audit aborts if it cannot be created. The undo command is printed with the results. - **Storage self-repair at session start.** `axme_context` runs the mechanical half of kb-doctor on every call and reports what it fixed. Waiting for a user to run a repair command is too late for the empty-slug defect: by then the second write has already destroyed the first. - **Knowledge-base hygiene reporting in `axme_context`.** Past a configurable threshold, one block reports the entry count with the compaction command, and separately reports how many entries overrun the catalog budget — two different problems with two different fixes. -- **Regression coverage**: 45 new tests plus a `self-test` check that round-trips two non-Latin titles and asserts both are readable back under distinct filenames. +- **`axme_merge_memories`** — fold duplicates into one. The agent composes the merged text (which detail from each loser is worth keeping is judgment); the tool rewrites the survivor and archives the rest. Refuses outright if any source slug is missing, since a partial merge leaves the survivor claiming content that was never folded in. +- **Regression coverage**: 50 new tests plus a `self-test` check that round-trips two non-Latin titles and asserts both are readable back under distinct filenames. ### Changed @@ -33,6 +34,9 @@ Knowledge-base hygiene release. A full manual compaction of a production knowled - **Save tools now return advisory notes instead of accepting anything silently.** An overlong `description` gets the concrete numbers ("1180 chars; the catalog renders 200; the last 980 will not be visible") and where the tail belongs. Near-duplicate titles are reported as merge candidates before a second half-record is created. `axme_save_decision` now says when title-dedup returned an existing decision unchanged, which previously read as a successful write. These are notes, never rejections: the write always lands, because a refused save loses the payload the agent just composed. - **The search-mode catalog marks truncated entries.** A cut line ends in `…[TRUNCATED]` and a header states how many of the entries are affected. Previously a truncated line was indistinguishable from a complete one, so an agent could not tell which entries it actually understood — and in practice fetched neither. The absence of the marker is now a guarantee that the entry is complete as shown, which is what makes writing to the budget worth doing: at that point search mode and full mode carry the same content. - **`audit-kb`'s prompt is the full compaction procedure** — classify into keep / compact / merge / archive, with the explicit negative list, "when in doubt, keep", "do not touch entries modified in the last 2 hours" (another session may be writing), and "do not rewrite history: keep both a retraction and what it retracts". It reindexes after applying, since compaction rewrites the text the embeddings index was built from and archival removes entries it still points at. +- **The automatic write paths now carry the same contract as the manual ones.** This was the largest remaining gap: the guidance had been fixed in the CLAUDE.md template and the MCP tool descriptions, while the three paths that write *most* entries were untouched. The session auditor's JSON schema did not even include `body`, so the deferred layer was unreachable from the automatic path; the memory extractor's prompt said `body: keep short or omit — description must carry all meaning`, the exact inversion of the contract; and the `axme_begin_close` checklist had no negative list and no format rule. All three now state the selection test, the two-level format quoting the project's real `catalog.excerpt_chars`, and (for decisions) the prohibition on meta-decisions. The shared text lives in `src/storage/kb-format.ts` so the five surfaces cannot drift apart again. +- **Meta-decisions are refused at the code level, not just discouraged.** A title that is nothing but a pointer between two ids ("D-020 absorbed by D-036") is rejected with a message naming `axme_archive_decision(superseded_by:)` as the operation that actually makes the edit. Matched on the title only — a decision that merely cites another in its body is legitimate and still saves. +- **The KB-audit counter is surfaced where someone reads it.** It was incremented after every session audit and its recommendation written to the stderr of a *detached background worker*, i.e. nowhere. It now appears in `axme_context` alongside the size and format warnings. - `axme_save_decision` now explicitly instructs against meta-decisions ("D-020 absorbed by D-036"). Those describe edits to other decisions rather than decisions; `axme_archive_decision`'s `superseded_by` argument makes the edit instead. Nine such records were found in one base. ## [0.6.3] - 2026-06-25 diff --git a/src/agents/memory-extractor.ts b/src/agents/memory-extractor.ts index 987dc5a..3d595d7 100644 --- a/src/agents/memory-extractor.ts +++ b/src/agents/memory-extractor.ts @@ -12,6 +12,8 @@ import type { Memory } from "../types.js"; import { extractCostFromResult, zeroCost, type CostInfo } from "../utils/cost-extractor.js"; import { toMemorySlug } from "../storage/memory.js"; +import { readConfig } from "../storage/config.js"; +import { twoLevelFormatRule, SELECTION_TEST } from "../storage/kb-format.js"; import { buildAgentEnv, claudePathForSdk } from "../utils/agent-options.js"; import { createAgentSdk } from "../utils/agent-sdk.js"; @@ -21,7 +23,14 @@ export interface MemoryExtractionResult { durationMs: number; } -const EXTRACTION_PROMPT = `You are a learning system that extracts memories from coding sessions. +/** + * Builder rather than a constant so the format contract quotes THIS + * project's catalog budget. The previous constant told the extractor + * "body: keep short or omit — description must carry all meaning", which is + * the exact inversion of the contract and a direct contributor to 91% of one + * base's memories sitting entirely in the layer paid for by every session. + */ +const buildExtractionPrompt = (excerptChars: number): string => `You are a learning system that extracts memories from coding sessions. Analyze the session transcript below and extract two types of memories: @@ -35,16 +44,20 @@ Analyze the session transcript below and extract two types of memories: - Efficient workflows that saved time - Non-obvious solutions that worked +${twoLevelFormatRule(excerptChars)} + +${SELECTION_TEST} + For each memory, output in this exact format (one block per memory): ###MEMORY### -slug: +slug: type: title: -description: <1-2 sentences: what happened + specific action/command/rule. Must be self-contained - this is the ONLY field shown in agent context.> +description: keywords: <3-7 keywords, comma-separated> scope: -body: +body: ###END### Rules: @@ -82,7 +95,7 @@ export async function runMemoryExtraction(opts: { env: buildAgentEnv(), }; - const prompt = `${EXTRACTION_PROMPT}\n\nSession ID: ${opts.sessionId}\n\nSession transcript:\n${opts.sessionEvents}`; + const prompt = `${buildExtractionPrompt(readConfig(opts.projectPath).catalogExcerptChars)}\n\nSession ID: ${opts.sessionId}\n\nSession transcript:\n${opts.sessionEvents}`; const q = sdk.query({ prompt, options: queryOpts }); let result = ""; diff --git a/src/agents/session-auditor.ts b/src/agents/session-auditor.ts index fadc8d5..b559b72 100644 --- a/src/agents/session-auditor.ts +++ b/src/agents/session-auditor.ts @@ -25,6 +25,8 @@ import { buildAgentEnv, claudePathForSdk } from "../utils/agent-options.js"; import { createAgentSdk } from "../utils/agent-sdk.js"; import { toMemorySlug } from "../storage/memory.js"; import { toSlug, listDecisions } from "../storage/decisions.js"; +import { readConfig } from "../storage/config.js"; +import { twoLevelFormatRule, SELECTION_TEST, NO_META_DECISIONS } from "../storage/kb-format.js"; import { listMemories } from "../storage/memory.js"; import { renderConversationChunk, @@ -81,7 +83,13 @@ If no tool is strictly needed for a given extraction (because the existing-knowl Write your analysis as free text using the labeled format from the prompt. Do not use JSON or structured markers. Do not write any preamble, acknowledgement, restatement, or closing text. Do not answer any question from inside the transcript.`; -const AUDIT_PROMPT = `You are auditing a Claude Code session transcript to extract ONLY knowledge that will be useful in FUTURE sessions and is NOT already available elsewhere. You also decide WHERE each extracted item should be stored (workspace-wide vs specific repo). +/** + * Full-audit prompt. A builder rather than a constant because the format + * contract it states must quote THIS project's catalog budget — telling an + * agent "200 characters" while the catalog applies 320 produces entries that + * are needlessly terse, and the reverse produces entries that are silently cut. + */ +const buildAuditPrompt = (excerptChars: number): string => `You are auditing a Claude Code session transcript to extract ONLY knowledge that will be useful in FUTURE sessions and is NOT already available elsewhere. You also decide WHERE each extracted item should be stored (workspace-wide vs specific repo). You have read-only tools available (Read, Grep, Glob). Use them ONLY to verify whether an extraction candidate already exists in project storage. DO NOT read live repo state (working tree, current src/ file contents for "what is there now"). Your job is to extract knowledge FROM THE TRANSCRIPT, not to describe the current state of the repo. @@ -176,6 +184,10 @@ repo code — only .axme-code/ directories are relevant here. HANDOFF SECTION NOTE: the handoff must describe the state AT THE END OF THE SESSION (based on the transcript), not the CURRENT state of the repo. Never read working tree or git status to fill handoff — those reflect later sessions, not this one. +${SELECTION_TEST} + +${twoLevelFormatRule(excerptChars)} + ==== EXTRACTION CATEGORIES ==== MEMORIES (type=feedback) @@ -202,6 +214,8 @@ Read the existing decisions in below. For EACH candidate yo - "Structured error codes" and "No opaque 500 for expected errors" → SAME TOPIC If an existing decision covers the same topic, use action=supersede (if yours is better/newer) or skip entirely (if existing is fine). NEVER create a second decision on the same topic. +${NO_META_DECISIONS} + REJECT: - "We added feature X because Y" — feature is in the code - "Use X instead of Y" — both visible in diff @@ -320,7 +334,7 @@ REMEMBER: Use your tools to verify every candidate before extracting. "None." is * during the live session with full context. This prompt only catches items * the agent missed. Handoff is skipped (agent already wrote it). */ -const VERIFY_ONLY_AUDIT_PROMPT = `You are auditing a Claude Code session where the AGENT ALREADY extracted knowledge during the session close process. The agent had full conversation context and saved memories, decisions, and safety rules via MCP tools. Your job is ONLY to catch items the agent MISSED. +const buildVerifyOnlyPrompt = (excerptChars: number): string => `You are auditing a Claude Code session where the AGENT ALREADY extracted knowledge during the session close process. The agent had full conversation context and saved memories, decisions, and safety rules via MCP tools. Your job is ONLY to catch items the agent MISSED. IMPORTANT: the agent's extractions are ALREADY in storage. Most categories should be EMPTY in your output. Only extract genuinely missed items. @@ -510,12 +524,15 @@ export async function runSessionAudit(opts: { EXISTING_CONTEXT_MAX_CHARS, ); const workspaceContext = buildWorkspaceContext(opts.sessionOrigin, opts.filesChanged, opts.workspaceInfo); + // Read once and reuse across chunks: the prompt quotes this project's + // catalog budget, and every chunk must state the same number. + const excerptChars = readConfig(opts.sessionOrigin).catalogExcerptChars; // Decide the chunking strategy based on which input the caller provided. let chunks: string[]; if (opts.sessionTurns && opts.sessionTurns.length > 0) { // Preferred path: we have structured turns, so we can chunk at turn boundaries. - const activePromptForBudget = opts.agentClosed ? VERIFY_ONLY_AUDIT_PROMPT : AUDIT_PROMPT; + const activePromptForBudget = opts.agentClosed ? buildVerifyOnlyPrompt(excerptChars) : buildAuditPrompt(excerptChars); const fixedOverhead = activePromptForBudget.length + workspaceContext.length + @@ -566,7 +583,7 @@ export async function runSessionAudit(opts: { mergedDecisions, mergedSafetyRules, ); - const activePrompt = opts.agentClosed ? VERIFY_ONLY_AUDIT_PROMPT : AUDIT_PROMPT; + const activePrompt = opts.agentClosed ? buildVerifyOnlyPrompt(excerptChars) : buildAuditPrompt(excerptChars); const chunkResult = await runSingleAuditCall({ sessionId: opts.sessionId, sessionOrigin: opts.sessionOrigin, @@ -878,6 +895,7 @@ export async function formatAuditResult( sessionOrigin: string, ): Promise<{ json: any; cost?: CostInfo }> { const sdk = await createAgentSdk("auditor", { cwd: sessionOrigin }); + const excerptChars = readConfig(sessionOrigin).catalogExcerptChars; const formatPrompt = `You are a formatting assistant. Convert the following free-text audit analysis into a JSON object. @@ -886,13 +904,15 @@ OUTPUT RULES: - Preserve all information from the analysis exactly. - Use empty arrays [] for sections with no candidates. - All text must be in English except session_summary which keeps the original language. -- Every memory MUST have: type, title, description, scope, keywords -- Every decision MUST have: action, title, decision, enforce, scope +- Every memory MUST have: type, title, description, body, scope, keywords +- Every decision MUST have: action, title, decision, reasoning, enforce, scope +- description / decision are the LOADED layer and MUST fit ${excerptChars} characters. Anything + longer belongs in body / reasoning, which cost nothing at session start. JSON SCHEMA: { - "memories": [{"type":"feedback|pattern","title":"max 80 chars","description":"1-2 sentences","keywords":["word"],"scope":"repo-name|all"}], - "decisions": [{"action":"new|supersede|amend","title":"max 80 chars","decision":"2-3 sentences","enforce":"required|advisory|none","scope":"repo-name|all","supersedes":"D-NNN","amends":"D-NNN"}], + "memories": [{"type":"feedback|pattern","title":"max 80 chars","description":"LOADED LAYER: rule + one concrete fact, <=${excerptChars} chars","body":"DEFERRED LAYER: numbers, paths, line refs, measurements. Not loaded at session start. Empty string if genuinely nothing.","keywords":["word"],"scope":"repo-name|all"}], + "decisions": [{"action":"new|supersede|amend","title":"max 80 chars","decision":"LOADED LAYER: what + why, <=${excerptChars} chars","reasoning":"DEFERRED LAYER: alternatives, measurements, paths, history. Not loaded at session start.","enforce":"required|advisory|none","scope":"repo-name|all","supersedes":"D-NNN","amends":"D-NNN"}], "safety": [{"rule_type":"bash_deny|bash_allow|fs_deny|git_protected_branch","value":"command/path","scope":"repo-name|all"}], "oracle_changes": "YES reason|NO", "questions": [{"question":"text","context":"text"}], diff --git a/src/server.ts b/src/server.ts index 6f26ddb..92f814c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -627,7 +627,16 @@ server.tool( async ({ project_path, title, decision, reasoning, enforce, scope }) => { const resolved = ppWithScope(project_path, scope); - const result = saveDecisionTool(resolved, { title, decision, reasoning, enforce, scope }); + let result; + try { + result = saveDecisionTool(resolved, { title, decision, reasoning, enforce, scope }); + } catch (err: any) { + const { MetaDecisionRejected } = await import("./tools/decision-tools.js"); + if (err instanceof MetaDecisionRejected) { + return { content: [{ type: "text" as const, text: err.message }], isError: true }; + } + throw err; + } // See axme_save_memory above — same bootstrap reasoning. const { ensureOracleBootstrapped } = await import("./storage/oracle.js"); ensureOracleBootstrapped(resolved); @@ -703,6 +712,36 @@ server.tool( }, ); +// --- axme_merge_memories --- +server.tool( + "axme_merge_memories", + "Fold two or more memories that cover the same ground into one. YOU compose the merged text — read the " + + "candidates first with axme_get_memory, decide which unique detail from each is worth keeping, and pass the " + + "result. The tool rewrites the survivor and archives the others. Do NOT pass a concatenation of the originals: " + + "gluing descriptions together produces exactly the overlong entry the format contract exists to prevent — the " + + "merged description is still the rule plus one concrete fact, with the accumulated specifics in the body.", + { + project_path: z.string().optional().describe("Absolute path to the project root (defaults to server cwd)"), + into: z.string({ error: "into is REQUIRED — the slug of the memory that survives the merge." }).describe("Slug of the memory that survives"), + from: z.array(z.string(), { error: "from is REQUIRED — the slugs being folded in and archived." }).describe("Slugs to fold in and archive. All must exist; the merge is refused outright if any does not, since a partial merge leaves the survivor claiming content that was never folded in."), + description: z.string().optional().describe("Merged LOADED layer for the survivor. Omit to keep its current one."), + body: z.string().optional().describe("Merged DEFERRED layer ('## Details') for the survivor — where the specifics from every merged entry belong."), + }, + async ({ project_path, into, from, description, body }) => { + const resolved = pp(project_path); + const { mergeMemories } = await import("./storage/archive.js"); + const result = mergeMemories(resolved, into, from, { description, body }); + if (!result.ok) { + return { content: [{ type: "text" as const, text: `Merge failed: ${result.error}` }], isError: true }; + } + try { + const { removeEmbedding } = await import("./storage/embeddings.js"); + for (const slug of result.archived) await removeEmbedding(resolved, slug, "memory"); + } catch {} + return { content: [{ type: "text" as const, text: `Merged into ${result.slug}; archived ${result.archived.length}: ${result.archived.join(", ") || "(none)"}` }] }; + }, +); + // --- axme_kb_doctor --- server.tool( "axme_kb_doctor", @@ -998,6 +1037,11 @@ server.tool( return { content: [{ type: "text" as const, text: "No active AXME session found." }] }; } + // Quote this project's real catalog budget, not a hardcoded 200 — an + // agent told the wrong number writes entries that are either needlessly + // terse or silently cut. + const excerptChars = readConfig(pp(undefined)).catalogExcerptChars; + const checklist = [ `# Session Close Checklist (session ${sid.slice(0, 8)})`, "", @@ -1016,6 +1060,33 @@ server.tool( "- **Decisions** (policies user confirmed, architectural choices)", "- **Safety rules** (user mandated bash_deny, fs_deny, git_protected_branch, etc.)", "", + "### What NOT to extract — apply this filter to every candidate:", + "Would this help an agent a MONTH FROM NOW who was not part of this session? If the value is", + "in the numbers rather than in a rule, it is a document, not a memory.", + "", + "- **Session state and \"where we stopped\"** — that is the handoff (Step 2), not a memory. A", + " handoff saved as a memory is re-read by every future session forever.", + "- **Measurement results and verdict numbers** — a doc; a memory may carry one line pointing to it.", + "- **Research diaries** (\"day 3 of X\", \"wave 2\") — the conclusion may be a memory, the diary is not.", + "- **A one-off incident already fixed in the code** that yields no transferable rule.", + "- **Anything an existing entry covers** — extend that entry (save under its exact title) instead", + " of adding a second half-record.", + "- **Meta-decisions** (\"D-020 absorbed by D-036\") — that is an edit, not a decision. Use", + " action `supersede`, or `axme_archive_decision` with `superseded_by`.", + "", + `### Format — the two-level rule (budget: ${excerptChars} chars):`, + "Each entry has a layer loaded into EVERY future session and a layer that is not.", + "", + `- \`description\` (memory) / \`decision\` (decision) — LOADED every session. The rule plus one`, + ` concrete fact, at most ${excerptChars} characters. Past that it is cut from the session-start`, + " catalog and the remainder is invisible unless someone explicitly fetches it.", + "- `body` (memory) / `reasoning` (decision) — renders as `## Details` / `## Reasoning`, NOT loaded", + " at session start, returned in full by `axme_get_memory` / `axme_get_decision`. Put every number,", + " file path, line reference, threshold and measurement here. It costs nothing per session.", + "", + "Do NOT split an entry into several to meet the budget — per-entry overhead multiplies by count.", + "Cut DOWN into the deferred layer, never ACROSS into more records.", + "", "### Dedup & conflict check (MANDATORY for each item):", "Compare every candidate against what you already loaded via `axme_context`.", "If writing to a repo you haven't loaded yet, call `axme_context` for it first.", @@ -1027,6 +1098,9 @@ server.tool( "**Unclear contradiction**: ask the user which to keep before adding.", "**Outdated item found** (even unrelated to new ones): action `remove` with its slug/id.", "", + "Outside the close flow, retire entries with `axme_archive_memory` / `axme_archive_decision` —", + "they move the file to `.axme-code/archive/` with the reason stamped in. Never delete by hand.", + "", "## Step 2: Prepare Everything for `axme_finalize_close`", "", "Collect ALL data into a single `axme_finalize_close` call.", diff --git a/src/storage/archive.ts b/src/storage/archive.ts index c52ad45..cf2f306 100644 --- a/src/storage/archive.ts +++ b/src/storage/archive.ts @@ -25,9 +25,10 @@ import { readFileSync, readdirSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWrite, ensureDir, pathExists } from "./engine.js"; import { setFrontmatterValue } from "./kb-doctor.js"; -import { getMemory } from "./memory.js"; +import { getMemory, saveMemory } from "./memory.js"; import { getDecision, rebuildDecisionIndex } from "./decisions.js"; import { AXME_CODE_DIR } from "../types.js"; +import type { Memory } from "../types.js"; const ARCHIVE_DIR = "archive"; @@ -183,3 +184,47 @@ function findDecisionFile(dir: string, id: string): string | null { function oneLine(text: string): string { return (text || "").replace(/\s+/g, " ").trim().slice(0, 300); } + +/** + * Fold several memories into one. + * + * Split of responsibility: the AGENT composes the merged text, because + * deciding which unique detail from each loser is worth keeping is judgment + * that no heuristic does well. This function does only the file work — + * rewrite the survivor, archive the rest, keep everything reversible. + * + * `into` must survive with the caller's text rather than a concatenation: + * gluing four descriptions together produces exactly the overlong entry the + * format contract exists to prevent. + */ +export function mergeMemories( + projectPath: string, + into: string, + from: string[], + merged: { description?: string; body?: string }, +): { ok: boolean; error?: string; archived: string[]; slug?: string } { + const survivor = getMemory(projectPath, into); + if (!survivor) return { ok: false, error: `Memory "${into}" not found`, archived: [] }; + + const losers = from.filter(s => s !== into); + const missing = losers.filter(s => !getMemory(projectPath, s)); + if (missing.length > 0) { + // Refuse the whole operation rather than half-merge: a partial merge + // leaves the survivor claiming content that was never folded in. + return { ok: false, error: `Not found: ${missing.join(", ")}`, archived: [] }; + } + + const updated: Memory = { + ...survivor, + ...(merged.description !== undefined ? { description: merged.description } : {}), + ...(merged.body !== undefined ? { body: merged.body } : {}), + }; + const outcome = saveMemory(projectPath, updated); + + const archived: string[] = []; + for (const slug of losers) { + const r = archiveMemory(projectPath, slug, `merged into ${into}`); + if (r.ok) archived.push(slug); + } + return { ok: true, archived, slug: outcome.slug }; +} diff --git a/src/storage/kb-format.ts b/src/storage/kb-format.ts new file mode 100644 index 0000000..144fc15 --- /dev/null +++ b/src/storage/kb-format.ts @@ -0,0 +1,95 @@ +/** + * The canonical knowledge-base authoring contract, in one place. + * + * Why a shared module rather than a paragraph in each prompt: this guidance + * has to appear in five separate write paths — the MCP tool descriptions, + * the CLAUDE.md template, the Cursor rules, the session auditor, and the + * memory extractor. Duplicated prose drifts, and drift here is expensive: + * before v0.6.4 the extractor prompt actively contradicted the contract + * ("body: keep short or omit — description must carry all meaning"), which + * is precisely the instruction that put 91% of one base's memories into the + * layer that is paid for by every session. + * + * The markdown templates in cli.ts / cursor-writers.ts render for a human + * reading CLAUDE.md and keep their own phrasing; everything that is fed to + * an LLM as a prompt fragment comes from here. + */ + +/** + * The two-level format, addressed to an agent that is about to write an entry. + * + * @param excerptChars The project's catalog excerpt width (config + * `catalog.excerpt_chars`). Passed in rather than read here so the + * number an agent is told matches the number the catalog will apply. + */ +export function twoLevelFormatRule(excerptChars: number): string { + return `==== THE TWO-LEVEL FORMAT (applies to every memory and decision) ==== + +Each entry has a layer that is loaded into EVERY future session and a layer that is not: + + LOADED memory "description" / decision "decision" + Rendered into every session's starting context. This is the only part that costs. + Budget: ${excerptChars} characters. Past that it is CUT from the session-start + catalog and the remainder is invisible unless someone explicitly fetches it. + + DEFERRED memory "body" / decision "reasoning" + Rendered as "## Details" / "## Reasoning". NOT loaded at session start. + Returned in full by axme_get_memory(slug) / axme_get_decision(id). + Costs nothing per session, so use it freely. + +So: put the RULE plus ONE concrete fact in the loaded layer, and put every number, +file path, line reference, threshold, measurement and command output in the deferred +layer. Nothing is lost by moving detail down — it simply stops being paid for by the +sessions that never needed it. + +An entry whose loaded layer fits the budget renders COMPLETE in the catalog, which is +what makes a large knowledge base affordable at all. + +Do NOT split one entry into several single-fact entries to meet the budget. Per-entry +overhead (slug, title, catalog markup) is 60-100 characters and multiplies by count, so +splitting makes the base bigger while scattering facts that belong together. Cut DOWN +into the deferred layer, never ACROSS into more records.`; +} + +/** + * The selection test, addressed to an agent deciding whether to save at all. + * + * The negative list is the operative half. The template this replaces said + * only "error pattern or successful approach discovered -> save immediately", + * under which every positive research result qualifies; a month of that put + * 110 research diaries and 18 session handoffs into one base's memory. + */ +export const SELECTION_TEST = `==== WHAT BELONGS IN MEMORY (ask before every candidate) ==== + +Would this help an agent a MONTH FROM NOW who was not part of this investigation? +If the value is in the numbers rather than in a rule, it is a document, not a memory. + +SAVE: +- a rule or ruling from the user +- vendor / feed / API semantics that will not be re-derived (sign convention, error + codes, limits, cadence) +- a tool or language trap that will recur +- a closed direction, recorded so nobody reopens it +- a live production contract + +DO NOT SAVE: +- measurement results and verdict numbers — those belong in a doc; a memory may carry + at most one line pointing to it +- session state, handoffs, "where we stopped" — the handoff section already stores that, + and a handoff in memory is read by every future session forever +- research diaries ("day 3 of B-008", "wave 2 of the basketball run") +- a one-off incident whose fix is already in the code and that yields no transferable rule +- anything an existing entry already covers — extend that entry instead of adding a second`; + +/** + * Instruction against meta-decisions. + * + * One prior audit produced nine of these ("D-020 absorbed by D-036", + * "D-024: recorders now run as 9 systemd units"). They are edits to other + * decisions wearing the shape of decisions, and the edits had already been + * applied — so each record cost context to restate a fact already stored. + */ +export const NO_META_DECISIONS = `Do NOT emit a decision whose content is a statement ABOUT other decisions — +"D-020 absorbed by D-036", "D-024 updated to reflect the new topology". Those are edits, +not decisions: make the edit in the decisions themselves (action=supersede, or +axme_archive_decision with superseded_by) instead of recording that an edit happened.`; diff --git a/src/tools/context.ts b/src/tools/context.ts index 9b3877d..8dec5f7 100644 --- a/src/tools/context.ts +++ b/src/tools/context.ts @@ -10,6 +10,7 @@ import { decisionsContext, showDecisions, enforceableDecisionsContext, listDecis import { pathExists, readSafe } from "../storage/engine.js"; import { configExists, readConfig } from "../storage/config.js"; import { runKbDoctor, countOverlong } from "../storage/kb-doctor.js"; +import { readKbAuditCounter } from "../storage/kb-audit.js"; import { isRuntimeInstalled } from "../storage/embeddings.js"; import { join } from "node:path"; import { existsSync } from "node:fs"; @@ -324,14 +325,31 @@ function buildHygieneLine( overlong = { memories: 0, decisions: 0, total: 0, excerptChars: config.catalogExcerptChars }; } + // The audit counter is bumped after every session audit, but its + // recommendation was only ever written to the stderr of a DETACHED + // background worker — a stream nobody reads. Surfacing it here is the + // difference between the counter existing and the counter working. + let sessionsSinceAudit = 0; + try { + const counter = readKbAuditCounter(projectPath); + if (counter && counter.count >= 20) sessionsSinceAudit = counter.count; + } catch {} + const sizeProblem = total >= config.kbSizeWarnThreshold; // A tenth of the base overrunning is where the catalog stops being a // faithful summary; below that it is a rounding error not worth a warning. const formatProblem = overlong.total > 0 && overlong.total >= Math.max(5, Math.round(total * 0.1)); - if (!sizeProblem && !formatProblem) return null; + if (!sizeProblem && !formatProblem && !sessionsSinceAudit) return null; const lines = ["## Knowledge base hygiene", ""]; + if (sessionsSinceAudit) { + lines.push( + `**${sessionsSinceAudit} sessions** have been audited since the last knowledge-base compaction.`, + "", + ); + } + if (sizeProblem) { lines.push( `This base holds **${memCount} memories + ${decCount} decisions = ${total} entries** ` + diff --git a/src/tools/decision-tools.ts b/src/tools/decision-tools.ts index 66b14c5..a40ad73 100644 --- a/src/tools/decision-tools.ts +++ b/src/tools/decision-tools.ts @@ -23,11 +23,37 @@ export interface SaveDecisionResult { notes: string[]; } +/** + * Titles of the form "D-020 absorbed by D-036" / "D-024: superseded by D-030". + * + * Matched on the TITLE only, deliberately. A legitimate decision often cites + * another one in its body ("supersedes D-012, which assumed a single repo"), + * and rejecting on body text would block real records. A title that is + * nothing but a pointer between two ids is never a decision. + */ +const META_DECISION_TITLE = + /^\s*D-\d+\s*[:,-]?\s*(?:is\s+|was\s+|now\s+)?(?:absorbed|superseded|replaced|covered|merged|subsumed|folded)\s+(?:by|into|in)\s+D-\d+/i; + +export class MetaDecisionRejected extends Error {} + export function saveDecisionTool( projectPath: string, input: SaveDecisionInput, sessionId?: string, ): SaveDecisionResult { + if (META_DECISION_TITLE.test(input.title)) { + // One prior audit produced nine of these. They are edits to other + // decisions wearing the shape of decisions — and the edits had already + // been applied, so each record spent context restating a stored fact. + throw new MetaDecisionRejected( + `Refused: "${input.title}" is a meta-decision — a statement ABOUT decisions, not a decision. ` + + `The relationship belongs IN the decisions themselves: call ` + + `axme_archive_decision(id: "", reason: "...", superseded_by: ""). ` + + `That marks the old record status: superseded, points it at its replacement, and moves it to the archive — ` + + `all of which a separate record can only describe.`, + ); + } + const slug = toSlug(input.title); const today = new Date().toISOString().slice(0, 10); const config = readConfig(projectPath); diff --git a/test/kb-hygiene.test.ts b/test/kb-hygiene.test.ts index 54c9ef6..92eacac 100644 --- a/test/kb-hygiene.test.ts +++ b/test/kb-hygiene.test.ts @@ -8,12 +8,13 @@ import { makeSlug, transliterate, isDegenerateSlug } from "../src/utils/slug.js" import { stripLeakedMarkup, stripLeakedMarkupFromRecord, hasLeakedMarkup, sanitizeFields } from "../src/utils/sanitize.js"; import { paginateSections } from "../src/utils/pagination.js"; import { runKbDoctor, loadedLayer, setFrontmatterValue, frontmatterValue } from "../src/storage/kb-doctor.js"; -import { archiveMemory, archiveDecision } from "../src/storage/archive.js"; +import { archiveMemory, archiveDecision, mergeMemories } from "../src/storage/archive.js"; +import { saveDecisionTool, MetaDecisionRejected } from "../src/tools/decision-tools.js"; import { formatKbAuditReport } from "../src/agents/kb-auditor.js"; import { checkOverrun, findDuplicateCandidates } from "../src/storage/save-feedback.js"; import { readConfig, writeConfig } from "../src/storage/config.js"; import { initMemoryStore, saveMemory, toMemorySlug, getMemory } from "../src/storage/memory.js"; -import { initDecisionStore, addDecision, getDecision, toSlug } from "../src/storage/decisions.js"; +import { initDecisionStore, addDecision, getDecision, toSlug, listDecisions } from "../src/storage/decisions.js"; import { DEFAULT_PROJECT_CONFIG } from "../src/types.js"; let ROOT: string; @@ -487,3 +488,90 @@ describe("audit-kb report", () => { assert.ok(!out.includes("Undo:")); }); }); + +// --- Meta-decision guard --- + +describe("meta-decision guard", () => { + const input = (title: string) => ({ + title, decision: "d", reasoning: "r", enforce: "required" as const, + }); + + it("refuses a title that is only a pointer between two decision ids", () => { + initDecisionStore(ROOT); + for (const t of [ + "D-020 absorbed by D-036", + "D-092: superseded by D-100", + "D-7 is covered by D-12", + "D-024 merged into D-030", + ]) { + assert.throws(() => saveDecisionTool(ROOT, input(t)), MetaDecisionRejected, t); + } + assert.equal(listDecisions(ROOT).length, 0); + }); + + it("points the agent at the tool that makes the edit instead", () => { + initDecisionStore(ROOT); + try { + saveDecisionTool(ROOT, input("D-020 absorbed by D-036")); + assert.fail("should have thrown"); + } catch (err: any) { + assert.ok(err.message.includes("axme_archive_decision")); + assert.ok(err.message.includes("superseded_by")); + } + }); + + it("does not block a real decision that merely cites another", () => { + initDecisionStore(ROOT); + const r = saveDecisionTool(ROOT, { + title: "Backlog storage is per-repo, not centralized", + decision: "Each repo keeps its own backlog. Supersedes D-012, which assumed a single repo.", + reasoning: "r", enforce: "required", + }); + assert.equal(r.saved, true); + assert.equal(listDecisions(ROOT).length, 1); + }); +}); + +// --- Merge --- + +describe("merging memories", () => { + function mem(slug: string, title: string, description: string) { + return { + slug, type: "pattern" as const, title, description, body: "", + keywords: [], source: "manual" as const, sessionId: null, date: "2026-01-01", + }; + } + + it("rewrites the survivor and archives the rest", () => { + initMemoryStore(ROOT); + saveMemory(ROOT, mem("keep", "Keep this", "original")); + saveMemory(ROOT, mem("dup-a", "Dup A", "a")); + saveMemory(ROOT, mem("dup-b", "Dup B", "b")); + + const r = mergeMemories(ROOT, "keep", ["dup-a", "dup-b"], { + description: "merged rule", body: "specifics from all three", + }); + assert.equal(r.ok, true); + assert.deepEqual(r.archived.sort(), ["dup-a", "dup-b"]); + + const survivor = getMemory(ROOT, "keep"); + assert.ok(survivor); + assert.equal(survivor.description, "merged rule"); + assert.equal(survivor.body, "specifics from all three"); + assert.equal(getMemory(ROOT, "dup-a"), null); + assert.equal(getMemory(ROOT, "dup-b"), null); + }); + + it("refuses the whole merge if any source is missing", () => { + // A partial merge leaves the survivor claiming content never folded in. + initMemoryStore(ROOT); + saveMemory(ROOT, mem("keep", "Keep this", "original")); + saveMemory(ROOT, mem("dup-a", "Dup A", "a")); + + const r = mergeMemories(ROOT, "keep", ["dup-a", "nope"], { description: "merged" }); + assert.equal(r.ok, false); + assert.ok(r.error!.includes("nope")); + assert.equal(getMemory(ROOT, "keep")!.description, "original"); + assert.ok(getMemory(ROOT, "dup-a")); + }); +});