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 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,
@@ -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
- if (sub !== 'status') {
929
- throw new Error(`Unknown characters subcommand: ${sub}. Try: status`);
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 = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.8.0-dev.183",
3
+ "version": "4.8.0-dev.191",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",