quilltap 4.6.0-dev.22 → 4.6.0-dev.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/quilltap.js CHANGED
@@ -782,6 +782,11 @@ Subcommands (high-level shortcuts; auto-pick the right database):
782
782
  log <id> Full request/response of a single LLM log
783
783
  memories --character <id> Memories held by a character
784
784
  (flags: --about <id|name> --source AUTO|MANUAL)
785
+ characters status Per-character vault readiness report:
786
+ flag value, vault present, single-file count,
787
+ Prompts/ and Scenarios/ folder counts, and any
788
+ divergence between DB columns and vault content.
789
+ (flags: --id <id|name> --diverged --blocked --limit N)
785
790
  optimize [target...] Run maintenance (VACUUM + ANALYZE + PRAGMA optimize)
786
791
  on the named databases, or all of them if no
787
792
  target is given. Targets: main, llm-logs,
@@ -804,6 +809,8 @@ Low-level options (legacy; still supported):
804
809
  --tables List all tables in the active database
805
810
  --count <table> Show row count for a table
806
811
  --repl Interactive SQL prompt (extras: .cols, .find)
812
+ --json Emit machine-readable JSON instead of a table
813
+ (works with --tables, --count, and raw SQL)
807
814
  --llm-logs Target the LLM logs database
808
815
  --mount-points Target the document mount-index database
809
816
  --data-dir <path> Override data directory (pass instance root)
@@ -911,6 +918,7 @@ async function dbCommand(args) {
911
918
  let lockStatus = false;
912
919
  let lockClean = false;
913
920
  let lockOverride = false;
921
+ let asJson = false;
914
922
 
915
923
  let i = 0;
916
924
  while (i < cleaned.length) {
@@ -920,6 +928,7 @@ async function dbCommand(args) {
920
928
  case '--tables': showTables = true; break;
921
929
  case '--count': countTable = cleaned[++i]; break;
922
930
  case '--repl': repl = true; break;
931
+ case '--json': asJson = true; break;
923
932
  case '--help': case '-h': showHelp = true; break;
924
933
  case '--lock-status': lockStatus = true; break;
925
934
  case '--lock-clean': lockClean = true; break;
@@ -1013,22 +1022,27 @@ async function dbCommand(args) {
1013
1022
  try {
1014
1023
  if (showTables) {
1015
1024
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
1016
- for (const t of tables) console.log(t.name);
1025
+ if (asJson) console.log(JSON.stringify(tables.map(t => t.name), null, 2));
1026
+ else for (const t of tables) console.log(t.name);
1017
1027
  } else if (countTable) {
1018
1028
  const row = db.prepare(`SELECT count(*) as count FROM "${countTable}"`).get();
1019
- console.log(row.count);
1029
+ if (asJson) console.log(JSON.stringify({ table: countTable, count: row.count }, null, 2));
1030
+ else console.log(row.count);
1020
1031
  } else if (sql) {
1021
1032
  const stmt = db.prepare(sql);
1022
1033
  if (stmt.reader) {
1023
1034
  const rows = stmt.all();
1024
- if (rows.length === 0) {
1035
+ if (asJson) {
1036
+ console.log(JSON.stringify(rows, null, 2));
1037
+ } else if (rows.length === 0) {
1025
1038
  console.log('(no results)');
1026
1039
  } else {
1027
1040
  console.table(rows);
1028
1041
  }
1029
1042
  } else {
1030
1043
  const info = stmt.run();
1031
- console.log(`Changes: ${info.changes}`);
1044
+ if (asJson) console.log(JSON.stringify({ changes: info.changes, lastInsertRowid: Number(info.lastInsertRowid) }, null, 2));
1045
+ else console.log(`Changes: ${info.changes}`);
1032
1046
  }
1033
1047
  } else if (repl) {
1034
1048
  const readline = require('readline');
@@ -561,6 +561,12 @@ function cmdLog(args, ctx) {
561
561
  if (!row) throw new Error(`No llm_log with id ${id}`);
562
562
  if (json) return printJson(row);
563
563
 
564
+ let finishReason = null;
565
+ try {
566
+ const parsed = typeof row.response === 'string' ? JSON.parse(row.response) : row.response;
567
+ if (parsed && typeof parsed.finishReason === 'string') finishReason = parsed.finishReason;
568
+ } catch { /* leave null */ }
569
+
564
570
  printRecord(`LLM log ${row.id}`, {
565
571
  createdAt: row.createdAt,
566
572
  type: row.type,
@@ -570,6 +576,7 @@ function cmdLog(args, ctx) {
570
576
  messageId: row.messageId,
571
577
  characterId: row.characterId,
572
578
  durationMs: row.durationMs,
579
+ finishReason,
573
580
  usage: row.usage,
574
581
  cacheUsage: row.cacheUsage,
575
582
  });
@@ -636,6 +643,323 @@ function cmdMemories(args, ctx) {
636
643
  }
637
644
  }
638
645
 
646
+ // ---------- verb: characters ----------
647
+
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 = [
654
+ 'properties.json',
655
+ 'identity.md',
656
+ 'description.md',
657
+ 'manifesto.md',
658
+ 'personality.md',
659
+ 'example-dialogues.md',
660
+ 'physical-description.md',
661
+ 'physical-prompts.json',
662
+ ];
663
+
664
+ function safeJsonArray(raw) {
665
+ if (raw == null || raw === '') return [];
666
+ try {
667
+ const v = JSON.parse(raw);
668
+ return Array.isArray(v) ? v : [];
669
+ } catch {
670
+ return [];
671
+ }
672
+ }
673
+
674
+ function normalizeEmpty(v) {
675
+ if (v == null) return '';
676
+ return v;
677
+ }
678
+
679
+ function inspectCharacterVault(row, mounts) {
680
+ // `flag` / `*Db` / divergence reporting only make sense before the 4.6
681
+ // vault cutover, when the DB still carried the content columns. After
682
+ // the cutover the columns are gone and the vault is the only source of
683
+ // truth — `row` won't carry them. Treat them as null and skip the
684
+ // divergence check; the file-presence count is still useful.
685
+ const preCutover = row.identity !== undefined
686
+ || row.description !== undefined
687
+ || row.systemPrompts !== undefined;
688
+
689
+ const status = {
690
+ id: row.id,
691
+ name: row.name,
692
+ flag: row.readPropertiesFromDocumentStore == null
693
+ ? null
694
+ : Number(row.readPropertiesFromDocumentStore),
695
+ mountPointId: row.characterDocumentMountPointId || null,
696
+ vault: 'missing',
697
+ presentSingleFiles: 0,
698
+ expectedSingleFiles: EXPECTED_VAULT_SINGLE_FILES.length,
699
+ missingSingleFiles: [],
700
+ promptsVault: 0,
701
+ promptsDb: 0,
702
+ scenariosVault: 0,
703
+ scenariosDb: 0,
704
+ wardrobeVault: 0,
705
+ diverged: [],
706
+ issue: null,
707
+ preCutover,
708
+ };
709
+
710
+ if (preCutover) {
711
+ status.promptsDb = safeJsonArray(row.systemPrompts).length;
712
+ status.scenariosDb = safeJsonArray(row.scenarios).length;
713
+ }
714
+
715
+ if (!row.characterDocumentMountPointId) {
716
+ status.issue = 'no vault';
717
+ return status;
718
+ }
719
+
720
+ status.vault = 'present';
721
+ const mountPointId = row.characterDocumentMountPointId;
722
+
723
+ // One-shot listing of every link for this vault; the rest is just lookups.
724
+ const links = mounts.prepare(
725
+ 'SELECT relativePath, fileId FROM doc_mount_file_links WHERE mountPointId = ?'
726
+ ).all(mountPointId);
727
+ const byPath = new Map();
728
+ for (const link of links) {
729
+ byPath.set(link.relativePath.toLowerCase(), link);
730
+ }
731
+
732
+ for (const p of EXPECTED_VAULT_SINGLE_FILES) {
733
+ if (byPath.has(p)) {
734
+ status.presentSingleFiles++;
735
+ } else {
736
+ status.missingSingleFiles.push(p);
737
+ }
738
+ }
739
+
740
+ for (const [p] of byPath) {
741
+ if (p.startsWith('prompts/') && p.endsWith('.md')) status.promptsVault++;
742
+ else if (p.startsWith('scenarios/') && p.endsWith('.md')) status.scenariosVault++;
743
+ else if (p.startsWith('wardrobe/') && p.endsWith('.md')) status.wardrobeVault++;
744
+ }
745
+
746
+ // Compare vault contents to DB row for each managed field where the
747
+ // corresponding file is actually present. Only meaningful pre-cutover;
748
+ // post-cutover the DB no longer carries the columns to compare against.
749
+ if (preCutover) {
750
+ const docStmt = mounts.prepare(
751
+ 'SELECT content FROM doc_mount_documents WHERE fileId = ?'
752
+ );
753
+ const readVault = (relPath) => {
754
+ const link = byPath.get(relPath);
755
+ if (!link) return null;
756
+ const doc = docStmt.get(link.fileId);
757
+ return doc ? doc.content : null;
758
+ };
759
+
760
+ const mdFields = [
761
+ ['identity.md', 'identity'],
762
+ ['description.md', 'description'],
763
+ ['manifesto.md', 'manifesto'],
764
+ ['personality.md', 'personality'],
765
+ ['example-dialogues.md', 'exampleDialogues'],
766
+ ];
767
+ for (const [vaultPath, dbField] of mdFields) {
768
+ const vault = readVault(vaultPath);
769
+ if (vault === null) continue;
770
+ const db = row[dbField] ?? '';
771
+ if (vault !== db) status.diverged.push(dbField);
772
+ }
773
+
774
+ const propsRaw = readVault('properties.json');
775
+ if (propsRaw !== null) {
776
+ try {
777
+ const props = JSON.parse(propsRaw);
778
+ const scalarChecks = [
779
+ ['pronouns', row.pronouns],
780
+ ['title', row.title],
781
+ ['firstMessage', row.firstMessage],
782
+ ['talkativeness', row.talkativeness],
783
+ ];
784
+ for (const [k, dbVal] of scalarChecks) {
785
+ if (normalizeEmpty(props[k]) !== normalizeEmpty(dbVal)) {
786
+ status.diverged.push(k);
787
+ }
788
+ }
789
+ const vaultAliases = JSON.stringify(Array.isArray(props.aliases) ? props.aliases : []);
790
+ const dbAliases = JSON.stringify(safeJsonArray(row.aliases));
791
+ if (vaultAliases !== dbAliases) status.diverged.push('aliases');
792
+ // systemTransparency: tristate (0 / 1 / null), only reported if vault has it
793
+ if (props.systemTransparency !== undefined) {
794
+ if ((props.systemTransparency ?? null) !== (row.systemTransparency ?? null)) {
795
+ status.diverged.push('systemTransparency');
796
+ }
797
+ }
798
+ } catch {
799
+ status.diverged.push('properties.json:unparseable');
800
+ }
801
+ }
802
+
803
+ const physArr = safeJsonArray(row.physicalDescriptions);
804
+ const primary = physArr[0] || null;
805
+ const physMd = readVault('physical-description.md');
806
+ if (physMd !== null) {
807
+ const dbVal = primary && primary.fullDescription != null ? primary.fullDescription : '';
808
+ if (physMd !== dbVal) status.diverged.push('physicalDescription.fullDescription');
809
+ }
810
+ const physJsonRaw = readVault('physical-prompts.json');
811
+ if (physJsonRaw !== null) {
812
+ try {
813
+ const physJson = JSON.parse(physJsonRaw);
814
+ const promptChecks = [
815
+ ['short', primary?.shortPrompt],
816
+ ['medium', primary?.mediumPrompt],
817
+ ['long', primary?.longPrompt],
818
+ ['complete', primary?.completePrompt],
819
+ ];
820
+ for (const [k, dbVal] of promptChecks) {
821
+ if (normalizeEmpty(physJson[k]) !== normalizeEmpty(dbVal)) {
822
+ status.diverged.push(`physical.${k}Prompt`);
823
+ }
824
+ }
825
+ } catch {
826
+ status.diverged.push('physical-prompts.json:unparseable');
827
+ }
828
+ }
829
+
830
+ if (status.promptsVault !== status.promptsDb) {
831
+ status.diverged.push(`systemPrompts:count(vault=${status.promptsVault},db=${status.promptsDb})`);
832
+ }
833
+ if (status.scenariosVault !== status.scenariosDb) {
834
+ status.diverged.push(`scenarios:count(vault=${status.scenariosVault},db=${status.scenariosDb})`);
835
+ }
836
+ }
837
+
838
+ if (status.missingSingleFiles.length === EXPECTED_VAULT_SINGLE_FILES.length) {
839
+ status.issue = 'vault empty';
840
+ } else if (status.missingSingleFiles.length > 0) {
841
+ status.issue = `${status.missingSingleFiles.length} files missing`;
842
+ } else if (status.diverged.length > 0) {
843
+ status.issue = `diverged (${status.diverged.length})`;
844
+ } else if (!preCutover) {
845
+ status.issue = 'ok (post-cutover, vault is canonical)';
846
+ } else if (status.flag === 1) {
847
+ status.issue = 'ok (vault authoritative)';
848
+ } else {
849
+ status.issue = 'ok (db matches vault)';
850
+ }
851
+ return status;
852
+ }
853
+
854
+ function cmdCharacters(args, ctx) {
855
+ const { flags, positional } = parseSubArgs(args);
856
+ const sub = positional[0] || 'status';
857
+ if (sub !== 'status') {
858
+ throw new Error(`Unknown characters subcommand: ${sub}. Try: status`);
859
+ }
860
+
861
+ const json = asBool(flags.json);
862
+ const limit = asInt(flags.limit, 0);
863
+ const onlyDiverged = asBool(flags.diverged);
864
+ const onlyBlocked = asBool(flags.blocked);
865
+ const idQuery = flags.id ? String(flags.id) : null;
866
+
867
+ const main = ctx.openMain();
868
+ const mounts = ctx.openMounts();
869
+ try {
870
+ // Probe the schema so this verb works both pre- and post-cutover: after
871
+ // the 4.6 migration the content columns are gone, so we can only ask
872
+ // for what's there.
873
+ const existing = new Set(
874
+ main.prepare('PRAGMA table_info(characters)')
875
+ .all()
876
+ .map(r => r.name)
877
+ );
878
+ const wanted = [
879
+ 'id', 'name', 'characterDocumentMountPointId', 'systemTransparency',
880
+ 'readPropertiesFromDocumentStore',
881
+ 'identity', 'description', 'manifesto', 'personality', 'exampleDialogues',
882
+ 'pronouns', 'aliases', 'title', 'firstMessage', 'talkativeness',
883
+ 'physicalDescriptions', 'systemPrompts', 'scenarios',
884
+ ];
885
+ const cols = wanted.filter(c => existing.has(c));
886
+ let sql = `SELECT ${cols.join(', ')} FROM characters`;
887
+ const params = [];
888
+ if (idQuery) {
889
+ const c = resolveCharacter(main, idQuery);
890
+ sql += ' WHERE id = ?';
891
+ params.push(c.id);
892
+ } else {
893
+ sql += ' ORDER BY name';
894
+ if (limit > 0) {
895
+ sql += ' LIMIT ?';
896
+ params.push(limit);
897
+ }
898
+ }
899
+ const rows = main.prepare(sql).all(...params);
900
+
901
+ const all = rows.map(r => inspectCharacterVault(r, mounts));
902
+ const filtered = all.filter(s => {
903
+ if (onlyBlocked && !(s.issue && (s.issue === 'no vault' || s.issue === 'vault empty' || s.issue.endsWith(' files missing')))) {
904
+ return false;
905
+ }
906
+ if (onlyDiverged && s.diverged.length === 0 && (!s.missingSingleFiles || s.missingSingleFiles.length === 0)) {
907
+ return false;
908
+ }
909
+ return true;
910
+ });
911
+
912
+ if (json) {
913
+ const summary = {
914
+ totalScanned: all.length,
915
+ returned: filtered.length,
916
+ counts: summarizeCharacterStatuses(all),
917
+ characters: filtered,
918
+ };
919
+ return printJson(summary);
920
+ }
921
+
922
+ const summary = summarizeCharacterStatuses(all);
923
+ const headline = `Scanned ${all.length} character${all.length === 1 ? '' : 's'}: ` +
924
+ `${summary.ok} ok, ${summary.diverged} diverged, ${summary.missingFiles} with missing files, ` +
925
+ `${summary.noVault} with no vault, ${summary.empty} empty.`;
926
+ console.log(headline);
927
+ 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
+ })));
939
+
940
+ if (filtered.length > 0 && filtered.some(s => s.diverged.length > 0)) {
941
+ console.log('');
942
+ console.log('Run with --json to see the full diverged-field list per character.');
943
+ }
944
+ } finally {
945
+ try { mounts.close(); } catch {}
946
+ try { main.close(); } catch {}
947
+ }
948
+ }
949
+
950
+ function summarizeCharacterStatuses(all) {
951
+ let ok = 0, diverged = 0, missingFiles = 0, noVault = 0, empty = 0;
952
+ for (const s of all) {
953
+ if (!s.issue) continue;
954
+ if (s.issue.startsWith('ok')) ok++;
955
+ else if (s.issue === 'no vault') noVault++;
956
+ else if (s.issue === 'vault empty') empty++;
957
+ else if (s.issue.endsWith(' files missing')) missingFiles++;
958
+ else if (s.issue.startsWith('diverged')) diverged++;
959
+ }
960
+ return { ok, diverged, missingFiles, noVault, empty };
961
+ }
962
+
639
963
  // ---------- verb: optimize ----------
640
964
 
641
965
  const OPTIMIZE_TARGETS = {
@@ -1105,6 +1429,7 @@ const VERBS = {
1105
1429
  message: cmdMessage,
1106
1430
  log: cmdLog,
1107
1431
  memories: cmdMemories,
1432
+ characters: cmdCharacters,
1108
1433
  optimize: cmdOptimize,
1109
1434
  backup: cmdBackup,
1110
1435
  integrity: cmdIntegrity,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.6.0-dev.22",
3
+ "version": "4.6.0-dev.39",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",