quilltap 4.5.1 → 4.6.0-dev.105
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/quilltap.js +79 -101
- 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/bin/quilltap.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
loadDbKey,
|
|
14
14
|
} = require('../lib/db-helpers');
|
|
15
15
|
const { resolveInstance } = require('../lib/instances');
|
|
16
|
+
const { resolveModuleDir, ensureNativeModules } = require('../lib/native-modules');
|
|
16
17
|
|
|
17
18
|
const PACKAGE_DIR = path.resolve(__dirname, '..');
|
|
18
19
|
|
|
@@ -137,85 +138,6 @@ function openBrowser(url) {
|
|
|
137
138
|
});
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
// Resolve a native module's directory, handling npm hoisting.
|
|
141
|
-
// Returns the directory containing package.json, or null if not found.
|
|
142
|
-
function resolveModuleDir(moduleName) {
|
|
143
|
-
try {
|
|
144
|
-
const pkgJson = require.resolve(moduleName + '/package.json');
|
|
145
|
-
return path.dirname(pkgJson);
|
|
146
|
-
} catch {
|
|
147
|
-
return null;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Check if native modules are compiled for the current Node.js version.
|
|
152
|
-
// This handles the case where npx caches the package but the user upgrades
|
|
153
|
-
// Node.js — the cached native modules will have a stale NODE_MODULE_VERSION.
|
|
154
|
-
function ensureNativeModules() {
|
|
155
|
-
const needsRebuild = [];
|
|
156
|
-
|
|
157
|
-
// Check better-sqlite3-multiple-ciphers (provides SQLCipher encryption support).
|
|
158
|
-
// The main app depends on this via an npm alias as 'better-sqlite3', so we must
|
|
159
|
-
// ensure the SQLCipher-capable version is available and link it as 'better-sqlite3'.
|
|
160
|
-
// We must load the native binding directly to detect NODE_MODULE_VERSION mismatches.
|
|
161
|
-
try {
|
|
162
|
-
const modDir = resolveModuleDir('better-sqlite3-multiple-ciphers')
|
|
163
|
-
|| resolveModuleDir('better-sqlite3');
|
|
164
|
-
if (!modDir) throw Object.assign(new Error('not found'), { code: 'MODULE_NOT_FOUND' });
|
|
165
|
-
const bindingsPath = path.join(modDir, 'build', 'Release', 'better_sqlite3.node');
|
|
166
|
-
require(bindingsPath);
|
|
167
|
-
} catch (err) {
|
|
168
|
-
if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
|
|
169
|
-
needsRebuild.push('better-sqlite3-multiple-ciphers');
|
|
170
|
-
} else if (err.code === 'MODULE_NOT_FOUND') {
|
|
171
|
-
needsRebuild.push('better-sqlite3-multiple-ciphers');
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Check sharp: loads its native binding eagerly on require, but we use
|
|
176
|
-
// the same explicit-path approach for consistency and reliability.
|
|
177
|
-
try {
|
|
178
|
-
require('sharp');
|
|
179
|
-
} catch (err) {
|
|
180
|
-
if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
|
|
181
|
-
needsRebuild.push('sharp');
|
|
182
|
-
} else if (err.code === 'MODULE_NOT_FOUND') {
|
|
183
|
-
needsRebuild.push('sharp');
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Check node-pty: backs the Ariel terminal feature. Loaded dynamically by
|
|
188
|
-
// pty-manager in the standalone server, so resolution must succeed and the
|
|
189
|
-
// native binding's NODE_MODULE_VERSION must match the runtime.
|
|
190
|
-
try {
|
|
191
|
-
require('node-pty');
|
|
192
|
-
} catch (err) {
|
|
193
|
-
if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
|
|
194
|
-
needsRebuild.push('node-pty');
|
|
195
|
-
} else if (err.code === 'MODULE_NOT_FOUND') {
|
|
196
|
-
needsRebuild.push('node-pty');
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (needsRebuild.length === 0) return;
|
|
201
|
-
|
|
202
|
-
console.log(` Rebuilding native modules for Node.js ${process.version}...`);
|
|
203
|
-
|
|
204
|
-
try {
|
|
205
|
-
execSync(`npm rebuild ${needsRebuild.join(' ')}`, {
|
|
206
|
-
cwd: PACKAGE_DIR,
|
|
207
|
-
stdio: 'inherit',
|
|
208
|
-
});
|
|
209
|
-
console.log(' Done.');
|
|
210
|
-
console.log('');
|
|
211
|
-
} catch (err) {
|
|
212
|
-
console.error('');
|
|
213
|
-
console.error(` Warning: Failed to rebuild native modules: ${err.message}`);
|
|
214
|
-
console.error(' Try running: npm rebuild --prefix ' + PACKAGE_DIR);
|
|
215
|
-
console.error('');
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
141
|
// Symlink native modules into the standalone directory's node_modules
|
|
220
142
|
// so that standard Node.js resolution finds them without relying on NODE_PATH.
|
|
221
143
|
function linkNativeModules(standaloneDir) {
|
|
@@ -266,25 +188,67 @@ function linkNativeModules(standaloneDir) {
|
|
|
266
188
|
|| resolveModuleDir('better-sqlite3');
|
|
267
189
|
linkModule('better-sqlite3', betterSqlite3Dir);
|
|
268
190
|
|
|
269
|
-
// Link node-pty —
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
191
|
+
// Link node-pty — but only when we have to. The standalone tarball ships
|
|
192
|
+
// node-pty intact with cross-platform prebuilds (darwin-arm64, darwin-x64,
|
|
193
|
+
// win32-arm64, win32-x64). On those platforms the tarball-shipped copy is
|
|
194
|
+
// already correct, and we MUST NOT replace it with a symlink to the
|
|
195
|
+
// npm-installed copy: `sudo npm install -g quilltap` on macOS can strip the
|
|
196
|
+
// executable bit off `spawn-helper`, and we can't chmod a root-owned file
|
|
197
|
+
// back as a non-root runtime user — pty.spawn() then fails with
|
|
198
|
+
// `posix_spawnp failed`. Only Linux (which has no node-pty prebuild) and
|
|
199
|
+
// rebuild-failure cases need the symlink.
|
|
200
|
+
const standaloneNodePtyPath = path.join(standaloneNodeModules, 'node-pty');
|
|
201
|
+
const standaloneHasUsablePty = (() => {
|
|
202
|
+
try {
|
|
203
|
+
const stat = fs.lstatSync(standaloneNodePtyPath);
|
|
204
|
+
if (stat.isSymbolicLink()) return false; // prior broken-symlink state — replace it
|
|
205
|
+
if (!stat.isDirectory()) return false;
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const platformPrebuildDir = path.join(
|
|
210
|
+
standaloneNodePtyPath,
|
|
211
|
+
'prebuilds',
|
|
212
|
+
`${process.platform}-${process.arch}`,
|
|
213
|
+
);
|
|
214
|
+
return fs.existsSync(platformPrebuildDir);
|
|
215
|
+
})();
|
|
216
|
+
|
|
217
|
+
if (!standaloneHasUsablePty) {
|
|
218
|
+
const nodePtyDir = resolveModuleDir('node-pty');
|
|
219
|
+
linkModule('node-pty', nodePtyDir);
|
|
220
|
+
if (nodePtyDir) {
|
|
221
|
+
// Some npm cache extractions (notably `sudo npm install -g`) strip the
|
|
222
|
+
// executable bit on spawn-helper. Restore it where we can. If chmod
|
|
223
|
+
// fails because we don't own the file (typical when the CLI was
|
|
224
|
+
// installed with sudo and is run as a non-root user), warn loudly —
|
|
225
|
+
// silent failure here produces the `posix_spawnp failed` runtime error
|
|
226
|
+
// with no actionable hint.
|
|
227
|
+
const prebuildsDir = path.join(nodePtyDir, 'prebuilds');
|
|
228
|
+
if (fs.existsSync(prebuildsDir)) {
|
|
229
|
+
try {
|
|
230
|
+
for (const entry of fs.readdirSync(prebuildsDir, { withFileTypes: true })) {
|
|
231
|
+
if (!entry.isDirectory()) continue;
|
|
232
|
+
const helper = path.join(prebuildsDir, entry.name, 'spawn-helper');
|
|
233
|
+
if (!fs.existsSync(helper)) continue;
|
|
234
|
+
try {
|
|
235
|
+
fs.chmodSync(helper, 0o755);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (err && err.code === 'EPERM') {
|
|
238
|
+
console.error('');
|
|
239
|
+
console.error(` Warning: Could not make node-pty spawn-helper executable:`);
|
|
240
|
+
console.error(` ${helper}`);
|
|
241
|
+
console.error(` The file is owned by another user (likely root from a sudo'd`);
|
|
242
|
+
console.error(` global install). Terminal sessions will fail to spawn with`);
|
|
243
|
+
console.error(` "posix_spawnp failed" until this is fixed. Run:`);
|
|
244
|
+
console.error(` sudo chmod 755 "${helper}"`);
|
|
245
|
+
console.error('');
|
|
246
|
+
}
|
|
247
|
+
// Other errors are non-fatal — node-pty may still work if the bit was already set.
|
|
248
|
+
}
|
|
285
249
|
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
250
|
+
} catch { /* best-effort */ }
|
|
251
|
+
}
|
|
288
252
|
}
|
|
289
253
|
}
|
|
290
254
|
|
|
@@ -740,6 +704,11 @@ Subcommands (high-level shortcuts; auto-pick the right database):
|
|
|
740
704
|
log <id> Full request/response of a single LLM log
|
|
741
705
|
memories --character <id> Memories held by a character
|
|
742
706
|
(flags: --about <id|name> --source AUTO|MANUAL)
|
|
707
|
+
characters status Per-character vault readiness report:
|
|
708
|
+
flag value, vault present, single-file count,
|
|
709
|
+
Prompts/ and Scenarios/ folder counts, and any
|
|
710
|
+
divergence between DB columns and vault content.
|
|
711
|
+
(flags: --id <id|name> --diverged --blocked --limit N)
|
|
743
712
|
optimize [target...] Run maintenance (VACUUM + ANALYZE + PRAGMA optimize)
|
|
744
713
|
on the named databases, or all of them if no
|
|
745
714
|
target is given. Targets: main, llm-logs,
|
|
@@ -762,6 +731,8 @@ Low-level options (legacy; still supported):
|
|
|
762
731
|
--tables List all tables in the active database
|
|
763
732
|
--count <table> Show row count for a table
|
|
764
733
|
--repl Interactive SQL prompt (extras: .cols, .find)
|
|
734
|
+
--json Emit machine-readable JSON instead of a table
|
|
735
|
+
(works with --tables, --count, and raw SQL)
|
|
765
736
|
--llm-logs Target the LLM logs database
|
|
766
737
|
--mount-points Target the document mount-index database
|
|
767
738
|
--data-dir <path> Override data directory (pass instance root)
|
|
@@ -869,6 +840,7 @@ async function dbCommand(args) {
|
|
|
869
840
|
let lockStatus = false;
|
|
870
841
|
let lockClean = false;
|
|
871
842
|
let lockOverride = false;
|
|
843
|
+
let asJson = false;
|
|
872
844
|
|
|
873
845
|
let i = 0;
|
|
874
846
|
while (i < cleaned.length) {
|
|
@@ -878,6 +850,7 @@ async function dbCommand(args) {
|
|
|
878
850
|
case '--tables': showTables = true; break;
|
|
879
851
|
case '--count': countTable = cleaned[++i]; break;
|
|
880
852
|
case '--repl': repl = true; break;
|
|
853
|
+
case '--json': asJson = true; break;
|
|
881
854
|
case '--help': case '-h': showHelp = true; break;
|
|
882
855
|
case '--lock-status': lockStatus = true; break;
|
|
883
856
|
case '--lock-clean': lockClean = true; break;
|
|
@@ -971,22 +944,27 @@ async function dbCommand(args) {
|
|
|
971
944
|
try {
|
|
972
945
|
if (showTables) {
|
|
973
946
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
|
|
974
|
-
|
|
947
|
+
if (asJson) console.log(JSON.stringify(tables.map(t => t.name), null, 2));
|
|
948
|
+
else for (const t of tables) console.log(t.name);
|
|
975
949
|
} else if (countTable) {
|
|
976
950
|
const row = db.prepare(`SELECT count(*) as count FROM "${countTable}"`).get();
|
|
977
|
-
console.log(row.count);
|
|
951
|
+
if (asJson) console.log(JSON.stringify({ table: countTable, count: row.count }, null, 2));
|
|
952
|
+
else console.log(row.count);
|
|
978
953
|
} else if (sql) {
|
|
979
954
|
const stmt = db.prepare(sql);
|
|
980
955
|
if (stmt.reader) {
|
|
981
956
|
const rows = stmt.all();
|
|
982
|
-
if (
|
|
957
|
+
if (asJson) {
|
|
958
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
959
|
+
} else if (rows.length === 0) {
|
|
983
960
|
console.log('(no results)');
|
|
984
961
|
} else {
|
|
985
962
|
console.table(rows);
|
|
986
963
|
}
|
|
987
964
|
} else {
|
|
988
965
|
const info = stmt.run();
|
|
989
|
-
console.log(
|
|
966
|
+
if (asJson) console.log(JSON.stringify({ changes: info.changes, lastInsertRowid: Number(info.lastInsertRowid) }, null, 2));
|
|
967
|
+
else console.log(`Changes: ${info.changes}`);
|
|
990
968
|
}
|
|
991
969
|
} else if (repl) {
|
|
992
970
|
const readline = require('readline');
|
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
|
};
|
package/lib/memories-commands.js
CHANGED
|
@@ -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
|
-
|
|
183
|
+
// Lazy opener for the mount-index DB so `resolveCharacter` can match
|
|
184
|
+
// vault-stored aliases (4.6 cutover moved them out of the `characters` row).
|
|
185
|
+
const openMounts = () => openMountIndexDb(dataDir, pepper, { readonly: true });
|
|
186
|
+
return { db, dataDir, openMounts };
|
|
183
187
|
}
|
|
184
188
|
|
|
185
189
|
// ---------- filter / sort builders ----------
|
|
@@ -188,13 +192,13 @@ async function openDb(flags) {
|
|
|
188
192
|
// `{ where: 'WHERE m.x = ? AND ...', params: [...], meta: { characterId, ... } }`.
|
|
189
193
|
// `meta` exposes resolved IDs so callers can decide e.g. whether to show a
|
|
190
194
|
// per-row holder column.
|
|
191
|
-
function buildWhereClause(db, flags) {
|
|
195
|
+
function buildWhereClause(db, flags, openMounts = null) {
|
|
192
196
|
const clauses = [];
|
|
193
197
|
const params = [];
|
|
194
198
|
const meta = { characterId: null, aboutId: null, chatId: null, projectId: null, allCharacters: true };
|
|
195
199
|
|
|
196
200
|
if (flags.character && flags.character !== 'all') {
|
|
197
|
-
const c = resolveCharacter(db, flags.character);
|
|
201
|
+
const c = resolveCharacter(db, flags.character, openMounts);
|
|
198
202
|
clauses.push('m.characterId = ?');
|
|
199
203
|
params.push(c.id);
|
|
200
204
|
meta.characterId = c.id;
|
|
@@ -207,7 +211,7 @@ function buildWhereClause(db, flags) {
|
|
|
207
211
|
} else if (flags.about === 'none') {
|
|
208
212
|
clauses.push('m.aboutCharacterId IS NULL');
|
|
209
213
|
} else {
|
|
210
|
-
const a = resolveCharacter(db, flags.about);
|
|
214
|
+
const a = resolveCharacter(db, flags.about, openMounts);
|
|
211
215
|
clauses.push('m.aboutCharacterId = ?');
|
|
212
216
|
params.push(a.id);
|
|
213
217
|
meta.aboutId = a.id;
|
|
@@ -395,9 +399,9 @@ function renderJson(obj) {
|
|
|
395
399
|
// ---------- ls ----------
|
|
396
400
|
|
|
397
401
|
async function cmdLs(flags) {
|
|
398
|
-
const { db } = await openDb(flags);
|
|
402
|
+
const { db, openMounts } = await openDb(flags);
|
|
399
403
|
try {
|
|
400
|
-
const { where, params, meta } = buildWhereClause(db, flags);
|
|
404
|
+
const { where, params, meta } = buildWhereClause(db, flags, openMounts);
|
|
401
405
|
const { order, impField } = buildOrderBy(flags.sort, flags.reverse);
|
|
402
406
|
const limit = flags.limit > 0 ? flags.limit : 50;
|
|
403
407
|
const sql = `${SELECT_BASE} ${where} ORDER BY ${order} LIMIT ?`;
|
|
@@ -512,9 +516,9 @@ async function cmdFind(flags, positional) {
|
|
|
512
516
|
if (!['summary', 'content', 'both'].includes(inWhere)) {
|
|
513
517
|
throw new Error(`--in must be one of: summary, content, both (got '${inWhere}')`);
|
|
514
518
|
}
|
|
515
|
-
const { db } = await openDb(flags);
|
|
519
|
+
const { db, openMounts } = await openDb(flags);
|
|
516
520
|
try {
|
|
517
|
-
const { where, params, meta } = buildWhereClause(db, flags);
|
|
521
|
+
const { where, params, meta } = buildWhereClause(db, flags, openMounts);
|
|
518
522
|
const like = `%${pattern}%`;
|
|
519
523
|
|
|
520
524
|
const matchClauses = [];
|
|
@@ -584,9 +588,9 @@ async function cmdSemanticGrep(flags, query) {
|
|
|
584
588
|
// Resolve character locally so the server gets a stable UUID.
|
|
585
589
|
let characterId;
|
|
586
590
|
{
|
|
587
|
-
const { db } = await openDb(flags);
|
|
591
|
+
const { db, openMounts } = await openDb(flags);
|
|
588
592
|
try {
|
|
589
|
-
const resolved = resolveCharacter(db, flags.character);
|
|
593
|
+
const resolved = resolveCharacter(db, flags.character, openMounts);
|
|
590
594
|
characterId = resolved.id;
|
|
591
595
|
} finally {
|
|
592
596
|
db.close();
|
|
@@ -675,9 +679,9 @@ async function cmdGrep(flags, positional) {
|
|
|
675
679
|
if (flags.semantic) {
|
|
676
680
|
return cmdSemanticGrep(flags, pattern);
|
|
677
681
|
}
|
|
678
|
-
const { db } = await openDb(flags);
|
|
682
|
+
const { db, openMounts } = await openDb(flags);
|
|
679
683
|
try {
|
|
680
|
-
const { where, params } = buildWhereClause(db, flags);
|
|
684
|
+
const { where, params } = buildWhereClause(db, flags, openMounts);
|
|
681
685
|
// Always restrict to rows whose content can match — quick pre-filter so we
|
|
682
686
|
// don't read all 32k rows into JS just to drop most of them.
|
|
683
687
|
const likeNeedle = flags.ignoreCase ? `%${pattern.toLowerCase()}%` : `%${pattern}%`;
|
|
@@ -1044,11 +1048,11 @@ function graphToJson(node) {
|
|
|
1044
1048
|
// ---------- status ----------
|
|
1045
1049
|
|
|
1046
1050
|
async function cmdStatus(flags) {
|
|
1047
|
-
const { db } = await openDb(flags);
|
|
1051
|
+
const { db, openMounts } = await openDb(flags);
|
|
1048
1052
|
try {
|
|
1049
1053
|
let holderRows;
|
|
1050
1054
|
if (flags.character && flags.character !== 'all') {
|
|
1051
|
-
const c = resolveCharacter(db, flags.character);
|
|
1055
|
+
const c = resolveCharacter(db, flags.character, openMounts);
|
|
1052
1056
|
holderRows = [{ id: c.id, name: c.name }];
|
|
1053
1057
|
} else {
|
|
1054
1058
|
holderRows = db.prepare(`
|
|
@@ -1180,12 +1184,12 @@ function renderStatusBlock(holder, stats) {
|
|
|
1180
1184
|
// ---------- validate ----------
|
|
1181
1185
|
|
|
1182
1186
|
async function cmdValidate(flags) {
|
|
1183
|
-
const { db } = await openDb(flags);
|
|
1187
|
+
const { db, openMounts } = await openDb(flags);
|
|
1184
1188
|
try {
|
|
1185
1189
|
let holderIds = null;
|
|
1186
1190
|
let holderRows;
|
|
1187
1191
|
if (flags.character && flags.character !== 'all') {
|
|
1188
|
-
const c = resolveCharacter(db, flags.character);
|
|
1192
|
+
const c = resolveCharacter(db, flags.character, openMounts);
|
|
1189
1193
|
holderRows = [{ id: c.id, name: c.name }];
|
|
1190
1194
|
holderIds = [c.id];
|
|
1191
1195
|
} else {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared helpers for keeping native modules compiled against the current Node
|
|
4
|
+
// ABI. Used by both the runtime CLI entry (bin/quilltap.js) and the package's
|
|
5
|
+
// `postinstall` hook, so a fresh install picks up the correct binaries up front
|
|
6
|
+
// and a later Node upgrade still self-heals on first run.
|
|
7
|
+
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { execSync } = require('child_process');
|
|
10
|
+
|
|
11
|
+
const PACKAGE_DIR = path.resolve(__dirname, '..');
|
|
12
|
+
|
|
13
|
+
// Resolve a native module's directory, handling npm hoisting.
|
|
14
|
+
// Returns the directory containing package.json, or null if not found.
|
|
15
|
+
function resolveModuleDir(moduleName) {
|
|
16
|
+
try {
|
|
17
|
+
const pkgJson = require.resolve(moduleName + '/package.json', { paths: [PACKAGE_DIR] });
|
|
18
|
+
return path.dirname(pkgJson);
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// node-pty needs a `spawn-helper` executable beside the pty.node it loads, or
|
|
25
|
+
// pty.spawn() fails with `posix_spawnp failed`. An ABI rebuild lands a fresh
|
|
26
|
+
// build/Release/pty.node (which node-pty's loader prefers over prebuilds/) but
|
|
27
|
+
// emits only the addon, not node-pty's separate spawn-helper target; tar/extract
|
|
28
|
+
// can also drop the exec bit on the shipped prebuilds/*/spawn-helper. spawn-helper
|
|
29
|
+
// is a plain executable (no Node linkage) so the prebuilt copy is ABI-independent
|
|
30
|
+
// and safe to reuse. Best-effort; never throws.
|
|
31
|
+
function reconcileNodePtySpawnHelper() {
|
|
32
|
+
if (process.platform === 'win32') return; // conpty has no spawn-helper
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
try {
|
|
35
|
+
const nodePtyDir = resolveModuleDir('node-pty');
|
|
36
|
+
if (!nodePtyDir) return;
|
|
37
|
+
const prebuildsDir = path.join(nodePtyDir, 'prebuilds');
|
|
38
|
+
const prebuiltHelper = path.join(prebuildsDir, `${process.platform}-${process.arch}`, 'spawn-helper');
|
|
39
|
+
|
|
40
|
+
if (fs.existsSync(prebuildsDir)) {
|
|
41
|
+
for (const entry of fs.readdirSync(prebuildsDir)) {
|
|
42
|
+
const helper = path.join(prebuildsDir, entry, 'spawn-helper');
|
|
43
|
+
if (fs.existsSync(helper)) {
|
|
44
|
+
try { fs.chmodSync(helper, 0o755); } catch { /* best-effort */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const buildType of ['Release', 'Debug']) {
|
|
50
|
+
const buildDir = path.join(nodePtyDir, 'build', buildType);
|
|
51
|
+
const builtAddon = path.join(buildDir, 'pty.node');
|
|
52
|
+
const builtHelper = path.join(buildDir, 'spawn-helper');
|
|
53
|
+
if (fs.existsSync(builtHelper)) {
|
|
54
|
+
try { fs.chmodSync(builtHelper, 0o755); } catch { /* best-effort */ }
|
|
55
|
+
} else if (fs.existsSync(builtAddon) && fs.existsSync(prebuiltHelper)) {
|
|
56
|
+
fs.copyFileSync(prebuiltHelper, builtHelper);
|
|
57
|
+
fs.chmodSync(builtHelper, 0o755);
|
|
58
|
+
console.log(` node-pty: backfilled build/${buildType}/spawn-helper from prebuilds`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// best-effort — node-pty terminals are optional; never block the CLI
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check if native modules are compiled for the current Node.js version.
|
|
67
|
+
// This handles the case where npx caches the package but the user upgrades
|
|
68
|
+
// Node.js — the cached native modules will have a stale NODE_MODULE_VERSION.
|
|
69
|
+
// Returns true if everything was healthy or successfully rebuilt; false on
|
|
70
|
+
// rebuild failure. Never throws.
|
|
71
|
+
function ensureNativeModules() {
|
|
72
|
+
const needsRebuild = [];
|
|
73
|
+
|
|
74
|
+
// Check better-sqlite3-multiple-ciphers (provides SQLCipher encryption support).
|
|
75
|
+
// The main app depends on this via an npm alias as 'better-sqlite3', so we must
|
|
76
|
+
// ensure the SQLCipher-capable version is available and link it as 'better-sqlite3'.
|
|
77
|
+
// We must load the native binding directly to detect NODE_MODULE_VERSION mismatches.
|
|
78
|
+
try {
|
|
79
|
+
const modDir = resolveModuleDir('better-sqlite3-multiple-ciphers')
|
|
80
|
+
|| resolveModuleDir('better-sqlite3');
|
|
81
|
+
if (!modDir) throw Object.assign(new Error('not found'), { code: 'MODULE_NOT_FOUND' });
|
|
82
|
+
const bindingsPath = path.join(modDir, 'build', 'Release', 'better_sqlite3.node');
|
|
83
|
+
require(bindingsPath);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
|
|
86
|
+
needsRebuild.push('better-sqlite3-multiple-ciphers');
|
|
87
|
+
} else if (err.code === 'MODULE_NOT_FOUND') {
|
|
88
|
+
needsRebuild.push('better-sqlite3-multiple-ciphers');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check sharp: loads its native binding eagerly on require.
|
|
93
|
+
try {
|
|
94
|
+
require.resolve('sharp', { paths: [PACKAGE_DIR] });
|
|
95
|
+
require('sharp');
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
|
|
98
|
+
needsRebuild.push('sharp');
|
|
99
|
+
} else if (err.code === 'MODULE_NOT_FOUND') {
|
|
100
|
+
needsRebuild.push('sharp');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check node-pty: backs the Ariel terminal feature. Loaded dynamically by
|
|
105
|
+
// pty-manager in the standalone server, so resolution must succeed and the
|
|
106
|
+
// native binding's NODE_MODULE_VERSION must match the runtime.
|
|
107
|
+
try {
|
|
108
|
+
require.resolve('node-pty', { paths: [PACKAGE_DIR] });
|
|
109
|
+
require('node-pty');
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err.message && err.message.includes('NODE_MODULE_VERSION')) {
|
|
112
|
+
needsRebuild.push('node-pty');
|
|
113
|
+
} else if (err.code === 'MODULE_NOT_FOUND') {
|
|
114
|
+
needsRebuild.push('node-pty');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (needsRebuild.length === 0) {
|
|
119
|
+
reconcileNodePtySpawnHelper();
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(` Rebuilding native modules for Node.js ${process.version}...`);
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
execSync(`npm rebuild ${needsRebuild.join(' ')}`, {
|
|
127
|
+
cwd: PACKAGE_DIR,
|
|
128
|
+
stdio: 'inherit',
|
|
129
|
+
});
|
|
130
|
+
console.log(' Done.');
|
|
131
|
+
console.log('');
|
|
132
|
+
reconcileNodePtySpawnHelper();
|
|
133
|
+
return true;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error('');
|
|
136
|
+
console.error(` Warning: Failed to rebuild native modules: ${err.message}`);
|
|
137
|
+
console.error(' Try running: npm rebuild --prefix ' + PACKAGE_DIR);
|
|
138
|
+
console.error('');
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { resolveModuleDir, ensureNativeModules, reconcileNodePtySpawnHelper, PACKAGE_DIR };
|
|
144
|
+
|
|
145
|
+
// Allow this file to be invoked directly as a postinstall script:
|
|
146
|
+
// node lib/native-modules.js
|
|
147
|
+
// Exits 0 on success or graceful warning; never blocks npm install on failure.
|
|
148
|
+
if (require.main === module) {
|
|
149
|
+
ensureNativeModules();
|
|
150
|
+
process.exit(0);
|
|
151
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quilltap",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.0-dev.105",
|
|
4
4
|
"description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Charles Sebold",
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
"bin": {
|
|
28
28
|
"quilltap": "bin/quilltap.js"
|
|
29
29
|
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"postinstall": "node lib/native-modules.js"
|
|
32
|
+
},
|
|
30
33
|
"files": [
|
|
31
34
|
"bin/",
|
|
32
35
|
"lib/",
|
|
@@ -34,11 +37,11 @@
|
|
|
34
37
|
],
|
|
35
38
|
"dependencies": {
|
|
36
39
|
"@napi-rs/canvas": "^0.1.100",
|
|
37
|
-
"better-sqlite3-multiple-ciphers": "^12.
|
|
40
|
+
"better-sqlite3-multiple-ciphers": "^12.10.0",
|
|
38
41
|
"node-pty": "^1.1.0",
|
|
39
42
|
"sharp": "^0.34.5",
|
|
40
43
|
"tar": "^7.5.15",
|
|
41
|
-
"yauzl": "^3.3.
|
|
44
|
+
"yauzl": "^3.3.1"
|
|
42
45
|
},
|
|
43
46
|
"engines": {
|
|
44
47
|
"node": ">=24.0.0"
|