@vib795/agent-memory 0.7.1 → 0.7.3

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
@@ -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 (106 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.1",
3
+ "version": "0.7.3",
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
@@ -103,6 +103,8 @@ export const DEFAULTS = {
103
103
  captureGapCommits: 50, // repo movement with no capture at all before it is worth saying
104
104
  compactThreshold: 10, // node-count delta that triggers an automatic compact
105
105
  briefRecentMinutes: 120, // window the capture brief calls already covered
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
106
108
  };
107
109
 
108
110
  // Written by the installer: every SKILL.md whose description compact regenerates.
@@ -134,7 +136,10 @@ export function loadConfig() {
134
136
  const merged = { ...DEFAULTS, skillPaths: [] };
135
137
 
136
138
  for (const [k, v] of Object.entries(readJson(paths.config))) {
137
- if (k in DEFAULTS && typeof v === 'number' && Number.isFinite(v) && v > 0) merged[k] = v;
139
+ // Integer, not merely finite. Every cap here counts something, and one of them is
140
+ // bound into a SQL `LIMIT`, where a fractional value is a datatype mismatch rather
141
+ // than a rounding question.
142
+ if (k in DEFAULTS && Number.isSafeInteger(v) && v > 0) merged[k] = v;
138
143
  }
139
144
  const machine = readJson(paths.machineConfig);
140
145
  for (const k of LIST_KEYS) {
@@ -163,7 +168,7 @@ export function saveConfig(patch) {
163
168
  let capsDirty = false;
164
169
  let machineDirty = false;
165
170
  for (const [k, v] of Object.entries(patch || {})) {
166
- if (k in DEFAULTS && typeof v === 'number' && Number.isFinite(v) && v > 0) {
171
+ if (k in DEFAULTS && Number.isSafeInteger(v) && v > 0) {
167
172
  caps[k] = v;
168
173
  capsDirty = true;
169
174
  }
package/src/digest.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { loadConfig, NOTE_TYPES } from './config.js';
2
+ import { createHash } from 'node:crypto';
2
3
  import { captureGap, currentRepo } from './staleness.js';
3
4
 
4
5
  /**
@@ -31,6 +32,47 @@ const USE_WHEN =
31
32
  'Use when you need to know how a system works, why a decision was made, ' +
32
33
  'what convention applies, or what the environment forbids.';
33
34
 
35
+ /**
36
+ * A repository name safe to put in front of a model.
37
+ *
38
+ * `currentRepo` is `basename(git rev-parse --show-toplevel)` — a directory name. That
39
+ * value reaches Tier 1, the one string loaded into every conversation, and
40
+ * `writeSkillDescription` only JSON-quotes the line, which keeps the YAML valid and
41
+ * does nothing about the content.
42
+ *
43
+ * The charset filter alone is not enough, and the reason is specific: a GitHub
44
+ * repository name is drawn from exactly this charset, so
45
+ * `SYSTEM-ignore-previous-instructions` survives it unchanged and arrives by nothing
46
+ * more exotic than `git clone`. Hyphens separate words as well as spaces do.
47
+ *
48
+ * So shape decides. A repository name is one to three segments and short; an
49
+ * instruction needs more words than that. Anything outside that shape is rendered as a
50
+ * stable non-semantic identifier instead — the name is still distinguishable from
51
+ * another repository's, and still tells a reader in the wrong tree that the numbers are
52
+ * not theirs, which is the only job it had.
53
+ *
54
+ * The cost is honest: a legitimate four-segment name shows as `repo-<hash>`. That is a
55
+ * deliberate trade of some legibility for a Tier-1 string that cannot be authored by
56
+ * whoever chose the directory name.
57
+ *
58
+ * Display only. Every query still matches on the real name, because a repository whose
59
+ * notes stopped being found would be a worse bug than the one this closes.
60
+ */
61
+ export function safeRepo(name) {
62
+ if (typeof name !== 'string' || !name) return 'unnamed';
63
+ // Filtering must not be able to *make* a name look ordinary. Stripping the spaces
64
+ // out of "Ignore previous instructions" collapses it into one long token that would
65
+ // pass the shape test below, so a name that had to be modified at all is already
66
+ // outside the shape and goes straight to an identifier.
67
+ const untouched = /^[A-Za-z0-9._-]+$/.test(name);
68
+ const segments = name.split(/[-._]+/).filter(Boolean);
69
+ if (untouched && segments.length <= 3 && name.length <= 32) return name;
70
+ // Stable across runs and machines, so the same repository always reads the same and
71
+ // two repositories never collide in the description.
72
+ return `repo-${createHash('sha256').update(name).digest('hex').slice(0, 8)}`;
73
+ }
74
+
75
+
34
76
  function typeRank(type) {
35
77
  const i = TYPE_ORDER.indexOf(type);
36
78
  return i === -1 ? TYPE_ORDER.length : i;
@@ -88,7 +130,7 @@ export function buildDigest(db, { cfg = loadConfig() } = {}) {
88
130
  ORDER BY c DESC, r.repo
89
131
  `)
90
132
  .all()
91
- .map((r) => r.repo);
133
+ .map((r) => safeRepo(r.repo));
92
134
 
93
135
  const topics = db
94
136
  .prepare(`
@@ -210,17 +252,20 @@ export function buildCaptureNudge(db, { cfg = loadConfig(), cwd = process.cwd(),
210
252
  // and the commit branches below stay on that same definition. The broader count is
211
253
  // reserved for the covered branches, where the question is how much knowledge applies
212
254
  // here rather than how much of it was captured here.
255
+ // Display only. `repoScopedCount` below is still given the real name.
256
+ const label = safeRepo(g.repo);
257
+
213
258
  if (g.notes === 0) {
214
259
  // captureGap withholds its note on a repository too young for the absence to mean
215
260
  // anything. Stay silent with it: nagging on commit three is how a signal gets
216
261
  // discounted long before the day it matters.
217
262
  return compose(
218
- g.note ? `Nothing has ever been captured for ${g.repo} — ${g.commits} commits of history.` : null,
263
+ g.note ? `Nothing has ever been captured for ${label} — ${g.commits} commits of history.` : null,
219
264
  );
220
265
  }
221
266
 
222
267
  // Captured, but the repository has moved a long way since the most recent one.
223
- if (g.note) return compose(`${g.commits} commits since anything was captured for ${g.repo}.`);
268
+ if (g.note) return compose(`${g.commits} commits since anything was captured for ${label}.`);
224
269
 
225
270
  // Current on commits, but blind in the type that matters most. `digest` privileges
226
271
  // constraints and never drops them from Tier 1; a store holding none has not recorded
@@ -228,13 +273,13 @@ export function buildCaptureNudge(db, { cfg = loadConfig(), cwd = process.cwd(),
228
273
  const notes = repoScopedCount(db, g.repo);
229
274
  if (repoScopedCount(db, g.repo, PRIVILEGED) === 0) {
230
275
  return compose(
231
- `${notes} note${notes === 1 ? '' : 's'} for ${g.repo} and no constraint recorded — ` +
276
+ `${notes} note${notes === 1 ? '' : 's'} for ${label} and no constraint recorded — ` +
232
277
  'what this environment forbids has never been written down.',
233
278
  );
234
279
  }
235
280
 
236
281
  // Covered. Report the state plainly and let the routing clause do the rest.
237
- return compose(`${notes} note${notes === 1 ? '' : 's'} for ${g.repo}, capture is current.`);
282
+ return compose(`${notes} note${notes === 1 ? '' : 's'} for ${label}, capture is current.`);
238
283
  }
239
284
 
240
285
  /**
@@ -301,7 +346,7 @@ export function buildTree(db, { repo = null, all = false, cfg = loadConfig() } =
301
346
  }
302
347
 
303
348
  export function renderTree(result) {
304
- const scope = result.repo ? result.repo : 'all repos';
349
+ const scope = result.repo ? safeRepo(result.repo) : 'all repos';
305
350
  const out = [`# memory: ${scope} — ${result.total} notes`];
306
351
  const width = result.lines.reduce((w, e) => Math.max(w, e.id.length), 0);
307
352
  for (const e of result.lines) {
@@ -366,10 +411,15 @@ function typeCounts(db, repo) {
366
411
  * window wide enough to cover the working session is the honest approximation.
367
412
  */
368
413
  function recentlyCaptured(db, repo, cfg, now) {
414
+ // Bound straight into SQLite's `LIMIT ?`, which rejects a REAL with `datatype
415
+ // mismatch`. Callers may hand in a cfg that never went through loadConfig -- every
416
+ // test does -- so the floor lives here as well as there.
417
+ const cap = Math.max(1, Math.floor(cfg.briefRecentIds));
369
418
  // Same format store.js writes, so a lexicographic compare is a chronological one.
370
419
  const cutoff = new Date(now - cfg.briefRecentMinutes * 60000)
371
420
  .toISOString()
372
421
  .replace(/\.\d{3}Z$/, 'Z');
422
+ // One row past the cap is the cheap overflow probe.
373
423
  const rows = repo
374
424
  ? db
375
425
  .prepare(`
@@ -378,15 +428,31 @@ function recentlyCaptured(db, repo, cfg, now) {
378
428
  LEFT JOIN node_repos r ON r.node_id = n.id
379
429
  WHERE n.archived = 0 AND n.updated >= ? AND (n.scope = 'global' OR r.repo = ?)
380
430
  ORDER BY n.updated DESC, n.id
431
+ LIMIT ?
381
432
  `)
382
- .all(cutoff, repo)
433
+ .all(cutoff, repo, cap + 1)
383
434
  : db
384
435
  .prepare(`
385
436
  SELECT id, updated FROM nodes
386
437
  WHERE archived = 0 AND updated >= ? ORDER BY updated DESC, id
438
+ LIMIT ?
439
+ `)
440
+ .all(cutoff, cap + 1);
441
+
442
+ if (rows.length <= cap) return { ids: rows.map((r) => r.id), omitted: 0 };
443
+
444
+ // The exact count is only worth a second query when there is something to report.
445
+ const total = repo
446
+ ? db
447
+ .prepare(`
448
+ SELECT COUNT(DISTINCT n.id) AS c
449
+ FROM nodes n
450
+ LEFT JOIN node_repos r ON r.node_id = n.id
451
+ WHERE n.archived = 0 AND n.updated >= ? AND (n.scope = 'global' OR r.repo = ?)
387
452
  `)
388
- .all(cutoff);
389
- return rows.map((r) => r.id);
453
+ .get(cutoff, repo).c
454
+ : db.prepare('SELECT COUNT(*) AS c FROM nodes WHERE archived = 0 AND updated >= ?').get(cutoff).c;
455
+ return { ids: rows.slice(0, cap).map((r) => r.id), omitted: total - cap };
390
456
  }
391
457
 
392
458
  /**
@@ -411,6 +477,7 @@ export function buildBrief(db, { repo, cwd = process.cwd(), cfg = loadConfig(),
411
477
  const g = gap !== undefined ? gap : here ? captureGap(db, { cwd, cfg, repo: here }) : null;
412
478
  const tree = buildTree(db, { repo: here, cfg });
413
479
  const counts = typeCounts(db, here);
480
+ const recent = recentlyCaptured(db, here, cfg, now);
414
481
 
415
482
  return {
416
483
  repo: here,
@@ -418,15 +485,16 @@ export function buildBrief(db, { repo, cwd = process.cwd(), cfg = loadConfig(),
418
485
  tree,
419
486
  counts: Object.fromEntries(counts),
420
487
  missing: NOTE_TYPES.filter((t) => counts.get(t) === 0),
421
- recent: recentlyCaptured(db, here, cfg, now),
488
+ recent: recent.ids,
489
+ recentOmitted: recent.omitted,
422
490
  recentMinutes: cfg.briefRecentMinutes,
423
491
  total: tree.total,
424
492
  };
425
493
  }
426
494
 
427
495
  export function renderBrief(result) {
428
- const { repo, gap, tree, counts, missing, recent, recentMinutes } = result;
429
- const scope = repo ?? 'all repos';
496
+ const { repo, gap, tree, counts, missing, recent, recentOmitted, recentMinutes } = result;
497
+ const scope = repo ? safeRepo(repo) : 'all repos';
430
498
 
431
499
  // The header carries the same signal the remember description does, at the same
432
500
  // moment it is being acted on. A brief that opens with a note count while the repo
@@ -471,9 +539,13 @@ export function renderBrief(result) {
471
539
  if (recent.length) {
472
540
  // Written as the reason rather than the rule, because the rule is already in the
473
541
  // skill and the model is being asked to apply it, not to learn it again.
542
+ // Bounded, and it says so. A bulk write or import stamps every note with the same
543
+ // minute; printing all of them would spend the context this brief exists to
544
+ // conserve, while reading as the whole list -- the exact failure the tree refuses.
545
+ const more = recentOmitted ? ` (+${recentOmitted} more)` : '';
474
546
  out.push(
475
547
  '',
476
- `captured in the last ${recentMinutes} minutes, so already covered: ${recent.join(', ')}`,
548
+ `captured in the last ${recentMinutes} minutes, so already covered: ${recent.join(', ')}${more}`,
477
549
  );
478
550
  }
479
551