quilltap 4.10.0-dev.149 → 4.10.0-dev.173

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,198 @@ 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
+ console.log(` last demotion: ${d.demoted} of ${d.excess} over the cap${d.effective ? '' : ' ⚠ ineffective; watermark backing off'}`);
1323
+ }
1324
+ } else if (!m.ledgerReachable) {
1325
+ console.log(` ledger: (server not reachable; job history only)`);
1326
+ }
1327
+ if (m.recentJobs.length === 0) {
1328
+ console.log(` jobs: none recorded`);
1329
+ }
1330
+ for (const job of m.recentJobs) {
1331
+ const phases = Array.isArray(job.phases) ? job.phases.join(',') : 'all';
1332
+ const err = job.lastError ? ` ⚠ ${truncate(job.lastError, 60)}` : '';
1333
+ console.log(` job ${shortId(job.id)} ${job.status.padEnd(10)} ${job.updatedAt} ${job.reason || '?'} [${phases}]${err}`);
1334
+ }
1335
+ }
1336
+
1236
1337
  // ---------- validate ----------
1237
1338
 
1238
1339
  async function cmdValidate(flags) {
1340
+ let characterId = null;
1341
+ let local;
1239
1342
  const { db, openMounts } = await openDb(flags);
1240
1343
  try {
1241
- let holderIds = null;
1242
1344
  let holderRows;
1243
1345
  if (flags.character && flags.character !== 'all') {
1244
1346
  const c = resolveCharacter(db, flags.character, openMounts);
1245
1347
  holderRows = [{ id: c.id, name: c.name }];
1246
- holderIds = [c.id];
1348
+ characterId = c.id;
1247
1349
  } else {
1350
+ // Every holder in the memories table — including one whose character
1351
+ // row is gone — so the local scan covers the same rows the server's
1352
+ // unscoped repair scans and scrubs (review of PR 98).
1248
1353
  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
1354
+ SELECT h.characterId AS id, COALESCE(c.name, '(missing character ' || substr(h.characterId, 1, 8) || ')') AS name
1355
+ FROM (SELECT DISTINCT characterId FROM memories) h
1356
+ LEFT JOIN characters c ON c.id = h.characterId
1357
+ ORDER BY c.name IS NULL, c.name
1252
1358
  `).all();
1253
1359
  }
1360
+ local = scanForValidate(db, holderRows, Boolean(flags.list));
1361
+ } finally {
1362
+ db.close();
1363
+ }
1254
1364
 
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
- }
1365
+ // --repair hands the fix (or its rehearsal) to the running server, which is
1366
+ // the only database writer; --write is the explicit opt-in to the real thing.
1367
+ let repair = null;
1368
+ if (flags.repair) {
1369
+ const body = { dryRun: !flags.write };
1370
+ if (characterId) body.characterId = characterId;
1371
+ const payload = await postMemoriesAction(flags, 'repair', body, {
1372
+ why: 'The repair runs inside the server (it is the only database writer, and it queues the embedding jobs).',
1373
+ announce: flags.json ? undefined : (url) => process.stderr.write(
1374
+ `${body.dryRun ? 'Rehearsing' : 'Running'} graph repair via ${url}\n`
1375
+ ),
1376
+ });
1377
+ repair = (payload && payload.report) || null;
1378
+ }
1270
1379
 
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));
1380
+ if (flags.json) {
1381
+ renderJson({
1382
+ scanned: characterId ? 'single' : 'all',
1383
+ characterIds: local.perHolder.map(({ holder }) => holder.id),
1384
+ totals: local.totals,
1385
+ holders: local.perHolder.map(({ holder, stats, digests }) => ({
1386
+ holder: { id: holder.id, name: holder.name },
1387
+ ...stats,
1388
+ ...digests,
1389
+ })),
1390
+ ...(flags.repair ? { repair } : {}),
1391
+ });
1392
+ } else {
1393
+ renderValidateText(local, characterId, flags);
1394
+ if (flags.repair) renderRepairText(repair);
1395
+ }
1396
+
1397
+ // Exit 1 while dangling links remain: always after a read-only validate or
1398
+ // a rehearsal; after a --write repair only if the server reported nothing
1399
+ // repaired (a missing embedding alone is a warning, never a failure).
1400
+ const t = local.totals;
1401
+ const dangling = t.danglingEdges + t.danglingConsolidatedFrom + t.danglingSupersededBy;
1402
+ const repaired = Boolean(flags.repair && flags.write && repair && repair.repaired);
1403
+ if (dangling > 0 && !repaired) {
1404
+ process.exitCode = 1;
1405
+ }
1406
+ }
1407
+
1408
+ /** Run every read-only integrity scan over the holders. */
1409
+ function scanForValidate(db, holderRows, includePairs) {
1410
+ const allIds = loadMemoryIds(db);
1411
+ const columns = memoryColumns(db);
1412
+ const totals = {
1413
+ nodes: 0,
1414
+ edges: 0,
1415
+ danglingEdges: 0,
1416
+ danglingConsolidatedFrom: 0,
1417
+ digestsWithDanglingMembers: 0,
1418
+ danglingSupersededBy: 0,
1419
+ missingEmbeddings: 0,
1420
+ };
1421
+ const perHolder = [];
1422
+ for (const holder of holderRows) {
1423
+ const stats = scanDanglingEdges(db, { characterId: holder.id, includePairs, allIds });
1424
+ const digests = scanDigestIntegrity(db, { characterId: holder.id, includePairs, allIds, columns });
1425
+ totals.nodes += stats.nodes;
1426
+ totals.edges += stats.totalEdges;
1427
+ totals.danglingEdges += stats.danglingEdges;
1428
+ totals.danglingConsolidatedFrom += digests.consolidatedFrom.links;
1429
+ totals.digestsWithDanglingMembers += digests.consolidatedFrom.digests;
1430
+ totals.danglingSupersededBy += digests.supersededBy;
1431
+ totals.missingEmbeddings += digests.missingEmbeddings;
1432
+ perHolder.push({ holder, stats, digests });
1433
+ }
1434
+ return { totals, perHolder };
1435
+ }
1436
+
1437
+ function renderValidateText(local, characterId, flags) {
1438
+ const { totals: t, perHolder } = local;
1439
+ const scope = characterId ? `holder ${perHolder[0].holder.name}` : `${perHolder.length} holders`;
1440
+ const drift = t.edges > 0 ? ((t.danglingEdges / t.edges) * 100).toFixed(2) : '0.00';
1441
+ console.log(`Scanned ${scope}: ${t.nodes} memories, ${t.edges} edges, ${t.danglingEdges} dangling (${drift}%).`);
1442
+ console.log(`Digests: ${t.danglingConsolidatedFrom} dangling consolidatedFrom member(s) across ` +
1443
+ `${t.digestsWithDanglingMembers} digest(s); ${t.danglingSupersededBy} dangling supersededById.`);
1444
+ if (t.missingEmbeddings > 0) {
1445
+ console.log(colorize(`Warning: ${t.missingEmbeddings} memories have no embedding and cannot be recalled by meaning.`, YELLOW));
1446
+ }
1447
+
1448
+ const dangling = t.danglingEdges + t.danglingConsolidatedFrom + t.danglingSupersededBy;
1449
+ if (dangling === 0) {
1450
+ console.log(colorize('Graph is clean.', GREEN));
1451
+ } else if (flags.list) {
1452
+ console.log('');
1453
+ for (const { holder, stats, digests } of perHolder) {
1454
+ const related = stats.danglingPairs || [];
1455
+ const members = digests.consolidatedFromPairs || [];
1456
+ const superseded = digests.supersededByPairs || [];
1457
+ if (related.length + members.length + superseded.length === 0) continue;
1458
+ console.log(colorize(`${holder.name} (${shortId(holder.id)})`, BOLD));
1459
+ for (const pair of related) {
1460
+ console.log(` related ${shortId(pair.sourceId)} → ${pair.targetIds.map(shortId).join(', ')}`);
1461
+ }
1462
+ for (const pair of members) {
1463
+ console.log(` digest ${shortId(pair.digestId)} ⊃ ${pair.memberIds.map(shortId).join(', ')}`);
1464
+ }
1465
+ for (const pair of superseded) {
1466
+ console.log(` superseded ${shortId(pair.memberId)} → ${shortId(pair.digestId)}`);
1302
1467
  }
1303
1468
  }
1469
+ } else {
1470
+ console.log(colorize('Run with --list to see the offending memory IDs and dangling targets.', YELLOW));
1471
+ }
1472
+ if ((dangling > 0 || t.missingEmbeddings > 0) && !flags.repair) {
1473
+ console.log(colorize('Run with --repair to rehearse the fix on the running server (add --write to apply it).', YELLOW));
1474
+ }
1475
+ }
1304
1476
 
1305
- if (totalDangling > 0) {
1306
- process.exitCode = 1;
1307
- }
1308
- } finally {
1309
- db.close();
1477
+ function renderRepairText(report) {
1478
+ if (!report) {
1479
+ console.log(colorize('The server returned no repair report.', YELLOW));
1480
+ return;
1481
+ }
1482
+ const f = report.found || {};
1483
+ const related = f.related || { links: 0, rows: 0 };
1484
+ const cf = f.consolidatedFrom || { links: 0, digests: 0 };
1485
+ console.log('');
1486
+ console.log(colorize(`Server graph repair (${report.dryRun ? 'dry run' : 'applied'}${report.characterId ? '' : ', every memory'}):`, BOLD));
1487
+ console.log(` found: ${related.links} dangling related link(s) on ${related.rows} row(s); ` +
1488
+ `${cf.links} dangling consolidatedFrom member(s) on ${cf.digests} digest(s); ` +
1489
+ `${f.supersededBy || 0} dangling supersededById; ${f.missingEmbeddings || 0} row(s) without an embedding.`);
1490
+ if (report.dryRun) {
1491
+ const anything = related.links + cf.links + (f.supersededBy || 0) + (f.missingEmbeddings || 0);
1492
+ console.log(anything > 0
1493
+ ? colorize(' Nothing was changed. Add --write to scrub the links and queue the embedding jobs.', YELLOW)
1494
+ : colorize(' Nothing to repair.', GREEN));
1495
+ return;
1496
+ }
1497
+ const r = report.repaired || {};
1498
+ console.log(` repaired: ${r.relatedRowsTouched || 0} row(s) relinked, ${r.digestsTouched || 0} digest(s) touched, ` +
1499
+ `${r.membersCut || 0} member(s) cut loose, ${r.charactersAffected || 0} character(s) affected; ` +
1500
+ `${r.embeddingJobsQueued || 0} embedding job(s) queued.`);
1501
+ if (r.embeddingSkippedReason) {
1502
+ console.log(colorize(` Embedding jobs not queued: ${r.embeddingSkippedReason} (set a default embedding profile, then run again).`, YELLOW));
1310
1503
  }
1311
1504
  }
1312
1505
 
@@ -1339,18 +1532,50 @@ Subcommands:
1339
1532
  tree <id|prefix> [--depth N] [--max-nodes N] [--json]
1340
1533
  ASCII walk of the
1341
1534
  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.
1535
+ status [--character <name|id>] [--json] [--port <n>]
1536
+ Per-holder rollup, maintenance
1537
+ ledger + dangling-edge check.
1538
+ validate [--character <name|id>] [--list] [--json]
1539
+ Read-only memory-graph health
1540
+ check: dangling relatedMemoryIds,
1541
+ consolidatedFrom members and
1542
+ supersededById links, plus rows
1543
+ with no embedding (a warning).
1544
+ Exits 1 if any dangling link
1545
+ remains.
1546
+ validate --repair [--write] [--character <name|id>] [--port N]
1547
+ Ask the running server to repair
1548
+ the graph. Without --write it is
1549
+ a dry run: it reports what would
1550
+ be repaired and changes nothing.
1551
+ With --write the server scrubs
1552
+ the dangling links and queues
1553
+ embedding jobs for unembedded
1554
+ rows. The write runs inside the
1555
+ server (the only database
1556
+ writer, holder of the instance
1557
+ lock), so the server must be up.
1558
+ maintain --character <name|id|all> [--phases a,b,…] [--dry-run]
1559
+ [--max N] [--threshold 0..1] [--cap N] [--port N] [--json]
1560
+ Run memory maintenance by hand on
1561
+ the running server. Phases, in
1562
+ run order: fold-near-duplicates,
1563
+ consolidate, demote, retire,
1564
+ mirror (default: all; naming
1565
+ consolidate brings mirror).
1566
+ --dry-run prints what each phase
1567
+ would do and writes nothing;
1568
+ otherwise one background job per
1569
+ character is queued. 'all' runs
1570
+ every character (a dry run over
1571
+ 'all' must leave out consolidate).
1572
+ --max caps clusters consolidated,
1573
+ --threshold sets the cluster
1574
+ cosine (neural embedding
1575
+ profiles), --cap the hot-row cap
1576
+ for demotion (100-100000).
1577
+ To consolidate only:
1578
+ maintain --phases consolidate.
1354
1579
 
1355
1580
  Shared filter flags:
1356
1581
  --character <name|id|all> Holder. Default: all.
@@ -1377,6 +1602,10 @@ Global flags (may appear before or after the subcommand):
1377
1602
  -d, --data-dir <path> Override data directory.
1378
1603
  --instance <name> Use a registered instance.
1379
1604
  --passphrase <pass> Decrypt .dbkey if peppered.
1605
+ -p, --port <n> Server port for the verbs that call the running
1606
+ server (default: the port in the instance's
1607
+ lock when --instance/--data-dir is given, else
1608
+ 3000).
1380
1609
  --json Machine-readable output.
1381
1610
  -h, --help Show this help.
1382
1611
 
@@ -1384,8 +1613,9 @@ Note: -i is reserved here for 'grep --ignore-case'. Use the long --instance
1384
1613
  form to target a registered instance.
1385
1614
 
1386
1615
  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.
1616
+ (quilltap.db) and never write to it. 'maintain' and 'validate --repair' leave
1617
+ the writing to the running server; 'validate --repair' changes data only with
1618
+ --write.
1389
1619
 
1390
1620
  Examples:
1391
1621
  quilltap memories ls
@@ -1398,8 +1628,12 @@ Examples:
1398
1628
  quilltap memories status --character Ariadne
1399
1629
  quilltap memories validate
1400
1630
  quilltap memories validate --list
1401
- quilltap memories consolidate --instance Friday --character Friday --dry-run
1402
- quilltap memories consolidate --instance Friday --character Friday --max 100
1631
+ quilltap memories validate --repair # rehearse the repair
1632
+ quilltap memories validate --repair --write # apply it
1633
+ quilltap memories maintain --instance Friday --character Friday --dry-run
1634
+ quilltap memories maintain --instance Friday --character Friday --phases consolidate --max 100
1635
+ quilltap memories maintain --character all --phases demote,retire --dry-run
1636
+ quilltap memories maintain --character all
1403
1637
  `);
1404
1638
  }
1405
1639
 
@@ -1430,6 +1664,12 @@ async function memoriesCommand(args) {
1430
1664
  printMemoriesHelp();
1431
1665
  return;
1432
1666
  }
1667
+ if (flags.repair && verb !== 'validate') {
1668
+ throw new Error('--repair applies only to memories validate.');
1669
+ }
1670
+ if (flags.write && !(verb === 'validate' && flags.repair)) {
1671
+ throw new Error('--write applies only to memories validate --repair.');
1672
+ }
1433
1673
  switch (verb) {
1434
1674
  case 'ls': await cmdLs(flags); break;
1435
1675
  case 'find': await cmdFind(flags, positional); break;
@@ -1438,7 +1678,7 @@ async function memoriesCommand(args) {
1438
1678
  case 'tree': await cmdTree(flags, positional); break;
1439
1679
  case 'status': await cmdStatus(flags); break;
1440
1680
  case 'validate': await cmdValidate(flags); break;
1441
- case 'consolidate': await cmdConsolidate(flags, resolveCharacterIdForServer); break;
1681
+ case 'maintain': await cmdMaintain(flags, resolveCharacterIdForServer); break;
1442
1682
  default:
1443
1683
  console.error(`Unknown memories subcommand: ${verb}`);
1444
1684
  console.error("Run 'quilltap memories --help' for a list.");
@@ -1451,6 +1691,7 @@ module.exports = {
1451
1691
  // Exported for unit tests:
1452
1692
  parseFlags,
1453
1693
  resolveCharacterIdForServer,
1694
+ renderRepairText,
1454
1695
  buildWhereClause,
1455
1696
  buildOrderBy,
1456
1697
  traverseMemoryGraph,