quilltap 4.5.1 → 4.6.0-dev.105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/quilltap.js CHANGED
@@ -13,6 +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
17
 
17
18
  const PACKAGE_DIR = path.resolve(__dirname, '..');
18
19
 
@@ -137,85 +138,6 @@ function openBrowser(url) {
137
138
  });
138
139
  }
139
140
 
140
- // Resolve a native module's directory, handling npm hoisting.
141
- // Returns the directory containing package.json, or null if not found.
142
- function resolveModuleDir(moduleName) {
143
- try {
144
- const pkgJson = require.resolve(moduleName + '/package.json');
145
- return path.dirname(pkgJson);
146
- } catch {
147
- return null;
148
- }
149
- }
150
-
151
- // Check if native modules are compiled for the current Node.js version.
152
- // This handles the case where npx caches the package but the user upgrades
153
- // Node.js — the cached native modules will have a stale NODE_MODULE_VERSION.
154
- function ensureNativeModules() {
155
- const needsRebuild = [];
156
-
157
- // Check better-sqlite3-multiple-ciphers (provides SQLCipher encryption support).
158
- // The main app depends on this via an npm alias as 'better-sqlite3', so we must
159
- // ensure the SQLCipher-capable version is available and link it as 'better-sqlite3'.
160
- // We must load the native binding directly to detect NODE_MODULE_VERSION mismatches.
161
- try {
162
- const modDir = resolveModuleDir('better-sqlite3-multiple-ciphers')
163
- || resolveModuleDir('better-sqlite3');
164
- if (!modDir) throw Object.assign(new Error('not found'), { code: 'MODULE_NOT_FOUND' });
165
- const bindingsPath = path.join(modDir, 'build', 'Release', 'better_sqlite3.node');
166
- require(bindingsPath);
167
- } catch (err) {
168
- if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
169
- needsRebuild.push('better-sqlite3-multiple-ciphers');
170
- } else if (err.code === 'MODULE_NOT_FOUND') {
171
- needsRebuild.push('better-sqlite3-multiple-ciphers');
172
- }
173
- }
174
-
175
- // Check sharp: loads its native binding eagerly on require, but we use
176
- // the same explicit-path approach for consistency and reliability.
177
- try {
178
- require('sharp');
179
- } catch (err) {
180
- if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
181
- needsRebuild.push('sharp');
182
- } else if (err.code === 'MODULE_NOT_FOUND') {
183
- needsRebuild.push('sharp');
184
- }
185
- }
186
-
187
- // Check node-pty: backs the Ariel terminal feature. Loaded dynamically by
188
- // pty-manager in the standalone server, so resolution must succeed and the
189
- // native binding's NODE_MODULE_VERSION must match the runtime.
190
- try {
191
- require('node-pty');
192
- } catch (err) {
193
- if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
194
- needsRebuild.push('node-pty');
195
- } else if (err.code === 'MODULE_NOT_FOUND') {
196
- needsRebuild.push('node-pty');
197
- }
198
- }
199
-
200
- if (needsRebuild.length === 0) return;
201
-
202
- console.log(` Rebuilding native modules for Node.js ${process.version}...`);
203
-
204
- try {
205
- execSync(`npm rebuild ${needsRebuild.join(' ')}`, {
206
- cwd: PACKAGE_DIR,
207
- stdio: 'inherit',
208
- });
209
- console.log(' Done.');
210
- console.log('');
211
- } catch (err) {
212
- console.error('');
213
- console.error(` Warning: Failed to rebuild native modules: ${err.message}`);
214
- console.error(' Try running: npm rebuild --prefix ' + PACKAGE_DIR);
215
- console.error('');
216
- }
217
- }
218
-
219
141
  // Symlink native modules into the standalone directory's node_modules
220
142
  // so that standard Node.js resolution finds them without relying on NODE_PATH.
221
143
  function linkNativeModules(standaloneDir) {
@@ -266,25 +188,67 @@ function linkNativeModules(standaloneDir) {
266
188
  || resolveModuleDir('better-sqlite3');
267
189
  linkModule('better-sqlite3', betterSqlite3Dir);
268
190
 
269
- // Link node-pty — the standalone tarball strips it (platform-specific),
270
- // and pty-manager loads it via a dynamic require, so it needs to resolve
271
- // from standaloneDir/node_modules.
272
- const nodePtyDir = resolveModuleDir('node-pty');
273
- linkModule('node-pty', nodePtyDir);
274
- if (nodePtyDir) {
275
- // Some npm cache extractions strip the executable bit on spawn-helper,
276
- // causing pty.spawn() to fail with `posix_spawnp failed`. Restore it.
277
- const prebuildsDir = path.join(nodePtyDir, 'prebuilds');
278
- if (fs.existsSync(prebuildsDir)) {
279
- try {
280
- for (const entry of fs.readdirSync(prebuildsDir, { withFileTypes: true })) {
281
- if (!entry.isDirectory()) continue;
282
- const helper = path.join(prebuildsDir, entry.name, 'spawn-helper');
283
- if (fs.existsSync(helper)) {
284
- try { fs.chmodSync(helper, 0o755); } catch { /* best-effort */ }
191
+ // Link node-pty — but only when we have to. The standalone tarball ships
192
+ // node-pty intact with cross-platform prebuilds (darwin-arm64, darwin-x64,
193
+ // win32-arm64, win32-x64). On those platforms the tarball-shipped copy is
194
+ // already correct, and we MUST NOT replace it with a symlink to the
195
+ // npm-installed copy: `sudo npm install -g quilltap` on macOS can strip the
196
+ // executable bit off `spawn-helper`, and we can't chmod a root-owned file
197
+ // back as a non-root runtime user pty.spawn() then fails with
198
+ // `posix_spawnp failed`. Only Linux (which has no node-pty prebuild) and
199
+ // rebuild-failure cases need the symlink.
200
+ const standaloneNodePtyPath = path.join(standaloneNodeModules, 'node-pty');
201
+ const standaloneHasUsablePty = (() => {
202
+ try {
203
+ const stat = fs.lstatSync(standaloneNodePtyPath);
204
+ if (stat.isSymbolicLink()) return false; // prior broken-symlink state — replace it
205
+ if (!stat.isDirectory()) return false;
206
+ } catch {
207
+ return false;
208
+ }
209
+ const platformPrebuildDir = path.join(
210
+ standaloneNodePtyPath,
211
+ 'prebuilds',
212
+ `${process.platform}-${process.arch}`,
213
+ );
214
+ return fs.existsSync(platformPrebuildDir);
215
+ })();
216
+
217
+ if (!standaloneHasUsablePty) {
218
+ const nodePtyDir = resolveModuleDir('node-pty');
219
+ linkModule('node-pty', nodePtyDir);
220
+ if (nodePtyDir) {
221
+ // Some npm cache extractions (notably `sudo npm install -g`) strip the
222
+ // executable bit on spawn-helper. Restore it where we can. If chmod
223
+ // fails because we don't own the file (typical when the CLI was
224
+ // installed with sudo and is run as a non-root user), warn loudly —
225
+ // silent failure here produces the `posix_spawnp failed` runtime error
226
+ // with no actionable hint.
227
+ const prebuildsDir = path.join(nodePtyDir, 'prebuilds');
228
+ if (fs.existsSync(prebuildsDir)) {
229
+ try {
230
+ for (const entry of fs.readdirSync(prebuildsDir, { withFileTypes: true })) {
231
+ if (!entry.isDirectory()) continue;
232
+ const helper = path.join(prebuildsDir, entry.name, 'spawn-helper');
233
+ if (!fs.existsSync(helper)) continue;
234
+ try {
235
+ fs.chmodSync(helper, 0o755);
236
+ } catch (err) {
237
+ if (err && err.code === 'EPERM') {
238
+ console.error('');
239
+ console.error(` Warning: Could not make node-pty spawn-helper executable:`);
240
+ console.error(` ${helper}`);
241
+ console.error(` The file is owned by another user (likely root from a sudo'd`);
242
+ console.error(` global install). Terminal sessions will fail to spawn with`);
243
+ console.error(` "posix_spawnp failed" until this is fixed. Run:`);
244
+ console.error(` sudo chmod 755 "${helper}"`);
245
+ console.error('');
246
+ }
247
+ // Other errors are non-fatal — node-pty may still work if the bit was already set.
248
+ }
285
249
  }
286
- }
287
- } catch { /* best-effort */ }
250
+ } catch { /* best-effort */ }
251
+ }
288
252
  }
289
253
  }
290
254
 
@@ -740,6 +704,11 @@ Subcommands (high-level shortcuts; auto-pick the right database):
740
704
  log <id> Full request/response of a single LLM log
741
705
  memories --character <id> Memories held by a character
742
706
  (flags: --about <id|name> --source AUTO|MANUAL)
707
+ characters status Per-character vault readiness report:
708
+ flag value, vault present, single-file count,
709
+ Prompts/ and Scenarios/ folder counts, and any
710
+ divergence between DB columns and vault content.
711
+ (flags: --id <id|name> --diverged --blocked --limit N)
743
712
  optimize [target...] Run maintenance (VACUUM + ANALYZE + PRAGMA optimize)
744
713
  on the named databases, or all of them if no
745
714
  target is given. Targets: main, llm-logs,
@@ -762,6 +731,8 @@ Low-level options (legacy; still supported):
762
731
  --tables List all tables in the active database
763
732
  --count <table> Show row count for a table
764
733
  --repl Interactive SQL prompt (extras: .cols, .find)
734
+ --json Emit machine-readable JSON instead of a table
735
+ (works with --tables, --count, and raw SQL)
765
736
  --llm-logs Target the LLM logs database
766
737
  --mount-points Target the document mount-index database
767
738
  --data-dir <path> Override data directory (pass instance root)
@@ -869,6 +840,7 @@ async function dbCommand(args) {
869
840
  let lockStatus = false;
870
841
  let lockClean = false;
871
842
  let lockOverride = false;
843
+ let asJson = false;
872
844
 
873
845
  let i = 0;
874
846
  while (i < cleaned.length) {
@@ -878,6 +850,7 @@ async function dbCommand(args) {
878
850
  case '--tables': showTables = true; break;
879
851
  case '--count': countTable = cleaned[++i]; break;
880
852
  case '--repl': repl = true; break;
853
+ case '--json': asJson = true; break;
881
854
  case '--help': case '-h': showHelp = true; break;
882
855
  case '--lock-status': lockStatus = true; break;
883
856
  case '--lock-clean': lockClean = true; break;
@@ -971,22 +944,27 @@ async function dbCommand(args) {
971
944
  try {
972
945
  if (showTables) {
973
946
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
974
- for (const t of tables) console.log(t.name);
947
+ if (asJson) console.log(JSON.stringify(tables.map(t => t.name), null, 2));
948
+ else for (const t of tables) console.log(t.name);
975
949
  } else if (countTable) {
976
950
  const row = db.prepare(`SELECT count(*) as count FROM "${countTable}"`).get();
977
- console.log(row.count);
951
+ if (asJson) console.log(JSON.stringify({ table: countTable, count: row.count }, null, 2));
952
+ else console.log(row.count);
978
953
  } else if (sql) {
979
954
  const stmt = db.prepare(sql);
980
955
  if (stmt.reader) {
981
956
  const rows = stmt.all();
982
- if (rows.length === 0) {
957
+ if (asJson) {
958
+ console.log(JSON.stringify(rows, null, 2));
959
+ } else if (rows.length === 0) {
983
960
  console.log('(no results)');
984
961
  } else {
985
962
  console.table(rows);
986
963
  }
987
964
  } else {
988
965
  const info = stmt.run();
989
- console.log(`Changes: ${info.changes}`);
966
+ if (asJson) console.log(JSON.stringify({ changes: info.changes, lastInsertRowid: Number(info.lastInsertRowid) }, null, 2));
967
+ else console.log(`Changes: ${info.changes}`);
990
968
  }
991
969
  } else if (repl) {
992
970
  const readline = require('readline');
@@ -10,6 +10,8 @@ const {
10
10
  UUID_RE,
11
11
  ambiguous,
12
12
  resolveCharacter,
13
+ resolveCharactersByAlias,
14
+ readVaultAliases,
13
15
  resolveChat,
14
16
  resolveProject,
15
17
  } = require('./db-helpers');
@@ -318,21 +320,56 @@ function cmdFind(args, ctx) {
318
320
  }
319
321
 
320
322
  function findCharacters(query, { json, limit, ctx }) {
323
+ // Aliases live in each character's vault `properties.json` post-4.6, not on
324
+ // the `characters` row, so name matching comes from the main DB and alias
325
+ // matching/display comes from the mount-index DB.
321
326
  const db = ctx.openMain();
327
+ let mounts = null;
322
328
  try {
329
+ mounts = ctx.openMounts();
330
+ } catch {
331
+ mounts = null; // mount-index DB absent — name-only matching, no aliases
332
+ }
333
+ try {
334
+ const SELECT_COLS =
335
+ 'SELECT id, name, npc, isFavorite, controlledBy, characterDocumentMountPointId AS mp FROM characters';
323
336
  let rows;
324
337
  if (!query) {
325
- rows = db.prepare('SELECT id, name, npc, isFavorite, controlledBy FROM characters ORDER BY name LIMIT ?').all(limit);
338
+ rows = db.prepare(`${SELECT_COLS} ORDER BY name LIMIT ?`).all(limit);
326
339
  } else if (UUID_RE.test(query)) {
327
- rows = db.prepare('SELECT id, name, npc, isFavorite, controlledBy, aliases FROM characters WHERE id = ?').all(query);
340
+ rows = db.prepare(`${SELECT_COLS} WHERE id = ?`).all(query);
328
341
  } else {
329
- rows = db.prepare(
330
- 'SELECT id, name, npc, isFavorite, controlledBy, aliases FROM characters WHERE LOWER(name) LIKE LOWER(?) OR LOWER(aliases) LIKE LOWER(?) ORDER BY name LIMIT ?'
331
- ).all(`%${query}%`, `%${query}%`, limit);
342
+ const byName = db
343
+ .prepare(`${SELECT_COLS} WHERE LOWER(name) LIKE LOWER(?) ORDER BY name`)
344
+ .all(`%${query}%`);
345
+ const seen = new Set(byName.map((r) => r.id));
346
+ rows = [...byName];
347
+ // Fold in alias matches from the vault, then re-fetch their rows so the
348
+ // output columns stay uniform.
349
+ for (const m of resolveCharactersByAlias(db, mounts, query)) {
350
+ if (seen.has(m.id)) continue;
351
+ seen.add(m.id);
352
+ const r = db.prepare(`${SELECT_COLS} WHERE id = ?`).get(m.id);
353
+ if (r) rows.push(r);
354
+ }
355
+ rows = rows.slice(0, limit);
332
356
  }
333
- if (json) return printJson(rows);
334
- printTable(rows);
357
+
358
+ // Attach vault-sourced aliases for display and drop the internal mount id.
359
+ const enriched = rows.map(({ mp, ...rest }) => ({
360
+ ...rest,
361
+ aliases: readVaultAliases(mounts, mp),
362
+ }));
363
+
364
+ if (json) return printJson(enriched);
365
+ printTable(
366
+ enriched.map((r) => ({
367
+ ...r,
368
+ aliases: r.aliases.length ? r.aliases.join(', ') : '',
369
+ })),
370
+ );
335
371
  } finally {
372
+ if (mounts) { try { mounts.close(); } catch {} }
336
373
  db.close();
337
374
  }
338
375
  }
@@ -388,7 +425,7 @@ function cmdChats(args, ctx) {
388
425
  try {
389
426
  let rows;
390
427
  if (flags.character) {
391
- const c = resolveCharacter(db, String(flags.character));
428
+ const c = resolveCharacter(db, String(flags.character), ctx.openMounts);
392
429
  rows = db.prepare(
393
430
  "SELECT id, title, chatType, messageCount, lastMessageAt, projectId " +
394
431
  "FROM chats WHERE participants LIKE ? ORDER BY lastMessageAt DESC LIMIT ?"
@@ -488,7 +525,7 @@ function cmdLogs(args, ctx) {
488
525
  } else if (flags.character) {
489
526
  const main = ctx.openMain();
490
527
  let c;
491
- try { c = resolveCharacter(main, String(flags.character)); } finally { main.close(); }
528
+ try { c = resolveCharacter(main, String(flags.character), ctx.openMounts); } finally { main.close(); }
492
529
  rows = logsDb.prepare(
493
530
  'SELECT id, createdAt, type, provider, modelName, chatId, messageId, durationMs FROM llm_logs WHERE characterId = ? ORDER BY createdAt DESC LIMIT ?'
494
531
  ).all(c.id, limit);
@@ -561,6 +598,12 @@ function cmdLog(args, ctx) {
561
598
  if (!row) throw new Error(`No llm_log with id ${id}`);
562
599
  if (json) return printJson(row);
563
600
 
601
+ let finishReason = null;
602
+ try {
603
+ const parsed = typeof row.response === 'string' ? JSON.parse(row.response) : row.response;
604
+ if (parsed && typeof parsed.finishReason === 'string') finishReason = parsed.finishReason;
605
+ } catch { /* leave null */ }
606
+
564
607
  printRecord(`LLM log ${row.id}`, {
565
608
  createdAt: row.createdAt,
566
609
  type: row.type,
@@ -570,6 +613,7 @@ function cmdLog(args, ctx) {
570
613
  messageId: row.messageId,
571
614
  characterId: row.characterId,
572
615
  durationMs: row.durationMs,
616
+ finishReason,
573
617
  usage: row.usage,
574
618
  cacheUsage: row.cacheUsage,
575
619
  });
@@ -598,11 +642,11 @@ function cmdMemories(args, ctx) {
598
642
 
599
643
  const db = ctx.openMain();
600
644
  try {
601
- const holder = resolveCharacter(db, String(flags.character));
645
+ const holder = resolveCharacter(db, String(flags.character), ctx.openMounts);
602
646
  const conditions = ['characterId = ?'];
603
647
  const params = [holder.id];
604
648
  if (flags.about) {
605
- const a = resolveCharacter(db, String(flags.about));
649
+ const a = resolveCharacter(db, String(flags.about), ctx.openMounts);
606
650
  conditions.push('aboutCharacterId = ?');
607
651
  params.push(a.id);
608
652
  }
@@ -636,6 +680,368 @@ function cmdMemories(args, ctx) {
636
680
  }
637
681
  }
638
682
 
683
+ // ---------- verb: characters ----------
684
+
685
+ // Single-file vault documents the character-properties overlay manages. Must
686
+ // stay in sync with `CHARACTER_VAULT_DESCRIPTORS` in
687
+ // lib/database/repositories/vault-overlay/. Post-4.6-cutover the vault is the
688
+ // only home for these fields.
689
+ //
690
+ // REQUIRED files are written unconditionally by `writeCharacterVaultManagedFields`
691
+ // (empty string when the field is blank), so a healthy character always has all
692
+ // of them. The physical-* pair is OPTIONAL: the writer skips both when the
693
+ // character has no physicalDescription. It writes them as a pair, so having
694
+ // exactly one of the two is an inconsistency worth flagging.
695
+ const REQUIRED_VAULT_SINGLE_FILES = [
696
+ 'properties.json',
697
+ 'identity.md',
698
+ 'description.md',
699
+ 'personality.md',
700
+ 'example-dialogues.md',
701
+ ];
702
+ // manifesto is nullable/optional. The full-projection writer writes an empty
703
+ // manifesto.md when the field is blank, but the patch-level write overlay only
704
+ // writes it when a patch carries a `manifesto` key — so a perfectly valid
705
+ // character can simply lack the file. On read, absent == empty == null, so we
706
+ // report manifesto's presence but never count its absence as "missing" or let
707
+ // it block. (Treated like the physical-* pair: optional, surfaced, not required.)
708
+ const OPTIONAL_VAULT_SINGLE_FILES = [
709
+ 'manifesto.md',
710
+ ];
711
+ const PHYSICAL_VAULT_FILES = [
712
+ 'physical-description.md',
713
+ 'physical-prompts.json',
714
+ ];
715
+
716
+ function safeJsonArray(raw) {
717
+ if (raw == null || raw === '') return [];
718
+ try {
719
+ const v = JSON.parse(raw);
720
+ return Array.isArray(v) ? v : [];
721
+ } catch {
722
+ return [];
723
+ }
724
+ }
725
+
726
+ function normalizeEmpty(v) {
727
+ if (v == null) return '';
728
+ return v;
729
+ }
730
+
731
+ function inspectCharacterVault(row, mounts) {
732
+ // `flag` / `*Db` / divergence reporting only make sense before the 4.6
733
+ // vault cutover, when the DB still carried the content columns. After
734
+ // the cutover the columns are gone and the vault is the only source of
735
+ // truth — `row` won't carry them. Treat them as null and skip the
736
+ // divergence check; the file-presence count is still useful.
737
+ const preCutover = row.identity !== undefined
738
+ || row.description !== undefined
739
+ || row.systemPrompts !== undefined;
740
+
741
+ const status = {
742
+ id: row.id,
743
+ name: row.name,
744
+ flag: row.readPropertiesFromDocumentStore == null
745
+ ? null
746
+ : Number(row.readPropertiesFromDocumentStore),
747
+ mountPointId: row.characterDocumentMountPointId || null,
748
+ vault: 'missing',
749
+ presentSingleFiles: 0,
750
+ expectedSingleFiles: REQUIRED_VAULT_SINGLE_FILES.length,
751
+ missingSingleFiles: [],
752
+ manifestoPresent: false, // optional single file: present or not, both valid
753
+ physicalFilesPresent: 0, // 0, 1, or 2 of the optional physical-* pair
754
+ physicalInconsistent: false,
755
+ promptsVault: 0,
756
+ promptsDb: 0,
757
+ scenariosVault: 0,
758
+ scenariosDb: 0,
759
+ wardrobeVault: 0,
760
+ diverged: [],
761
+ issue: null,
762
+ preCutover,
763
+ };
764
+
765
+ if (preCutover) {
766
+ status.promptsDb = safeJsonArray(row.systemPrompts).length;
767
+ status.scenariosDb = safeJsonArray(row.scenarios).length;
768
+ }
769
+
770
+ if (!row.characterDocumentMountPointId) {
771
+ status.issue = 'no vault';
772
+ return status;
773
+ }
774
+
775
+ status.vault = 'present';
776
+ const mountPointId = row.characterDocumentMountPointId;
777
+
778
+ // One-shot listing of every link for this vault; the rest is just lookups.
779
+ const links = mounts.prepare(
780
+ 'SELECT relativePath, fileId FROM doc_mount_file_links WHERE mountPointId = ?'
781
+ ).all(mountPointId);
782
+ const byPath = new Map();
783
+ for (const link of links) {
784
+ byPath.set(link.relativePath.toLowerCase(), link);
785
+ }
786
+
787
+ for (const p of REQUIRED_VAULT_SINGLE_FILES) {
788
+ if (byPath.has(p)) {
789
+ status.presentSingleFiles++;
790
+ } else {
791
+ status.missingSingleFiles.push(p);
792
+ }
793
+ }
794
+
795
+ // Manifesto is optional: report presence, never require it.
796
+ status.manifestoPresent = OPTIONAL_VAULT_SINGLE_FILES.every((p) => byPath.has(p));
797
+
798
+ // Physical-* files are optional (a character may legitimately have no
799
+ // physical description). Both-or-neither is healthy; exactly one is not.
800
+ status.physicalFilesPresent = PHYSICAL_VAULT_FILES.filter((p) => byPath.has(p)).length;
801
+ status.physicalInconsistent = status.physicalFilesPresent === 1;
802
+
803
+ for (const [p] of byPath) {
804
+ if (p.startsWith('prompts/') && p.endsWith('.md')) status.promptsVault++;
805
+ else if (p.startsWith('scenarios/') && p.endsWith('.md')) status.scenariosVault++;
806
+ else if (p.startsWith('wardrobe/') && p.endsWith('.md')) status.wardrobeVault++;
807
+ }
808
+
809
+ // Compare vault contents to DB row for each managed field where the
810
+ // corresponding file is actually present. Only meaningful pre-cutover;
811
+ // post-cutover the DB no longer carries the columns to compare against.
812
+ if (preCutover) {
813
+ const docStmt = mounts.prepare(
814
+ 'SELECT content FROM doc_mount_documents WHERE fileId = ?'
815
+ );
816
+ const readVault = (relPath) => {
817
+ const link = byPath.get(relPath);
818
+ if (!link) return null;
819
+ const doc = docStmt.get(link.fileId);
820
+ return doc ? doc.content : null;
821
+ };
822
+
823
+ const mdFields = [
824
+ ['identity.md', 'identity'],
825
+ ['description.md', 'description'],
826
+ ['manifesto.md', 'manifesto'],
827
+ ['personality.md', 'personality'],
828
+ ['example-dialogues.md', 'exampleDialogues'],
829
+ ];
830
+ for (const [vaultPath, dbField] of mdFields) {
831
+ const vault = readVault(vaultPath);
832
+ if (vault === null) continue;
833
+ const db = row[dbField] ?? '';
834
+ if (vault !== db) status.diverged.push(dbField);
835
+ }
836
+
837
+ const propsRaw = readVault('properties.json');
838
+ if (propsRaw !== null) {
839
+ try {
840
+ const props = JSON.parse(propsRaw);
841
+ const scalarChecks = [
842
+ ['pronouns', row.pronouns],
843
+ ['title', row.title],
844
+ ['firstMessage', row.firstMessage],
845
+ ['talkativeness', row.talkativeness],
846
+ ];
847
+ for (const [k, dbVal] of scalarChecks) {
848
+ if (normalizeEmpty(props[k]) !== normalizeEmpty(dbVal)) {
849
+ status.diverged.push(k);
850
+ }
851
+ }
852
+ const vaultAliases = JSON.stringify(Array.isArray(props.aliases) ? props.aliases : []);
853
+ const dbAliases = JSON.stringify(safeJsonArray(row.aliases));
854
+ if (vaultAliases !== dbAliases) status.diverged.push('aliases');
855
+ // systemTransparency: tristate (0 / 1 / null), only reported if vault has it
856
+ if (props.systemTransparency !== undefined) {
857
+ if ((props.systemTransparency ?? null) !== (row.systemTransparency ?? null)) {
858
+ status.diverged.push('systemTransparency');
859
+ }
860
+ }
861
+ } catch {
862
+ status.diverged.push('properties.json:unparseable');
863
+ }
864
+ }
865
+
866
+ const physArr = safeJsonArray(row.physicalDescriptions);
867
+ const primary = physArr[0] || null;
868
+ const physMd = readVault('physical-description.md');
869
+ if (physMd !== null) {
870
+ const dbVal = primary && primary.fullDescription != null ? primary.fullDescription : '';
871
+ if (physMd !== dbVal) status.diverged.push('physicalDescription.fullDescription');
872
+ }
873
+ const physJsonRaw = readVault('physical-prompts.json');
874
+ if (physJsonRaw !== null) {
875
+ try {
876
+ const physJson = JSON.parse(physJsonRaw);
877
+ const promptChecks = [
878
+ ['short', primary?.shortPrompt],
879
+ ['medium', primary?.mediumPrompt],
880
+ ['long', primary?.longPrompt],
881
+ ['complete', primary?.completePrompt],
882
+ ];
883
+ for (const [k, dbVal] of promptChecks) {
884
+ if (normalizeEmpty(physJson[k]) !== normalizeEmpty(dbVal)) {
885
+ status.diverged.push(`physical.${k}Prompt`);
886
+ }
887
+ }
888
+ } catch {
889
+ status.diverged.push('physical-prompts.json:unparseable');
890
+ }
891
+ }
892
+
893
+ if (status.promptsVault !== status.promptsDb) {
894
+ status.diverged.push(`systemPrompts:count(vault=${status.promptsVault},db=${status.promptsDb})`);
895
+ }
896
+ if (status.scenariosVault !== status.scenariosDb) {
897
+ status.diverged.push(`scenarios:count(vault=${status.scenariosVault},db=${status.scenariosDb})`);
898
+ }
899
+ }
900
+
901
+ const anyContent = status.presentSingleFiles > 0
902
+ || status.manifestoPresent
903
+ || status.physicalFilesPresent > 0
904
+ || status.promptsVault > 0
905
+ || status.scenariosVault > 0
906
+ || status.wardrobeVault > 0;
907
+ if (!anyContent) {
908
+ status.issue = 'vault empty';
909
+ } else if (status.missingSingleFiles.length > 0) {
910
+ status.issue = `${status.missingSingleFiles.length} required files missing`;
911
+ } else if (status.physicalInconsistent) {
912
+ status.issue = 'physical files incomplete (1 of 2)';
913
+ } else if (status.diverged.length > 0) {
914
+ status.issue = `diverged (${status.diverged.length})`;
915
+ } else if (!preCutover) {
916
+ status.issue = 'ok (post-cutover, vault is canonical)';
917
+ } else if (status.flag === 1) {
918
+ status.issue = 'ok (vault authoritative)';
919
+ } else {
920
+ status.issue = 'ok (db matches vault)';
921
+ }
922
+ return status;
923
+ }
924
+
925
+ function cmdCharacters(args, ctx) {
926
+ const { flags, positional } = parseSubArgs(args);
927
+ const sub = positional[0] || 'status';
928
+ if (sub !== 'status') {
929
+ throw new Error(`Unknown characters subcommand: ${sub}. Try: status`);
930
+ }
931
+
932
+ const json = asBool(flags.json);
933
+ const limit = asInt(flags.limit, 0);
934
+ const onlyDiverged = asBool(flags.diverged);
935
+ const onlyBlocked = asBool(flags.blocked);
936
+ const idQuery = flags.id ? String(flags.id) : null;
937
+
938
+ const main = ctx.openMain();
939
+ const mounts = ctx.openMounts();
940
+ try {
941
+ // Probe the schema so this verb works both pre- and post-cutover: after
942
+ // the 4.6 migration the content columns are gone, so we can only ask
943
+ // for what's there.
944
+ const existing = new Set(
945
+ main.prepare('PRAGMA table_info(characters)')
946
+ .all()
947
+ .map(r => r.name)
948
+ );
949
+ const wanted = [
950
+ 'id', 'name', 'characterDocumentMountPointId', 'systemTransparency',
951
+ 'readPropertiesFromDocumentStore',
952
+ 'identity', 'description', 'manifesto', 'personality', 'exampleDialogues',
953
+ 'pronouns', 'aliases', 'title', 'firstMessage', 'talkativeness',
954
+ 'physicalDescriptions', 'systemPrompts', 'scenarios',
955
+ ];
956
+ const cols = wanted.filter(c => existing.has(c));
957
+ let sql = `SELECT ${cols.join(', ')} FROM characters`;
958
+ const params = [];
959
+ if (idQuery) {
960
+ const c = resolveCharacter(main, idQuery, ctx.openMounts);
961
+ sql += ' WHERE id = ?';
962
+ params.push(c.id);
963
+ } else {
964
+ sql += ' ORDER BY name';
965
+ if (limit > 0) {
966
+ sql += ' LIMIT ?';
967
+ params.push(limit);
968
+ }
969
+ }
970
+ const rows = main.prepare(sql).all(...params);
971
+
972
+ const all = rows.map(r => inspectCharacterVault(r, mounts));
973
+ const filtered = all.filter(s => {
974
+ if (onlyBlocked && !(s.issue && (s.issue === 'no vault' || s.issue === 'vault empty' || s.issue.endsWith(' files missing')))) {
975
+ return false;
976
+ }
977
+ if (onlyDiverged && s.diverged.length === 0 && (!s.missingSingleFiles || s.missingSingleFiles.length === 0)) {
978
+ return false;
979
+ }
980
+ return true;
981
+ });
982
+
983
+ if (json) {
984
+ const summary = {
985
+ totalScanned: all.length,
986
+ returned: filtered.length,
987
+ counts: summarizeCharacterStatuses(all),
988
+ characters: filtered,
989
+ };
990
+ return printJson(summary);
991
+ }
992
+
993
+ const summary = summarizeCharacterStatuses(all);
994
+ let headline = `Scanned ${all.length} character${all.length === 1 ? '' : 's'}: ` +
995
+ `${summary.ok} ok, ${summary.diverged} diverged, ${summary.missingFiles} with missing files, ` +
996
+ `${summary.noVault} with no vault, ${summary.empty} empty`;
997
+ if (summary.physIncomplete > 0) headline += `, ${summary.physIncomplete} with incomplete physical files`;
998
+ headline += '.';
999
+ console.log(headline);
1000
+ console.log('');
1001
+ // The readPropertiesFromDocumentStore flag and the DB side of the
1002
+ // prompts/scenarios counts only exist before the 4.6 cutover. Post-cutover
1003
+ // (the normal case now) the vault is canonical, so drop the dead `flag`
1004
+ // column and show vault-only counts instead of misleading `vault/db`.
1005
+ const anyPreCutover = all.some(s => s.preCutover);
1006
+ printTable(filtered.map(s => {
1007
+ const missing = s.vault === 'missing';
1008
+ const row = { id: s.id.slice(0, 8), name: truncate(s.name, 28) };
1009
+ if (anyPreCutover) row.flag = s.flag == null ? '-' : s.flag;
1010
+ row.vault = s.vault;
1011
+ row.files = missing ? '-' : `${s.presentSingleFiles}/${s.expectedSingleFiles}`;
1012
+ row.manifesto = missing ? '-' : (s.manifestoPresent ? 'yes' : 'no');
1013
+ row.phys = missing ? '-' : `${s.physicalFilesPresent}/2`;
1014
+ row.prompts = missing ? '-' : (anyPreCutover ? `${s.promptsVault}/${s.promptsDb}` : String(s.promptsVault));
1015
+ row.scenarios = missing ? '-' : (anyPreCutover ? `${s.scenariosVault}/${s.scenariosDb}` : String(s.scenariosVault));
1016
+ row.wardrobe = missing ? '-' : s.wardrobeVault;
1017
+ row.status = truncate(s.issue, 60);
1018
+ return row;
1019
+ }));
1020
+
1021
+ if (filtered.length > 0 && filtered.some(s => s.diverged.length > 0)) {
1022
+ console.log('');
1023
+ console.log('Run with --json to see the full diverged-field list per character.');
1024
+ }
1025
+ } finally {
1026
+ try { mounts.close(); } catch {}
1027
+ try { main.close(); } catch {}
1028
+ }
1029
+ }
1030
+
1031
+ function summarizeCharacterStatuses(all) {
1032
+ let ok = 0, diverged = 0, missingFiles = 0, noVault = 0, empty = 0, physIncomplete = 0;
1033
+ for (const s of all) {
1034
+ if (!s.issue) continue;
1035
+ if (s.issue.startsWith('ok')) ok++;
1036
+ else if (s.issue === 'no vault') noVault++;
1037
+ else if (s.issue === 'vault empty') empty++;
1038
+ else if (s.issue.endsWith(' files missing')) missingFiles++;
1039
+ else if (s.issue.startsWith('physical files incomplete')) physIncomplete++;
1040
+ else if (s.issue.startsWith('diverged')) diverged++;
1041
+ }
1042
+ return { ok, diverged, missingFiles, noVault, empty, physIncomplete };
1043
+ }
1044
+
639
1045
  // ---------- verb: optimize ----------
640
1046
 
641
1047
  const OPTIMIZE_TARGETS = {
@@ -1105,6 +1511,7 @@ const VERBS = {
1105
1511
  message: cmdMessage,
1106
1512
  log: cmdLog,
1107
1513
  memories: cmdMemories,
1514
+ characters: cmdCharacters,
1108
1515
  optimize: cmdOptimize,
1109
1516
  backup: cmdBackup,
1110
1517
  integrity: cmdIntegrity,
package/lib/db-helpers.js CHANGED
@@ -255,25 +255,104 @@ function ambiguous(kind, rows) {
255
255
  return err;
256
256
  }
257
257
 
258
- function resolveCharacter(db, query) {
258
+ // Read the `aliases` array out of a character vault's `properties.json`.
259
+ // The 4.6 vault cutover dropped the `aliases` (and `pronouns`) columns from
260
+ // the `characters` table — they now live only in the per-character vault — so
261
+ // any alias lookup has to go through the mount-index DB. `mounts` is that
262
+ // handle; `mountPointId` is `characters.characterDocumentMountPointId`.
263
+ // Returns [] for a missing/empty/unparseable vault so callers can treat the
264
+ // absence of aliases as "no match" rather than an error.
265
+ function readVaultAliases(mounts, mountPointId) {
266
+ if (!mounts || !mountPointId) return [];
267
+ const rec = mounts.prepare(
268
+ `SELECT d.content AS content
269
+ FROM doc_mount_file_links l
270
+ JOIN doc_mount_documents d ON d.fileId = l.fileId
271
+ WHERE l.mountPointId = ? AND LOWER(l.relativePath) = 'properties.json'
272
+ LIMIT 1`
273
+ ).get(mountPointId);
274
+ if (!rec || !rec.content) return [];
275
+ try {
276
+ const props = JSON.parse(rec.content);
277
+ return Array.isArray(props.aliases)
278
+ ? props.aliases.filter((a) => typeof a === 'string')
279
+ : [];
280
+ } catch {
281
+ return [];
282
+ }
283
+ }
284
+
285
+ // Find characters whose vault aliases contain `query` (case-insensitive
286
+ // substring). Requires a mount-index DB handle; returns [{ id, name }].
287
+ // Scans every vaulted character's `properties.json` — fine for a CLI, and only
288
+ // reached on the fuzzy-resolution fallback (a clean name match returns first).
289
+ function resolveCharactersByAlias(db, mounts, query) {
290
+ if (!mounts) return [];
291
+ const needle = query.toLowerCase();
292
+ const chars = db.prepare(
293
+ 'SELECT id, name, characterDocumentMountPointId AS mp FROM characters WHERE characterDocumentMountPointId IS NOT NULL'
294
+ ).all();
295
+ const matches = [];
296
+ for (const c of chars) {
297
+ const aliases = readVaultAliases(mounts, c.mp);
298
+ if (aliases.some((a) => a.toLowerCase().includes(needle))) {
299
+ matches.push({ id: c.id, name: c.name });
300
+ }
301
+ }
302
+ return matches;
303
+ }
304
+
305
+ // Resolve a UUID / name / alias to a single { id, name } row.
306
+ //
307
+ // `openMounts` (optional) is a zero-arg function that returns a mount-index DB
308
+ // handle (e.g. `ctx.openMounts`). When supplied, the fuzzy fallback also
309
+ // matches against vault-stored aliases; when omitted (or if the mount-index DB
310
+ // can't be opened), resolution degrades gracefully to UUID + name only.
311
+ function resolveCharacter(db, query, openMounts = null) {
259
312
  if (UUID_RE.test(query)) {
260
- const row = db.prepare('SELECT id, name, aliases FROM characters WHERE id = ?').get(query);
313
+ const row = db.prepare('SELECT id, name FROM characters WHERE id = ?').get(query);
261
314
  if (!row) throw new Error(`No character with id ${query}`);
262
315
  return row;
263
316
  }
264
317
  const exact = db.prepare(
265
- 'SELECT id, name, aliases FROM characters WHERE LOWER(name) = LOWER(?)'
318
+ 'SELECT id, name FROM characters WHERE LOWER(name) = LOWER(?)'
266
319
  ).all(query);
267
320
  if (exact.length === 1) return exact[0];
268
321
  if (exact.length > 1) {
269
322
  throw ambiguous('character', exact);
270
323
  }
271
- const fuzzy = db.prepare(
272
- 'SELECT id, name, aliases FROM characters WHERE LOWER(name) LIKE LOWER(?) OR LOWER(aliases) LIKE LOWER(?) ORDER BY name'
273
- ).all(`%${query}%`, `%${query}%`);
274
- if (fuzzy.length === 0) throw new Error(`No character matching '${query}'`);
275
- if (fuzzy.length > 1) throw ambiguous('character', fuzzy);
276
- return fuzzy[0];
324
+
325
+ const candidates = db.prepare(
326
+ 'SELECT id, name FROM characters WHERE LOWER(name) LIKE LOWER(?) ORDER BY name'
327
+ ).all(`%${query}%`);
328
+
329
+ // Fold in alias matches from the vault when the caller gave us a way to
330
+ // reach the mount-index DB. Aliases moved into properties.json in 4.6.
331
+ if (openMounts) {
332
+ let mounts = null;
333
+ try {
334
+ mounts = openMounts();
335
+ } catch {
336
+ mounts = null; // mount-index DB absent on this instance — skip aliases
337
+ }
338
+ if (mounts) {
339
+ try {
340
+ const seen = new Set(candidates.map((r) => r.id));
341
+ for (const m of resolveCharactersByAlias(db, mounts, query)) {
342
+ if (!seen.has(m.id)) {
343
+ seen.add(m.id);
344
+ candidates.push(m);
345
+ }
346
+ }
347
+ } finally {
348
+ try { mounts.close(); } catch {}
349
+ }
350
+ }
351
+ }
352
+
353
+ if (candidates.length === 0) throw new Error(`No character matching '${query}'`);
354
+ if (candidates.length > 1) throw ambiguous('character', candidates);
355
+ return candidates[0];
277
356
  }
278
357
 
279
358
  function resolveChat(db, query) {
@@ -317,6 +396,8 @@ module.exports = {
317
396
  UUID_RE,
318
397
  ambiguous,
319
398
  resolveCharacter,
399
+ resolveCharactersByAlias,
400
+ readVaultAliases,
320
401
  resolveChat,
321
402
  resolveProject,
322
403
  };
@@ -5,6 +5,7 @@ const {
5
5
  printDefaultInstanceHint,
6
6
  loadDbKey,
7
7
  openMainDb,
8
+ openMountIndexDb,
8
9
  UUID_RE,
9
10
  resolveCharacter,
10
11
  resolveChat,
@@ -179,7 +180,10 @@ async function openDb(flags) {
179
180
  const { dataDir, passphrase } = resolved;
180
181
  const pepper = await loadDbKey(dataDir, passphrase);
181
182
  const db = openMainDb(dataDir, pepper, { readonly: true });
182
- return { db, dataDir };
183
+ // Lazy opener for the mount-index DB so `resolveCharacter` can match
184
+ // vault-stored aliases (4.6 cutover moved them out of the `characters` row).
185
+ const openMounts = () => openMountIndexDb(dataDir, pepper, { readonly: true });
186
+ return { db, dataDir, openMounts };
183
187
  }
184
188
 
185
189
  // ---------- filter / sort builders ----------
@@ -188,13 +192,13 @@ async function openDb(flags) {
188
192
  // `{ where: 'WHERE m.x = ? AND ...', params: [...], meta: { characterId, ... } }`.
189
193
  // `meta` exposes resolved IDs so callers can decide e.g. whether to show a
190
194
  // per-row holder column.
191
- function buildWhereClause(db, flags) {
195
+ function buildWhereClause(db, flags, openMounts = null) {
192
196
  const clauses = [];
193
197
  const params = [];
194
198
  const meta = { characterId: null, aboutId: null, chatId: null, projectId: null, allCharacters: true };
195
199
 
196
200
  if (flags.character && flags.character !== 'all') {
197
- const c = resolveCharacter(db, flags.character);
201
+ const c = resolveCharacter(db, flags.character, openMounts);
198
202
  clauses.push('m.characterId = ?');
199
203
  params.push(c.id);
200
204
  meta.characterId = c.id;
@@ -207,7 +211,7 @@ function buildWhereClause(db, flags) {
207
211
  } else if (flags.about === 'none') {
208
212
  clauses.push('m.aboutCharacterId IS NULL');
209
213
  } else {
210
- const a = resolveCharacter(db, flags.about);
214
+ const a = resolveCharacter(db, flags.about, openMounts);
211
215
  clauses.push('m.aboutCharacterId = ?');
212
216
  params.push(a.id);
213
217
  meta.aboutId = a.id;
@@ -395,9 +399,9 @@ function renderJson(obj) {
395
399
  // ---------- ls ----------
396
400
 
397
401
  async function cmdLs(flags) {
398
- const { db } = await openDb(flags);
402
+ const { db, openMounts } = await openDb(flags);
399
403
  try {
400
- const { where, params, meta } = buildWhereClause(db, flags);
404
+ const { where, params, meta } = buildWhereClause(db, flags, openMounts);
401
405
  const { order, impField } = buildOrderBy(flags.sort, flags.reverse);
402
406
  const limit = flags.limit > 0 ? flags.limit : 50;
403
407
  const sql = `${SELECT_BASE} ${where} ORDER BY ${order} LIMIT ?`;
@@ -512,9 +516,9 @@ async function cmdFind(flags, positional) {
512
516
  if (!['summary', 'content', 'both'].includes(inWhere)) {
513
517
  throw new Error(`--in must be one of: summary, content, both (got '${inWhere}')`);
514
518
  }
515
- const { db } = await openDb(flags);
519
+ const { db, openMounts } = await openDb(flags);
516
520
  try {
517
- const { where, params, meta } = buildWhereClause(db, flags);
521
+ const { where, params, meta } = buildWhereClause(db, flags, openMounts);
518
522
  const like = `%${pattern}%`;
519
523
 
520
524
  const matchClauses = [];
@@ -584,9 +588,9 @@ async function cmdSemanticGrep(flags, query) {
584
588
  // Resolve character locally so the server gets a stable UUID.
585
589
  let characterId;
586
590
  {
587
- const { db } = await openDb(flags);
591
+ const { db, openMounts } = await openDb(flags);
588
592
  try {
589
- const resolved = resolveCharacter(db, flags.character);
593
+ const resolved = resolveCharacter(db, flags.character, openMounts);
590
594
  characterId = resolved.id;
591
595
  } finally {
592
596
  db.close();
@@ -675,9 +679,9 @@ async function cmdGrep(flags, positional) {
675
679
  if (flags.semantic) {
676
680
  return cmdSemanticGrep(flags, pattern);
677
681
  }
678
- const { db } = await openDb(flags);
682
+ const { db, openMounts } = await openDb(flags);
679
683
  try {
680
- const { where, params } = buildWhereClause(db, flags);
684
+ const { where, params } = buildWhereClause(db, flags, openMounts);
681
685
  // Always restrict to rows whose content can match — quick pre-filter so we
682
686
  // don't read all 32k rows into JS just to drop most of them.
683
687
  const likeNeedle = flags.ignoreCase ? `%${pattern.toLowerCase()}%` : `%${pattern}%`;
@@ -1044,11 +1048,11 @@ function graphToJson(node) {
1044
1048
  // ---------- status ----------
1045
1049
 
1046
1050
  async function cmdStatus(flags) {
1047
- const { db } = await openDb(flags);
1051
+ const { db, openMounts } = await openDb(flags);
1048
1052
  try {
1049
1053
  let holderRows;
1050
1054
  if (flags.character && flags.character !== 'all') {
1051
- const c = resolveCharacter(db, flags.character);
1055
+ const c = resolveCharacter(db, flags.character, openMounts);
1052
1056
  holderRows = [{ id: c.id, name: c.name }];
1053
1057
  } else {
1054
1058
  holderRows = db.prepare(`
@@ -1180,12 +1184,12 @@ function renderStatusBlock(holder, stats) {
1180
1184
  // ---------- validate ----------
1181
1185
 
1182
1186
  async function cmdValidate(flags) {
1183
- const { db } = await openDb(flags);
1187
+ const { db, openMounts } = await openDb(flags);
1184
1188
  try {
1185
1189
  let holderIds = null;
1186
1190
  let holderRows;
1187
1191
  if (flags.character && flags.character !== 'all') {
1188
- const c = resolveCharacter(db, flags.character);
1192
+ const c = resolveCharacter(db, flags.character, openMounts);
1189
1193
  holderRows = [{ id: c.id, name: c.name }];
1190
1194
  holderIds = [c.id];
1191
1195
  } else {
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ // Shared helpers for keeping native modules compiled against the current Node
4
+ // ABI. Used by both the runtime CLI entry (bin/quilltap.js) and the package's
5
+ // `postinstall` hook, so a fresh install picks up the correct binaries up front
6
+ // and a later Node upgrade still self-heals on first run.
7
+
8
+ const path = require('path');
9
+ const { execSync } = require('child_process');
10
+
11
+ const PACKAGE_DIR = path.resolve(__dirname, '..');
12
+
13
+ // Resolve a native module's directory, handling npm hoisting.
14
+ // Returns the directory containing package.json, or null if not found.
15
+ function resolveModuleDir(moduleName) {
16
+ try {
17
+ const pkgJson = require.resolve(moduleName + '/package.json', { paths: [PACKAGE_DIR] });
18
+ return path.dirname(pkgJson);
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ // node-pty needs a `spawn-helper` executable beside the pty.node it loads, or
25
+ // pty.spawn() fails with `posix_spawnp failed`. An ABI rebuild lands a fresh
26
+ // build/Release/pty.node (which node-pty's loader prefers over prebuilds/) but
27
+ // emits only the addon, not node-pty's separate spawn-helper target; tar/extract
28
+ // can also drop the exec bit on the shipped prebuilds/*/spawn-helper. spawn-helper
29
+ // is a plain executable (no Node linkage) so the prebuilt copy is ABI-independent
30
+ // and safe to reuse. Best-effort; never throws.
31
+ function reconcileNodePtySpawnHelper() {
32
+ if (process.platform === 'win32') return; // conpty has no spawn-helper
33
+ const fs = require('fs');
34
+ try {
35
+ const nodePtyDir = resolveModuleDir('node-pty');
36
+ if (!nodePtyDir) return;
37
+ const prebuildsDir = path.join(nodePtyDir, 'prebuilds');
38
+ const prebuiltHelper = path.join(prebuildsDir, `${process.platform}-${process.arch}`, 'spawn-helper');
39
+
40
+ if (fs.existsSync(prebuildsDir)) {
41
+ for (const entry of fs.readdirSync(prebuildsDir)) {
42
+ const helper = path.join(prebuildsDir, entry, 'spawn-helper');
43
+ if (fs.existsSync(helper)) {
44
+ try { fs.chmodSync(helper, 0o755); } catch { /* best-effort */ }
45
+ }
46
+ }
47
+ }
48
+
49
+ for (const buildType of ['Release', 'Debug']) {
50
+ const buildDir = path.join(nodePtyDir, 'build', buildType);
51
+ const builtAddon = path.join(buildDir, 'pty.node');
52
+ const builtHelper = path.join(buildDir, 'spawn-helper');
53
+ if (fs.existsSync(builtHelper)) {
54
+ try { fs.chmodSync(builtHelper, 0o755); } catch { /* best-effort */ }
55
+ } else if (fs.existsSync(builtAddon) && fs.existsSync(prebuiltHelper)) {
56
+ fs.copyFileSync(prebuiltHelper, builtHelper);
57
+ fs.chmodSync(builtHelper, 0o755);
58
+ console.log(` node-pty: backfilled build/${buildType}/spawn-helper from prebuilds`);
59
+ }
60
+ }
61
+ } catch {
62
+ // best-effort — node-pty terminals are optional; never block the CLI
63
+ }
64
+ }
65
+
66
+ // Check if native modules are compiled for the current Node.js version.
67
+ // This handles the case where npx caches the package but the user upgrades
68
+ // Node.js — the cached native modules will have a stale NODE_MODULE_VERSION.
69
+ // Returns true if everything was healthy or successfully rebuilt; false on
70
+ // rebuild failure. Never throws.
71
+ function ensureNativeModules() {
72
+ const needsRebuild = [];
73
+
74
+ // Check better-sqlite3-multiple-ciphers (provides SQLCipher encryption support).
75
+ // The main app depends on this via an npm alias as 'better-sqlite3', so we must
76
+ // 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
+ }
90
+ }
91
+
92
+ // Check sharp: loads its native binding eagerly on require.
93
+ try {
94
+ require.resolve('sharp', { paths: [PACKAGE_DIR] });
95
+ require('sharp');
96
+ } catch (err) {
97
+ if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
98
+ needsRebuild.push('sharp');
99
+ } else if (err.code === 'MODULE_NOT_FOUND') {
100
+ needsRebuild.push('sharp');
101
+ }
102
+ }
103
+
104
+ // Check node-pty: backs the Ariel terminal feature. Loaded dynamically by
105
+ // pty-manager in the standalone server, so resolution must succeed and the
106
+ // native binding's NODE_MODULE_VERSION must match the runtime.
107
+ try {
108
+ require.resolve('node-pty', { paths: [PACKAGE_DIR] });
109
+ require('node-pty');
110
+ } catch (err) {
111
+ if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
112
+ needsRebuild.push('node-pty');
113
+ } else if (err.code === 'MODULE_NOT_FOUND') {
114
+ needsRebuild.push('node-pty');
115
+ }
116
+ }
117
+
118
+ if (needsRebuild.length === 0) {
119
+ reconcileNodePtySpawnHelper();
120
+ return true;
121
+ }
122
+
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
+ }
141
+ }
142
+
143
+ module.exports = { resolveModuleDir, ensureNativeModules, reconcileNodePtySpawnHelper, PACKAGE_DIR };
144
+
145
+ // Allow this file to be invoked directly as a postinstall script:
146
+ // node lib/native-modules.js
147
+ // Exits 0 on success or graceful warning; never blocks npm install on failure.
148
+ if (require.main === module) {
149
+ ensureNativeModules();
150
+ process.exit(0);
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.5.1",
3
+ "version": "4.6.0-dev.105",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",
@@ -27,6 +27,9 @@
27
27
  "bin": {
28
28
  "quilltap": "bin/quilltap.js"
29
29
  },
30
+ "scripts": {
31
+ "postinstall": "node lib/native-modules.js"
32
+ },
30
33
  "files": [
31
34
  "bin/",
32
35
  "lib/",
@@ -34,11 +37,11 @@
34
37
  ],
35
38
  "dependencies": {
36
39
  "@napi-rs/canvas": "^0.1.100",
37
- "better-sqlite3-multiple-ciphers": "^12.9.0",
40
+ "better-sqlite3-multiple-ciphers": "^12.10.0",
38
41
  "node-pty": "^1.1.0",
39
42
  "sharp": "^0.34.5",
40
43
  "tar": "^7.5.15",
41
- "yauzl": "^3.3.0"
44
+ "yauzl": "^3.3.1"
42
45
  },
43
46
  "engines": {
44
47
  "node": ">=24.0.0"