quilltap 4.5.0-dev → 4.5.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.
@@ -0,0 +1,1374 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ resolveDataDirAndPassphrase,
5
+ printDefaultInstanceHint,
6
+ loadDbKey,
7
+ openMainDb,
8
+ UUID_RE,
9
+ resolveCharacter,
10
+ resolveChat,
11
+ resolveProject,
12
+ } = require('./db-helpers');
13
+ const { scanDanglingEdges } = require('./graph-integrity');
14
+
15
+ // ---------- colour & marker helpers ----------
16
+
17
+ const RESET = '\x1b[0m';
18
+ const BOLD = '\x1b[1m';
19
+ const DIM = '\x1b[2m';
20
+ const GREEN = '\x1b[32m';
21
+ const RED = '\x1b[31m';
22
+ const YELLOW = '\x1b[33m';
23
+ const CYAN = '\x1b[36m';
24
+
25
+ function isTty() {
26
+ return Boolean(process.stdout.isTTY);
27
+ }
28
+
29
+ function colorize(text, color) {
30
+ if (!isTty()) return text;
31
+ return `${color}${text}${RESET}`;
32
+ }
33
+
34
+ function importanceColor(v) {
35
+ if (v == null) return DIM;
36
+ if (v >= 0.7) return GREEN;
37
+ if (v >= 0.4) return YELLOW;
38
+ return DIM + RED;
39
+ }
40
+
41
+ function formatImportance(v) {
42
+ if (v == null) return ' -';
43
+ const s = Number(v).toFixed(2);
44
+ return s.padStart(4);
45
+ }
46
+
47
+ // ---------- argument parsing ----------
48
+
49
+ const SORT_FIELDS = new Set([
50
+ 'reinforced', 'importance', 'created', 'accessed', 'reinforcement-count', 'links',
51
+ ]);
52
+
53
+ function parseFlags(args) {
54
+ const flags = {
55
+ // globals
56
+ dataDir: '',
57
+ instance: '',
58
+ passphrase: '',
59
+ json: false,
60
+ help: false,
61
+ // shared filters
62
+ character: '',
63
+ about: '',
64
+ source: '',
65
+ chat: '',
66
+ project: '',
67
+ since: '',
68
+ until: '',
69
+ minImportance: null,
70
+ minReinforced: null,
71
+ hasEmbedding: null, // null = unset, true / false otherwise
72
+ // ls / find / grep
73
+ sort: '',
74
+ reverse: false,
75
+ limit: 0,
76
+ fullTitles: false,
77
+ // find
78
+ findIn: '',
79
+ // grep
80
+ ignoreCase: false,
81
+ pathsOnly: false,
82
+ max: 0,
83
+ context: 0,
84
+ // show / tree
85
+ depth: -1,
86
+ maxNodes: 0,
87
+ noRelated: false,
88
+ // validate
89
+ list: false,
90
+ // semantic search
91
+ semantic: false,
92
+ top: 0,
93
+ threshold: -1,
94
+ port: 3000,
95
+ };
96
+ const positional = [];
97
+ let i = 0;
98
+ while (i < args.length) {
99
+ const a = args[i];
100
+ switch (a) {
101
+ case '-d': case '--data-dir': flags.dataDir = args[++i]; break;
102
+ case '--instance': flags.instance = args[++i]; break;
103
+ case '--passphrase': flags.passphrase = args[++i]; break;
104
+ case '--json': flags.json = true; break;
105
+ case '-h': case '--help': flags.help = true; break;
106
+
107
+ case '--character': flags.character = args[++i]; break;
108
+ case '--about': flags.about = args[++i]; break;
109
+ case '--source': flags.source = args[++i]; break;
110
+ case '--chat': flags.chat = args[++i]; break;
111
+ case '--project': flags.project = args[++i]; break;
112
+ case '--since': flags.since = args[++i]; break;
113
+ case '--until': flags.until = args[++i]; break;
114
+ case '--min-importance': flags.minImportance = parseFloat(args[++i]); break;
115
+ case '--min-reinforced': flags.minReinforced = parseFloat(args[++i]); break;
116
+ case '--has-embedding': flags.hasEmbedding = true; break;
117
+ case '--no-embedding': flags.hasEmbedding = false; break;
118
+
119
+ case '--sort': flags.sort = args[++i]; break;
120
+ case '-r': case '--reverse': flags.reverse = true; break;
121
+ case '--limit': flags.limit = parseInt(args[++i], 10) || 0; break;
122
+ case '--full-titles': flags.fullTitles = true; break;
123
+
124
+ case '--in': flags.findIn = args[++i]; break;
125
+
126
+ case '-i': case '--ignore-case': flags.ignoreCase = true; break;
127
+ case '-l': case '--paths-only': flags.pathsOnly = true; break;
128
+ case '--max': flags.max = parseInt(args[++i], 10) || 0; break;
129
+ case '--context': flags.context = parseInt(args[++i], 10) || 0; break;
130
+
131
+ case '--depth': flags.depth = parseInt(args[++i], 10); break;
132
+ case '--max-nodes': flags.maxNodes = parseInt(args[++i], 10) || 0; break;
133
+ case '--no-related': flags.noRelated = true; break;
134
+
135
+ case '--list': flags.list = true; break;
136
+
137
+ case '--semantic': flags.semantic = true; break;
138
+ case '--top': flags.top = parseInt(args[++i], 10) || 0; break;
139
+ case '--threshold': {
140
+ const v = parseFloat(args[++i]);
141
+ if (isNaN(v) || v < 0 || v > 1) {
142
+ console.error('Error: --threshold must be a number between 0 and 1');
143
+ process.exit(1);
144
+ }
145
+ flags.threshold = v;
146
+ break;
147
+ }
148
+ case '--port': {
149
+ const p = parseInt(args[++i], 10);
150
+ if (isNaN(p) || p < 1 || p > 65535) {
151
+ console.error('Error: --port must be between 1 and 65535');
152
+ process.exit(1);
153
+ }
154
+ flags.port = p;
155
+ break;
156
+ }
157
+
158
+ default:
159
+ if (a.startsWith('-')) {
160
+ console.error(`Unknown option: ${a}`);
161
+ process.exit(1);
162
+ }
163
+ positional.push(a);
164
+ }
165
+ i++;
166
+ }
167
+ return { flags, positional };
168
+ }
169
+
170
+ // ---------- db open ----------
171
+
172
+ async function openDb(flags) {
173
+ const resolved = resolveDataDirAndPassphrase({
174
+ dataDir: flags.dataDir,
175
+ instance: flags.instance,
176
+ passphrase: flags.passphrase,
177
+ });
178
+ printDefaultInstanceHint(resolved);
179
+ const { dataDir, passphrase } = resolved;
180
+ const pepper = await loadDbKey(dataDir, passphrase);
181
+ const db = openMainDb(dataDir, pepper, { readonly: true });
182
+ return { db, dataDir };
183
+ }
184
+
185
+ // ---------- filter / sort builders ----------
186
+
187
+ // Build a SQL WHERE clause + parameters from parsed flags. Returns
188
+ // `{ where: 'WHERE m.x = ? AND ...', params: [...], meta: { characterId, ... } }`.
189
+ // `meta` exposes resolved IDs so callers can decide e.g. whether to show a
190
+ // per-row holder column.
191
+ function buildWhereClause(db, flags) {
192
+ const clauses = [];
193
+ const params = [];
194
+ const meta = { characterId: null, aboutId: null, chatId: null, projectId: null, allCharacters: true };
195
+
196
+ if (flags.character && flags.character !== 'all') {
197
+ const c = resolveCharacter(db, flags.character);
198
+ clauses.push('m.characterId = ?');
199
+ params.push(c.id);
200
+ meta.characterId = c.id;
201
+ meta.allCharacters = false;
202
+ }
203
+
204
+ if (flags.about) {
205
+ if (flags.about === 'self') {
206
+ clauses.push('m.aboutCharacterId = m.characterId');
207
+ } else if (flags.about === 'none') {
208
+ clauses.push('m.aboutCharacterId IS NULL');
209
+ } else {
210
+ const a = resolveCharacter(db, flags.about);
211
+ clauses.push('m.aboutCharacterId = ?');
212
+ params.push(a.id);
213
+ meta.aboutId = a.id;
214
+ }
215
+ }
216
+
217
+ if (flags.source) {
218
+ const s = String(flags.source).toUpperCase();
219
+ if (s !== 'AUTO' && s !== 'MANUAL') {
220
+ throw new Error(`--source must be AUTO or MANUAL (got '${flags.source}')`);
221
+ }
222
+ clauses.push('m.source = ?');
223
+ params.push(s);
224
+ }
225
+
226
+ if (flags.chat) {
227
+ if (flags.chat === 'none') {
228
+ clauses.push('m.chatId IS NULL');
229
+ } else {
230
+ const ch = resolveChat(db, flags.chat);
231
+ clauses.push('m.chatId = ?');
232
+ params.push(ch.id);
233
+ meta.chatId = ch.id;
234
+ }
235
+ }
236
+
237
+ if (flags.project) {
238
+ const p = resolveProject(db, flags.project);
239
+ clauses.push('m.projectId = ?');
240
+ params.push(p.id);
241
+ meta.projectId = p.id;
242
+ }
243
+
244
+ if (flags.since) {
245
+ clauses.push('m.createdAt >= ?');
246
+ params.push(flags.since);
247
+ }
248
+ if (flags.until) {
249
+ clauses.push('m.createdAt <= ?');
250
+ params.push(flags.until);
251
+ }
252
+
253
+ if (flags.minImportance != null && !Number.isNaN(flags.minImportance)) {
254
+ clauses.push('m.importance >= ?');
255
+ params.push(flags.minImportance);
256
+ }
257
+ if (flags.minReinforced != null && !Number.isNaN(flags.minReinforced)) {
258
+ clauses.push('m.reinforcedImportance >= ?');
259
+ params.push(flags.minReinforced);
260
+ }
261
+
262
+ if (flags.hasEmbedding === true) {
263
+ clauses.push('m.embedding IS NOT NULL');
264
+ } else if (flags.hasEmbedding === false) {
265
+ clauses.push('m.embedding IS NULL');
266
+ }
267
+
268
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
269
+ return { where, params, meta };
270
+ }
271
+
272
+ function buildOrderBy(sortFlag, reverse) {
273
+ // SQL ORDER BY fragment + which column the rendered `imp` field reflects.
274
+ // Default is `reinforced`, which is what the recall path uses.
275
+ const field = sortFlag || 'reinforced';
276
+ let order;
277
+ let impField = 'reinforcedImportance';
278
+ switch (field) {
279
+ case 'reinforced':
280
+ order = 'm.reinforcedImportance DESC, m.createdAt DESC';
281
+ break;
282
+ case 'importance':
283
+ order = 'm.importance DESC, m.createdAt DESC';
284
+ impField = 'importance';
285
+ break;
286
+ case 'created':
287
+ order = 'm.createdAt DESC';
288
+ break;
289
+ case 'accessed':
290
+ order = 'COALESCE(m.lastAccessedAt, m.createdAt) DESC';
291
+ break;
292
+ case 'reinforcement-count':
293
+ order = 'm.reinforcementCount DESC, m.reinforcedImportance DESC';
294
+ break;
295
+ case 'links':
296
+ // SQLite's json_array_length tolerates NULL / empty arrays.
297
+ order = "json_array_length(COALESCE(m.relatedMemoryIds, '[]')) DESC, m.reinforcedImportance DESC";
298
+ break;
299
+ default:
300
+ throw new Error(`Unknown --sort field '${field}'. Valid: ${[...SORT_FIELDS].join(', ')}`);
301
+ }
302
+ if (reverse) {
303
+ // Flip every DESC→ASC and ASC→DESC in the fragment.
304
+ order = order.replace(/\bDESC\b/g, '__ASC__').replace(/\bASC\b/g, 'DESC').replace(/__ASC__/g, 'ASC');
305
+ }
306
+ return { order, impField };
307
+ }
308
+
309
+ // SELECT fragment that pulls every column the renderers need, including the
310
+ // joined-in character / chat names. `m.*` is followed by aliases for the joins
311
+ // so callers don't have to re-resolve UUIDs.
312
+ const SELECT_BASE = `
313
+ SELECT
314
+ m.id,
315
+ m.characterId,
316
+ m.aboutCharacterId,
317
+ m.chatId,
318
+ m.projectId,
319
+ m.sourceMessageId,
320
+ m.content,
321
+ m.summary,
322
+ m.keywords,
323
+ m.tags,
324
+ m.importance,
325
+ m.reinforcedImportance,
326
+ m.reinforcementCount,
327
+ m.lastReinforcedAt,
328
+ m.lastAccessedAt,
329
+ m.source,
330
+ m.relatedMemoryIds,
331
+ m.createdAt,
332
+ m.updatedAt,
333
+ CASE WHEN m.embedding IS NULL THEN 0 ELSE 1 END AS hasEmbedding,
334
+ holder.name AS holderName,
335
+ aboutChar.name AS aboutCharName,
336
+ chats.title AS chatTitle,
337
+ projects.name AS projectName
338
+ FROM memories m
339
+ LEFT JOIN characters holder ON holder.id = m.characterId
340
+ LEFT JOIN characters aboutChar ON aboutChar.id = m.aboutCharacterId
341
+ LEFT JOIN chats ON chats.id = m.chatId
342
+ LEFT JOIN projects ON projects.id = m.projectId
343
+ `;
344
+
345
+ // ---------- output helpers ----------
346
+
347
+ function truncate(str, n) {
348
+ if (str == null) return '';
349
+ const s = String(str);
350
+ if (s.length <= n) return s;
351
+ return s.slice(0, n - 1) + '…';
352
+ }
353
+
354
+ function aboutLabel(row) {
355
+ if (row.aboutCharacterId == null) return '(none)';
356
+ if (row.aboutCharacterId === row.characterId) return 'self';
357
+ return row.aboutCharName || '(unknown)';
358
+ }
359
+
360
+ function chatLabel(row, fullTitles) {
361
+ if (row.chatId == null) return '(manual entry)';
362
+ const t = row.chatTitle || '(untitled)';
363
+ return fullTitles ? t : truncate(t, 32);
364
+ }
365
+
366
+ function linkCount(row) {
367
+ if (!row.relatedMemoryIds) return 0;
368
+ try {
369
+ const arr = JSON.parse(row.relatedMemoryIds);
370
+ return Array.isArray(arr) ? arr.length : 0;
371
+ } catch {
372
+ return 0;
373
+ }
374
+ }
375
+
376
+ function shortId(id) {
377
+ return id ? id.slice(0, 8) : '';
378
+ }
379
+
380
+ function summaryWidth() {
381
+ const cols = process.stdout.columns || 120;
382
+ // Reserve enough for the other columns; let summary take the rest.
383
+ // Minimum 30, maximum 120.
384
+ const reserved = 80;
385
+ const w = cols - reserved;
386
+ if (w < 30) return 30;
387
+ if (w > 120) return 120;
388
+ return w;
389
+ }
390
+
391
+ function renderJson(obj) {
392
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
393
+ }
394
+
395
+ // ---------- ls ----------
396
+
397
+ async function cmdLs(flags) {
398
+ const { db } = await openDb(flags);
399
+ try {
400
+ const { where, params, meta } = buildWhereClause(db, flags);
401
+ const { order, impField } = buildOrderBy(flags.sort, flags.reverse);
402
+ const limit = flags.limit > 0 ? flags.limit : 50;
403
+ const sql = `${SELECT_BASE} ${where} ORDER BY ${order} LIMIT ?`;
404
+ const rows = db.prepare(sql).all(...params, limit);
405
+
406
+ if (flags.json) {
407
+ renderJson({
408
+ sort: impField,
409
+ count: rows.length,
410
+ memories: rows.map(rowToJson),
411
+ });
412
+ return;
413
+ }
414
+
415
+ renderTable(rows, { showHolder: meta.allCharacters, impField, fullTitles: flags.fullTitles });
416
+ } finally {
417
+ db.close();
418
+ }
419
+ }
420
+
421
+ function rowToJson(row) {
422
+ let keywords = [];
423
+ let tags = [];
424
+ let relatedMemoryIds = [];
425
+ try { keywords = JSON.parse(row.keywords || '[]'); } catch { /* ignore */ }
426
+ try { tags = JSON.parse(row.tags || '[]'); } catch { /* ignore */ }
427
+ try { relatedMemoryIds = JSON.parse(row.relatedMemoryIds || '[]'); } catch { /* ignore */ }
428
+ return {
429
+ id: row.id,
430
+ characterId: row.characterId,
431
+ holder: row.holderName,
432
+ aboutCharacterId: row.aboutCharacterId,
433
+ aboutCharacter: row.aboutCharName,
434
+ chatId: row.chatId,
435
+ chatTitle: row.chatTitle,
436
+ projectId: row.projectId,
437
+ projectName: row.projectName,
438
+ sourceMessageId: row.sourceMessageId,
439
+ source: row.source,
440
+ importance: row.importance,
441
+ reinforcedImportance: row.reinforcedImportance,
442
+ reinforcementCount: row.reinforcementCount,
443
+ lastReinforcedAt: row.lastReinforcedAt,
444
+ lastAccessedAt: row.lastAccessedAt,
445
+ hasEmbedding: Boolean(row.hasEmbedding),
446
+ keywords,
447
+ tags,
448
+ relatedMemoryIds,
449
+ summary: row.summary,
450
+ content: row.content,
451
+ createdAt: row.createdAt,
452
+ updatedAt: row.updatedAt,
453
+ };
454
+ }
455
+
456
+ function renderTable(rows, { showHolder, impField, fullTitles }) {
457
+ if (rows.length === 0) {
458
+ console.log('(no memories matched the filters)');
459
+ return;
460
+ }
461
+ const impHeader = impField === 'importance' ? 'imp' : 'imp';
462
+ const sumW = summaryWidth();
463
+
464
+ const header = [
465
+ showHolder ? 'holder'.padEnd(14) : null,
466
+ impHeader.padStart(4),
467
+ 'rein'.padStart(4),
468
+ 'src'.padEnd(6),
469
+ 'about'.padEnd(20),
470
+ 'chat'.padEnd(fullTitles ? 24 : 32),
471
+ 'links'.padStart(5),
472
+ 'emb',
473
+ 'summary',
474
+ ].filter(Boolean).join(' ');
475
+ console.log(colorize(header, DIM));
476
+ console.log(colorize(header.replace(/[^\s]/g, '-'), DIM));
477
+
478
+ for (const row of rows) {
479
+ const impVal = impField === 'importance' ? row.importance : row.reinforcedImportance;
480
+ const impStr = colorize(formatImportance(impVal), importanceColor(impVal));
481
+ const rein = String(row.reinforcementCount ?? 0).padStart(4);
482
+ const src = (row.source || '').padEnd(6);
483
+ const about = truncate(aboutLabel(row), 20).padEnd(20);
484
+ const chat = truncate(chatLabel(row, fullTitles), fullTitles ? 24 : 32).padEnd(fullTitles ? 24 : 32);
485
+ const links = String(linkCount(row)).padStart(5);
486
+ const emb = row.hasEmbedding ? 'Y' : '-';
487
+ const summary = truncate(row.summary || '', sumW);
488
+
489
+ const cells = [
490
+ showHolder ? truncate(row.holderName || '(?)', 14).padEnd(14) : null,
491
+ impStr,
492
+ rein,
493
+ src,
494
+ about,
495
+ chat,
496
+ links,
497
+ emb,
498
+ summary,
499
+ ].filter(c => c !== null);
500
+ console.log(cells.join(' '));
501
+ }
502
+ }
503
+
504
+ // ---------- find ----------
505
+
506
+ async function cmdFind(flags, positional) {
507
+ const pattern = positional[0];
508
+ if (!pattern) {
509
+ throw new Error('memories find: missing <pattern>. Usage: quilltap memories find [filters] <pattern>');
510
+ }
511
+ const inWhere = flags.findIn || 'summary';
512
+ if (!['summary', 'content', 'both'].includes(inWhere)) {
513
+ throw new Error(`--in must be one of: summary, content, both (got '${inWhere}')`);
514
+ }
515
+ const { db } = await openDb(flags);
516
+ try {
517
+ const { where, params, meta } = buildWhereClause(db, flags);
518
+ const like = `%${pattern}%`;
519
+
520
+ const matchClauses = [];
521
+ const matchParams = [];
522
+ if (inWhere === 'summary' || inWhere === 'both') {
523
+ matchClauses.push('m.summary LIKE ?');
524
+ matchParams.push(like);
525
+ }
526
+ if (inWhere === 'content' || inWhere === 'both') {
527
+ matchClauses.push('m.content LIKE ?');
528
+ matchParams.push(like);
529
+ }
530
+ const matchSql = `(${matchClauses.join(' OR ')})`;
531
+
532
+ let order;
533
+ let orderParams = [];
534
+ let impField = 'reinforcedImportance';
535
+ if (flags.sort) {
536
+ const ob = buildOrderBy(flags.sort, flags.reverse);
537
+ order = ob.order;
538
+ impField = ob.impField;
539
+ } else {
540
+ // Relevance ranking: summary-hit > content-only-hit > then reinforced + recency.
541
+ order = `CASE WHEN m.summary LIKE ? THEN 1 ELSE 2 END,
542
+ m.reinforcedImportance DESC,
543
+ m.createdAt DESC`;
544
+ orderParams = [like];
545
+ if (flags.reverse) {
546
+ // Reverse the relevance dimension by inverting the CASE outcome.
547
+ order = `CASE WHEN m.summary LIKE ? THEN 2 ELSE 1 END,
548
+ m.reinforcedImportance ASC,
549
+ m.createdAt ASC`;
550
+ }
551
+ }
552
+
553
+ const limit = flags.limit > 0 ? flags.limit : 50;
554
+ const fullWhere = where ? `${where} AND ${matchSql}` : `WHERE ${matchSql}`;
555
+ const sql = `${SELECT_BASE} ${fullWhere} ORDER BY ${order} LIMIT ?`;
556
+ const allParams = [...params, ...matchParams, ...orderParams, limit];
557
+ const rows = db.prepare(sql).all(...allParams);
558
+
559
+ if (flags.json) {
560
+ renderJson({
561
+ pattern,
562
+ in: inWhere,
563
+ count: rows.length,
564
+ memories: rows.map(rowToJson),
565
+ });
566
+ return;
567
+ }
568
+
569
+ renderTable(rows, { showHolder: meta.allCharacters, impField, fullTitles: flags.fullTitles });
570
+ } finally {
571
+ db.close();
572
+ }
573
+ }
574
+
575
+ // ---------- grep ----------
576
+
577
+ async function cmdSemanticGrep(flags, query) {
578
+ if (!flags.character || flags.character === 'all') {
579
+ console.error('Error: memories grep --semantic requires --character <name|id>.');
580
+ console.error('The server search endpoint scopes results to one holder at a time.');
581
+ process.exit(1);
582
+ }
583
+
584
+ // Resolve character locally so the server gets a stable UUID.
585
+ let characterId;
586
+ {
587
+ const { db } = await openDb(flags);
588
+ try {
589
+ const resolved = resolveCharacter(db, flags.character);
590
+ characterId = resolved.id;
591
+ } finally {
592
+ db.close();
593
+ }
594
+ }
595
+ // Sanity-check: the server validates `characterId` as a UUID. If the local
596
+ // resolution returned something else (shouldn't happen), bail early with a
597
+ // useful message rather than letting the server emit a generic "Validation
598
+ // error".
599
+ if (!/^[0-9a-fA-F-]{36}$/.test(characterId)) {
600
+ console.error(`Error: could not resolve --character to a UUID (got "${characterId}").`);
601
+ console.error('Pass --character <name|id|all> against the same instance the dev server is running.');
602
+ process.exit(1);
603
+ }
604
+
605
+ const top = flags.top > 0 ? flags.top : 20;
606
+ const threshold = flags.threshold >= 0 ? flags.threshold : 0.5;
607
+ const port = flags.port || 3000;
608
+ const url = `http://localhost:${port}/api/v1/memories?action=search`;
609
+
610
+ const body = { characterId, query, limit: top, minScore: threshold };
611
+ if (flags.source) body.source = flags.source;
612
+ if (flags.minImportance != null) body.minImportance = flags.minImportance;
613
+
614
+ let res;
615
+ try {
616
+ res = await fetch(url, {
617
+ method: 'POST',
618
+ headers: { 'Content-Type': 'application/json' },
619
+ body: JSON.stringify(body),
620
+ });
621
+ } catch (err) {
622
+ console.error(`Could not reach Quilltap server at http://localhost:${port}: ${err.message}`);
623
+ console.error('Semantic search requires the running server (the embedding provider lives there).');
624
+ console.error('Start it with: npm run dev');
625
+ process.exit(1);
626
+ }
627
+
628
+ let payload = null;
629
+ try {
630
+ const text = await res.text();
631
+ payload = text ? JSON.parse(text) : null;
632
+ } catch { /* leave payload null */ }
633
+
634
+ if (!res.ok) {
635
+ if (payload && payload.error && /dimension/i.test(payload.error)) {
636
+ console.error(`Error: ${payload.error}`);
637
+ console.error('Re-apply the embedding profile, or switch the active profile back to one that matches the corpus.');
638
+ process.exit(1);
639
+ }
640
+ const msg = (payload && payload.error) || `HTTP ${res.status}`;
641
+ console.error(`Error: ${msg}`);
642
+ process.exit(1);
643
+ }
644
+
645
+ if (flags.json) {
646
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
647
+ return;
648
+ }
649
+
650
+ const memories = (payload && payload.memories) || [];
651
+ if (memories.length === 0) {
652
+ console.log('(no matches)');
653
+ return;
654
+ }
655
+
656
+ for (const m of memories) {
657
+ const score = (m.score ?? 0).toFixed(3);
658
+ const summary = (m.summary || '').replace(/\s+/g, ' ').trim();
659
+ const trimmed = summary.length > 100 ? summary.slice(0, 100) + '…' : summary;
660
+ console.log(`${score} imp ${(m.reinforcedImportance ?? m.importance ?? 0).toFixed(2)} ${trimmed}`);
661
+ const content = (m.content || '').replace(/\s+/g, ' ').trim();
662
+ const snippet = content.length > 240 ? content.slice(0, 240) + '…' : content;
663
+ if (snippet) console.log(` ${snippet}`);
664
+ }
665
+ console.log('');
666
+ console.log(`${memories.length} match${memories.length === 1 ? '' : 'es'}` +
667
+ (payload.usedEmbedding === false ? ' (fallback keyword search — no usable embeddings)' : ''));
668
+ }
669
+
670
+ async function cmdGrep(flags, positional) {
671
+ const pattern = positional[0];
672
+ if (!pattern) {
673
+ throw new Error('memories grep: missing <pattern>. Usage: quilltap memories grep [filters] <pattern>');
674
+ }
675
+ if (flags.semantic) {
676
+ return cmdSemanticGrep(flags, pattern);
677
+ }
678
+ const { db } = await openDb(flags);
679
+ try {
680
+ const { where, params } = buildWhereClause(db, flags);
681
+ // Always restrict to rows whose content can match — quick pre-filter so we
682
+ // don't read all 32k rows into JS just to drop most of them.
683
+ const likeNeedle = flags.ignoreCase ? `%${pattern.toLowerCase()}%` : `%${pattern}%`;
684
+ const contentClause = flags.ignoreCase
685
+ ? 'LOWER(m.content) LIKE ?'
686
+ : 'm.content LIKE ?';
687
+ const fullWhere = where ? `${where} AND ${contentClause}` : `WHERE ${contentClause}`;
688
+
689
+ const limit = flags.limit > 0 ? flags.limit : 50;
690
+ const sql = `${SELECT_BASE} ${fullWhere}
691
+ ORDER BY m.reinforcedImportance DESC, m.createdAt DESC
692
+ LIMIT ?`;
693
+ const rows = db.prepare(sql).all(...params, likeNeedle, limit);
694
+
695
+ const maxPerFile = flags.max > 0 ? flags.max : 5;
696
+ const ctxLines = flags.context > 0 ? flags.context : 0;
697
+ const results = [];
698
+
699
+ for (const row of rows) {
700
+ const matches = findMatches(row.content || '', pattern, {
701
+ ignoreCase: flags.ignoreCase,
702
+ max: maxPerFile,
703
+ context: ctxLines,
704
+ });
705
+ if (matches.length === 0) continue;
706
+ results.push({ row, matches });
707
+ }
708
+
709
+ if (flags.json) {
710
+ renderJson({
711
+ pattern,
712
+ ignoreCase: flags.ignoreCase,
713
+ count: results.length,
714
+ matches: results.map(({ row, matches }) => ({
715
+ id: row.id,
716
+ holder: row.holderName,
717
+ aboutCharacter: row.aboutCharName,
718
+ chatTitle: row.chatTitle,
719
+ importance: row.reinforcedImportance,
720
+ matches,
721
+ })),
722
+ });
723
+ return;
724
+ }
725
+
726
+ if (results.length === 0) {
727
+ console.log('(no matches)');
728
+ return;
729
+ }
730
+
731
+ if (flags.pathsOnly) {
732
+ for (const { row } of results) {
733
+ console.log(row.id);
734
+ }
735
+ return;
736
+ }
737
+
738
+ for (const { row, matches } of results) {
739
+ const header = `${colorize(shortId(row.id), CYAN)} (holder: ${row.holderName || '?'}, imp ${formatImportance(row.reinforcedImportance).trim()}, chat: "${truncate(chatLabel(row, flags.fullTitles), flags.fullTitles ? 60 : 32)}"):`;
740
+ console.log(header);
741
+ for (const m of matches) {
742
+ const snippetLines = m.context.map((line, idx) => {
743
+ const isMatchLine = idx === m.matchIndexInContext;
744
+ const lineNumPrefix = ` line ${m.line + idx - m.matchIndexInContext}:`;
745
+ if (!isTty()) return `${lineNumPrefix} ${line}`;
746
+ const colored = isMatchLine
747
+ ? highlightMatch(line, pattern, flags.ignoreCase)
748
+ : colorize(line, DIM);
749
+ return `${lineNumPrefix} ${colored}`;
750
+ });
751
+ console.log(snippetLines.join('\n'));
752
+ }
753
+ console.log('');
754
+ }
755
+ } finally {
756
+ db.close();
757
+ }
758
+ }
759
+
760
+ function findMatches(text, pattern, { ignoreCase, max, context }) {
761
+ if (!text) return [];
762
+ const lines = text.split('\n');
763
+ const needle = ignoreCase ? pattern.toLowerCase() : pattern;
764
+ const out = [];
765
+ for (let i = 0; i < lines.length; i++) {
766
+ const hay = ignoreCase ? lines[i].toLowerCase() : lines[i];
767
+ if (hay.includes(needle)) {
768
+ const ctxStart = Math.max(0, i - context);
769
+ const ctxEnd = Math.min(lines.length, i + context + 1);
770
+ const slice = lines.slice(ctxStart, ctxEnd);
771
+ out.push({
772
+ line: i + 1,
773
+ text: lines[i],
774
+ context: slice,
775
+ matchIndexInContext: i - ctxStart,
776
+ });
777
+ if (out.length >= max) break;
778
+ }
779
+ }
780
+ return out;
781
+ }
782
+
783
+ function highlightMatch(line, pattern, ignoreCase) {
784
+ if (!pattern) return line;
785
+ try {
786
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
787
+ const re = new RegExp(escaped, ignoreCase ? 'gi' : 'g');
788
+ return line.replace(re, (m) => colorize(m, BOLD + YELLOW));
789
+ } catch {
790
+ return line;
791
+ }
792
+ }
793
+
794
+ // ---------- show ----------
795
+
796
+ async function cmdShow(flags, positional) {
797
+ const idArg = positional[0];
798
+ if (!idArg) {
799
+ throw new Error('memories show: missing <id>. Usage: quilltap memories show <id|prefix>');
800
+ }
801
+ const { db } = await openDb(flags);
802
+ try {
803
+ const id = resolveMemoryId(db, idArg);
804
+ const row = db.prepare(`${SELECT_BASE} WHERE m.id = ?`).get(id);
805
+ if (!row) {
806
+ throw new Error(`Memory ${id} not found`);
807
+ }
808
+
809
+ const depthRaw = flags.depth;
810
+ let depth = depthRaw < 0 ? 1 : depthRaw;
811
+ if (flags.noRelated) depth = 0;
812
+ if (depth > 4) depth = 4;
813
+ const maxNodes = flags.maxNodes > 0 ? flags.maxNodes : 100;
814
+
815
+ let graph = null;
816
+ if (depth > 0) {
817
+ graph = traverseMemoryGraph(db, id, depth, maxNodes);
818
+ }
819
+
820
+ // Pull holder + about character vault IDs so the JSON view is fully resolved.
821
+ if (flags.json) {
822
+ const json = rowToJson(row);
823
+ if (graph) json.related = graphToJson(graph.root);
824
+ if (graph) {
825
+ json.graphMeta = {
826
+ visited: graph.visited,
827
+ cycles: graph.cycles,
828
+ truncated: graph.truncated,
829
+ };
830
+ }
831
+ renderJson(json);
832
+ return;
833
+ }
834
+
835
+ renderShowText(row, graph, depth);
836
+ } finally {
837
+ db.close();
838
+ }
839
+ }
840
+
841
+ function resolveMemoryId(db, idArg) {
842
+ if (UUID_RE.test(idArg)) {
843
+ const row = db.prepare('SELECT id FROM memories WHERE id = ?').get(idArg);
844
+ if (!row) throw new Error(`No memory with id ${idArg}`);
845
+ return row.id;
846
+ }
847
+ if (idArg.length < 8) {
848
+ throw new Error('Memory id prefix must be at least 8 characters.');
849
+ }
850
+ const prefix = idArg.toLowerCase();
851
+ const rows = db.prepare("SELECT id FROM memories WHERE LOWER(id) LIKE ? LIMIT 11").all(`${prefix}%`);
852
+ if (rows.length === 0) {
853
+ throw new Error(`No memory matching prefix '${idArg}'`);
854
+ }
855
+ if (rows.length > 1) {
856
+ const list = rows.slice(0, 10).map(r => ` ${r.id}`).join('\n');
857
+ const err = new Error(`Multiple memories match prefix '${idArg}':\n${list}${rows.length > 10 ? '\n …' : ''}`);
858
+ err.ambiguous = true;
859
+ throw err;
860
+ }
861
+ return rows[0].id;
862
+ }
863
+
864
+ function renderShowText(row, graph, depth) {
865
+ const sep = '─'.repeat(77);
866
+ console.log(`Memory ${row.id}`);
867
+ console.log(sep);
868
+ const holderName = row.holderName || '?';
869
+ const aboutName = row.aboutCharacterId == null
870
+ ? '(none)'
871
+ : row.aboutCharacterId === row.characterId
872
+ ? 'self'
873
+ : row.aboutCharName || '(unknown)';
874
+ console.log(` Holder: ${holderName} (${shortId(row.characterId)})`);
875
+ console.log(` About: ${aboutName}${row.aboutCharacterId && row.aboutCharacterId !== row.characterId ? ` (${shortId(row.aboutCharacterId)})` : ''}`);
876
+ console.log(` Source: ${row.source || '(?)'}`);
877
+ const reinf = row.reinforcedImportance != null ? Number(row.reinforcedImportance).toFixed(2) : '?';
878
+ const baseImp = row.importance != null ? Number(row.importance).toFixed(2) : '?';
879
+ console.log(` Importance: ${reinf} (reinforced from ${baseImp}, count: ${row.reinforcementCount ?? 0})`);
880
+ if (row.createdAt) console.log(` Created: ${row.createdAt}`);
881
+ if (row.lastAccessedAt) console.log(` Last access: ${row.lastAccessedAt}`);
882
+ if (row.lastReinforcedAt) console.log(` Last reinf.: ${row.lastReinforcedAt}`);
883
+ console.log(` Embedding: ${row.hasEmbedding ? 'present' : '(none)'}`);
884
+ if (row.chatId) {
885
+ console.log(` Chat: "${row.chatTitle || '(untitled)'}" (${shortId(row.chatId)})`);
886
+ if (row.sourceMessageId) {
887
+ console.log(` Source msg: ${shortId(row.sourceMessageId)} (in chat above)`);
888
+ }
889
+ } else {
890
+ console.log(` Chat: (manual entry)`);
891
+ }
892
+ if (row.projectId) {
893
+ console.log(` Project: ${row.projectName || '(?)'} (${shortId(row.projectId)})`);
894
+ } else {
895
+ console.log(` Project: (none)`);
896
+ }
897
+
898
+ try {
899
+ const kw = JSON.parse(row.keywords || '[]');
900
+ if (kw.length) console.log(` Keywords: [${kw.join(', ')}]`);
901
+ } catch { /* ignore */ }
902
+ try {
903
+ const tags = JSON.parse(row.tags || '[]');
904
+ if (tags.length) console.log(` Tags: [${tags.join(', ')}]`);
905
+ } catch { /* ignore */ }
906
+
907
+ console.log('');
908
+ console.log('Summary:');
909
+ console.log(` ${row.summary || '(no summary)'}`);
910
+ console.log('');
911
+ console.log('Content:');
912
+ for (const line of (row.content || '').split('\n')) {
913
+ console.log(` ${line}`);
914
+ }
915
+
916
+ if (depth > 0 && graph && graph.root) {
917
+ const direct = (graph.root.children || []).filter(c => c && !c.cycle && !c.missing);
918
+ console.log('');
919
+ console.log(`Related (${direct.length} direct, --depth ${depth}):`);
920
+ for (const child of graph.root.children || []) {
921
+ if (!child) continue;
922
+ if (child.cycle) {
923
+ console.log(` ↺ ${shortId(child.id)} (already shown)`);
924
+ continue;
925
+ }
926
+ if (child.missing) {
927
+ console.log(` ✗ ${shortId(child.id)} (deleted or missing)`);
928
+ continue;
929
+ }
930
+ const imp = child.reinforcedImportance != null ? Number(child.reinforcedImportance).toFixed(2) : '?';
931
+ const sum = truncate(child.summary || '', 80);
932
+ console.log(` ▸ ${shortId(child.id)} (imp ${imp}) "${sum}"`);
933
+ }
934
+ }
935
+ }
936
+
937
+ // ---------- tree ----------
938
+
939
+ async function cmdTree(flags, positional) {
940
+ const idArg = positional[0];
941
+ if (!idArg) {
942
+ throw new Error('memories tree: missing <id>. Usage: quilltap memories tree <id|prefix>');
943
+ }
944
+ const { db } = await openDb(flags);
945
+ try {
946
+ const id = resolveMemoryId(db, idArg);
947
+ let depth = flags.depth >= 0 ? flags.depth : 2;
948
+ if (depth > 4) depth = 4;
949
+ const maxNodes = flags.maxNodes > 0 ? Math.min(flags.maxNodes, 1000) : 100;
950
+
951
+ const graph = traverseMemoryGraph(db, id, depth, maxNodes);
952
+
953
+ if (flags.json) {
954
+ renderJson({
955
+ root: graphToJson(graph.root),
956
+ visited: graph.visited,
957
+ cycles: graph.cycles,
958
+ truncated: graph.truncated,
959
+ depth,
960
+ maxNodes,
961
+ });
962
+ return;
963
+ }
964
+
965
+ if (!graph.root) {
966
+ console.log('(no nodes — max-nodes cap hit before root could render)');
967
+ return;
968
+ }
969
+
970
+ renderTreeNode(graph.root, '', true, true);
971
+ console.log('');
972
+ const depthSuffix = graph.truncated ? `, depth ${depth} reached (max-nodes ${maxNodes} hit)` : `, depth ${depth} reached`;
973
+ console.log(`${graph.visited} nodes visited, ${graph.cycles} cycles detected${depthSuffix}.`);
974
+ } finally {
975
+ db.close();
976
+ }
977
+ }
978
+
979
+ function renderTreeNode(node, prefix, isLast, isRoot) {
980
+ if (!node) return;
981
+ if (node.cycle) {
982
+ console.log(`${prefix}${isLast ? '└─' : '├─'} ↺ ${shortId(node.id)} (already shown)`);
983
+ return;
984
+ }
985
+ if (node.missing) {
986
+ console.log(`${prefix}${isLast ? '└─' : '├─'} ✗ ${shortId(node.id)} (deleted or missing)`);
987
+ return;
988
+ }
989
+ const imp = node.reinforcedImportance != null ? Number(node.reinforcedImportance).toFixed(2) : '?';
990
+ const sum = truncate(node.summary || '', 80);
991
+ if (isRoot) {
992
+ console.log(`${shortId(node.id)} (imp ${imp}) "${sum}"`);
993
+ } else {
994
+ console.log(`${prefix}${isLast ? '└─' : '├─'} ${shortId(node.id)} (imp ${imp}) "${sum}"`);
995
+ }
996
+ const children = node.children || [];
997
+ for (let i = 0; i < children.length; i++) {
998
+ const child = children[i];
999
+ const last = i === children.length - 1;
1000
+ const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
1001
+ renderTreeNode(child, childPrefix, last, false);
1002
+ }
1003
+ }
1004
+
1005
+ // Walk the related-memory graph rooted at `rootId`. Maintains a visited-set
1006
+ // for cycle handling; renders dangling edges as { missing: true } leaves;
1007
+ // halts early when the visit count would exceed `maxNodes`.
1008
+ function traverseMemoryGraph(db, rootId, maxDepth, maxNodes) {
1009
+ const visited = new Set();
1010
+ let cycleCount = 0;
1011
+ let truncated = false;
1012
+ const stmt = db.prepare(
1013
+ 'SELECT id, summary, reinforcedImportance, relatedMemoryIds FROM memories WHERE id = ?'
1014
+ );
1015
+
1016
+ function walk(id, depth) {
1017
+ if (visited.size >= maxNodes) { truncated = true; return null; }
1018
+ if (visited.has(id)) { cycleCount++; return { id, cycle: true }; }
1019
+ visited.add(id);
1020
+ const row = stmt.get(id);
1021
+ if (!row) return { id, missing: true };
1022
+ if (depth >= maxDepth) return { ...row, children: [] };
1023
+ let childIds = [];
1024
+ try { childIds = JSON.parse(row.relatedMemoryIds || '[]'); } catch { childIds = []; }
1025
+ const children = childIds.map(cid => walk(cid, depth + 1)).filter(Boolean);
1026
+ return { ...row, children };
1027
+ }
1028
+
1029
+ return { root: walk(rootId, 0), visited: visited.size, cycles: cycleCount, truncated };
1030
+ }
1031
+
1032
+ function graphToJson(node) {
1033
+ if (!node) return null;
1034
+ if (node.cycle) return { id: node.id, cycle: true };
1035
+ if (node.missing) return { id: node.id, missing: true };
1036
+ return {
1037
+ id: node.id,
1038
+ summary: node.summary,
1039
+ reinforcedImportance: node.reinforcedImportance,
1040
+ children: (node.children || []).map(graphToJson),
1041
+ };
1042
+ }
1043
+
1044
+ // ---------- status ----------
1045
+
1046
+ async function cmdStatus(flags) {
1047
+ const { db } = await openDb(flags);
1048
+ try {
1049
+ let holderRows;
1050
+ if (flags.character && flags.character !== 'all') {
1051
+ const c = resolveCharacter(db, flags.character);
1052
+ holderRows = [{ id: c.id, name: c.name }];
1053
+ } else {
1054
+ holderRows = db.prepare(`
1055
+ SELECT c.id, c.name, COUNT(m.id) AS cnt
1056
+ FROM characters c
1057
+ LEFT JOIN memories m ON m.characterId = c.id
1058
+ GROUP BY c.id
1059
+ HAVING cnt > 0
1060
+ ORDER BY cnt DESC, c.name
1061
+ `).all();
1062
+ }
1063
+
1064
+ const report = [];
1065
+ for (const holder of holderRows) {
1066
+ const stats = computeHolderStats(db, holder.id);
1067
+ report.push({ holder, stats });
1068
+ }
1069
+
1070
+ if (flags.json) {
1071
+ renderJson({
1072
+ holders: report.map(({ holder, stats }) => ({
1073
+ holder: { id: holder.id, name: holder.name },
1074
+ ...stats,
1075
+ })),
1076
+ });
1077
+ return;
1078
+ }
1079
+
1080
+ for (const { holder, stats } of report) {
1081
+ renderStatusBlock(holder, stats);
1082
+ console.log('');
1083
+ }
1084
+
1085
+ // After per-holder rendering, log any dangling edges to stderr so the user
1086
+ // notices the inconsistency without it cluttering the structured output.
1087
+ const danglingTotal = report.reduce((sum, r) => sum + r.stats.graph.danglingEdges, 0);
1088
+ if (danglingTotal > 0) {
1089
+ process.stderr.write(
1090
+ `Warning: ${danglingTotal} dangling related-memory edges across all holders. ` +
1091
+ `These are JSON UUIDs in relatedMemoryIds that no longer resolve to a memory.\n`
1092
+ );
1093
+ }
1094
+ } finally {
1095
+ db.close();
1096
+ }
1097
+ }
1098
+
1099
+ function computeHolderStats(db, characterId) {
1100
+ const counts = db.prepare(`
1101
+ SELECT
1102
+ COUNT(*) AS total,
1103
+ SUM(CASE WHEN source = 'AUTO' THEN 1 ELSE 0 END) AS auto,
1104
+ SUM(CASE WHEN source = 'MANUAL' THEN 1 ELSE 0 END) AS manual,
1105
+ SUM(CASE WHEN aboutCharacterId = characterId THEN 1 ELSE 0 END) AS selfRef,
1106
+ SUM(CASE WHEN aboutCharacterId IS NOT NULL AND aboutCharacterId != characterId THEN 1 ELSE 0 END) AS aboutOthers,
1107
+ SUM(CASE WHEN aboutCharacterId IS NULL THEN 1 ELSE 0 END) AS legacy,
1108
+ SUM(CASE WHEN embedding IS NOT NULL THEN 1 ELSE 0 END) AS withEmbedding,
1109
+ SUM(CASE WHEN embedding IS NULL THEN 1 ELSE 0 END) AS withoutEmbedding
1110
+ FROM memories
1111
+ WHERE characterId = ?
1112
+ `).get(characterId);
1113
+
1114
+ const graph = scanDanglingEdges(db, { characterId });
1115
+
1116
+ const topMemories = db.prepare(`
1117
+ SELECT id, summary, reinforcedImportance
1118
+ FROM memories
1119
+ WHERE characterId = ?
1120
+ ORDER BY reinforcedImportance DESC, createdAt DESC
1121
+ LIMIT 5
1122
+ `).all(characterId);
1123
+
1124
+ return {
1125
+ counts: {
1126
+ total: counts.total || 0,
1127
+ auto: counts.auto || 0,
1128
+ manual: counts.manual || 0,
1129
+ },
1130
+ aboutDistribution: {
1131
+ selfReferential: counts.selfRef || 0,
1132
+ aboutOthers: counts.aboutOthers || 0,
1133
+ legacy: counts.legacy || 0,
1134
+ },
1135
+ embeddings: {
1136
+ present: counts.withEmbedding || 0,
1137
+ missing: counts.withoutEmbedding || 0,
1138
+ },
1139
+ graph: {
1140
+ withLinks: graph.withLinks,
1141
+ isolated: graph.isolated,
1142
+ avgDegree: graph.avgDegree,
1143
+ maxDegree: graph.maxDegree,
1144
+ danglingEdges: graph.danglingEdges,
1145
+ },
1146
+ top: topMemories,
1147
+ };
1148
+ }
1149
+
1150
+ function renderStatusBlock(holder, stats) {
1151
+ console.log(`Holder: ${holder.name} (${shortId(holder.id)})`);
1152
+ console.log(` Total memories: ${stats.counts.total}`);
1153
+ console.log(` AUTO: ${stats.counts.auto}`);
1154
+ console.log(` MANUAL: ${stats.counts.manual}`);
1155
+ console.log(` About-distribution:`);
1156
+ console.log(` self-referential: ${stats.aboutDistribution.selfReferential}`);
1157
+ console.log(` about-others: ${stats.aboutDistribution.aboutOthers}`);
1158
+ const legacySuffix = stats.aboutDistribution.legacy > 0 ? ' ⚠ run alignment migration?' : '';
1159
+ console.log(` legacy (NULL): ${stats.aboutDistribution.legacy}${legacySuffix}`);
1160
+ console.log(` Embeddings:`);
1161
+ console.log(` present: ${stats.embeddings.present}`);
1162
+ const missingSuffix = stats.embeddings.missing > 0 ? ' ⚠ may not be recallable' : '';
1163
+ console.log(` missing: ${stats.embeddings.missing}${missingSuffix}`);
1164
+ console.log(` Graph:`);
1165
+ console.log(` nodes with links: ${stats.graph.withLinks}`);
1166
+ console.log(` isolated (0 links): ${stats.graph.isolated}`);
1167
+ console.log(` avg degree: ${stats.graph.avgDegree}`);
1168
+ console.log(` max degree: ${stats.graph.maxDegree}`);
1169
+ const danglingSuffix = stats.graph.danglingEdges > 0 ? ' ⚠ JSON UUIDs that no longer resolve' : '';
1170
+ console.log(` dangling edges: ${stats.graph.danglingEdges}${danglingSuffix}`);
1171
+ if (stats.top.length) {
1172
+ console.log(` Top by reinforcedImportance:`);
1173
+ for (const t of stats.top) {
1174
+ const imp = t.reinforcedImportance != null ? Number(t.reinforcedImportance).toFixed(2) : '?';
1175
+ console.log(` ${shortId(t.id)} (imp ${imp}) "${truncate(t.summary || '', 80)}"`);
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ // ---------- validate ----------
1181
+
1182
+ async function cmdValidate(flags) {
1183
+ const { db } = await openDb(flags);
1184
+ try {
1185
+ let holderIds = null;
1186
+ let holderRows;
1187
+ if (flags.character && flags.character !== 'all') {
1188
+ const c = resolveCharacter(db, flags.character);
1189
+ holderRows = [{ id: c.id, name: c.name }];
1190
+ holderIds = [c.id];
1191
+ } else {
1192
+ holderRows = db.prepare(`
1193
+ SELECT c.id, c.name FROM characters c
1194
+ WHERE EXISTS (SELECT 1 FROM memories m WHERE m.characterId = c.id)
1195
+ ORDER BY c.name
1196
+ `).all();
1197
+ }
1198
+
1199
+ const includePairs = Boolean(flags.list);
1200
+ let totalDangling = 0;
1201
+ let totalEdges = 0;
1202
+ let totalNodes = 0;
1203
+ const perHolder = [];
1204
+ for (const holder of holderRows) {
1205
+ const stats = scanDanglingEdges(db, {
1206
+ characterId: holder.id,
1207
+ includePairs,
1208
+ });
1209
+ totalDangling += stats.danglingEdges;
1210
+ totalEdges += stats.totalEdges;
1211
+ totalNodes += stats.nodes;
1212
+ perHolder.push({ holder, stats });
1213
+ }
1214
+
1215
+ if (flags.json) {
1216
+ renderJson({
1217
+ scanned: holderIds ? 'single' : 'all',
1218
+ characterIds: holderRows.map(h => h.id),
1219
+ totals: {
1220
+ nodes: totalNodes,
1221
+ edges: totalEdges,
1222
+ danglingEdges: totalDangling,
1223
+ },
1224
+ holders: perHolder.map(({ holder, stats }) => ({
1225
+ holder: { id: holder.id, name: holder.name },
1226
+ ...stats,
1227
+ })),
1228
+ });
1229
+ } else {
1230
+ const scope = holderIds ? `holder ${holderRows[0].name}` : `${holderRows.length} holders`;
1231
+ const drift = totalEdges > 0 ? ((totalDangling / totalEdges) * 100).toFixed(2) : '0.00';
1232
+ console.log(`Scanned ${scope}: ${totalNodes} memories, ${totalEdges} edges, ${totalDangling} dangling (${drift}%).`);
1233
+ if (totalDangling === 0) {
1234
+ console.log(colorize('Graph is clean.', GREEN));
1235
+ } else if (flags.list) {
1236
+ console.log('');
1237
+ for (const { holder, stats } of perHolder) {
1238
+ if (!stats.danglingPairs || stats.danglingPairs.length === 0) continue;
1239
+ console.log(colorize(`${holder.name} (${shortId(holder.id)})`, BOLD));
1240
+ for (const pair of stats.danglingPairs) {
1241
+ console.log(` ${shortId(pair.sourceId)} → ${pair.targetIds.map(shortId).join(', ')}`);
1242
+ }
1243
+ }
1244
+ } else {
1245
+ console.log(colorize('Run with --list to see offending source memory IDs and dangling targets.', YELLOW));
1246
+ }
1247
+ }
1248
+
1249
+ if (totalDangling > 0) {
1250
+ process.exitCode = 1;
1251
+ }
1252
+ } finally {
1253
+ db.close();
1254
+ }
1255
+ }
1256
+
1257
+ // ---------- help ----------
1258
+
1259
+ function printMemoriesHelp() {
1260
+ console.log(`
1261
+ Quilltap Memories Tool
1262
+
1263
+ Usage: quilltap memories <subcommand> [filters] [options]
1264
+
1265
+ Subcommands:
1266
+ ls [filters] [--sort <field>] [-r] [--limit N] [--json]
1267
+ List memories (default sort:
1268
+ reinforcedImportance DESC).
1269
+ find [filters] [--in summary|content|both] [--limit N] <pattern>
1270
+ Substring search on summary
1271
+ and/or content.
1272
+ grep [filters] [-i] [-l] [--max N] [--context N] <pattern>
1273
+ Pattern search inside content
1274
+ with snippets.
1275
+ show <id|prefix> [--depth N] [--no-related] [--json]
1276
+ Full record + related-memory
1277
+ neighbourhood.
1278
+ tree <id|prefix> [--depth N] [--max-nodes N] [--json]
1279
+ ASCII walk of the
1280
+ related-memory graph.
1281
+ status [--character <name|id>] [--json] Per-holder rollup + dangling-
1282
+ edge check.
1283
+ validate [--character <name|id>] [--list] Read-only memory-graph health
1284
+ [--json] check. Exits 1 if any
1285
+ dangling edges remain.
1286
+
1287
+ Shared filter flags:
1288
+ --character <name|id|all> Holder. Default: all.
1289
+ --about <name|id|self|none> Subject (aboutCharacterId).
1290
+ --source AUTO|MANUAL Restrict by source.
1291
+ --chat <id|title|none> Source chat ('none' for manual memories).
1292
+ --project <id|name> Project context.
1293
+ --since <date> ISO date floor on createdAt.
1294
+ --until <date> ISO date ceiling on createdAt.
1295
+ --min-importance <n> Floor on raw importance.
1296
+ --min-reinforced <n> Floor on reinforcedImportance.
1297
+ --has-embedding Only memories with an embedding.
1298
+ --no-embedding Only memories WITHOUT an embedding.
1299
+
1300
+ Sort flags (ls / find / grep):
1301
+ --sort <field> reinforced (default) | importance | created |
1302
+ accessed | reinforcement-count | links
1303
+ -r, --reverse Flip the sort order.
1304
+ --limit N Default 50.
1305
+ --full-titles Don't truncate chat titles to 32 chars.
1306
+
1307
+ Global flags (may appear before or after the subcommand):
1308
+ -d, --data-dir <path> Override data directory.
1309
+ --instance <name> Use a registered instance.
1310
+ --passphrase <pass> Decrypt .dbkey if peppered.
1311
+ --json Machine-readable output.
1312
+ -h, --help Show this help.
1313
+
1314
+ Note: -i is reserved here for 'grep --ignore-case'. Use the long --instance
1315
+ form to target a registered instance.
1316
+
1317
+ All memories verbs are read-only. They open the main encrypted database
1318
+ (quilltap.db) and never write to it.
1319
+
1320
+ Examples:
1321
+ quilltap memories ls
1322
+ quilltap memories ls --character Ariadne --sort created --limit 10
1323
+ quilltap memories find "concrete examples"
1324
+ quilltap memories grep -i --max 3 --context 1 "concrete examples"
1325
+ quilltap memories show abc12345 --depth 2
1326
+ quilltap memories tree abc12345 --depth 3
1327
+ quilltap memories status --character Ariadne
1328
+ quilltap memories validate
1329
+ quilltap memories validate --list
1330
+ `);
1331
+ }
1332
+
1333
+ // ---------- dispatcher ----------
1334
+
1335
+ async function memoriesCommand(args) {
1336
+ if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
1337
+ printMemoriesHelp();
1338
+ return;
1339
+ }
1340
+ // The dispatcher in bin/quilltap.js already strips global flags that appear
1341
+ // BEFORE the verb. Anything left here is verb + flags. The first positional
1342
+ // is the verb.
1343
+ const { flags, positional } = parseFlags(args);
1344
+ if (flags.help) { printMemoriesHelp(); return; }
1345
+ const verb = positional.shift();
1346
+ if (!verb) {
1347
+ printMemoriesHelp();
1348
+ return;
1349
+ }
1350
+ switch (verb) {
1351
+ case 'ls': await cmdLs(flags); break;
1352
+ case 'find': await cmdFind(flags, positional); break;
1353
+ case 'grep': await cmdGrep(flags, positional); break;
1354
+ case 'show': await cmdShow(flags, positional); break;
1355
+ case 'tree': await cmdTree(flags, positional); break;
1356
+ case 'status': await cmdStatus(flags); break;
1357
+ case 'validate': await cmdValidate(flags); break;
1358
+ default:
1359
+ console.error(`Unknown memories subcommand: ${verb}`);
1360
+ console.error("Run 'quilltap memories --help' for a list.");
1361
+ process.exit(1);
1362
+ }
1363
+ }
1364
+
1365
+ module.exports = {
1366
+ memoriesCommand,
1367
+ // Exported for unit tests:
1368
+ parseFlags,
1369
+ buildWhereClause,
1370
+ buildOrderBy,
1371
+ traverseMemoryGraph,
1372
+ findMatches,
1373
+ resolveMemoryId,
1374
+ };