From 9a5229c0720a6bfd3c6467664a48ccfd61f14278 Mon Sep 17 00:00:00 2001 From: Utkarsh Singh <6995377+vib795@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:31:04 -0500 Subject: [PATCH] feat: hand back the colliding note, not just its id (0.7.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last piece of the capture pipeline, and the one that survived contact with `brief`. A title collision reported the other note's id and stopped there. Deciding what to do with it — merge a duplicate, supersede a changed claim, or link a genuine contradiction — needs that note's words, and getting them meant a second `agent-memory get`. On a biller that charges per prompt rather than per tool call, that is another request for something `write` already had in hand. It now returns the existing note's type, title and a clipped body, so the fix happens in the turn that found the problem. One extra row is read, and only when a collision actually occurred; bodies are still not loaded for the whole store to describe the one that collided. The excerpt is cut on a word or line boundary, for the same reason the digest sheds whole items rather than trimming characters: text that stops mid-word reads as corrupted. When it is clipped it says where the rest is. This is what content-hash dedup structurally cannot catch. `compact` merges notes whose content is identical; two notes making one claim in different words are not identical, only synonymous, and no amount of compaction repairs that later. Not built, deliberately: `write --dry-run`. It would turn one call into two, and `brief` already answers "what is already here" before composing — which is the same question, asked earlier and cheaper. 111 tests, up from 109. Zero dependencies. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014fSLBRUVVhAauWDuzJM4mc --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- skills/remember/SKILL.md | 10 +++- src/cli.js | 50 ++++++++++++++++++-- src/config.js | 1 + test/integration.test.js | 84 +++++++++++++++++++++++++++++++++ 9 files changed, 147 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b2111a3..39fe148 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "agent-memory", "source": "./", "description": "Durable cross-repository memory for coding agents — built to survive an IT security review. Zero runtime dependencies, zero dev dependencies, no install script: nothing runs when you install it, and granting it your agents is a separate explicit command. Independently scanned, with a passing verdict. Adds /handoff, /remember and /recall over one local markdown store. Requires the CLI: npm install -g @vib795/agent-memory (Node >= 22.5).", - "version": "0.7.2", + "version": "0.7.3", "author": { "name": "Utkarsh Singh", "url": "https://github.com/vib795" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index dd7b6dc..c6c3783 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "agent-memory", "displayName": "agent-memory", - "version": "0.7.2", + "version": "0.7.3", "description": "Durable cross-repository memory for coding agents — built to survive an IT security review. Zero runtime dependencies, zero dev dependencies, no install script: nothing runs when you install it, and granting it your agents is a separate explicit command. Independently scanned, with a passing verdict. Adds /handoff, /remember and /recall over one local markdown store. Requires the CLI: npm install -g @vib795/agent-memory (Node >= 22.5).", "author": { "name": "Utkarsh Singh", diff --git a/README.md b/README.md index b2688b5..a327232 100644 --- a/README.md +++ b/README.md @@ -561,7 +561,7 @@ them as skipped, which is the intended outcome, not a failure. Needs Node 22.5 or newer; `doctor` says so plainly if the version is too old. -Run `npm test` for the suite (109 tests, no dependencies). CI runs it on Linux, +Run `npm test` for the suite (111 tests, no dependencies). CI runs it on Linux, macOS and Windows across Node 22 and 24, and separately installs the packed tarball and exercises it end to end on all three. diff --git a/package-lock.json b/package-lock.json index baec089..8574c65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vib795/agent-memory", - "version": "0.7.2", + "version": "0.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vib795/agent-memory", - "version": "0.7.2", + "version": "0.7.3", "license": "MIT", "bin": { "agent-memory": "src/cli.js" diff --git a/package.json b/package.json index ab8786c..6647ee3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vib795/agent-memory", - "version": "0.7.2", + "version": "0.7.3", "description": "Durable cross-repo knowledge graph for GitHub Copilot and Claude Code. Markdown source of truth, disposable SQLite index, zero runtime dependencies.", "keywords": [ "github-copilot", diff --git a/skills/remember/SKILL.md b/skills/remember/SKILL.md index 3415a38..ce1d8de 100644 --- a/skills/remember/SKILL.md +++ b/skills/remember/SKILL.md @@ -223,7 +223,15 @@ Then stop. Do not summarize the conversation. Warnings from `write` are worth surfacing verbatim: - `title collision` means an existing note reads as the same thing under a different - id. Tell the user which two, and offer to merge or link them with `contradicts`. + id. The warning now carries that note's type, title and body, so decide in **this + turn** — do not run `get` to fetch what you were already handed: + - **Same claim, better wording** — update the existing id with the fuller body. One + note, improved. + - **The claim changed** — set `supersedes` to the old id on your new note. + - **They genuinely disagree** — link them with `contradicts` and say so to the user. + + Two notes making one claim is the thing compaction cannot repair for you: it merges + on identical content, and these are not identical, only synonymous. - `redacted Nx ` means the guard caught something. Say what kind was caught so the user knows a secret was in play, never what the value was. diff --git a/src/cli.js b/src/cli.js index 28d9bd5..3513e44 100755 --- a/src/cli.js +++ b/src/cli.js @@ -400,6 +400,20 @@ function readNodesFrom(file) { return [raw]; } +/** + * A readable slice of a note body, cut on a boundary rather than mid-word. + * + * Enough to decide whether two notes make the same claim, and no more: this rides + * inside a `write` response, which is not the place to reproduce a 4 KB note. + */ +function clip(body, limit) { + const text = String(body ?? '').trim(); + if (text.length <= limit) return { text, truncated: false }; + const head = text.slice(0, limit); + const cut = Math.max(head.lastIndexOf('\n'), head.lastIndexOf(' ')); + return { text: (cut > limit / 2 ? head.slice(0, cut) : head).trimEnd(), truncated: true }; +} + function cmdWrite(opts) { const file = opts['from-json']; if (!file || file === true || (file !== '-' && !existsSync(file))) { @@ -432,6 +446,8 @@ function cmdWrite(opts) { // Normalized titles of what already exists, so two agents naming one thing two // different ways surface as a collision instead of quietly becoming two nodes. + // Bodies are deliberately not loaded here: a collision is rare, and paying for every + // body in the store to describe the one that collided is the wrong trade. const titles = new Map(); for (const row of db.prepare('SELECT id, title, content_hash FROM nodes').all()) { titles.set(normalizeTitle(row.title), { id: row.id, hash: row.content_hash }); @@ -440,6 +456,7 @@ function cmdWrite(opts) { const written = []; const failed = []; const warnings = []; + const collisions = []; for (const raw of incoming) { const node = { ...raw }; node.source = node.source || (opts.source === true ? undefined : opts.source) || 'manual'; @@ -453,9 +470,25 @@ function cmdWrite(opts) { try { const res = writeNote(node, { selfEmail }); if (collision && collision.id !== res.node.id) { + // The id alone forced a second turn: deciding whether this is a duplicate to + // merge or a genuine contradiction needs the other note's words, and fetching + // them meant another `get`, which on a per-prompt biller is another request. + // One row, and only when a collision actually happened. + const other = getNodeRow(db, collision.id); + const excerpt = other ? clip(other.body, cfg.collisionBodyChars) : null; + collisions.push({ + id: res.node.id, + existing: collision.id, + existingType: other?.type ?? null, + existingTitle: other?.title ?? null, + existingArchived: Boolean(other?.archived), + excerpt: excerpt?.text ?? null, + truncated: Boolean(excerpt?.truncated), + }); warnings.push( - `title collision: ${res.node.id} reads the same as existing ${collision.id}; ` + - 'set contradicts or merge them', + `title collision: ${res.node.id} reads the same as existing ${collision.id}` + + (other ? ` [${other.type}] ${other.title}` : '') + + (other?.archived ? ' (archived)' : ''), ); } written.push({ @@ -479,9 +512,18 @@ function cmdWrite(opts) { const compacted = maybeCompact(db, before, after, cfg); db.close(); + // Printed under the warning and indented, so it reads as evidence rather than as a + // second instruction competing with the first. + const collisionLines = collisions.flatMap((c) => [ + ...(c.excerpt ? c.excerpt.split('\n').map((l) => ` ${l}`) : []), + ...(c.truncated ? [` ... agent-memory get ${c.existing} for the rest`] : []), + ` -> update ${c.existing} by id, or set supersedes or contradicts on ${c.id}.`, + ]); + const text = [ ...written.map((w) => `${w.created ? 'created' : 'updated'} ${w.id} [${w.type}]`), ...warnings.map((w) => `warning: ${w}`), + ...collisionLines, ...failed.map((f) => `failed ${f.id ?? ''}: ${f.errors.join('; ')}`), written.length ? '' : 'No nodes written.', compacted ? `compacted: ${compacted.indexed} notes indexed` : '', @@ -489,7 +531,9 @@ function cmdWrite(opts) { .filter(Boolean) .join('\n'); - return { ok: failed.length === 0, written, failed, warnings, compacted: !!compacted, text }; + return { + ok: failed.length === 0, written, failed, warnings, collisions, compacted: !!compacted, text, + }; } function cmdCompact() { diff --git a/src/config.js b/src/config.js index 761061d..b72ce38 100644 --- a/src/config.js +++ b/src/config.js @@ -104,6 +104,7 @@ export const DEFAULTS = { compactThreshold: 10, // node-count delta that triggers an automatic compact briefRecentMinutes: 120, // window the capture brief calls already covered briefRecentIds: 10, // ids printed from that window before the rest are counted + collisionBodyChars: 500, // excerpt returned with a title collision, so one turn can fix it }; // Written by the installer: every SKILL.md whose description compact regenerates. diff --git a/test/integration.test.js b/test/integration.test.js index 128792d..109dccf 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -1735,3 +1735,87 @@ test('a fractional cap cannot reach SQLite as a LIMIT', () => { saveConfig({ briefRecentIds: DEFAULTS.briefRecentIds }); } }); + +test('a title collision returns the other note, not just its id', () => { + // The id alone forced a second turn: deciding whether this is a duplicate to merge or + // a genuine contradiction needs the other note's words. Fetching them meant another + // `get`, and on a per-prompt biller that is another request for something the command + // already had in hand. + const home = mkdtempSync(join(tmpdir(), 'agent-memory-collide-')); + const cli = fileURLToPath(new URL('../src/cli.js', import.meta.url)); + const run = (args, input) => + spawnSync(process.execPath, [cli, ...args], { + env: { ...process.env, AGENT_MEMORY_HOME: home }, input, encoding: 'utf8', + }); + const body = 'Why: revocation had to take effect immediately.\nRejected: short-TTL JWT.'; + try { + assert.equal(run(['init']).status, 0); + run(['write', '--from-json', '-'], JSON.stringify({ + nodes: [{ id: 'use-sessions', type: 'decision', title: 'Chose server sessions over JWT', body }], + })); + + // Same claim, different words and different id -- exactly what content-hash dedup + // cannot catch, because the bytes differ. + const r = run(['write', '--from-json', '-'], JSON.stringify({ + nodes: [{ id: 'session-auth', type: 'decision', title: 'chose SERVER-SESSIONS over jwt!', body: 'We went with sessions.' }], + })); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /title collision: session-auth reads the same as existing use-sessions/); + assert.match(r.stdout, /\[decision\] Chose server sessions over JWT/, 'the type and title come back'); + assert.match(r.stdout, /revocation had to take effect immediately/, 'and the body, which is the point'); + assert.match(r.stdout, /update use-sessions by id, or set supersedes or contradicts/); + + const j = JSON.parse(run(['write', '--from-json', '-', '--json'], JSON.stringify({ + nodes: [{ id: 'third-way', type: 'decision', title: 'CHOSE SERVER SESSIONS OVER JWT', body: 'x' }], + })).stdout); + assert.equal(j.collisions.length, 1); + assert.equal(j.collisions[0].existing, 'use-sessions'); + assert.equal(j.collisions[0].existingType, 'decision'); + assert.equal(j.collisions[0].truncated, false, 'a short body is returned whole'); + assert.ok(j.collisions[0].excerpt.includes('short-TTL JWT')); + + // Updating a note by its own id is not a collision with itself. + const same = JSON.parse(run(['write', '--from-json', '-', '--json'], JSON.stringify({ + nodes: [{ id: 'use-sessions', type: 'decision', title: 'Chose server sessions over JWT', body: 'Revised.' }], + })).stdout); + assert.deepEqual(same.collisions, [], 'an update to the same id must stay silent'); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test('a long collision body is clipped on a boundary and says where the rest is', () => { + const home = mkdtempSync(join(tmpdir(), 'agent-memory-clip-')); + const cli = fileURLToPath(new URL('../src/cli.js', import.meta.url)); + const run = (args, input) => + spawnSync(process.execPath, [cli, ...args], { + env: { ...process.env, AGENT_MEMORY_HOME: home }, input, encoding: 'utf8', + }); + try { + assert.equal(run(['init']).status, 0); + run(['write', '--from-json', '-'], JSON.stringify({ + nodes: [{ id: 'long-note', type: 'system', title: 'A long one', body: 'Sentence explaining the mechanism. '.repeat(40) }], + })); + const j = JSON.parse(run(['write', '--from-json', '-', '--json'], JSON.stringify({ + nodes: [{ id: 'long-dup', type: 'system', title: 'a LONG one', body: 'Shorter.' }], + })).stdout); + + const c = j.collisions[0]; + assert.equal(c.truncated, true); + assert.ok(c.excerpt.length <= DEFAULTS.collisionBodyChars, `excerpt was ${c.excerpt.length}`); + // Cut on a boundary: a body that stops mid-word reads as corrupted, which is the + // same reason the digest sheds whole items rather than trimming characters. The + // real property is that the excerpt is a prefix ending exactly where a word does. + const full = ('Sentence explaining the mechanism. '.repeat(40)).trim(); + assert.ok(full.startsWith(c.excerpt), 'the excerpt must be a prefix of the body'); + assert.match(full[c.excerpt.length], /\s/, 'and the next character must be whitespace'); + assert.match( + run(['write', '--from-json', '-'], JSON.stringify({ + nodes: [{ id: 'long-dup-2', type: 'system', title: 'A Long One', body: 'x' }], + })).stdout, + /agent-memory get long-note for the rest/, + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } +});