Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 9 additions & 1 deletion skills/remember/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kind>` means the guard caught something. Say what kind was caught so
the user knows a secret was in play, never what the value was.

Expand Down
50 changes: 47 additions & 3 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down Expand Up @@ -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 });
Expand All @@ -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';
Expand All @@ -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({
Expand All @@ -479,17 +512,28 @@ 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 ?? '<no id>'}: ${f.errors.join('; ')}`),
written.length ? '' : 'No nodes written.',
compacted ? `compacted: ${compacted.indexed} notes indexed` : '',
]
.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() {
Expand Down
1 change: 1 addition & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
84 changes: 84 additions & 0 deletions test/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
Loading