quilltap 4.6.0-dev.89 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -115,6 +115,7 @@ quilltap db logs --tail 20 # Recent LLM logs
115
115
  quilltap db message <id> # Full content of one message
116
116
  quilltap db log <id> [--field request|response|both]
117
117
  quilltap db memories --character Friday [--about Amy] [--source AUTO]
118
+ quilltap db characters status # Per-character vault readiness (--id, --diverged, --blocked)
118
119
  ```
119
120
 
120
121
  ### Maintenance and Snapshots
@@ -157,6 +158,7 @@ In the REPL, `.cols <table>` and `.find <text>` mirror the subcommand helpers.
157
158
  quilltap docs list # All mounts
158
159
  quilltap docs show <mount> # One mount, with counts
159
160
  quilltap docs ls <mount> [path] [--links] # POSIX-flavoured listing (alias: dir)
161
+ quilltap docs tree <mount> [path] # ASCII tree of a folder hierarchy (--depth, --max-nodes)
160
162
  quilltap docs read [--rendered] <mount> <path> # File contents → stdout
161
163
  quilltap docs export <mount> <outputDir> # Mount → directory
162
164
  quilltap docs find <pattern> # Substring match on file names (--mount, --ext, --type, --limit)
@@ -286,7 +288,7 @@ Dynamic completions shell out to `quilltap`'s own subcommands. If the active ins
286
288
 
287
289
  ## Requirements
288
290
 
289
- - Node.js 18 or later
291
+ - Node.js 24 or later
290
292
 
291
293
  ## Other Ways to Run Quilltap
292
294
 
package/bin/quilltap.js CHANGED
@@ -116,6 +116,8 @@ Examples:
116
116
  quilltap -d /mnt/data/quilltap # Custom data directory
117
117
  quilltap -o # Start and open browser
118
118
  quilltap --update # Re-download app files
119
+ quilltap --instance Friday db schema # Universal flags (-i/--instance, -d/--data-dir,
120
+ # --passphrase) work before or after a subcommand
119
121
 
120
122
  More info: https://quilltap.ai
121
123
  `);
@@ -1047,51 +1049,81 @@ async function dbCommand(args) {
1047
1049
  }
1048
1050
  }
1049
1051
 
1050
- // Route to subcommand or main
1051
- if (process.argv[2] === 'db') {
1052
- dbCommand(process.argv.slice(3));
1053
- } else if (process.argv[2] === 'themes') {
1052
+ // Route to subcommand or main.
1053
+ //
1054
+ // Universal flags (-i/--instance, -d/--data-dir, --passphrase, -p/--port) may
1055
+ // appear *before* the subcommand, e.g. `quilltap --instance Friday db schema`.
1056
+ // We locate the subcommand by walking the args and skipping those
1057
+ // value-taking flags (so an instance literally named "db" is not mistaken for
1058
+ // the subcommand), then hand every other arg — including the leading flags —
1059
+ // to the subcommand. Each subcommand parses these flags position-independently,
1060
+ // so they behave the same before or after the verb.
1061
+ const SUBCOMMANDS = new Set([
1062
+ 'db', 'themes', 'docs', 'memories', 'instances', 'memory-diff', 'completion', 'logs', 'migrations',
1063
+ ]);
1064
+ // Global flags that consume the following token as their value.
1065
+ const GLOBAL_VALUE_FLAGS = new Set(['-p', '--port', '-d', '--data-dir', '-i', '--instance', '--passphrase']);
1066
+
1067
+ function locateSubcommand(argv) {
1068
+ for (let k = 0; k < argv.length; k++) {
1069
+ const a = argv[k];
1070
+ if (GLOBAL_VALUE_FLAGS.has(a)) { k++; continue; } // skip the flag's value
1071
+ if (a.startsWith('-')) continue; // boolean / unknown flag
1072
+ return SUBCOMMANDS.has(a) ? k : -1; // first bare token decides
1073
+ }
1074
+ return -1;
1075
+ }
1076
+
1077
+ const cliArgs = process.argv.slice(2);
1078
+ const subIdx = locateSubcommand(cliArgs);
1079
+ const subName = subIdx >= 0 ? cliArgs[subIdx] : '';
1080
+ // Everything except the subcommand token itself (leading global flags kept).
1081
+ const subArgs = subIdx >= 0 ? [...cliArgs.slice(0, subIdx), ...cliArgs.slice(subIdx + 1)] : [];
1082
+
1083
+ if (subName === 'db') {
1084
+ dbCommand(subArgs);
1085
+ } else if (subName === 'themes') {
1054
1086
  const { themesCommand } = require('../lib/theme-commands');
1055
- themesCommand(process.argv.slice(3));
1056
- } else if (process.argv[2] === 'docs') {
1087
+ themesCommand(subArgs);
1088
+ } else if (subName === 'docs') {
1057
1089
  const { docsCommand } = require('../lib/docs-commands');
1058
- docsCommand(process.argv.slice(3));
1059
- } else if (process.argv[2] === 'memories') {
1090
+ docsCommand(subArgs);
1091
+ } else if (subName === 'memories') {
1060
1092
  const { memoriesCommand } = require('../lib/memories-commands');
1061
- memoriesCommand(process.argv.slice(3)).catch(err => {
1093
+ memoriesCommand(subArgs).catch(err => {
1062
1094
  if (!err.silent) {
1063
1095
  console.error(`Error: ${err.message}`);
1064
1096
  }
1065
1097
  const code = err.exitCode != null ? err.exitCode : (err.ambiguous ? 2 : 1);
1066
1098
  process.exit(code);
1067
1099
  });
1068
- } else if (process.argv[2] === 'instances') {
1100
+ } else if (subName === 'instances') {
1069
1101
  const { instancesCommand } = require('../lib/instances-commands');
1070
- instancesCommand(process.argv.slice(3)).catch(err => {
1102
+ instancesCommand(subArgs).catch(err => {
1071
1103
  console.error(`Error: ${err.message}`);
1072
1104
  process.exit(1);
1073
1105
  });
1074
- } else if (process.argv[2] === 'memory-diff') {
1106
+ } else if (subName === 'memory-diff') {
1075
1107
  const { memoryDiffCommand } = require('../lib/memory-diff-command');
1076
- memoryDiffCommand(process.argv.slice(3)).catch(err => {
1108
+ memoryDiffCommand(subArgs).catch(err => {
1077
1109
  console.error(`Error: ${err.message}`);
1078
1110
  process.exit(1);
1079
1111
  });
1080
- } else if (process.argv[2] === 'completion') {
1112
+ } else if (subName === 'completion') {
1081
1113
  const { completionCommand } = require('../lib/completion-commands');
1082
- completionCommand(process.argv.slice(3)).catch(err => {
1114
+ completionCommand(subArgs).catch(err => {
1083
1115
  console.error(`Error: ${err.message}`);
1084
1116
  process.exit(1);
1085
1117
  });
1086
- } else if (process.argv[2] === 'logs') {
1118
+ } else if (subName === 'logs') {
1087
1119
  const { logsCommand } = require('../lib/logs-commands');
1088
- logsCommand(process.argv.slice(3)).catch(err => {
1120
+ logsCommand(subArgs).catch(err => {
1089
1121
  console.error(`Error: ${err.message}`);
1090
1122
  process.exit(1);
1091
1123
  });
1092
- } else if (process.argv[2] === 'migrations') {
1124
+ } else if (subName === 'migrations') {
1093
1125
  const { migrationsCommand } = require('../lib/migrations-commands');
1094
- migrationsCommand(process.argv.slice(3)).catch(err => {
1126
+ migrationsCommand(subArgs).catch(err => {
1095
1127
  console.error(`Error: ${err.message}`);
1096
1128
  process.exit(1);
1097
1129
  });
@@ -89,7 +89,7 @@ _quilltap_complete() {
89
89
  return
90
90
  ;;
91
91
  --sort)
92
- COMPREPLY=($(compgen -W "name path size modified created reinforced importance accessed reinforcement-count links" -- "$cur"))
92
+ COMPREPLY=($(compgen -W "name time size links reinforced importance created accessed reinforcement-count" -- "$cur"))
93
93
  return
94
94
  ;;
95
95
  --field)
@@ -109,10 +109,10 @@ _quilltap_complete() {
109
109
  # Subcommand-specific completion
110
110
  case "$subcommand" in
111
111
  db)
112
- local db_verbs="schema find chats messages logs message log memories optimize backup integrity"
112
+ local db_verbs="schema find chats messages logs message log memories characters optimize backup integrity"
113
113
  local db_flags="--instance --data-dir --passphrase --json --limit --grep \
114
114
  --character --project --about --source --chat --message --rendered --field \
115
- --tail --last --full --from --type --out --help \
115
+ --tail --last --full --from --type --out --id --diverged --blocked --help \
116
116
  --tables --count --repl --llm-logs --mount-points \
117
117
  --lock-status --lock-clean --lock-override"
118
118
  if [[ -z "$subverb" ]]; then
@@ -126,7 +126,7 @@ _quilltap_complete() {
126
126
  fi
127
127
  ;;
128
128
  docs)
129
- local docs_verbs="list show files ls dir 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 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"
@@ -85,6 +85,7 @@ complete -c quilltap -n '__quilltap_using_subcommand db' -f -a 'logs' -d 'Get LL
85
85
  complete -c quilltap -n '__quilltap_using_subcommand db' -f -a 'message' -d 'Get single message'
86
86
  complete -c quilltap -n '__quilltap_using_subcommand db' -f -a 'log' -d 'Get single log entry'
87
87
  complete -c quilltap -n '__quilltap_using_subcommand db' -f -a 'memories' -d 'List memories'
88
+ complete -c quilltap -n '__quilltap_using_subcommand db' -f -a 'characters' -d 'Per-character vault status'
88
89
  complete -c quilltap -n '__quilltap_using_subcommand db' -f -a 'optimize' -d 'Optimize database'
89
90
  complete -c quilltap -n '__quilltap_using_subcommand db' -f -a 'backup' -d 'Backup database'
90
91
  complete -c quilltap -n '__quilltap_using_subcommand db' -f -a 'integrity' -d 'Check integrity'
@@ -114,6 +115,9 @@ complete -c quilltap -n '__quilltap_using_subcommand db' -l 'mount-points' -d 'T
114
115
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'lock-status' -d 'Show instance lock status'
115
116
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'lock-clean' -d 'Clean stale lock'
116
117
  complete -c quilltap -n '__quilltap_using_subcommand db' -l 'lock-override' -d 'Override active lock'
118
+ complete -c quilltap -n '__quilltap_using_subcommand db' -l 'id' -d 'Single character by name or id' -x
119
+ complete -c quilltap -n '__quilltap_using_subcommand db' -l 'diverged' -d 'Only diverged characters'
120
+ complete -c quilltap -n '__quilltap_using_subcommand db' -l 'blocked' -d 'Only characters with vault issues'
117
121
 
118
122
  # ---------- docs verbs ----------
119
123
  complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'list' -d 'List mount points'
@@ -121,6 +125,7 @@ complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'show' -d 'Show
121
125
  complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'files' -d 'List files in mount'
122
126
  complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'ls' -d 'ls-style listing'
123
127
  complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'dir' -d 'Directory listing'
128
+ complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'tree' -d 'ASCII tree view'
124
129
  complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'read' -d 'Print file contents'
125
130
  complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'export' -d 'Export mount'
126
131
  complete -c quilltap -n '__quilltap_using_subcommand docs' -f -a 'scan' -d 'Trigger rescan'
@@ -153,7 +158,7 @@ complete -c quilltap -n '__quilltap_using_subcommand docs' -l 'ignore-case' -d '
153
158
  complete -c quilltap -n '__quilltap_using_subcommand docs' -s 'l' -d 'Paths only'
154
159
  complete -c quilltap -n '__quilltap_using_subcommand docs' -l 'wait' -d 'Wait for completion'
155
160
  complete -c quilltap -n '__quilltap_using_subcommand docs' -l 'recursive' -s 'R' -d 'Recursive listing'
156
- complete -c quilltap -n '__quilltap_using_subcommand docs' -l 'sort' -d 'Sort field' -x -a 'name path size modified created'
161
+ complete -c quilltap -n '__quilltap_using_subcommand docs' -l 'sort' -d 'Sort field' -x -a 'name time size links'
157
162
  complete -c quilltap -n '__quilltap_using_subcommand docs' -l 'reverse' -s 'r' -d 'Reverse sort order'
158
163
  complete -c quilltap -n '__quilltap_using_subcommand docs' -l 'depth' -d 'Maximum traversal depth' -x
159
164
  complete -c quilltap -n '__quilltap_using_subcommand docs' -l 'max-nodes' -d 'Maximum graph nodes' -x
@@ -93,6 +93,7 @@ _quilltap_db() {
93
93
  'message:Get a single message'
94
94
  'log:Get a single LLM log entry'
95
95
  'memories:List memories for a character'
96
+ 'characters:Per-character vault status report'
96
97
  'optimize:Optimize database'
97
98
  'backup:Backup database'
98
99
  'integrity:Check database integrity'
@@ -119,6 +120,9 @@ _quilltap_db() {
119
120
  '--from[Participant filter]:participant:'
120
121
  '--type[Filter by type]:type:'
121
122
  '--out[Output directory]:path:_files -/'
123
+ '--id[Single character by name or id]:character:'
124
+ '--diverged[Only characters whose DB and vault differ]'
125
+ '--blocked[Only characters with vault issues]'
122
126
  '--tables[List tables (low-level)]'
123
127
  '--count[Count rows in table]:table:'
124
128
  '--repl[Open SQL REPL]'
@@ -144,6 +148,7 @@ _quilltap_docs() {
144
148
  'files:List files in a mount'
145
149
  'ls:ls-style listing'
146
150
  'dir:Directory listing'
151
+ 'tree:ASCII tree view of a folder'
147
152
  'read:Print file contents'
148
153
  'export:Export mount to directory'
149
154
  'scan:Trigger rescan'
@@ -181,7 +186,7 @@ _quilltap_docs() {
181
186
  '-l[Paths only]'
182
187
  '--wait[Wait for completion]'
183
188
  '(-R --recursive)'{-R,--recursive}'[Recursive listing]'
184
- '--sort[Sort field]:field:(name path size modified created)'
189
+ '--sort[Sort field]:field:(name time size links)'
185
190
  '(-r --reverse)'{-r,--reverse}'[Reverse sort order]'
186
191
  '--depth[Maximum traversal depth]:n:'
187
192
  '--max-nodes[Maximum nodes in graph]:n:'
@@ -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);
@@ -605,11 +642,11 @@ function cmdMemories(args, ctx) {
605
642
 
606
643
  const db = ctx.openMain();
607
644
  try {
608
- const holder = resolveCharacter(db, String(flags.character));
645
+ const holder = resolveCharacter(db, String(flags.character), ctx.openMounts);
609
646
  const conditions = ['characterId = ?'];
610
647
  const params = [holder.id];
611
648
  if (flags.about) {
612
- const a = resolveCharacter(db, String(flags.about));
649
+ const a = resolveCharacter(db, String(flags.about), ctx.openMounts);
613
650
  conditions.push('aboutCharacterId = ?');
614
651
  params.push(a.id);
615
652
  }
@@ -645,18 +682,33 @@ function cmdMemories(args, ctx) {
645
682
 
646
683
  // ---------- verb: characters ----------
647
684
 
648
- // Vault files that the character-properties overlay manages today. Must stay
649
- // in sync with `CHARACTER_VAULT_DESCRIPTORS` in
650
- // lib/database/repositories/character-properties-overlay.ts. When the 4.6
651
- // vault-cutover migration lands, every character is expected to have all of
652
- // these present.
653
- const EXPECTED_VAULT_SINGLE_FILES = [
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 = [
654
696
  'properties.json',
655
697
  'identity.md',
656
698
  'description.md',
657
- 'manifesto.md',
658
699
  'personality.md',
659
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 = [
660
712
  'physical-description.md',
661
713
  'physical-prompts.json',
662
714
  ];
@@ -695,8 +747,11 @@ function inspectCharacterVault(row, mounts) {
695
747
  mountPointId: row.characterDocumentMountPointId || null,
696
748
  vault: 'missing',
697
749
  presentSingleFiles: 0,
698
- expectedSingleFiles: EXPECTED_VAULT_SINGLE_FILES.length,
750
+ expectedSingleFiles: REQUIRED_VAULT_SINGLE_FILES.length,
699
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,
700
755
  promptsVault: 0,
701
756
  promptsDb: 0,
702
757
  scenariosVault: 0,
@@ -729,7 +784,7 @@ function inspectCharacterVault(row, mounts) {
729
784
  byPath.set(link.relativePath.toLowerCase(), link);
730
785
  }
731
786
 
732
- for (const p of EXPECTED_VAULT_SINGLE_FILES) {
787
+ for (const p of REQUIRED_VAULT_SINGLE_FILES) {
733
788
  if (byPath.has(p)) {
734
789
  status.presentSingleFiles++;
735
790
  } else {
@@ -737,6 +792,14 @@ function inspectCharacterVault(row, mounts) {
737
792
  }
738
793
  }
739
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
+
740
803
  for (const [p] of byPath) {
741
804
  if (p.startsWith('prompts/') && p.endsWith('.md')) status.promptsVault++;
742
805
  else if (p.startsWith('scenarios/') && p.endsWith('.md')) status.scenariosVault++;
@@ -835,10 +898,18 @@ function inspectCharacterVault(row, mounts) {
835
898
  }
836
899
  }
837
900
 
838
- if (status.missingSingleFiles.length === EXPECTED_VAULT_SINGLE_FILES.length) {
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) {
839
908
  status.issue = 'vault empty';
840
909
  } else if (status.missingSingleFiles.length > 0) {
841
- status.issue = `${status.missingSingleFiles.length} files missing`;
910
+ status.issue = `${status.missingSingleFiles.length} required files missing`;
911
+ } else if (status.physicalInconsistent) {
912
+ status.issue = 'physical files incomplete (1 of 2)';
842
913
  } else if (status.diverged.length > 0) {
843
914
  status.issue = `diverged (${status.diverged.length})`;
844
915
  } else if (!preCutover) {
@@ -886,7 +957,7 @@ function cmdCharacters(args, ctx) {
886
957
  let sql = `SELECT ${cols.join(', ')} FROM characters`;
887
958
  const params = [];
888
959
  if (idQuery) {
889
- const c = resolveCharacter(main, idQuery);
960
+ const c = resolveCharacter(main, idQuery, ctx.openMounts);
890
961
  sql += ' WHERE id = ?';
891
962
  params.push(c.id);
892
963
  } else {
@@ -920,22 +991,32 @@ function cmdCharacters(args, ctx) {
920
991
  }
921
992
 
922
993
  const summary = summarizeCharacterStatuses(all);
923
- const headline = `Scanned ${all.length} character${all.length === 1 ? '' : 's'}: ` +
994
+ let headline = `Scanned ${all.length} character${all.length === 1 ? '' : 's'}: ` +
924
995
  `${summary.ok} ok, ${summary.diverged} diverged, ${summary.missingFiles} with missing files, ` +
925
- `${summary.noVault} with no vault, ${summary.empty} empty.`;
996
+ `${summary.noVault} with no vault, ${summary.empty} empty`;
997
+ if (summary.physIncomplete > 0) headline += `, ${summary.physIncomplete} with incomplete physical files`;
998
+ headline += '.';
926
999
  console.log(headline);
927
1000
  console.log('');
928
- printTable(filtered.map(s => ({
929
- id: s.id.slice(0, 8),
930
- name: truncate(s.name, 28),
931
- flag: s.flag == null ? '-' : s.flag,
932
- vault: s.vault,
933
- files: s.vault === 'missing' ? '-' : `${s.presentSingleFiles}/${s.expectedSingleFiles}`,
934
- prompts: s.vault === 'missing' ? '-' : `${s.promptsVault}/${s.promptsDb}`,
935
- scenarios: s.vault === 'missing' ? '-' : `${s.scenariosVault}/${s.scenariosDb}`,
936
- wardrobe: s.vault === 'missing' ? '-' : s.wardrobeVault,
937
- status: truncate(s.issue, 60),
938
- })));
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
+ }));
939
1020
 
940
1021
  if (filtered.length > 0 && filtered.some(s => s.diverged.length > 0)) {
941
1022
  console.log('');
@@ -948,16 +1029,17 @@ function cmdCharacters(args, ctx) {
948
1029
  }
949
1030
 
950
1031
  function summarizeCharacterStatuses(all) {
951
- let ok = 0, diverged = 0, missingFiles = 0, noVault = 0, empty = 0;
1032
+ let ok = 0, diverged = 0, missingFiles = 0, noVault = 0, empty = 0, physIncomplete = 0;
952
1033
  for (const s of all) {
953
1034
  if (!s.issue) continue;
954
1035
  if (s.issue.startsWith('ok')) ok++;
955
1036
  else if (s.issue === 'no vault') noVault++;
956
1037
  else if (s.issue === 'vault empty') empty++;
957
1038
  else if (s.issue.endsWith(' files missing')) missingFiles++;
1039
+ else if (s.issue.startsWith('physical files incomplete')) physIncomplete++;
958
1040
  else if (s.issue.startsWith('diverged')) diverged++;
959
1041
  }
960
- return { ok, diverged, missingFiles, noVault, empty };
1042
+ return { ok, diverged, missingFiles, noVault, empty, physIncomplete };
961
1043
  }
962
1044
 
963
1045
  // ---------- verb: optimize ----------
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 {
@@ -26,7 +26,7 @@ function parseFlags(args) {
26
26
  const a = args[i];
27
27
  switch (a) {
28
28
  case '-d': case '--data-dir': flags.dataDir = args[++i]; break;
29
- case '--instance': flags.instance = args[++i]; break;
29
+ case '-i': case '--instance': flags.instance = args[++i]; break;
30
30
  case '--passphrase': flags.passphrase = args[++i]; break;
31
31
  case '--json': flags.json = true; break;
32
32
  case '-h': case '--help': flags.help = true; break;
@@ -118,7 +118,7 @@ Commands:
118
118
 
119
119
  Options:
120
120
  -d, --data-dir <path> Use a specific data directory
121
- --instance <name> Use a named instance
121
+ -i, --instance <name> Use a named instance
122
122
  --passphrase <pass> Provide passphrase (prompts if needed)
123
123
  --json Output as JSON
124
124
  -h, --help Show this help message
@@ -21,6 +21,48 @@ function resolveModuleDir(moduleName) {
21
21
  }
22
22
  }
23
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
+
24
66
  // Check if native modules are compiled for the current Node.js version.
25
67
  // This handles the case where npx caches the package but the user upgrades
26
68
  // Node.js — the cached native modules will have a stale NODE_MODULE_VERSION.
@@ -73,7 +115,10 @@ function ensureNativeModules() {
73
115
  }
74
116
  }
75
117
 
76
- if (needsRebuild.length === 0) return true;
118
+ if (needsRebuild.length === 0) {
119
+ reconcileNodePtySpawnHelper();
120
+ return true;
121
+ }
77
122
 
78
123
  console.log(` Rebuilding native modules for Node.js ${process.version}...`);
79
124
 
@@ -84,6 +129,7 @@ function ensureNativeModules() {
84
129
  });
85
130
  console.log(' Done.');
86
131
  console.log('');
132
+ reconcileNodePtySpawnHelper();
87
133
  return true;
88
134
  } catch (err) {
89
135
  console.error('');
@@ -94,7 +140,7 @@ function ensureNativeModules() {
94
140
  }
95
141
  }
96
142
 
97
- module.exports = { resolveModuleDir, ensureNativeModules, PACKAGE_DIR };
143
+ module.exports = { resolveModuleDir, ensureNativeModules, reconcileNodePtySpawnHelper, PACKAGE_DIR };
98
144
 
99
145
  // Allow this file to be invoked directly as a postinstall script:
100
146
  // node lib/native-modules.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.6.0-dev.89",
3
+ "version": "4.6.0",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",
@@ -40,8 +40,8 @@
40
40
  "better-sqlite3-multiple-ciphers": "^12.10.0",
41
41
  "node-pty": "^1.1.0",
42
42
  "sharp": "^0.34.5",
43
- "tar": "^7.5.15",
44
- "yauzl": "^3.3.1"
43
+ "tar": "^7.5.16",
44
+ "yauzl": "^3.3.2"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.0.0"