@vib795/agent-memory 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -440,7 +440,7 @@ equivalent and is not: npm links the global install to that folder rather than c
440
440
  it, which shows up as an arrow in `npm list -g`:
441
441
 
442
442
  ```
443
- `-- @vib795/agent-memory@0.6.5 -> .\..\..\..\agent-memory
443
+ `-- @vib795/agent-memory@0.7.4 -> .\..\..\..\agent-memory
444
444
  ```
445
445
 
446
446
  Move or delete the clone afterwards and the global install points at nothing — the same
@@ -561,7 +561,7 @@ them as skipped, which is the intended outcome, not a failure.
561
561
 
562
562
  Needs Node 22.5 or newer; `doctor` says so plainly if the version is too old.
563
563
 
564
- Run `npm test` for the suite (109 tests, no dependencies). CI runs it on Linux,
564
+ Run `npm test` for the suite (111 tests, no dependencies). CI runs it on Linux,
565
565
  macOS and Windows across Node 22 and 24, and separately installs the packed tarball
566
566
  and exercises it end to end on all three.
567
567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vib795/agent-memory",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Durable cross-repo knowledge graph for GitHub Copilot and Claude Code. Markdown source of truth, disposable SQLite index, zero runtime dependencies.",
5
5
  "keywords": [
6
6
  "github-copilot",
@@ -223,7 +223,15 @@ Then stop. Do not summarize the conversation.
223
223
  Warnings from `write` are worth surfacing verbatim:
224
224
 
225
225
  - `title collision` means an existing note reads as the same thing under a different
226
- id. Tell the user which two, and offer to merge or link them with `contradicts`.
226
+ id. The warning now carries that note's type, title and body, so decide in **this
227
+ turn** — do not run `get` to fetch what you were already handed:
228
+ - **Same claim, better wording** — update the existing id with the fuller body. One
229
+ note, improved.
230
+ - **The claim changed** — set `supersedes` to the old id on your new note.
231
+ - **They genuinely disagree** — link them with `contradicts` and say so to the user.
232
+
233
+ Two notes making one claim is the thing compaction cannot repair for you: it merges
234
+ on identical content, and these are not identical, only synonymous.
227
235
  - `redacted Nx <kind>` means the guard caught something. Say what kind was caught so
228
236
  the user knows a secret was in play, never what the value was.
229
237
 
package/src/cli.js CHANGED
@@ -400,6 +400,20 @@ function readNodesFrom(file) {
400
400
  return [raw];
401
401
  }
402
402
 
403
+ /**
404
+ * A readable slice of a note body, cut on a boundary rather than mid-word.
405
+ *
406
+ * Enough to decide whether two notes make the same claim, and no more: this rides
407
+ * inside a `write` response, which is not the place to reproduce a 4 KB note.
408
+ */
409
+ function clip(body, limit) {
410
+ const text = String(body ?? '').trim();
411
+ if (text.length <= limit) return { text, truncated: false };
412
+ const head = text.slice(0, limit);
413
+ const cut = Math.max(head.lastIndexOf('\n'), head.lastIndexOf(' '));
414
+ return { text: (cut > limit / 2 ? head.slice(0, cut) : head).trimEnd(), truncated: true };
415
+ }
416
+
403
417
  function cmdWrite(opts) {
404
418
  const file = opts['from-json'];
405
419
  if (!file || file === true || (file !== '-' && !existsSync(file))) {
@@ -432,6 +446,8 @@ function cmdWrite(opts) {
432
446
 
433
447
  // Normalized titles of what already exists, so two agents naming one thing two
434
448
  // different ways surface as a collision instead of quietly becoming two nodes.
449
+ // Bodies are deliberately not loaded here: a collision is rare, and paying for every
450
+ // body in the store to describe the one that collided is the wrong trade.
435
451
  const titles = new Map();
436
452
  for (const row of db.prepare('SELECT id, title, content_hash FROM nodes').all()) {
437
453
  titles.set(normalizeTitle(row.title), { id: row.id, hash: row.content_hash });
@@ -440,6 +456,7 @@ function cmdWrite(opts) {
440
456
  const written = [];
441
457
  const failed = [];
442
458
  const warnings = [];
459
+ const collisions = [];
443
460
  for (const raw of incoming) {
444
461
  const node = { ...raw };
445
462
  node.source = node.source || (opts.source === true ? undefined : opts.source) || 'manual';
@@ -453,9 +470,25 @@ function cmdWrite(opts) {
453
470
  try {
454
471
  const res = writeNote(node, { selfEmail });
455
472
  if (collision && collision.id !== res.node.id) {
473
+ // The id alone forced a second turn: deciding whether this is a duplicate to
474
+ // merge or a genuine contradiction needs the other note's words, and fetching
475
+ // them meant another `get`, which on a per-prompt biller is another request.
476
+ // One row, and only when a collision actually happened.
477
+ const other = getNodeRow(db, collision.id);
478
+ const excerpt = other ? clip(other.body, cfg.collisionBodyChars) : null;
479
+ collisions.push({
480
+ id: res.node.id,
481
+ existing: collision.id,
482
+ existingType: other?.type ?? null,
483
+ existingTitle: other?.title ?? null,
484
+ existingArchived: Boolean(other?.archived),
485
+ excerpt: excerpt?.text ?? null,
486
+ truncated: Boolean(excerpt?.truncated),
487
+ });
456
488
  warnings.push(
457
- `title collision: ${res.node.id} reads the same as existing ${collision.id}; ` +
458
- 'set contradicts or merge them',
489
+ `title collision: ${res.node.id} reads the same as existing ${collision.id}` +
490
+ (other ? ` [${other.type}] ${other.title}` : '') +
491
+ (other?.archived ? ' (archived)' : ''),
459
492
  );
460
493
  }
461
494
  written.push({
@@ -479,9 +512,18 @@ function cmdWrite(opts) {
479
512
  const compacted = maybeCompact(db, before, after, cfg);
480
513
  db.close();
481
514
 
515
+ // Printed under the warning and indented, so it reads as evidence rather than as a
516
+ // second instruction competing with the first.
517
+ const collisionLines = collisions.flatMap((c) => [
518
+ ...(c.excerpt ? c.excerpt.split('\n').map((l) => ` ${l}`) : []),
519
+ ...(c.truncated ? [` ... agent-memory get ${c.existing} for the rest`] : []),
520
+ ` -> update ${c.existing} by id, or set supersedes or contradicts on ${c.id}.`,
521
+ ]);
522
+
482
523
  const text = [
483
524
  ...written.map((w) => `${w.created ? 'created' : 'updated'} ${w.id} [${w.type}]`),
484
525
  ...warnings.map((w) => `warning: ${w}`),
526
+ ...collisionLines,
485
527
  ...failed.map((f) => `failed ${f.id ?? '<no id>'}: ${f.errors.join('; ')}`),
486
528
  written.length ? '' : 'No nodes written.',
487
529
  compacted ? `compacted: ${compacted.indexed} notes indexed` : '',
@@ -489,7 +531,9 @@ function cmdWrite(opts) {
489
531
  .filter(Boolean)
490
532
  .join('\n');
491
533
 
492
- return { ok: failed.length === 0, written, failed, warnings, compacted: !!compacted, text };
534
+ return {
535
+ ok: failed.length === 0, written, failed, warnings, collisions, compacted: !!compacted, text,
536
+ };
493
537
  }
494
538
 
495
539
  function cmdCompact() {
package/src/config.js CHANGED
@@ -104,6 +104,7 @@ export const DEFAULTS = {
104
104
  compactThreshold: 10, // node-count delta that triggers an automatic compact
105
105
  briefRecentMinutes: 120, // window the capture brief calls already covered
106
106
  briefRecentIds: 10, // ids printed from that window before the rest are counted
107
+ collisionBodyChars: 500, // excerpt returned with a title collision, so one turn can fix it
107
108
  };
108
109
 
109
110
  // Written by the installer: every SKILL.md whose description compact regenerates.
package/src/digest.js CHANGED
@@ -3,22 +3,25 @@ import { createHash } from 'node:crypto';
3
3
  import { captureGap, currentRepo } from './staleness.js';
4
4
 
5
5
  /**
6
- * Two-tier routing.
6
+ * Tiers 1 and 2 of a three-tier ladder.
7
7
  *
8
- * Tier 1 is the `recall` skill's description, which is loaded into every chat
9
- * whether or not memory is ever used. It is standing cost, so it has to read like
10
- * a description rather than a document.
8
+ * Tier 1 is a skill description, loaded into every chat whether or not memory is ever
9
+ * used. It is standing cost, so it has to read like a description rather than a
10
+ * document. It has two occupants, not one: `recall`'s description advertises what the
11
+ * store knows; `remember`'s advertises what it is missing. Both are the same mechanism
12
+ * — a line of frontmatter that code regenerates and every conversation loads —
13
+ * pointed at opposite halves of the same problem.
11
14
  *
12
- * Tier 2 is the tree, printed only when `recall` actually fires. Per-invocation
13
- * cost, paid once, and only when someone is already looking something up.
15
+ * Tier 2 is printed only when a skill actually fires: the tree for `recall`, the
16
+ * capture brief for `remember`. Per-invocation cost, paid once, and only when someone
17
+ * is already looking something up or about to write one down.
14
18
  *
15
- * Neither tier costs a premium request. A request is charged per prompt, not per
16
- * tool call, so both of these ride inside a turn that was already paid for.
19
+ * Tier 3 — note bodies, and the colliding note that `write` hands back — lives
20
+ * outside this module, because by then the question is which note rather than which
21
+ * of them.
17
22
  *
18
- * Tier 1 has two occupants, not one. `recall`'s description advertises what the store
19
- * knows; `remember`'s advertises what it is missing. Both are the same mechanism — a
20
- * line of frontmatter that code regenerates and every conversation loads — pointed at
21
- * opposite halves of the same problem.
23
+ * Neither tier here costs a premium request. A request is charged per prompt, not per
24
+ * tool call, so both of these ride inside a turn that was already paid for.
22
25
  */
23
26
 
24
27
  // Never dropped from either tier. A constraint is what stops an agent from burning