quilltap 4.10.0-dev.18 → 4.10.0-dev.183

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.
@@ -11,7 +11,14 @@ const {
11
11
  resolveChat,
12
12
  resolveProject,
13
13
  } = require('./db-helpers');
14
- const { scanDanglingEdges } = require('./graph-integrity');
14
+ const {
15
+ scanDanglingEdges,
16
+ scanDigestIntegrity,
17
+ loadMemoryIds,
18
+ memoryColumns,
19
+ } = require('./graph-integrity');
20
+ const { cmdMaintain } = require('./memories-maintain-command');
21
+ const { postMemoriesAction, resolveMemoriesServerPort } = require('./memories-server');
15
22
 
16
23
  // ---------- colour & marker helpers ----------
17
24
 
@@ -88,11 +95,19 @@ function parseFlags(args) {
88
95
  noRelated: false,
89
96
  // validate
90
97
  list: false,
98
+ repair: false,
99
+ write: false,
91
100
  // semantic search
92
101
  semantic: false,
93
102
  top: 0,
94
103
  threshold: -1,
95
- port: 3000,
104
+ // Undefined unless --port is given, so the instance lock can supply it
105
+ // (resolveServerPort in lock-helpers).
106
+ port: undefined,
107
+ // maintain
108
+ dryRun: false,
109
+ phases: '',
110
+ cap: null,
96
111
  };
97
112
  const positional = [];
98
113
  let i = 0;
@@ -108,6 +123,7 @@ function parseFlags(args) {
108
123
  case '--character': flags.character = args[++i]; break;
109
124
  case '--about': flags.about = args[++i]; break;
110
125
  case '--source': flags.source = args[++i]; break;
126
+ case '--tier': flags.tier = args[++i]; break;
111
127
  case '--chat': flags.chat = args[++i]; break;
112
128
  case '--project': flags.project = args[++i]; break;
113
129
  case '--since': flags.since = args[++i]; break;
@@ -134,8 +150,22 @@ function parseFlags(args) {
134
150
  case '--no-related': flags.noRelated = true; break;
135
151
 
136
152
  case '--list': flags.list = true; break;
153
+ case '--repair': flags.repair = true; break;
154
+ case '--write': flags.write = true; break;
155
+
156
+ case '--phases': flags.phases = args[++i] || ''; break;
157
+ case '--cap': {
158
+ const v = Number(args[++i]);
159
+ if (!Number.isInteger(v)) {
160
+ console.error('Error: --cap must be an integer');
161
+ process.exit(1);
162
+ }
163
+ flags.cap = v;
164
+ break;
165
+ }
137
166
 
138
167
  case '--semantic': flags.semantic = true; break;
168
+ case '--dry-run': flags.dryRun = true; break;
139
169
  case '--top': flags.top = parseInt(args[++i], 10) || 0; break;
140
170
  case '--threshold': {
141
171
  const v = parseFloat(args[++i]);
@@ -146,7 +176,7 @@ function parseFlags(args) {
146
176
  flags.threshold = v;
147
177
  break;
148
178
  }
149
- case '--port': {
179
+ case '-p': case '--port': {
150
180
  const p = parseInt(args[++i], 10);
151
181
  if (isNaN(p) || p < 1 || p > 65535) {
152
182
  console.error('Error: --port must be between 1 and 65535');
@@ -220,13 +250,22 @@ function buildWhereClause(db, flags, openMounts = null) {
220
250
 
221
251
  if (flags.source) {
222
252
  const s = String(flags.source).toUpperCase();
223
- if (s !== 'AUTO' && s !== 'MANUAL') {
224
- throw new Error(`--source must be AUTO or MANUAL (got '${flags.source}')`);
253
+ if (s !== 'AUTO' && s !== 'MANUAL' && s !== 'CONSOLIDATED') {
254
+ throw new Error(`--source must be AUTO, MANUAL or CONSOLIDATED (got '${flags.source}')`);
225
255
  }
226
256
  clauses.push('m.source = ?');
227
257
  params.push(s);
228
258
  }
229
259
 
260
+ if (flags.tier) {
261
+ const t = String(flags.tier).toLowerCase();
262
+ if (t !== 'hot' && t !== 'cold') {
263
+ throw new Error(`--tier must be hot or cold (got '${flags.tier}')`);
264
+ }
265
+ // Legacy instances without the column read every row as hot.
266
+ clauses.push(t === 'hot' ? "COALESCE(m.tier, 'hot') = 'hot'" : "m.tier = 'cold'");
267
+ }
268
+
230
269
  if (flags.chat) {
231
270
  if (flags.chat === 'none') {
232
271
  clauses.push('m.chatId IS NULL');
@@ -313,7 +352,7 @@ function buildOrderBy(sortFlag, reverse) {
313
352
  // SELECT fragment that pulls every column the renderers need, including the
314
353
  // joined-in character / chat names. `m.*` is followed by aliases for the joins
315
354
  // so callers don't have to re-resolve UUIDs.
316
- const SELECT_BASE = `
355
+ const SELECT_BASE_TEMPLATE = `
317
356
  SELECT
318
357
  m.id,
319
358
  m.characterId,
@@ -331,6 +370,7 @@ const SELECT_BASE = `
331
370
  m.lastReinforcedAt,
332
371
  m.lastAccessedAt,
333
372
  m.source,
373
+ __TIER_COLUMNS__
334
374
  m.relatedMemoryIds,
335
375
  m.createdAt,
336
376
  m.updatedAt,
@@ -346,6 +386,20 @@ const SELECT_BASE = `
346
386
  LEFT JOIN projects ON projects.id = m.projectId
347
387
  `;
348
388
 
389
+ // Instances that haven't yet run add-memory-tiers-v1 lack the tier columns;
390
+ // substitute constants so the CLI still reads them (rows read as hot).
391
+ function selectBase(db) {
392
+ let hasTier = false;
393
+ try {
394
+ hasTier = db.prepare("PRAGMA table_info('memories')").all().some((c) => c.name === 'tier');
395
+ } catch { /* fall through: treat as legacy */ }
396
+ const cols = hasTier
397
+ ? 'm.tier, m.supersededById, m.consolidatedFrom, m.consolidatedAt,'
398
+ : "'hot' AS tier, NULL AS supersededById, '[]' AS consolidatedFrom, NULL AS consolidatedAt,";
399
+ return SELECT_BASE_TEMPLATE.replace('__TIER_COLUMNS__', cols);
400
+ }
401
+
402
+
349
403
  // ---------- output helpers ----------
350
404
 
351
405
  function truncate(str, n) {
@@ -404,7 +458,7 @@ async function cmdLs(flags) {
404
458
  const { where, params, meta } = buildWhereClause(db, flags, openMounts);
405
459
  const { order, impField } = buildOrderBy(flags.sort, flags.reverse);
406
460
  const limit = flags.limit > 0 ? flags.limit : 50;
407
- const sql = `${SELECT_BASE} ${where} ORDER BY ${order} LIMIT ?`;
461
+ const sql = `${selectBase(db)} ${where} ORDER BY ${order} LIMIT ?`;
408
462
  const rows = db.prepare(sql).all(...params, limit);
409
463
 
410
464
  if (flags.json) {
@@ -441,6 +495,10 @@ function rowToJson(row) {
441
495
  projectName: row.projectName,
442
496
  sourceMessageId: row.sourceMessageId,
443
497
  source: row.source,
498
+ tier: row.tier || 'hot',
499
+ supersededById: row.supersededById || null,
500
+ consolidatedFrom: (() => { try { return JSON.parse(row.consolidatedFrom || '[]'); } catch { return []; } })(),
501
+ consolidatedAt: row.consolidatedAt || null,
444
502
  importance: row.importance,
445
503
  reinforcedImportance: row.reinforcedImportance,
446
504
  reinforcementCount: row.reinforcementCount,
@@ -469,7 +527,7 @@ function renderTable(rows, { showHolder, impField, fullTitles }) {
469
527
  showHolder ? 'holder'.padEnd(14) : null,
470
528
  impHeader.padStart(4),
471
529
  'rein'.padStart(4),
472
- 'src'.padEnd(6),
530
+ 'src'.padEnd(7),
473
531
  'about'.padEnd(20),
474
532
  'chat'.padEnd(fullTitles ? 24 : 32),
475
533
  'links'.padStart(5),
@@ -483,7 +541,7 @@ function renderTable(rows, { showHolder, impField, fullTitles }) {
483
541
  const impVal = impField === 'importance' ? row.importance : row.reinforcedImportance;
484
542
  const impStr = colorize(formatImportance(impVal), importanceColor(impVal));
485
543
  const rein = String(row.reinforcementCount ?? 0).padStart(4);
486
- const src = (row.source || '').padEnd(6);
544
+ const src = ((row.source || '') + (row.tier === 'cold' ? '*' : '')).padEnd(7);
487
545
  const about = truncate(aboutLabel(row), 20).padEnd(20);
488
546
  const chat = truncate(chatLabel(row, fullTitles), fullTitles ? 24 : 32).padEnd(fullTitles ? 24 : 32);
489
547
  const links = String(linkCount(row)).padStart(5);
@@ -556,7 +614,7 @@ async function cmdFind(flags, positional) {
556
614
 
557
615
  const limit = flags.limit > 0 ? flags.limit : 50;
558
616
  const fullWhere = where ? `${where} AND ${matchSql}` : `WHERE ${matchSql}`;
559
- const sql = `${SELECT_BASE} ${fullWhere} ORDER BY ${order} LIMIT ?`;
617
+ const sql = `${selectBase(db)} ${fullWhere} ORDER BY ${order} LIMIT ?`;
560
618
  const allParams = [...params, ...matchParams, ...orderParams, limit];
561
619
  const rows = db.prepare(sql).all(...allParams);
562
620
 
@@ -608,7 +666,7 @@ async function cmdSemanticGrep(flags, query) {
608
666
 
609
667
  const top = flags.top > 0 ? flags.top : 20;
610
668
  const threshold = flags.threshold >= 0 ? flags.threshold : 0.5;
611
- const port = flags.port || 3000;
669
+ const { port } = resolveMemoriesServerPort(flags);
612
670
  const url = `http://localhost:${port}/api/v1/memories?action=search`;
613
671
 
614
672
  const body = { characterId, query, limit: top, minScore: threshold };
@@ -691,7 +749,7 @@ async function cmdGrep(flags, positional) {
691
749
  const fullWhere = where ? `${where} AND ${contentClause}` : `WHERE ${contentClause}`;
692
750
 
693
751
  const limit = flags.limit > 0 ? flags.limit : 50;
694
- const sql = `${SELECT_BASE} ${fullWhere}
752
+ const sql = `${selectBase(db)} ${fullWhere}
695
753
  ORDER BY m.reinforcedImportance DESC, m.createdAt DESC
696
754
  LIMIT ?`;
697
755
  const rows = db.prepare(sql).all(...params, likeNeedle, limit);
@@ -805,7 +863,7 @@ async function cmdShow(flags, positional) {
805
863
  const { db } = await openDb(flags);
806
864
  try {
807
865
  const id = resolveMemoryId(db, idArg);
808
- const row = db.prepare(`${SELECT_BASE} WHERE m.id = ?`).get(id);
866
+ const row = db.prepare(`${selectBase(db)} WHERE m.id = ?`).get(id);
809
867
  if (!row) {
810
868
  throw new Error(`Memory ${id} not found`);
811
869
  }
@@ -878,6 +936,7 @@ function renderShowText(row, graph, depth) {
878
936
  console.log(` Holder: ${holderName} (${shortId(row.characterId)})`);
879
937
  console.log(` About: ${aboutName}${row.aboutCharacterId && row.aboutCharacterId !== row.characterId ? ` (${shortId(row.aboutCharacterId)})` : ''}`);
880
938
  console.log(` Source: ${row.source || '(?)'}`);
939
+ console.log(` Tier: ${row.tier || 'hot'}${row.supersededById ? ` (superseded by ${shortId(row.supersededById)})` : ''}`);
881
940
  const reinf = row.reinforcedImportance != null ? Number(row.reinforcedImportance).toFixed(2) : '?';
882
941
  const baseImp = row.importance != null ? Number(row.importance).toFixed(2) : '?';
883
942
  console.log(` Importance: ${reinf} (reinforced from ${baseImp}, count: ${row.reinforcementCount ?? 0})`);
@@ -1065,9 +1124,23 @@ async function cmdStatus(flags) {
1065
1124
  `).all();
1066
1125
  }
1067
1126
 
1127
+ // The parent's maintenance ledger (last run per phase, last demotion's
1128
+ // effectiveness) lives in the running server; ask it, briefly. Without a
1129
+ // server the job rows still say when maintenance last ran.
1130
+ let ledgerPort = 3000;
1131
+ try {
1132
+ ledgerPort = resolveMemoriesServerPort(flags, { quiet: true }).port;
1133
+ } catch { /* openDb already validated the instance; fall back to the default */ }
1134
+ const ledger = await fetchMaintenanceLedger(ledgerPort);
1135
+
1068
1136
  const report = [];
1069
1137
  for (const holder of holderRows) {
1070
1138
  const stats = computeHolderStats(db, holder.id);
1139
+ stats.maintenance = {
1140
+ recentJobs: recentMaintenanceJobs(db, holder.id),
1141
+ ledger: ledger ? ledger.find((e) => e.characterId === holder.id) || null : null,
1142
+ ledgerReachable: !!ledger,
1143
+ };
1071
1144
  report.push({ holder, stats });
1072
1145
  }
1073
1146
 
@@ -1100,6 +1173,41 @@ async function cmdStatus(flags) {
1100
1173
  }
1101
1174
  }
1102
1175
 
1176
+ /** The server's maintenance ledger, or null when the server cannot be reached. */
1177
+ async function fetchMaintenanceLedger(port) {
1178
+ try {
1179
+ const res = await fetch(`http://localhost:${port}/api/v1/memories?action=maintenance-status`, {
1180
+ signal: AbortSignal.timeout(1500),
1181
+ });
1182
+ if (!res.ok) return null;
1183
+ const payload = await res.json();
1184
+ return Array.isArray(payload && payload.ledger) ? payload.ledger : null;
1185
+ } catch {
1186
+ return null;
1187
+ }
1188
+ }
1189
+
1190
+ /** The character's most recent MEMORY_MAINTENANCE job rows, newest first. */
1191
+ function recentMaintenanceJobs(db, characterId) {
1192
+ try {
1193
+ return db.prepare(`
1194
+ SELECT id, status, updatedAt, lastError,
1195
+ json_extract(payload, '$.reason') AS reason,
1196
+ json_extract(payload, '$.phases') AS phases
1197
+ FROM background_jobs
1198
+ WHERE type = 'MEMORY_MAINTENANCE'
1199
+ AND json_extract(payload, '$.characterId') = ?
1200
+ ORDER BY updatedAt DESC
1201
+ LIMIT 3
1202
+ `).all(characterId).map((row) => ({
1203
+ ...row,
1204
+ phases: row.phases ? JSON.parse(row.phases) : null,
1205
+ }));
1206
+ } catch {
1207
+ return [];
1208
+ }
1209
+ }
1210
+
1103
1211
  function computeHolderStats(db, characterId) {
1104
1212
  const counts = db.prepare(`
1105
1213
  SELECT
@@ -1115,6 +1223,19 @@ function computeHolderStats(db, characterId) {
1115
1223
  WHERE characterId = ?
1116
1224
  `).get(characterId);
1117
1225
 
1226
+ // Tier rollup — guarded so pre-tier instances still report.
1227
+ let tiers = { hot: counts.total || 0, cold: 0, consolidated: 0 };
1228
+ try {
1229
+ const t = db.prepare(`
1230
+ SELECT
1231
+ SUM(CASE WHEN COALESCE(tier, 'hot') = 'hot' THEN 1 ELSE 0 END) AS hot,
1232
+ SUM(CASE WHEN tier = 'cold' THEN 1 ELSE 0 END) AS cold,
1233
+ SUM(CASE WHEN source = 'CONSOLIDATED' THEN 1 ELSE 0 END) AS consolidated
1234
+ FROM memories WHERE characterId = ?
1235
+ `).get(characterId);
1236
+ tiers = { hot: t.hot || 0, cold: t.cold || 0, consolidated: t.consolidated || 0 };
1237
+ } catch { /* tier column absent: everything is hot */ }
1238
+
1118
1239
  const graph = scanDanglingEdges(db, { characterId });
1119
1240
 
1120
1241
  const topMemories = db.prepare(`
@@ -1131,6 +1252,7 @@ function computeHolderStats(db, characterId) {
1131
1252
  auto: counts.auto || 0,
1132
1253
  manual: counts.manual || 0,
1133
1254
  },
1255
+ tiers,
1134
1256
  aboutDistribution: {
1135
1257
  selfReferential: counts.selfRef || 0,
1136
1258
  aboutOthers: counts.aboutOthers || 0,
@@ -1156,6 +1278,10 @@ function renderStatusBlock(holder, stats) {
1156
1278
  console.log(` Total memories: ${stats.counts.total}`);
1157
1279
  console.log(` AUTO: ${stats.counts.auto}`);
1158
1280
  console.log(` MANUAL: ${stats.counts.manual}`);
1281
+ console.log(` Tiers:`);
1282
+ console.log(` hot: ${stats.tiers.hot}`);
1283
+ console.log(` cold: ${stats.tiers.cold}`);
1284
+ console.log(` digests (CONSOLIDATED): ${stats.tiers.consolidated}`);
1159
1285
  console.log(` About-distribution:`);
1160
1286
  console.log(` self-referential: ${stats.aboutDistribution.selfReferential}`);
1161
1287
  console.log(` about-others: ${stats.aboutDistribution.aboutOthers}`);
@@ -1172,6 +1298,7 @@ function renderStatusBlock(holder, stats) {
1172
1298
  console.log(` max degree: ${stats.graph.maxDegree}`);
1173
1299
  const danglingSuffix = stats.graph.danglingEdges > 0 ? ' ⚠ JSON UUIDs that no longer resolve' : '';
1174
1300
  console.log(` dangling edges: ${stats.graph.danglingEdges}${danglingSuffix}`);
1301
+ if (stats.maintenance) renderMaintenance(stats.maintenance);
1175
1302
  if (stats.top.length) {
1176
1303
  console.log(` Top by reinforcedImportance:`);
1177
1304
  for (const t of stats.top) {
@@ -1181,80 +1308,202 @@ function renderStatusBlock(holder, stats) {
1181
1308
  }
1182
1309
  }
1183
1310
 
1311
+ function renderMaintenance(m) {
1312
+ console.log(` Maintenance:`);
1313
+ if (m.ledger) {
1314
+ // The phases are the server's; print the ones it reports a run for.
1315
+ const runs = Object.entries(m.ledger.lastRun || {});
1316
+ if (runs.length === 0) console.log(' no phase has run since the server started');
1317
+ for (const [phase, run] of runs) {
1318
+ console.log(` ${(phase + ':').padEnd(22)}${run.at} (${run.reason})`);
1319
+ }
1320
+ const d = m.ledger.lastDemotion;
1321
+ if (d) {
1322
+ if (d.excess === 0) {
1323
+ console.log(` last demotion: within the cap${d.effective ? '' : '; automatic demotion looks again in an hour'}`);
1324
+ } else {
1325
+ console.log(` last demotion: ${d.demoted} of ${d.excess} over the cap${d.effective ? '' : ' ⚠ ineffective; watermark backing off'}`);
1326
+ }
1327
+ }
1328
+ } else if (!m.ledgerReachable) {
1329
+ console.log(` ledger: (server not reachable; job history only)`);
1330
+ }
1331
+ if (m.recentJobs.length === 0) {
1332
+ console.log(` jobs: none recorded`);
1333
+ }
1334
+ for (const job of m.recentJobs) {
1335
+ const phases = Array.isArray(job.phases) ? job.phases.join(',') : 'all';
1336
+ const err = job.lastError ? ` ⚠ ${truncate(job.lastError, 60)}` : '';
1337
+ console.log(` job ${shortId(job.id)} ${job.status.padEnd(10)} ${job.updatedAt} ${job.reason || '?'} [${phases}]${err}`);
1338
+ }
1339
+ }
1340
+
1184
1341
  // ---------- validate ----------
1185
1342
 
1186
1343
  async function cmdValidate(flags) {
1344
+ let characterId = null;
1345
+ let local;
1187
1346
  const { db, openMounts } = await openDb(flags);
1188
1347
  try {
1189
- let holderIds = null;
1190
1348
  let holderRows;
1191
1349
  if (flags.character && flags.character !== 'all') {
1192
1350
  const c = resolveCharacter(db, flags.character, openMounts);
1193
1351
  holderRows = [{ id: c.id, name: c.name }];
1194
- holderIds = [c.id];
1352
+ characterId = c.id;
1195
1353
  } else {
1354
+ // Every holder in the memories table — including one whose character
1355
+ // row is gone — so the local scan covers the same rows the server's
1356
+ // unscoped repair scans and scrubs (review of PR 98).
1196
1357
  holderRows = db.prepare(`
1197
- SELECT c.id, c.name FROM characters c
1198
- WHERE EXISTS (SELECT 1 FROM memories m WHERE m.characterId = c.id)
1199
- ORDER BY c.name
1358
+ SELECT h.characterId AS id, COALESCE(c.name, '(missing character ' || substr(h.characterId, 1, 8) || ')') AS name
1359
+ FROM (SELECT DISTINCT characterId FROM memories) h
1360
+ LEFT JOIN characters c ON c.id = h.characterId
1361
+ ORDER BY c.name IS NULL, c.name
1200
1362
  `).all();
1201
1363
  }
1364
+ local = scanForValidate(db, holderRows, Boolean(flags.list));
1365
+ } finally {
1366
+ db.close();
1367
+ }
1202
1368
 
1203
- const includePairs = Boolean(flags.list);
1204
- let totalDangling = 0;
1205
- let totalEdges = 0;
1206
- let totalNodes = 0;
1207
- const perHolder = [];
1208
- for (const holder of holderRows) {
1209
- const stats = scanDanglingEdges(db, {
1210
- characterId: holder.id,
1211
- includePairs,
1212
- });
1213
- totalDangling += stats.danglingEdges;
1214
- totalEdges += stats.totalEdges;
1215
- totalNodes += stats.nodes;
1216
- perHolder.push({ holder, stats });
1217
- }
1369
+ // --repair hands the fix (or its rehearsal) to the running server, which is
1370
+ // the only database writer; --write is the explicit opt-in to the real thing.
1371
+ let repair = null;
1372
+ if (flags.repair) {
1373
+ const body = { dryRun: !flags.write };
1374
+ if (characterId) body.characterId = characterId;
1375
+ const payload = await postMemoriesAction(flags, 'repair', body, {
1376
+ why: 'The repair runs inside the server (it is the only database writer, and it queues the embedding jobs).',
1377
+ announce: flags.json ? undefined : (url) => process.stderr.write(
1378
+ `${body.dryRun ? 'Rehearsing' : 'Running'} graph repair via ${url}\n`
1379
+ ),
1380
+ });
1381
+ repair = (payload && payload.report) || null;
1382
+ }
1218
1383
 
1219
- if (flags.json) {
1220
- renderJson({
1221
- scanned: holderIds ? 'single' : 'all',
1222
- characterIds: holderRows.map(h => h.id),
1223
- totals: {
1224
- nodes: totalNodes,
1225
- edges: totalEdges,
1226
- danglingEdges: totalDangling,
1227
- },
1228
- holders: perHolder.map(({ holder, stats }) => ({
1229
- holder: { id: holder.id, name: holder.name },
1230
- ...stats,
1231
- })),
1232
- });
1233
- } else {
1234
- const scope = holderIds ? `holder ${holderRows[0].name}` : `${holderRows.length} holders`;
1235
- const drift = totalEdges > 0 ? ((totalDangling / totalEdges) * 100).toFixed(2) : '0.00';
1236
- console.log(`Scanned ${scope}: ${totalNodes} memories, ${totalEdges} edges, ${totalDangling} dangling (${drift}%).`);
1237
- if (totalDangling === 0) {
1238
- console.log(colorize('Graph is clean.', GREEN));
1239
- } else if (flags.list) {
1240
- console.log('');
1241
- for (const { holder, stats } of perHolder) {
1242
- if (!stats.danglingPairs || stats.danglingPairs.length === 0) continue;
1243
- console.log(colorize(`${holder.name} (${shortId(holder.id)})`, BOLD));
1244
- for (const pair of stats.danglingPairs) {
1245
- console.log(` ${shortId(pair.sourceId)} → ${pair.targetIds.map(shortId).join(', ')}`);
1246
- }
1247
- }
1248
- } else {
1249
- console.log(colorize('Run with --list to see offending source memory IDs and dangling targets.', YELLOW));
1384
+ if (flags.json) {
1385
+ renderJson({
1386
+ scanned: characterId ? 'single' : 'all',
1387
+ characterIds: local.perHolder.map(({ holder }) => holder.id),
1388
+ totals: local.totals,
1389
+ holders: local.perHolder.map(({ holder, stats, digests }) => ({
1390
+ holder: { id: holder.id, name: holder.name },
1391
+ ...stats,
1392
+ ...digests,
1393
+ })),
1394
+ ...(flags.repair ? { repair } : {}),
1395
+ });
1396
+ } else {
1397
+ renderValidateText(local, characterId, flags);
1398
+ if (flags.repair) renderRepairText(repair);
1399
+ }
1400
+
1401
+ // Exit 1 while dangling links remain: always after a read-only validate or
1402
+ // a rehearsal; after a --write repair only if the server reported nothing
1403
+ // repaired (a missing embedding alone is a warning, never a failure).
1404
+ const t = local.totals;
1405
+ const dangling = t.danglingEdges + t.danglingConsolidatedFrom + t.danglingSupersededBy;
1406
+ const repaired = Boolean(flags.repair && flags.write && repair && repair.repaired);
1407
+ if (dangling > 0 && !repaired) {
1408
+ process.exitCode = 1;
1409
+ }
1410
+ }
1411
+
1412
+ /** Run every read-only integrity scan over the holders. */
1413
+ function scanForValidate(db, holderRows, includePairs) {
1414
+ const allIds = loadMemoryIds(db);
1415
+ const columns = memoryColumns(db);
1416
+ const totals = {
1417
+ nodes: 0,
1418
+ edges: 0,
1419
+ danglingEdges: 0,
1420
+ danglingConsolidatedFrom: 0,
1421
+ digestsWithDanglingMembers: 0,
1422
+ danglingSupersededBy: 0,
1423
+ missingEmbeddings: 0,
1424
+ };
1425
+ const perHolder = [];
1426
+ for (const holder of holderRows) {
1427
+ const stats = scanDanglingEdges(db, { characterId: holder.id, includePairs, allIds });
1428
+ const digests = scanDigestIntegrity(db, { characterId: holder.id, includePairs, allIds, columns });
1429
+ totals.nodes += stats.nodes;
1430
+ totals.edges += stats.totalEdges;
1431
+ totals.danglingEdges += stats.danglingEdges;
1432
+ totals.danglingConsolidatedFrom += digests.consolidatedFrom.links;
1433
+ totals.digestsWithDanglingMembers += digests.consolidatedFrom.digests;
1434
+ totals.danglingSupersededBy += digests.supersededBy;
1435
+ totals.missingEmbeddings += digests.missingEmbeddings;
1436
+ perHolder.push({ holder, stats, digests });
1437
+ }
1438
+ return { totals, perHolder };
1439
+ }
1440
+
1441
+ function renderValidateText(local, characterId, flags) {
1442
+ const { totals: t, perHolder } = local;
1443
+ const scope = characterId ? `holder ${perHolder[0].holder.name}` : `${perHolder.length} holders`;
1444
+ const drift = t.edges > 0 ? ((t.danglingEdges / t.edges) * 100).toFixed(2) : '0.00';
1445
+ console.log(`Scanned ${scope}: ${t.nodes} memories, ${t.edges} edges, ${t.danglingEdges} dangling (${drift}%).`);
1446
+ console.log(`Digests: ${t.danglingConsolidatedFrom} dangling consolidatedFrom member(s) across ` +
1447
+ `${t.digestsWithDanglingMembers} digest(s); ${t.danglingSupersededBy} dangling supersededById.`);
1448
+ if (t.missingEmbeddings > 0) {
1449
+ console.log(colorize(`Warning: ${t.missingEmbeddings} memories have no embedding and cannot be recalled by meaning.`, YELLOW));
1450
+ }
1451
+
1452
+ const dangling = t.danglingEdges + t.danglingConsolidatedFrom + t.danglingSupersededBy;
1453
+ if (dangling === 0) {
1454
+ console.log(colorize('Graph is clean.', GREEN));
1455
+ } else if (flags.list) {
1456
+ console.log('');
1457
+ for (const { holder, stats, digests } of perHolder) {
1458
+ const related = stats.danglingPairs || [];
1459
+ const members = digests.consolidatedFromPairs || [];
1460
+ const superseded = digests.supersededByPairs || [];
1461
+ if (related.length + members.length + superseded.length === 0) continue;
1462
+ console.log(colorize(`${holder.name} (${shortId(holder.id)})`, BOLD));
1463
+ for (const pair of related) {
1464
+ console.log(` related ${shortId(pair.sourceId)} → ${pair.targetIds.map(shortId).join(', ')}`);
1465
+ }
1466
+ for (const pair of members) {
1467
+ console.log(` digest ${shortId(pair.digestId)} ⊃ ${pair.memberIds.map(shortId).join(', ')}`);
1468
+ }
1469
+ for (const pair of superseded) {
1470
+ console.log(` superseded ${shortId(pair.memberId)} → ${shortId(pair.digestId)}`);
1250
1471
  }
1251
1472
  }
1473
+ } else {
1474
+ console.log(colorize('Run with --list to see the offending memory IDs and dangling targets.', YELLOW));
1475
+ }
1476
+ if ((dangling > 0 || t.missingEmbeddings > 0) && !flags.repair) {
1477
+ console.log(colorize('Run with --repair to rehearse the fix on the running server (add --write to apply it).', YELLOW));
1478
+ }
1479
+ }
1252
1480
 
1253
- if (totalDangling > 0) {
1254
- process.exitCode = 1;
1255
- }
1256
- } finally {
1257
- db.close();
1481
+ function renderRepairText(report) {
1482
+ if (!report) {
1483
+ console.log(colorize('The server returned no repair report.', YELLOW));
1484
+ return;
1485
+ }
1486
+ const f = report.found || {};
1487
+ const related = f.related || { links: 0, rows: 0 };
1488
+ const cf = f.consolidatedFrom || { links: 0, digests: 0 };
1489
+ console.log('');
1490
+ console.log(colorize(`Server graph repair (${report.dryRun ? 'dry run' : 'applied'}${report.characterId ? '' : ', every memory'}):`, BOLD));
1491
+ console.log(` found: ${related.links} dangling related link(s) on ${related.rows} row(s); ` +
1492
+ `${cf.links} dangling consolidatedFrom member(s) on ${cf.digests} digest(s); ` +
1493
+ `${f.supersededBy || 0} dangling supersededById; ${f.missingEmbeddings || 0} row(s) without an embedding.`);
1494
+ if (report.dryRun) {
1495
+ const anything = related.links + cf.links + (f.supersededBy || 0) + (f.missingEmbeddings || 0);
1496
+ console.log(anything > 0
1497
+ ? colorize(' Nothing was changed. Add --write to scrub the links and queue the embedding jobs.', YELLOW)
1498
+ : colorize(' Nothing to repair.', GREEN));
1499
+ return;
1500
+ }
1501
+ const r = report.repaired || {};
1502
+ console.log(` repaired: ${r.relatedRowsTouched || 0} row(s) relinked, ${r.digestsTouched || 0} digest(s) touched, ` +
1503
+ `${r.membersCut || 0} member(s) cut loose, ${r.charactersAffected || 0} character(s) affected; ` +
1504
+ `${r.embeddingJobsQueued || 0} embedding job(s) queued.`);
1505
+ if (r.embeddingSkippedReason) {
1506
+ console.log(colorize(` Embedding jobs not queued: ${r.embeddingSkippedReason} (set a default embedding profile, then run again).`, YELLOW));
1258
1507
  }
1259
1508
  }
1260
1509
 
@@ -1287,16 +1536,56 @@ Subcommands:
1287
1536
  tree <id|prefix> [--depth N] [--max-nodes N] [--json]
1288
1537
  ASCII walk of the
1289
1538
  related-memory graph.
1290
- status [--character <name|id>] [--json] Per-holder rollup + dangling-
1291
- edge check.
1292
- validate [--character <name|id>] [--list] Read-only memory-graph health
1293
- [--json] check. Exits 1 if any
1294
- dangling edges remain.
1539
+ status [--character <name|id>] [--json] [--port <n>]
1540
+ Per-holder rollup, maintenance
1541
+ ledger + dangling-edge check.
1542
+ validate [--character <name|id>] [--list] [--json]
1543
+ Read-only memory-graph health
1544
+ check: dangling relatedMemoryIds,
1545
+ consolidatedFrom members and
1546
+ supersededById links, plus rows
1547
+ with no embedding (a warning).
1548
+ Exits 1 if any dangling link
1549
+ remains.
1550
+ validate --repair [--write] [--character <name|id>] [--port N]
1551
+ Ask the running server to repair
1552
+ the graph. Without --write it is
1553
+ a dry run: it reports what would
1554
+ be repaired and changes nothing.
1555
+ With --write the server scrubs
1556
+ the dangling links and queues
1557
+ embedding jobs for unembedded
1558
+ rows. The write runs inside the
1559
+ server (the only database
1560
+ writer, holder of the instance
1561
+ lock), so the server must be up.
1562
+ maintain --character <name|id|all> [--phases a,b,…] [--dry-run]
1563
+ [--max N] [--threshold 0..1] [--cap N] [--port N] [--json]
1564
+ Run memory maintenance by hand on
1565
+ the running server. Phases, in
1566
+ run order: fold-near-duplicates,
1567
+ consolidate, demote, retire,
1568
+ mirror (default: all; naming
1569
+ consolidate brings mirror).
1570
+ --dry-run prints what each phase
1571
+ would do and writes nothing;
1572
+ otherwise one background job per
1573
+ character is queued. 'all' runs
1574
+ every character (a dry run over
1575
+ 'all' must leave out consolidate).
1576
+ --max caps clusters consolidated,
1577
+ --threshold sets the cluster
1578
+ cosine (neural embedding
1579
+ profiles), --cap the hot-row cap
1580
+ for demotion (100-100000).
1581
+ To consolidate only:
1582
+ maintain --phases consolidate.
1295
1583
 
1296
1584
  Shared filter flags:
1297
1585
  --character <name|id|all> Holder. Default: all.
1298
1586
  --about <name|id|self|none> Subject (aboutCharacterId).
1299
- --source AUTO|MANUAL Restrict by source.
1587
+ --source AUTO|MANUAL|CONSOLIDATED Restrict by source.
1588
+ --tier hot|cold Restrict by recall tier (default: both).
1300
1589
  --chat <id|title|none> Source chat ('none' for manual memories).
1301
1590
  --project <id|name> Project context.
1302
1591
  --since <date> ISO date floor on createdAt.
@@ -1317,6 +1606,10 @@ Global flags (may appear before or after the subcommand):
1317
1606
  -d, --data-dir <path> Override data directory.
1318
1607
  --instance <name> Use a registered instance.
1319
1608
  --passphrase <pass> Decrypt .dbkey if peppered.
1609
+ -p, --port <n> Server port for the verbs that call the running
1610
+ server (default: the port in the instance's
1611
+ lock when --instance/--data-dir is given, else
1612
+ 3000).
1320
1613
  --json Machine-readable output.
1321
1614
  -h, --help Show this help.
1322
1615
 
@@ -1324,7 +1617,9 @@ Note: -i is reserved here for 'grep --ignore-case'. Use the long --instance
1324
1617
  form to target a registered instance.
1325
1618
 
1326
1619
  All memories verbs are read-only. They open the main encrypted database
1327
- (quilltap.db) and never write to it.
1620
+ (quilltap.db) and never write to it. 'maintain' and 'validate --repair' leave
1621
+ the writing to the running server; 'validate --repair' changes data only with
1622
+ --write.
1328
1623
 
1329
1624
  Examples:
1330
1625
  quilltap memories ls
@@ -1337,11 +1632,27 @@ Examples:
1337
1632
  quilltap memories status --character Ariadne
1338
1633
  quilltap memories validate
1339
1634
  quilltap memories validate --list
1635
+ quilltap memories validate --repair # rehearse the repair
1636
+ quilltap memories validate --repair --write # apply it
1637
+ quilltap memories maintain --instance Friday --character Friday --dry-run
1638
+ quilltap memories maintain --instance Friday --character Friday --phases consolidate --max 100
1639
+ quilltap memories maintain --character all --phases demote,retire --dry-run
1640
+ quilltap memories maintain --character all
1340
1641
  `);
1341
1642
  }
1342
1643
 
1343
1644
  // ---------- dispatcher ----------
1344
1645
 
1646
+ /** Resolve --character locally to a UUID, for verbs that call the running server. */
1647
+ async function resolveCharacterIdForServer(flags) {
1648
+ const { db, openMounts } = await openDb(flags);
1649
+ try {
1650
+ return resolveCharacter(db, flags.character, openMounts).id;
1651
+ } finally {
1652
+ db.close();
1653
+ }
1654
+ }
1655
+
1345
1656
  async function memoriesCommand(args) {
1346
1657
  if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
1347
1658
  printMemoriesHelp();
@@ -1357,6 +1668,12 @@ async function memoriesCommand(args) {
1357
1668
  printMemoriesHelp();
1358
1669
  return;
1359
1670
  }
1671
+ if (flags.repair && verb !== 'validate') {
1672
+ throw new Error('--repair applies only to memories validate.');
1673
+ }
1674
+ if (flags.write && !(verb === 'validate' && flags.repair)) {
1675
+ throw new Error('--write applies only to memories validate --repair.');
1676
+ }
1360
1677
  switch (verb) {
1361
1678
  case 'ls': await cmdLs(flags); break;
1362
1679
  case 'find': await cmdFind(flags, positional); break;
@@ -1365,6 +1682,7 @@ async function memoriesCommand(args) {
1365
1682
  case 'tree': await cmdTree(flags, positional); break;
1366
1683
  case 'status': await cmdStatus(flags); break;
1367
1684
  case 'validate': await cmdValidate(flags); break;
1685
+ case 'maintain': await cmdMaintain(flags, resolveCharacterIdForServer); break;
1368
1686
  default:
1369
1687
  console.error(`Unknown memories subcommand: ${verb}`);
1370
1688
  console.error("Run 'quilltap memories --help' for a list.");
@@ -1376,6 +1694,8 @@ module.exports = {
1376
1694
  memoriesCommand,
1377
1695
  // Exported for unit tests:
1378
1696
  parseFlags,
1697
+ resolveCharacterIdForServer,
1698
+ renderRepairText,
1379
1699
  buildWhereClause,
1380
1700
  buildOrderBy,
1381
1701
  traverseMemoryGraph,