@vib795/agent-memory 0.1.0

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/src/cli.js ADDED
@@ -0,0 +1,558 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { loadConfig, saveConfig, paths, NOTE_TYPES } from './config.js';
5
+ import { ensureStore, writeNote, normalizeTitle, listNotes } from './store.js';
6
+ import {
7
+ openDb, reindex, searchNodes, getNodeRow, markAccessed, nodeCount, hasFts,
8
+ } from './index-db.js';
9
+ import { neighborhood, applyBudget } from './graph.js';
10
+ import { buildTree, renderTree, buildDigest } from './digest.js';
11
+ import { compact, maybeCompact } from './compact.js';
12
+ import { staleness, currentRepo, reviewCandidates } from './staleness.js';
13
+ import { setup as runSetup, unlinkSkills, danglingSkillLinks } from './setup.js';
14
+ import { detectTargets } from './targets.js';
15
+
16
+ /**
17
+ * One process, one answer.
18
+ *
19
+ * Every command completes in a single invocation with no daemon and no server,
20
+ * because a background process is the first thing a locked-down desktop refuses to
21
+ * run and the first thing that breaks after a reboot.
22
+ */
23
+
24
+ const MIN_NODE = [22, 5];
25
+
26
+ function parseArgs(argv) {
27
+ const opts = { _: [] };
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ if (!a.startsWith('--')) {
31
+ opts._.push(a);
32
+ continue;
33
+ }
34
+ const key = a.slice(2);
35
+ const next = argv[i + 1];
36
+ if (next === undefined || next.startsWith('--')) {
37
+ opts[key] = true;
38
+ } else {
39
+ opts[key] = next;
40
+ i++;
41
+ }
42
+ }
43
+ return opts;
44
+ }
45
+
46
+ function num(v, fallback) {
47
+ const n = Number.parseInt(v, 10);
48
+ return Number.isFinite(n) && n > 0 ? n : fallback;
49
+ }
50
+
51
+ function nodeVersionOk() {
52
+ const [maj, min] = process.versions.node.split('.').map((s) => Number.parseInt(s, 10));
53
+ return maj > MIN_NODE[0] || (maj === MIN_NODE[0] && min >= MIN_NODE[1]);
54
+ }
55
+
56
+ function git(args, cwd = process.cwd()) {
57
+ try {
58
+ return execFileSync('git', args, {
59
+ cwd,
60
+ encoding: 'utf8',
61
+ stdio: ['ignore', 'pipe', 'ignore'],
62
+ timeout: 5000,
63
+ }).trim();
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ const USAGE = `agent-memory — durable cross-repo knowledge for coding agents
70
+
71
+ setup link all three skills, build the store
72
+ (runs automatically on npm install)
73
+ uninstall remove the skill links; keeps every note
74
+ init [--skills "<p1>,<p2>"] create the store; register skill files
75
+ index rebuild index.db from notes/
76
+ tree [--repo <name>] [--all] routing map, scoped to a repo
77
+ get <id> [--depth N] [--budget N] a note plus its neighborhood
78
+ [--include-archived]
79
+ search <terms> [--limit N] full-text fallback when the tree misses
80
+ write --from-json <file> validated upsert; used by the skills
81
+ [--source <name>] [--repo <name>]
82
+ compact dedup, decay, reindex, regenerate
83
+ doctor preflight and health report
84
+
85
+ Add --json to any command for machine-readable output.
86
+ Store: ${paths.root}`;
87
+
88
+ // --- commands ---------------------------------------------------------------
89
+
90
+ function cmdInit(opts) {
91
+ ensureStore();
92
+
93
+ // Registered before the compact below, so the very first run already writes the
94
+ // digest into each skill description rather than leaving it as a placeholder.
95
+ let registered = [];
96
+ if (typeof opts.skills === 'string') {
97
+ const existing = loadConfig().skillPaths || [];
98
+ const incoming = opts.skills.split(',').map((s) => s.trim()).filter(Boolean);
99
+ registered = [...new Set([...existing, ...incoming])];
100
+ saveConfig({ skillPaths: registered });
101
+ }
102
+
103
+ const db = openDb();
104
+ const result = compact({ db });
105
+ db.close();
106
+ return {
107
+ ok: true,
108
+ store: paths.root,
109
+ notes: result.indexed,
110
+ skills: registered,
111
+ digest: result.digest,
112
+ text: [
113
+ `Store ready at ${paths.root}`,
114
+ `${result.indexed} notes indexed.`,
115
+ ...registered.map((s) => `registered skill ${s}`),
116
+ ].join('\n'),
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Link the skills into both agents and build the store.
122
+ *
123
+ * Runs automatically from npm postinstall, but exists as a command because managed
124
+ * npm configurations often set `ignore-scripts=true`, which skips postinstall with
125
+ * no warning. When that happens the recovery is one command rather than hunting for
126
+ * a shell script inside a global node_modules directory.
127
+ */
128
+ function cmdSetup() {
129
+ ensureStore();
130
+ const r = runSetup({ compactFn: () => compact() });
131
+
132
+ const lines = [];
133
+ for (const t of r.targets) {
134
+ lines.push(` ${t.label}`);
135
+ for (const s of r.installed.filter((i) => i.target === t.id)) {
136
+ lines.push(` [${s.mode}] ${s.path}`);
137
+ }
138
+ }
139
+
140
+ // Detected but not written to. Saying so is the point: silence would read as
141
+ // "supported" for a tool we deliberately skipped.
142
+ const skipped = detectTargets().filter((t) => t.kind === 'unsupported');
143
+ if (skipped.length) {
144
+ lines.push('', ' Detected but not installable:');
145
+ for (const t of skipped) lines.push(` ${t.label} — ${t.note}`);
146
+ }
147
+ if (r.copies.length) {
148
+ lines.push(
149
+ '',
150
+ ' Some skills were copied rather than linked, which happens on a network-backed',
151
+ ' profile. Re-run `agent-memory setup` after upgrading to refresh them.',
152
+ );
153
+ }
154
+
155
+ return {
156
+ ok: true,
157
+ ...r,
158
+ text: [
159
+ `Installed for ${r.targets.length} agent${r.targets.length === 1 ? '' : 's'}:`,
160
+ ...lines,
161
+ '',
162
+ `Store ready at ${paths.root} (${r.notes} notes).`,
163
+ 'Restart your editor, then try /recall, /remember or /handoff.',
164
+ ].join('\n'),
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Remove the skill links, leaving every note in place.
170
+ *
171
+ * Runs from npm's preuninstall hook so that `npm uninstall -g` does not leave links
172
+ * pointing into a deleted package. The store is deliberately untouched: it is plain
173
+ * markdown, it is the user's own writing, and it stays readable with no tooling.
174
+ */
175
+ function cmdUninstall() {
176
+ const r = unlinkSkills();
177
+ return {
178
+ ok: true,
179
+ ...r,
180
+ store: paths.root,
181
+ text: [
182
+ ...r.removed.map((p) => ` [removed] ${p}`),
183
+ ...r.kept.map((p) => ` [kept] ${p} (not ours)`),
184
+ r.removed.length ? '' : 'No skill links found.',
185
+ `Your notes are untouched at ${paths.root}.`,
186
+ 'They are plain markdown and stay readable with nothing installed.',
187
+ ].filter(Boolean).join('\n'),
188
+ };
189
+ }
190
+
191
+ function cmdIndex() {
192
+ const db = openDb({ reindexOnCreate: false });
193
+ const r = reindex(db);
194
+ db.close();
195
+ const warnings = [
196
+ ...r.malformed.map((m) => `unparseable: ${m.path} (${m.error})`),
197
+ ...r.duplicates.map((d) => `duplicate id ${d.id}: kept ${d.kept}, ignored ${d.dropped}`),
198
+ ];
199
+ return {
200
+ ok: r.malformed.length === 0,
201
+ indexed: r.indexed,
202
+ warnings,
203
+ text: [`${r.indexed} notes indexed.`, ...warnings].join('\n'),
204
+ };
205
+ }
206
+
207
+ function cmdTree(opts) {
208
+ const cfg = loadConfig();
209
+ const db = openDb();
210
+ const repo = opts.repo === true ? null : (opts.repo ?? currentRepo());
211
+ const result = buildTree(db, { repo, all: !!opts.all, cfg });
212
+ db.close();
213
+ return { ok: true, ...result, text: renderTree(result) };
214
+ }
215
+
216
+ function cmdGet(opts) {
217
+ const cfg = loadConfig();
218
+ const id = opts._[0];
219
+ if (!id) return { ok: false, error: 'get requires a node id', text: 'get requires a node id' };
220
+
221
+ const db = openDb();
222
+ const includeArchived = !!opts['include-archived'];
223
+ // Looked up permissively so that an archived node can be reported as archived.
224
+ // Without this the traversal below would silently prune the root and return an
225
+ // empty result that reads exactly like "this note does not exist".
226
+ const root = getNodeRow(db, id, { includeArchived: true });
227
+ if (root?.archived && !includeArchived) {
228
+ db.close();
229
+ return {
230
+ ok: false,
231
+ error: `${id} is archived`,
232
+ archived: true,
233
+ text: `${id} is archived (superseded, merged, or decayed).\nRetrieve it with: agent-memory get ${id} --include-archived`,
234
+ };
235
+ }
236
+ if (!root) {
237
+ // A miss is a routing failure, not a dead end. Offer what search would find.
238
+ const near = searchNodes(db, id, { limit: 5 }).map((n) => ({ id: n.id, title: n.title }));
239
+ db.close();
240
+ return {
241
+ ok: false,
242
+ error: `no note with id ${id}`,
243
+ suggestions: near,
244
+ text: [`No note with id ${id}.`, ...near.map((n) => ` did you mean ${n.id} — ${n.title}`)].join('\n'),
245
+ };
246
+ }
247
+
248
+ const depth = num(opts.depth, 1);
249
+ const budget = num(opts.budget, cfg.getBudgetBytes);
250
+ const hood = neighborhood(db, id, { depth, includeArchived });
251
+ const { kept, omitted, overBudget, bytes } = applyBudget(hood, budget);
252
+
253
+ markAccessed(db, kept.map((n) => n.id));
254
+ const cwd = process.cwd();
255
+ const repo = currentRepo(cwd);
256
+ const nodes = kept.map((n) => ({ ...n, stale: staleness(n, { cwd, cfg, repo }) }));
257
+ db.close();
258
+
259
+ const lines = [];
260
+ for (const n of nodes) {
261
+ const note = n.stale ? ` [${n.stale.note}]` : '';
262
+ lines.push(`## ${n.id} [${n.type}] depth ${n.depth}${note}`);
263
+ lines.push(n.title);
264
+ lines.push('');
265
+ lines.push(n.body);
266
+ if (n.edges.length) lines.push('', ...n.edges.map((e) => ` -> ${e.rel} ${e.dst}`));
267
+ lines.push('');
268
+ }
269
+ if (omitted.length) {
270
+ lines.push(`${omitted.length} nodes omitted for budget: ${omitted.map((o) => o.id).join(', ')}`);
271
+ lines.push('Ask for one by id with: agent-memory get <id>');
272
+ }
273
+ if (overBudget) {
274
+ lines.push(
275
+ `Note: ${bytes} bytes returned, over the ${budget} budget, because constraints are never dropped.`,
276
+ );
277
+ }
278
+ return { ok: true, root: id, depth, bytes, nodes, omitted, overBudget, text: lines.join('\n') };
279
+ }
280
+
281
+ function cmdSearch(opts) {
282
+ const terms = opts._.join(' ');
283
+ const db = openDb();
284
+ const hits = searchNodes(db, terms, {
285
+ limit: num(opts.limit, 10),
286
+ includeArchived: !!opts['include-archived'],
287
+ });
288
+ db.close();
289
+ return {
290
+ ok: true,
291
+ query: terms,
292
+ hits: hits.map((n) => ({ id: n.id, type: n.type, title: n.title })),
293
+ text: hits.length
294
+ ? hits.map((n) => `${n.type.padEnd(10)} ${n.id} ${n.title}`).join('\n')
295
+ : `No match for ${JSON.stringify(terms)}.`,
296
+ };
297
+ }
298
+
299
+ function readNodesFrom(file) {
300
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
301
+ if (Array.isArray(raw)) return raw;
302
+ if (Array.isArray(raw?.nodes)) return raw.nodes;
303
+ return [raw];
304
+ }
305
+
306
+ function cmdWrite(opts) {
307
+ const file = opts['from-json'];
308
+ if (!file || file === true || !existsSync(file)) {
309
+ return {
310
+ ok: false,
311
+ error: 'write requires --from-json <file>',
312
+ text: 'write requires --from-json <file>',
313
+ };
314
+ }
315
+
316
+ const cfg = loadConfig();
317
+ const cwd = process.cwd();
318
+ const repo = opts.repo === true ? currentRepo(cwd) : (opts.repo ?? currentRepo(cwd));
319
+ const sha = git(['rev-parse', 'HEAD'], cwd);
320
+ const selfEmail = git(['config', 'user.email'], cwd) || '';
321
+
322
+ let incoming;
323
+ try {
324
+ incoming = readNodesFrom(file);
325
+ } catch (err) {
326
+ return {
327
+ ok: false,
328
+ error: `unreadable JSON: ${err.message}`,
329
+ text: `Unreadable JSON: ${err.message}`,
330
+ };
331
+ }
332
+
333
+ const db = openDb();
334
+ const before = nodeCount(db, { includeArchived: true });
335
+
336
+ // Normalized titles of what already exists, so two agents naming one thing two
337
+ // different ways surface as a collision instead of quietly becoming two nodes.
338
+ const titles = new Map();
339
+ for (const row of db.prepare('SELECT id, title, content_hash FROM nodes').all()) {
340
+ titles.set(normalizeTitle(row.title), { id: row.id, hash: row.content_hash });
341
+ }
342
+
343
+ const written = [];
344
+ const failed = [];
345
+ const warnings = [];
346
+ for (const raw of incoming) {
347
+ const node = { ...raw };
348
+ node.source = node.source || (opts.source === true ? undefined : opts.source) || 'manual';
349
+ if (repo && !node.repos?.length && node.scope !== 'global') node.repos = [repo];
350
+ // A commit sha only means something for a note about this repository.
351
+ if (!node.captured_sha && sha && repo && (node.repos || []).includes(repo)) {
352
+ node.captured_sha = sha;
353
+ }
354
+
355
+ const collision = titles.get(normalizeTitle(node.title));
356
+ try {
357
+ const res = writeNote(node, { selfEmail });
358
+ if (collision && collision.id !== res.node.id) {
359
+ warnings.push(
360
+ `title collision: ${res.node.id} reads the same as existing ${collision.id}; ` +
361
+ 'set contradicts or merge them',
362
+ );
363
+ }
364
+ written.push({
365
+ id: res.node.id,
366
+ type: res.node.type,
367
+ created: res.created,
368
+ redacted: res.findings,
369
+ });
370
+ if (res.findings.length) {
371
+ warnings.push(
372
+ `${res.node.id}: redacted ${res.findings.map((f) => `${f.count}x ${f.kind}`).join(', ')}`,
373
+ );
374
+ }
375
+ } catch (err) {
376
+ failed.push({ id: raw?.id ?? null, errors: err.errors ?? [err.message] });
377
+ }
378
+ }
379
+
380
+ reindex(db);
381
+ const after = nodeCount(db, { includeArchived: true });
382
+ const compacted = maybeCompact(db, before, after, cfg);
383
+ db.close();
384
+
385
+ const text = [
386
+ ...written.map((w) => `${w.created ? 'created' : 'updated'} ${w.id} [${w.type}]`),
387
+ ...warnings.map((w) => `warning: ${w}`),
388
+ ...failed.map((f) => `failed ${f.id ?? '<no id>'}: ${f.errors.join('; ')}`),
389
+ written.length ? '' : 'No nodes written.',
390
+ compacted ? `compacted: ${compacted.indexed} notes indexed` : '',
391
+ ]
392
+ .filter(Boolean)
393
+ .join('\n');
394
+
395
+ return { ok: failed.length === 0, written, failed, warnings, compacted: !!compacted, text };
396
+ }
397
+
398
+ function cmdCompact() {
399
+ const r = compact();
400
+ const text = [
401
+ `${r.indexed} notes indexed.`,
402
+ ...r.merged.map((m) => `merged ${m.id} into ${m.into}`),
403
+ ...r.superseded.map((s) => `archived ${s.id}, superseded by ${s.by}`),
404
+ ...r.decayed.map((d) => `archived ${d.id}, last seen ${d.lastSeen}`),
405
+ ...r.malformed.map((m) => `warning: unparseable ${m.path}`),
406
+ `digest ${r.digestChars} chars`,
407
+ ...r.skills.map((s) => `updated description in ${s}`),
408
+ ].join('\n');
409
+ return { ok: true, ...r, text };
410
+ }
411
+
412
+ function cmdDoctor() {
413
+ const cfg = loadConfig();
414
+ const checks = [];
415
+ const add = (name, ok, detail) => checks.push({ name, ok, detail });
416
+
417
+ add('node version', nodeVersionOk(), `${process.versions.node} (need >= ${MIN_NODE.join('.')})`);
418
+ if (!nodeVersionOk()) {
419
+ return {
420
+ ok: false,
421
+ checks,
422
+ text:
423
+ `FAIL node version: ${process.versions.node}, need >= ${MIN_NODE.join('.')}.\n` +
424
+ 'node:sqlite ships in Node core from 22.5 onward; there is no dependency to install.',
425
+ };
426
+ }
427
+
428
+ ensureStore();
429
+ add('store path', existsSync(paths.root), paths.root);
430
+
431
+ const db = openDb();
432
+ const integrity = db.prepare('PRAGMA integrity_check').get()?.integrity_check;
433
+ add('index integrity', integrity === 'ok', String(integrity));
434
+ add('fts5', hasFts(), hasFts() ? 'available' : 'unavailable, search falls back to LIKE');
435
+
436
+ const notes = listNotes();
437
+ const malformed = notes.filter((n) => n.__error);
438
+ add(
439
+ 'notes parse',
440
+ malformed.length === 0,
441
+ malformed.length ? malformed.map((m) => m.path).join(', ') : `${notes.length} notes`,
442
+ );
443
+
444
+ const active = nodeCount(db);
445
+ const counts = NOTE_TYPES.map(
446
+ (t) => `${t} ${db.prepare('SELECT COUNT(*) AS c FROM nodes WHERE archived = 0 AND type = ?').get(t).c}`,
447
+ ).join(', ');
448
+ add('active notes', true, `${active} (${counts})`);
449
+
450
+ const digest = buildDigest(db, { cfg });
451
+ add('digest within cap', digest.length <= cfg.digestChars, `${digest.length}/${cfg.digestChars} chars`);
452
+
453
+ const registered = cfg.skillPaths || [];
454
+ const missing = registered.filter((p) => !existsSync(p));
455
+ add(
456
+ 'skills linked',
457
+ registered.length > 0 && missing.length === 0,
458
+ // Name what is broken. Listing the paths that resolve while reporting a failure
459
+ // sends the reader to look at the one file that is fine.
460
+ registered.length === 0
461
+ ? 'none registered; run `agent-memory setup`'
462
+ : missing.length
463
+ ? `${missing.join(', ')} no longer exists — run \`agent-memory setup\``
464
+ : registered.join(', '),
465
+ );
466
+
467
+ // Which agents are actually on this machine, so a user who installed and saw
468
+ // nothing can tell "we did not find your editor" from "your editor ignored us".
469
+ const agents = detectTargets();
470
+ add(
471
+ 'agents detected',
472
+ agents.some((t) => t.kind !== 'unsupported'),
473
+ agents.map((t) => `${t.label}${t.kind === 'unsupported' ? ' (skipped)' : ''}`).join('; '),
474
+ );
475
+
476
+ const dangling = danglingSkillLinks();
477
+ add(
478
+ 'no broken skill links',
479
+ dangling.length === 0,
480
+ dangling.length
481
+ ? `${dangling.join(', ')} — leftovers from an uninstall; remove them or run \`agent-memory setup\``
482
+ : 'none',
483
+ );
484
+
485
+ const stale = reviewCandidates(db, { cfg });
486
+ add(
487
+ 'staleness',
488
+ stale.length === 0,
489
+ stale.length ? `${stale.length} notes worth reviewing` : 'nothing far behind HEAD',
490
+ );
491
+ db.close();
492
+
493
+ const text = [
494
+ ...checks.map((c) => `${c.ok ? 'ok ' : 'FAIL'} ${c.name}: ${c.detail}`),
495
+ ...stale.map(
496
+ (s) => ` review ${s.id} — ${s.reason}${s.commits === null ? '' : ` (${s.commits} commits)`}`,
497
+ ),
498
+ ].join('\n');
499
+
500
+ // Staleness is a report, not a failure. Being told about it is the whole feature.
501
+ const advisory = new Set(['staleness', 'skills linked']);
502
+ const fatal = checks.filter((c) => !c.ok && !advisory.has(c.name));
503
+ return { ok: fatal.length === 0, checks, stale, digest, text };
504
+ }
505
+
506
+ // --- dispatch ---------------------------------------------------------------
507
+
508
+ const COMMANDS = {
509
+ setup: cmdSetup,
510
+ uninstall: cmdUninstall,
511
+ init: cmdInit,
512
+ index: cmdIndex,
513
+ tree: cmdTree,
514
+ get: cmdGet,
515
+ search: cmdSearch,
516
+ write: cmdWrite,
517
+ compact: cmdCompact,
518
+ doctor: cmdDoctor,
519
+ };
520
+
521
+ function main(argv) {
522
+ const [cmd, ...rest] = argv;
523
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
524
+ process.stdout.write(`${USAGE}\n`);
525
+ return 0;
526
+ }
527
+ const fn = COMMANDS[cmd];
528
+ if (!fn) {
529
+ process.stderr.write(`Unknown command ${JSON.stringify(cmd)}.\n\n${USAGE}\n`);
530
+ return 2;
531
+ }
532
+ // doctor is exempt: reporting the wrong Node version is precisely its job.
533
+ if (cmd !== 'doctor' && !nodeVersionOk()) {
534
+ process.stderr.write(
535
+ `Node ${process.versions.node} is too old; agent-memory needs >= ${MIN_NODE.join('.')} for node:sqlite.\n`,
536
+ );
537
+ return 1;
538
+ }
539
+
540
+ const opts = parseArgs(rest);
541
+ let result;
542
+ try {
543
+ result = fn(opts);
544
+ } catch (err) {
545
+ result = { ok: false, error: err.message, text: `Error: ${err.message}` };
546
+ }
547
+
548
+ if (opts.json) {
549
+ const { text: _text, ...payload } = result;
550
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
551
+ } else {
552
+ const out = result.text ?? JSON.stringify(result, null, 2);
553
+ (result.ok === false ? process.stderr : process.stdout).write(`${out}\n`);
554
+ }
555
+ return result.ok === false ? 1 : 0;
556
+ }
557
+
558
+ process.exitCode = main(process.argv.slice(2));