quilltap 4.10.0-dev.149 → 4.10.0-dev.175

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,8 +11,14 @@ const {
11
11
  resolveChat,
12
12
  resolveProject,
13
13
  } = require('./db-helpers');
14
- const { scanDanglingEdges } = require('./graph-integrity');
15
- const { cmdConsolidate } = require('./memories-consolidate-command');
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');
16
22
 
17
23
  // ---------- colour & marker helpers ----------
18
24
 
@@ -89,13 +95,19 @@ function parseFlags(args) {
89
95
  noRelated: false,
90
96
  // validate
91
97
  list: false,
98
+ repair: false,
99
+ write: false,
92
100
  // semantic search
93
101
  semantic: false,
94
102
  top: 0,
95
103
  threshold: -1,
96
- port: 3000,
97
- // consolidate
104
+ // Undefined unless --port is given, so the instance lock can supply it
105
+ // (resolveServerPort in lock-helpers).
106
+ port: undefined,
107
+ // maintain
98
108
  dryRun: false,
109
+ phases: '',
110
+ cap: null,
99
111
  };
100
112
  const positional = [];
101
113
  let i = 0;
@@ -138,6 +150,19 @@ function parseFlags(args) {
138
150
  case '--no-related': flags.noRelated = true; break;
139
151
 
140
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
+ }
141
166
 
142
167
  case '--semantic': flags.semantic = true; break;
143
168
  case '--dry-run': flags.dryRun = true; break;
@@ -151,7 +176,7 @@ function parseFlags(args) {
151
176
  flags.threshold = v;
152
177
  break;
153
178
  }
154
- case '--port': {
179
+ case '-p': case '--port': {
155
180
  const p = parseInt(args[++i], 10);
156
181
  if (isNaN(p) || p < 1 || p > 65535) {
157
182
  console.error('Error: --port must be between 1 and 65535');
@@ -641,7 +666,7 @@ async function cmdSemanticGrep(flags, query) {
641
666
 
642
667
  const top = flags.top > 0 ? flags.top : 20;
643
668
  const threshold = flags.threshold >= 0 ? flags.threshold : 0.5;
644
- const port = flags.port || 3000;
669
+ const { port } = resolveMemoriesServerPort(flags);
645
670
  const url = `http://localhost:${port}/api/v1/memories?action=search`;
646
671
 
647
672
  const body = { characterId, query, limit: top, minScore: threshold };
@@ -1099,9 +1124,23 @@ async function cmdStatus(flags) {
1099
1124
  `).all();
1100
1125
  }
1101
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
+
1102
1136
  const report = [];
1103
1137
  for (const holder of holderRows) {
1104
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
+ };
1105
1144
  report.push({ holder, stats });
1106
1145
  }
1107
1146
 
@@ -1134,6 +1173,41 @@ async function cmdStatus(flags) {
1134
1173
  }
1135
1174
  }
1136
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
+
1137
1211
  function computeHolderStats(db, characterId) {
1138
1212
  const counts = db.prepare(`
1139
1213
  SELECT
@@ -1224,6 +1298,7 @@ function renderStatusBlock(holder, stats) {
1224
1298
  console.log(` max degree: ${stats.graph.maxDegree}`);
1225
1299
  const danglingSuffix = stats.graph.danglingEdges > 0 ? ' ⚠ JSON UUIDs that no longer resolve' : '';
1226
1300
  console.log(` dangling edges: ${stats.graph.danglingEdges}${danglingSuffix}`);
1301
+ if (stats.maintenance) renderMaintenance(stats.maintenance);
1227
1302
  if (stats.top.length) {
1228
1303
  console.log(` Top by reinforcedImportance:`);
1229
1304
  for (const t of stats.top) {
@@ -1233,80 +1308,202 @@ function renderStatusBlock(holder, stats) {
1233
1308
  }
1234
1309
  }
1235
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
+
1236
1341
  // ---------- validate ----------
1237
1342
 
1238
1343
  async function cmdValidate(flags) {
1344
+ let characterId = null;
1345
+ let local;
1239
1346
  const { db, openMounts } = await openDb(flags);
1240
1347
  try {
1241
- let holderIds = null;
1242
1348
  let holderRows;
1243
1349
  if (flags.character && flags.character !== 'all') {
1244
1350
  const c = resolveCharacter(db, flags.character, openMounts);
1245
1351
  holderRows = [{ id: c.id, name: c.name }];
1246
- holderIds = [c.id];
1352
+ characterId = c.id;
1247
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).
1248
1357
  holderRows = db.prepare(`
1249
- SELECT c.id, c.name FROM characters c
1250
- WHERE EXISTS (SELECT 1 FROM memories m WHERE m.characterId = c.id)
1251
- 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
1252
1362
  `).all();
1253
1363
  }
1364
+ local = scanForValidate(db, holderRows, Boolean(flags.list));
1365
+ } finally {
1366
+ db.close();
1367
+ }
1254
1368
 
1255
- const includePairs = Boolean(flags.list);
1256
- let totalDangling = 0;
1257
- let totalEdges = 0;
1258
- let totalNodes = 0;
1259
- const perHolder = [];
1260
- for (const holder of holderRows) {
1261
- const stats = scanDanglingEdges(db, {
1262
- characterId: holder.id,
1263
- includePairs,
1264
- });
1265
- totalDangling += stats.danglingEdges;
1266
- totalEdges += stats.totalEdges;
1267
- totalNodes += stats.nodes;
1268
- perHolder.push({ holder, stats });
1269
- }
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
+ }
1270
1383
 
1271
- if (flags.json) {
1272
- renderJson({
1273
- scanned: holderIds ? 'single' : 'all',
1274
- characterIds: holderRows.map(h => h.id),
1275
- totals: {
1276
- nodes: totalNodes,
1277
- edges: totalEdges,
1278
- danglingEdges: totalDangling,
1279
- },
1280
- holders: perHolder.map(({ holder, stats }) => ({
1281
- holder: { id: holder.id, name: holder.name },
1282
- ...stats,
1283
- })),
1284
- });
1285
- } else {
1286
- const scope = holderIds ? `holder ${holderRows[0].name}` : `${holderRows.length} holders`;
1287
- const drift = totalEdges > 0 ? ((totalDangling / totalEdges) * 100).toFixed(2) : '0.00';
1288
- console.log(`Scanned ${scope}: ${totalNodes} memories, ${totalEdges} edges, ${totalDangling} dangling (${drift}%).`);
1289
- if (totalDangling === 0) {
1290
- console.log(colorize('Graph is clean.', GREEN));
1291
- } else if (flags.list) {
1292
- console.log('');
1293
- for (const { holder, stats } of perHolder) {
1294
- if (!stats.danglingPairs || stats.danglingPairs.length === 0) continue;
1295
- console.log(colorize(`${holder.name} (${shortId(holder.id)})`, BOLD));
1296
- for (const pair of stats.danglingPairs) {
1297
- console.log(` ${shortId(pair.sourceId)} → ${pair.targetIds.map(shortId).join(', ')}`);
1298
- }
1299
- }
1300
- } else {
1301
- 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)}`);
1302
1471
  }
1303
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
+ }
1304
1480
 
1305
- if (totalDangling > 0) {
1306
- process.exitCode = 1;
1307
- }
1308
- } finally {
1309
- 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));
1310
1507
  }
1311
1508
  }
1312
1509
 
@@ -1339,18 +1536,50 @@ Subcommands:
1339
1536
  tree <id|prefix> [--depth N] [--max-nodes N] [--json]
1340
1537
  ASCII walk of the
1341
1538
  related-memory graph.
1342
- status [--character <name|id>] [--json] Per-holder rollup + dangling-
1343
- edge check.
1344
- validate [--character <name|id>] [--list] Read-only memory-graph health
1345
- [--json] check. Exits 1 if any
1346
- dangling edges remain.
1347
- consolidate --character <name|id> [--dry-run] [--max N] [--threshold 0..1]
1348
- [--port N] Fold clusters of hot memories
1349
- into digests (cold originals).
1350
- Asks the running server: a dry
1351
- run prints the proposed digests
1352
- and writes nothing; otherwise a
1353
- background job is queued.
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.
1354
1583
 
1355
1584
  Shared filter flags:
1356
1585
  --character <name|id|all> Holder. Default: all.
@@ -1377,6 +1606,10 @@ Global flags (may appear before or after the subcommand):
1377
1606
  -d, --data-dir <path> Override data directory.
1378
1607
  --instance <name> Use a registered instance.
1379
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).
1380
1613
  --json Machine-readable output.
1381
1614
  -h, --help Show this help.
1382
1615
 
@@ -1384,8 +1617,9 @@ Note: -i is reserved here for 'grep --ignore-case'. Use the long --instance
1384
1617
  form to target a registered instance.
1385
1618
 
1386
1619
  All memories verbs are read-only. They open the main encrypted database
1387
- (quilltap.db) and never write to it; 'consolidate' leaves the writing to the
1388
- running server.
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.
1389
1623
 
1390
1624
  Examples:
1391
1625
  quilltap memories ls
@@ -1398,8 +1632,12 @@ Examples:
1398
1632
  quilltap memories status --character Ariadne
1399
1633
  quilltap memories validate
1400
1634
  quilltap memories validate --list
1401
- quilltap memories consolidate --instance Friday --character Friday --dry-run
1402
- quilltap memories consolidate --instance Friday --character Friday --max 100
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
1403
1641
  `);
1404
1642
  }
1405
1643
 
@@ -1430,6 +1668,12 @@ async function memoriesCommand(args) {
1430
1668
  printMemoriesHelp();
1431
1669
  return;
1432
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
+ }
1433
1677
  switch (verb) {
1434
1678
  case 'ls': await cmdLs(flags); break;
1435
1679
  case 'find': await cmdFind(flags, positional); break;
@@ -1438,7 +1682,7 @@ async function memoriesCommand(args) {
1438
1682
  case 'tree': await cmdTree(flags, positional); break;
1439
1683
  case 'status': await cmdStatus(flags); break;
1440
1684
  case 'validate': await cmdValidate(flags); break;
1441
- case 'consolidate': await cmdConsolidate(flags, resolveCharacterIdForServer); break;
1685
+ case 'maintain': await cmdMaintain(flags, resolveCharacterIdForServer); break;
1442
1686
  default:
1443
1687
  console.error(`Unknown memories subcommand: ${verb}`);
1444
1688
  console.error("Run 'quilltap memories --help' for a list.");
@@ -1451,6 +1695,7 @@ module.exports = {
1451
1695
  // Exported for unit tests:
1452
1696
  parseFlags,
1453
1697
  resolveCharacterIdForServer,
1698
+ renderRepairText,
1454
1699
  buildWhereClause,
1455
1700
  buildOrderBy,
1456
1701
  traverseMemoryGraph,