@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 +1 -1
- package/package.json +1 -1
- package/src/compact.js +65 -9
- package/src/config.js +6 -2
- package/src/digest.js +85 -13
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 (
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
168
|
+
* Does git consider this exact file tracked?
|
|
164
169
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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
|
-
.
|
|
389
|
-
|
|
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:
|
|
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
|
|
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
|
|