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 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.4.0-dev.87",
3
+ "version": "4.4.0-dev.99",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",