quilltap 4.8.0-dev.183 → 4.8.0-dev.191
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 +14 -0
- package/lib/db-commands.js +268 -3
- package/package.json +1 -1
package/bin/quilltap.js
CHANGED
|
@@ -717,6 +717,20 @@ Subcommands (high-level shortcuts; auto-pick the right database):
|
|
|
717
717
|
Prompts/ and Scenarios/ folder counts, and any
|
|
718
718
|
divergence between DB columns and vault content.
|
|
719
719
|
(flags: --id <id|name> --diverged --blocked --limit N)
|
|
720
|
+
characters archives List archived characters and ARCHIVE bundles on
|
|
721
|
+
the shelf, loose bundles included. Read-only.
|
|
722
|
+
characters archive <name|id> --write [--port N]
|
|
723
|
+
Archive a character via the RUNNING server (it
|
|
724
|
+
holds the export pipeline and the passphrase).
|
|
725
|
+
characters rehydrate <name|id> --write [--port N]
|
|
726
|
+
Wake an archived character via the running server.
|
|
727
|
+
characters export <name|id> [--out <path>] [--port N]
|
|
728
|
+
Write a PLAINTEXT .qtap for a character. Archived:
|
|
729
|
+
decrypts the bundle offline (prompts for the
|
|
730
|
+
passphrase on protected instances) — the only way
|
|
731
|
+
to reach packed-away mail/photos/summaries without
|
|
732
|
+
rehydrating. Live: runs the server's export
|
|
733
|
+
pipeline (server must be up). Read-only.
|
|
720
734
|
optimize [target...] Run maintenance (VACUUM + ANALYZE + PRAGMA optimize)
|
|
721
735
|
on the named databases, or all of them if no
|
|
722
736
|
target is given. Targets: main, llm-logs,
|
package/lib/db-commands.js
CHANGED
|
@@ -922,13 +922,26 @@ function inspectCharacterVault(row, mounts) {
|
|
|
922
922
|
return status;
|
|
923
923
|
}
|
|
924
924
|
|
|
925
|
-
function cmdCharacters(args, ctx) {
|
|
925
|
+
async function cmdCharacters(args, ctx) {
|
|
926
926
|
const { flags, positional } = parseSubArgs(args);
|
|
927
927
|
const sub = positional[0] || 'status';
|
|
928
|
-
|
|
929
|
-
|
|
928
|
+
switch (sub) {
|
|
929
|
+
case 'status':
|
|
930
|
+
return cmdCharactersStatus(flags, ctx);
|
|
931
|
+
case 'archives':
|
|
932
|
+
return cmdCharactersArchives(flags, ctx);
|
|
933
|
+
case 'archive':
|
|
934
|
+
return cmdCharactersArchiveVerb('archive', positional[1], flags, ctx);
|
|
935
|
+
case 'rehydrate':
|
|
936
|
+
return cmdCharactersArchiveVerb('rehydrate', positional[1], flags, ctx);
|
|
937
|
+
case 'export':
|
|
938
|
+
return cmdCharactersExport(positional[1], flags, ctx);
|
|
939
|
+
default:
|
|
940
|
+
throw new Error(`Unknown characters subcommand: ${sub}. Try: status, archives, archive, rehydrate, export`);
|
|
930
941
|
}
|
|
942
|
+
}
|
|
931
943
|
|
|
944
|
+
function cmdCharactersStatus(flags, ctx) {
|
|
932
945
|
const json = asBool(flags.json);
|
|
933
946
|
const limit = asInt(flags.limit, 0);
|
|
934
947
|
const onlyDiverged = asBool(flags.diverged);
|
|
@@ -1042,6 +1055,258 @@ function summarizeCharacterStatuses(all) {
|
|
|
1042
1055
|
return { ok, diverged, missingFiles, noVault, empty, physIncomplete };
|
|
1043
1056
|
}
|
|
1044
1057
|
|
|
1058
|
+
// ---------- characters: archive shelf ----------
|
|
1059
|
+
|
|
1060
|
+
const ARCHIVE_MAGIC = Buffer.from('QTAPARC1', 'ascii');
|
|
1061
|
+
const ARCHIVE_INTERNAL_PASSPHRASE = '__quilltap_no_passphrase__';
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* List archived characters and the ARCHIVE bundle files on the shelf,
|
|
1065
|
+
* including loose bundles (files rows no character points at — the survivors
|
|
1066
|
+
* of a "keep archived bundles" wipe). Read-only.
|
|
1067
|
+
*/
|
|
1068
|
+
function cmdCharactersArchives(flags, ctx) {
|
|
1069
|
+
const json = asBool(flags.json);
|
|
1070
|
+
const main = ctx.openMain();
|
|
1071
|
+
try {
|
|
1072
|
+
const cols = new Set(main.prepare('PRAGMA table_info(characters)').all().map(r => r.name));
|
|
1073
|
+
if (!cols.has('archivedAt')) {
|
|
1074
|
+
console.log('This database predates character archiving (no archivedAt column).');
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const archived = main.prepare(
|
|
1078
|
+
'SELECT id, name, archivedAt, archiveFileId FROM characters WHERE archivedAt IS NOT NULL ORDER BY archivedAt DESC'
|
|
1079
|
+
).all();
|
|
1080
|
+
const bundles = main.prepare(
|
|
1081
|
+
"SELECT id, originalFilename, storageKey, size, createdAt FROM files WHERE category = 'ARCHIVE' ORDER BY createdAt DESC"
|
|
1082
|
+
).all();
|
|
1083
|
+
|
|
1084
|
+
const referenced = new Set(archived.map(c => c.archiveFileId).filter(Boolean));
|
|
1085
|
+
const looseBundles = bundles.filter(b => !referenced.has(b.id));
|
|
1086
|
+
|
|
1087
|
+
if (json) {
|
|
1088
|
+
return printJson({ archivedCharacters: archived, bundles, looseBundles });
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (archived.length === 0 && bundles.length === 0) {
|
|
1092
|
+
console.log('The archive shelf stands empty — no archived characters, no bundles.');
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
if (archived.length > 0) {
|
|
1097
|
+
console.log(`Archived characters (${archived.length}):`);
|
|
1098
|
+
printTable(archived.map(c => ({
|
|
1099
|
+
id: c.id.slice(0, 8),
|
|
1100
|
+
name: truncate(c.name, 28),
|
|
1101
|
+
archivedAt: c.archivedAt,
|
|
1102
|
+
bundle: c.archiveFileId ? c.archiveFileId.slice(0, 8) : '(none — pre-bundle tombstone)',
|
|
1103
|
+
})));
|
|
1104
|
+
}
|
|
1105
|
+
if (bundles.length > 0) {
|
|
1106
|
+
console.log('');
|
|
1107
|
+
console.log(`Archive bundles (${bundles.length}${looseBundles.length > 0 ? `, ${looseBundles.length} loose` : ''}):`);
|
|
1108
|
+
printTable(bundles.map(b => ({
|
|
1109
|
+
id: b.id.slice(0, 8),
|
|
1110
|
+
file: truncate(b.originalFilename, 44),
|
|
1111
|
+
bytes: b.size,
|
|
1112
|
+
createdAt: b.createdAt,
|
|
1113
|
+
state: referenced.has(b.id) ? 'held by character' : 'loose (importable only)',
|
|
1114
|
+
})));
|
|
1115
|
+
}
|
|
1116
|
+
} finally {
|
|
1117
|
+
try { main.close(); } catch {}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Archive or rehydrate a character through the RUNNING server's API. The
|
|
1123
|
+
* archive pipeline (export, encryption, prune) and the passphrase cache live
|
|
1124
|
+
* in the server process — the CLI cannot run them against the raw database —
|
|
1125
|
+
* so the server must be up, and it is the server that holds the instance
|
|
1126
|
+
* lock. `--write` is still required as the explicit opt-in to a write.
|
|
1127
|
+
*/
|
|
1128
|
+
async function cmdCharactersArchiveVerb(verb, query, flags, ctx) {
|
|
1129
|
+
if (!query) {
|
|
1130
|
+
throw new Error(`Usage: characters ${verb} <name|id> --write [--port N]`);
|
|
1131
|
+
}
|
|
1132
|
+
if (!asBool(flags.write)) {
|
|
1133
|
+
throw new Error(`characters ${verb} changes data; add --write to proceed.`);
|
|
1134
|
+
}
|
|
1135
|
+
const port = asInt(flags.port, 3000);
|
|
1136
|
+
|
|
1137
|
+
const main = ctx.openMain();
|
|
1138
|
+
let character;
|
|
1139
|
+
try {
|
|
1140
|
+
character = resolveCharacter(main, String(query), ctx.openMounts);
|
|
1141
|
+
} finally {
|
|
1142
|
+
try { main.close(); } catch {}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const url = `http://localhost:${port}/api/v1/characters/${encodeURIComponent(character.id)}?action=${verb}`;
|
|
1146
|
+
let res;
|
|
1147
|
+
try {
|
|
1148
|
+
res = await fetch(url, { method: 'POST' });
|
|
1149
|
+
} catch (err) {
|
|
1150
|
+
throw new Error(
|
|
1151
|
+
`Could not reach the Quilltap server at http://localhost:${port}: ${err.message}\n` +
|
|
1152
|
+
`The ${verb} operation runs inside the server (it needs the export pipeline and the ` +
|
|
1153
|
+
'unlocked passphrase), so start the server first.'
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
const body = await res.json().catch(() => ({}));
|
|
1157
|
+
if (!res.ok) {
|
|
1158
|
+
throw new Error(body.error || `${verb} failed with HTTP ${res.status}`);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (asBool(flags.json)) return printJson(body);
|
|
1162
|
+
if (verb === 'archive') {
|
|
1163
|
+
console.log(
|
|
1164
|
+
body.pruneComplete === false
|
|
1165
|
+
? `${character.name} is archived, but the prune did not finish — run the same command again to complete it.`
|
|
1166
|
+
: `${character.name} rests in the archive. Bundle file: ${body.archiveFileId || '(none)'}.`
|
|
1167
|
+
);
|
|
1168
|
+
} else {
|
|
1169
|
+
const r = body.restored;
|
|
1170
|
+
console.log(
|
|
1171
|
+
r
|
|
1172
|
+
? `${character.name} is awake again — ${r.memories} memories, ${r.documents} documents, ${r.blobs} blobs restored.`
|
|
1173
|
+
: `${character.name} is awake again.`
|
|
1174
|
+
);
|
|
1175
|
+
if (body.archiveBundleFileId) {
|
|
1176
|
+
console.log(`The archive bundle stays in the file library (file ${body.archiveBundleFileId}); delete it there if you no longer want the spare copy.`);
|
|
1177
|
+
}
|
|
1178
|
+
for (const w of body.warnings || []) console.log(`warning: ${w}`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** Parse + decrypt a QTAPARC1 bundle. Returns null on a wrong passphrase. */
|
|
1183
|
+
function tryDecryptArchiveBundle(data, passphrase) {
|
|
1184
|
+
const crypto = require('crypto');
|
|
1185
|
+
const headerLength = data.readUInt32BE(ARCHIVE_MAGIC.length);
|
|
1186
|
+
const bodyStart = ARCHIVE_MAGIC.length + 4 + headerLength;
|
|
1187
|
+
if (headerLength <= 0 || data.length < bodyStart + 16) {
|
|
1188
|
+
throw new Error('Archive bundle is truncated (bad header or missing auth tag).');
|
|
1189
|
+
}
|
|
1190
|
+
const header = JSON.parse(data.subarray(ARCHIVE_MAGIC.length + 4, bodyStart).toString('utf8'));
|
|
1191
|
+
const salt = Buffer.from(header.salt, 'hex');
|
|
1192
|
+
const iv = Buffer.from(header.iv, 'hex');
|
|
1193
|
+
const key = crypto.pbkdf2Sync(passphrase, new Uint8Array(salt), header.kdfIterations, 32, header.kdfDigest);
|
|
1194
|
+
const keyHash = crypto.createHash('sha256').update(new Uint8Array(key)).digest('hex');
|
|
1195
|
+
if (keyHash !== header.keyHash) return null;
|
|
1196
|
+
|
|
1197
|
+
const ciphertext = data.subarray(bodyStart, data.length - 16);
|
|
1198
|
+
const authTag = data.subarray(data.length - 16);
|
|
1199
|
+
const decipher = crypto.createDecipheriv(header.algorithm, new Uint8Array(key), new Uint8Array(iv));
|
|
1200
|
+
decipher.setAuthTag(new Uint8Array(authTag));
|
|
1201
|
+
return Buffer.concat([decipher.update(new Uint8Array(ciphertext)), decipher.final()]);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Export a character as a plaintext `.qtap` — the interchange escape hatch.
|
|
1206
|
+
*
|
|
1207
|
+
* Archived characters: decrypt their bundle straight off the disk (offline;
|
|
1208
|
+
* prompts for the passphrase on protected instances). This is the only way to
|
|
1209
|
+
* reach an archived character's packed-away material — mail, photographs,
|
|
1210
|
+
* summaries — without rehydrating. Live characters: proxy to the running
|
|
1211
|
+
* server's export pipeline. Read-only either way.
|
|
1212
|
+
*/
|
|
1213
|
+
async function cmdCharactersExport(query, flags, ctx) {
|
|
1214
|
+
if (!query) {
|
|
1215
|
+
throw new Error('Usage: characters export <name|id> [--out <path>] [--port N]');
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const main = ctx.openMain();
|
|
1219
|
+
let character;
|
|
1220
|
+
let archiveFile = null;
|
|
1221
|
+
try {
|
|
1222
|
+
character = resolveCharacter(main, String(query), ctx.openMounts);
|
|
1223
|
+
const cols = new Set(main.prepare('PRAGMA table_info(characters)').all().map(r => r.name));
|
|
1224
|
+
if (cols.has('archivedAt')) {
|
|
1225
|
+
const row = main.prepare('SELECT archivedAt, archiveFileId FROM characters WHERE id = ?').get(character.id);
|
|
1226
|
+
if (row && row.archivedAt && row.archiveFileId) {
|
|
1227
|
+
archiveFile = main.prepare('SELECT id, storageKey, sha256 FROM files WHERE id = ?').get(row.archiveFileId);
|
|
1228
|
+
if (!archiveFile) {
|
|
1229
|
+
throw new Error(`${character.name} is archived but their bundle file row (${row.archiveFileId}) is missing.`);
|
|
1230
|
+
}
|
|
1231
|
+
} else if (row && row.archivedAt) {
|
|
1232
|
+
throw new Error(`${character.name} is a pre-bundle tombstone (no archive bundle exists to export).`);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
} finally {
|
|
1236
|
+
try { main.close(); } catch {}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const safeName = String(character.name || character.id).replace(/[\\/:*?"<>|]/g, '_');
|
|
1240
|
+
const outPath = path.resolve(flags.out ? String(flags.out) : `${safeName}.qtap`);
|
|
1241
|
+
|
|
1242
|
+
let plaintext;
|
|
1243
|
+
if (archiveFile) {
|
|
1244
|
+
if (!archiveFile.storageKey) {
|
|
1245
|
+
throw new Error('The bundle row has no storage key; the file was never written.');
|
|
1246
|
+
}
|
|
1247
|
+
const bundlePath = path.join(ctx.dataDir, '..', 'files', archiveFile.storageKey);
|
|
1248
|
+
if (!fs.existsSync(bundlePath)) {
|
|
1249
|
+
throw new Error(`Bundle bytes not found on disk: ${bundlePath}`);
|
|
1250
|
+
}
|
|
1251
|
+
const data = fs.readFileSync(bundlePath);
|
|
1252
|
+
|
|
1253
|
+
if (!data.subarray(0, ARCHIVE_MAGIC.length).equals(ARCHIVE_MAGIC)) {
|
|
1254
|
+
// Pre-encryption plaintext bundle — pass it through untouched.
|
|
1255
|
+
plaintext = data;
|
|
1256
|
+
} else {
|
|
1257
|
+
plaintext = tryDecryptArchiveBundle(data, ARCHIVE_INTERNAL_PASSPHRASE);
|
|
1258
|
+
if (plaintext === null && process.env.QUILLTAP_DB_PASSPHRASE) {
|
|
1259
|
+
plaintext = tryDecryptArchiveBundle(data, process.env.QUILLTAP_DB_PASSPHRASE);
|
|
1260
|
+
}
|
|
1261
|
+
if (plaintext === null) {
|
|
1262
|
+
const { promptPassphrase } = require('./db-helpers');
|
|
1263
|
+
const pass = await promptPassphrase('Archive passphrase: ');
|
|
1264
|
+
if (pass) plaintext = tryDecryptArchiveBundle(data, pass);
|
|
1265
|
+
}
|
|
1266
|
+
if (plaintext === null) {
|
|
1267
|
+
throw new Error(
|
|
1268
|
+
'That passphrase does not open this archive. If you changed your passphrase and this ' +
|
|
1269
|
+
'bundle was reported left behind, it still wants the old one.'
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
} else {
|
|
1274
|
+
// Live character: the export pipeline lives in the server.
|
|
1275
|
+
const port = asInt(flags.port, 3000);
|
|
1276
|
+
const url = `http://localhost:${port}/api/v1/system/tools?action=export`;
|
|
1277
|
+
let res;
|
|
1278
|
+
try {
|
|
1279
|
+
res = await fetch(url, {
|
|
1280
|
+
method: 'POST',
|
|
1281
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1282
|
+
body: JSON.stringify({
|
|
1283
|
+
type: 'characters',
|
|
1284
|
+
scope: 'selected',
|
|
1285
|
+
selectedIds: [character.id],
|
|
1286
|
+
includeMemories: true,
|
|
1287
|
+
}),
|
|
1288
|
+
});
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
throw new Error(
|
|
1291
|
+
`Could not reach the Quilltap server at http://localhost:${port}: ${err.message}\n` +
|
|
1292
|
+
'Exporting a live character runs the server\'s export pipeline, so start the server first. ' +
|
|
1293
|
+
'(Archived characters export offline from their bundle.)'
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
if (!res.ok) {
|
|
1297
|
+
const body = await res.json().catch(() => ({}));
|
|
1298
|
+
throw new Error(body.error || `Export failed with HTTP ${res.status}`);
|
|
1299
|
+
}
|
|
1300
|
+
plaintext = Buffer.from(await res.arrayBuffer());
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
fs.writeFileSync(outPath, plaintext);
|
|
1304
|
+
console.log(`Wrote ${plaintext.length} bytes to ${outPath}`);
|
|
1305
|
+
if (archiveFile) {
|
|
1306
|
+
console.log('This is the decrypted archive bundle — a plaintext .qtap. Guard it accordingly.');
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1045
1310
|
// ---------- verb: optimize ----------
|
|
1046
1311
|
|
|
1047
1312
|
const OPTIMIZE_TARGETS = {
|