quilltap 4.6.0-dev → 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.
@@ -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
  };