@vib795/agent-memory 0.7.1 → 0.7.2

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 (109 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.2",
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",
package/src/config.js CHANGED
@@ -103,6 +103,7 @@ 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
106
107
  };
107
108
 
108
109
  // Written by the installer: every SKILL.md whose description compact regenerates.
@@ -134,7 +135,10 @@ export function loadConfig() {
134
135
  const merged = { ...DEFAULTS, skillPaths: [] };
135
136
 
136
137
  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;
138
+ // Integer, not merely finite. Every cap here counts something, and one of them is
139
+ // bound into a SQL `LIMIT`, where a fractional value is a datatype mismatch rather
140
+ // than a rounding question.
141
+ if (k in DEFAULTS && Number.isSafeInteger(v) && v > 0) merged[k] = v;
138
142
  }
139
143
  const machine = readJson(paths.machineConfig);
140
144
  for (const k of LIST_KEYS) {
@@ -163,7 +167,7 @@ export function saveConfig(patch) {
163
167
  let capsDirty = false;
164
168
  let machineDirty = false;
165
169
  for (const [k, v] of Object.entries(patch || {})) {
166
- if (k in DEFAULTS && typeof v === 'number' && Number.isFinite(v) && v > 0) {
170
+ if (k in DEFAULTS && Number.isSafeInteger(v) && v > 0) {
167
171
  caps[k] = v;
168
172
  capsDirty = true;
169
173
  }
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