quilltap 4.3.0-dev.88 → 4.3.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
@@ -5,6 +5,7 @@ const { fork, exec, execSync } = require('child_process');
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
7
  const { getCacheDir, isCacheValid, ensureStandalone } = require('../lib/download-manager');
8
+ const { resolveDataDir, promptPassphrase, loadDbKey } = require('../lib/db-helpers');
8
9
 
9
10
  const PACKAGE_DIR = path.resolve(__dirname, '..');
10
11
 
@@ -77,6 +78,7 @@ Usage: quilltap [options]
77
78
  Subcommands:
78
79
  db Query encrypted databases
79
80
  themes Manage theme bundles
81
+ docs Inspect, read, and export document mounts
80
82
 
81
83
  Options:
82
84
  -p, --port <number> Port to listen on (default: 3000)
@@ -368,137 +370,10 @@ async function main() {
368
370
  }
369
371
 
370
372
  // ============================================================================
371
- // db subcommand query encrypted databases directly
373
+ // db subcommand helpers (resolveDataDir, promptPassphrase, loadDbKey) live in
374
+ // ../lib/db-helpers.js so the docs subcommand can share them.
372
375
  // ============================================================================
373
376
 
374
- /**
375
- * Resolve the data directory using the same platform logic as lib/paths.ts.
376
- */
377
- function resolveDataDir(overrideDir) {
378
- if (overrideDir) {
379
- const resolved = overrideDir.startsWith('~')
380
- ? path.join(require('os').homedir(), overrideDir.slice(1))
381
- : overrideDir;
382
- return path.join(resolved, 'data');
383
- }
384
- const os = require('os');
385
- if (process.env.QUILLTAP_DATA_DIR) {
386
- return path.join(process.env.QUILLTAP_DATA_DIR, 'data');
387
- }
388
- const home = os.homedir();
389
- if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Quilltap', 'data');
390
- if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Quilltap', 'data');
391
- return path.join(home, '.quilltap', 'data');
392
- }
393
-
394
- /**
395
- * Prompt for a passphrase interactively with hidden input.
396
- * Returns a promise that resolves to the entered passphrase.
397
- */
398
- function promptPassphrase(prompt) {
399
- return new Promise((resolve, reject) => {
400
- const readline = require('readline');
401
- if (!process.stdin.isTTY) {
402
- reject(new Error('This database requires a passphrase. Use --passphrase <pass> or set QUILLTAP_DB_PASSPHRASE'));
403
- return;
404
- }
405
- process.stdout.write(prompt || 'Passphrase: ');
406
- const rl = readline.createInterface({ input: process.stdin, terminal: false });
407
- // Disable echo by switching stdin to raw mode
408
- process.stdin.setRawMode(true);
409
- process.stdin.resume();
410
- let passphrase = '';
411
- const onData = (ch) => {
412
- const c = ch.toString();
413
- if (c === '\n' || c === '\r' || c === '\u0004') {
414
- // Enter or Ctrl+D — done
415
- process.stdin.setRawMode(false);
416
- process.stdin.removeListener('data', onData);
417
- process.stdin.pause();
418
- rl.close();
419
- process.stdout.write('\n');
420
- resolve(passphrase);
421
- } else if (c === '\u0003') {
422
- // Ctrl+C — abort
423
- process.stdin.setRawMode(false);
424
- process.stdin.removeListener('data', onData);
425
- process.stdin.pause();
426
- rl.close();
427
- process.stdout.write('\n');
428
- process.exit(130);
429
- } else if (c === '\u007F' || c === '\b') {
430
- // Backspace
431
- if (passphrase.length > 0) {
432
- passphrase = passphrase.slice(0, -1);
433
- }
434
- } else {
435
- passphrase += c;
436
- }
437
- };
438
- process.stdin.on('data', onData);
439
- });
440
- }
441
-
442
- /**
443
- * Read and decrypt the .dbkey file to get the SQLCipher key.
444
- * If passphrase is needed and not provided, prompts interactively.
445
- */
446
- async function loadDbKey(dataDir, passphrase) {
447
- const crypto = require('crypto');
448
- const dbkeyPath = path.join(dataDir, 'quilltap.dbkey');
449
- if (!fs.existsSync(dbkeyPath)) {
450
- return null; // No .dbkey file — DB may be unencrypted
451
- }
452
-
453
- const data = JSON.parse(fs.readFileSync(dbkeyPath, 'utf8'));
454
- const INTERNAL_PASSPHRASE = '__quilltap_no_passphrase__';
455
-
456
- // Strip legacy hasPassphrase field if present
457
- if ('hasPassphrase' in data) {
458
- delete data.hasPassphrase;
459
- fs.writeFileSync(dbkeyPath, JSON.stringify(data, null, 2), { mode: 0o600 });
460
- }
461
-
462
- // Helper to attempt decryption with a given passphrase
463
- function tryDecrypt(pass) {
464
- const salt = Buffer.from(data.salt, 'hex');
465
- const key = crypto.pbkdf2Sync(pass, new Uint8Array(salt), data.kdfIterations, 32, data.kdfDigest);
466
- const iv = Buffer.from(data.iv, 'hex');
467
- const decipher = crypto.createDecipheriv(data.algorithm, new Uint8Array(key), new Uint8Array(iv));
468
- decipher.setAuthTag(new Uint8Array(Buffer.from(data.authTag, 'hex')));
469
- let plaintext = decipher.update(data.ciphertext, 'hex', 'utf8');
470
- plaintext += decipher.final('utf8');
471
-
472
- const hash = crypto.createHash('sha256').update(plaintext).digest('hex');
473
- if (hash !== data.pepperHash) {
474
- throw new Error('Pepper hash mismatch');
475
- }
476
- return plaintext;
477
- }
478
-
479
- // Try internal passphrase first (no user passphrase case)
480
- try {
481
- return tryDecrypt(INTERNAL_PASSPHRASE);
482
- } catch {
483
- // Internal passphrase failed — need user passphrase
484
- }
485
-
486
- // Check environment variable if no CLI passphrase provided
487
- if (!passphrase && process.env.QUILLTAP_DB_PASSPHRASE) {
488
- passphrase = process.env.QUILLTAP_DB_PASSPHRASE;
489
- }
490
-
491
- // Prompt interactively if still no passphrase
492
- if (!passphrase) {
493
- passphrase = await promptPassphrase('Database passphrase: ');
494
- if (!passphrase) {
495
- throw new Error('No passphrase provided');
496
- }
497
- }
498
-
499
- return tryDecrypt(passphrase);
500
- }
501
-
502
377
  // ============================================================================
503
378
  // Instance Lock CLI Commands
504
379
  // ============================================================================
@@ -787,6 +662,7 @@ Options:
787
662
  --count <table> Show row count for a table
788
663
  --repl Interactive SQL prompt
789
664
  --llm-logs Target the LLM logs database
665
+ --mount-points Target the document mount-index database
790
666
  --data-dir <path> Override data directory
791
667
  --passphrase <pass> Provide passphrase for encrypted .dbkey
792
668
  -h, --help Show this help
@@ -806,6 +682,8 @@ Examples:
806
682
  quilltap db --count messages
807
683
  quilltap db --repl
808
684
  quilltap db --llm-logs --tables
685
+ quilltap db --mount-points --tables
686
+ quilltap db --mount-points "SELECT id, name FROM doc_mount_points"
809
687
  quilltap db --lock-status
810
688
  quilltap db --lock-clean
811
689
  QUILLTAP_DB_PASSPHRASE=secret quilltap db --tables
@@ -816,6 +694,7 @@ async function dbCommand(args) {
816
694
  let dataDirOverride = '';
817
695
  let passphrase = '';
818
696
  let useLlmLogs = false;
697
+ let useMountPoints = false;
819
698
  let showTables = false;
820
699
  let countTable = '';
821
700
  let repl = false;
@@ -831,6 +710,7 @@ async function dbCommand(args) {
831
710
  case '--data-dir': case '-d': dataDirOverride = args[++i]; break;
832
711
  case '--passphrase': passphrase = args[++i]; break;
833
712
  case '--llm-logs': useLlmLogs = true; break;
713
+ case '--mount-points': useMountPoints = true; break;
834
714
  case '--tables': showTables = true; break;
835
715
  case '--count': countTable = args[++i]; break;
836
716
  case '--repl': repl = true; break;
@@ -862,7 +742,15 @@ async function dbCommand(args) {
862
742
  return;
863
743
  }
864
744
 
865
- const dbFilename = useLlmLogs ? 'quilltap-llm-logs.db' : 'quilltap.db';
745
+ if (useLlmLogs && useMountPoints) {
746
+ console.error('Error: --llm-logs and --mount-points are mutually exclusive');
747
+ process.exit(1);
748
+ }
749
+
750
+ let dbFilename;
751
+ if (useLlmLogs) dbFilename = 'quilltap-llm-logs.db';
752
+ else if (useMountPoints) dbFilename = 'quilltap-mount-index.db';
753
+ else dbFilename = 'quilltap.db';
866
754
  const dbPath = path.join(dataDir, dbFilename);
867
755
 
868
756
  if (!fs.existsSync(dbPath)) {
@@ -978,6 +866,9 @@ if (process.argv[2] === 'db') {
978
866
  } else if (process.argv[2] === 'themes') {
979
867
  const { themesCommand } = require('../lib/theme-commands');
980
868
  themesCommand(process.argv.slice(3));
869
+ } else if (process.argv[2] === 'docs') {
870
+ const { docsCommand } = require('../lib/docs-commands');
871
+ docsCommand(process.argv.slice(3));
981
872
  } else {
982
873
  main();
983
874
  }
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ const CTRL_C = String.fromCharCode(3);
7
+ const CTRL_D = String.fromCharCode(4);
8
+ const DEL = String.fromCharCode(0x7F);
9
+
10
+ function resolveDataDir(overrideDir) {
11
+ if (overrideDir) {
12
+ const resolved = overrideDir.startsWith('~')
13
+ ? path.join(require('os').homedir(), overrideDir.slice(1))
14
+ : overrideDir;
15
+ return path.join(resolved, 'data');
16
+ }
17
+ const os = require('os');
18
+ if (process.env.QUILLTAP_DATA_DIR) {
19
+ return path.join(process.env.QUILLTAP_DATA_DIR, 'data');
20
+ }
21
+ const home = os.homedir();
22
+ if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Quilltap', 'data');
23
+ if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Quilltap', 'data');
24
+ return path.join(home, '.quilltap', 'data');
25
+ }
26
+
27
+ function promptPassphrase(prompt) {
28
+ return new Promise((resolve, reject) => {
29
+ const readline = require('readline');
30
+ if (!process.stdin.isTTY) {
31
+ reject(new Error('This database requires a passphrase. Use --passphrase <pass> or set QUILLTAP_DB_PASSPHRASE'));
32
+ return;
33
+ }
34
+ process.stdout.write(prompt || 'Passphrase: ');
35
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
36
+ process.stdin.setRawMode(true);
37
+ process.stdin.resume();
38
+ let passphrase = '';
39
+ const onData = (ch) => {
40
+ const c = ch.toString();
41
+ if (c === '\n' || c === '\r' || c === CTRL_D) {
42
+ process.stdin.setRawMode(false);
43
+ process.stdin.removeListener('data', onData);
44
+ process.stdin.pause();
45
+ rl.close();
46
+ process.stdout.write('\n');
47
+ resolve(passphrase);
48
+ } else if (c === CTRL_C) {
49
+ process.stdin.setRawMode(false);
50
+ process.stdin.removeListener('data', onData);
51
+ process.stdin.pause();
52
+ rl.close();
53
+ process.stdout.write('\n');
54
+ process.exit(130);
55
+ } else if (c === DEL || c === '\b') {
56
+ if (passphrase.length > 0) {
57
+ passphrase = passphrase.slice(0, -1);
58
+ }
59
+ } else {
60
+ passphrase += c;
61
+ }
62
+ };
63
+ process.stdin.on('data', onData);
64
+ });
65
+ }
66
+
67
+ async function loadDbKey(dataDir, passphrase) {
68
+ const crypto = require('crypto');
69
+ const dbkeyPath = path.join(dataDir, 'quilltap.dbkey');
70
+ if (!fs.existsSync(dbkeyPath)) {
71
+ return null;
72
+ }
73
+
74
+ const data = JSON.parse(fs.readFileSync(dbkeyPath, 'utf8'));
75
+ const INTERNAL_PASSPHRASE = '__quilltap_no_passphrase__';
76
+
77
+ if ('hasPassphrase' in data) {
78
+ delete data.hasPassphrase;
79
+ fs.writeFileSync(dbkeyPath, JSON.stringify(data, null, 2), { mode: 0o600 });
80
+ }
81
+
82
+ function tryDecrypt(pass) {
83
+ const salt = Buffer.from(data.salt, 'hex');
84
+ const key = crypto.pbkdf2Sync(pass, new Uint8Array(salt), data.kdfIterations, 32, data.kdfDigest);
85
+ const iv = Buffer.from(data.iv, 'hex');
86
+ const decipher = crypto.createDecipheriv(data.algorithm, new Uint8Array(key), new Uint8Array(iv));
87
+ decipher.setAuthTag(new Uint8Array(Buffer.from(data.authTag, 'hex')));
88
+ let plaintext = decipher.update(data.ciphertext, 'hex', 'utf8');
89
+ plaintext += decipher.final('utf8');
90
+
91
+ const hash = crypto.createHash('sha256').update(plaintext).digest('hex');
92
+ if (hash !== data.pepperHash) {
93
+ throw new Error('Pepper hash mismatch');
94
+ }
95
+ return plaintext;
96
+ }
97
+
98
+ try {
99
+ return tryDecrypt(INTERNAL_PASSPHRASE);
100
+ } catch {
101
+ // Internal passphrase failed — need user passphrase
102
+ }
103
+
104
+ if (!passphrase && process.env.QUILLTAP_DB_PASSPHRASE) {
105
+ passphrase = process.env.QUILLTAP_DB_PASSPHRASE;
106
+ }
107
+
108
+ if (!passphrase) {
109
+ passphrase = await promptPassphrase('Database passphrase: ');
110
+ if (!passphrase) {
111
+ throw new Error('No passphrase provided');
112
+ }
113
+ }
114
+
115
+ return tryDecrypt(passphrase);
116
+ }
117
+
118
+ function openMountIndexDb(dataDir, pepper, { readonly = true } = {}) {
119
+ const dbPath = path.join(dataDir, 'quilltap-mount-index.db');
120
+ if (!fs.existsSync(dbPath)) {
121
+ throw new Error(`Mount index database not found: ${dbPath}`);
122
+ }
123
+
124
+ let Database;
125
+ try {
126
+ Database = require('better-sqlite3-multiple-ciphers');
127
+ } catch {
128
+ Database = require('better-sqlite3');
129
+ }
130
+ const db = new Database(dbPath, { readonly });
131
+
132
+ if (pepper) {
133
+ const keyHex = Buffer.from(pepper, 'base64').toString('hex');
134
+ db.pragma(`key = "x'${keyHex}'"`);
135
+ }
136
+
137
+ try {
138
+ db.prepare('SELECT 1').get();
139
+ } catch (err) {
140
+ db.close();
141
+ throw new Error(`Cannot open mount index database: ${err.message}\n` +
142
+ 'The database may be encrypted with a different key, or the .dbkey file may be missing.');
143
+ }
144
+
145
+ return db;
146
+ }
147
+
148
+ module.exports = {
149
+ resolveDataDir,
150
+ promptPassphrase,
151
+ loadDbKey,
152
+ openMountIndexDb,
153
+ };
@@ -0,0 +1,629 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { resolveDataDir, loadDbKey, openMountIndexDb } = require('./db-helpers');
6
+
7
+ const RESET = '\x1b[0m';
8
+ const BOLD = '\x1b[1m';
9
+ const DIM = '\x1b[2m';
10
+ const GREEN = '\x1b[32m';
11
+ const RED = '\x1b[31m';
12
+ const YELLOW = '\x1b[33m';
13
+ const CYAN = '\x1b[36m';
14
+
15
+ const TEXT_FILE_TYPES = new Set(['markdown', 'txt', 'json', 'jsonl']);
16
+ const BINARY_FILE_TYPES = new Set(['pdf', 'docx', 'blob']);
17
+
18
+ function printDocsHelp() {
19
+ console.log(`
20
+ Quilltap Document Store Tool
21
+
22
+ Usage: quilltap docs <subcommand> [options]
23
+
24
+ Subcommands:
25
+ list List all mount points
26
+ show <id> Details for one mount point
27
+ files <id> [--folder <path>] List files in a mount
28
+ read <id> <relativePath> Print file contents to stdout
29
+ read --rendered <id> <relativePath> Print extracted plaintext to stdout
30
+ export <id> <outputDir> Export an entire mount to a directory
31
+ scan <id> Trigger a rescan via the running server
32
+
33
+ Options:
34
+ -d, --data-dir <path> Override data directory
35
+ --passphrase <pass> Decrypt .dbkey if peppered
36
+ --port <number> Server port for API calls (default: 3000)
37
+ --json Machine-readable output (list/show/files/scan)
38
+ --rendered For 'read': output extracted plaintext
39
+ --folder <path> For 'files': narrow to a folder prefix
40
+ --force For 'read': dump binary to TTY anyway
41
+ -h, --help Show this help
42
+
43
+ Read-only operations (list, show, files, read, export) open the mount-index
44
+ database directly. Write operations (scan) require the Quilltap server to be
45
+ running on the chosen --port.
46
+
47
+ Examples:
48
+ quilltap docs list
49
+ quilltap docs list --json
50
+ quilltap docs show <mount-id>
51
+ quilltap docs files <mount-id> --folder notes/2026
52
+ quilltap docs read <mount-id> notes/today.md
53
+ quilltap docs read --rendered <mount-id> papers/foo.pdf
54
+ quilltap docs read <mount-id> images/avatar.webp > /tmp/avatar.webp
55
+ quilltap docs export <mount-id> /tmp/quilltap-mount-backup
56
+ quilltap docs scan <mount-id>
57
+ `);
58
+ }
59
+
60
+ function parseFlags(args) {
61
+ const flags = {
62
+ dataDir: '',
63
+ passphrase: '',
64
+ port: 3000,
65
+ json: false,
66
+ rendered: false,
67
+ folder: '',
68
+ force: false,
69
+ help: false,
70
+ };
71
+ const positional = [];
72
+ let i = 0;
73
+ while (i < args.length) {
74
+ const a = args[i];
75
+ switch (a) {
76
+ case '-d': case '--data-dir': flags.dataDir = args[++i]; break;
77
+ case '--passphrase': flags.passphrase = args[++i]; break;
78
+ case '--port': {
79
+ const p = parseInt(args[++i], 10);
80
+ if (isNaN(p) || p < 1 || p > 65535) {
81
+ console.error('Error: --port must be between 1 and 65535');
82
+ process.exit(1);
83
+ }
84
+ flags.port = p;
85
+ break;
86
+ }
87
+ case '--json': flags.json = true; break;
88
+ case '--rendered': flags.rendered = true; break;
89
+ case '--folder': flags.folder = args[++i]; break;
90
+ case '--force': flags.force = true; break;
91
+ case '-h': case '--help': flags.help = true; break;
92
+ default:
93
+ if (a.startsWith('-')) {
94
+ console.error(`Unknown option: ${a}`);
95
+ process.exit(1);
96
+ }
97
+ positional.push(a);
98
+ }
99
+ i++;
100
+ }
101
+ return { flags, positional };
102
+ }
103
+
104
+ async function openDb(flags) {
105
+ const dataDir = resolveDataDir(flags.dataDir);
106
+ const pepper = await loadDbKey(dataDir, flags.passphrase);
107
+ const db = openMountIndexDb(dataDir, pepper, { readonly: true });
108
+ return { db, dataDir };
109
+ }
110
+
111
+ function requireMount(db, id) {
112
+ const row = db.prepare('SELECT * FROM doc_mount_points WHERE id = ?').get(id);
113
+ if (!row) {
114
+ console.error(`No mount point found with id ${id}`);
115
+ process.exit(1);
116
+ }
117
+ return row;
118
+ }
119
+
120
+ function formatBytes(n) {
121
+ if (n < 1024) return `${n} B`;
122
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
123
+ if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
124
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
125
+ }
126
+
127
+ // ----------------------------------------------------------------------------
128
+ // list
129
+ // ----------------------------------------------------------------------------
130
+
131
+ async function handleList(flags) {
132
+ const { db } = await openDb(flags);
133
+ try {
134
+ const rows = db.prepare(`
135
+ SELECT id, name, mountType, storeType, basePath, enabled,
136
+ fileCount, chunkCount, totalSizeBytes, scanStatus
137
+ FROM doc_mount_points
138
+ ORDER BY name COLLATE NOCASE
139
+ `).all();
140
+ if (flags.json) {
141
+ process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
142
+ return;
143
+ }
144
+ if (rows.length === 0) {
145
+ console.log('(no mount points)');
146
+ return;
147
+ }
148
+ const display = rows.map(r => ({
149
+ id: r.id,
150
+ name: r.name,
151
+ type: r.mountType,
152
+ store: r.storeType,
153
+ enabled: r.enabled ? 'yes' : 'no',
154
+ files: r.fileCount,
155
+ chunks: r.chunkCount,
156
+ size: formatBytes(r.totalSizeBytes || 0),
157
+ status: r.scanStatus,
158
+ }));
159
+ console.table(display);
160
+ } finally {
161
+ db.close();
162
+ }
163
+ }
164
+
165
+ // ----------------------------------------------------------------------------
166
+ // show
167
+ // ----------------------------------------------------------------------------
168
+
169
+ async function handleShow(flags, id) {
170
+ if (!id) {
171
+ console.error('Usage: quilltap docs show <mount-id>');
172
+ process.exit(1);
173
+ }
174
+ const { db } = await openDb(flags);
175
+ try {
176
+ const mount = requireMount(db, id);
177
+ const fileCount = db.prepare('SELECT COUNT(*) AS n FROM doc_mount_files WHERE mountPointId = ?').get(id).n;
178
+ const chunkCount = db.prepare('SELECT COUNT(*) AS n FROM doc_mount_chunks WHERE mountPointId = ?').get(id).n;
179
+ const blobCount = db.prepare('SELECT COUNT(*) AS n FROM doc_mount_blobs WHERE mountPointId = ?').get(id).n;
180
+ const docCount = db.prepare('SELECT COUNT(*) AS n FROM doc_mount_documents WHERE mountPointId = ?').get(id).n;
181
+
182
+ const liveCounts = {
183
+ filesActual: fileCount,
184
+ chunksActual: chunkCount,
185
+ blobsActual: blobCount,
186
+ documentsActual: docCount,
187
+ };
188
+
189
+ if (flags.json) {
190
+ process.stdout.write(JSON.stringify({ ...mount, ...liveCounts }, null, 2) + '\n');
191
+ return;
192
+ }
193
+
194
+ console.log(`${BOLD}${mount.name}${RESET} ${DIM}${mount.id}${RESET}`);
195
+ console.log(` Type: ${mount.mountType} / ${mount.storeType}`);
196
+ console.log(` Base path: ${mount.basePath || DIM + '(database-backed)' + RESET}`);
197
+ console.log(` Enabled: ${mount.enabled ? GREEN + 'yes' + RESET : RED + 'no' + RESET}`);
198
+ console.log(` Scan status: ${mount.scanStatus}${mount.lastScanError ? ' ' + RED + mount.lastScanError + RESET : ''}`);
199
+ console.log(` Conversion: ${mount.conversionStatus}${mount.conversionError ? ' ' + RED + mount.conversionError + RESET : ''}`);
200
+ console.log(` Last scanned: ${mount.lastScannedAt || DIM + 'never' + RESET}`);
201
+ console.log(` Cached counts: ${mount.fileCount} files, ${mount.chunkCount} chunks, ${formatBytes(mount.totalSizeBytes || 0)}`);
202
+ console.log(` Live counts: ${fileCount} files, ${chunkCount} chunks, ${blobCount} blobs, ${docCount} db-docs`);
203
+ if (fileCount !== mount.fileCount || chunkCount !== mount.chunkCount) {
204
+ console.log(` ${YELLOW}Note: cached counts disagree with live counts; consider 'docs scan'${RESET}`);
205
+ }
206
+ console.log(` Created: ${mount.createdAt}`);
207
+ console.log(` Updated: ${mount.updatedAt}`);
208
+ } finally {
209
+ db.close();
210
+ }
211
+ }
212
+
213
+ // ----------------------------------------------------------------------------
214
+ // files
215
+ // ----------------------------------------------------------------------------
216
+
217
+ async function handleFiles(flags, id) {
218
+ if (!id) {
219
+ console.error('Usage: quilltap docs files <mount-id> [--folder <path>]');
220
+ process.exit(1);
221
+ }
222
+ const { db } = await openDb(flags);
223
+ try {
224
+ requireMount(db, id);
225
+ let rows;
226
+ if (flags.folder) {
227
+ const prefix = flags.folder.replace(/\/+$/, '') + '/';
228
+ rows = db.prepare(`
229
+ SELECT relativePath, fileType, source, fileSizeBytes, chunkCount, conversionStatus
230
+ FROM doc_mount_files
231
+ WHERE mountPointId = ? AND relativePath LIKE ?
232
+ ORDER BY relativePath
233
+ `).all(id, prefix + '%');
234
+ } else {
235
+ rows = db.prepare(`
236
+ SELECT relativePath, fileType, source, fileSizeBytes, chunkCount, conversionStatus
237
+ FROM doc_mount_files
238
+ WHERE mountPointId = ?
239
+ ORDER BY relativePath
240
+ `).all(id);
241
+ }
242
+ if (flags.json) {
243
+ process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
244
+ return;
245
+ }
246
+ if (rows.length === 0) {
247
+ console.log('(no files)');
248
+ return;
249
+ }
250
+ const display = rows.map(r => ({
251
+ relativePath: r.relativePath,
252
+ type: r.fileType,
253
+ source: r.source,
254
+ size: formatBytes(r.fileSizeBytes || 0),
255
+ chunks: r.chunkCount,
256
+ status: r.conversionStatus,
257
+ }));
258
+ console.table(display);
259
+ } finally {
260
+ db.close();
261
+ }
262
+ }
263
+
264
+ // ----------------------------------------------------------------------------
265
+ // read
266
+ // ----------------------------------------------------------------------------
267
+
268
+ function isBinaryFileType(fileType) {
269
+ return BINARY_FILE_TYPES.has(fileType);
270
+ }
271
+
272
+ function ttyGuard(fileType, flags, label) {
273
+ if (!process.stdout.isTTY) return;
274
+ if (!isBinaryFileType(fileType)) return;
275
+ if (flags.force) return;
276
+ console.error(`${label} is binary (${fileType}); redirect to a file (e.g. > out.${fileType}) or pass --force.`);
277
+ process.exit(1);
278
+ }
279
+
280
+ async function handleRead(flags, id, relativePath) {
281
+ if (!id || !relativePath) {
282
+ console.error('Usage: quilltap docs read [--rendered] <mount-id> <relativePath>');
283
+ process.exit(1);
284
+ }
285
+
286
+ const { db } = await openDb(flags);
287
+ try {
288
+ const mount = requireMount(db, id);
289
+
290
+ if (flags.rendered) {
291
+ return readRendered(db, mount, relativePath);
292
+ }
293
+ return readRaw(db, mount, relativePath, flags);
294
+ } finally {
295
+ db.close();
296
+ }
297
+ }
298
+
299
+ function readRaw(db, mount, relativePath, flags) {
300
+ const file = db.prepare(`
301
+ SELECT id, source, fileType
302
+ FROM doc_mount_files
303
+ WHERE mountPointId = ? AND relativePath = ?
304
+ `).get(mount.id, relativePath);
305
+
306
+ if (file) {
307
+ ttyGuard(file.fileType, flags, relativePath);
308
+
309
+ if (file.source === 'filesystem') {
310
+ const fullPath = path.join(mount.basePath, relativePath);
311
+ if (!fs.existsSync(fullPath)) {
312
+ console.error(`File missing on disk: ${fullPath}`);
313
+ process.exit(1);
314
+ }
315
+ // Stream so we don't load huge files into memory.
316
+ const stream = fs.createReadStream(fullPath);
317
+ stream.pipe(process.stdout);
318
+ return new Promise((resolve, reject) => {
319
+ stream.on('end', resolve);
320
+ stream.on('error', reject);
321
+ });
322
+ }
323
+
324
+ if (file.source === 'database') {
325
+ if (TEXT_FILE_TYPES.has(file.fileType)) {
326
+ const doc = db.prepare(`
327
+ SELECT content FROM doc_mount_documents
328
+ WHERE mountPointId = ? AND relativePath = ?
329
+ `).get(mount.id, relativePath);
330
+ if (!doc) {
331
+ console.error(`File row exists but no document content for ${relativePath}`);
332
+ process.exit(1);
333
+ }
334
+ process.stdout.write(doc.content);
335
+ return;
336
+ }
337
+ // Binary stored in doc_mount_blobs
338
+ const blob = db.prepare(`
339
+ SELECT data FROM doc_mount_blobs
340
+ WHERE mountPointId = ? AND relativePath = ?
341
+ `).get(mount.id, relativePath);
342
+ if (!blob) {
343
+ console.error(`File row exists but no blob bytes for ${relativePath}`);
344
+ process.exit(1);
345
+ }
346
+ process.stdout.write(blob.data);
347
+ return;
348
+ }
349
+
350
+ console.error(`Unknown file source: ${file.source}`);
351
+ process.exit(1);
352
+ }
353
+
354
+ // No row in doc_mount_files — try blobs directly (some binaries may not be mirrored).
355
+ const blob = db.prepare(`
356
+ SELECT data, originalMimeType FROM doc_mount_blobs
357
+ WHERE mountPointId = ? AND relativePath = ?
358
+ `).get(mount.id, relativePath);
359
+ if (blob) {
360
+ ttyGuard('blob', flags, relativePath);
361
+ process.stdout.write(blob.data);
362
+ return;
363
+ }
364
+
365
+ console.error(`No file at ${relativePath} in mount ${mount.name}`);
366
+ process.exit(1);
367
+ }
368
+
369
+ function readRendered(db, mount, relativePath) {
370
+ // 1. Blob with extractedText wins.
371
+ const blob = db.prepare(`
372
+ SELECT extractedText FROM doc_mount_blobs
373
+ WHERE mountPointId = ? AND relativePath = ?
374
+ `).get(mount.id, relativePath);
375
+ if (blob && blob.extractedText) {
376
+ process.stdout.write(blob.extractedText);
377
+ return;
378
+ }
379
+
380
+ // 2. Look up the file row.
381
+ const file = db.prepare(`
382
+ SELECT id, source, fileType
383
+ FROM doc_mount_files
384
+ WHERE mountPointId = ? AND relativePath = ?
385
+ `).get(mount.id, relativePath);
386
+
387
+ if (!file) {
388
+ console.error(`No file at ${relativePath} in mount ${mount.name}`);
389
+ process.exit(1);
390
+ }
391
+
392
+ // 3. Database-backed text doc — content IS the rendered form.
393
+ if (file.source === 'database' && TEXT_FILE_TYPES.has(file.fileType)) {
394
+ const doc = db.prepare(`
395
+ SELECT content FROM doc_mount_documents
396
+ WHERE mountPointId = ? AND relativePath = ?
397
+ `).get(mount.id, relativePath);
398
+ if (doc) {
399
+ process.stdout.write(doc.content);
400
+ return;
401
+ }
402
+ }
403
+
404
+ // 4. Plain-text filesystem files render to themselves.
405
+ if (file.source === 'filesystem' && TEXT_FILE_TYPES.has(file.fileType)) {
406
+ const fullPath = path.join(mount.basePath, relativePath);
407
+ if (fs.existsSync(fullPath)) {
408
+ process.stdout.write(fs.readFileSync(fullPath, 'utf8'));
409
+ return;
410
+ }
411
+ }
412
+
413
+ // 5. Fall back to concatenated chunks.
414
+ const chunks = db.prepare(`
415
+ SELECT content FROM doc_mount_chunks
416
+ WHERE fileId = ?
417
+ ORDER BY chunkIndex
418
+ `).all(file.id);
419
+ if (chunks.length === 0) {
420
+ console.error(`No rendered text available for ${relativePath}. Run 'quilltap docs scan ${mount.id}' to extract and embed.`);
421
+ process.exit(1);
422
+ }
423
+ process.stdout.write(chunks.map(c => c.content).join('\n\n'));
424
+ }
425
+
426
+ // ----------------------------------------------------------------------------
427
+ // export
428
+ // ----------------------------------------------------------------------------
429
+
430
+ async function handleExport(flags, id, outputDir) {
431
+ if (!id || !outputDir) {
432
+ console.error('Usage: quilltap docs export <mount-id> <outputDir>');
433
+ process.exit(1);
434
+ }
435
+
436
+ const resolvedOut = outputDir.startsWith('~')
437
+ ? path.join(require('os').homedir(), outputDir.slice(1))
438
+ : path.resolve(outputDir);
439
+
440
+ if (!fs.existsSync(resolvedOut)) {
441
+ fs.mkdirSync(resolvedOut, { recursive: true });
442
+ } else {
443
+ const stat = fs.statSync(resolvedOut);
444
+ if (!stat.isDirectory()) {
445
+ console.error(`Output path exists and is not a directory: ${resolvedOut}`);
446
+ process.exit(1);
447
+ }
448
+ }
449
+
450
+ const { db } = await openDb(flags);
451
+ let writtenFiles = 0;
452
+ let writtenBytes = 0;
453
+ const writtenPaths = new Set();
454
+ try {
455
+ const mount = requireMount(db, id);
456
+
457
+ const files = db.prepare(`
458
+ SELECT id, relativePath, source, fileType
459
+ FROM doc_mount_files
460
+ WHERE mountPointId = ?
461
+ ORDER BY relativePath
462
+ `).all(id);
463
+
464
+ for (const file of files) {
465
+ const dest = path.join(resolvedOut, file.relativePath);
466
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
467
+
468
+ if (file.source === 'filesystem') {
469
+ const src = path.join(mount.basePath, file.relativePath);
470
+ if (!fs.existsSync(src)) {
471
+ console.error(`${YELLOW}skip${RESET} ${file.relativePath} (missing on disk: ${src})`);
472
+ continue;
473
+ }
474
+ fs.copyFileSync(src, dest);
475
+ writtenBytes += fs.statSync(dest).size;
476
+ } else if (file.source === 'database') {
477
+ if (TEXT_FILE_TYPES.has(file.fileType)) {
478
+ const doc = db.prepare(`
479
+ SELECT content FROM doc_mount_documents
480
+ WHERE mountPointId = ? AND relativePath = ?
481
+ `).get(id, file.relativePath);
482
+ if (!doc) {
483
+ console.error(`${YELLOW}skip${RESET} ${file.relativePath} (no document content)`);
484
+ continue;
485
+ }
486
+ fs.writeFileSync(dest, doc.content, 'utf8');
487
+ writtenBytes += Buffer.byteLength(doc.content, 'utf8');
488
+ } else {
489
+ const blob = db.prepare(`
490
+ SELECT data FROM doc_mount_blobs
491
+ WHERE mountPointId = ? AND relativePath = ?
492
+ `).get(id, file.relativePath);
493
+ if (!blob) {
494
+ console.error(`${YELLOW}skip${RESET} ${file.relativePath} (no blob bytes)`);
495
+ continue;
496
+ }
497
+ fs.writeFileSync(dest, blob.data);
498
+ writtenBytes += blob.data.length;
499
+ }
500
+ } else {
501
+ console.error(`${YELLOW}skip${RESET} ${file.relativePath} (unknown source: ${file.source})`);
502
+ continue;
503
+ }
504
+ writtenFiles += 1;
505
+ writtenPaths.add(file.relativePath);
506
+ }
507
+
508
+ // Catch any blobs not mirrored into doc_mount_files (defensive).
509
+ const blobs = db.prepare(`
510
+ SELECT relativePath, data
511
+ FROM doc_mount_blobs
512
+ WHERE mountPointId = ?
513
+ `).all(id);
514
+ for (const blob of blobs) {
515
+ if (writtenPaths.has(blob.relativePath)) continue;
516
+ const dest = path.join(resolvedOut, blob.relativePath);
517
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
518
+ fs.writeFileSync(dest, blob.data);
519
+ writtenBytes += blob.data.length;
520
+ writtenFiles += 1;
521
+ }
522
+
523
+ console.log(`${GREEN}Exported${RESET} ${writtenFiles} file(s), ${formatBytes(writtenBytes)} → ${resolvedOut}`);
524
+ } finally {
525
+ db.close();
526
+ }
527
+ }
528
+
529
+ // ----------------------------------------------------------------------------
530
+ // scan
531
+ // ----------------------------------------------------------------------------
532
+
533
+ async function handleScan(flags, id) {
534
+ if (!id) {
535
+ console.error('Usage: quilltap docs scan <mount-id>');
536
+ process.exit(1);
537
+ }
538
+ const url = `http://localhost:${flags.port}/api/v1/mount-points/${encodeURIComponent(id)}?action=scan`;
539
+ let res;
540
+ try {
541
+ res = await fetch(url, { method: 'POST' });
542
+ } catch (err) {
543
+ console.error(`Could not reach Quilltap server at http://localhost:${flags.port}: ${err.message}`);
544
+ console.error('Start the server with `quilltap` (or pass --port to match a non-default port).');
545
+ process.exit(1);
546
+ }
547
+ let body;
548
+ try {
549
+ body = await res.json();
550
+ } catch {
551
+ console.error(`Server returned status ${res.status} with no JSON body`);
552
+ process.exit(1);
553
+ }
554
+ if (!res.ok) {
555
+ console.error(`Scan failed (${res.status}): ${body && body.error ? body.error : JSON.stringify(body)}`);
556
+ process.exit(1);
557
+ }
558
+
559
+ if (flags.json) {
560
+ process.stdout.write(JSON.stringify(body, null, 2) + '\n');
561
+ return;
562
+ }
563
+
564
+ const data = body.data || body;
565
+ const r = data.scanResult || {};
566
+ console.log(`${GREEN}Scan complete${RESET}`);
567
+ console.log(` Files scanned: ${r.filesScanned ?? '?'}`);
568
+ console.log(` New: ${r.filesNew ?? '?'}`);
569
+ console.log(` Modified: ${r.filesModified ?? '?'}`);
570
+ console.log(` Deleted: ${r.filesDeleted ?? '?'}`);
571
+ console.log(` Chunks created: ${r.chunksCreated ?? '?'}`);
572
+ console.log(` Errors: ${(r.errors && r.errors.length) || 0}`);
573
+ console.log(` Embed jobs: ${data.embeddingJobsEnqueued ?? '?'}`);
574
+ }
575
+
576
+ // ----------------------------------------------------------------------------
577
+ // dispatch
578
+ // ----------------------------------------------------------------------------
579
+
580
+ async function docsCommand(args) {
581
+ if (args.length === 0) {
582
+ printDocsHelp();
583
+ process.exit(1);
584
+ }
585
+ if (args[0] === '-h' || args[0] === '--help') {
586
+ printDocsHelp();
587
+ process.exit(0);
588
+ }
589
+
590
+ const verb = args[0];
591
+ const { flags, positional } = parseFlags(args.slice(1));
592
+
593
+ if (flags.help) {
594
+ printDocsHelp();
595
+ process.exit(0);
596
+ }
597
+
598
+ try {
599
+ switch (verb) {
600
+ case 'list':
601
+ await handleList(flags);
602
+ break;
603
+ case 'show':
604
+ await handleShow(flags, positional[0]);
605
+ break;
606
+ case 'files':
607
+ await handleFiles(flags, positional[0]);
608
+ break;
609
+ case 'read':
610
+ await handleRead(flags, positional[0], positional[1]);
611
+ break;
612
+ case 'export':
613
+ await handleExport(flags, positional[0], positional[1]);
614
+ break;
615
+ case 'scan':
616
+ await handleScan(flags, positional[0]);
617
+ break;
618
+ default:
619
+ console.error(`Unknown docs subcommand: ${verb}`);
620
+ printDocsHelp();
621
+ process.exit(1);
622
+ }
623
+ } catch (err) {
624
+ console.error(`Error: ${err.message}`);
625
+ process.exit(1);
626
+ }
627
+ }
628
+
629
+ module.exports = { docsCommand };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.3.0-dev.88",
3
+ "version": "4.3.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",