quilltap 4.10.0-dev.99 → 4.11.0-dev.106

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, putMemory, 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,24 @@ 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
+ case '--keystone': flags.keystone = true; break;
156
+ case '--off': flags.off = true; break;
157
+
158
+ case '--phases': flags.phases = args[++i] || ''; break;
159
+ case '--cap': {
160
+ const v = Number(args[++i]);
161
+ if (!Number.isInteger(v)) {
162
+ console.error('Error: --cap must be an integer');
163
+ process.exit(1);
164
+ }
165
+ flags.cap = v;
166
+ break;
167
+ }
137
168
 
138
169
  case '--semantic': flags.semantic = true; break;
170
+ case '--dry-run': flags.dryRun = true; break;
139
171
  case '--top': flags.top = parseInt(args[++i], 10) || 0; break;
140
172
  case '--threshold': {
141
173
  const v = parseFloat(args[++i]);
@@ -146,7 +178,7 @@ function parseFlags(args) {
146
178
  flags.threshold = v;
147
179
  break;
148
180
  }
149
- case '--port': {
181
+ case '-p': case '--port': {
150
182
  const p = parseInt(args[++i], 10);
151
183
  if (isNaN(p) || p < 1 || p > 65535) {
152
184
  console.error('Error: --port must be between 1 and 65535');
@@ -220,13 +252,27 @@ function buildWhereClause(db, flags, openMounts = null) {
220
252
 
221
253
  if (flags.source) {
222
254
  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}')`);
255
+ if (s !== 'AUTO' && s !== 'MANUAL' && s !== 'CONSOLIDATED') {
256
+ throw new Error(`--source must be AUTO, MANUAL or CONSOLIDATED (got '${flags.source}')`);
225
257
  }
226
258
  clauses.push('m.source = ?');
227
259
  params.push(s);
228
260
  }
229
261
 
262
+ if (flags.keystone) {
263
+ // Instances that haven't run add-memory-keystone-v1 have no keystones.
264
+ clauses.push(hasMemoryColumn(db, 'keystone') ? 'm.keystone = 1' : '0');
265
+ }
266
+
267
+ if (flags.tier) {
268
+ const t = String(flags.tier).toLowerCase();
269
+ if (t !== 'hot' && t !== 'cold') {
270
+ throw new Error(`--tier must be hot or cold (got '${flags.tier}')`);
271
+ }
272
+ // Legacy instances without the column read every row as hot.
273
+ clauses.push(t === 'hot' ? "COALESCE(m.tier, 'hot') = 'hot'" : "m.tier = 'cold'");
274
+ }
275
+
230
276
  if (flags.chat) {
231
277
  if (flags.chat === 'none') {
232
278
  clauses.push('m.chatId IS NULL');
@@ -313,7 +359,7 @@ function buildOrderBy(sortFlag, reverse) {
313
359
  // SELECT fragment that pulls every column the renderers need, including the
314
360
  // joined-in character / chat names. `m.*` is followed by aliases for the joins
315
361
  // so callers don't have to re-resolve UUIDs.
316
- const SELECT_BASE = `
362
+ const SELECT_BASE_TEMPLATE = `
317
363
  SELECT
318
364
  m.id,
319
365
  m.characterId,
@@ -331,6 +377,7 @@ const SELECT_BASE = `
331
377
  m.lastReinforcedAt,
332
378
  m.lastAccessedAt,
333
379
  m.source,
380
+ __TIER_COLUMNS__
334
381
  m.relatedMemoryIds,
335
382
  m.createdAt,
336
383
  m.updatedAt,
@@ -346,6 +393,26 @@ const SELECT_BASE = `
346
393
  LEFT JOIN projects ON projects.id = m.projectId
347
394
  `;
348
395
 
396
+ function hasMemoryColumn(db, name) {
397
+ try {
398
+ return db.prepare("PRAGMA table_info('memories')").all().some((c) => c.name === name);
399
+ } catch {
400
+ return false; // treat as legacy
401
+ }
402
+ }
403
+
404
+ // Instances that haven't yet run add-memory-tiers-v1 lack the tier columns (and
405
+ // those before add-memory-keystone-v1 the keystone column); substitute
406
+ // constants so the CLI still reads them (rows read as hot, not keystones).
407
+ function selectBase(db) {
408
+ const tierCols = hasMemoryColumn(db, 'tier')
409
+ ? 'm.tier, m.supersededById, m.consolidatedFrom, m.consolidatedAt,'
410
+ : "'hot' AS tier, NULL AS supersededById, '[]' AS consolidatedFrom, NULL AS consolidatedAt,";
411
+ const keystoneCol = hasMemoryColumn(db, 'keystone') ? 'm.keystone,' : '0 AS keystone,';
412
+ return SELECT_BASE_TEMPLATE.replace('__TIER_COLUMNS__', `${tierCols} ${keystoneCol}`);
413
+ }
414
+
415
+
349
416
  // ---------- output helpers ----------
350
417
 
351
418
  function truncate(str, n) {
@@ -404,7 +471,7 @@ async function cmdLs(flags) {
404
471
  const { where, params, meta } = buildWhereClause(db, flags, openMounts);
405
472
  const { order, impField } = buildOrderBy(flags.sort, flags.reverse);
406
473
  const limit = flags.limit > 0 ? flags.limit : 50;
407
- const sql = `${SELECT_BASE} ${where} ORDER BY ${order} LIMIT ?`;
474
+ const sql = `${selectBase(db)} ${where} ORDER BY ${order} LIMIT ?`;
408
475
  const rows = db.prepare(sql).all(...params, limit);
409
476
 
410
477
  if (flags.json) {
@@ -441,6 +508,11 @@ function rowToJson(row) {
441
508
  projectName: row.projectName,
442
509
  sourceMessageId: row.sourceMessageId,
443
510
  source: row.source,
511
+ tier: row.tier || 'hot',
512
+ supersededById: row.supersededById || null,
513
+ consolidatedFrom: (() => { try { return JSON.parse(row.consolidatedFrom || '[]'); } catch { return []; } })(),
514
+ consolidatedAt: row.consolidatedAt || null,
515
+ keystone: Number(row.keystone) === 1,
444
516
  importance: row.importance,
445
517
  reinforcedImportance: row.reinforcedImportance,
446
518
  reinforcementCount: row.reinforcementCount,
@@ -469,7 +541,7 @@ function renderTable(rows, { showHolder, impField, fullTitles }) {
469
541
  showHolder ? 'holder'.padEnd(14) : null,
470
542
  impHeader.padStart(4),
471
543
  'rein'.padStart(4),
472
- 'src'.padEnd(6),
544
+ 'src'.padEnd(7),
473
545
  'about'.padEnd(20),
474
546
  'chat'.padEnd(fullTitles ? 24 : 32),
475
547
  'links'.padStart(5),
@@ -483,12 +555,13 @@ function renderTable(rows, { showHolder, impField, fullTitles }) {
483
555
  const impVal = impField === 'importance' ? row.importance : row.reinforcedImportance;
484
556
  const impStr = colorize(formatImportance(impVal), importanceColor(impVal));
485
557
  const rein = String(row.reinforcementCount ?? 0).padStart(4);
486
- const src = (row.source || '').padEnd(6);
558
+ const src = ((row.source || '') + (row.tier === 'cold' ? '*' : '')).padEnd(7);
487
559
  const about = truncate(aboutLabel(row), 20).padEnd(20);
488
560
  const chat = truncate(chatLabel(row, fullTitles), fullTitles ? 24 : 32).padEnd(fullTitles ? 24 : 32);
489
561
  const links = String(linkCount(row)).padStart(5);
490
562
  const emb = row.hasEmbedding ? 'Y' : '-';
491
- const summary = truncate(row.summary || '', sumW);
563
+ // A keystone's line is marked ◆ (a standing fact; never folded or demoted).
564
+ const summary = (Number(row.keystone) === 1 ? '◆ ' : '') + truncate(row.summary || '', sumW);
492
565
 
493
566
  const cells = [
494
567
  showHolder ? truncate(row.holderName || '(?)', 14).padEnd(14) : null,
@@ -556,7 +629,7 @@ async function cmdFind(flags, positional) {
556
629
 
557
630
  const limit = flags.limit > 0 ? flags.limit : 50;
558
631
  const fullWhere = where ? `${where} AND ${matchSql}` : `WHERE ${matchSql}`;
559
- const sql = `${SELECT_BASE} ${fullWhere} ORDER BY ${order} LIMIT ?`;
632
+ const sql = `${selectBase(db)} ${fullWhere} ORDER BY ${order} LIMIT ?`;
560
633
  const allParams = [...params, ...matchParams, ...orderParams, limit];
561
634
  const rows = db.prepare(sql).all(...allParams);
562
635
 
@@ -608,7 +681,7 @@ async function cmdSemanticGrep(flags, query) {
608
681
 
609
682
  const top = flags.top > 0 ? flags.top : 20;
610
683
  const threshold = flags.threshold >= 0 ? flags.threshold : 0.5;
611
- const port = flags.port || 3000;
684
+ const { port } = resolveMemoriesServerPort(flags);
612
685
  const url = `http://localhost:${port}/api/v1/memories?action=search`;
613
686
 
614
687
  const body = { characterId, query, limit: top, minScore: threshold };
@@ -691,7 +764,7 @@ async function cmdGrep(flags, positional) {
691
764
  const fullWhere = where ? `${where} AND ${contentClause}` : `WHERE ${contentClause}`;
692
765
 
693
766
  const limit = flags.limit > 0 ? flags.limit : 50;
694
- const sql = `${SELECT_BASE} ${fullWhere}
767
+ const sql = `${selectBase(db)} ${fullWhere}
695
768
  ORDER BY m.reinforcedImportance DESC, m.createdAt DESC
696
769
  LIMIT ?`;
697
770
  const rows = db.prepare(sql).all(...params, likeNeedle, limit);
@@ -805,7 +878,7 @@ async function cmdShow(flags, positional) {
805
878
  const { db } = await openDb(flags);
806
879
  try {
807
880
  const id = resolveMemoryId(db, idArg);
808
- const row = db.prepare(`${SELECT_BASE} WHERE m.id = ?`).get(id);
881
+ const row = db.prepare(`${selectBase(db)} WHERE m.id = ?`).get(id);
809
882
  if (!row) {
810
883
  throw new Error(`Memory ${id} not found`);
811
884
  }
@@ -878,6 +951,8 @@ function renderShowText(row, graph, depth) {
878
951
  console.log(` Holder: ${holderName} (${shortId(row.characterId)})`);
879
952
  console.log(` About: ${aboutName}${row.aboutCharacterId && row.aboutCharacterId !== row.characterId ? ` (${shortId(row.aboutCharacterId)})` : ''}`);
880
953
  console.log(` Source: ${row.source || '(?)'}`);
954
+ console.log(` Tier: ${row.tier || 'hot'}${row.supersededById ? ` (superseded by ${shortId(row.supersededById)})` : ''}`);
955
+ console.log(` Keystone: ${Number(row.keystone) === 1 ? 'yes (standing fact)' : 'no'}`);
881
956
  const reinf = row.reinforcedImportance != null ? Number(row.reinforcedImportance).toFixed(2) : '?';
882
957
  const baseImp = row.importance != null ? Number(row.importance).toFixed(2) : '?';
883
958
  console.log(` Importance: ${reinf} (reinforced from ${baseImp}, count: ${row.reinforcementCount ?? 0})`);
@@ -1065,9 +1140,23 @@ async function cmdStatus(flags) {
1065
1140
  `).all();
1066
1141
  }
1067
1142
 
1143
+ // The parent's maintenance ledger (last run per phase, last demotion's
1144
+ // effectiveness) lives in the running server; ask it, briefly. Without a
1145
+ // server the job rows still say when maintenance last ran.
1146
+ let ledgerPort = 3000;
1147
+ try {
1148
+ ledgerPort = resolveMemoriesServerPort(flags, { quiet: true }).port;
1149
+ } catch { /* openDb already validated the instance; fall back to the default */ }
1150
+ const ledger = await fetchMaintenanceLedger(ledgerPort);
1151
+
1068
1152
  const report = [];
1069
1153
  for (const holder of holderRows) {
1070
1154
  const stats = computeHolderStats(db, holder.id);
1155
+ stats.maintenance = {
1156
+ recentJobs: recentMaintenanceJobs(db, holder.id),
1157
+ ledger: ledger ? ledger.find((e) => e.characterId === holder.id) || null : null,
1158
+ ledgerReachable: !!ledger,
1159
+ };
1071
1160
  report.push({ holder, stats });
1072
1161
  }
1073
1162
 
@@ -1100,6 +1189,41 @@ async function cmdStatus(flags) {
1100
1189
  }
1101
1190
  }
1102
1191
 
1192
+ /** The server's maintenance ledger, or null when the server cannot be reached. */
1193
+ async function fetchMaintenanceLedger(port) {
1194
+ try {
1195
+ const res = await fetch(`http://localhost:${port}/api/v1/memories?action=maintenance-status`, {
1196
+ signal: AbortSignal.timeout(1500),
1197
+ });
1198
+ if (!res.ok) return null;
1199
+ const payload = await res.json();
1200
+ return Array.isArray(payload && payload.ledger) ? payload.ledger : null;
1201
+ } catch {
1202
+ return null;
1203
+ }
1204
+ }
1205
+
1206
+ /** The character's most recent MEMORY_MAINTENANCE job rows, newest first. */
1207
+ function recentMaintenanceJobs(db, characterId) {
1208
+ try {
1209
+ return db.prepare(`
1210
+ SELECT id, status, updatedAt, lastError,
1211
+ json_extract(payload, '$.reason') AS reason,
1212
+ json_extract(payload, '$.phases') AS phases
1213
+ FROM background_jobs
1214
+ WHERE type = 'MEMORY_MAINTENANCE'
1215
+ AND json_extract(payload, '$.characterId') = ?
1216
+ ORDER BY updatedAt DESC
1217
+ LIMIT 3
1218
+ `).all(characterId).map((row) => ({
1219
+ ...row,
1220
+ phases: row.phases ? JSON.parse(row.phases) : null,
1221
+ }));
1222
+ } catch {
1223
+ return [];
1224
+ }
1225
+ }
1226
+
1103
1227
  function computeHolderStats(db, characterId) {
1104
1228
  const counts = db.prepare(`
1105
1229
  SELECT
@@ -1115,6 +1239,19 @@ function computeHolderStats(db, characterId) {
1115
1239
  WHERE characterId = ?
1116
1240
  `).get(characterId);
1117
1241
 
1242
+ // Tier rollup — guarded so pre-tier instances still report.
1243
+ let tiers = { hot: counts.total || 0, cold: 0, consolidated: 0 };
1244
+ try {
1245
+ const t = db.prepare(`
1246
+ SELECT
1247
+ SUM(CASE WHEN COALESCE(tier, 'hot') = 'hot' THEN 1 ELSE 0 END) AS hot,
1248
+ SUM(CASE WHEN tier = 'cold' THEN 1 ELSE 0 END) AS cold,
1249
+ SUM(CASE WHEN source = 'CONSOLIDATED' THEN 1 ELSE 0 END) AS consolidated
1250
+ FROM memories WHERE characterId = ?
1251
+ `).get(characterId);
1252
+ tiers = { hot: t.hot || 0, cold: t.cold || 0, consolidated: t.consolidated || 0 };
1253
+ } catch { /* tier column absent: everything is hot */ }
1254
+
1118
1255
  const graph = scanDanglingEdges(db, { characterId });
1119
1256
 
1120
1257
  const topMemories = db.prepare(`
@@ -1131,6 +1268,7 @@ function computeHolderStats(db, characterId) {
1131
1268
  auto: counts.auto || 0,
1132
1269
  manual: counts.manual || 0,
1133
1270
  },
1271
+ tiers,
1134
1272
  aboutDistribution: {
1135
1273
  selfReferential: counts.selfRef || 0,
1136
1274
  aboutOthers: counts.aboutOthers || 0,
@@ -1156,6 +1294,10 @@ function renderStatusBlock(holder, stats) {
1156
1294
  console.log(` Total memories: ${stats.counts.total}`);
1157
1295
  console.log(` AUTO: ${stats.counts.auto}`);
1158
1296
  console.log(` MANUAL: ${stats.counts.manual}`);
1297
+ console.log(` Tiers:`);
1298
+ console.log(` hot: ${stats.tiers.hot}`);
1299
+ console.log(` cold: ${stats.tiers.cold}`);
1300
+ console.log(` digests (CONSOLIDATED): ${stats.tiers.consolidated}`);
1159
1301
  console.log(` About-distribution:`);
1160
1302
  console.log(` self-referential: ${stats.aboutDistribution.selfReferential}`);
1161
1303
  console.log(` about-others: ${stats.aboutDistribution.aboutOthers}`);
@@ -1172,6 +1314,7 @@ function renderStatusBlock(holder, stats) {
1172
1314
  console.log(` max degree: ${stats.graph.maxDegree}`);
1173
1315
  const danglingSuffix = stats.graph.danglingEdges > 0 ? ' ⚠ JSON UUIDs that no longer resolve' : '';
1174
1316
  console.log(` dangling edges: ${stats.graph.danglingEdges}${danglingSuffix}`);
1317
+ if (stats.maintenance) renderMaintenance(stats.maintenance);
1175
1318
  if (stats.top.length) {
1176
1319
  console.log(` Top by reinforcedImportance:`);
1177
1320
  for (const t of stats.top) {
@@ -1181,81 +1324,248 @@ function renderStatusBlock(holder, stats) {
1181
1324
  }
1182
1325
  }
1183
1326
 
1327
+ function renderMaintenance(m) {
1328
+ console.log(` Maintenance:`);
1329
+ if (m.ledger) {
1330
+ // The phases are the server's; print the ones it reports a run for.
1331
+ const runs = Object.entries(m.ledger.lastRun || {});
1332
+ if (runs.length === 0) console.log(' no phase has run since the server started');
1333
+ for (const [phase, run] of runs) {
1334
+ console.log(` ${(phase + ':').padEnd(22)}${run.at} (${run.reason})`);
1335
+ }
1336
+ const d = m.ledger.lastDemotion;
1337
+ if (d) {
1338
+ if (d.excess === 0) {
1339
+ console.log(` last demotion: within the cap${d.effective ? '' : '; automatic demotion looks again in an hour'}`);
1340
+ } else {
1341
+ console.log(` last demotion: ${d.demoted} of ${d.excess} over the cap${d.effective ? '' : ' ⚠ ineffective; watermark backing off'}`);
1342
+ }
1343
+ }
1344
+ } else if (!m.ledgerReachable) {
1345
+ console.log(` ledger: (server not reachable; job history only)`);
1346
+ }
1347
+ if (m.recentJobs.length === 0) {
1348
+ console.log(` jobs: none recorded`);
1349
+ }
1350
+ for (const job of m.recentJobs) {
1351
+ const phases = Array.isArray(job.phases) ? job.phases.join(',') : 'all';
1352
+ const err = job.lastError ? ` ⚠ ${truncate(job.lastError, 60)}` : '';
1353
+ console.log(` job ${shortId(job.id)} ${job.status.padEnd(10)} ${job.updatedAt} ${job.reason || '?'} [${phases}]${err}`);
1354
+ }
1355
+ }
1356
+
1184
1357
  // ---------- validate ----------
1185
1358
 
1186
1359
  async function cmdValidate(flags) {
1360
+ let characterId = null;
1361
+ let local;
1187
1362
  const { db, openMounts } = await openDb(flags);
1188
1363
  try {
1189
- let holderIds = null;
1190
1364
  let holderRows;
1191
1365
  if (flags.character && flags.character !== 'all') {
1192
1366
  const c = resolveCharacter(db, flags.character, openMounts);
1193
1367
  holderRows = [{ id: c.id, name: c.name }];
1194
- holderIds = [c.id];
1368
+ characterId = c.id;
1195
1369
  } else {
1370
+ // Every holder in the memories table — including one whose character
1371
+ // row is gone — so the local scan covers the same rows the server's
1372
+ // unscoped repair scans and scrubs (review of PR 98).
1196
1373
  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
1374
+ SELECT h.characterId AS id, COALESCE(c.name, '(missing character ' || substr(h.characterId, 1, 8) || ')') AS name
1375
+ FROM (SELECT DISTINCT characterId FROM memories) h
1376
+ LEFT JOIN characters c ON c.id = h.characterId
1377
+ ORDER BY c.name IS NULL, c.name
1200
1378
  `).all();
1201
1379
  }
1380
+ local = scanForValidate(db, holderRows, Boolean(flags.list));
1381
+ } finally {
1382
+ db.close();
1383
+ }
1202
1384
 
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
- }
1385
+ // --repair hands the fix (or its rehearsal) to the running server, which is
1386
+ // the only database writer; --write is the explicit opt-in to the real thing.
1387
+ let repair = null;
1388
+ if (flags.repair) {
1389
+ const body = { dryRun: !flags.write };
1390
+ if (characterId) body.characterId = characterId;
1391
+ const payload = await postMemoriesAction(flags, 'repair', body, {
1392
+ why: 'The repair runs inside the server (it is the only database writer, and it queues the embedding jobs).',
1393
+ announce: flags.json ? undefined : (url) => process.stderr.write(
1394
+ `${body.dryRun ? 'Rehearsing' : 'Running'} graph repair via ${url}\n`
1395
+ ),
1396
+ });
1397
+ repair = (payload && payload.report) || null;
1398
+ }
1218
1399
 
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));
1400
+ if (flags.json) {
1401
+ renderJson({
1402
+ scanned: characterId ? 'single' : 'all',
1403
+ characterIds: local.perHolder.map(({ holder }) => holder.id),
1404
+ totals: local.totals,
1405
+ holders: local.perHolder.map(({ holder, stats, digests }) => ({
1406
+ holder: { id: holder.id, name: holder.name },
1407
+ ...stats,
1408
+ ...digests,
1409
+ })),
1410
+ ...(flags.repair ? { repair } : {}),
1411
+ });
1412
+ } else {
1413
+ renderValidateText(local, characterId, flags);
1414
+ if (flags.repair) renderRepairText(repair);
1415
+ }
1416
+
1417
+ // Exit 1 while dangling links remain: always after a read-only validate or
1418
+ // a rehearsal; after a --write repair only if the server reported nothing
1419
+ // repaired (a missing embedding alone is a warning, never a failure).
1420
+ const t = local.totals;
1421
+ const dangling = t.danglingEdges + t.danglingConsolidatedFrom + t.danglingSupersededBy;
1422
+ const repaired = Boolean(flags.repair && flags.write && repair && repair.repaired);
1423
+ if (dangling > 0 && !repaired) {
1424
+ process.exitCode = 1;
1425
+ }
1426
+ }
1427
+
1428
+ /** Run every read-only integrity scan over the holders. */
1429
+ function scanForValidate(db, holderRows, includePairs) {
1430
+ const allIds = loadMemoryIds(db);
1431
+ const columns = memoryColumns(db);
1432
+ const totals = {
1433
+ nodes: 0,
1434
+ edges: 0,
1435
+ danglingEdges: 0,
1436
+ danglingConsolidatedFrom: 0,
1437
+ digestsWithDanglingMembers: 0,
1438
+ danglingSupersededBy: 0,
1439
+ missingEmbeddings: 0,
1440
+ };
1441
+ const perHolder = [];
1442
+ for (const holder of holderRows) {
1443
+ const stats = scanDanglingEdges(db, { characterId: holder.id, includePairs, allIds });
1444
+ const digests = scanDigestIntegrity(db, { characterId: holder.id, includePairs, allIds, columns });
1445
+ totals.nodes += stats.nodes;
1446
+ totals.edges += stats.totalEdges;
1447
+ totals.danglingEdges += stats.danglingEdges;
1448
+ totals.danglingConsolidatedFrom += digests.consolidatedFrom.links;
1449
+ totals.digestsWithDanglingMembers += digests.consolidatedFrom.digests;
1450
+ totals.danglingSupersededBy += digests.supersededBy;
1451
+ totals.missingEmbeddings += digests.missingEmbeddings;
1452
+ perHolder.push({ holder, stats, digests });
1453
+ }
1454
+ return { totals, perHolder };
1455
+ }
1456
+
1457
+ function renderValidateText(local, characterId, flags) {
1458
+ const { totals: t, perHolder } = local;
1459
+ const scope = characterId ? `holder ${perHolder[0].holder.name}` : `${perHolder.length} holders`;
1460
+ const drift = t.edges > 0 ? ((t.danglingEdges / t.edges) * 100).toFixed(2) : '0.00';
1461
+ console.log(`Scanned ${scope}: ${t.nodes} memories, ${t.edges} edges, ${t.danglingEdges} dangling (${drift}%).`);
1462
+ console.log(`Digests: ${t.danglingConsolidatedFrom} dangling consolidatedFrom member(s) across ` +
1463
+ `${t.digestsWithDanglingMembers} digest(s); ${t.danglingSupersededBy} dangling supersededById.`);
1464
+ if (t.missingEmbeddings > 0) {
1465
+ console.log(colorize(`Warning: ${t.missingEmbeddings} memories have no embedding and cannot be recalled by meaning.`, YELLOW));
1466
+ }
1467
+
1468
+ const dangling = t.danglingEdges + t.danglingConsolidatedFrom + t.danglingSupersededBy;
1469
+ if (dangling === 0) {
1470
+ console.log(colorize('Graph is clean.', GREEN));
1471
+ } else if (flags.list) {
1472
+ console.log('');
1473
+ for (const { holder, stats, digests } of perHolder) {
1474
+ const related = stats.danglingPairs || [];
1475
+ const members = digests.consolidatedFromPairs || [];
1476
+ const superseded = digests.supersededByPairs || [];
1477
+ if (related.length + members.length + superseded.length === 0) continue;
1478
+ console.log(colorize(`${holder.name} (${shortId(holder.id)})`, BOLD));
1479
+ for (const pair of related) {
1480
+ console.log(` related ${shortId(pair.sourceId)} → ${pair.targetIds.map(shortId).join(', ')}`);
1481
+ }
1482
+ for (const pair of members) {
1483
+ console.log(` digest ${shortId(pair.digestId)} ⊃ ${pair.memberIds.map(shortId).join(', ')}`);
1484
+ }
1485
+ for (const pair of superseded) {
1486
+ console.log(` superseded ${shortId(pair.memberId)} → ${shortId(pair.digestId)}`);
1250
1487
  }
1251
1488
  }
1489
+ } else {
1490
+ console.log(colorize('Run with --list to see the offending memory IDs and dangling targets.', YELLOW));
1491
+ }
1492
+ if ((dangling > 0 || t.missingEmbeddings > 0) && !flags.repair) {
1493
+ console.log(colorize('Run with --repair to rehearse the fix on the running server (add --write to apply it).', YELLOW));
1494
+ }
1495
+ }
1252
1496
 
1253
- if (totalDangling > 0) {
1254
- process.exitCode = 1;
1255
- }
1497
+ function renderRepairText(report) {
1498
+ if (!report) {
1499
+ console.log(colorize('The server returned no repair report.', YELLOW));
1500
+ return;
1501
+ }
1502
+ const f = report.found || {};
1503
+ const related = f.related || { links: 0, rows: 0 };
1504
+ const cf = f.consolidatedFrom || { links: 0, digests: 0 };
1505
+ console.log('');
1506
+ console.log(colorize(`Server graph repair (${report.dryRun ? 'dry run' : 'applied'}${report.characterId ? '' : ', every memory'}):`, BOLD));
1507
+ console.log(` found: ${related.links} dangling related link(s) on ${related.rows} row(s); ` +
1508
+ `${cf.links} dangling consolidatedFrom member(s) on ${cf.digests} digest(s); ` +
1509
+ `${f.supersededBy || 0} dangling supersededById; ${f.missingEmbeddings || 0} row(s) without an embedding.`);
1510
+ if (report.dryRun) {
1511
+ const anything = related.links + cf.links + (f.supersededBy || 0) + (f.missingEmbeddings || 0);
1512
+ console.log(anything > 0
1513
+ ? colorize(' Nothing was changed. Add --write to scrub the links and queue the embedding jobs.', YELLOW)
1514
+ : colorize(' Nothing to repair.', GREEN));
1515
+ return;
1516
+ }
1517
+ const r = report.repaired || {};
1518
+ console.log(` repaired: ${r.relatedRowsTouched || 0} row(s) relinked, ${r.digestsTouched || 0} digest(s) touched, ` +
1519
+ `${r.membersCut || 0} member(s) cut loose, ${r.charactersAffected || 0} character(s) affected; ` +
1520
+ `${r.embeddingJobsQueued || 0} embedding job(s) queued.`);
1521
+ if (r.embeddingSkippedReason) {
1522
+ console.log(colorize(` Embedding jobs not queued: ${r.embeddingSkippedReason} (set a default embedding profile, then run again).`, YELLOW));
1523
+ }
1524
+ }
1525
+
1526
+ // ---------- keystone ----------
1527
+
1528
+ /**
1529
+ * Mark or unmark a keystone. The id resolves locally (read-only); the change
1530
+ * itself goes to the running server's memory PUT, through the edit chokepoint
1531
+ * that enforces the per-subject limit and invalidates the frozen archive.
1532
+ */
1533
+ async function cmdKeystone(flags, positional) {
1534
+ const idArg = positional[0];
1535
+ if (!idArg) {
1536
+ throw new Error('memories keystone: missing <id>. Usage: quilltap memories keystone <id|prefix> [--off] [--write]');
1537
+ }
1538
+ const want = !flags.off;
1539
+ const { db } = await openDb(flags);
1540
+ let row;
1541
+ try {
1542
+ const id = resolveMemoryId(db, idArg);
1543
+ row = db.prepare(`${selectBase(db)} WHERE m.id = ?`).get(id);
1544
+ if (!row) throw new Error(`Memory ${id} not found`);
1256
1545
  } finally {
1257
1546
  db.close();
1258
1547
  }
1548
+ const is = Number(row.keystone) === 1;
1549
+ const label = `${shortId(row.id)} (${truncate(row.summary || '', 60)})`;
1550
+ if (is === want) {
1551
+ const msg = `${label} is ${want ? 'already' : 'not'} a keystone; nothing to do.`;
1552
+ if (flags.json) renderJson({ id: row.id, keystone: is, changed: false }); else console.log(msg);
1553
+ return;
1554
+ }
1555
+ if (!flags.write) {
1556
+ const msg = `Would ${want ? 'mark' : 'unmark'} ${label} as a keystone. Re-run with --write to apply.`;
1557
+ if (flags.json) renderJson({ id: row.id, keystone: is, wouldBe: want, changed: false, dryRun: true }); else console.log(msg);
1558
+ return;
1559
+ }
1560
+ const payload = await putMemory(flags, row.id, { keystone: want }, {
1561
+ why: 'Changing a keystone needs the running server (the only database writer).',
1562
+ });
1563
+ const now = Boolean(payload && payload.memory && payload.memory.keystone);
1564
+ if (flags.json) {
1565
+ renderJson({ id: row.id, keystone: now, changed: now === want });
1566
+ } else {
1567
+ console.log(`${want ? 'Marked' : 'Unmarked'} ${label} as a keystone.`);
1568
+ }
1259
1569
  }
1260
1570
 
1261
1571
  // ---------- help ----------
@@ -1287,16 +1597,67 @@ Subcommands:
1287
1597
  tree <id|prefix> [--depth N] [--max-nodes N] [--json]
1288
1598
  ASCII walk of the
1289
1599
  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.
1600
+ status [--character <name|id>] [--json] [--port <n>]
1601
+ Per-holder rollup, maintenance
1602
+ ledger + dangling-edge check.
1603
+ validate [--character <name|id>] [--list] [--json]
1604
+ Read-only memory-graph health
1605
+ check: dangling relatedMemoryIds,
1606
+ consolidatedFrom members and
1607
+ supersededById links, plus rows
1608
+ with no embedding (a warning).
1609
+ Exits 1 if any dangling link
1610
+ remains.
1611
+ validate --repair [--write] [--character <name|id>] [--port N]
1612
+ Ask the running server to repair
1613
+ the graph. Without --write it is
1614
+ a dry run: it reports what would
1615
+ be repaired and changes nothing.
1616
+ With --write the server scrubs
1617
+ the dangling links and queues
1618
+ embedding jobs for unembedded
1619
+ rows. The write runs inside the
1620
+ server (the only database
1621
+ writer, holder of the instance
1622
+ lock), so the server must be up.
1623
+ keystone <id|prefix> [--off] [--write] [--port N]
1624
+ Mark a memory a keystone (a
1625
+ standing fact, always recalled in
1626
+ full, never folded, consolidated
1627
+ or demoted), or unmark it with
1628
+ --off. Without --write it only
1629
+ says what would change; with
1630
+ --write the running server makes
1631
+ the change (at most 5 keystones
1632
+ per holder and subject).
1633
+ maintain --character <name|id|all> [--phases a,b,…] [--dry-run]
1634
+ [--max N] [--threshold 0..1] [--cap N] [--port N] [--json]
1635
+ Run memory maintenance by hand on
1636
+ the running server. Phases, in
1637
+ run order: fold-near-duplicates,
1638
+ consolidate, demote, retire,
1639
+ mirror (default: all; naming
1640
+ consolidate brings mirror).
1641
+ --dry-run prints what each phase
1642
+ would do and writes nothing;
1643
+ otherwise one background job per
1644
+ character is queued. 'all' runs
1645
+ every character (a dry run over
1646
+ 'all' must leave out consolidate).
1647
+ --max caps clusters consolidated,
1648
+ --threshold sets the cluster
1649
+ cosine (neural embedding
1650
+ profiles), --cap the hot-row cap
1651
+ for demotion (100-100000).
1652
+ To consolidate only:
1653
+ maintain --phases consolidate.
1295
1654
 
1296
1655
  Shared filter flags:
1297
1656
  --character <name|id|all> Holder. Default: all.
1298
1657
  --about <name|id|self|none> Subject (aboutCharacterId).
1299
- --source AUTO|MANUAL Restrict by source.
1658
+ --source AUTO|MANUAL|CONSOLIDATED Restrict by source.
1659
+ --tier hot|cold Restrict by recall tier (default: both).
1660
+ --keystone Only keystones (marked ◆ in tables).
1300
1661
  --chat <id|title|none> Source chat ('none' for manual memories).
1301
1662
  --project <id|name> Project context.
1302
1663
  --since <date> ISO date floor on createdAt.
@@ -1317,6 +1678,10 @@ Global flags (may appear before or after the subcommand):
1317
1678
  -d, --data-dir <path> Override data directory.
1318
1679
  --instance <name> Use a registered instance.
1319
1680
  --passphrase <pass> Decrypt .dbkey if peppered.
1681
+ -p, --port <n> Server port for the verbs that call the running
1682
+ server (default: the port in the instance's
1683
+ lock when --instance/--data-dir is given, else
1684
+ 3000).
1320
1685
  --json Machine-readable output.
1321
1686
  -h, --help Show this help.
1322
1687
 
@@ -1324,7 +1689,9 @@ Note: -i is reserved here for 'grep --ignore-case'. Use the long --instance
1324
1689
  form to target a registered instance.
1325
1690
 
1326
1691
  All memories verbs are read-only. They open the main encrypted database
1327
- (quilltap.db) and never write to it.
1692
+ (quilltap.db) and never write to it. 'maintain', 'keystone' and 'validate
1693
+ --repair' leave the writing to the running server; 'keystone' and 'validate
1694
+ --repair' change data only with --write.
1328
1695
 
1329
1696
  Examples:
1330
1697
  quilltap memories ls
@@ -1337,11 +1704,30 @@ Examples:
1337
1704
  quilltap memories status --character Ariadne
1338
1705
  quilltap memories validate
1339
1706
  quilltap memories validate --list
1707
+ quilltap memories validate --repair # rehearse the repair
1708
+ quilltap memories validate --repair --write # apply it
1709
+ quilltap memories ls --character Friday --keystone
1710
+ quilltap memories keystone abc12345 --write # mark a standing fact
1711
+ quilltap memories keystone abc12345 --off --write # unmark it
1712
+ quilltap memories maintain --instance Friday --character Friday --dry-run
1713
+ quilltap memories maintain --instance Friday --character Friday --phases consolidate --max 100
1714
+ quilltap memories maintain --character all --phases demote,retire --dry-run
1715
+ quilltap memories maintain --character all
1340
1716
  `);
1341
1717
  }
1342
1718
 
1343
1719
  // ---------- dispatcher ----------
1344
1720
 
1721
+ /** Resolve --character locally to a UUID, for verbs that call the running server. */
1722
+ async function resolveCharacterIdForServer(flags) {
1723
+ const { db, openMounts } = await openDb(flags);
1724
+ try {
1725
+ return resolveCharacter(db, flags.character, openMounts).id;
1726
+ } finally {
1727
+ db.close();
1728
+ }
1729
+ }
1730
+
1345
1731
  async function memoriesCommand(args) {
1346
1732
  if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
1347
1733
  printMemoriesHelp();
@@ -1357,6 +1743,15 @@ async function memoriesCommand(args) {
1357
1743
  printMemoriesHelp();
1358
1744
  return;
1359
1745
  }
1746
+ if (flags.repair && verb !== 'validate') {
1747
+ throw new Error('--repair applies only to memories validate.');
1748
+ }
1749
+ if (flags.write && !(verb === 'validate' && flags.repair) && verb !== 'keystone') {
1750
+ throw new Error('--write applies only to memories validate --repair and memories keystone.');
1751
+ }
1752
+ if (flags.off && verb !== 'keystone') {
1753
+ throw new Error('--off applies only to memories keystone.');
1754
+ }
1360
1755
  switch (verb) {
1361
1756
  case 'ls': await cmdLs(flags); break;
1362
1757
  case 'find': await cmdFind(flags, positional); break;
@@ -1365,6 +1760,8 @@ async function memoriesCommand(args) {
1365
1760
  case 'tree': await cmdTree(flags, positional); break;
1366
1761
  case 'status': await cmdStatus(flags); break;
1367
1762
  case 'validate': await cmdValidate(flags); break;
1763
+ case 'keystone': await cmdKeystone(flags, positional); break;
1764
+ case 'maintain': await cmdMaintain(flags, resolveCharacterIdForServer); break;
1368
1765
  default:
1369
1766
  console.error(`Unknown memories subcommand: ${verb}`);
1370
1767
  console.error("Run 'quilltap memories --help' for a list.");
@@ -1376,6 +1773,8 @@ module.exports = {
1376
1773
  memoriesCommand,
1377
1774
  // Exported for unit tests:
1378
1775
  parseFlags,
1776
+ resolveCharacterIdForServer,
1777
+ renderRepairText,
1379
1778
  buildWhereClause,
1380
1779
  buildOrderBy,
1381
1780
  traverseMemoryGraph,