quilltap 4.7.0-dev.101 → 4.7.0-dev.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -89,6 +89,20 @@ Quilltap stores its database, files, and logs in a platform-specific directory:
89
89
 
90
90
  Override with `--data-dir` or the `QUILLTAP_DATA_DIR` environment variable.
91
91
 
92
+ ## Named Instances
93
+
94
+ Register a data directory once and point the CLI at it by name, instead of repeating `--data-dir`:
95
+
96
+ ```bash
97
+ quilltap instances add Friday ~/iCloud/Quilltap/Friday # Register (prompts for the passphrase if encrypted)
98
+ quilltap instances list # Registered instances (* marks the default)
99
+ quilltap instances default Friday # Make it the fall-through for flag-free runs
100
+ quilltap instances rename Friday Weekday # Rename, preserving the stored passphrase
101
+ quilltap instances remove Friday # Unregister
102
+ ```
103
+
104
+ Every subcommand then accepts `--instance <name>` in place of `--data-dir`. The registry lives at `<app-support>/Quilltap/instances.json` (mode 0600; e.g. `~/Library/Application Support/Quilltap/instances.json` on macOS). **Resolution precedence:** `--data-dir` > `--instance` > registered default > `QUILLTAP_DATA_DIR` > the OS platform default. Pass the **instance root** (e.g. `~/iCloud/Quilltap/Friday`), not its `data/` subdirectory.
105
+
92
106
  ## Database Tool
93
107
 
94
108
  The encrypted SQLite databases (main, LLM logs, mount index) can be queried directly via `quilltap db`. There are two modes: high-level subcommands that auto-pick the right database and resolve characters/chats/projects by name, and a low-level path for arbitrary SQL.
@@ -216,6 +230,17 @@ quilltap memories status [--character <name|id>] # Per-hol
216
230
 
217
231
  Shared filter flags apply to `ls`, `find`, `grep`, and `status` where they make sense: `--character`, `--about` (with `self` / `none` shortcuts), `--source`, `--chat` (with `none` for manual entries), `--project`, `--since`, `--until`, `--min-importance`, `--min-reinforced`, `--has-embedding` / `--no-embedding`. Sort flags (`--sort reinforced|importance|created|accessed|reinforcement-count|links`, plus `-r` to reverse) apply to `ls`, `find`, and `grep`. Names accept fuzzy substrings; ambiguous names print candidates and exit 2. `--json` is supported by every verb. The legacy `quilltap db memories --character <name>` verb remains undisturbed.
218
232
 
233
+ ## Memory Extraction Dry-Run
234
+
235
+ `quilltap memory-diff <chatId>` is a diagnostic that dumps a chat's existing memories and dry-runs re-extraction against it **without writing anything** — useful for seeing how an extractor change would land on a real conversation.
236
+
237
+ ```bash
238
+ quilltap memory-diff <chatId> --instance Friday # Report to the current directory
239
+ quilltap memory-diff <chatId> --out /tmp/diff --concurrency 8
240
+ ```
241
+
242
+ Needs a running server (`--port`, default 3000) to reach the extraction pipeline. `--out <dir>` sets the report destination (default: cwd); `--concurrency N` bounds parallel turns (default 4, max 32).
243
+
219
244
  ## Maintenance & Cleanup
220
245
 
221
246
  `quilltap maintenance` is the manual trigger for the retention sweeps that otherwise run on the server's daily maintenance tick. It reaps data with no bearing on characters, stories, or memories.
@@ -228,6 +253,31 @@ quilltap maintenance run --instance Friday # Run the sweeps once (lock-gated;
228
253
 
229
254
  `maintenance run` is a DB writer: it claims `<dataDir>/quilltap.lock` and **refuses while a running Quilltap server holds it** — stop the server first. Because it can only run with the server down, it performs the sweeps expressible as direct SQL/filesystem work: reaping finished background jobs (COMPLETED after 7 days, DEAD after 30, keyed off `completedAt`), closed terminal sessions older than 30 days plus their transcript files, and orphaned mount-index files. The **stale-chat asset collapse** (superseded story-backgrounds and wardrobe avatars) needs the server's file-storage machinery and runs only on the server's daily tick — `status` reports a stale-chat count so you can see the backlog. Retention windows mirror `lib/background-jobs/maintenance/retention-constants.ts`.
230
255
 
256
+ ## Logs
257
+
258
+ `quilltap logs` tails or prints an instance's log files without hunting down the logs directory. Prefer it over `tail -f` — it follows across log rotation and can merge streams.
259
+
260
+ ```bash
261
+ quilltap logs # Last 100 lines of combined.log
262
+ quilltap logs --tail 0 --stream error # Whole error log
263
+ quilltap logs -f --grep "MEMORY_EXTRACTION" # Follow, filtered by regex
264
+ quilltap logs --stream combined,error # Merge streams with [stream] prefixes
265
+ ```
266
+
267
+ Flags: `--stream combined|error|stdout|stderr|startup` (comma-separated for multiple), `--tail N` (default 100; `0` = full file), `--follow` / `-f`, `--grep <pattern>` (JS regex). Resolves the logs directory via the same `--instance` / `--data-dir` plumbing as the rest of the CLI.
268
+
269
+ ## Migrations
270
+
271
+ `quilltap migrations` inspects migration state read-only. The actual runner stays at server startup (where the loading screen reports progress), so the CLI deliberately won't apply anything.
272
+
273
+ ```bash
274
+ quilltap migrations status # Counts: in-source vs recorded-applied vs not-yet-recorded
275
+ quilltap migrations pending # Just the not-yet-recorded list
276
+ quilltap migrations run --dry-run # List what would run (refuses without --dry-run)
277
+ ```
278
+
279
+ `--json` works on all three. "Not yet recorded" includes migrations whose `shouldRun()` is `false` on this instance — the CLI doesn't evaluate the predicate, so it can't distinguish "would skip" from "would run."
280
+
231
281
  ## Theme Management
232
282
 
233
283
  The CLI includes theme management commands:
package/bin/quilltap.js CHANGED
@@ -13,7 +13,7 @@ const {
13
13
  loadDbKey,
14
14
  } = require('../lib/db-helpers');
15
15
  const { resolveInstance } = require('../lib/instances');
16
- const { resolveModuleDir, ensureNativeModules } = require('../lib/native-modules');
16
+ const { resolveModuleDir, ensureNativeModules, ensureDatabaseNativeModule } = require('../lib/native-modules');
17
17
 
18
18
  const PACKAGE_DIR = path.resolve(__dirname, '..');
19
19
 
@@ -94,7 +94,12 @@ Subcommands:
94
94
  docs Inspect, read, and export document mounts
95
95
  memories Search, browse, and graph memories
96
96
  instances Register / inspect named Quilltap instances
97
+ logs Tail or print an instance log file
98
+ migrations Inspect migration status (status / pending / run)
99
+ maintenance Run retention / cleanup sweeps (status / run)
100
+ file-verify Force-download cloud-evicted data files (iCloud, etc.)
97
101
  memory-diff <chatId> Dump existing memories and dry-run re-extraction for a chat
102
+ completion <shell> Generate a shell completion script (bash / zsh / fish)
98
103
 
99
104
  Options:
100
105
  -p, --port <number> Port to listen on (default: 3000)
@@ -1103,7 +1108,7 @@ async function dbCommand(args) {
1103
1108
  // to the subcommand. Each subcommand parses these flags position-independently,
1104
1109
  // so they behave the same before or after the verb.
1105
1110
  const SUBCOMMANDS = new Set([
1106
- 'db', 'themes', 'docs', 'memories', 'instances', 'memory-diff', 'completion', 'logs', 'migrations', 'maintenance',
1111
+ 'db', 'themes', 'docs', 'memories', 'instances', 'memory-diff', 'completion', 'logs', 'migrations', 'maintenance', 'file-verify',
1107
1112
  ]);
1108
1113
  // Global flags that consume the following token as their value.
1109
1114
  const GLOBAL_VALUE_FLAGS = new Set(['-p', '--port', '-d', '--data-dir', '-i', '--instance', '--passphrase']);
@@ -1124,6 +1129,16 @@ const subName = subIdx >= 0 ? cliArgs[subIdx] : '';
1124
1129
  // Everything except the subcommand token itself (leading global flags kept).
1125
1130
  const subArgs = subIdx >= 0 ? [...cliArgs.slice(0, subIdx), ...cliArgs.slice(subIdx + 1)] : [];
1126
1131
 
1132
+ // Subcommands load the SQLCipher binding directly and never reach main()'s
1133
+ // native-module heal, so self-heal the database ABI here first. Cheap no-op when
1134
+ // healthy; rebuilds (with a friendly notice, not an error) only on a real
1135
+ // Node-ABI mismatch — e.g. after the user upgrades Node under a cached install.
1136
+ // `file-verify` is excluded: it's a pure-fs recovery tool that never opens the
1137
+ // database, so it must work even when the native binding is broken.
1138
+ if (subName && subName !== 'file-verify') {
1139
+ ensureDatabaseNativeModule();
1140
+ }
1141
+
1127
1142
  if (subName === 'db') {
1128
1143
  dbCommand(subArgs);
1129
1144
  } else if (subName === 'themes') {
@@ -1177,6 +1192,12 @@ if (subName === 'db') {
1177
1192
  console.error(`Error: ${err.message}`);
1178
1193
  process.exit(1);
1179
1194
  });
1195
+ } else if (subName === 'file-verify') {
1196
+ const { fileVerifyCommand } = require('../lib/file-verify-commands');
1197
+ fileVerifyCommand(subArgs).catch(err => {
1198
+ console.error(`Error: ${err.message}`);
1199
+ process.exit(1);
1200
+ });
1180
1201
  } else {
1181
1202
  main();
1182
1203
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Guard test: the shell-completion templates and the top-level `--help` must
3
+ * stay in sync with the real subcommand dispatch table in bin/quilltap.js.
4
+ *
5
+ * This is the check that was missing when the docs/completions drifted behind
6
+ * the CLI (e.g. the `maintenance` subcommand shipped without ever being added
7
+ * to any completion script or to `quilltap --help`). If you add a top-level
8
+ * subcommand to SUBCOMMANDS, this test fails until you also teach the three
9
+ * completion templates and printHelp() about it.
10
+ *
11
+ * @jest-environment node
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const BIN = path.join(__dirname, '..', '..', 'bin', 'quilltap.js');
20
+ const COMPLETION_DIR = path.join(__dirname, '..', 'completion');
21
+
22
+ function readBin() {
23
+ return fs.readFileSync(BIN, 'utf8');
24
+ }
25
+
26
+ /** Parse the authoritative `const SUBCOMMANDS = new Set([...])` literal. */
27
+ function readSubcommands(src) {
28
+ const m = src.match(/const SUBCOMMANDS = new Set\(\[([\s\S]*?)\]\)/);
29
+ if (!m) throw new Error('Could not locate SUBCOMMANDS set in bin/quilltap.js');
30
+ return m[1]
31
+ .split(',')
32
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
33
+ .filter(Boolean);
34
+ }
35
+
36
+ const SRC = readBin();
37
+ const SUBCOMMANDS = readSubcommands(SRC);
38
+
39
+ describe('CLI subcommand surface stays documented', () => {
40
+ it('parses a non-trivial subcommand set from bin/quilltap.js', () => {
41
+ expect(SUBCOMMANDS).toContain('db');
42
+ expect(SUBCOMMANDS).toContain('maintenance');
43
+ expect(SUBCOMMANDS.length).toBeGreaterThanOrEqual(10);
44
+ });
45
+
46
+ it.each(['bash', 'zsh', 'fish'])(
47
+ '%s completion template lists every top-level subcommand',
48
+ (shell) => {
49
+ const tpl = fs.readFileSync(path.join(COMPLETION_DIR, `${shell}.template`), 'utf8');
50
+ const missing = SUBCOMMANDS.filter((sub) => !tpl.includes(sub));
51
+ expect(missing).toEqual([]);
52
+ }
53
+ );
54
+
55
+ it('top-level --help lists every subcommand', () => {
56
+ const help = SRC.match(/function printHelp\(\) \{([\s\S]*?)\n\}/);
57
+ expect(help).toBeTruthy();
58
+ const missing = SUBCOMMANDS.filter((sub) => !help[1].includes(sub));
59
+ expect(missing).toEqual([]);
60
+ });
61
+ });
@@ -15,7 +15,7 @@ _quilltap_complete() {
15
15
  local global_opts="-d --data-dir -i --instance -p --port -o --open -v --version -h --help --update --passphrase"
16
16
 
17
17
  # Top-level subcommands
18
- local top_cmds="db docs themes instances memories memory-diff logs migrations completion"
18
+ local top_cmds="db docs themes instances memories memory-diff logs migrations maintenance file-verify completion"
19
19
 
20
20
  # Get the subcommand (first non-option word after quilltap)
21
21
  local subcommand=""
@@ -126,7 +126,7 @@ _quilltap_complete() {
126
126
  fi
127
127
  ;;
128
128
  docs)
129
- local docs_verbs="list show files ls dir tree read export scan write delete mkdir move copy status find grep reindex embed"
129
+ local docs_verbs="list show files ls dir tree read export scan write delete mkdir move copy link rmdir mvdir status find grep reindex embed"
130
130
  local docs_flags="--mount --instance --data-dir --passphrase --port --json --help \
131
131
  --force --rendered --links --folder --type --ext --limit --max --context --top --threshold \
132
132
  --ignore-case -l --wait -R --recursive --sort -r --reverse --depth --max-nodes --long --semantic"
@@ -227,6 +227,19 @@ _quilltap_complete() {
227
227
  COMPREPLY=($(compgen -W "$mig_flags" -- "$cur"))
228
228
  fi
229
229
  ;;
230
+ maintenance)
231
+ local maint_verbs="status run"
232
+ local maint_flags="--instance --data-dir --passphrase --json --help"
233
+ if [[ -z "$subverb" ]]; then
234
+ if [[ "$cur" == -* ]]; then
235
+ COMPREPLY=($(compgen -W "$maint_flags" -- "$cur"))
236
+ else
237
+ COMPREPLY=($(compgen -W "$maint_verbs" -- "$cur"))
238
+ fi
239
+ else
240
+ COMPREPLY=($(compgen -W "$maint_flags" -- "$cur"))
241
+ fi
242
+ ;;
230
243
  completion)
231
244
  if [[ "$cur" == -* ]]; then
232
245
  COMPREPLY=($(compgen -W "--help" -- "$cur"))
@@ -15,7 +15,7 @@ function __quilltap_no_subcommand
15
15
  end
16
16
  for i in (seq 2 (count $cmd))
17
17
  switch $cmd[$i]
18
- case db docs themes instances memories memory-diff logs migrations completion
18
+ case db docs themes instances memories memory-diff logs migrations maintenance file-verify completion
19
19
  return 1
20
20
  end
21
21
  end
@@ -27,7 +27,7 @@ function __quilltap_using_subcommand
27
27
  set -l cmd (commandline -opc)
28
28
  for i in (seq 2 (count $cmd))
29
29
  switch $cmd[$i]
30
- case db docs themes instances memories memory-diff logs migrations completion
30
+ case db docs themes instances memories memory-diff logs migrations maintenance file-verify completion
31
31
  test "$cmd[$i]" = "$target"
32
32
  return $status
33
33
  end
@@ -57,6 +57,8 @@ complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'memories' -d 'Search a
57
57
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'memory-diff' -d 'Memory extraction dry-run'
58
58
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'logs' -d 'Tail or print log files'
59
59
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'migrations' -d 'Inspect migration status'
60
+ complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'maintenance' -d 'Run retention/cleanup sweeps'
61
+ complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'file-verify' -d 'Force-download cloud-evicted data files'
60
62
  complete -c quilltap -n '__quilltap_no_subcommand' -f -a 'completion' -d 'Generate shell completion'
61
63
 
62
64
  # Global options
@@ -140,6 +142,9 @@ complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'delete' -d 'De
140
142
  complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'mkdir' -d 'Create folder'
141
143
  complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'move' -d 'Move file'
142
144
  complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'copy' -d 'Copy file'
145
+ complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'link' -d 'Hard-link a file into another mount'
146
+ complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'rmdir' -d 'Remove a folder'
147
+ complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'mvdir' -d 'Rename or move a folder'
143
148
 
144
149
  # docs flags
145
150
  complete -c quilltap -n '__quilltap_using_subcommand docs' -l 'mount' -d 'Mount name or id' -x
@@ -268,6 +273,10 @@ complete -c quilltap -n '__quilltap_using_subcommand migrations' -f -a 'pending'
268
273
  complete -c quilltap -n '__quilltap_using_subcommand migrations' -f -a 'run' -d 'Run pending migrations'
269
274
  complete -c quilltap -n '__quilltap_using_subcommand migrations' -l 'dry-run' -d 'Dry run (required for run)'
270
275
 
276
+ # ---------- maintenance verbs ----------
277
+ complete -c quilltap -n '__quilltap_using_subcommand maintenance' -f -a 'status' -d 'Show dry-run retention counts'
278
+ complete -c quilltap -n '__quilltap_using_subcommand maintenance' -f -a 'run' -d 'Run retention/cleanup sweeps (lock-gated)'
279
+
271
280
  # ---------- completion sub-args ----------
272
281
  complete -c quilltap -n '__quilltap_using_subcommand completion' -f -a 'bash' -d 'Bash completion'
273
282
  complete -c quilltap -n '__quilltap_using_subcommand completion' -f -a 'zsh' -d 'Zsh completion'
@@ -28,6 +28,8 @@ _quilltap() {
28
28
  'memory-diff:Dump existing memories and dry-run re-extraction'
29
29
  'logs:Tail or print an instance log file'
30
30
  'migrations:Inspect migration status'
31
+ 'maintenance:Run retention/cleanup sweeps'
32
+ 'file-verify:Force-download cloud-evicted data files'
31
33
  'completion:Generate shell completion scripts'
32
34
  )
33
35
 
@@ -76,6 +78,9 @@ _quilltap_subcommand() {
76
78
  migrations)
77
79
  _quilltap_migrations
78
80
  ;;
81
+ maintenance)
82
+ _quilltap_maintenance
83
+ ;;
79
84
  completion)
80
85
  _quilltap_completion
81
86
  ;;
@@ -163,6 +168,9 @@ _quilltap_docs() {
163
168
  'mkdir:Create folder'
164
169
  'move:Move file'
165
170
  'copy:Copy file'
171
+ 'link:Hard-link a file into another mount'
172
+ 'rmdir:Remove a folder'
173
+ 'mvdir:Rename or move a folder'
166
174
  )
167
175
 
168
176
  docs_opts=(
@@ -395,6 +403,27 @@ _quilltap_migrations() {
395
403
  _arguments $mig_opts
396
404
  }
397
405
 
406
+ _quilltap_maintenance() {
407
+ local -a subverbs maint_opts
408
+ subverbs=(
409
+ 'status:Show dry-run retention counts'
410
+ 'run:Run retention/cleanup sweeps (lock-gated)'
411
+ )
412
+
413
+ maint_opts=(
414
+ '(-i --instance)'{-i,--instance}'[Registered instance name]:instance:_quilltap_instance_names'
415
+ '(-d --data-dir)'{-d,--data-dir}'[Data directory]:directory:_directories'
416
+ '--passphrase[Database passphrase]:passphrase:'
417
+ '--json[JSON output]'
418
+ '(-h --help)'{-h,--help}'[Show help]'
419
+ )
420
+
421
+ if (( CURRENT == 2 )); then
422
+ _describe 'maintenance subcommand' subverbs
423
+ fi
424
+ _arguments $maint_opts
425
+ }
426
+
398
427
  _quilltap_completion() {
399
428
  _values 'shell' bash zsh fish
400
429
  }
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `quilltap file-verify` — force-download an instance's cloud-evicted data files.
5
+ *
6
+ * An instance can live in a cloud-synced folder (iCloud Drive on macOS today;
7
+ * OneDrive / Google Drive File Stream on Windows later). Those providers EVICT
8
+ * idle files to dataless placeholders. If a database file is still dataless when
9
+ * SQLite/SQLCipher opens it, the read fails with "file is not a database" or
10
+ * returns partially-materialized garbage — which has wedged whole startups.
11
+ *
12
+ * The server does this automatically at boot (Phase -1, before the .dbkey is
13
+ * read). This command is the manual/diagnostic twin for the CLI, which opens the
14
+ * encrypted databases directly and benefits from the same guarantee. It needs no
15
+ * passphrase and never decrypts anything — it just reads the bytes to nowhere,
16
+ * which is what faults a dataless file in.
17
+ *
18
+ * Logic MIRRORS the TypeScript source of truth at
19
+ * `lib/startup/materialize-cloud-files.ts`. Keep them in sync.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const { resolveDataDirAndPassphrase, printDefaultInstanceHint } = require('./db-helpers');
25
+
26
+ const DEFAULT_STALL_MS = 30_000;
27
+ const READ_CHUNK_BYTES = 8 * 1024 * 1024;
28
+
29
+ // ---------- detection (platform seam) ----------
30
+
31
+ // macOS dataless heuristic: a real file (size > 0) with no allocated blocks has
32
+ // not been materialized locally (mirrors the SF_DATALESS flag). Zero-byte files
33
+ // are never flagged. Other platforms fall through as a no-op for now.
34
+ function isDatalessStat(stat) {
35
+ return stat.size > 0 && stat.blocks === 0;
36
+ }
37
+
38
+ function listTopLevelFiles(dataDir) {
39
+ let entries;
40
+ try {
41
+ entries = fs.readdirSync(dataDir, { withFileTypes: true });
42
+ } catch {
43
+ return [];
44
+ }
45
+ const out = [];
46
+ for (const entry of entries) {
47
+ if (!entry.isFile()) continue; // skip directories (backups/) and symlinks
48
+ const full = path.join(dataDir, entry.name);
49
+ let stat;
50
+ try {
51
+ stat = fs.statSync(full);
52
+ } catch {
53
+ continue;
54
+ }
55
+ out.push({ name: entry.name, full, size: stat.size, dataless: isDatalessStat(stat) });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // ---------- streaming materialize with per-chunk stall guard ----------
61
+
62
+ function streamMaterialize(filePath, stallMs) {
63
+ return new Promise((resolve, reject) => {
64
+ const stream = fs.createReadStream(filePath, { highWaterMark: READ_CHUNK_BYTES });
65
+ let timer;
66
+ const arm = () => {
67
+ clearTimeout(timer);
68
+ timer = setTimeout(() => {
69
+ stream.destroy(new Error(`stalled — no data for ${Math.round(stallMs / 1000)}s`));
70
+ }, stallMs);
71
+ };
72
+ arm();
73
+ stream.on('data', () => arm()); // discard chunk; the read faults the bytes in
74
+ stream.on('end', () => {
75
+ clearTimeout(timer);
76
+ resolve();
77
+ });
78
+ stream.on('error', (err) => {
79
+ clearTimeout(timer);
80
+ reject(err);
81
+ });
82
+ });
83
+ }
84
+
85
+ // ---------- argument parsing ----------
86
+
87
+ function parseFlags(args) {
88
+ const flags = {
89
+ dataDir: '',
90
+ instance: '',
91
+ all: false,
92
+ json: false,
93
+ help: false,
94
+ stallMs: DEFAULT_STALL_MS,
95
+ };
96
+ let i = 0;
97
+ while (i < args.length) {
98
+ const a = args[i];
99
+ switch (a) {
100
+ case '-d': case '--data-dir': flags.dataDir = args[++i]; break;
101
+ case '-i': case '--instance': flags.instance = args[++i]; break;
102
+ case '--all': flags.all = true; break;
103
+ case '--json': flags.json = true; break;
104
+ case '--stall-ms': flags.stallMs = Number(args[++i]) || DEFAULT_STALL_MS; break;
105
+ case '-h': case '--help': flags.help = true; break;
106
+ default:
107
+ if (a.startsWith('-')) console.error(`unknown flag: ${a}`);
108
+ break;
109
+ }
110
+ i++;
111
+ }
112
+ return flags;
113
+ }
114
+
115
+ function printHelp() {
116
+ console.log(`Usage: quilltap file-verify [options]
117
+
118
+ Force-download the instance's cloud-evicted (dataless) data files so the
119
+ databases are fully local before anything opens them. Reads each placeholder to
120
+ nowhere, which faults it in through the cloud provider (iCloud Drive, etc.).
121
+ Safe to run repeatedly; a no-op when nothing is evicted. macOS only for now.
122
+
123
+ Only the TOP-LEVEL files of the data directory are considered (the backups/
124
+ subdirectory is left alone).
125
+
126
+ Options:
127
+ -d, --data-dir <path> Use a specific data directory (instance root)
128
+ -i, --instance <name> Use a named instance
129
+ --all Read every top-level file, not just dataless ones
130
+ --stall-ms <ms> Treat a download as stalled after this many ms with
131
+ no bytes (per-chunk, not per-file; default ${DEFAULT_STALL_MS})
132
+ --json Output as JSON
133
+ -h, --help Show this help message
134
+
135
+ Examples:
136
+ quilltap file-verify --instance Ignite
137
+ quilltap file-verify --instance Friday --json
138
+ quilltap file-verify -d ~/iCloud/Quilltap/Ignite
139
+ `);
140
+ }
141
+
142
+ // ---------- main entry point ----------
143
+
144
+ async function fileVerifyCommand(args) {
145
+ const flags = parseFlags(args);
146
+ if (flags.help) {
147
+ printHelp();
148
+ return;
149
+ }
150
+
151
+ let resolved;
152
+ try {
153
+ resolved = resolveDataDirAndPassphrase({ dataDir: flags.dataDir, instance: flags.instance });
154
+ } catch (err) {
155
+ console.error(`Error: ${err.message}`);
156
+ process.exit(1);
157
+ }
158
+ printDefaultInstanceHint(resolved);
159
+ // resolved.dataDir already points at the instance's data/ directory.
160
+ const targetDir = resolved.dataDir;
161
+
162
+ const files = listTopLevelFiles(targetDir);
163
+ const targets = flags.all ? files : files.filter((f) => f.dataless);
164
+
165
+ const summary = {
166
+ dataDir: targetDir,
167
+ checked: files.length,
168
+ targeted: targets.length,
169
+ downloaded: 0,
170
+ failed: 0,
171
+ failedNames: [],
172
+ };
173
+
174
+ if (targets.length === 0) {
175
+ if (flags.json) {
176
+ console.log(JSON.stringify(summary, null, 2));
177
+ } else {
178
+ console.log(
179
+ `Nothing to fetch — all ${files.length} top-level file(s) in ${targetDir} are already ashore.`,
180
+ );
181
+ }
182
+ return;
183
+ }
184
+
185
+ if (!flags.json) {
186
+ console.log(
187
+ `Fetching ${targets.length} file(s) down from the cloud (of ${files.length} top-level):`,
188
+ );
189
+ }
190
+
191
+ for (let i = 0; i < targets.length; i++) {
192
+ const file = targets[i];
193
+ const mb = (file.size / (1024 * 1024)).toFixed(1);
194
+ if (!flags.json) {
195
+ process.stdout.write(` Coaxing «${file.name}» (${mb} MB) down — ${i + 1}/${targets.length}… `);
196
+ }
197
+ try {
198
+ await streamMaterialize(file.full, flags.stallMs);
199
+ summary.downloaded++;
200
+ if (!flags.json) console.log('secured.');
201
+ } catch (err) {
202
+ summary.failed++;
203
+ summary.failedNames.push(file.name);
204
+ if (!flags.json) console.log(`failed (${err.message}).`);
205
+ }
206
+ }
207
+
208
+ if (flags.json) {
209
+ console.log(JSON.stringify(summary, null, 2));
210
+ } else {
211
+ console.log(
212
+ `Done — ${summary.downloaded} secured, ${summary.failed} failed.` +
213
+ (summary.failed ? ` Failed: ${summary.failedNames.join(', ')}` : ''),
214
+ );
215
+ }
216
+
217
+ if (summary.failed > 0) process.exit(1);
218
+ }
219
+
220
+ module.exports = {
221
+ fileVerifyCommand,
222
+ };
@@ -21,6 +21,84 @@ function resolveModuleDir(moduleName) {
21
21
  }
22
22
  }
23
23
 
24
+ // Locate the SQLCipher binding (better-sqlite3-multiple-ciphers, aliased as
25
+ // better-sqlite3). Returns the absolute path to better_sqlite3.node, or null.
26
+ function betterSqlite3BindingPath() {
27
+ const modDir = resolveModuleDir('better-sqlite3-multiple-ciphers')
28
+ || resolveModuleDir('better-sqlite3');
29
+ if (!modDir) return null;
30
+ return path.join(modDir, 'build', 'Release', 'better_sqlite3.node');
31
+ }
32
+
33
+ // Read the Node ABI (NODE_MODULE_VERSION) a node-gyp/NAN addon was compiled
34
+ // against, by scanning for its `node_register_module_v<ABI>` export — a few-KB
35
+ // byte read, no dlopen, no rebuild. Returns the ABI as a string, or null when
36
+ // the symbol is absent (e.g. an N-API build, which is ABI-stable) or unreadable.
37
+ function readCompiledAbi(bindingPath) {
38
+ try {
39
+ const buf = require('fs').readFileSync(bindingPath);
40
+ const m = /node_register_module_v(\d+)/.exec(buf.toString('latin1'));
41
+ return m ? m[1] : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ // True when the SQLCipher binding is missing or was built for a different Node
48
+ // ABI than the one we're running. Reads the compiled-for ABI straight from the
49
+ // binary; only falls back to an actual load probe if the symbol can't be read.
50
+ function betterSqlite3NeedsRebuild() {
51
+ const bindingPath = betterSqlite3BindingPath();
52
+ if (!bindingPath) return true; // unresolvable → needs (re)build
53
+ if (!require('fs').existsSync(bindingPath)) return true;
54
+ const compiledAbi = readCompiledAbi(bindingPath);
55
+ if (compiledAbi) return compiledAbi !== process.versions.modules;
56
+ // Symbol unreadable — fall back to the authoritative dlopen probe.
57
+ try {
58
+ require(bindingPath);
59
+ return false;
60
+ } catch (err) {
61
+ return !!(err.message && err.message.includes('NODE_MODULE_VERSION'));
62
+ }
63
+ }
64
+
65
+ // Rebuild the named native modules against the current Node ABI. Prints a
66
+ // friendly notice rather than throwing; returns true on success, false on
67
+ // failure. Backfills node-pty's spawn-helper afterward.
68
+ function rebuildModules(moduleNames) {
69
+ console.log(` Rebuilding native modules for Node.js ${process.version}...`);
70
+ try {
71
+ execSync(`npm rebuild ${moduleNames.join(' ')}`, {
72
+ cwd: PACKAGE_DIR,
73
+ stdio: 'inherit',
74
+ });
75
+ console.log(' Done.');
76
+ console.log('');
77
+ reconcileNodePtySpawnHelper();
78
+ return true;
79
+ } catch (err) {
80
+ console.error('');
81
+ console.error(` Warning: Failed to rebuild native modules: ${err.message}`);
82
+ console.error(' Try running: npm rebuild --prefix ' + PACKAGE_DIR);
83
+ console.error('');
84
+ return false;
85
+ }
86
+ }
87
+
88
+ // Fast pre-flight for the ONE ABI-fragile native module every DB path needs:
89
+ // better-sqlite3-multiple-ciphers (SQLCipher). sharp and node-pty are N-API and
90
+ // ABI-stable, so they can't hit this failure. Detects an ABI mismatch from the
91
+ // binary itself and rebuilds before anything tries to load it, so a Node upgrade
92
+ // self-heals instead of throwing. Cheap no-op when already healthy. Never throws.
93
+ function ensureDatabaseNativeModule() {
94
+ try {
95
+ if (!betterSqlite3NeedsRebuild()) return true;
96
+ } catch {
97
+ return true; // detection hiccup — let the real load be the source of truth
98
+ }
99
+ return rebuildModules(['better-sqlite3-multiple-ciphers']);
100
+ }
101
+
24
102
  // node-pty needs a `spawn-helper` executable beside the pty.node it loads, or
25
103
  // pty.spawn() fails with `posix_spawnp failed`. An ABI rebuild lands a fresh
26
104
  // build/Release/pty.node (which node-pty's loader prefers over prebuilds/) but
@@ -74,19 +152,9 @@ function ensureNativeModules() {
74
152
  // Check better-sqlite3-multiple-ciphers (provides SQLCipher encryption support).
75
153
  // The main app depends on this via an npm alias as 'better-sqlite3', so we must
76
154
  // ensure the SQLCipher-capable version is available and link it as 'better-sqlite3'.
77
- // We must load the native binding directly to detect NODE_MODULE_VERSION mismatches.
78
- try {
79
- const modDir = resolveModuleDir('better-sqlite3-multiple-ciphers')
80
- || resolveModuleDir('better-sqlite3');
81
- if (!modDir) throw Object.assign(new Error('not found'), { code: 'MODULE_NOT_FOUND' });
82
- const bindingsPath = path.join(modDir, 'build', 'Release', 'better_sqlite3.node');
83
- require(bindingsPath);
84
- } catch (err) {
85
- if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
86
- needsRebuild.push('better-sqlite3-multiple-ciphers');
87
- } else if (err.code === 'MODULE_NOT_FOUND') {
88
- needsRebuild.push('better-sqlite3-multiple-ciphers');
89
- }
155
+ // This is the only ABI-fragile binding detected straight from the binary.
156
+ if (betterSqlite3NeedsRebuild()) {
157
+ needsRebuild.push('better-sqlite3-multiple-ciphers');
90
158
  }
91
159
 
92
160
  // Check sharp: loads its native binding eagerly on require.
@@ -120,27 +188,18 @@ function ensureNativeModules() {
120
188
  return true;
121
189
  }
122
190
 
123
- console.log(` Rebuilding native modules for Node.js ${process.version}...`);
124
-
125
- try {
126
- execSync(`npm rebuild ${needsRebuild.join(' ')}`, {
127
- cwd: PACKAGE_DIR,
128
- stdio: 'inherit',
129
- });
130
- console.log(' Done.');
131
- console.log('');
132
- reconcileNodePtySpawnHelper();
133
- return true;
134
- } catch (err) {
135
- console.error('');
136
- console.error(` Warning: Failed to rebuild native modules: ${err.message}`);
137
- console.error(' Try running: npm rebuild --prefix ' + PACKAGE_DIR);
138
- console.error('');
139
- return false;
140
- }
191
+ return rebuildModules(needsRebuild);
141
192
  }
142
193
 
143
- module.exports = { resolveModuleDir, ensureNativeModules, reconcileNodePtySpawnHelper, PACKAGE_DIR };
194
+ module.exports = {
195
+ resolveModuleDir,
196
+ readCompiledAbi,
197
+ betterSqlite3NeedsRebuild,
198
+ ensureDatabaseNativeModule,
199
+ ensureNativeModules,
200
+ reconcileNodePtySpawnHelper,
201
+ PACKAGE_DIR,
202
+ };
144
203
 
145
204
  // Allow this file to be invoked directly as a postinstall script:
146
205
  // node lib/native-modules.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.7.0-dev.101",
3
+ "version": "4.7.0-dev.129",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",
@@ -37,11 +37,11 @@
37
37
  ],
38
38
  "dependencies": {
39
39
  "@napi-rs/canvas": "^0.1.100",
40
- "better-sqlite3-multiple-ciphers": "^12.10.0",
40
+ "better-sqlite3-multiple-ciphers": "^12.11.1",
41
41
  "node-pty": "^1.1.0",
42
42
  "sharp": "^0.34.5",
43
43
  "tar": "^7.5.16",
44
- "yauzl": "^3.3.2"
44
+ "yauzl": "^3.4.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.0.0"