quilltap 4.4.0-dev.93 → 4.4.0

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)
@@ -168,6 +169,19 @@ function ensureNativeModules() {
168
169
  }
169
170
  }
170
171
 
172
+ // Check node-pty: backs the Ariel terminal feature. Loaded dynamically by
173
+ // pty-manager in the standalone server, so resolution must succeed and the
174
+ // native binding's NODE_MODULE_VERSION must match the runtime.
175
+ try {
176
+ require('node-pty');
177
+ } catch (err) {
178
+ if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
179
+ needsRebuild.push('node-pty');
180
+ } else if (err.code === 'MODULE_NOT_FOUND') {
181
+ needsRebuild.push('node-pty');
182
+ }
183
+ }
184
+
171
185
  if (needsRebuild.length === 0) return;
172
186
 
173
187
  console.log(` Rebuilding native modules for Node.js ${process.version}...`);
@@ -237,6 +251,28 @@ function linkNativeModules(standaloneDir) {
237
251
  || resolveModuleDir('better-sqlite3');
238
252
  linkModule('better-sqlite3', betterSqlite3Dir);
239
253
 
254
+ // Link node-pty — the standalone tarball strips it (platform-specific),
255
+ // and pty-manager loads it via a dynamic require, so it needs to resolve
256
+ // from standaloneDir/node_modules.
257
+ const nodePtyDir = resolveModuleDir('node-pty');
258
+ linkModule('node-pty', nodePtyDir);
259
+ if (nodePtyDir) {
260
+ // Some npm cache extractions strip the executable bit on spawn-helper,
261
+ // causing pty.spawn() to fail with `posix_spawnp failed`. Restore it.
262
+ const prebuildsDir = path.join(nodePtyDir, 'prebuilds');
263
+ if (fs.existsSync(prebuildsDir)) {
264
+ try {
265
+ for (const entry of fs.readdirSync(prebuildsDir, { withFileTypes: true })) {
266
+ if (!entry.isDirectory()) continue;
267
+ const helper = path.join(prebuildsDir, entry.name, 'spawn-helper');
268
+ if (fs.existsSync(helper)) {
269
+ try { fs.chmodSync(helper, 0o755); } catch { /* best-effort */ }
270
+ }
271
+ }
272
+ } catch { /* best-effort */ }
273
+ }
274
+ }
275
+
240
276
  // Link sharp
241
277
  const sharpDir = resolveModuleDir('sharp');
242
278
  linkModule('sharp', sharpDir);
@@ -869,6 +905,12 @@ if (process.argv[2] === 'db') {
869
905
  } else if (process.argv[2] === 'docs') {
870
906
  const { docsCommand } = require('../lib/docs-commands');
871
907
  docsCommand(process.argv.slice(3));
908
+ } else if (process.argv[2] === 'memory-diff') {
909
+ const { memoryDiffCommand } = require('../lib/memory-diff-command');
910
+ memoryDiffCommand(process.argv.slice(3)).catch(err => {
911
+ console.error(`Error: ${err.message}`);
912
+ process.exit(1);
913
+ });
872
914
  } else {
873
915
  main();
874
916
  }
@@ -0,0 +1,355 @@
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
+ --concurrency <number> Parallel turns to process (default: 4, max: 32).
46
+ Cloud cheap-LLMs can handle 8–16; keep this low
47
+ for local Ollama to avoid saturating the model.
48
+ -h, --help Show this help
49
+
50
+ Examples:
51
+ quilltap memory-diff <chatId>
52
+ quilltap memory-diff <chatId> --data-dir ~/iCloud/Quilltap/Friday
53
+ quilltap memory-diff <chatId> --out /tmp/extract-diff
54
+ quilltap memory-diff <chatId> --concurrency 8 # cloud cheap-LLM
55
+ `);
56
+ }
57
+
58
+ function parseFlags(args) {
59
+ const flags = {
60
+ dataDir: '',
61
+ passphrase: '',
62
+ port: 3000,
63
+ out: process.cwd(),
64
+ concurrency: 4,
65
+ help: false,
66
+ };
67
+ const positional = [];
68
+ let i = 0;
69
+ while (i < args.length) {
70
+ const a = args[i];
71
+ switch (a) {
72
+ case '-d':
73
+ case '--data-dir':
74
+ flags.dataDir = args[++i];
75
+ break;
76
+ case '--passphrase':
77
+ flags.passphrase = args[++i];
78
+ break;
79
+ case '--port': {
80
+ const p = parseInt(args[++i], 10);
81
+ if (isNaN(p) || p < 1 || p > 65535) {
82
+ console.error('Error: --port must be between 1 and 65535');
83
+ process.exit(1);
84
+ }
85
+ flags.port = p;
86
+ break;
87
+ }
88
+ case '--concurrency': {
89
+ const n = parseInt(args[++i], 10);
90
+ if (isNaN(n) || n < 1 || n > 32) {
91
+ console.error('Error: --concurrency must be between 1 and 32');
92
+ process.exit(1);
93
+ }
94
+ flags.concurrency = n;
95
+ break;
96
+ }
97
+ case '--out':
98
+ flags.out = args[++i];
99
+ break;
100
+ case '-h':
101
+ case '--help':
102
+ flags.help = true;
103
+ break;
104
+ default:
105
+ if (a.startsWith('-')) {
106
+ console.error(`Unknown option: ${a}`);
107
+ process.exit(1);
108
+ }
109
+ positional.push(a);
110
+ }
111
+ i++;
112
+ }
113
+ return { flags, positional };
114
+ }
115
+
116
+ function tryParseJsonColumn(value, fallback) {
117
+ if (value === null || value === undefined || value === '') return fallback;
118
+ if (typeof value !== 'string') return value;
119
+ try {
120
+ return JSON.parse(value);
121
+ } catch {
122
+ return value;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Open quilltap.db read-only and return all memories for a chat. JSON
128
+ * columns are parsed; the binary `embedding` column is dropped from output.
129
+ */
130
+ async function readExistingMemories(flags, chatId) {
131
+ const dataDir = resolveDataDir(flags.dataDir);
132
+ const dbPath = path.join(dataDir, 'quilltap.db');
133
+ if (!fs.existsSync(dbPath)) {
134
+ console.error(`Database not found: ${dbPath}`);
135
+ process.exit(1);
136
+ }
137
+
138
+ let pepper;
139
+ try {
140
+ pepper = await loadDbKey(dataDir, flags.passphrase);
141
+ } catch (err) {
142
+ console.error(`Error: ${err.message}`);
143
+ process.exit(1);
144
+ }
145
+
146
+ let Database;
147
+ try {
148
+ Database = require('better-sqlite3-multiple-ciphers');
149
+ } catch {
150
+ Database = require('better-sqlite3');
151
+ }
152
+ const db = new Database(dbPath, { readonly: true });
153
+
154
+ if (pepper) {
155
+ const keyHex = Buffer.from(pepper, 'base64').toString('hex');
156
+ db.pragma(`key = "x'${keyHex}'"`);
157
+ }
158
+
159
+ try {
160
+ db.prepare('SELECT 1').get();
161
+ } catch (err) {
162
+ db.close();
163
+ console.error(`Cannot open database: ${err.message}`);
164
+ console.error('The database may be encrypted with a different key, or the .dbkey file may be missing.');
165
+ process.exit(1);
166
+ }
167
+
168
+ let rows;
169
+ try {
170
+ rows = db.prepare(`
171
+ SELECT id, characterId, aboutCharacterId, chatId, source, sourceMessageId,
172
+ content, summary, keywords, tags, importance, reinforcementCount,
173
+ lastReinforcedAt, relatedMemoryIds, reinforcedImportance,
174
+ createdAt, updatedAt
175
+ FROM memories
176
+ WHERE chatId = ?
177
+ ORDER BY createdAt
178
+ `).all(chatId);
179
+ } finally {
180
+ db.close();
181
+ }
182
+
183
+ return rows.map(row => ({
184
+ ...row,
185
+ keywords: tryParseJsonColumn(row.keywords, []),
186
+ tags: tryParseJsonColumn(row.tags, []),
187
+ relatedMemoryIds: tryParseJsonColumn(row.relatedMemoryIds, []),
188
+ }));
189
+ }
190
+
191
+ /**
192
+ * Read NDJSON from a Response body, calling `onEvent` for every parsed line.
193
+ * Tolerates split lines across chunks.
194
+ */
195
+ async function streamNdjson(res, onEvent) {
196
+ const reader = res.body.getReader();
197
+ const decoder = new TextDecoder();
198
+ let buffer = '';
199
+ for (;;) {
200
+ const { value, done } = await reader.read();
201
+ if (done) break;
202
+ buffer += decoder.decode(value, { stream: true });
203
+ let nl;
204
+ while ((nl = buffer.indexOf('\n')) !== -1) {
205
+ const line = buffer.slice(0, nl).trim();
206
+ buffer = buffer.slice(nl + 1);
207
+ if (!line) continue;
208
+ let event;
209
+ try {
210
+ event = JSON.parse(line);
211
+ } catch (err) {
212
+ process.stderr.write(`${YELLOW}[warn] could not parse server line: ${line.slice(0, 200)}${RESET}\n`);
213
+ continue;
214
+ }
215
+ onEvent(event);
216
+ }
217
+ }
218
+ // Flush any trailing partial line
219
+ const tail = buffer.trim();
220
+ if (tail) {
221
+ try {
222
+ onEvent(JSON.parse(tail));
223
+ } catch {
224
+ /* swallow */
225
+ }
226
+ }
227
+ }
228
+
229
+ async function memoryDiffCommand(args) {
230
+ const { flags, positional } = parseFlags(args);
231
+
232
+ if (flags.help || positional.length === 0) {
233
+ printMemoryDiffHelp();
234
+ process.exit(flags.help ? 0 : 1);
235
+ }
236
+ if (positional.length > 1) {
237
+ console.error('Error: only one chatId may be specified');
238
+ process.exit(1);
239
+ }
240
+
241
+ const chatId = positional[0];
242
+
243
+ if (!fs.existsSync(flags.out)) {
244
+ try {
245
+ fs.mkdirSync(flags.out, { recursive: true });
246
+ } catch (err) {
247
+ console.error(`Cannot create output directory ${flags.out}: ${err.message}`);
248
+ process.exit(1);
249
+ }
250
+ }
251
+
252
+ const existingPath = path.join(flags.out, `${chatId}-existing.json`);
253
+ const extractedPath = path.join(flags.out, `${chatId}-extracted.json`);
254
+
255
+ // -------- 1. Read existing memories from the encrypted DB ----------------
256
+ process.stderr.write(`${BOLD}Reading existing memories${RESET} for chat ${DIM}${chatId}${RESET}...\n`);
257
+ const existing = await readExistingMemories(flags, chatId);
258
+ fs.writeFileSync(existingPath, JSON.stringify(existing, null, 2) + '\n');
259
+ process.stderr.write(` wrote ${GREEN}${existing.length}${RESET} memories to ${existingPath}\n`);
260
+
261
+ // -------- 2. Stream dry-run re-extraction from the server ----------------
262
+ const url =
263
+ `http://localhost:${flags.port}/api/v1/chats/${encodeURIComponent(chatId)}` +
264
+ `?action=extract-memories-dry-run&concurrency=${flags.concurrency}`;
265
+ process.stderr.write(
266
+ `${BOLD}Streaming re-extraction${RESET} (concurrency ${CYAN}${flags.concurrency}${RESET}) from ${DIM}${url}${RESET}\n`
267
+ );
268
+
269
+ let res;
270
+ try {
271
+ res = await fetch(url, { method: 'POST' });
272
+ } catch (err) {
273
+ console.error(`${RED}Could not reach Quilltap server at http://localhost:${flags.port}: ${err.message}${RESET}`);
274
+ console.error('Start the server (npm run dev) or pass --port to match a non-default port.');
275
+ process.exit(1);
276
+ }
277
+
278
+ if (!res.ok) {
279
+ let body;
280
+ try {
281
+ body = await res.text();
282
+ } catch {
283
+ body = '';
284
+ }
285
+ console.error(`${RED}Server returned ${res.status}: ${body.slice(0, 500)}${RESET}`);
286
+ process.exit(1);
287
+ }
288
+
289
+ if (!res.body) {
290
+ console.error(`${RED}Server response had no body${RESET}`);
291
+ process.exit(1);
292
+ }
293
+
294
+ const candidates = [];
295
+ let turnCount = 0;
296
+ let totalCandidates = null;
297
+ let fatal = null;
298
+
299
+ await streamNdjson(res, (event) => {
300
+ switch (event.type) {
301
+ case 'start':
302
+ turnCount = event.turnCount;
303
+ process.stderr.write(` re-extracting ${CYAN}${turnCount}${RESET} turns...\n`);
304
+ break;
305
+ case 'candidate':
306
+ candidates.push(event);
307
+ break;
308
+ case 'turn':
309
+ process.stderr.write(
310
+ ` [${String(event.index + 1).padStart(String(turnCount).length)}/${turnCount}] ` +
311
+ `turn ${DIM}${event.sourceMessageId ?? '?'}${RESET}: ` +
312
+ `${GREEN}${event.candidatesAdded}${RESET} candidate(s)\n`
313
+ );
314
+ break;
315
+ case 'turn-error':
316
+ process.stderr.write(
317
+ ` ${RED}[${event.index + 1}/${turnCount}] FAILED${RESET}: ${event.error}\n`
318
+ );
319
+ break;
320
+ case 'ping':
321
+ // Server-side heartbeat keeping the connection warm during long
322
+ // first-turn LLM passes; nothing to display.
323
+ break;
324
+ case 'done':
325
+ totalCandidates = event.totalCandidates;
326
+ break;
327
+ case 'fatal':
328
+ fatal = event.error;
329
+ break;
330
+ default:
331
+ process.stderr.write(` ${YELLOW}[unknown event] ${JSON.stringify(event)}${RESET}\n`);
332
+ }
333
+ });
334
+
335
+ if (fatal) {
336
+ console.error(`${RED}Server reported fatal error: ${fatal}${RESET}`);
337
+ // Still write what we collected so the user can inspect partial output.
338
+ }
339
+
340
+ fs.writeFileSync(extractedPath, JSON.stringify(candidates, null, 2) + '\n');
341
+ process.stderr.write(` wrote ${GREEN}${candidates.length}${RESET} candidates to ${extractedPath}\n`);
342
+
343
+ if (totalCandidates !== null && totalCandidates !== candidates.length) {
344
+ process.stderr.write(
345
+ ` ${YELLOW}note: server reported ${totalCandidates} total candidates but stream delivered ${candidates.length}${RESET}\n`
346
+ );
347
+ }
348
+
349
+ // -------- 3. Summary on stdout ------------------------------------------
350
+ console.log(`${existing.length} existing → ${candidates.length} re-extracted`);
351
+
352
+ if (fatal) process.exit(2);
353
+ }
354
+
355
+ module.exports = { memoryDiffCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.4.0-dev.93",
3
+ "version": "4.4.0",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",
@@ -33,12 +33,14 @@
33
33
  "README.md"
34
34
  ],
35
35
  "dependencies": {
36
+ "@napi-rs/canvas": "^0.1.100",
36
37
  "better-sqlite3-multiple-ciphers": "^12.9.0",
38
+ "node-pty": "^1.1.0",
37
39
  "sharp": "^0.34.5",
38
- "tar": "^7.5.13",
40
+ "tar": "^7.5.15",
39
41
  "yauzl": "^3.3.0"
40
42
  },
41
43
  "engines": {
42
- "node": ">=22.0.0"
44
+ "node": ">=24.0.0"
43
45
  }
44
46
  }