quilltap 4.4.0-dev.87 → 4.4.0-dev.99
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/bin/quilltap.js +7 -0
- package/lib/memory-diff-command.js +333 -0
- package/package.json +1 -1
package/bin/quilltap.js
CHANGED
|
@@ -79,6 +79,7 @@ Subcommands:
|
|
|
79
79
|
db Query encrypted databases
|
|
80
80
|
themes Manage theme bundles
|
|
81
81
|
docs Inspect, read, and export document mounts
|
|
82
|
+
memory-diff <chatId> Dump existing memories and dry-run re-extraction for a chat
|
|
82
83
|
|
|
83
84
|
Options:
|
|
84
85
|
-p, --port <number> Port to listen on (default: 3000)
|
|
@@ -869,6 +870,12 @@ if (process.argv[2] === 'db') {
|
|
|
869
870
|
} else if (process.argv[2] === 'docs') {
|
|
870
871
|
const { docsCommand } = require('../lib/docs-commands');
|
|
871
872
|
docsCommand(process.argv.slice(3));
|
|
873
|
+
} else if (process.argv[2] === 'memory-diff') {
|
|
874
|
+
const { memoryDiffCommand } = require('../lib/memory-diff-command');
|
|
875
|
+
memoryDiffCommand(process.argv.slice(3)).catch(err => {
|
|
876
|
+
console.error(`Error: ${err.message}`);
|
|
877
|
+
process.exit(1);
|
|
878
|
+
});
|
|
872
879
|
} else {
|
|
873
880
|
main();
|
|
874
881
|
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `quilltap memory-diff <chatId>` — dump existing memories for a chat and
|
|
5
|
+
* stream a dry-run re-extraction from the running server, writing both to
|
|
6
|
+
* JSON files for diffing.
|
|
7
|
+
*
|
|
8
|
+
* Read-only against the encrypted SQLite (memories table). The dry-run
|
|
9
|
+
* extraction is performed by the running server via
|
|
10
|
+
* POST /api/v1/chats/<chatId>?action=extract-memories-dry-run, which streams
|
|
11
|
+
* NDJSON progress events. Nothing is persisted server-side.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const { resolveDataDir, loadDbKey } = require('./db-helpers');
|
|
17
|
+
|
|
18
|
+
const RESET = '\x1b[0m';
|
|
19
|
+
const BOLD = '\x1b[1m';
|
|
20
|
+
const DIM = '\x1b[2m';
|
|
21
|
+
const GREEN = '\x1b[32m';
|
|
22
|
+
const RED = '\x1b[31m';
|
|
23
|
+
const YELLOW = '\x1b[33m';
|
|
24
|
+
const CYAN = '\x1b[36m';
|
|
25
|
+
|
|
26
|
+
function printMemoryDiffHelp() {
|
|
27
|
+
console.log(`
|
|
28
|
+
Quilltap memory-diff Tool
|
|
29
|
+
|
|
30
|
+
Usage: quilltap memory-diff <chatId> [options]
|
|
31
|
+
|
|
32
|
+
Reads existing memories for a chat from the encrypted SQLite database and
|
|
33
|
+
runs a dry-run re-extraction against the running Quilltap server. Writes:
|
|
34
|
+
<out>/<chatId>-existing.json — current memories from the database
|
|
35
|
+
<out>/<chatId>-extracted.json — what the extraction pipeline would write
|
|
36
|
+
|
|
37
|
+
Nothing is persisted; the server runs the extraction passes in dry-run mode
|
|
38
|
+
so the comparison is non-destructive.
|
|
39
|
+
|
|
40
|
+
Options:
|
|
41
|
+
-d, --data-dir <path> Override data directory (instance root)
|
|
42
|
+
--passphrase <pass> Decrypt .dbkey if peppered
|
|
43
|
+
--port <number> Server port for API calls (default: 3000)
|
|
44
|
+
--out <dir> Output directory (default: cwd)
|
|
45
|
+
-h, --help Show this help
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
quilltap memory-diff <chatId>
|
|
49
|
+
quilltap memory-diff <chatId> --data-dir ~/iCloud/Quilltap/Friday
|
|
50
|
+
quilltap memory-diff <chatId> --out /tmp/extract-diff
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseFlags(args) {
|
|
55
|
+
const flags = {
|
|
56
|
+
dataDir: '',
|
|
57
|
+
passphrase: '',
|
|
58
|
+
port: 3000,
|
|
59
|
+
out: process.cwd(),
|
|
60
|
+
help: false,
|
|
61
|
+
};
|
|
62
|
+
const positional = [];
|
|
63
|
+
let i = 0;
|
|
64
|
+
while (i < args.length) {
|
|
65
|
+
const a = args[i];
|
|
66
|
+
switch (a) {
|
|
67
|
+
case '-d':
|
|
68
|
+
case '--data-dir':
|
|
69
|
+
flags.dataDir = args[++i];
|
|
70
|
+
break;
|
|
71
|
+
case '--passphrase':
|
|
72
|
+
flags.passphrase = args[++i];
|
|
73
|
+
break;
|
|
74
|
+
case '--port': {
|
|
75
|
+
const p = parseInt(args[++i], 10);
|
|
76
|
+
if (isNaN(p) || p < 1 || p > 65535) {
|
|
77
|
+
console.error('Error: --port must be between 1 and 65535');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
flags.port = p;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case '--out':
|
|
84
|
+
flags.out = args[++i];
|
|
85
|
+
break;
|
|
86
|
+
case '-h':
|
|
87
|
+
case '--help':
|
|
88
|
+
flags.help = true;
|
|
89
|
+
break;
|
|
90
|
+
default:
|
|
91
|
+
if (a.startsWith('-')) {
|
|
92
|
+
console.error(`Unknown option: ${a}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
positional.push(a);
|
|
96
|
+
}
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
return { flags, positional };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tryParseJsonColumn(value, fallback) {
|
|
103
|
+
if (value === null || value === undefined || value === '') return fallback;
|
|
104
|
+
if (typeof value !== 'string') return value;
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(value);
|
|
107
|
+
} catch {
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Open quilltap.db read-only and return all memories for a chat. JSON
|
|
114
|
+
* columns are parsed; the binary `embedding` column is dropped from output.
|
|
115
|
+
*/
|
|
116
|
+
async function readExistingMemories(flags, chatId) {
|
|
117
|
+
const dataDir = resolveDataDir(flags.dataDir);
|
|
118
|
+
const dbPath = path.join(dataDir, 'quilltap.db');
|
|
119
|
+
if (!fs.existsSync(dbPath)) {
|
|
120
|
+
console.error(`Database not found: ${dbPath}`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let pepper;
|
|
125
|
+
try {
|
|
126
|
+
pepper = await loadDbKey(dataDir, flags.passphrase);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.error(`Error: ${err.message}`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let Database;
|
|
133
|
+
try {
|
|
134
|
+
Database = require('better-sqlite3-multiple-ciphers');
|
|
135
|
+
} catch {
|
|
136
|
+
Database = require('better-sqlite3');
|
|
137
|
+
}
|
|
138
|
+
const db = new Database(dbPath, { readonly: true });
|
|
139
|
+
|
|
140
|
+
if (pepper) {
|
|
141
|
+
const keyHex = Buffer.from(pepper, 'base64').toString('hex');
|
|
142
|
+
db.pragma(`key = "x'${keyHex}'"`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
db.prepare('SELECT 1').get();
|
|
147
|
+
} catch (err) {
|
|
148
|
+
db.close();
|
|
149
|
+
console.error(`Cannot open database: ${err.message}`);
|
|
150
|
+
console.error('The database may be encrypted with a different key, or the .dbkey file may be missing.');
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let rows;
|
|
155
|
+
try {
|
|
156
|
+
rows = db.prepare(`
|
|
157
|
+
SELECT id, characterId, aboutCharacterId, chatId, source, sourceMessageId,
|
|
158
|
+
content, summary, keywords, tags, importance, reinforcementCount,
|
|
159
|
+
lastReinforcedAt, relatedMemoryIds, reinforcedImportance,
|
|
160
|
+
createdAt, updatedAt
|
|
161
|
+
FROM memories
|
|
162
|
+
WHERE chatId = ?
|
|
163
|
+
ORDER BY createdAt
|
|
164
|
+
`).all(chatId);
|
|
165
|
+
} finally {
|
|
166
|
+
db.close();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return rows.map(row => ({
|
|
170
|
+
...row,
|
|
171
|
+
keywords: tryParseJsonColumn(row.keywords, []),
|
|
172
|
+
tags: tryParseJsonColumn(row.tags, []),
|
|
173
|
+
relatedMemoryIds: tryParseJsonColumn(row.relatedMemoryIds, []),
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Read NDJSON from a Response body, calling `onEvent` for every parsed line.
|
|
179
|
+
* Tolerates split lines across chunks.
|
|
180
|
+
*/
|
|
181
|
+
async function streamNdjson(res, onEvent) {
|
|
182
|
+
const reader = res.body.getReader();
|
|
183
|
+
const decoder = new TextDecoder();
|
|
184
|
+
let buffer = '';
|
|
185
|
+
for (;;) {
|
|
186
|
+
const { value, done } = await reader.read();
|
|
187
|
+
if (done) break;
|
|
188
|
+
buffer += decoder.decode(value, { stream: true });
|
|
189
|
+
let nl;
|
|
190
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
191
|
+
const line = buffer.slice(0, nl).trim();
|
|
192
|
+
buffer = buffer.slice(nl + 1);
|
|
193
|
+
if (!line) continue;
|
|
194
|
+
let event;
|
|
195
|
+
try {
|
|
196
|
+
event = JSON.parse(line);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
process.stderr.write(`${YELLOW}[warn] could not parse server line: ${line.slice(0, 200)}${RESET}\n`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
onEvent(event);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Flush any trailing partial line
|
|
205
|
+
const tail = buffer.trim();
|
|
206
|
+
if (tail) {
|
|
207
|
+
try {
|
|
208
|
+
onEvent(JSON.parse(tail));
|
|
209
|
+
} catch {
|
|
210
|
+
/* swallow */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function memoryDiffCommand(args) {
|
|
216
|
+
const { flags, positional } = parseFlags(args);
|
|
217
|
+
|
|
218
|
+
if (flags.help || positional.length === 0) {
|
|
219
|
+
printMemoryDiffHelp();
|
|
220
|
+
process.exit(flags.help ? 0 : 1);
|
|
221
|
+
}
|
|
222
|
+
if (positional.length > 1) {
|
|
223
|
+
console.error('Error: only one chatId may be specified');
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const chatId = positional[0];
|
|
228
|
+
|
|
229
|
+
if (!fs.existsSync(flags.out)) {
|
|
230
|
+
try {
|
|
231
|
+
fs.mkdirSync(flags.out, { recursive: true });
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.error(`Cannot create output directory ${flags.out}: ${err.message}`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const existingPath = path.join(flags.out, `${chatId}-existing.json`);
|
|
239
|
+
const extractedPath = path.join(flags.out, `${chatId}-extracted.json`);
|
|
240
|
+
|
|
241
|
+
// -------- 1. Read existing memories from the encrypted DB ----------------
|
|
242
|
+
process.stderr.write(`${BOLD}Reading existing memories${RESET} for chat ${DIM}${chatId}${RESET}...\n`);
|
|
243
|
+
const existing = await readExistingMemories(flags, chatId);
|
|
244
|
+
fs.writeFileSync(existingPath, JSON.stringify(existing, null, 2) + '\n');
|
|
245
|
+
process.stderr.write(` wrote ${GREEN}${existing.length}${RESET} memories to ${existingPath}\n`);
|
|
246
|
+
|
|
247
|
+
// -------- 2. Stream dry-run re-extraction from the server ----------------
|
|
248
|
+
const url = `http://localhost:${flags.port}/api/v1/chats/${encodeURIComponent(chatId)}?action=extract-memories-dry-run`;
|
|
249
|
+
process.stderr.write(`${BOLD}Streaming re-extraction${RESET} from ${DIM}${url}${RESET}\n`);
|
|
250
|
+
|
|
251
|
+
let res;
|
|
252
|
+
try {
|
|
253
|
+
res = await fetch(url, { method: 'POST' });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
console.error(`${RED}Could not reach Quilltap server at http://localhost:${flags.port}: ${err.message}${RESET}`);
|
|
256
|
+
console.error('Start the server (npm run dev) or pass --port to match a non-default port.');
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
let body;
|
|
262
|
+
try {
|
|
263
|
+
body = await res.text();
|
|
264
|
+
} catch {
|
|
265
|
+
body = '';
|
|
266
|
+
}
|
|
267
|
+
console.error(`${RED}Server returned ${res.status}: ${body.slice(0, 500)}${RESET}`);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!res.body) {
|
|
272
|
+
console.error(`${RED}Server response had no body${RESET}`);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const candidates = [];
|
|
277
|
+
let turnCount = 0;
|
|
278
|
+
let totalCandidates = null;
|
|
279
|
+
let fatal = null;
|
|
280
|
+
|
|
281
|
+
await streamNdjson(res, (event) => {
|
|
282
|
+
switch (event.type) {
|
|
283
|
+
case 'start':
|
|
284
|
+
turnCount = event.turnCount;
|
|
285
|
+
process.stderr.write(` re-extracting ${CYAN}${turnCount}${RESET} turns...\n`);
|
|
286
|
+
break;
|
|
287
|
+
case 'candidate':
|
|
288
|
+
candidates.push(event);
|
|
289
|
+
break;
|
|
290
|
+
case 'turn':
|
|
291
|
+
process.stderr.write(
|
|
292
|
+
` [${String(event.index + 1).padStart(String(turnCount).length)}/${turnCount}] ` +
|
|
293
|
+
`turn ${DIM}${event.sourceMessageId ?? '?'}${RESET}: ` +
|
|
294
|
+
`${GREEN}${event.candidatesAdded}${RESET} candidate(s)\n`
|
|
295
|
+
);
|
|
296
|
+
break;
|
|
297
|
+
case 'turn-error':
|
|
298
|
+
process.stderr.write(
|
|
299
|
+
` ${RED}[${event.index + 1}/${turnCount}] FAILED${RESET}: ${event.error}\n`
|
|
300
|
+
);
|
|
301
|
+
break;
|
|
302
|
+
case 'done':
|
|
303
|
+
totalCandidates = event.totalCandidates;
|
|
304
|
+
break;
|
|
305
|
+
case 'fatal':
|
|
306
|
+
fatal = event.error;
|
|
307
|
+
break;
|
|
308
|
+
default:
|
|
309
|
+
process.stderr.write(` ${YELLOW}[unknown event] ${JSON.stringify(event)}${RESET}\n`);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
if (fatal) {
|
|
314
|
+
console.error(`${RED}Server reported fatal error: ${fatal}${RESET}`);
|
|
315
|
+
// Still write what we collected so the user can inspect partial output.
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
fs.writeFileSync(extractedPath, JSON.stringify(candidates, null, 2) + '\n');
|
|
319
|
+
process.stderr.write(` wrote ${GREEN}${candidates.length}${RESET} candidates to ${extractedPath}\n`);
|
|
320
|
+
|
|
321
|
+
if (totalCandidates !== null && totalCandidates !== candidates.length) {
|
|
322
|
+
process.stderr.write(
|
|
323
|
+
` ${YELLOW}note: server reported ${totalCandidates} total candidates but stream delivered ${candidates.length}${RESET}\n`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// -------- 3. Summary on stdout ------------------------------------------
|
|
328
|
+
console.log(`${existing.length} existing → ${candidates.length} re-extracted`);
|
|
329
|
+
|
|
330
|
+
if (fatal) process.exit(2);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = { memoryDiffCommand };
|