quilltap 4.5.0-dev → 4.6.0-dev
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 +178 -0
- package/bin/quilltap.js +226 -20
- package/lib/completion/bash.template +121 -0
- package/lib/completion/fish.template +93 -0
- package/lib/completion/zsh.template +209 -0
- package/lib/completion-commands.js +77 -0
- package/lib/db-commands.js +1142 -0
- package/lib/db-helpers.js +173 -4
- package/lib/docs-commands.js +2157 -172
- package/lib/graph-integrity.js +105 -0
- package/lib/instances-commands.js +342 -0
- package/lib/instances.js +335 -0
- package/lib/lock-helpers.js +117 -0
- package/lib/logs-commands.js +383 -0
- package/lib/memories-commands.js +1374 -0
- package/lib/memory-diff-command.js +19 -3
- package/lib/migrations-commands.js +324 -0
- package/lib/theme-commands.js +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
openMainDb,
|
|
7
|
+
openLlmLogsDb,
|
|
8
|
+
openMountIndexDb,
|
|
9
|
+
openEncryptedDb,
|
|
10
|
+
UUID_RE,
|
|
11
|
+
ambiguous,
|
|
12
|
+
resolveCharacter,
|
|
13
|
+
resolveChat,
|
|
14
|
+
resolveProject,
|
|
15
|
+
} = require('./db-helpers');
|
|
16
|
+
const { getLockStatus } = require('./lock-helpers');
|
|
17
|
+
|
|
18
|
+
// Tables grouped by domain for `db schema` (with-no-arg) overview, and for
|
|
19
|
+
// DB-routing when a verb names a specific table. Keep this list short — it's
|
|
20
|
+
// a cheat-sheet, not an exhaustive catalogue.
|
|
21
|
+
const DB_DOMAINS = {
|
|
22
|
+
main: {
|
|
23
|
+
'Characters & memory': ['characters', 'wardrobe_items', 'character_plugin_data', 'memories'],
|
|
24
|
+
'Chats & messages': ['chats', 'chat_messages', 'chat_settings', 'chat_documents', 'terminal_sessions'],
|
|
25
|
+
'Projects & files': ['projects', 'files', 'folders'],
|
|
26
|
+
'Connections & templates': ['connection_profiles', 'prompt_templates', 'roleplay_templates'],
|
|
27
|
+
'System': ['background_jobs', 'migrations_state', 'instance_settings', 'users'],
|
|
28
|
+
},
|
|
29
|
+
logs: {
|
|
30
|
+
'LLM logs': ['llm_logs'],
|
|
31
|
+
},
|
|
32
|
+
mount: {
|
|
33
|
+
'Document mount index': [
|
|
34
|
+
'doc_mount_points', 'doc_mount_folders', 'doc_mount_files',
|
|
35
|
+
'doc_mount_documents', 'doc_mount_chunks', 'doc_mount_blobs',
|
|
36
|
+
'doc_mount_file_links', 'project_doc_mount_links',
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Lookup: table name → which DB it lives in
|
|
42
|
+
const TABLE_DB = (() => {
|
|
43
|
+
const m = {};
|
|
44
|
+
for (const [db, groups] of Object.entries(DB_DOMAINS)) {
|
|
45
|
+
for (const tables of Object.values(groups)) {
|
|
46
|
+
for (const t of tables) m[t] = db;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return m;
|
|
50
|
+
})();
|
|
51
|
+
|
|
52
|
+
// GitHub heading anchors: lowercase, spaces → dashes, most punctuation dropped.
|
|
53
|
+
// Underscores are preserved, so `### chat_messages` → `#chat_messages`.
|
|
54
|
+
function ddlAnchor(tableName) {
|
|
55
|
+
return `docs/developer/DDL.md#${tableName.toLowerCase()}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------- arg parsing ----------
|
|
59
|
+
|
|
60
|
+
function parseSubArgs(args) {
|
|
61
|
+
const flags = {};
|
|
62
|
+
const positional = [];
|
|
63
|
+
let i = 0;
|
|
64
|
+
while (i < args.length) {
|
|
65
|
+
const a = args[i];
|
|
66
|
+
if (a.startsWith('--')) {
|
|
67
|
+
const eq = a.indexOf('=');
|
|
68
|
+
if (eq !== -1) {
|
|
69
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
70
|
+
} else {
|
|
71
|
+
const next = args[i + 1];
|
|
72
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
73
|
+
flags[a.slice(2)] = next;
|
|
74
|
+
i++;
|
|
75
|
+
} else {
|
|
76
|
+
flags[a.slice(2)] = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
positional.push(a);
|
|
81
|
+
}
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
return { flags, positional };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function asInt(v, dflt) {
|
|
88
|
+
if (v === undefined || v === true) return dflt;
|
|
89
|
+
const n = parseInt(v, 10);
|
|
90
|
+
return Number.isFinite(n) ? n : dflt;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function asBool(v) {
|
|
94
|
+
return v === true || v === 'true' || v === '1';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------- output ----------
|
|
98
|
+
|
|
99
|
+
function printTable(rows) {
|
|
100
|
+
if (!rows || rows.length === 0) {
|
|
101
|
+
console.log('(no results)');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
console.table(rows);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function printJson(data) {
|
|
108
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function truncate(s, n) {
|
|
112
|
+
if (s == null) return '';
|
|
113
|
+
const str = String(s);
|
|
114
|
+
if (str.length <= n) return str;
|
|
115
|
+
return str.slice(0, n) + `… (+${str.length - n} chars)`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function printRecord(label, row) {
|
|
119
|
+
console.log(`── ${label} ──`);
|
|
120
|
+
for (const [k, v] of Object.entries(row)) {
|
|
121
|
+
if (v == null) continue;
|
|
122
|
+
if (typeof v === 'string' && v.length > 80) {
|
|
123
|
+
console.log(` ${k}:`);
|
|
124
|
+
for (const line of v.split('\n')) console.log(` ${line}`);
|
|
125
|
+
} else {
|
|
126
|
+
console.log(` ${k}: ${v}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------- verb: schema ----------
|
|
132
|
+
|
|
133
|
+
function cmdSchema(args, ctx) {
|
|
134
|
+
const { flags, positional } = parseSubArgs(args);
|
|
135
|
+
const json = asBool(flags.json);
|
|
136
|
+
|
|
137
|
+
if (flags.grep) {
|
|
138
|
+
return schemaGrep(String(flags.grep), { json, ctx });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const table = positional[0];
|
|
142
|
+
if (!table) {
|
|
143
|
+
return schemaOverview({ json, ctx });
|
|
144
|
+
}
|
|
145
|
+
return schemaForTable(table, { json, ctx });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getAllTables(db) {
|
|
149
|
+
return db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map(r => r.name);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getTableInfo(db, table) {
|
|
153
|
+
return db.prepare(`PRAGMA table_info("${table.replace(/"/g, '""')}")`).all();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getForeignKeys(db, table) {
|
|
157
|
+
return db.prepare(`PRAGMA foreign_key_list("${table.replace(/"/g, '""')}")`).all();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getIndexes(db, table) {
|
|
161
|
+
return db.prepare(`PRAGMA index_list("${table.replace(/"/g, '""')}")`).all();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function openDbForTable(ctx, table) {
|
|
165
|
+
const which = TABLE_DB[table] || 'main';
|
|
166
|
+
if (which === 'logs') return { db: ctx.openLogs(), which };
|
|
167
|
+
if (which === 'mount') return { db: ctx.openMounts(), which };
|
|
168
|
+
return { db: ctx.openMain(), which };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function schemaForTable(table, { json, ctx }) {
|
|
172
|
+
// Try main first, fall back to other DBs if not found
|
|
173
|
+
const order = (TABLE_DB[table] === 'logs') ? ['logs', 'main', 'mount']
|
|
174
|
+
: (TABLE_DB[table] === 'mount') ? ['mount', 'main', 'logs']
|
|
175
|
+
: ['main', 'logs', 'mount'];
|
|
176
|
+
|
|
177
|
+
for (const which of order) {
|
|
178
|
+
const opener = which === 'logs' ? ctx.openLogs : which === 'mount' ? ctx.openMounts : ctx.openMain;
|
|
179
|
+
let db;
|
|
180
|
+
try { db = opener(); } catch { continue; }
|
|
181
|
+
try {
|
|
182
|
+
const tables = new Set(getAllTables(db));
|
|
183
|
+
if (!tables.has(table)) continue;
|
|
184
|
+
const cols = getTableInfo(db, table);
|
|
185
|
+
const fks = getForeignKeys(db, table);
|
|
186
|
+
const idxs = getIndexes(db, table);
|
|
187
|
+
|
|
188
|
+
if (json) {
|
|
189
|
+
printJson({ database: which, table, columns: cols, foreignKeys: fks, indexes: idxs });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log(`Table: ${table} (database: ${which})`);
|
|
194
|
+
console.log('');
|
|
195
|
+
console.log('Columns:');
|
|
196
|
+
const rows = cols.map(c => ({
|
|
197
|
+
name: c.name,
|
|
198
|
+
type: c.type,
|
|
199
|
+
notNull: c.notnull ? 'NOT NULL' : '',
|
|
200
|
+
default: c.dflt_value === null ? '' : c.dflt_value,
|
|
201
|
+
pk: c.pk ? `pk${c.pk > 1 ? '(' + c.pk + ')' : ''}` : '',
|
|
202
|
+
}));
|
|
203
|
+
console.table(rows);
|
|
204
|
+
if (fks.length) {
|
|
205
|
+
console.log('Foreign keys:');
|
|
206
|
+
console.table(fks.map(f => ({ from: f.from, to: `${f.table}.${f.to}`, onDelete: f.on_delete, onUpdate: f.on_update })));
|
|
207
|
+
}
|
|
208
|
+
if (idxs.length) {
|
|
209
|
+
console.log('Indexes:');
|
|
210
|
+
for (const idx of idxs) {
|
|
211
|
+
const cols = db.prepare(`PRAGMA index_info("${idx.name.replace(/"/g, '""')}")`).all().map(c => c.name).join(', ');
|
|
212
|
+
console.log(` ${idx.unique ? 'UNIQUE ' : ''}${idx.name} (${cols})`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
console.log('');
|
|
216
|
+
console.log(`→ ${ddlAnchor(table)}`);
|
|
217
|
+
return;
|
|
218
|
+
} finally {
|
|
219
|
+
try { db.close(); } catch {}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
throw new Error(`Table '${table}' not found in any database.`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function schemaOverview({ json, ctx }) {
|
|
226
|
+
const out = {};
|
|
227
|
+
for (const [which, opener] of [['main', ctx.openMain], ['logs', ctx.openLogs], ['mount', ctx.openMounts]]) {
|
|
228
|
+
let db;
|
|
229
|
+
try { db = opener(); } catch { continue; }
|
|
230
|
+
try {
|
|
231
|
+
out[which] = getAllTables(db);
|
|
232
|
+
} finally {
|
|
233
|
+
try { db.close(); } catch {}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (json) {
|
|
238
|
+
printJson({ databases: out, domains: DB_DOMAINS });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const [whichDb, groups] of Object.entries(DB_DOMAINS)) {
|
|
243
|
+
if (!out[whichDb]) continue;
|
|
244
|
+
const present = new Set(out[whichDb]);
|
|
245
|
+
console.log(`── ${whichDb} database (${out[whichDb].length} tables) ──`);
|
|
246
|
+
for (const [groupName, tables] of Object.entries(groups)) {
|
|
247
|
+
const hits = tables.filter(t => present.has(t));
|
|
248
|
+
if (hits.length === 0) continue;
|
|
249
|
+
console.log(` ${groupName}:`);
|
|
250
|
+
for (const t of hits) console.log(` ${t}`);
|
|
251
|
+
}
|
|
252
|
+
const grouped = new Set(Object.values(groups).flat());
|
|
253
|
+
const ungrouped = out[whichDb].filter(t => !grouped.has(t) && !t.startsWith('sqlite_'));
|
|
254
|
+
if (ungrouped.length) {
|
|
255
|
+
console.log(' Other:');
|
|
256
|
+
for (const t of ungrouped) console.log(` ${t}`);
|
|
257
|
+
}
|
|
258
|
+
console.log('');
|
|
259
|
+
}
|
|
260
|
+
console.log("Use `quilltap db schema <table>` for column details, or `quilltap db schema --grep <text>` to search.");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function schemaGrep(needle, { json, ctx }) {
|
|
264
|
+
const lc = needle.toLowerCase();
|
|
265
|
+
const matches = [];
|
|
266
|
+
for (const [which, opener] of [['main', ctx.openMain], ['logs', ctx.openLogs], ['mount', ctx.openMounts]]) {
|
|
267
|
+
let db;
|
|
268
|
+
try { db = opener(); } catch { continue; }
|
|
269
|
+
try {
|
|
270
|
+
const tables = getAllTables(db);
|
|
271
|
+
for (const t of tables) {
|
|
272
|
+
const tableHit = t.toLowerCase().includes(lc);
|
|
273
|
+
const cols = getTableInfo(db, t);
|
|
274
|
+
const colHits = cols.filter(c => c.name.toLowerCase().includes(lc));
|
|
275
|
+
if (tableHit || colHits.length) {
|
|
276
|
+
matches.push({
|
|
277
|
+
database: which,
|
|
278
|
+
table: t,
|
|
279
|
+
tableMatch: tableHit,
|
|
280
|
+
columns: tableHit && colHits.length === 0 ? cols.map(c => c.name) : colHits.map(c => c.name),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
try { db.close(); } catch {}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (json) { printJson({ query: needle, matches }); return; }
|
|
290
|
+
|
|
291
|
+
if (matches.length === 0) {
|
|
292
|
+
console.log(`No tables or columns matching '${needle}'.`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
for (const m of matches) {
|
|
296
|
+
const flag = m.tableMatch ? '*' : ' ';
|
|
297
|
+
console.log(`${flag} ${m.database}: ${m.table}`);
|
|
298
|
+
for (const c of m.columns) console.log(` .${c}`);
|
|
299
|
+
}
|
|
300
|
+
console.log('');
|
|
301
|
+
console.log('(* = table name matched; columns shown are matched columns, or all columns if the whole table matched)');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------- verb: find ----------
|
|
305
|
+
|
|
306
|
+
function cmdFind(args, ctx) {
|
|
307
|
+
const [kind, ...rest] = args;
|
|
308
|
+
if (!kind) throw new Error('Usage: quilltap db find <character|chat|project> [query]');
|
|
309
|
+
const { flags, positional } = parseSubArgs(rest);
|
|
310
|
+
const json = asBool(flags.json);
|
|
311
|
+
const limit = asInt(flags.limit, 50);
|
|
312
|
+
const query = positional.join(' ');
|
|
313
|
+
|
|
314
|
+
if (kind === 'character') return findCharacters(query, { json, limit, ctx });
|
|
315
|
+
if (kind === 'chat') return findChats(query, { json, limit, ctx });
|
|
316
|
+
if (kind === 'project') return findProjects(query, { json, limit, ctx });
|
|
317
|
+
throw new Error(`Unknown find kind: ${kind}. Try character|chat|project.`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function findCharacters(query, { json, limit, ctx }) {
|
|
321
|
+
const db = ctx.openMain();
|
|
322
|
+
try {
|
|
323
|
+
let rows;
|
|
324
|
+
if (!query) {
|
|
325
|
+
rows = db.prepare('SELECT id, name, npc, isFavorite, controlledBy FROM characters ORDER BY name LIMIT ?').all(limit);
|
|
326
|
+
} else if (UUID_RE.test(query)) {
|
|
327
|
+
rows = db.prepare('SELECT id, name, npc, isFavorite, controlledBy, aliases FROM characters WHERE id = ?').all(query);
|
|
328
|
+
} else {
|
|
329
|
+
rows = db.prepare(
|
|
330
|
+
'SELECT id, name, npc, isFavorite, controlledBy, aliases FROM characters WHERE LOWER(name) LIKE LOWER(?) OR LOWER(aliases) LIKE LOWER(?) ORDER BY name LIMIT ?'
|
|
331
|
+
).all(`%${query}%`, `%${query}%`, limit);
|
|
332
|
+
}
|
|
333
|
+
if (json) return printJson(rows);
|
|
334
|
+
printTable(rows);
|
|
335
|
+
} finally {
|
|
336
|
+
db.close();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function findChats(query, { json, limit, ctx }) {
|
|
341
|
+
const db = ctx.openMain();
|
|
342
|
+
try {
|
|
343
|
+
let rows;
|
|
344
|
+
if (!query) {
|
|
345
|
+
rows = db.prepare('SELECT id, title, chatType, messageCount, lastMessageAt FROM chats ORDER BY lastMessageAt DESC LIMIT ?').all(limit);
|
|
346
|
+
} else if (UUID_RE.test(query)) {
|
|
347
|
+
rows = db.prepare('SELECT id, title, chatType, projectId, messageCount, lastMessageAt FROM chats WHERE id = ?').all(query);
|
|
348
|
+
} else {
|
|
349
|
+
rows = db.prepare(
|
|
350
|
+
'SELECT id, title, chatType, projectId, messageCount, lastMessageAt FROM chats WHERE LOWER(title) LIKE LOWER(?) ORDER BY lastMessageAt DESC LIMIT ?'
|
|
351
|
+
).all(`%${query}%`, limit);
|
|
352
|
+
}
|
|
353
|
+
if (json) return printJson(rows);
|
|
354
|
+
printTable(rows);
|
|
355
|
+
} finally {
|
|
356
|
+
db.close();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function findProjects(query, { json, limit, ctx }) {
|
|
361
|
+
const db = ctx.openMain();
|
|
362
|
+
try {
|
|
363
|
+
let rows;
|
|
364
|
+
if (!query) {
|
|
365
|
+
rows = db.prepare('SELECT id, name, createdAt FROM projects ORDER BY name LIMIT ?').all(limit);
|
|
366
|
+
} else if (UUID_RE.test(query)) {
|
|
367
|
+
rows = db.prepare('SELECT id, name, description, createdAt FROM projects WHERE id = ?').all(query);
|
|
368
|
+
} else {
|
|
369
|
+
rows = db.prepare(
|
|
370
|
+
'SELECT id, name, createdAt FROM projects WHERE LOWER(name) LIKE LOWER(?) ORDER BY name LIMIT ?'
|
|
371
|
+
).all(`%${query}%`, limit);
|
|
372
|
+
}
|
|
373
|
+
if (json) return printJson(rows);
|
|
374
|
+
printTable(rows);
|
|
375
|
+
} finally {
|
|
376
|
+
db.close();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------- verb: chats ----------
|
|
381
|
+
|
|
382
|
+
function cmdChats(args, ctx) {
|
|
383
|
+
const { flags } = parseSubArgs(args);
|
|
384
|
+
const json = asBool(flags.json);
|
|
385
|
+
const limit = asInt(flags.limit, 50);
|
|
386
|
+
|
|
387
|
+
const db = ctx.openMain();
|
|
388
|
+
try {
|
|
389
|
+
let rows;
|
|
390
|
+
if (flags.character) {
|
|
391
|
+
const c = resolveCharacter(db, String(flags.character));
|
|
392
|
+
rows = db.prepare(
|
|
393
|
+
"SELECT id, title, chatType, messageCount, lastMessageAt, projectId " +
|
|
394
|
+
"FROM chats WHERE participants LIKE ? ORDER BY lastMessageAt DESC LIMIT ?"
|
|
395
|
+
).all(`%${c.id}%`, limit);
|
|
396
|
+
if (!json) console.log(`Chats for character: ${c.name} (${c.id})`);
|
|
397
|
+
} else if (flags.project) {
|
|
398
|
+
const p = resolveProject(db, String(flags.project));
|
|
399
|
+
rows = db.prepare(
|
|
400
|
+
"SELECT id, title, chatType, messageCount, lastMessageAt " +
|
|
401
|
+
"FROM chats WHERE projectId = ? ORDER BY lastMessageAt DESC LIMIT ?"
|
|
402
|
+
).all(p.id, limit);
|
|
403
|
+
if (!json) console.log(`Chats for project: ${p.name} (${p.id})`);
|
|
404
|
+
} else {
|
|
405
|
+
rows = db.prepare(
|
|
406
|
+
"SELECT id, title, chatType, messageCount, lastMessageAt, projectId " +
|
|
407
|
+
"FROM chats ORDER BY lastMessageAt DESC LIMIT ?"
|
|
408
|
+
).all(limit);
|
|
409
|
+
}
|
|
410
|
+
if (json) return printJson(rows);
|
|
411
|
+
printTable(rows);
|
|
412
|
+
} finally {
|
|
413
|
+
db.close();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------- verb: messages ----------
|
|
418
|
+
|
|
419
|
+
function cmdMessages(args, ctx) {
|
|
420
|
+
const { flags } = parseSubArgs(args);
|
|
421
|
+
const json = asBool(flags.json);
|
|
422
|
+
const last = asInt(flags.last, 20);
|
|
423
|
+
const full = asBool(flags.full);
|
|
424
|
+
if (!flags.chat) throw new Error('Usage: quilltap db messages --chat <id|title> [--last N] [--full] [--from <participant>] [--type <type>]');
|
|
425
|
+
|
|
426
|
+
const db = ctx.openMain();
|
|
427
|
+
try {
|
|
428
|
+
const chat = resolveChat(db, String(flags.chat));
|
|
429
|
+
const conditions = ['chatId = ?'];
|
|
430
|
+
const params = [chat.id];
|
|
431
|
+
if (flags.from) { conditions.push('participantId = ?'); params.push(String(flags.from)); }
|
|
432
|
+
if (flags.type) { conditions.push('type = ?'); params.push(String(flags.type)); }
|
|
433
|
+
|
|
434
|
+
const totalRow = db.prepare(`SELECT count(*) AS n FROM chat_messages WHERE ${conditions.join(' AND ')}`).get(...params);
|
|
435
|
+
const sql = `
|
|
436
|
+
SELECT id, createdAt, role, type, systemSender, participantId, content
|
|
437
|
+
FROM chat_messages WHERE ${conditions.join(' AND ')}
|
|
438
|
+
ORDER BY createdAt DESC LIMIT ?
|
|
439
|
+
`;
|
|
440
|
+
params.push(last);
|
|
441
|
+
const rows = db.prepare(sql).all(...params).reverse(); // oldest first
|
|
442
|
+
|
|
443
|
+
if (json) return printJson({ chat, total: totalRow.n, returned: rows.length, messages: rows });
|
|
444
|
+
|
|
445
|
+
console.log(`Chat: ${chat.title} (${chat.id}) — showing ${rows.length} of ${totalRow.n} matching messages`);
|
|
446
|
+
const summary = rows.map(r => ({
|
|
447
|
+
createdAt: r.createdAt,
|
|
448
|
+
id: r.id,
|
|
449
|
+
role: r.role,
|
|
450
|
+
type: r.type,
|
|
451
|
+
from: r.systemSender || r.participantId || '',
|
|
452
|
+
content: full ? r.content : truncate(r.content, 120),
|
|
453
|
+
}));
|
|
454
|
+
printTable(summary);
|
|
455
|
+
} finally {
|
|
456
|
+
db.close();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------- verb: logs ----------
|
|
461
|
+
|
|
462
|
+
function cmdLogs(args, ctx) {
|
|
463
|
+
const { flags } = parseSubArgs(args);
|
|
464
|
+
const json = asBool(flags.json);
|
|
465
|
+
const limit = asInt(flags.limit, 50);
|
|
466
|
+
|
|
467
|
+
const logsDb = ctx.openLogs();
|
|
468
|
+
try {
|
|
469
|
+
let rows;
|
|
470
|
+
if (flags.message) {
|
|
471
|
+
rows = logsDb.prepare(
|
|
472
|
+
'SELECT id, createdAt, type, provider, modelName, chatId, characterId, durationMs FROM llm_logs WHERE messageId = ? ORDER BY createdAt DESC LIMIT ?'
|
|
473
|
+
).all(String(flags.message), limit);
|
|
474
|
+
} else if (flags.chat) {
|
|
475
|
+
// need to resolve chat from main DB if a name was given
|
|
476
|
+
let chatId = String(flags.chat);
|
|
477
|
+
if (!UUID_RE.test(chatId)) {
|
|
478
|
+
const main = ctx.openMain();
|
|
479
|
+
try {
|
|
480
|
+
chatId = resolveChat(main, chatId).id;
|
|
481
|
+
} finally {
|
|
482
|
+
main.close();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
rows = logsDb.prepare(
|
|
486
|
+
'SELECT id, createdAt, type, provider, modelName, messageId, characterId, durationMs FROM llm_logs WHERE chatId = ? ORDER BY createdAt DESC LIMIT ?'
|
|
487
|
+
).all(chatId, limit);
|
|
488
|
+
} else if (flags.character) {
|
|
489
|
+
const main = ctx.openMain();
|
|
490
|
+
let c;
|
|
491
|
+
try { c = resolveCharacter(main, String(flags.character)); } finally { main.close(); }
|
|
492
|
+
rows = logsDb.prepare(
|
|
493
|
+
'SELECT id, createdAt, type, provider, modelName, chatId, messageId, durationMs FROM llm_logs WHERE characterId = ? ORDER BY createdAt DESC LIMIT ?'
|
|
494
|
+
).all(c.id, limit);
|
|
495
|
+
} else if (flags.tail) {
|
|
496
|
+
const n = asInt(flags.tail, 20);
|
|
497
|
+
rows = logsDb.prepare(
|
|
498
|
+
'SELECT id, createdAt, type, provider, modelName, chatId, messageId, characterId, durationMs FROM llm_logs ORDER BY createdAt DESC LIMIT ?'
|
|
499
|
+
).all(n);
|
|
500
|
+
} else {
|
|
501
|
+
throw new Error('Usage: quilltap db logs (--chat <id|title> | --message <id> | --character <id|name> | --tail N) [--limit N]');
|
|
502
|
+
}
|
|
503
|
+
if (json) return printJson(rows);
|
|
504
|
+
printTable(rows);
|
|
505
|
+
} finally {
|
|
506
|
+
logsDb.close();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------- verb: message (single record) ----------
|
|
511
|
+
|
|
512
|
+
function cmdMessage(args, ctx) {
|
|
513
|
+
const { flags, positional } = parseSubArgs(args);
|
|
514
|
+
const id = positional[0];
|
|
515
|
+
if (!id) throw new Error('Usage: quilltap db message <id> [--json] [--rendered]');
|
|
516
|
+
const json = asBool(flags.json);
|
|
517
|
+
const rendered = asBool(flags.rendered);
|
|
518
|
+
|
|
519
|
+
const db = ctx.openMain();
|
|
520
|
+
try {
|
|
521
|
+
const row = db.prepare('SELECT * FROM chat_messages WHERE id = ?').get(id);
|
|
522
|
+
if (!row) throw new Error(`No chat_message with id ${id}`);
|
|
523
|
+
if (json) return printJson(row);
|
|
524
|
+
|
|
525
|
+
printRecord(`Message ${row.id}`, {
|
|
526
|
+
chatId: row.chatId,
|
|
527
|
+
createdAt: row.createdAt,
|
|
528
|
+
role: row.role,
|
|
529
|
+
type: row.type,
|
|
530
|
+
systemSender: row.systemSender,
|
|
531
|
+
participantId: row.participantId,
|
|
532
|
+
provider: row.provider,
|
|
533
|
+
modelName: row.modelName,
|
|
534
|
+
tokenCount: row.tokenCount,
|
|
535
|
+
});
|
|
536
|
+
console.log('');
|
|
537
|
+
console.log('── Content ──');
|
|
538
|
+
console.log(row.content || '(empty)');
|
|
539
|
+
if (rendered && row.renderedHtml) {
|
|
540
|
+
console.log('');
|
|
541
|
+
console.log('── Rendered HTML ──');
|
|
542
|
+
console.log(row.renderedHtml);
|
|
543
|
+
}
|
|
544
|
+
} finally {
|
|
545
|
+
db.close();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---------- verb: log (single record) ----------
|
|
550
|
+
|
|
551
|
+
function cmdLog(args, ctx) {
|
|
552
|
+
const { flags, positional } = parseSubArgs(args);
|
|
553
|
+
const id = positional[0];
|
|
554
|
+
if (!id) throw new Error('Usage: quilltap db log <id> [--json] [--field request|response|both]');
|
|
555
|
+
const json = asBool(flags.json);
|
|
556
|
+
const field = flags.field || 'both';
|
|
557
|
+
|
|
558
|
+
const db = ctx.openLogs();
|
|
559
|
+
try {
|
|
560
|
+
const row = db.prepare('SELECT * FROM llm_logs WHERE id = ?').get(id);
|
|
561
|
+
if (!row) throw new Error(`No llm_log with id ${id}`);
|
|
562
|
+
if (json) return printJson(row);
|
|
563
|
+
|
|
564
|
+
printRecord(`LLM log ${row.id}`, {
|
|
565
|
+
createdAt: row.createdAt,
|
|
566
|
+
type: row.type,
|
|
567
|
+
provider: row.provider,
|
|
568
|
+
modelName: row.modelName,
|
|
569
|
+
chatId: row.chatId,
|
|
570
|
+
messageId: row.messageId,
|
|
571
|
+
characterId: row.characterId,
|
|
572
|
+
durationMs: row.durationMs,
|
|
573
|
+
usage: row.usage,
|
|
574
|
+
cacheUsage: row.cacheUsage,
|
|
575
|
+
});
|
|
576
|
+
if (field === 'request' || field === 'both') {
|
|
577
|
+
console.log('');
|
|
578
|
+
console.log('── Request ──');
|
|
579
|
+
console.log(row.request);
|
|
580
|
+
}
|
|
581
|
+
if (field === 'response' || field === 'both') {
|
|
582
|
+
console.log('');
|
|
583
|
+
console.log('── Response ──');
|
|
584
|
+
console.log(row.response);
|
|
585
|
+
}
|
|
586
|
+
} finally {
|
|
587
|
+
db.close();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---------- verb: memories ----------
|
|
592
|
+
|
|
593
|
+
function cmdMemories(args, ctx) {
|
|
594
|
+
const { flags } = parseSubArgs(args);
|
|
595
|
+
const json = asBool(flags.json);
|
|
596
|
+
const limit = asInt(flags.limit, 50);
|
|
597
|
+
if (!flags.character) throw new Error('Usage: quilltap db memories --character <id|name> [--about <id|name>] [--source AUTO|MANUAL] [--limit N]');
|
|
598
|
+
|
|
599
|
+
const db = ctx.openMain();
|
|
600
|
+
try {
|
|
601
|
+
const holder = resolveCharacter(db, String(flags.character));
|
|
602
|
+
const conditions = ['characterId = ?'];
|
|
603
|
+
const params = [holder.id];
|
|
604
|
+
if (flags.about) {
|
|
605
|
+
const a = resolveCharacter(db, String(flags.about));
|
|
606
|
+
conditions.push('aboutCharacterId = ?');
|
|
607
|
+
params.push(a.id);
|
|
608
|
+
}
|
|
609
|
+
if (flags.source) {
|
|
610
|
+
conditions.push('source = ?');
|
|
611
|
+
params.push(String(flags.source).toUpperCase());
|
|
612
|
+
}
|
|
613
|
+
const rows = db.prepare(`
|
|
614
|
+
SELECT id, createdAt, source, importance, reinforcementCount,
|
|
615
|
+
aboutCharacterId, chatId, summary
|
|
616
|
+
FROM memories WHERE ${conditions.join(' AND ')}
|
|
617
|
+
ORDER BY createdAt DESC LIMIT ?
|
|
618
|
+
`).all(...params, limit);
|
|
619
|
+
|
|
620
|
+
const totalRow = db.prepare(`SELECT count(*) AS n FROM memories WHERE ${conditions.join(' AND ')}`).get(...params);
|
|
621
|
+
|
|
622
|
+
if (json) return printJson({ holder, total: totalRow.n, returned: rows.length, memories: rows });
|
|
623
|
+
|
|
624
|
+
console.log(`Memories held by ${holder.name} (${holder.id}) — ${rows.length} of ${totalRow.n}`);
|
|
625
|
+
printTable(rows.map(r => ({
|
|
626
|
+
createdAt: r.createdAt,
|
|
627
|
+
id: r.id,
|
|
628
|
+
src: r.source,
|
|
629
|
+
imp: r.importance,
|
|
630
|
+
rein: r.reinforcementCount,
|
|
631
|
+
about: r.aboutCharacterId === holder.id ? 'self' : (r.aboutCharacterId || ''),
|
|
632
|
+
summary: truncate(r.summary, 100),
|
|
633
|
+
})));
|
|
634
|
+
} finally {
|
|
635
|
+
db.close();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ---------- verb: optimize ----------
|
|
640
|
+
|
|
641
|
+
const OPTIMIZE_TARGETS = {
|
|
642
|
+
'main': { filename: 'quilltap.db', label: 'main' },
|
|
643
|
+
'llm-logs': { filename: 'quilltap-llm-logs.db', label: 'llm-logs' },
|
|
644
|
+
'mount-points': { filename: 'quilltap-mount-index.db', label: 'mount-points' },
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
function formatBytes(n) {
|
|
648
|
+
if (n < 1024) return `${n} B`;
|
|
649
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
650
|
+
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
651
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function formatDuration(ms) {
|
|
655
|
+
if (ms < 1000) return `${ms} ms`;
|
|
656
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(2)} s`;
|
|
657
|
+
return `${(ms / 60_000).toFixed(2)} min`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function fileSize(filePath) {
|
|
661
|
+
try {
|
|
662
|
+
return fs.statSync(filePath).size;
|
|
663
|
+
} catch {
|
|
664
|
+
return 0;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function cmdOptimize(args, ctx) {
|
|
669
|
+
const { flags, positional } = parseSubArgs(args);
|
|
670
|
+
const json = asBool(flags.json);
|
|
671
|
+
|
|
672
|
+
// Resolve target list
|
|
673
|
+
let targets;
|
|
674
|
+
if (positional.length === 0 || (positional.length === 1 && positional[0] === 'all')) {
|
|
675
|
+
targets = Object.keys(OPTIMIZE_TARGETS);
|
|
676
|
+
} else {
|
|
677
|
+
targets = positional.map(p => {
|
|
678
|
+
if (!OPTIMIZE_TARGETS[p]) {
|
|
679
|
+
const allowed = Object.keys(OPTIMIZE_TARGETS).join(' | ');
|
|
680
|
+
throw new Error(`Unknown optimize target '${p}'. Allowed: ${allowed} | all`);
|
|
681
|
+
}
|
|
682
|
+
return p;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Refuse to proceed if any instance is actively holding the lock.
|
|
687
|
+
const lockStatus = getLockStatus(ctx.dataDir);
|
|
688
|
+
if (lockStatus.state === 'active' || lockStatus.state === 'suspect') {
|
|
689
|
+
const msg = lockStatus.state === 'active'
|
|
690
|
+
? `Database is currently in use — ${lockStatus.reason}.\n` +
|
|
691
|
+
`Stop the running Quilltap instance before optimizing, then try again.\n` +
|
|
692
|
+
`(See \`quilltap db --lock-status\` for details.)`
|
|
693
|
+
: `Lock file ${lockStatus.reason}.\n` +
|
|
694
|
+
`This may be a stale lock from a reused PID. Inspect it with\n` +
|
|
695
|
+
`\`quilltap db --lock-status\` and clean it up with \`quilltap db --lock-clean\` if safe.`;
|
|
696
|
+
const err = new Error(msg);
|
|
697
|
+
err.locked = true;
|
|
698
|
+
throw err;
|
|
699
|
+
}
|
|
700
|
+
if (lockStatus.state === 'corrupt') {
|
|
701
|
+
throw new Error(
|
|
702
|
+
`Lock file at ${lockStatus.lockPath} is corrupt. Inspect it manually or clean with ` +
|
|
703
|
+
`\`quilltap db --lock-clean\`, then retry.`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const results = [];
|
|
708
|
+
for (const key of targets) {
|
|
709
|
+
const target = OPTIMIZE_TARGETS[key];
|
|
710
|
+
const dbPath = path.join(ctx.dataDir, target.filename);
|
|
711
|
+
if (!fs.existsSync(dbPath)) {
|
|
712
|
+
if (!json) console.log(`Skipping ${target.label}: ${dbPath} not found.`);
|
|
713
|
+
results.push({ target: target.label, skipped: true, reason: 'not found' });
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
const result = optimizeOneDb(key, dbPath, ctx);
|
|
717
|
+
results.push(result);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (json) {
|
|
721
|
+
printJson({ results });
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Final summary
|
|
726
|
+
const totalSaved = results
|
|
727
|
+
.filter(r => !r.skipped && r.sizeBefore != null && r.sizeAfter != null)
|
|
728
|
+
.reduce((acc, r) => acc + (r.sizeBefore - r.sizeAfter), 0);
|
|
729
|
+
if (totalSaved > 0) {
|
|
730
|
+
console.log('');
|
|
731
|
+
console.log(`Total reclaimed: ${formatBytes(totalSaved)}`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function optimizeOneDb(key, dbPath, ctx) {
|
|
736
|
+
const target = OPTIMIZE_TARGETS[key];
|
|
737
|
+
const opener = key === 'main' ? openMainDb : key === 'llm-logs' ? openLlmLogsDb : openMountIndexDb;
|
|
738
|
+
|
|
739
|
+
console.log('');
|
|
740
|
+
console.log(`── ${target.label} (${dbPath}) ──`);
|
|
741
|
+
const sizeBefore = fileSize(dbPath);
|
|
742
|
+
console.log(` size before: ${formatBytes(sizeBefore)}`);
|
|
743
|
+
|
|
744
|
+
let db;
|
|
745
|
+
try {
|
|
746
|
+
db = opener(ctx.dataDir, ctx.pepper, { readonly: false });
|
|
747
|
+
} catch (err) {
|
|
748
|
+
console.log(` open failed: ${err.message}`);
|
|
749
|
+
return { target: target.label, skipped: true, reason: err.message };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const steps = [];
|
|
753
|
+
function runStep(name, fn) {
|
|
754
|
+
const t0 = Date.now();
|
|
755
|
+
let info = '';
|
|
756
|
+
try {
|
|
757
|
+
info = fn() || '';
|
|
758
|
+
} catch (err) {
|
|
759
|
+
const elapsed = Date.now() - t0;
|
|
760
|
+
console.log(` ${name}: FAILED after ${formatDuration(elapsed)} — ${err.message}`);
|
|
761
|
+
steps.push({ name, ok: false, ms: elapsed, error: err.message });
|
|
762
|
+
throw err;
|
|
763
|
+
}
|
|
764
|
+
const elapsed = Date.now() - t0;
|
|
765
|
+
console.log(` ${name}: ${formatDuration(elapsed)}${info ? ` (${info})` : ''}`);
|
|
766
|
+
steps.push({ name, ok: true, ms: elapsed });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
try {
|
|
770
|
+
runStep('VACUUM', () => { db.exec('VACUUM'); });
|
|
771
|
+
runStep('ANALYZE', () => { db.exec('ANALYZE'); });
|
|
772
|
+
runStep('PRAGMA optimize', () => { db.pragma('optimize'); });
|
|
773
|
+
} catch {
|
|
774
|
+
try { db.close(); } catch {}
|
|
775
|
+
return { target: target.label, skipped: false, sizeBefore, sizeAfter: fileSize(dbPath), steps };
|
|
776
|
+
} finally {
|
|
777
|
+
try { db.close(); } catch {}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const sizeAfter = fileSize(dbPath);
|
|
781
|
+
const delta = sizeBefore - sizeAfter;
|
|
782
|
+
const deltaStr = delta === 0
|
|
783
|
+
? 'no change'
|
|
784
|
+
: delta > 0
|
|
785
|
+
? `reclaimed ${formatBytes(delta)}`
|
|
786
|
+
: `grew by ${formatBytes(-delta)}`;
|
|
787
|
+
console.log(` size after: ${formatBytes(sizeAfter)} (${deltaStr})`);
|
|
788
|
+
return { target: target.label, skipped: false, sizeBefore, sizeAfter, steps };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ---------- verb: backup ----------
|
|
792
|
+
|
|
793
|
+
// Targets mirror OPTIMIZE_TARGETS but live separately so the verb can grow
|
|
794
|
+
// its own per-target metadata (e.g. exclude logs) without disturbing optimize.
|
|
795
|
+
const BACKUP_TARGETS = OPTIMIZE_TARGETS;
|
|
796
|
+
const INTEGRITY_TARGETS = OPTIMIZE_TARGETS;
|
|
797
|
+
|
|
798
|
+
function isoTimestampForDir() {
|
|
799
|
+
// 2026-05-20T14-32-07
|
|
800
|
+
return new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, '').slice(0, 19);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function resolveBackupTargets(positional) {
|
|
804
|
+
if (positional.length === 0 || (positional.length === 1 && positional[0] === 'all')) {
|
|
805
|
+
return Object.keys(BACKUP_TARGETS);
|
|
806
|
+
}
|
|
807
|
+
return positional.map(p => {
|
|
808
|
+
if (!BACKUP_TARGETS[p]) {
|
|
809
|
+
const allowed = Object.keys(BACKUP_TARGETS).join(' | ');
|
|
810
|
+
throw new Error(`Unknown backup target '${p}'. Allowed: ${allowed} | all`);
|
|
811
|
+
}
|
|
812
|
+
return p;
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async function cmdBackup(args, ctx) {
|
|
817
|
+
const { flags, positional } = parseSubArgs(args);
|
|
818
|
+
const json = asBool(flags.json);
|
|
819
|
+
const out = typeof flags.out === 'string' ? flags.out : '';
|
|
820
|
+
|
|
821
|
+
const targets = resolveBackupTargets(positional);
|
|
822
|
+
|
|
823
|
+
// Backups are safe while the server is running — page-level online copy.
|
|
824
|
+
// Log the situation and proceed; do not refuse.
|
|
825
|
+
const lockStatus = getLockStatus(ctx.dataDir);
|
|
826
|
+
if (!json && (lockStatus.state === 'active' || lockStatus.state === 'suspect')) {
|
|
827
|
+
const pidPart = lockStatus.lock && lockStatus.lock.pid ? ` (PID ${lockStatus.lock.pid})` : '';
|
|
828
|
+
console.log(`Live instance detected${pidPart} — taking online snapshot.`);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Resolve destination directory. Default: <dataDir>/backups/<ISO-timestamp>/
|
|
832
|
+
const destDir = out
|
|
833
|
+
? (out.startsWith('~') ? path.join(require('os').homedir(), out.slice(1)) : out)
|
|
834
|
+
: path.join(ctx.dataDir, 'backups', isoTimestampForDir());
|
|
835
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
836
|
+
|
|
837
|
+
if (!json) {
|
|
838
|
+
console.log(`Destination: ${destDir}`);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const results = [];
|
|
842
|
+
for (const key of targets) {
|
|
843
|
+
const target = BACKUP_TARGETS[key];
|
|
844
|
+
const sourcePath = path.join(ctx.dataDir, target.filename);
|
|
845
|
+
if (!fs.existsSync(sourcePath)) {
|
|
846
|
+
if (!json) console.log(`Skipping ${target.label}: ${sourcePath} not found.`);
|
|
847
|
+
results.push({ target: target.label, source: sourcePath, skipped: true, ok: false, reason: 'not found' });
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
const result = await backupOneDb(key, sourcePath, destDir, ctx);
|
|
851
|
+
results.push(result);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const okCount = results.filter(r => r.ok).length;
|
|
855
|
+
const totalBytes = results.filter(r => r.ok && r.destSize != null).reduce((acc, r) => acc + r.destSize, 0);
|
|
856
|
+
|
|
857
|
+
if (json) {
|
|
858
|
+
printJson({ destDir, results, summary: { ok: okCount, total: results.length, totalBytes } });
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
console.log('');
|
|
863
|
+
console.log(`Snapshot complete: ${okCount}/${results.length} target${results.length === 1 ? '' : 's'} (${formatBytes(totalBytes)} written).`);
|
|
864
|
+
|
|
865
|
+
if (okCount !== results.length) {
|
|
866
|
+
// Surface failure via non-zero exit so scripts can pick it up.
|
|
867
|
+
const err = new Error('one or more snapshots failed');
|
|
868
|
+
err.silent = true;
|
|
869
|
+
throw err;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function backupOneDb(key, sourcePath, destDir, ctx) {
|
|
874
|
+
const target = BACKUP_TARGETS[key];
|
|
875
|
+
const opener = key === 'main' ? openMainDb : key === 'llm-logs' ? openLlmLogsDb : openMountIndexDb;
|
|
876
|
+
const destPath = path.join(destDir, target.filename);
|
|
877
|
+
|
|
878
|
+
console.log('');
|
|
879
|
+
console.log(`── ${target.label} (${sourcePath}) ──`);
|
|
880
|
+
const sourceSize = fileSize(sourcePath);
|
|
881
|
+
console.log(` source size: ${formatBytes(sourceSize)}`);
|
|
882
|
+
|
|
883
|
+
if (fs.existsSync(destPath)) {
|
|
884
|
+
console.log(` refusing: destination ${destPath} already exists`);
|
|
885
|
+
return { target: target.label, source: sourcePath, sourceSize, dest: destPath, ok: false, error: 'destination exists' };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Open source RW. We need write capability for `wal_checkpoint(TRUNCATE)`
|
|
889
|
+
// and `BEGIN EXCLUSIVE`. The lock is held only for the duration of the
|
|
890
|
+
// file copy itself.
|
|
891
|
+
let src;
|
|
892
|
+
try {
|
|
893
|
+
src = opener(ctx.dataDir, ctx.pepper, { readonly: false });
|
|
894
|
+
} catch (err) {
|
|
895
|
+
console.log(` open failed: ${err.message}`);
|
|
896
|
+
return { target: target.label, source: sourcePath, sourceSize, dest: destPath, ok: false, error: err.message };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// The SQLCipher build in this driver does not expose `sqlcipher_export`
|
|
900
|
+
// and the SQLite online-backup API refuses cross-cipher copies. We
|
|
901
|
+
// instead take a brief exclusive lock, force a WAL checkpoint, and copy
|
|
902
|
+
// the encrypted .db file at the byte level — the pages are already
|
|
903
|
+
// encrypted in the source, so the destination inherits the key.
|
|
904
|
+
const t0 = Date.now();
|
|
905
|
+
let inTxn = false;
|
|
906
|
+
try {
|
|
907
|
+
src.exec('BEGIN EXCLUSIVE');
|
|
908
|
+
inTxn = true;
|
|
909
|
+
try { src.pragma('wal_checkpoint(TRUNCATE)'); } catch { /* best effort */ }
|
|
910
|
+
fs.copyFileSync(sourcePath, destPath);
|
|
911
|
+
src.exec('COMMIT');
|
|
912
|
+
inTxn = false;
|
|
913
|
+
} catch (err) {
|
|
914
|
+
if (inTxn) { try { src.exec('ROLLBACK'); } catch {} }
|
|
915
|
+
try { src.close(); } catch {}
|
|
916
|
+
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch {}
|
|
917
|
+
console.log(` backup failed: ${err.message}`);
|
|
918
|
+
return { target: target.label, source: sourcePath, sourceSize, dest: destPath, ok: false, error: err.message, durationMs: Date.now() - t0 };
|
|
919
|
+
} finally {
|
|
920
|
+
try { src.close(); } catch {}
|
|
921
|
+
}
|
|
922
|
+
const durationMs = Date.now() - t0;
|
|
923
|
+
const destSize = fileSize(destPath);
|
|
924
|
+
console.log(` dest: ${destPath}`);
|
|
925
|
+
console.log(` dest size: ${formatBytes(destSize)} (${formatDuration(durationMs)})`);
|
|
926
|
+
|
|
927
|
+
// Post-flight: open the snapshot with the same key and run quick_check.
|
|
928
|
+
let verifyDb;
|
|
929
|
+
try {
|
|
930
|
+
verifyDb = openEncryptedDb(destPath, ctx.pepper, { readonly: true, friendlyName: `snapshot of ${target.label}` });
|
|
931
|
+
} catch (err) {
|
|
932
|
+
console.log(` verify failed: ${err.message}`);
|
|
933
|
+
return { target: target.label, source: sourcePath, sourceSize, dest: destPath, destSize, durationMs, ok: false, error: `verify open failed: ${err.message}` };
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
const rows = verifyDb.pragma('quick_check');
|
|
937
|
+
const result = rows && rows[0] ? (rows[0].integrity_check || rows[0].quick_check || Object.values(rows[0])[0]) : 'unknown';
|
|
938
|
+
if (result !== 'ok') {
|
|
939
|
+
console.log(` verify failed: quick_check returned ${result}`);
|
|
940
|
+
return { target: target.label, source: sourcePath, sourceSize, dest: destPath, destSize, durationMs, ok: false, error: `quick_check: ${result}` };
|
|
941
|
+
}
|
|
942
|
+
console.log(` verify: ok`);
|
|
943
|
+
} catch (err) {
|
|
944
|
+
console.log(` verify failed: ${err.message}`);
|
|
945
|
+
return { target: target.label, source: sourcePath, sourceSize, dest: destPath, destSize, durationMs, ok: false, error: err.message };
|
|
946
|
+
} finally {
|
|
947
|
+
try { verifyDb.close(); } catch {}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
return { target: target.label, source: sourcePath, sourceSize, dest: destPath, destSize, durationMs, ok: true };
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ---------- verb: integrity ----------
|
|
954
|
+
|
|
955
|
+
function cmdIntegrity(args, ctx) {
|
|
956
|
+
const { flags, positional } = parseSubArgs(args);
|
|
957
|
+
const json = asBool(flags.json);
|
|
958
|
+
|
|
959
|
+
let targets;
|
|
960
|
+
if (positional.length === 0 || (positional.length === 1 && positional[0] === 'all')) {
|
|
961
|
+
targets = Object.keys(INTEGRITY_TARGETS);
|
|
962
|
+
} else {
|
|
963
|
+
targets = positional.map(p => {
|
|
964
|
+
if (!INTEGRITY_TARGETS[p]) {
|
|
965
|
+
const allowed = Object.keys(INTEGRITY_TARGETS).join(' | ');
|
|
966
|
+
throw new Error(`Unknown integrity target '${p}'. Allowed: ${allowed} | all`);
|
|
967
|
+
}
|
|
968
|
+
return p;
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Read-only — safe alongside a running instance. Log it once if active.
|
|
973
|
+
const lockStatus = getLockStatus(ctx.dataDir);
|
|
974
|
+
if (!json && (lockStatus.state === 'active' || lockStatus.state === 'suspect')) {
|
|
975
|
+
const pidPart = lockStatus.lock && lockStatus.lock.pid ? ` (PID ${lockStatus.lock.pid})` : '';
|
|
976
|
+
console.log(`Live instance detected${pidPart} — running read-only checks.`);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const results = [];
|
|
980
|
+
for (const key of targets) {
|
|
981
|
+
const target = INTEGRITY_TARGETS[key];
|
|
982
|
+
const dbPath = path.join(ctx.dataDir, target.filename);
|
|
983
|
+
if (!fs.existsSync(dbPath)) {
|
|
984
|
+
if (!json) console.log(`Skipping ${target.label}: ${dbPath} not found.`);
|
|
985
|
+
results.push({ target: target.label, ok: false, openable: false, issues: [], reason: 'not found' });
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
const result = integrityOneDb(key, dbPath, ctx);
|
|
989
|
+
results.push(result);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const anyOpenFailed = results.some(r => r.openable === false && r.reason !== 'not found');
|
|
993
|
+
const anyIssues = results.some(r => r.openable !== false && !r.ok);
|
|
994
|
+
|
|
995
|
+
if (json) {
|
|
996
|
+
printJson({ results });
|
|
997
|
+
} else {
|
|
998
|
+
console.log('');
|
|
999
|
+
if (!anyIssues && !anyOpenFailed) {
|
|
1000
|
+
console.log('All databases reported ok.');
|
|
1001
|
+
} else if (anyOpenFailed) {
|
|
1002
|
+
console.log('One or more databases could not be opened.');
|
|
1003
|
+
} else {
|
|
1004
|
+
console.log('One or more databases reported integrity issues — see above.');
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (anyOpenFailed) {
|
|
1009
|
+
const err = new Error('database open failure');
|
|
1010
|
+
err.silent = true;
|
|
1011
|
+
err.exitCode = 2;
|
|
1012
|
+
throw err;
|
|
1013
|
+
}
|
|
1014
|
+
if (anyIssues) {
|
|
1015
|
+
const err = new Error('integrity issues detected');
|
|
1016
|
+
err.silent = true;
|
|
1017
|
+
err.exitCode = 1;
|
|
1018
|
+
throw err;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function integrityOneDb(key, dbPath, ctx) {
|
|
1023
|
+
const target = INTEGRITY_TARGETS[key];
|
|
1024
|
+
const opener = key === 'main' ? openMainDb : key === 'llm-logs' ? openLlmLogsDb : openMountIndexDb;
|
|
1025
|
+
|
|
1026
|
+
console.log('');
|
|
1027
|
+
console.log(`── ${target.label} (${dbPath}) ──`);
|
|
1028
|
+
|
|
1029
|
+
let db;
|
|
1030
|
+
try {
|
|
1031
|
+
db = opener(ctx.dataDir, ctx.pepper, { readonly: true });
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
console.log(` open failed: ${err.message}`);
|
|
1034
|
+
return { target: target.label, ok: false, openable: false, issues: [], reason: err.message };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const issues = [];
|
|
1038
|
+
let cipherCheck = null;
|
|
1039
|
+
let integrityCheck = null;
|
|
1040
|
+
const t0 = Date.now();
|
|
1041
|
+
try {
|
|
1042
|
+
try {
|
|
1043
|
+
const rows = db.pragma('cipher_integrity_check');
|
|
1044
|
+
if (rows && rows.length === 0) {
|
|
1045
|
+
cipherCheck = 'ok';
|
|
1046
|
+
console.log(' cipher_integrity_check: ok');
|
|
1047
|
+
} else {
|
|
1048
|
+
const lines = rows.map(r => (r.cipher_integrity_check || Object.values(r)[0] || '')).filter(Boolean);
|
|
1049
|
+
if (lines.length === 1 && lines[0] === 'ok') {
|
|
1050
|
+
cipherCheck = 'ok';
|
|
1051
|
+
console.log(' cipher_integrity_check: ok');
|
|
1052
|
+
} else {
|
|
1053
|
+
cipherCheck = lines.join('; ');
|
|
1054
|
+
for (const line of lines) {
|
|
1055
|
+
console.log(` cipher_integrity_check: ${line}`);
|
|
1056
|
+
issues.push({ pragma: 'cipher_integrity_check', message: line });
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
// Plain SQLite has no cipher_integrity_check; treat as N/A and continue.
|
|
1062
|
+
cipherCheck = `n/a (${err.message})`;
|
|
1063
|
+
console.log(` cipher_integrity_check: n/a (${err.message})`);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const integRows = db.pragma('integrity_check');
|
|
1067
|
+
const lines = integRows.map(r => (r.integrity_check || Object.values(r)[0] || '')).filter(Boolean);
|
|
1068
|
+
if (lines.length === 1 && lines[0] === 'ok') {
|
|
1069
|
+
integrityCheck = 'ok';
|
|
1070
|
+
console.log(' integrity_check: ok');
|
|
1071
|
+
} else {
|
|
1072
|
+
integrityCheck = lines.join('; ');
|
|
1073
|
+
for (const line of lines) {
|
|
1074
|
+
console.log(` integrity_check: ${line}`);
|
|
1075
|
+
issues.push({ pragma: 'integrity_check', message: line });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
} finally {
|
|
1079
|
+
try { db.close(); } catch {}
|
|
1080
|
+
}
|
|
1081
|
+
const durationMs = Date.now() - t0;
|
|
1082
|
+
console.log(` duration: ${formatDuration(durationMs)}`);
|
|
1083
|
+
|
|
1084
|
+
const ok = (cipherCheck === 'ok' || cipherCheck === null || (typeof cipherCheck === 'string' && cipherCheck.startsWith('n/a')))
|
|
1085
|
+
&& integrityCheck === 'ok';
|
|
1086
|
+
return {
|
|
1087
|
+
target: target.label,
|
|
1088
|
+
ok,
|
|
1089
|
+
openable: true,
|
|
1090
|
+
cipherIntegrityCheck: cipherCheck,
|
|
1091
|
+
integrityCheck,
|
|
1092
|
+
issues,
|
|
1093
|
+
durationMs,
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// ---------- dispatch ----------
|
|
1098
|
+
|
|
1099
|
+
const VERBS = {
|
|
1100
|
+
schema: cmdSchema,
|
|
1101
|
+
find: cmdFind,
|
|
1102
|
+
chats: cmdChats,
|
|
1103
|
+
messages: cmdMessages,
|
|
1104
|
+
logs: cmdLogs,
|
|
1105
|
+
message: cmdMessage,
|
|
1106
|
+
log: cmdLog,
|
|
1107
|
+
memories: cmdMemories,
|
|
1108
|
+
optimize: cmdOptimize,
|
|
1109
|
+
backup: cmdBackup,
|
|
1110
|
+
integrity: cmdIntegrity,
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
function isVerb(arg) {
|
|
1114
|
+
return Object.prototype.hasOwnProperty.call(VERBS, arg);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async function runVerb(args, ctx) {
|
|
1118
|
+
const [verb, ...rest] = args;
|
|
1119
|
+
const handler = VERBS[verb];
|
|
1120
|
+
if (!handler) throw new Error(`Unknown db subcommand: ${verb}`);
|
|
1121
|
+
return handler(rest, ctx);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function makeCtx(dataDir, pepper) {
|
|
1125
|
+
return {
|
|
1126
|
+
dataDir,
|
|
1127
|
+
pepper,
|
|
1128
|
+
openMain: () => openMainDb(dataDir, pepper, { readonly: true }),
|
|
1129
|
+
openLogs: () => openLlmLogsDb(dataDir, pepper, { readonly: true }),
|
|
1130
|
+
openMounts: () => openMountIndexDb(dataDir, pepper, { readonly: true }),
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
module.exports = {
|
|
1135
|
+
VERBS,
|
|
1136
|
+
isVerb,
|
|
1137
|
+
runVerb,
|
|
1138
|
+
makeCtx,
|
|
1139
|
+
DB_DOMAINS,
|
|
1140
|
+
TABLE_DB,
|
|
1141
|
+
ddlAnchor,
|
|
1142
|
+
};
|