@vib795/agent-memory 0.7.0 → 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 (105 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.0",
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/compact.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, existsSync, realpathSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
2
3
  import { fileURLToPath } from 'node:url';
3
4
  import { join, sep, basename, dirname } from 'node:path';
4
5
  import { loadConfig, paths } from './config.js';
@@ -152,28 +153,78 @@ function decay(active, cfg, now) {
152
153
  * what it now knows without costing anything at chat time.
153
154
  */
154
155
  // The directory this package was installed into. `setup` links skill directories at
155
- // `<package>/skills/<name>`, so every description write lands in the package's own
156
+ // `<package>/skills/<name>`, so a description write normally lands in the package's own
156
157
  // files. That is correct for an installed package and wrong for a git checkout, where
157
158
  // those files are tracked: one developer's digest gets committed and then published to
158
159
  // everyone. It shipped that way for twenty releases, advertising one machine's five
159
160
  // notes to every user who installed the plugin.
160
161
  const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
161
162
 
163
+ // One process, one answer, so a per-run cache cannot go stale. `compact` asks about
164
+ // every registered path on every call, and most of them resolve to the same few trees.
165
+ const trackedCache = new Map();
166
+
162
167
  /**
163
- * Is this path inside the package's own git checkout?
168
+ * Does git consider this exact file tracked?
164
169
  *
165
- * Resolved through `realpathSync` because the path arrives as a symlink planted by
166
- * `setup`; the link sits outside the checkout even when its target is inside it, which
167
- * is the whole reason this went unnoticed. The `.git` test separates a developer's
168
- * working tree from an ordinary install, which has no `.git` and must keep being
169
- * written to — that write is the Tier-1 mechanism, not a bug.
170
+ * `true` and `false` are answers; `null` means git could not be asked and the caller
171
+ * has to fall back. A non-zero exit covers both "not tracked" and "not in a repository",
172
+ * and those mean the same thing here: writing the file publishes nothing.
173
+ */
174
+ function isTracked(real) {
175
+ if (trackedCache.has(real)) return trackedCache.get(real);
176
+ let answer;
177
+ try {
178
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', basename(real)], {
179
+ cwd: dirname(real),
180
+ stdio: ['ignore', 'ignore', 'ignore'],
181
+ timeout: 5000,
182
+ });
183
+ answer = true;
184
+ } catch (err) {
185
+ // ENOENT is git missing, which is not an answer about the file. Anything else is
186
+ // git having run and said no.
187
+ answer = err.code === 'ENOENT' ? null : false;
188
+ }
189
+ trackedCache.set(real, answer);
190
+ return answer;
191
+ }
192
+
193
+ /**
194
+ * Would writing this file commit local state into someone's repository?
195
+ *
196
+ * The question is about the **target**, not about where this code is running from, and
197
+ * that distinction is the bug this replaced. The old test asked whether the running
198
+ * package had a `.git`, which is true from a checkout and false from an installed
199
+ * package — so a registered path pointing into a checkout was refused in dev mode and
200
+ * silently written in normal mode. Switching a machine from `npm install -g .` to the
201
+ * published package leaves exactly such a path behind, and the next `compact` wrote a
202
+ * machine-specific digest into a tracked file. The same failure that shipped one
203
+ * machine's note count for twenty releases, reached from the other direction.
204
+ *
205
+ * Tracked-ness is the property that actually matters, so ask git directly. Resolved
206
+ * through `realpathSync` first because the path arrives as a symlink planted by
207
+ * `setup`: the link sits outside the repository even when its target is inside it, and
208
+ * asking about the link would answer "not tracked" and then write straight through it.
209
+ *
210
+ * Without git, fall back to the old package-root heuristic. It is narrower than the
211
+ * real question but it is what this shipped with, and a machine with no git also has
212
+ * no tracked file to damage.
170
213
  */
171
214
  export function insideCheckout(file) {
172
- if (!existsSync(join(PACKAGE_ROOT, '.git'))) return false;
173
215
  let real;
174
- let root;
175
216
  try {
176
217
  real = realpathSync(file);
218
+ } catch {
219
+ return false;
220
+ }
221
+
222
+ const tracked = isTracked(real);
223
+ if (tracked !== null) return tracked;
224
+
225
+ if (!existsSync(join(PACKAGE_ROOT, '.git'))) return false;
226
+ let root;
227
+ try {
177
228
  root = realpathSync(PACKAGE_ROOT);
178
229
  } catch {
179
230
  return false;
@@ -182,6 +233,11 @@ export function insideCheckout(file) {
182
233
  return real === root || real.startsWith(root + sep);
183
234
  }
184
235
 
236
+ /** Testing seam: the per-process cache would otherwise outlive a fixture repo. */
237
+ export function resetTrackedCache() {
238
+ trackedCache.clear();
239
+ }
240
+
185
241
  export function writeSkillDescription(skillPath, description) {
186
242
  if (!existsSync(skillPath)) return false;
187
243
  if (insideCheckout(skillPath)) return false;
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