quilltap 4.5.0-dev → 4.5.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.
@@ -2,7 +2,13 @@
2
2
 
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
- const { resolveDataDir, loadDbKey, openMountIndexDb } = require('./db-helpers');
5
+ const crypto = require('crypto');
6
+ const {
7
+ resolveDataDirAndPassphrase,
8
+ printDefaultInstanceHint,
9
+ loadDbKey,
10
+ openMountIndexDb,
11
+ } = require('./db-helpers');
6
12
 
7
13
  const RESET = '\x1b[0m';
8
14
  const BOLD = '\x1b[1m';
@@ -15,58 +21,159 @@ const CYAN = '\x1b[36m';
15
21
  const TEXT_FILE_TYPES = new Set(['markdown', 'txt', 'json', 'jsonl']);
16
22
  const BINARY_FILE_TYPES = new Set(['pdf', 'docx', 'blob']);
17
23
 
24
+ // Single-character markers for the `ls` "text" column:
25
+ // = raw bytes are already textual (markdown/txt/json/jsonl)
26
+ // T separately-extracted plaintext is stored on the link row
27
+ // ~ extraction queued or in progress
28
+ // ! extraction attempted and failed
29
+ // - no extracted text and the file is not text-native
30
+ function textColumnMarker(fileType, extractionStatus) {
31
+ if (TEXT_FILE_TYPES.has(fileType)) return '=';
32
+ switch (extractionStatus) {
33
+ case 'converted': return 'T';
34
+ case 'pending': return '~';
35
+ case 'failed': return '!';
36
+ case 'skipped':
37
+ case 'none':
38
+ default: return '-';
39
+ }
40
+ }
41
+
42
+ // Single-character markers for the `ls` "emb" column:
43
+ // Y every chunk on this file has an embedding
44
+ // ~ chunks exist but none / only some have an embedding (queued or partial)
45
+ // - no chunks at all
46
+ function embedColumnMarker(chunkCount, embeddedChunkCount) {
47
+ if (!chunkCount) return '-';
48
+ if (embeddedChunkCount === chunkCount) return 'Y';
49
+ return '~';
50
+ }
51
+
18
52
  function printDocsHelp() {
19
53
  console.log(`
20
54
  Quilltap Document Store Tool
21
55
 
22
56
  Usage: quilltap docs <subcommand> [options]
23
57
 
24
- Subcommands:
58
+ Read subcommands:
25
59
  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
60
+ show <mount> Details for one mount point
61
+ files <mount> [--folder <path>] List files in a mount
62
+ ls|dir <mount> [path] [options] ls-style listing of one folder (or file)
63
+ tree <mount> [path] [options] ASCII tree view of a folder hierarchy
64
+ read <mount> <relativePath> Print file contents to stdout
65
+ read --rendered <mount> <relativePath> Print extracted plaintext to stdout
66
+ export <mount> <outputDir> Export an entire mount to a directory
67
+ scan <mount> Trigger a rescan via the running server
68
+ find [--mount <name|id|all>] [--type file|folder] [--ext <ext>] [--limit N] <pattern>
69
+ Substring search on file (or folder) names
70
+ grep [--mount <name|id|all>] [--ignore-case] [-l] [--max N] [--context N] <pattern>
71
+ Substring search inside extracted text
72
+ status [--mount <name|id>] [--top N] Per-mount extraction + embedding rollup
73
+
74
+ Server-required subcommands (background-job queue lives in the running server):
75
+ reindex <mount> [path] [--force] Re-extract text + re-chunk affected files
76
+ embed <mount> [path] [--force] [--wait]
77
+ Enqueue embedding jobs for un-embedded chunks
78
+
79
+ Write subcommands (server required for database-backed mounts):
80
+ write [--force] <mount> <path> [file] Write a file from <file> or stdin
81
+ delete <mount> <path> Idempotent file delete
82
+ mkdir <mount> <path> Idempotent folder create
83
+ move <srcMount> <srcPath> <dstMount> <dstPath> Move file (hard-link when possible)
84
+ copy [--force] <srcMount> <srcPath> <dstMount> <dstPath> Copy file (hard-link unless --force)
85
+
86
+ <mount> may be a mount name or UUID. Names are case-insensitive; ambiguous
87
+ names print candidates and exit non-zero.
32
88
 
33
89
  Options:
34
90
  -d, --data-dir <path> Override data directory
91
+ -i, --instance <name> Use a registered instance (see 'quilltap instances')
35
92
  --passphrase <pass> Decrypt .dbkey if peppered
36
93
  --port <number> Server port for API calls (default: 3000)
37
- --json Machine-readable output (list/show/files/scan)
94
+ --json Machine-readable output
38
95
  --rendered For 'read': output extracted plaintext
39
96
  --folder <path> For 'files': narrow to a folder prefix
97
+ --recursive, -R For 'ls': list all files recursively, grouped by folder
98
+ --sort name|time|size|links For 'ls': sort by name (default), modification time,
99
+ file size, or hard-link count
100
+ -r, --reverse Reverse sort order
101
+ --links For 'ls' / 'dir': under each file with more than
102
+ one hard link, list the other mount/path entries
103
+ --depth N For 'tree': maximum nesting depth (default: 20)
104
+ --max-nodes N For 'tree': maximum nodes to render (default: 1000)
105
+ --long For 'tree': include text/emb columns (reserved for future)
40
106
  --force For 'read': dump binary to TTY anyway
107
+ For 'write': overwrite existing destination
108
+ For 'copy': overwrite + force a real byte copy
109
+ (skips the default hard-link path)
41
110
  -h, --help Show this help
42
111
 
43
112
  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.
113
+ database directly. Write operations talk to the running Quilltap server when
114
+ available (so reindex/embed kicks off automatically); they fall back to
115
+ filesystem-only writes when the server is down, and report what the index
116
+ will see after the next 'docs scan'.
117
+
118
+ Verification: every write computes a SHA-256 on both ends and compares them
119
+ before reporting success. Hard-linked files match trivially.
46
120
 
47
121
  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>
122
+ quilltap docs ls notes
123
+ quilltap docs ls notes 2026/may --links
124
+ quilltap docs dir notes today.md --json
125
+ quilltap docs write notes today.md < draft.md
126
+ quilltap docs write --force notes today.md draft.md
127
+ quilltap docs delete notes today.md
128
+ quilltap docs mkdir notes 2026/may
129
+ quilltap docs move drafts foo.md notes 2026/foo.md
130
+ quilltap docs copy notes today.md archive 2026-05/today.md
131
+ quilltap docs copy --force notes today.md archive copy.md
132
+ quilltap docs find Manifesto
133
+ quilltap docs find --mount notes --ext md Knowledge
134
+ quilltap docs grep --mount notes --ignore-case "five-point Calvinist"
135
+ quilltap docs grep --mount notes -l "TODO"
136
+ quilltap docs status
137
+ quilltap docs status --mount notes --top 10
138
+ quilltap docs reindex notes Knowledge --force
139
+ quilltap docs embed notes --wait
57
140
  `);
58
141
  }
59
142
 
60
143
  function parseFlags(args) {
61
144
  const flags = {
62
145
  dataDir: '',
146
+ instance: '',
63
147
  passphrase: '',
64
148
  port: 3000,
65
149
  json: false,
66
150
  rendered: false,
67
151
  folder: '',
68
152
  force: false,
153
+ links: false,
69
154
  help: false,
155
+ // find / grep / reindex / embed / status flags
156
+ mount: '',
157
+ type: '',
158
+ ext: '',
159
+ limit: 0,
160
+ max: 0,
161
+ context: 0,
162
+ top: -1,
163
+ ignoreCase: false,
164
+ pathsOnly: false,
165
+ wait: false,
166
+ // ls flags: recursive, sort, reverse
167
+ recursive: false,
168
+ sort: 'name',
169
+ reverse: false,
170
+ // tree flags: depth, max-nodes, long
171
+ depth: 20,
172
+ maxNodes: 1000,
173
+ long: false,
174
+ // semantic search
175
+ semantic: false,
176
+ threshold: -1,
70
177
  };
71
178
  const positional = [];
72
179
  let i = 0;
@@ -74,6 +181,7 @@ function parseFlags(args) {
74
181
  const a = args[i];
75
182
  switch (a) {
76
183
  case '-d': case '--data-dir': flags.dataDir = args[++i]; break;
184
+ case '-i': case '--instance': flags.instance = args[++i]; break;
77
185
  case '--passphrase': flags.passphrase = args[++i]; break;
78
186
  case '--port': {
79
187
  const p = parseInt(args[++i], 10);
@@ -88,6 +196,38 @@ function parseFlags(args) {
88
196
  case '--rendered': flags.rendered = true; break;
89
197
  case '--folder': flags.folder = args[++i]; break;
90
198
  case '--force': flags.force = true; break;
199
+ case '--links': flags.links = true; break;
200
+ case '--mount': flags.mount = args[++i]; break;
201
+ case '--type': flags.type = args[++i]; break;
202
+ case '--ext': flags.ext = args[++i]; break;
203
+ case '--limit': flags.limit = parseInt(args[++i], 10) || 0; break;
204
+ case '--max': flags.max = parseInt(args[++i], 10) || 0; break;
205
+ case '--context': flags.context = parseInt(args[++i], 10) || 0; break;
206
+ case '--top': {
207
+ const n = parseInt(args[++i], 10);
208
+ flags.top = isNaN(n) ? -1 : n;
209
+ break;
210
+ }
211
+ case '--ignore-case': flags.ignoreCase = true; break;
212
+ case '-l': flags.pathsOnly = true; break;
213
+ case '--wait': flags.wait = true; break;
214
+ case '-R': case '--recursive': flags.recursive = true; break;
215
+ case '--sort': flags.sort = args[++i] || 'name'; break;
216
+ case '-r': case '--reverse': flags.reverse = true; break;
217
+ case '--depth': flags.depth = parseInt(args[++i], 10) || 20; break;
218
+ case '--max-nodes': flags.maxNodes = parseInt(args[++i], 10) || 1000; break;
219
+ case '--long': flags.long = true; break;
220
+ case '--names-only': flags.namesOnly = true; break;
221
+ case '--semantic': flags.semantic = true; break;
222
+ case '--threshold': {
223
+ const v = parseFloat(args[++i]);
224
+ if (isNaN(v) || v < 0 || v > 1) {
225
+ console.error('Error: --threshold must be a number between 0 and 1');
226
+ process.exit(1);
227
+ }
228
+ flags.threshold = v;
229
+ break;
230
+ }
91
231
  case '-h': case '--help': flags.help = true; break;
92
232
  default:
93
233
  if (a.startsWith('-')) {
@@ -102,19 +242,94 @@ function parseFlags(args) {
102
242
  }
103
243
 
104
244
  async function openDb(flags) {
105
- const dataDir = resolveDataDir(flags.dataDir);
106
- const pepper = await loadDbKey(dataDir, flags.passphrase);
245
+ const resolved = resolveDataDirAndPassphrase({
246
+ dataDir: flags.dataDir,
247
+ instance: flags.instance,
248
+ passphrase: flags.passphrase,
249
+ });
250
+ printDefaultInstanceHint(resolved);
251
+ const { dataDir, passphrase } = resolved;
252
+ const pepper = await loadDbKey(dataDir, passphrase);
107
253
  const db = openMountIndexDb(dataDir, pepper, { readonly: true });
254
+ assertDocsSchema(db, dataDir);
108
255
  return { db, dataDir };
109
256
  }
110
257
 
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}`);
258
+ // Every docs subcommand assumes the post-link-table mount-index schema
259
+ // (doc_mount_file_links plus the columns it expects on neighbouring tables).
260
+ // Older instances opened by an out-of-date CLI — or, more often, by a fresh
261
+ // CLI pointed at an instance that hasn't been booted since the migration
262
+ // landed — fail deep inside a prepared statement with `no such table:
263
+ // doc_mount_file_links`. Catch the missing table up front and tell the
264
+ // operator exactly what to do.
265
+ function assertDocsSchema(db, dataDir) {
266
+ const row = db.prepare(
267
+ "SELECT 1 AS ok FROM sqlite_master WHERE type='table' AND name='doc_mount_file_links'"
268
+ ).get();
269
+ if (row && row.ok === 1) return;
270
+
271
+ db.close();
272
+
273
+ let registered = [];
274
+ try {
275
+ const { listInstances } = require('./instances');
276
+ registered = listInstances();
277
+ } catch {
278
+ // Registry unreadable or absent — keep going with an empty list.
279
+ }
280
+
281
+ const lines = [
282
+ 'Error: this instance has not been migrated to the post-link-table mount-index schema.',
283
+ ` Database: ${path.join(dataDir, 'quilltap-mount-index.db')}`,
284
+ ' Missing table: doc_mount_file_links',
285
+ '',
286
+ 'Start Quilltap against this data directory once (npm run dev / npx quilltap)',
287
+ 'to run the pending migrations, or target a different instance:',
288
+ ];
289
+ if (registered.length > 0) {
290
+ for (const inst of registered) {
291
+ lines.push(` --instance ${inst.name} (${inst.path})`);
292
+ }
293
+ } else {
294
+ lines.push(' (no instances registered — see `quilltap instances --help`)');
295
+ }
296
+ process.stderr.write(lines.join('\n') + '\n');
297
+ process.exit(1);
298
+ }
299
+
300
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
301
+
302
+ function requireMount(db, spec) {
303
+ if (!spec) {
304
+ console.error('Error: mount name or id is required');
305
+ process.exit(1);
306
+ }
307
+ if (UUID_RE.test(spec)) {
308
+ const row = db.prepare('SELECT * FROM doc_mount_points WHERE id = ?').get(spec);
309
+ if (!row) {
310
+ console.error(`No mount point found with id ${spec}`);
311
+ process.exit(1);
312
+ }
313
+ return row;
314
+ }
315
+ const rows = db.prepare(
316
+ `SELECT * FROM doc_mount_points
317
+ WHERE LOWER(name) = LOWER(?)
318
+ ORDER BY name COLLATE NOCASE`
319
+ ).all(spec);
320
+ if (rows.length === 0) {
321
+ console.error(`No mount point found with name "${spec}"`);
322
+ process.exit(1);
323
+ }
324
+ if (rows.length > 1) {
325
+ console.error(`Ambiguous mount name "${spec}" matches multiple mounts:`);
326
+ for (const r of rows) {
327
+ console.error(` ${r.id} ${r.name} (${r.mountType})`);
328
+ }
329
+ console.error('Pass the UUID instead.');
115
330
  process.exit(1);
116
331
  }
117
- return row;
332
+ return rows[0];
118
333
  }
119
334
 
120
335
  function formatBytes(n) {
@@ -137,6 +352,13 @@ async function handleList(flags) {
137
352
  FROM doc_mount_points
138
353
  ORDER BY name COLLATE NOCASE
139
354
  `).all();
355
+ if (flags.namesOnly) {
356
+ // Hidden flag for completion: print one name per line
357
+ for (const row of rows) {
358
+ console.log(row.name);
359
+ }
360
+ return;
361
+ }
140
362
  if (flags.json) {
141
363
  process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
142
364
  return;
@@ -174,10 +396,22 @@ async function handleShow(flags, id) {
174
396
  const { db } = await openDb(flags);
175
397
  try {
176
398
  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;
399
+ const fileCount = db.prepare(
400
+ 'SELECT COUNT(*) AS n FROM doc_mount_file_links WHERE mountPointId = ?'
401
+ ).get(mount.id).n;
402
+ const chunkCount = db.prepare(
403
+ 'SELECT COUNT(*) AS n FROM doc_mount_chunks WHERE mountPointId = ?'
404
+ ).get(mount.id).n;
405
+ const blobCount = db.prepare(`
406
+ SELECT COUNT(*) AS n FROM doc_mount_file_links l
407
+ JOIN doc_mount_blobs b ON b.fileId = l.fileId
408
+ WHERE l.mountPointId = ?
409
+ `).get(mount.id).n;
410
+ const docCount = db.prepare(`
411
+ SELECT COUNT(*) AS n FROM doc_mount_file_links l
412
+ JOIN doc_mount_documents d ON d.fileId = l.fileId
413
+ WHERE l.mountPointId = ?
414
+ `).get(mount.id).n;
181
415
 
182
416
  const liveCounts = {
183
417
  filesActual: fileCount,
@@ -221,23 +455,27 @@ async function handleFiles(flags, id) {
221
455
  }
222
456
  const { db } = await openDb(flags);
223
457
  try {
224
- requireMount(db, id);
458
+ const mount = requireMount(db, id);
225
459
  let rows;
226
460
  if (flags.folder) {
227
461
  const prefix = flags.folder.replace(/\/+$/, '') + '/';
228
462
  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 + '%');
463
+ SELECT l.relativePath, f.fileType, f.source, f.fileSizeBytes,
464
+ l.chunkCount, l.conversionStatus
465
+ FROM doc_mount_file_links l
466
+ JOIN doc_mount_files f ON f.id = l.fileId
467
+ WHERE l.mountPointId = ? AND l.relativePath LIKE ?
468
+ ORDER BY l.relativePath
469
+ `).all(mount.id, prefix + '%');
234
470
  } else {
235
471
  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);
472
+ SELECT l.relativePath, f.fileType, f.source, f.fileSizeBytes,
473
+ l.chunkCount, l.conversionStatus
474
+ FROM doc_mount_file_links l
475
+ JOIN doc_mount_files f ON f.id = l.fileId
476
+ WHERE l.mountPointId = ?
477
+ ORDER BY l.relativePath
478
+ `).all(mount.id);
241
479
  }
242
480
  if (flags.json) {
243
481
  process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
@@ -261,6 +499,567 @@ async function handleFiles(flags, id) {
261
499
  }
262
500
  }
263
501
 
502
+ // ----------------------------------------------------------------------------
503
+ // ls / dir
504
+ // ----------------------------------------------------------------------------
505
+
506
+ function normalizeLsPath(p) {
507
+ if (!p) return '';
508
+ // Strip leading/trailing slashes; treat '.' and '/' as root.
509
+ const trimmed = p.replace(/^\/+|\/+$/g, '');
510
+ if (trimmed === '.' || trimmed === '') return '';
511
+ return trimmed;
512
+ }
513
+
514
+ function formatLsDate(iso) {
515
+ if (!iso) return '-';
516
+ const d = new Date(iso);
517
+ if (isNaN(d.getTime())) return iso.slice(0, 16);
518
+ const pad = (n) => String(n).padStart(2, '0');
519
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
520
+ }
521
+
522
+ const LS_FILE_COLUMNS = `
523
+ l.id AS linkId, l.fileId, l.relativePath, l.fileName, l.lastModified,
524
+ l.extractionStatus, l.extractedTextSha256, l.chunkCount,
525
+ f.fileType, f.fileSizeBytes, f.source, f.sha256,
526
+ (SELECT COUNT(*) FROM doc_mount_file_links WHERE fileId = l.fileId) AS linkCount,
527
+ (SELECT COUNT(*) FROM doc_mount_chunks
528
+ WHERE linkId = l.id AND embedding IS NOT NULL) AS embeddedChunkCount
529
+ `;
530
+
531
+ function resolveLsTarget(db, mountId, normalizedPath) {
532
+ if (!normalizedPath) return { kind: 'root', path: '' };
533
+
534
+ // Exact file match wins — handles the single-file display mode.
535
+ const file = db.prepare(`
536
+ SELECT ${LS_FILE_COLUMNS}
537
+ FROM doc_mount_file_links l
538
+ JOIN doc_mount_files f ON f.id = l.fileId
539
+ WHERE l.mountPointId = ? AND l.relativePath = ?
540
+ `).get(mountId, normalizedPath);
541
+ if (file) return { kind: 'file', file };
542
+
543
+ // Explicit folder row.
544
+ const folder = db.prepare(
545
+ `SELECT id, name, path, parentId, createdAt, updatedAt
546
+ FROM doc_mount_folders WHERE mountPointId = ? AND path = ?`
547
+ ).get(mountId, normalizedPath);
548
+ if (folder) return { kind: 'folder', folder, path: normalizedPath };
549
+
550
+ // Implicit folder — files or subfolders live under this path even though
551
+ // no doc_mount_folders row exists for it (or the folderId on links is
552
+ // null due to upstream drift). Mirrors how `docs files --folder` matches.
553
+ const hasFiles = db.prepare(`
554
+ SELECT 1 FROM doc_mount_file_links
555
+ WHERE mountPointId = ? AND relativePath LIKE ? LIMIT 1
556
+ `).get(mountId, normalizedPath + '/%');
557
+ const hasFolders = db.prepare(`
558
+ SELECT 1 FROM doc_mount_folders
559
+ WHERE mountPointId = ? AND path LIKE ? LIMIT 1
560
+ `).get(mountId, normalizedPath + '/%');
561
+ if (hasFiles || hasFolders) return { kind: 'folder', folder: null, path: normalizedPath };
562
+
563
+ return { kind: 'none' };
564
+ }
565
+
566
+ function fetchLsRows(db, mountId, parentPath) {
567
+ // parentPath: '' = mount root, else "Knowledge" or "foo/bar" etc.
568
+ // We filter by path-prefix rather than folderId / parentId so we stay
569
+ // honest about what the filesystem (or docs read/files) actually sees,
570
+ // even when folderId on a link row drifts to NULL behind our back.
571
+ const folders = parentPath === ''
572
+ ? db.prepare(`
573
+ SELECT id, name, path, createdAt, updatedAt
574
+ FROM doc_mount_folders
575
+ WHERE mountPointId = ?
576
+ AND path NOT LIKE '%/%'
577
+ ORDER BY name COLLATE NOCASE
578
+ `).all(mountId)
579
+ : db.prepare(`
580
+ SELECT id, name, path, createdAt, updatedAt
581
+ FROM doc_mount_folders
582
+ WHERE mountPointId = ?
583
+ AND path LIKE ?
584
+ AND path NOT LIKE ?
585
+ ORDER BY name COLLATE NOCASE
586
+ `).all(mountId, parentPath + '/%', parentPath + '/%/%');
587
+
588
+ const files = parentPath === ''
589
+ ? db.prepare(`
590
+ SELECT ${LS_FILE_COLUMNS}
591
+ FROM doc_mount_file_links l
592
+ JOIN doc_mount_files f ON f.id = l.fileId
593
+ WHERE l.mountPointId = ?
594
+ AND l.relativePath NOT LIKE '%/%'
595
+ ORDER BY l.fileName COLLATE NOCASE
596
+ `).all(mountId)
597
+ : db.prepare(`
598
+ SELECT ${LS_FILE_COLUMNS}
599
+ FROM doc_mount_file_links l
600
+ JOIN doc_mount_files f ON f.id = l.fileId
601
+ WHERE l.mountPointId = ?
602
+ AND l.relativePath LIKE ?
603
+ AND l.relativePath NOT LIKE ?
604
+ ORDER BY l.fileName COLLATE NOCASE
605
+ `).all(mountId, parentPath + '/%', parentPath + '/%/%');
606
+
607
+ return { folders, files };
608
+ }
609
+
610
+ function fetchLinksForFiles(db, fileIds) {
611
+ if (fileIds.length === 0) return new Map();
612
+ const placeholders = fileIds.map(() => '?').join(',');
613
+ const rows = db.prepare(`
614
+ SELECT l.fileId, l.relativePath, l.mountPointId, m.name AS mountName
615
+ FROM doc_mount_file_links l
616
+ JOIN doc_mount_points m ON m.id = l.mountPointId
617
+ WHERE l.fileId IN (${placeholders})
618
+ ORDER BY m.name COLLATE NOCASE, l.relativePath COLLATE NOCASE
619
+ `).all(...fileIds);
620
+ const byFile = new Map();
621
+ for (const r of rows) {
622
+ if (!byFile.has(r.fileId)) byFile.set(r.fileId, []);
623
+ byFile.get(r.fileId).push({
624
+ mountPointId: r.mountPointId,
625
+ mountName: r.mountName,
626
+ relativePath: r.relativePath,
627
+ });
628
+ }
629
+ return byFile;
630
+ }
631
+
632
+ function sortLsFiles(files, sortType, reverse) {
633
+ const sorted = [...files];
634
+ switch (sortType) {
635
+ case 'name':
636
+ sorted.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' }));
637
+ break;
638
+ case 'time':
639
+ sorted.sort((a, b) => {
640
+ const aTime = new Date(a.lastModified || 0).getTime();
641
+ const bTime = new Date(b.lastModified || 0).getTime();
642
+ return bTime - aTime; // newest first by default
643
+ });
644
+ break;
645
+ case 'size':
646
+ sorted.sort((a, b) => (b.fileSizeBytes || 0) - (a.fileSizeBytes || 0)); // largest first
647
+ break;
648
+ case 'links':
649
+ sorted.sort((a, b) => (b.linkCount || 0) - (a.linkCount || 0)); // most linked first
650
+ break;
651
+ default:
652
+ sorted.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' }));
653
+ }
654
+ if (reverse) sorted.reverse();
655
+ return sorted;
656
+ }
657
+
658
+ async function handleLs(flags, mountSpec, rawPath) {
659
+ if (!mountSpec) {
660
+ console.error('Usage: quilltap docs ls <mount> [path] [--recursive|-R] [--sort name|time|size|links] [-r] [--links]');
661
+ process.exit(1);
662
+ }
663
+ const { db } = await openDb(flags);
664
+ try {
665
+ const mount = requireMount(db, mountSpec);
666
+ const normalizedPath = normalizeLsPath(rawPath);
667
+
668
+ let folders = [];
669
+ let files = [];
670
+ let singleFile = false;
671
+ let allFilesByFolder = {};
672
+
673
+ if (flags.recursive) {
674
+ // Recursive mode: fetch all files under the path prefix
675
+ const prefix = normalizedPath ? normalizedPath.replace(/\/+$/, '') + '/' : '';
676
+ const allFiles = normalizedPath
677
+ ? db.prepare(`
678
+ SELECT ${LS_FILE_COLUMNS}
679
+ FROM doc_mount_file_links l
680
+ JOIN doc_mount_files f ON f.id = l.fileId
681
+ WHERE l.mountPointId = ? AND l.relativePath LIKE ?
682
+ ORDER BY l.relativePath
683
+ `).all(mount.id, prefix + '%')
684
+ : db.prepare(`
685
+ SELECT ${LS_FILE_COLUMNS}
686
+ FROM doc_mount_file_links l
687
+ JOIN doc_mount_files f ON f.id = l.fileId
688
+ WHERE l.mountPointId = ?
689
+ ORDER BY l.relativePath
690
+ `).all(mount.id);
691
+
692
+ // Group by folder
693
+ for (const file of allFiles) {
694
+ const lastSlash = file.relativePath.lastIndexOf('/');
695
+ const folderPath = lastSlash === -1 ? '' : file.relativePath.substring(0, lastSlash);
696
+ if (!allFilesByFolder[folderPath]) allFilesByFolder[folderPath] = [];
697
+ allFilesByFolder[folderPath].push(file);
698
+ }
699
+ files = allFiles;
700
+ } else {
701
+ // Non-recursive: single folder listing
702
+ const target = resolveLsTarget(db, mount.id, normalizedPath);
703
+
704
+ if (target.kind === 'none') {
705
+ console.error(`No file or folder at "${normalizedPath || '/'}" in mount ${mount.name}`);
706
+ process.exit(1);
707
+ } else if (target.kind === 'root') {
708
+ ({ folders, files } = fetchLsRows(db, mount.id, ''));
709
+ } else if (target.kind === 'folder') {
710
+ ({ folders, files } = fetchLsRows(db, mount.id, target.path));
711
+ } else {
712
+ files = [target.file];
713
+ singleFile = true;
714
+ }
715
+ }
716
+
717
+ // Apply sort
718
+ files = sortLsFiles(files, flags.sort, flags.reverse);
719
+
720
+ // Fetch links for JSON or --links flag
721
+ const wantLinks = flags.json || flags.links;
722
+ const multiLinkFileIds = wantLinks
723
+ ? files.filter((f) => f.linkCount > 1).map((f) => f.fileId)
724
+ : [];
725
+ const linksByFile = fetchLinksForFiles(db, multiLinkFileIds);
726
+
727
+ // JSON output
728
+ if (flags.json) {
729
+ const out = [];
730
+ if (!flags.recursive) {
731
+ for (const folder of folders) {
732
+ out.push({
733
+ type: 'folder',
734
+ name: folder.name,
735
+ path: folder.path,
736
+ createdAt: folder.createdAt,
737
+ updatedAt: folder.updatedAt,
738
+ });
739
+ }
740
+ }
741
+ for (const file of files) {
742
+ const others = linksByFile.get(file.fileId);
743
+ const links = others && others.length > 0
744
+ ? others
745
+ : [{
746
+ mountPointId: mount.id,
747
+ mountName: mount.name,
748
+ relativePath: file.relativePath,
749
+ }];
750
+ out.push({
751
+ type: 'file',
752
+ name: file.fileName,
753
+ relativePath: file.relativePath,
754
+ fileType: file.fileType,
755
+ source: file.source,
756
+ fileSizeBytes: file.fileSizeBytes,
757
+ sha256: file.sha256,
758
+ lastModified: file.lastModified,
759
+ linkCount: file.linkCount,
760
+ textRepresentation: {
761
+ kind: TEXT_FILE_TYPES.has(file.fileType) ? 'inline' : 'extracted',
762
+ extractionStatus: file.extractionStatus,
763
+ hasExtractedText: !!file.extractedTextSha256,
764
+ },
765
+ embedding: {
766
+ chunkCount: file.chunkCount || 0,
767
+ embeddedChunkCount: file.embeddedChunkCount || 0,
768
+ fullyEmbedded: (file.chunkCount || 0) > 0
769
+ && file.chunkCount === file.embeddedChunkCount,
770
+ },
771
+ links,
772
+ });
773
+ }
774
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
775
+ return;
776
+ }
777
+
778
+ // Recursive text output: grouped by folder
779
+ if (flags.recursive) {
780
+ const folderPaths = Object.keys(allFilesByFolder).sort();
781
+ let anyOutput = false;
782
+ for (const folderPath of folderPaths) {
783
+ const folderFiles = allFilesByFolder[folderPath].sort((a, b) =>
784
+ a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' })
785
+ );
786
+ if (folderFiles.length > 0) {
787
+ anyOutput = true;
788
+ const displayPath = folderPath || '/';
789
+ console.log(`${BOLD}${displayPath}${RESET}`);
790
+ for (const file of folderFiles) {
791
+ const cells = [
792
+ '-',
793
+ String(file.linkCount).padStart(5),
794
+ formatBytes(file.fileSizeBytes || 0).padStart(8),
795
+ formatLsDate(file.lastModified),
796
+ textColumnMarker(file.fileType, file.extractionStatus),
797
+ embedColumnMarker(file.chunkCount, file.embeddedChunkCount),
798
+ file.fileName,
799
+ ];
800
+ console.log(' ' + cells.join(' '));
801
+ }
802
+ }
803
+ }
804
+ if (!anyOutput) console.log('(no files)');
805
+ return;
806
+ }
807
+
808
+ // Non-recursive text output
809
+ if (!singleFile && folders.length === 0 && files.length === 0) {
810
+ console.log('(empty)');
811
+ return;
812
+ }
813
+
814
+ const headerRow = {
815
+ type: 'T',
816
+ links: 'links',
817
+ size: 'size',
818
+ modified: 'modified',
819
+ text: 'text',
820
+ emb: 'emb',
821
+ name: 'name',
822
+ isHeader: true,
823
+ };
824
+ const dataRows = [];
825
+ for (const folder of folders) {
826
+ dataRows.push({
827
+ type: 'd',
828
+ links: '-',
829
+ size: '-',
830
+ modified: formatLsDate(folder.updatedAt || folder.createdAt),
831
+ text: '-',
832
+ emb: '-',
833
+ name: folder.name + '/',
834
+ });
835
+ }
836
+ for (const file of files) {
837
+ dataRows.push({
838
+ type: '-',
839
+ links: String(file.linkCount),
840
+ size: formatBytes(file.fileSizeBytes || 0),
841
+ modified: formatLsDate(file.lastModified),
842
+ text: textColumnMarker(file.fileType, file.extractionStatus),
843
+ emb: embedColumnMarker(file.chunkCount, file.embeddedChunkCount),
844
+ name: singleFile ? file.relativePath : file.fileName,
845
+ fileId: file.fileId,
846
+ relativePath: file.relativePath,
847
+ });
848
+ }
849
+
850
+ const widths = {
851
+ type: 1,
852
+ links: Math.max(headerRow.links.length, ...dataRows.map((r) => r.links.length)),
853
+ size: Math.max(headerRow.size.length, ...dataRows.map((r) => r.size.length)),
854
+ modified: Math.max(headerRow.modified.length, ...dataRows.map((r) => r.modified.length)),
855
+ text: Math.max(headerRow.text.length, ...dataRows.map((r) => r.text.length)),
856
+ emb: Math.max(headerRow.emb.length, ...dataRows.map((r) => r.emb.length)),
857
+ };
858
+
859
+ const renderLine = (r, dim) => {
860
+ const cells = [
861
+ r.type,
862
+ r.links.padStart(widths.links),
863
+ r.size.padStart(widths.size),
864
+ r.modified.padEnd(widths.modified),
865
+ r.text.padStart(widths.text),
866
+ r.emb.padStart(widths.emb),
867
+ r.name,
868
+ ];
869
+ const line = cells.join(' ');
870
+ return dim ? `${DIM}${line}${RESET}` : line;
871
+ };
872
+
873
+ console.log(renderLine(headerRow, true));
874
+ for (const r of dataRows) {
875
+ console.log(renderLine(r, false));
876
+ if (flags.links && r.type === '-') {
877
+ const others = (linksByFile.get(r.fileId) || []).filter(
878
+ (l) => !(l.mountPointId === mount.id && l.relativePath === r.relativePath)
879
+ );
880
+ if (others.length > 0) {
881
+ const indentWidth = widths.type + widths.links + widths.size
882
+ + widths.modified + widths.text + widths.emb + 6 * 2;
883
+ const indent = ' '.repeat(indentWidth);
884
+ for (const link of others) {
885
+ const sameMount = link.mountPointId === mount.id;
886
+ const display = sameMount ? link.relativePath : `${link.mountName}:${link.relativePath}`;
887
+ console.log(`${indent}${DIM}→ ${display}${RESET}`);
888
+ }
889
+ }
890
+ }
891
+ }
892
+ } finally {
893
+ db.close();
894
+ }
895
+ }
896
+
897
+ // ----------------------------------------------------------------------------
898
+ // tree
899
+ // ----------------------------------------------------------------------------
900
+
901
+ function buildFolderTree(db, mountId, startPath, maxDepth) {
902
+ // Build a folder/file tree starting from startPath
903
+ const tree = { name: startPath || '', type: 'folder', children: [] };
904
+
905
+ function addChildren(node, parentPath, currentDepth) {
906
+ if (currentDepth >= maxDepth) return;
907
+
908
+ // Get immediate child folders
909
+ const folderQuery = parentPath === ''
910
+ ? `SELECT id, name, path FROM doc_mount_folders
911
+ WHERE mountPointId = ? AND path NOT LIKE '%/%'
912
+ ORDER BY name COLLATE NOCASE`
913
+ : `SELECT id, name, path FROM doc_mount_folders
914
+ WHERE mountPointId = ? AND path LIKE ? AND path NOT LIKE ?
915
+ ORDER BY name COLLATE NOCASE`;
916
+
917
+ const folders = parentPath === ''
918
+ ? db.prepare(folderQuery).all(mountId)
919
+ : db.prepare(folderQuery).all(mountId, parentPath + '/%', parentPath + '/%/%');
920
+
921
+ // Get immediate child files
922
+ const fileQuery = parentPath === ''
923
+ ? `SELECT fileName, relativePath, fileSizeBytes, chunkCount, extractedText
924
+ FROM doc_mount_file_links l
925
+ JOIN doc_mount_files f ON f.id = l.fileId
926
+ WHERE l.mountPointId = ? AND l.relativePath NOT LIKE '%/%'
927
+ ORDER BY fileName COLLATE NOCASE`
928
+ : `SELECT fileName, relativePath, fileSizeBytes, chunkCount, extractedText
929
+ FROM doc_mount_file_links l
930
+ JOIN doc_mount_files f ON f.id = l.fileId
931
+ WHERE l.mountPointId = ? AND l.relativePath LIKE ? AND l.relativePath NOT LIKE ?
932
+ ORDER BY fileName COLLATE NOCASE`;
933
+
934
+ const files = parentPath === ''
935
+ ? db.prepare(fileQuery).all(mountId)
936
+ : db.prepare(fileQuery).all(mountId, parentPath + '/%', parentPath + '/%/%');
937
+
938
+ // Add folders first (alphabetically)
939
+ for (const folder of folders) {
940
+ const childNode = { name: folder.name, type: 'folder', path: folder.path, children: [] };
941
+ addChildren(childNode, folder.path, currentDepth + 1);
942
+ node.children.push(childNode);
943
+ }
944
+
945
+ // Then add files (alphabetically)
946
+ for (const file of files) {
947
+ node.children.push({
948
+ name: file.fileName,
949
+ type: 'file',
950
+ relativePath: file.relativePath,
951
+ size: file.fileSizeBytes || 0,
952
+ chunkCount: file.chunkCount || 0,
953
+ });
954
+ }
955
+ }
956
+
957
+ addChildren(tree, startPath, 0);
958
+ return tree;
959
+ }
960
+
961
+ function renderTreeAscii(node, prefix, isLast, isRoot, maxNodes, nodeCount) {
962
+ if (nodeCount.count >= maxNodes.max) {
963
+ nodeCount.truncated = true;
964
+ return;
965
+ }
966
+
967
+ if (!isRoot) {
968
+ nodeCount.count++;
969
+ if (nodeCount.count > maxNodes.max) {
970
+ nodeCount.truncated = true;
971
+ return;
972
+ }
973
+ const connector = isLast ? '└─' : '├─';
974
+ const typeMarker = node.type === 'folder' ? 'd' : '-';
975
+ const displayName = node.type === 'folder' ? node.name + '/' : node.name;
976
+ console.log(`${prefix}${connector} ${typeMarker} ${displayName}`);
977
+ prefix = prefix + (isLast ? ' ' : '│ ');
978
+ }
979
+
980
+ const children = node.children || [];
981
+ for (let i = 0; i < children.length; i++) {
982
+ if (nodeCount.count >= maxNodes.max) {
983
+ nodeCount.truncated = true;
984
+ break;
985
+ }
986
+ const child = children[i];
987
+ const last = i === children.length - 1;
988
+ renderTreeAscii(child, prefix, last, false, maxNodes, nodeCount);
989
+ }
990
+ }
991
+
992
+ function treeToJson(node) {
993
+ return {
994
+ name: node.name,
995
+ type: node.type,
996
+ path: node.path || undefined,
997
+ relativePath: node.relativePath || undefined,
998
+ size: node.size !== undefined ? node.size : undefined,
999
+ chunkCount: node.chunkCount !== undefined ? node.chunkCount : undefined,
1000
+ children: node.children && node.children.length > 0
1001
+ ? node.children.map(treeToJson)
1002
+ : undefined,
1003
+ };
1004
+ }
1005
+
1006
+ async function handleTree(flags, mountSpec, rawPath) {
1007
+ if (!mountSpec) {
1008
+ console.error('Usage: quilltap docs tree <mount> [path] [--depth N] [--max-nodes N] [--long] [--json]');
1009
+ process.exit(1);
1010
+ }
1011
+ const { db } = await openDb(flags);
1012
+ try {
1013
+ const mount = requireMount(db, mountSpec);
1014
+ const normalizedPath = normalizeLsPath(rawPath);
1015
+
1016
+ // Validate the path exists
1017
+ const target = resolveLsTarget(db, mount.id, normalizedPath);
1018
+ if (target.kind === 'none') {
1019
+ console.error(`No file or folder at "${normalizedPath || '/'}" in mount ${mount.name}`);
1020
+ process.exit(1);
1021
+ }
1022
+ if (target.kind === 'file') {
1023
+ console.error(`"${normalizedPath}" is a file, not a folder`);
1024
+ process.exit(1);
1025
+ }
1026
+
1027
+ const startPath = target.kind === 'folder' ? target.path : '';
1028
+ const maxDepth = Math.min(flags.depth || 20, 50); // Cap at 50
1029
+ const maxNodes = Math.max(flags.maxNodes || 1000, 1);
1030
+
1031
+ const tree = buildFolderTree(db, mount.id, startPath, maxDepth);
1032
+
1033
+ if (flags.json) {
1034
+ const output = treeToJson(tree);
1035
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
1036
+ return;
1037
+ }
1038
+
1039
+ // ASCII render
1040
+ const nodeCount = { count: 0, truncated: false };
1041
+ const displayRoot = startPath || '/';
1042
+ console.log(`${BOLD}${displayRoot}${RESET}`);
1043
+ if (tree.children && tree.children.length > 0) {
1044
+ for (let i = 0; i < tree.children.length; i++) {
1045
+ if (nodeCount.count >= maxNodes) {
1046
+ nodeCount.truncated = true;
1047
+ break;
1048
+ }
1049
+ const child = tree.children[i];
1050
+ const last = i === tree.children.length - 1;
1051
+ renderTreeAscii(child, '', last, false, { max: maxNodes }, nodeCount);
1052
+ }
1053
+ }
1054
+
1055
+ if (nodeCount.truncated) {
1056
+ console.log(`${DIM}… (truncated at ${maxNodes} nodes; use --max-nodes <N> for more)${RESET}`);
1057
+ }
1058
+ } finally {
1059
+ db.close();
1060
+ }
1061
+ }
1062
+
264
1063
  // ----------------------------------------------------------------------------
265
1064
  // read
266
1065
  // ----------------------------------------------------------------------------
@@ -298,90 +1097,70 @@ async function handleRead(flags, id, relativePath) {
298
1097
 
299
1098
  function readRaw(db, mount, relativePath, flags) {
300
1099
  const file = db.prepare(`
301
- SELECT id, source, fileType
302
- FROM doc_mount_files
303
- WHERE mountPointId = ? AND relativePath = ?
1100
+ SELECT l.fileId, f.source, f.fileType
1101
+ FROM doc_mount_file_links l
1102
+ JOIN doc_mount_files f ON f.id = l.fileId
1103
+ WHERE l.mountPointId = ? AND l.relativePath = ?
304
1104
  `).get(mount.id, relativePath);
305
1105
 
306
- if (file) {
307
- ttyGuard(file.fileType, flags, relativePath);
1106
+ if (!file) {
1107
+ console.error(`No file at ${relativePath} in mount ${mount.name}`);
1108
+ process.exit(1);
1109
+ }
1110
+
1111
+ ttyGuard(file.fileType, flags, relativePath);
308
1112
 
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
- });
1113
+ if (file.source === 'filesystem') {
1114
+ const fullPath = path.join(mount.basePath, relativePath);
1115
+ if (!fs.existsSync(fullPath)) {
1116
+ console.error(`File missing on disk: ${fullPath}`);
1117
+ process.exit(1);
322
1118
  }
1119
+ // Stream so we don't load huge files into memory.
1120
+ const stream = fs.createReadStream(fullPath);
1121
+ stream.pipe(process.stdout);
1122
+ return new Promise((resolve, reject) => {
1123
+ stream.on('end', resolve);
1124
+ stream.on('error', reject);
1125
+ });
1126
+ }
323
1127
 
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}`);
1128
+ if (file.source === 'database') {
1129
+ if (TEXT_FILE_TYPES.has(file.fileType)) {
1130
+ const doc = db.prepare(
1131
+ `SELECT content FROM doc_mount_documents WHERE fileId = ?`
1132
+ ).get(file.fileId);
1133
+ if (!doc) {
1134
+ console.error(`File row exists but no document content for ${relativePath}`);
344
1135
  process.exit(1);
345
1136
  }
346
- process.stdout.write(blob.data);
1137
+ process.stdout.write(doc.content);
347
1138
  return;
348
1139
  }
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);
1140
+ // Binary stored in doc_mount_blobs
1141
+ const blob = db.prepare(
1142
+ `SELECT data FROM doc_mount_blobs WHERE fileId = ?`
1143
+ ).get(file.fileId);
1144
+ if (!blob) {
1145
+ console.error(`File row exists but no blob bytes for ${relativePath}`);
1146
+ process.exit(1);
1147
+ }
361
1148
  process.stdout.write(blob.data);
362
1149
  return;
363
1150
  }
364
1151
 
365
- console.error(`No file at ${relativePath} in mount ${mount.name}`);
1152
+ console.error(`Unknown file source: ${file.source}`);
366
1153
  process.exit(1);
367
1154
  }
368
1155
 
369
1156
  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.
1157
+ // 1. Look up the link row (it carries extractedText now).
381
1158
  const file = db.prepare(`
382
- SELECT id, source, fileType
383
- FROM doc_mount_files
384
- WHERE mountPointId = ? AND relativePath = ?
1159
+ SELECT l.id AS linkId, l.fileId, l.extractedText,
1160
+ f.source, f.fileType
1161
+ FROM doc_mount_file_links l
1162
+ JOIN doc_mount_files f ON f.id = l.fileId
1163
+ WHERE l.mountPointId = ? AND l.relativePath = ?
385
1164
  `).get(mount.id, relativePath);
386
1165
 
387
1166
  if (!file) {
@@ -389,12 +1168,17 @@ function readRendered(db, mount, relativePath) {
389
1168
  process.exit(1);
390
1169
  }
391
1170
 
1171
+ // 2. Extracted text on the link wins.
1172
+ if (file.extractedText) {
1173
+ process.stdout.write(file.extractedText);
1174
+ return;
1175
+ }
1176
+
392
1177
  // 3. Database-backed text doc — content IS the rendered form.
393
1178
  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);
1179
+ const doc = db.prepare(
1180
+ `SELECT content FROM doc_mount_documents WHERE fileId = ?`
1181
+ ).get(file.fileId);
398
1182
  if (doc) {
399
1183
  process.stdout.write(doc.content);
400
1184
  return;
@@ -410,12 +1194,12 @@ function readRendered(db, mount, relativePath) {
410
1194
  }
411
1195
  }
412
1196
 
413
- // 5. Fall back to concatenated chunks.
1197
+ // 5. Fall back to concatenated chunks (now keyed by linkId).
414
1198
  const chunks = db.prepare(`
415
1199
  SELECT content FROM doc_mount_chunks
416
- WHERE fileId = ?
1200
+ WHERE linkId = ?
417
1201
  ORDER BY chunkIndex
418
- `).all(file.id);
1202
+ `).all(file.linkId);
419
1203
  if (chunks.length === 0) {
420
1204
  console.error(`No rendered text available for ${relativePath}. Run 'quilltap docs scan ${mount.id}' to extract and embed.`);
421
1205
  process.exit(1);
@@ -450,16 +1234,16 @@ async function handleExport(flags, id, outputDir) {
450
1234
  const { db } = await openDb(flags);
451
1235
  let writtenFiles = 0;
452
1236
  let writtenBytes = 0;
453
- const writtenPaths = new Set();
454
1237
  try {
455
1238
  const mount = requireMount(db, id);
456
1239
 
457
1240
  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);
1241
+ SELECT l.fileId, l.relativePath, f.source, f.fileType
1242
+ FROM doc_mount_file_links l
1243
+ JOIN doc_mount_files f ON f.id = l.fileId
1244
+ WHERE l.mountPointId = ?
1245
+ ORDER BY l.relativePath
1246
+ `).all(mount.id);
463
1247
 
464
1248
  for (const file of files) {
465
1249
  const dest = path.join(resolvedOut, file.relativePath);
@@ -475,10 +1259,9 @@ async function handleExport(flags, id, outputDir) {
475
1259
  writtenBytes += fs.statSync(dest).size;
476
1260
  } else if (file.source === 'database') {
477
1261
  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);
1262
+ const doc = db.prepare(
1263
+ `SELECT content FROM doc_mount_documents WHERE fileId = ?`
1264
+ ).get(file.fileId);
482
1265
  if (!doc) {
483
1266
  console.error(`${YELLOW}skip${RESET} ${file.relativePath} (no document content)`);
484
1267
  continue;
@@ -486,10 +1269,9 @@ async function handleExport(flags, id, outputDir) {
486
1269
  fs.writeFileSync(dest, doc.content, 'utf8');
487
1270
  writtenBytes += Buffer.byteLength(doc.content, 'utf8');
488
1271
  } else {
489
- const blob = db.prepare(`
490
- SELECT data FROM doc_mount_blobs
491
- WHERE mountPointId = ? AND relativePath = ?
492
- `).get(id, file.relativePath);
1272
+ const blob = db.prepare(
1273
+ `SELECT data FROM doc_mount_blobs WHERE fileId = ?`
1274
+ ).get(file.fileId);
493
1275
  if (!blob) {
494
1276
  console.error(`${YELLOW}skip${RESET} ${file.relativePath} (no blob bytes)`);
495
1277
  continue;
@@ -502,22 +1284,6 @@ async function handleExport(flags, id, outputDir) {
502
1284
  continue;
503
1285
  }
504
1286
  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
1287
  }
522
1288
 
523
1289
  console.log(`${GREEN}Exported${RESET} ${writtenFiles} file(s), ${formatBytes(writtenBytes)} → ${resolvedOut}`);
@@ -574,47 +1340,1266 @@ async function handleScan(flags, id) {
574
1340
  }
575
1341
 
576
1342
  // ----------------------------------------------------------------------------
577
- // dispatch
1343
+ // Helpers shared by write/delete/mkdir/move/copy
578
1344
  // ----------------------------------------------------------------------------
579
1345
 
580
- async function docsCommand(args) {
581
- if (args.length === 0) {
582
- printDocsHelp();
583
- process.exit(1);
1346
+ function sha256OfBuffer(buf) {
1347
+ return crypto.createHash('sha256').update(buf).digest('hex');
1348
+ }
1349
+
1350
+ function isConnectionRefused(err) {
1351
+ if (!err) return false;
1352
+ const code = err.cause && err.cause.code ? err.cause.code : err.code;
1353
+ return (
1354
+ code === 'ECONNREFUSED' ||
1355
+ code === 'ENOTFOUND' ||
1356
+ code === 'EHOSTUNREACH' ||
1357
+ code === 'ECONNRESET'
1358
+ );
1359
+ }
1360
+
1361
+ async function tryFetch(url, init) {
1362
+ try {
1363
+ return { ok: true, res: await fetch(url, init) };
1364
+ } catch (err) {
1365
+ if (isConnectionRefused(err)) {
1366
+ return { ok: false, err };
1367
+ }
1368
+ throw err;
584
1369
  }
585
- if (args[0] === '-h' || args[0] === '--help') {
586
- printDocsHelp();
587
- process.exit(0);
1370
+ }
1371
+
1372
+ async function readBodyJson(res) {
1373
+ try {
1374
+ return await res.json();
1375
+ } catch {
1376
+ return null;
588
1377
  }
1378
+ }
1379
+
1380
+ function actionUrl(port, mountId, action) {
1381
+ return `http://localhost:${port}/api/v1/mount-points/${encodeURIComponent(mountId)}?action=${action}`;
1382
+ }
589
1383
 
590
- const verb = args[0];
591
- const { flags, positional } = parseFlags(args.slice(1));
1384
+ function unwrap(body) {
1385
+ if (body && typeof body === 'object' && 'data' in body) return body.data;
1386
+ return body;
1387
+ }
592
1388
 
593
- if (flags.help) {
594
- printDocsHelp();
595
- process.exit(0);
1389
+ // ----------------------------------------------------------------------------
1390
+ // Direct (offline) helpers — filesystem-only fallback used when the server is
1391
+ // unreachable. Database-mount writes always require the server because the
1392
+ // index updates need the server's reindex / embed pipeline.
1393
+ // ----------------------------------------------------------------------------
1394
+
1395
+ function requireServerForDb(mount, action) {
1396
+ if (mount.mountType === 'database') {
1397
+ console.error(
1398
+ `Cannot ${action} on database-backed mount "${mount.name}" without the Quilltap server.`
1399
+ );
1400
+ console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
1401
+ process.exit(1);
596
1402
  }
1403
+ }
597
1404
 
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;
1405
+ function loadMountBasePath(db, mountId) {
1406
+ const row = db.prepare(
1407
+ 'SELECT basePath FROM doc_mount_points WHERE id = ?'
1408
+ ).get(mountId);
1409
+ if (!row || !row.basePath) {
1410
+ throw new Error(`Mount ${mountId} has no basePath`);
1411
+ }
1412
+ return row.basePath;
1413
+ }
1414
+
1415
+ function fsAbsolute(basePath, relativePath) {
1416
+ const abs = path.resolve(basePath, relativePath);
1417
+ const base = path.resolve(basePath);
1418
+ const withSep = base.endsWith(path.sep) ? base : base + path.sep;
1419
+ if (abs !== base && !abs.startsWith(withSep)) {
1420
+ throw new Error(`Path escapes mount boundary: ${relativePath}`);
1421
+ }
1422
+ return abs;
1423
+ }
1424
+
1425
+ // ----------------------------------------------------------------------------
1426
+ // write
1427
+ // ----------------------------------------------------------------------------
1428
+
1429
+ async function readWriteSource(filename) {
1430
+ if (filename) {
1431
+ const resolved = filename.startsWith('~')
1432
+ ? path.join(require('os').homedir(), filename.slice(1))
1433
+ : path.resolve(filename);
1434
+ if (!fs.existsSync(resolved)) {
1435
+ throw new Error(`Source file not found: ${resolved}`);
1436
+ }
1437
+ return fs.readFileSync(resolved);
1438
+ }
1439
+ if (process.stdin.isTTY) {
1440
+ throw new Error('No source file given and stdin is a TTY. Pipe content or pass a filename.');
1441
+ }
1442
+ const chunks = [];
1443
+ for await (const chunk of process.stdin) {
1444
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1445
+ }
1446
+ if (chunks.length === 0) {
1447
+ throw new Error('Empty stdin payload');
1448
+ }
1449
+ return Buffer.concat(chunks);
1450
+ }
1451
+
1452
+ async function writeViaHttp(port, mountId, relativePath, data, force) {
1453
+ const form = new FormData();
1454
+ const blob = new Blob([data]);
1455
+ form.append('file', blob, path.posix.basename(relativePath));
1456
+ form.append('path', relativePath);
1457
+ form.append('force', force ? 'true' : 'false');
1458
+ const attempt = await tryFetch(actionUrl(port, mountId, 'write-file'), {
1459
+ method: 'POST',
1460
+ body: form,
1461
+ });
1462
+ if (!attempt.ok) return { reachable: false };
1463
+ const body = await readBodyJson(attempt.res);
1464
+ if (!attempt.res.ok) {
1465
+ const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
1466
+ const code = body && body.code ? body.code : null;
1467
+ return { reachable: true, ok: false, status: attempt.res.status, error: msg, code };
1468
+ }
1469
+ return { reachable: true, ok: true, result: unwrap(body) };
1470
+ }
1471
+
1472
+ async function handleWrite(flags, positional) {
1473
+ const force = flags.force;
1474
+ const [mountSpec, relativePath, filename] = positional;
1475
+ if (!mountSpec || !relativePath) {
1476
+ console.error('Usage: quilltap docs write [--force] <mount> <path> [filename]');
1477
+ process.exit(1);
1478
+ }
1479
+
1480
+ const data = await readWriteSource(filename);
1481
+ const sourceSha = sha256OfBuffer(data);
1482
+
1483
+ const { db } = await openDb(flags);
1484
+ let mount;
1485
+ try {
1486
+ mount = requireMount(db, mountSpec);
1487
+ } finally {
1488
+ db.close();
1489
+ }
1490
+
1491
+ const http = await writeViaHttp(flags.port, mount.id, relativePath, data, force);
1492
+ if (http.reachable) {
1493
+ if (!http.ok) {
1494
+ console.error(`Write failed: ${http.error}`);
1495
+ process.exit(http.code === 'DEST_EXISTS' ? 2 : 1);
1496
+ }
1497
+ const r = http.result;
1498
+ if (r.sha256 !== sourceSha) {
1499
+ console.error(`Checksum mismatch: source ${sourceSha} != dest ${r.sha256}`);
1500
+ process.exit(1);
1501
+ }
1502
+ if (flags.json) {
1503
+ process.stdout.write(JSON.stringify({ ...r, sourceSha256: sourceSha }, null, 2) + '\n');
1504
+ return;
1505
+ }
1506
+ console.log(`${GREEN}Wrote${RESET} ${mount.name}:${r.destPath} (${formatBytes(r.sizeBytes)}, sha=${r.sha256.slice(0, 12)}…)`);
1507
+ return;
1508
+ }
1509
+
1510
+ // Direct fallback — filesystem mounts only.
1511
+ requireServerForDb(mount, 'write');
1512
+
1513
+ const { db: db2 } = await openDb(flags);
1514
+ let basePath;
1515
+ try {
1516
+ basePath = loadMountBasePath(db2, mount.id);
1517
+ } finally {
1518
+ db2.close();
1519
+ }
1520
+ const abs = fsAbsolute(basePath, relativePath);
1521
+ if (fs.existsSync(abs) && !force) {
1522
+ console.error(`Destination already exists: ${relativePath}. Use --force to overwrite.`);
1523
+ process.exit(2);
1524
+ }
1525
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
1526
+ fs.writeFileSync(abs, data);
1527
+ const destSha = sha256OfBuffer(fs.readFileSync(abs));
1528
+ if (destSha !== sourceSha) {
1529
+ console.error(`Checksum mismatch after write: source ${sourceSha} != dest ${destSha}`);
1530
+ process.exit(1);
1531
+ }
1532
+ if (flags.json) {
1533
+ process.stdout.write(JSON.stringify({
1534
+ sha256: destSha,
1535
+ sourceSha256: sourceSha,
1536
+ sizeBytes: data.length,
1537
+ destPath: relativePath,
1538
+ mountPointId: mount.id,
1539
+ mode: 'direct',
1540
+ }, null, 2) + '\n');
1541
+ return;
1542
+ }
1543
+ console.log(`${GREEN}Wrote${RESET} ${mount.name}:${relativePath} (${formatBytes(data.length)}, sha=${destSha.slice(0, 12)}…) ${DIM}[direct mode — run 'quilltap docs scan' once the server is back]${RESET}`);
1544
+ }
1545
+
1546
+ // ----------------------------------------------------------------------------
1547
+ // delete
1548
+ // ----------------------------------------------------------------------------
1549
+
1550
+ async function deleteViaHttp(port, mountId, relativePath) {
1551
+ const attempt = await tryFetch(actionUrl(port, mountId, 'delete-file'), {
1552
+ method: 'POST',
1553
+ headers: { 'Content-Type': 'application/json' },
1554
+ body: JSON.stringify({ path: relativePath }),
1555
+ });
1556
+ if (!attempt.ok) return { reachable: false };
1557
+ const body = await readBodyJson(attempt.res);
1558
+ if (!attempt.res.ok) {
1559
+ const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
1560
+ return { reachable: true, ok: false, status: attempt.res.status, error: msg };
1561
+ }
1562
+ return { reachable: true, ok: true, result: unwrap(body) };
1563
+ }
1564
+
1565
+ async function handleDelete(flags, positional) {
1566
+ const [mountSpec, relativePath] = positional;
1567
+ if (!mountSpec || !relativePath) {
1568
+ console.error('Usage: quilltap docs delete <mount> <path>');
1569
+ process.exit(1);
1570
+ }
1571
+
1572
+ const { db } = await openDb(flags);
1573
+ let mount;
1574
+ try {
1575
+ mount = requireMount(db, mountSpec);
1576
+ } finally {
1577
+ db.close();
1578
+ }
1579
+
1580
+ const http = await deleteViaHttp(flags.port, mount.id, relativePath);
1581
+ if (http.reachable) {
1582
+ if (!http.ok) {
1583
+ console.error(`Delete failed: ${http.error}`);
1584
+ process.exit(1);
1585
+ }
1586
+ const r = http.result;
1587
+ if (flags.json) {
1588
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
1589
+ return;
1590
+ }
1591
+ if (r.deleted) {
1592
+ console.log(`${GREEN}Deleted${RESET} ${mount.name}:${r.path}`);
1593
+ } else {
1594
+ console.log(`${DIM}No-op${RESET} ${mount.name}:${r.path} (did not exist)`);
1595
+ }
1596
+ return;
1597
+ }
1598
+
1599
+ requireServerForDb(mount, 'delete');
1600
+
1601
+ const { db: db2 } = await openDb(flags);
1602
+ let basePath;
1603
+ try {
1604
+ basePath = loadMountBasePath(db2, mount.id);
1605
+ } finally {
1606
+ db2.close();
1607
+ }
1608
+ const abs = fsAbsolute(basePath, relativePath);
1609
+ let existed = false;
1610
+ try {
1611
+ fs.unlinkSync(abs);
1612
+ existed = true;
1613
+ } catch (err) {
1614
+ if (err.code !== 'ENOENT') throw err;
1615
+ }
1616
+ if (fs.existsSync(abs)) {
1617
+ console.error(`Delete verification failed: path still present at ${abs}`);
1618
+ process.exit(1);
1619
+ }
1620
+ if (flags.json) {
1621
+ process.stdout.write(JSON.stringify({
1622
+ deleted: existed,
1623
+ path: relativePath,
1624
+ mountPointId: mount.id,
1625
+ mode: 'direct',
1626
+ }, null, 2) + '\n');
1627
+ return;
1628
+ }
1629
+ if (existed) {
1630
+ console.log(`${GREEN}Deleted${RESET} ${mount.name}:${relativePath} ${DIM}[direct mode]${RESET}`);
1631
+ } else {
1632
+ console.log(`${DIM}No-op${RESET} ${mount.name}:${relativePath} (did not exist) ${DIM}[direct mode]${RESET}`);
1633
+ }
1634
+ }
1635
+
1636
+ // ----------------------------------------------------------------------------
1637
+ // mkdir
1638
+ // ----------------------------------------------------------------------------
1639
+
1640
+ async function mkdirViaHttp(port, mountId, relativePath) {
1641
+ const url = `http://localhost:${port}/api/v1/mount-points/${encodeURIComponent(mountId)}/folders`;
1642
+ const attempt = await tryFetch(url, {
1643
+ method: 'POST',
1644
+ headers: { 'Content-Type': 'application/json' },
1645
+ body: JSON.stringify({ path: relativePath }),
1646
+ });
1647
+ if (!attempt.ok) return { reachable: false };
1648
+ const body = await readBodyJson(attempt.res);
1649
+ if (!attempt.res.ok) {
1650
+ const msg = body && body.error ? body.error : `HTTP ${attempt.res.status}`;
1651
+ return { reachable: true, ok: false, status: attempt.res.status, error: msg };
1652
+ }
1653
+ return { reachable: true, ok: true, result: unwrap(body) };
1654
+ }
1655
+
1656
+ async function handleMkdir(flags, positional) {
1657
+ const [mountSpec, relativePath] = positional;
1658
+ if (!mountSpec || !relativePath) {
1659
+ console.error('Usage: quilltap docs mkdir <mount> <path>');
1660
+ process.exit(1);
1661
+ }
1662
+
1663
+ const { db } = await openDb(flags);
1664
+ let mount;
1665
+ try {
1666
+ mount = requireMount(db, mountSpec);
1667
+ } finally {
1668
+ db.close();
1669
+ }
1670
+
1671
+ const http = await mkdirViaHttp(flags.port, mount.id, relativePath);
1672
+ if (http.reachable) {
1673
+ if (!http.ok) {
1674
+ console.error(`mkdir failed: ${http.error}`);
1675
+ process.exit(1);
1676
+ }
1677
+ if (flags.json) {
1678
+ process.stdout.write(JSON.stringify(http.result, null, 2) + '\n');
1679
+ return;
1680
+ }
1681
+ console.log(`${GREEN}Folder ready${RESET} ${mount.name}:${http.result.path ?? relativePath}`);
1682
+ return;
1683
+ }
1684
+
1685
+ requireServerForDb(mount, 'mkdir');
1686
+
1687
+ const { db: db2 } = await openDb(flags);
1688
+ let basePath;
1689
+ try {
1690
+ basePath = loadMountBasePath(db2, mount.id);
1691
+ } finally {
1692
+ db2.close();
1693
+ }
1694
+ const abs = fsAbsolute(basePath, relativePath);
1695
+ fs.mkdirSync(abs, { recursive: true });
1696
+ if (!fs.existsSync(abs)) {
1697
+ console.error(`mkdir verification failed: ${abs} does not exist after creation`);
1698
+ process.exit(1);
1699
+ }
1700
+ if (flags.json) {
1701
+ process.stdout.write(JSON.stringify({
1702
+ path: relativePath,
1703
+ mountPointId: mount.id,
1704
+ mode: 'direct',
1705
+ }, null, 2) + '\n');
1706
+ return;
1707
+ }
1708
+ console.log(`${GREEN}Folder ready${RESET} ${mount.name}:${relativePath} ${DIM}[direct mode]${RESET}`);
1709
+ }
1710
+
1711
+ // ----------------------------------------------------------------------------
1712
+ // move / copy
1713
+ // ----------------------------------------------------------------------------
1714
+
1715
+ async function fileOpViaHttp(port, action, sourceMountId, body) {
1716
+ const attempt = await tryFetch(actionUrl(port, sourceMountId, action), {
1717
+ method: 'POST',
1718
+ headers: { 'Content-Type': 'application/json' },
1719
+ body: JSON.stringify(body),
1720
+ });
1721
+ if (!attempt.ok) return { reachable: false };
1722
+ const resBody = await readBodyJson(attempt.res);
1723
+ if (!attempt.res.ok) {
1724
+ const msg = resBody && resBody.error ? resBody.error : `HTTP ${attempt.res.status}`;
1725
+ const code = resBody && resBody.code ? resBody.code : null;
1726
+ return { reachable: true, ok: false, status: attempt.res.status, error: msg, code };
1727
+ }
1728
+ return { reachable: true, ok: true, result: unwrap(resBody) };
1729
+ }
1730
+
1731
+ async function directFsFileOp({ flags, sourceMount, srcPath, destMount, dstPath, action, force }) {
1732
+ if (sourceMount.mountType === 'database' || destMount.mountType === 'database') {
1733
+ console.error(
1734
+ `Cannot ${action} between/with database-backed mounts without the Quilltap server.`
1735
+ );
1736
+ console.error('Start the server (`quilltap`) or pass --port to match a non-default port.');
1737
+ process.exit(1);
1738
+ }
1739
+
1740
+ const { db } = await openDb(flags);
1741
+ let srcBase, dstBase;
1742
+ try {
1743
+ srcBase = loadMountBasePath(db, sourceMount.id);
1744
+ dstBase = loadMountBasePath(db, destMount.id);
1745
+ } finally {
1746
+ db.close();
1747
+ }
1748
+ const srcAbs = fsAbsolute(srcBase, srcPath);
1749
+ const dstAbs = fsAbsolute(dstBase, dstPath);
1750
+
1751
+ if (!fs.existsSync(srcAbs)) {
1752
+ console.error(`Source not found: ${srcPath}`);
1753
+ process.exit(1);
1754
+ }
1755
+ if (fs.existsSync(dstAbs)) {
1756
+ if (action === 'copy' && !force) {
1757
+ console.error(`Destination already exists: ${dstPath}. Use --force to overwrite.`);
1758
+ process.exit(2);
1759
+ }
1760
+ if (action === 'move') {
1761
+ console.error(`Destination already exists: ${dstPath}. Move will not overwrite.`);
1762
+ process.exit(2);
1763
+ }
1764
+ fs.unlinkSync(dstAbs);
1765
+ }
1766
+ fs.mkdirSync(path.dirname(dstAbs), { recursive: true });
1767
+
1768
+ const sourceSha = sha256OfBuffer(fs.readFileSync(srcAbs));
1769
+ let strategy;
1770
+ if (action === 'move') {
1771
+ try {
1772
+ fs.renameSync(srcAbs, dstAbs);
1773
+ strategy = 'rename';
1774
+ } catch (err) {
1775
+ if (err.code !== 'EXDEV') throw err;
1776
+ fs.copyFileSync(srcAbs, dstAbs);
1777
+ fs.unlinkSync(srcAbs);
1778
+ strategy = 'byte-copy';
1779
+ }
1780
+ } else if (force) {
1781
+ fs.copyFileSync(srcAbs, dstAbs);
1782
+ strategy = 'byte-copy';
1783
+ } else {
1784
+ try {
1785
+ fs.linkSync(srcAbs, dstAbs);
1786
+ strategy = 'fs-link';
1787
+ } catch (err) {
1788
+ if (err.code !== 'EXDEV') throw err;
1789
+ fs.copyFileSync(srcAbs, dstAbs);
1790
+ strategy = 'byte-copy';
1791
+ }
1792
+ }
1793
+
1794
+ const destSha = sha256OfBuffer(fs.readFileSync(dstAbs));
1795
+ if (destSha !== sourceSha) {
1796
+ console.error(`Checksum mismatch after ${action}: ${sourceSha} != ${destSha}`);
1797
+ process.exit(1);
1798
+ }
1799
+ return {
1800
+ strategy,
1801
+ sourceSha256: sourceSha,
1802
+ destSha256: destSha,
1803
+ sizeBytes: fs.statSync(dstAbs).size,
1804
+ sourcePath: srcPath,
1805
+ destPath: dstPath,
1806
+ sourceMountPointId: sourceMount.id,
1807
+ destMountPointId: destMount.id,
1808
+ mode: 'direct',
1809
+ };
1810
+ }
1811
+
1812
+ async function handleFileOp(flags, positional, action) {
1813
+ const [srcMountSpec, srcPath, dstMountSpec, dstPath] = positional;
1814
+ if (!srcMountSpec || !srcPath || !dstMountSpec || !dstPath) {
1815
+ const flagHint = action === 'copy' ? '[--force] ' : '';
1816
+ console.error(`Usage: quilltap docs ${action} ${flagHint}<srcMount> <srcPath> <dstMount> <dstPath>`);
1817
+ process.exit(1);
1818
+ }
1819
+
1820
+ const { db } = await openDb(flags);
1821
+ let sourceMount, destMount;
1822
+ try {
1823
+ sourceMount = requireMount(db, srcMountSpec);
1824
+ destMount = requireMount(db, dstMountSpec);
1825
+ } finally {
1826
+ db.close();
1827
+ }
1828
+
1829
+ const http = await fileOpViaHttp(flags.port, `${action}-file`, sourceMount.id, {
1830
+ sourcePath: srcPath,
1831
+ destMountPointId: destMount.id,
1832
+ destPath: dstPath,
1833
+ ...(action === 'copy' ? { force: !!flags.force } : {}),
1834
+ });
1835
+ if (http.reachable) {
1836
+ if (!http.ok) {
1837
+ console.error(`${action} failed: ${http.error}`);
1838
+ process.exit(http.code === 'DEST_EXISTS' ? 2 : 1);
1839
+ }
1840
+ const r = http.result;
1841
+ if (r.sourceSha256 !== r.destSha256) {
1842
+ console.error(`Checksum mismatch: source ${r.sourceSha256} != dest ${r.destSha256}`);
1843
+ process.exit(1);
1844
+ }
1845
+ if (flags.json) {
1846
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
1847
+ return;
1848
+ }
1849
+ const verb = action === 'move' ? 'Moved' : 'Copied';
1850
+ console.log(`${GREEN}${verb}${RESET} ${sourceMount.name}:${r.sourcePath} → ${destMount.name}:${r.destPath} ${DIM}(${r.strategy}, ${formatBytes(r.sizeBytes)}, sha=${r.destSha256.slice(0, 12)}…)${RESET}`);
1851
+ return;
1852
+ }
1853
+
1854
+ const result = await directFsFileOp({
1855
+ flags,
1856
+ sourceMount,
1857
+ srcPath,
1858
+ destMount,
1859
+ dstPath,
1860
+ action,
1861
+ force: !!flags.force,
1862
+ });
1863
+ if (flags.json) {
1864
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
1865
+ return;
1866
+ }
1867
+ const verb = action === 'move' ? 'Moved' : 'Copied';
1868
+ console.log(`${GREEN}${verb}${RESET} ${sourceMount.name}:${result.sourcePath} → ${destMount.name}:${result.destPath} ${DIM}(${result.strategy}, ${formatBytes(result.sizeBytes)}, sha=${result.destSha256.slice(0, 12)}…) [direct mode — run 'quilltap docs scan' once the server is back]${RESET}`);
1869
+ }
1870
+
1871
+ // ----------------------------------------------------------------------------
1872
+ // dispatch
1873
+ // ----------------------------------------------------------------------------
1874
+
1875
+ // ----------------------------------------------------------------------------
1876
+ // find — substring search on relativePath
1877
+ // ----------------------------------------------------------------------------
1878
+
1879
+ function escapeLike(s) {
1880
+ // SQLite LIKE uses % and _ as wildcards; we treat the pattern as a substring
1881
+ // literal so the user's % / _ don't have surprise semantics.
1882
+ return s.replace(/[%_\\]/g, ch => '\\' + ch);
1883
+ }
1884
+
1885
+ function resolveSearchMounts(db, mountSpec) {
1886
+ if (!mountSpec || mountSpec === 'all') {
1887
+ return db.prepare(
1888
+ `SELECT * FROM doc_mount_points ORDER BY name COLLATE NOCASE`
1889
+ ).all();
1890
+ }
1891
+ return [requireMount(db, mountSpec)];
1892
+ }
1893
+
1894
+ async function handleFind(flags, positional) {
1895
+ const pattern = positional[0];
1896
+ if (!pattern) {
1897
+ console.error('Usage: quilltap docs find [--mount <name|id|all>] [--type file|folder] [--ext <ext>] [--limit N] [--json] <pattern>');
1898
+ process.exit(1);
1899
+ }
1900
+ const limit = flags.limit > 0 ? flags.limit : 100;
1901
+ const type = flags.type || 'file';
1902
+ if (type !== 'file' && type !== 'folder') {
1903
+ console.error(`Error: --type must be file or folder, got "${flags.type}"`);
1904
+ process.exit(1);
1905
+ }
1906
+
1907
+ const { db } = await openDb(flags);
1908
+ try {
1909
+ const mounts = resolveSearchMounts(db, flags.mount);
1910
+ const showMountColumn = !flags.mount || flags.mount === 'all';
1911
+
1912
+ const liked = `%${escapeLike(pattern)}%`;
1913
+
1914
+ const out = [];
1915
+ if (type === 'file') {
1916
+ // LIKE is case-insensitive on NOCASE collation; use COLLATE NOCASE on
1917
+ // the comparison to make match independent of stored case.
1918
+ const stmt = db.prepare(`
1919
+ SELECT l.relativePath, l.lastModified, l.updatedAt,
1920
+ f.fileSizeBytes, f.fileType
1921
+ FROM doc_mount_file_links l
1922
+ JOIN doc_mount_files f ON f.id = l.fileId
1923
+ WHERE l.mountPointId = ?
1924
+ AND l.relativePath LIKE ? ESCAPE '\\' COLLATE NOCASE
1925
+ ORDER BY l.relativePath COLLATE NOCASE
1926
+ `);
1927
+ for (const mount of mounts) {
1928
+ const rows = stmt.all(mount.id, liked);
1929
+ for (const r of rows) {
1930
+ if (flags.ext) {
1931
+ const ext = flags.ext.startsWith('.') ? flags.ext.slice(1) : flags.ext;
1932
+ if (!r.relativePath.toLowerCase().endsWith('.' + ext.toLowerCase())) continue;
1933
+ }
1934
+ out.push({
1935
+ mount: { id: mount.id, name: mount.name },
1936
+ kind: 'file',
1937
+ relativePath: r.relativePath,
1938
+ size: r.fileSizeBytes,
1939
+ fileType: r.fileType,
1940
+ lastModified: r.lastModified,
1941
+ updatedAt: r.updatedAt,
1942
+ });
1943
+ if (out.length >= limit) break;
1944
+ }
1945
+ if (out.length >= limit) break;
1946
+ }
1947
+ } else {
1948
+ // folder search (database-backed mounts only — doc_mount_folders is
1949
+ // populated by the database scan path; filesystem mounts derive
1950
+ // hierarchy from the OS at request time).
1951
+ const stmt = db.prepare(`
1952
+ SELECT path, name, updatedAt
1953
+ FROM doc_mount_folders
1954
+ WHERE mountPointId = ?
1955
+ AND path LIKE ? ESCAPE '\\' COLLATE NOCASE
1956
+ ORDER BY path COLLATE NOCASE
1957
+ `);
1958
+ for (const mount of mounts) {
1959
+ const rows = stmt.all(mount.id, liked);
1960
+ for (const r of rows) {
1961
+ out.push({
1962
+ mount: { id: mount.id, name: mount.name },
1963
+ kind: 'folder',
1964
+ relativePath: r.path,
1965
+ name: r.name,
1966
+ updatedAt: r.updatedAt,
1967
+ });
1968
+ if (out.length >= limit) break;
1969
+ }
1970
+ if (out.length >= limit) break;
1971
+ }
1972
+ }
1973
+
1974
+ if (flags.json) {
1975
+ process.stdout.write(JSON.stringify({ pattern, type, results: out }, null, 2) + '\n');
1976
+ return;
1977
+ }
1978
+ if (out.length === 0) {
1979
+ console.log('(no matches)');
1980
+ return;
1981
+ }
1982
+ // Plain text columns.
1983
+ const headers = showMountColumn
1984
+ ? ['mount', 'path', 'size', 'modified']
1985
+ : ['path', 'size', 'modified'];
1986
+ const rows = out.map(r => {
1987
+ const size = r.size != null ? formatBytes(r.size) : '-';
1988
+ const mod = r.lastModified || r.updatedAt || '';
1989
+ return showMountColumn
1990
+ ? [r.mount.name, r.relativePath, size, mod]
1991
+ : [r.relativePath, size, mod];
1992
+ });
1993
+ printColumnar(headers, rows);
1994
+ if (out.length >= limit) {
1995
+ console.log(`${DIM}(limit ${limit} reached — use --limit to widen)${RESET}`);
1996
+ }
1997
+ } finally {
1998
+ db.close();
1999
+ }
2000
+ }
2001
+
2002
+ function printColumnar(headers, rows) {
2003
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => String(r[i] || '').length)));
2004
+ const fmt = (cols) => cols.map((c, i) => String(c || '').padEnd(widths[i])).join(' ');
2005
+ console.log(`${BOLD}${fmt(headers)}${RESET}`);
2006
+ for (const r of rows) console.log(fmt(r));
2007
+ }
2008
+
2009
+ // ----------------------------------------------------------------------------
2010
+ // grep — substring search inside extracted text
2011
+ // ----------------------------------------------------------------------------
2012
+
2013
+ async function handleSemanticGrep(flags, query) {
2014
+ let mountPointIds;
2015
+ if (flags.mount && flags.mount !== 'all') {
2016
+ const { db } = await openDb(flags);
2017
+ try {
2018
+ const mount = requireMount(db, flags.mount);
2019
+ mountPointIds = [mount.id];
2020
+ } finally {
2021
+ db.close();
2022
+ }
2023
+ }
2024
+
2025
+ const top = flags.top > 0 ? flags.top : 20;
2026
+ const threshold = flags.threshold >= 0 ? flags.threshold : 0.5;
2027
+ const port = flags.port || 3000;
2028
+ const url = `http://localhost:${port}/api/v1/mount-points?action=semantic-search`;
2029
+
2030
+ let res;
2031
+ try {
2032
+ res = await fetch(url, {
2033
+ method: 'POST',
2034
+ headers: { 'Content-Type': 'application/json' },
2035
+ body: JSON.stringify({ query, mountPointIds, top, threshold }),
2036
+ });
2037
+ } catch (err) {
2038
+ console.error(`Could not reach Quilltap server at http://localhost:${port}: ${err.message}`);
2039
+ console.error('Semantic search requires the running server (the embedding provider lives there).');
2040
+ console.error('Start it with: npm run dev');
2041
+ process.exit(1);
2042
+ }
2043
+
2044
+ let payload = null;
2045
+ try {
2046
+ const text = await res.text();
2047
+ payload = text ? JSON.parse(text) : null;
2048
+ } catch { /* leave payload null */ }
2049
+
2050
+ if (!res.ok) {
2051
+ if (payload && payload.code === 'EMBEDDING_DIMENSION_MISMATCH') {
2052
+ console.error(`Error: ${payload.error}`);
2053
+ console.error(` Query embedding: ${payload.queryDimensions}-d`);
2054
+ console.error(` Stored chunks: ${payload.storedDimensions}-d`);
2055
+ console.error('Re-apply the embedding profile via "quilltap docs reindex" + "quilltap docs embed",');
2056
+ console.error('or switch the active embedding profile back to one that matches the corpus.');
2057
+ process.exit(1);
2058
+ }
2059
+ if (payload && payload.code === 'EMBEDDING_FAILED') {
2060
+ console.error(`Error: ${payload.error}`);
2061
+ console.error('Configure an embedding provider in /settings (Embeddings tab) and try again.');
2062
+ process.exit(1);
2063
+ }
2064
+ const msg = (payload && payload.error) || `HTTP ${res.status}`;
2065
+ console.error(`Error: ${msg}`);
2066
+ process.exit(1);
2067
+ }
2068
+
2069
+ if (flags.json) {
2070
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
2071
+ return;
2072
+ }
2073
+
2074
+ const results = (payload && payload.results) || [];
2075
+ if (results.length === 0) {
2076
+ console.log('(no matches)');
2077
+ return;
2078
+ }
2079
+
2080
+ for (const r of results) {
2081
+ const score = r.score.toFixed(3);
2082
+ const heading = r.headingContext ? ` [${r.headingContext}]` : '';
2083
+ console.log(`${score} ${r.mountPointName}:${r.relativePath}${heading}`);
2084
+ const snippet = (r.content || '').replace(/\s+/g, ' ').trim();
2085
+ const trimmed = snippet.length > 240 ? snippet.slice(0, 240) + '…' : snippet;
2086
+ console.log(` ${trimmed}`);
2087
+ }
2088
+ console.log('');
2089
+ console.log(`${results.length} match${results.length === 1 ? '' : 'es'}` +
2090
+ (payload.embeddingModel ? ` (model: ${payload.embeddingModel}, ${payload.embeddingDimensions}-d)` : ''));
2091
+ }
2092
+
2093
+ async function handleGrep(flags, positional) {
2094
+ const pattern = positional[0];
2095
+ if (!pattern) {
2096
+ console.error('Usage: quilltap docs grep [--mount <name|id|all>] [--ignore-case] [-l] [--max N] [--context N] [--json] <pattern>');
2097
+ console.error(' quilltap docs grep --semantic [--mount <name|id|all>] [--top N] [--threshold 0..1] [--json] <query>');
2098
+ process.exit(1);
2099
+ }
2100
+ if (flags.semantic) {
2101
+ return handleSemanticGrep(flags, pattern);
2102
+ }
2103
+ const max = flags.max > 0 ? flags.max : 5;
2104
+ const contextLines = flags.context > 0 ? flags.context : 0;
2105
+
2106
+ const { db } = await openDb(flags);
2107
+ try {
2108
+ const mounts = resolveSearchMounts(db, flags.mount);
2109
+ const showMountColumn = !flags.mount || flags.mount === 'all';
2110
+
2111
+ const linksByMount = db.prepare(`
2112
+ SELECT l.id AS linkId, l.fileId, l.relativePath, l.extractedText,
2113
+ f.source, f.fileType
2114
+ FROM doc_mount_file_links l
2115
+ JOIN doc_mount_files f ON f.id = l.fileId
2116
+ WHERE l.mountPointId = ?
2117
+ ORDER BY l.relativePath COLLATE NOCASE
2118
+ `);
2119
+ const docStmt = db.prepare(`SELECT content FROM doc_mount_documents WHERE fileId = ?`);
2120
+ const chunkStmt = db.prepare(`SELECT content FROM doc_mount_chunks WHERE linkId = ? ORDER BY chunkIndex`);
2121
+
2122
+ const needle = flags.ignoreCase ? pattern.toLowerCase() : pattern;
2123
+
2124
+ const results = [];
2125
+ for (const mount of mounts) {
2126
+ const links = linksByMount.all(mount.id);
2127
+ for (const link of links) {
2128
+ const text = resolveTextForLink(db, mount, link, docStmt, chunkStmt);
2129
+ if (text == null || text.length === 0) continue;
2130
+ const haystack = flags.ignoreCase ? text.toLowerCase() : text;
2131
+ if (haystack.indexOf(needle) === -1) continue;
2132
+
2133
+ const matches = [];
2134
+ if (!flags.pathsOnly) {
2135
+ const lines = text.split(/\r?\n/);
2136
+ for (let lineNum = 0; lineNum < lines.length && matches.length < max; lineNum++) {
2137
+ const hay = flags.ignoreCase ? lines[lineNum].toLowerCase() : lines[lineNum];
2138
+ if (hay.indexOf(needle) === -1) continue;
2139
+ let snippet = lines[lineNum];
2140
+ if (contextLines > 0) {
2141
+ const start = Math.max(0, lineNum - contextLines);
2142
+ const end = Math.min(lines.length, lineNum + contextLines + 1);
2143
+ snippet = lines.slice(start, end).join('\n');
2144
+ }
2145
+ matches.push({ line: lineNum + 1, snippet });
2146
+ }
2147
+ }
2148
+ results.push({
2149
+ mount: { id: mount.id, name: mount.name },
2150
+ relativePath: link.relativePath,
2151
+ matches,
2152
+ });
2153
+ }
2154
+ }
2155
+
2156
+ if (flags.json) {
2157
+ process.stdout.write(JSON.stringify({ pattern, results }, null, 2) + '\n');
2158
+ return;
2159
+ }
2160
+ if (results.length === 0) {
2161
+ console.log('(no matches)');
2162
+ return;
2163
+ }
2164
+
2165
+ for (const r of results) {
2166
+ const prefix = showMountColumn ? `${r.mount.name}:` : '';
2167
+ if (flags.pathsOnly) {
2168
+ console.log(`${prefix}${r.relativePath}`);
2169
+ continue;
2170
+ }
2171
+ for (const m of r.matches) {
2172
+ const oneLine = m.snippet.replace(/\n/g, ' ⏎ ');
2173
+ console.log(`${prefix}${r.relativePath}:${m.line}: ${oneLine}`);
2174
+ }
2175
+ }
2176
+ } finally {
2177
+ db.close();
2178
+ }
2179
+ }
2180
+
2181
+ // Resolve text for a single link row using the same decision tree as
2182
+ // readRendered — so grep sees everything the user can `docs read --rendered`.
2183
+ function resolveTextForLink(db, mount, link, docStmt, chunkStmt) {
2184
+ if (link.extractedText) return link.extractedText;
2185
+ if (link.source === 'database' && TEXT_FILE_TYPES.has(link.fileType)) {
2186
+ const doc = docStmt.get(link.fileId);
2187
+ if (doc) return doc.content;
2188
+ }
2189
+ if (link.source === 'filesystem' && TEXT_FILE_TYPES.has(link.fileType)) {
2190
+ const fullPath = path.join(mount.basePath, link.relativePath);
2191
+ try {
2192
+ if (fs.existsSync(fullPath)) {
2193
+ return fs.readFileSync(fullPath, 'utf8');
2194
+ }
2195
+ } catch { /* unreadable file — skip */ }
2196
+ }
2197
+ const chunks = chunkStmt.all(link.linkId);
2198
+ if (chunks.length > 0) return chunks.map(c => c.content).join('\n\n');
2199
+ return null;
2200
+ }
2201
+
2202
+ // ----------------------------------------------------------------------------
2203
+ // reindex / embed — server-required, talks to /api/v1/mount-points/[id]
2204
+ // ----------------------------------------------------------------------------
2205
+
2206
+ async function callMountAction(port, mountId, action, body) {
2207
+ const url = `http://localhost:${port}/api/v1/mount-points/${encodeURIComponent(mountId)}?action=${action}`;
2208
+ let res;
2209
+ try {
2210
+ res = await fetch(url, {
2211
+ method: 'POST',
2212
+ headers: { 'Content-Type': 'application/json' },
2213
+ body: JSON.stringify(body || {}),
2214
+ });
2215
+ } catch (err) {
2216
+ return { reachable: false, error: err.message };
2217
+ }
2218
+ let text = '';
2219
+ let parsed = null;
2220
+ try {
2221
+ text = await res.text();
2222
+ parsed = text ? JSON.parse(text) : null;
2223
+ } catch { /* leave parsed null */ }
2224
+ if (!res.ok) {
2225
+ const msg = parsed && parsed.error ? parsed.error : `HTTP ${res.status}`;
2226
+ return { reachable: true, ok: false, status: res.status, error: msg };
2227
+ }
2228
+ return { reachable: true, ok: true, result: parsed && parsed.data ? parsed.data : parsed };
2229
+ }
2230
+
2231
+ async function pollJob(port, jobId, opts = {}) {
2232
+ const intervalMs = opts.intervalMs || 1500;
2233
+ const timeoutMs = opts.timeoutMs || 0;
2234
+ const start = Date.now();
2235
+ while (true) {
2236
+ let res;
2237
+ try {
2238
+ res = await fetch(`http://localhost:${port}/api/v1/system/jobs/${encodeURIComponent(jobId)}`);
2239
+ } catch (err) {
2240
+ return { ok: false, error: `Unreachable while polling: ${err.message}` };
2241
+ }
2242
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status} polling job` };
2243
+ let body;
2244
+ try { body = await res.json(); } catch { body = null; }
2245
+ const job = body && (body.job || (body.data && body.data.job));
2246
+ if (!job) return { ok: false, error: 'Malformed job response' };
2247
+ if (job.status === 'COMPLETED' || job.status === 'FAILED' || job.status === 'CANCELLED') {
2248
+ return { ok: true, terminal: true, job };
2249
+ }
2250
+ if (timeoutMs && Date.now() - start > timeoutMs) {
2251
+ return { ok: false, error: 'Polling timed out' };
2252
+ }
2253
+ await new Promise(r => setTimeout(r, intervalMs));
2254
+ }
2255
+ }
2256
+
2257
+ async function handleReindex(flags, positional) {
2258
+ const mountSpec = positional[0];
2259
+ const pathArg = positional[1];
2260
+ if (!mountSpec) {
2261
+ console.error('Usage: quilltap docs reindex <mount> [path] [--force] [--json]');
2262
+ process.exit(1);
2263
+ }
2264
+
2265
+ const { db } = await openDb(flags);
2266
+ let mount;
2267
+ try {
2268
+ mount = requireMount(db, mountSpec);
2269
+ } finally {
2270
+ db.close();
2271
+ }
2272
+
2273
+ const http = await callMountAction(flags.port, mount.id, 'reindex', {
2274
+ path: pathArg,
2275
+ force: !!flags.force,
2276
+ });
2277
+ if (!http.reachable) {
2278
+ console.error(`Cannot reach Quilltap server at localhost:${flags.port}. The reindex action requires the running server (it owns the background job queue).`);
2279
+ console.error(` ${http.error || ''}`);
2280
+ process.exit(1);
2281
+ }
2282
+ if (!http.ok) {
2283
+ console.error(`Reindex failed: ${http.error}`);
2284
+ process.exit(1);
2285
+ }
2286
+
2287
+ const r = http.result;
2288
+ if (flags.json) {
2289
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
2290
+ return;
2291
+ }
2292
+ console.log(`${BOLD}Reindex${RESET} ${mount.name}${pathArg ? `:${pathArg}` : ''}`);
2293
+ console.log(` processed: ${r.processed}`);
2294
+ console.log(` succeeded: ${r.succeeded}`);
2295
+ console.log(` failed: ${r.failed}`);
2296
+ console.log(` skipped: ${r.skipped}`);
2297
+ if (r.errors && r.errors.length > 0) {
2298
+ console.log(` errors:`);
2299
+ for (const e of r.errors.slice(0, 10)) {
2300
+ console.log(` ${e.relativePath}: ${e.error}`);
2301
+ }
2302
+ if (r.errors.length > 10) {
2303
+ console.log(` … ${r.errors.length - 10} more`);
2304
+ }
2305
+ }
2306
+ }
2307
+
2308
+ async function handleEmbed(flags, positional) {
2309
+ const mountSpec = positional[0];
2310
+ const pathArg = positional[1];
2311
+ if (!mountSpec) {
2312
+ console.error('Usage: quilltap docs embed <mount> [path] [--force] [--wait] [--json]');
2313
+ process.exit(1);
2314
+ }
2315
+
2316
+ const { db } = await openDb(flags);
2317
+ let mount;
2318
+ try {
2319
+ mount = requireMount(db, mountSpec);
2320
+ } finally {
2321
+ db.close();
2322
+ }
2323
+
2324
+ const http = await callMountAction(flags.port, mount.id, 'embed', {
2325
+ path: pathArg,
2326
+ force: !!flags.force,
2327
+ });
2328
+ if (!http.reachable) {
2329
+ console.error(`Cannot reach Quilltap server at localhost:${flags.port}. The embed action requires the running server (it owns the background job queue).`);
2330
+ console.error(` ${http.error || ''}`);
2331
+ process.exit(1);
2332
+ }
2333
+ if (!http.ok) {
2334
+ console.error(`Embed failed: ${http.error}`);
2335
+ process.exit(1);
2336
+ }
2337
+
2338
+ const r = http.result;
2339
+ if (!flags.wait) {
2340
+ if (flags.json) {
2341
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
2342
+ return;
2343
+ }
2344
+ console.log(`${BOLD}Embed${RESET} ${mount.name}${pathArg ? `:${pathArg}` : ''}`);
2345
+ console.log(` queued: ${r.queued}`);
2346
+ console.log(` skipped: ${r.skipped}`);
2347
+ if (r.jobs && r.jobs.length > 0 && r.jobs.length <= 5) {
2348
+ console.log(` jobs:`);
2349
+ for (const j of r.jobs) console.log(` ${j.id} (${j.status})`);
2350
+ } else if (r.jobs && r.jobs.length > 5) {
2351
+ console.log(` jobs: ${r.jobs.length} queued (use --json for ids)`);
2352
+ }
2353
+ return;
2354
+ }
2355
+
2356
+ // --wait: poll each job to terminal status.
2357
+ const jobs = r.jobs || [];
2358
+ if (jobs.length === 0) {
2359
+ if (flags.json) {
2360
+ process.stdout.write(JSON.stringify({ ...r, waited: true, completed: 0, failed: 0 }, null, 2) + '\n');
2361
+ } else {
2362
+ console.log('No jobs queued.');
2363
+ }
2364
+ return;
2365
+ }
2366
+ console.log(`Waiting on ${jobs.length} job${jobs.length === 1 ? '' : 's'}…`);
2367
+ let done = 0;
2368
+ let failed = 0;
2369
+ for (const j of jobs) {
2370
+ const poll = await pollJob(flags.port, j.id);
2371
+ if (!poll.ok) {
2372
+ console.error(`Job ${j.id}: ${poll.error}`);
2373
+ failed++;
2374
+ continue;
2375
+ }
2376
+ if (poll.job.status === 'COMPLETED') done++;
2377
+ else failed++;
2378
+ }
2379
+ if (flags.json) {
2380
+ process.stdout.write(JSON.stringify({ ...r, waited: true, completed: done, failed }, null, 2) + '\n');
2381
+ } else {
2382
+ console.log(`Embed done: ${done} completed, ${failed} failed.`);
2383
+ }
2384
+ if (failed > 0) process.exit(1);
2385
+ }
2386
+
2387
+ // ----------------------------------------------------------------------------
2388
+ // status
2389
+ // ----------------------------------------------------------------------------
2390
+
2391
+ async function handleStatus(flags) {
2392
+ const { db } = await openDb(flags);
2393
+ try {
2394
+ // Resolve target mounts: --mount narrows; default is all.
2395
+ let mounts;
2396
+ if (flags.mount) {
2397
+ mounts = [requireMount(db, flags.mount)];
2398
+ } else {
2399
+ mounts = db.prepare(
2400
+ `SELECT * FROM doc_mount_points ORDER BY name COLLATE NOCASE`
2401
+ ).all();
2402
+ }
2403
+
2404
+ const topN = flags.top >= 0 ? flags.top : 5;
2405
+
2406
+ // Per-mount aggregates.
2407
+ const extractionGroupByStatus = db.prepare(
2408
+ `SELECT extractionStatus, COUNT(*) AS n FROM doc_mount_file_links WHERE mountPointId = ? GROUP BY extractionStatus`
2409
+ );
2410
+ const textNativeCount = db.prepare(
2411
+ `SELECT COUNT(*) AS n
2412
+ FROM doc_mount_file_links l
2413
+ JOIN doc_mount_files f ON f.id = l.fileId
2414
+ WHERE l.mountPointId = ? AND f.fileType IN ('markdown', 'txt', 'json', 'jsonl')`
2415
+ );
2416
+ const nonTextLinkCount = db.prepare(
2417
+ `SELECT COUNT(*) AS n
2418
+ FROM doc_mount_file_links l
2419
+ JOIN doc_mount_files f ON f.id = l.fileId
2420
+ WHERE l.mountPointId = ? AND f.fileType NOT IN ('markdown', 'txt', 'json', 'jsonl')`
2421
+ );
2422
+ const chunkAgg = db.prepare(
2423
+ `SELECT COUNT(*) AS total,
2424
+ SUM(CASE WHEN embedding IS NOT NULL THEN 1 ELSE 0 END) AS embedded
2425
+ FROM doc_mount_chunks WHERE mountPointId = ?`
2426
+ );
2427
+ const oldestExtraction = db.prepare(
2428
+ `SELECT l.relativePath, l.updatedAt, l.extractionStatus, l.extractionError
2429
+ FROM doc_mount_file_links l
2430
+ WHERE l.mountPointId = ? AND l.extractionStatus = ?
2431
+ ORDER BY l.updatedAt ASC
2432
+ LIMIT ?`
2433
+ );
2434
+
2435
+ const report = [];
2436
+ for (const mount of mounts) {
2437
+ const statusRows = extractionGroupByStatus.all(mount.id);
2438
+ const byStatus = {};
2439
+ for (const r of statusRows) byStatus[r.extractionStatus] = r.n;
2440
+ const textNative = (textNativeCount.get(mount.id) || { n: 0 }).n;
2441
+ const nonText = (nonTextLinkCount.get(mount.id) || { n: 0 }).n;
2442
+ // converted/pending/failed counts apply to non-text-native files only.
2443
+ const extracted = byStatus['converted'] || 0;
2444
+ const extractionPending = byStatus['pending'] || 0;
2445
+ const extractionFailed = byStatus['failed'] || 0;
2446
+ const extractionSkipped = byStatus['skipped'] || 0;
2447
+ const extractionNone = byStatus['none'] || 0;
2448
+
2449
+ const chunkRow = chunkAgg.get(mount.id) || { total: 0, embedded: 0 };
2450
+ const totalChunks = chunkRow.total || 0;
2451
+ const embeddedChunks = chunkRow.embedded || 0;
2452
+ const embeddingPending = Math.max(0, totalChunks - embeddedChunks);
2453
+
2454
+ const oldestPending = topN > 0 ? oldestExtraction.all(mount.id, 'pending', topN) : [];
2455
+ const oldestFailed = topN > 0 ? oldestExtraction.all(mount.id, 'failed', topN) : [];
2456
+
2457
+ report.push({
2458
+ mount: { id: mount.id, name: mount.name, mountType: mount.mountType, basePath: mount.basePath },
2459
+ files: {
2460
+ 'text-native': textNative,
2461
+ extracted,
2462
+ 'extraction-pending': extractionPending,
2463
+ 'extraction-failed': extractionFailed,
2464
+ 'extraction-skipped': extractionSkipped,
2465
+ 'extraction-none': extractionNone,
2466
+ 'non-text-total': nonText,
2467
+ },
2468
+ chunks: {
2469
+ total: totalChunks,
2470
+ embedded: embeddedChunks,
2471
+ 'embedding-pending': embeddingPending,
2472
+ },
2473
+ oldestPendingExtractions: oldestPending,
2474
+ oldestFailedExtractions: oldestFailed,
2475
+ });
2476
+ }
2477
+
2478
+ if (flags.json) {
2479
+ process.stdout.write(JSON.stringify({ mounts: report }, null, 2) + '\n');
2480
+ return;
2481
+ }
2482
+
2483
+ // Human text output.
2484
+ for (let i = 0; i < report.length; i++) {
2485
+ if (i > 0) console.log('');
2486
+ const r = report[i];
2487
+ console.log(`${BOLD}Mount: ${r.mount.name}${RESET} ${DIM}(${r.mount.id})${RESET}`);
2488
+ console.log(' Files');
2489
+ console.log(` text-native: ${String(r.files['text-native']).padStart(7)}`);
2490
+ console.log(` extracted: ${String(r.files.extracted).padStart(7)}`);
2491
+ console.log(` extraction-pending: ${String(r.files['extraction-pending']).padStart(7)}`);
2492
+ console.log(` extraction-failed: ${String(r.files['extraction-failed']).padStart(7)}`);
2493
+ if (r.files['extraction-skipped']) {
2494
+ console.log(` extraction-skipped: ${String(r.files['extraction-skipped']).padStart(7)}`);
2495
+ }
2496
+ console.log(' Chunks');
2497
+ console.log(` total: ${String(r.chunks.total).padStart(7)}`);
2498
+ console.log(` embedded: ${String(r.chunks.embedded).padStart(7)}`);
2499
+ console.log(` embedding-pending: ${String(r.chunks['embedding-pending']).padStart(7)}`);
2500
+ if (r.oldestPendingExtractions.length > 0) {
2501
+ console.log(' Oldest pending extractions:');
2502
+ for (const row of r.oldestPendingExtractions) {
2503
+ console.log(` ${row.relativePath} ${DIM}queued ${row.updatedAt}${RESET}`);
2504
+ }
2505
+ }
2506
+ if (r.oldestFailedExtractions.length > 0) {
2507
+ console.log(' Oldest failed extractions:');
2508
+ for (const row of r.oldestFailedExtractions) {
2509
+ const reason = row.extractionError ? ` ("${truncateSnippet(row.extractionError, 60)}")` : '';
2510
+ console.log(` ${row.relativePath} ${DIM}failed ${row.updatedAt}${RESET}${reason}`);
2511
+ }
2512
+ }
2513
+ }
2514
+ } finally {
2515
+ db.close();
2516
+ }
2517
+ }
2518
+
2519
+ function truncateSnippet(s, n) {
2520
+ if (s == null) return '';
2521
+ const str = String(s).replace(/\s+/g, ' ');
2522
+ if (str.length <= n) return str;
2523
+ return str.slice(0, n) + '…';
2524
+ }
2525
+
2526
+ async function docsCommand(args) {
2527
+ if (args.length === 0) {
2528
+ printDocsHelp();
2529
+ process.exit(1);
2530
+ }
2531
+
2532
+ const { flags, positional } = parseFlags(args);
2533
+
2534
+ if (flags.help) {
2535
+ printDocsHelp();
2536
+ process.exit(0);
2537
+ }
2538
+
2539
+ if (positional.length === 0) {
2540
+ printDocsHelp();
2541
+ process.exit(1);
2542
+ }
2543
+
2544
+ const verb = positional.shift();
2545
+
2546
+ try {
2547
+ switch (verb) {
2548
+ case 'list':
2549
+ await handleList(flags);
2550
+ break;
2551
+ case 'show':
2552
+ await handleShow(flags, positional[0]);
2553
+ break;
2554
+ case 'files':
2555
+ await handleFiles(flags, positional[0]);
2556
+ break;
2557
+ case 'ls':
2558
+ case 'dir':
2559
+ await handleLs(flags, positional[0], positional[1]);
2560
+ break;
2561
+ case 'tree':
2562
+ await handleTree(flags, positional[0], positional[1]);
2563
+ break;
2564
+ case 'read':
2565
+ await handleRead(flags, positional[0], positional[1]);
2566
+ break;
612
2567
  case 'export':
613
2568
  await handleExport(flags, positional[0], positional[1]);
614
2569
  break;
615
2570
  case 'scan':
616
2571
  await handleScan(flags, positional[0]);
617
2572
  break;
2573
+ case 'write':
2574
+ await handleWrite(flags, positional);
2575
+ break;
2576
+ case 'delete':
2577
+ await handleDelete(flags, positional);
2578
+ break;
2579
+ case 'mkdir':
2580
+ await handleMkdir(flags, positional);
2581
+ break;
2582
+ case 'move':
2583
+ await handleFileOp(flags, positional, 'move');
2584
+ break;
2585
+ case 'copy':
2586
+ await handleFileOp(flags, positional, 'copy');
2587
+ break;
2588
+ case 'status':
2589
+ await handleStatus(flags);
2590
+ break;
2591
+ case 'find':
2592
+ await handleFind(flags, positional);
2593
+ break;
2594
+ case 'grep':
2595
+ await handleGrep(flags, positional);
2596
+ break;
2597
+ case 'reindex':
2598
+ await handleReindex(flags, positional);
2599
+ break;
2600
+ case 'embed':
2601
+ await handleEmbed(flags, positional);
2602
+ break;
618
2603
  default:
619
2604
  console.error(`Unknown docs subcommand: ${verb}`);
620
2605
  printDocsHelp();