quilltap 4.10.0-dev.99 → 4.11.0-dev.11

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.
@@ -1,11 +1,17 @@
1
1
  'use strict';
2
2
 
3
+ const { resolveServerPort } = require('./lock-helpers');
4
+
3
5
  /**
4
6
  * `quilltap recall-replay <chatId>` — replay a turn's memory recall against
5
7
  * the running server and print the candidate table, old path (episodic
6
8
  * signals inert) vs. new path (retrospective flip + time window + entity
7
9
  * anchors + multi-probe) side by side.
8
10
  *
11
+ * Then the text fallback's table and the stage trace (query, gather, hydrate,
12
+ * score, select, assemble): what the archive removed, what the budget cut, what
13
+ * the inter-character section took.
14
+ *
9
15
  * Thin wrapper over POST /api/v1/chats/<chatId>?action=recall-replay.
10
16
  * Read-only; nothing is persisted server-side.
11
17
  */
@@ -28,25 +34,62 @@ Replays the per-turn memory recall for a chat turn against the running
28
34
  Quilltap server and prints the full candidate table twice — the pre-overhaul
29
35
  ranking and the episodic (retrospective/time-window/entity) ranking — so the
30
36
  recall constants can be tuned against real "the character forgot" turns.
37
+ After them: what the text fallback would have returned, and the stage trace
38
+ (query → gather → hydrate → score → select → assemble). The anti-repetition
39
+ ring is the one in force at that turn, read from the chat's recall records.
31
40
 
32
41
  Options:
42
+ -d, --data-dir <path> Instance root; used to find the server's port
43
+ -i, --instance <name> Registered instance whose server to call (see 'quilltap instances')
44
+ --passphrase <pass> Accepted and ignored (this tool never opens the database)
33
45
  --turn <number> 1-based interchange to replay at (default: last)
34
46
  --char <characterId> Character whose memories are searched
35
47
  (default: first LLM-controlled participant)
36
- --limit <number> Candidate rows per path (default: 25, max: 100)
37
- --port <number> Server port for API calls (default: 3000)
48
+ --limit <number> Candidate rows per path (default: the larger of 25
49
+ and the new path's head size + 10; max: 100)
50
+ --memory-budget <tokens>
51
+ Memory token budget (positive integer). The new
52
+ path's selected rows then follow the budget-sized
53
+ dynamic head (the head scales with the model's
54
+ memory budget); the old path keeps the historical
55
+ fixed 5-entry head.
56
+ --tuning '<json>' Retuning constants for the NEW path only, e.g.
57
+ '{"boostGateAbs":0.45,"boostGateMargin":0.15,
58
+ "boostGateRamp":0.1,"boostCap":1.6}'. Keys:
59
+ boostGateAbs, boostGateMargin, boostGateRamp,
60
+ boostCap, freshBypassesGate, windowBypassesGate,
61
+ multipliers
62
+ {RECALL_MULTIPLIERS key:
63
+ value}, specificAnchors, anchorMinHits,
64
+ anchorOrder, backgroundReserve.
65
+ Unknown keys are rejected.
66
+ --tuning-file <path> Read the --tuning JSON from a file
67
+ --signals-from <path> Reuse the signals saved in a previous --json
68
+ replay of the same chat and turn, instead of a
69
+ fresh (non-deterministic) distillation
70
+ --as-of Search only memories created before the turn's
71
+ opening message (the corpus as it stood then)
72
+ --port <number> Server port for API calls. Default: the port recorded in the
73
+ --instance/--data-dir server's lock file, else 3000
38
74
  --json Print the raw JSON result instead of tables
39
75
  -h, --help Show this help
40
76
 
41
77
  Examples:
42
78
  quilltap recall-replay <chatId>
43
79
  quilltap recall-replay <chatId> --turn 42
80
+ quilltap recall-replay <chatId> --turn 42 --memory-budget 4000
44
81
  quilltap recall-replay <chatId> --turn 42 --json > replay.json
82
+ quilltap recall-replay <chatId> --turn 42 --signals-from replay.json --as-of \
83
+ --tuning-file tuning-r1.json
45
84
  `);
46
85
  }
47
86
 
48
87
  function parseFlags(args) {
49
- const flags = { turn: undefined, char: undefined, limit: undefined, port: 3000, json: false, help: false };
88
+ const flags = {
89
+ turn: undefined, char: undefined, limit: undefined, memoryBudget: undefined,
90
+ tuning: undefined, tuningFile: undefined, signalsFrom: undefined, asOf: false,
91
+ port: undefined, dataDir: '', instance: '', json: false, help: false,
92
+ };
50
93
  const positional = [];
51
94
  let i = 0;
52
95
  while (i < args.length) {
@@ -73,6 +116,40 @@ function parseFlags(args) {
73
116
  flags.limit = n;
74
117
  break;
75
118
  }
119
+ case '--memory-budget': {
120
+ const n = Number(args[++i]);
121
+ if (!Number.isInteger(n) || n < 1) {
122
+ console.error('Error: --memory-budget must be a positive integer');
123
+ process.exit(1);
124
+ }
125
+ flags.memoryBudget = n;
126
+ break;
127
+ }
128
+ case '--tuning':
129
+ flags.tuning = args[++i];
130
+ break;
131
+ case '--tuning-file':
132
+ flags.tuningFile = args[++i];
133
+ break;
134
+ case '--signals-from':
135
+ flags.signalsFrom = args[++i];
136
+ break;
137
+ case '--as-of':
138
+ flags.asOf = true;
139
+ break;
140
+ case '-d':
141
+ case '--data-dir':
142
+ flags.dataDir = args[++i];
143
+ break;
144
+ case '-i':
145
+ case '--instance':
146
+ flags.instance = args[++i];
147
+ break;
148
+ case '--passphrase':
149
+ // Accepted so universal flags never error; these tools talk to the
150
+ // running server and never open the database themselves.
151
+ i++;
152
+ break;
76
153
  case '--port': {
77
154
  const p = parseInt(args[++i], 10);
78
155
  if (isNaN(p) || p < 1 || p > 65535) {
@@ -106,8 +183,9 @@ function fmt(n, digits = 3) {
106
183
  return n.toFixed(digits);
107
184
  }
108
185
 
109
- function printPath(label, rows) {
110
- console.log(`\n${BOLD}${label}${RESET} (${rows.length} candidates)`);
186
+ function printPath(label, rows, headSize) {
187
+ const head = typeof headSize === 'number' ? `, head ${headSize}` : '';
188
+ console.log(`\n${BOLD}${label}${RESET} (${rows.length} candidates${head})`);
111
189
  if (rows.length === 0) {
112
190
  console.log(` ${DIM}(none)${RESET}`);
113
191
  return;
@@ -127,6 +205,73 @@ function printPath(label, rows) {
127
205
  }
128
206
  }
129
207
 
208
+ /** The stage trace: one line per stage entry (query → gather → … → assemble). */
209
+ function printTrace(trace) {
210
+ if (!Array.isArray(trace) || trace.length === 0) return;
211
+ console.log(`\n${BOLD}STAGE TRACE${RESET}`);
212
+ for (const entry of trace) {
213
+ const label = entry.label ? `${DIM}(${entry.label})${RESET} ` : '';
214
+ const detail = Object.entries(entry.detail || {})
215
+ .map(([k, v]) => `${k}=${typeof v === 'object' && v !== null ? JSON.stringify(v) : v}`)
216
+ .join(' ');
217
+ console.log(` ${CYAN}${String(entry.stage).padEnd(9)}${RESET}${label}${detail}`);
218
+ }
219
+ }
220
+
221
+ function readJson(text, what) {
222
+ try {
223
+ return JSON.parse(text);
224
+ } catch (err) {
225
+ console.error(`${RED}Could not parse ${what} as JSON: ${err.message}${RESET}`);
226
+ process.exit(1);
227
+ }
228
+ }
229
+
230
+ function readJsonFile(path, what) {
231
+ const fs = require('fs');
232
+ let text;
233
+ try {
234
+ text = fs.readFileSync(path, 'utf8');
235
+ } catch (err) {
236
+ console.error(`${RED}Could not read ${what} ${path}: ${err.message}${RESET}`);
237
+ process.exit(1);
238
+ }
239
+ return readJson(text, `${what} ${path}`);
240
+ }
241
+
242
+ /** The tuning object from --tuning or --tuning-file (never both). */
243
+ function loadTuning(flags) {
244
+ if (flags.tuning !== undefined && flags.tuningFile !== undefined) {
245
+ console.error('Error: pass --tuning or --tuning-file, not both');
246
+ process.exit(1);
247
+ }
248
+ if (flags.tuning !== undefined) return readJson(flags.tuning, '--tuning');
249
+ if (flags.tuningFile !== undefined) return readJsonFile(flags.tuningFile, '--tuning-file');
250
+ return undefined;
251
+ }
252
+
253
+ /**
254
+ * The signals saved in a previous --json replay. Refuses a file from another
255
+ * chat or turn — reusing its signals would replay a different question.
256
+ */
257
+ function loadSignals(path, chatId, turn) {
258
+ const saved = readJsonFile(path, '--signals-from');
259
+ const result = saved.data ?? saved;
260
+ if (!result.signals) {
261
+ console.error(`${RED}${path} has no saved signals (its distillation failed); replay without --signals-from${RESET}`);
262
+ process.exit(1);
263
+ }
264
+ if (result.chatId && result.chatId !== chatId) {
265
+ console.error(`${RED}${path} is a replay of chat ${result.chatId}, not ${chatId}${RESET}`);
266
+ process.exit(1);
267
+ }
268
+ if (turn !== undefined && result.turnIndex !== undefined && result.turnIndex !== turn) {
269
+ console.error(`${RED}${path} is a replay of turn ${result.turnIndex}, not ${turn}${RESET}`);
270
+ process.exit(1);
271
+ }
272
+ return result.signals;
273
+ }
274
+
130
275
  async function recallReplayCommand(args) {
131
276
  const { flags, positional } = parseFlags(args);
132
277
 
@@ -140,11 +285,26 @@ async function recallReplayCommand(args) {
140
285
  }
141
286
  const chatId = positional[0];
142
287
 
143
- const url = `http://localhost:${flags.port}/api/v1/chats/${encodeURIComponent(chatId)}?action=recall-replay`;
288
+ let target;
289
+ try {
290
+ target = resolveServerPort({ port: flags.port, instance: flags.instance, dataDir: flags.dataDir });
291
+ } catch (err) {
292
+ console.error(`Error: ${err.message}`);
293
+ process.exit(1);
294
+ }
295
+ const port = target.port;
296
+ if (target.note) process.stderr.write(`${DIM}${target.note}${RESET}\n`);
297
+
298
+ const url = `http://localhost:${port}/api/v1/chats/${encodeURIComponent(chatId)}?action=recall-replay`;
144
299
  const body = {};
145
300
  if (flags.turn !== undefined) body.turnIndex = flags.turn;
146
301
  if (flags.char) body.characterId = flags.char;
147
302
  if (flags.limit !== undefined) body.limit = flags.limit;
303
+ if (flags.memoryBudget !== undefined) body.memoryBudget = flags.memoryBudget;
304
+ const tuning = loadTuning(flags);
305
+ if (tuning !== undefined) body.tuning = tuning;
306
+ if (flags.signalsFrom !== undefined) body.signals = loadSignals(flags.signalsFrom, chatId, flags.turn);
307
+ if (flags.asOf) body.asOf = true;
148
308
 
149
309
  process.stderr.write(`${BOLD}Replaying recall${RESET} for chat ${DIM}${chatId}${RESET} via ${DIM}${url}${RESET}\n`);
150
310
 
@@ -156,8 +316,8 @@ async function recallReplayCommand(args) {
156
316
  body: JSON.stringify(body),
157
317
  });
158
318
  } catch (err) {
159
- console.error(`${RED}Could not reach Quilltap server at http://localhost:${flags.port}: ${err.message}${RESET}`);
160
- console.error('Start the server (npm run dev) or pass --port to match a non-default port.');
319
+ console.error(`${RED}Could not reach Quilltap server at http://localhost:${port}: ${err.message}${RESET}`);
320
+ console.error(target.hint);
161
321
  process.exit(1);
162
322
  }
163
323
 
@@ -170,6 +330,11 @@ async function recallReplayCommand(args) {
170
330
  }
171
331
  if (!res.ok || payload?.success === false) {
172
332
  console.error(`${RED}Replay failed (status ${res.status}): ${payload?.error || payload?.message || 'unknown error'}${RESET}`);
333
+ if (Array.isArray(payload?.details)) {
334
+ for (const issue of payload.details) {
335
+ console.error(` ${(issue.path || []).join('.') || '(body)'}: ${issue.message}`);
336
+ }
337
+ }
173
338
  process.exit(1);
174
339
  }
175
340
 
@@ -193,9 +358,26 @@ async function recallReplayCommand(args) {
193
358
  console.log(`${BOLD}Signals${RESET} ${YELLOW}distillation failed — new path ran inert${RESET}`);
194
359
  }
195
360
 
196
- printPath('OLD PATH (episodic signals inert)', result.oldPath || []);
197
- printPath('NEW PATH (retrospective/window/entities live)', result.newPath || []);
361
+ const pinned = result.signalsPinned ? `${CYAN}pinned${RESET}` : `${DIM}fresh distillation${RESET}`;
362
+ const asOf = result.asOf
363
+ ? `${CYAN}as of ${result.asOf}${RESET} ${DIM}(${result.excludedAfterAsOf} later memories left out)${RESET}`
364
+ : `${DIM}whole corpus${RESET}`;
365
+ console.log(`${BOLD}Harness${RESET} signals ${pinned} · ${asOf}`);
366
+ console.log(`${BOLD}Tuning${RESET} ${result.tuning === 'defaults' || !result.tuning ? `${DIM}defaults${RESET}` : `${YELLOW}${result.tuning}${RESET}`} ${DIM}(new path only)${RESET}`);
367
+ if (result.ringSource) {
368
+ const source = result.ringSource === 'debug-log'
369
+ ? `${DIM}as of the turn, from its recall records${RESET}`
370
+ : `${YELLOW}the chat's current ring (no recall records that early)${RESET}`;
371
+ console.log(`${BOLD}Ring${RESET} ${(result.ring || []).length} recently whispered · ${source}`);
372
+ }
373
+
374
+ printPath('OLD PATH (episodic signals inert)', result.oldPath || [], result.oldHeadSize);
375
+ printPath('NEW PATH (retrospective/window/entities live)', result.newPath || [], result.newHeadSize);
376
+ if (Array.isArray(result.fallbackPath)) {
377
+ printPath('TEXT FALLBACK (what the new path returns with no usable vector)', result.fallbackPath, result.newHeadSize);
378
+ }
379
+ printTrace(result.trace);
198
380
  console.log('');
199
381
  }
200
382
 
201
- module.exports = { recallReplayCommand };
383
+ module.exports = { recallReplayCommand, parseFlags };
package/lib/text-codec.js CHANGED
@@ -58,4 +58,33 @@ function registerTextCodecFunction(db) {
58
58
  db.function('qt_text', { deterministic: true }, (value) => decodeText(value));
59
59
  }
60
60
 
61
- module.exports = { decodeText, isCompressedTextBlob, registerTextCodecFunction };
61
+ /**
62
+ * Decode every compressed-text value in a set of result rows, in place, for
63
+ * printing. Raw SQL (`quilltap db "SELECT content …"`, `--repl`) hands BLOBs to
64
+ * `JSON.stringify` / `console.table` as Buffers, which print as
65
+ * `{"type":"Buffer","data":[…]}` and read as data loss (bug 173). Only values
66
+ * carrying the `0x51 0x01 0x01` header are touched — embedding BLOBs and other
67
+ * binary columns pass through unchanged, as does anything already text
68
+ * (including an explicit `qt_text()`). Returns the number of values decoded.
69
+ */
70
+ function decodeCompressedTextInRows(rows) {
71
+ let decoded = 0;
72
+ if (!Array.isArray(rows)) return decoded;
73
+ for (const row of rows) {
74
+ if (!row || typeof row !== 'object') continue;
75
+ for (const key of Object.keys(row)) {
76
+ if (isCompressedTextBlob(row[key])) {
77
+ row[key] = decodeText(row[key]);
78
+ decoded++;
79
+ }
80
+ }
81
+ }
82
+ return decoded;
83
+ }
84
+
85
+ module.exports = {
86
+ decodeText,
87
+ decodeCompressedTextInRows,
88
+ isCompressedTextBlob,
89
+ registerTextCodecFunction,
90
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.10.0-dev.99",
3
+ "version": "4.11.0-dev.11",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",