quilltap 4.10.0-dev.99 → 4.11.0-dev.6
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.
- package/README.md +69 -11
- package/bin/quilltap.js +19 -2
- package/lib/__tests__/completion-coverage.test.js +1 -0
- package/lib/__tests__/memories-maintain-command.test.js +276 -0
- package/lib/__tests__/migrations-source-ids.test.js +54 -0
- package/lib/__tests__/server-port-resolution.test.js +122 -0
- package/lib/anchor-probe-command.js +222 -0
- package/lib/completion/bash.template +25 -11
- package/lib/completion/fish.template +23 -4
- package/lib/completion/zsh.template +32 -3
- package/lib/db-commands.js +19 -3
- package/lib/graph-integrity.js +108 -5
- package/lib/lock-helpers.js +66 -0
- package/lib/memories-commands.js +396 -76
- package/lib/memories-maintain-command.js +238 -0
- package/lib/memories-server.js +86 -0
- package/lib/memory-diff-command.js +28 -6
- package/lib/migrations-commands.js +18 -4
- package/lib/recall-replay-command.js +193 -11
- package/lib/text-codec.js +30 -1
- package/package.json +1 -1
package/lib/memories-commands.js
CHANGED
|
@@ -11,7 +11,14 @@ const {
|
|
|
11
11
|
resolveChat,
|
|
12
12
|
resolveProject,
|
|
13
13
|
} = require('./db-helpers');
|
|
14
|
-
const {
|
|
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
|
|
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
|
|
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
|
|
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 = `${
|
|
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(
|
|
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(
|
|
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 = `${
|
|
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
|
|
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 = `${
|
|
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(`${
|
|
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
|
-
|
|
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
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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]
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
|
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,
|