quilltap 4.6.0-dev → 4.6.0-dev.106
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 +79 -101
- package/lib/completion/bash.template +152 -33
- package/lib/completion/fish.template +262 -87
- package/lib/completion/zsh.template +219 -26
- package/lib/db-commands.js +418 -11
- package/lib/db-helpers.js +90 -9
- package/lib/memories-commands.js +20 -16
- package/lib/native-modules.js +151 -0
- package/package.json +6 -3
package/lib/db-commands.js
CHANGED
|
@@ -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(
|
|
338
|
+
rows = db.prepare(`${SELECT_COLS} ORDER BY name LIMIT ?`).all(limit);
|
|
326
339
|
} else if (UUID_RE.test(query)) {
|
|
327
|
-
rows = db.prepare(
|
|
340
|
+
rows = db.prepare(`${SELECT_COLS} WHERE id = ?`).all(query);
|
|
328
341
|
} else {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
};
|