quilltap 4.10.0-dev.99 → 4.11.0-dev.106
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/README.md +70 -11
- package/bin/quilltap.js +20 -3
- package/lib/__tests__/completion-coverage.test.js +1 -0
- package/lib/__tests__/memories-maintain-command.test.js +276 -0
- package/lib/__tests__/migrations-source-ids.test.js +54 -0
- package/lib/__tests__/server-port-resolution.test.js +122 -0
- package/lib/anchor-probe-command.js +222 -0
- package/lib/completion/bash.template +25 -11
- package/lib/completion/fish.template +26 -4
- package/lib/completion/zsh.template +35 -3
- package/lib/db-commands.js +20 -3
- package/lib/graph-integrity.js +108 -5
- package/lib/lock-helpers.js +66 -0
- package/lib/memories-commands.js +474 -75
- package/lib/memories-maintain-command.js +238 -0
- package/lib/memories-server.js +105 -0
- package/lib/memory-diff-command.js +28 -6
- package/lib/migrations-commands.js +18 -4
- package/lib/recall-replay-command.js +195 -11
- package/lib/text-codec.js +30 -1
- package/package.json +2 -2
|
@@ -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,64 @@ 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:
|
|
37
|
-
|
|
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
|
+
innerCircleShare, innerCircleAllowance
|
|
66
|
+
([3,2,1] = digests per member by tier).
|
|
67
|
+
Unknown keys are rejected.
|
|
68
|
+
--tuning-file <path> Read the --tuning JSON from a file
|
|
69
|
+
--signals-from <path> Reuse the signals saved in a previous --json
|
|
70
|
+
replay of the same chat and turn, instead of a
|
|
71
|
+
fresh (non-deterministic) distillation
|
|
72
|
+
--as-of Search only memories created before the turn's
|
|
73
|
+
opening message (the corpus as it stood then)
|
|
74
|
+
--port <number> Server port for API calls. Default: the port recorded in the
|
|
75
|
+
--instance/--data-dir server's lock file, else 3000
|
|
38
76
|
--json Print the raw JSON result instead of tables
|
|
39
77
|
-h, --help Show this help
|
|
40
78
|
|
|
41
79
|
Examples:
|
|
42
80
|
quilltap recall-replay <chatId>
|
|
43
81
|
quilltap recall-replay <chatId> --turn 42
|
|
82
|
+
quilltap recall-replay <chatId> --turn 42 --memory-budget 4000
|
|
44
83
|
quilltap recall-replay <chatId> --turn 42 --json > replay.json
|
|
84
|
+
quilltap recall-replay <chatId> --turn 42 --signals-from replay.json --as-of \
|
|
85
|
+
--tuning-file tuning-r1.json
|
|
45
86
|
`);
|
|
46
87
|
}
|
|
47
88
|
|
|
48
89
|
function parseFlags(args) {
|
|
49
|
-
const flags = {
|
|
90
|
+
const flags = {
|
|
91
|
+
turn: undefined, char: undefined, limit: undefined, memoryBudget: undefined,
|
|
92
|
+
tuning: undefined, tuningFile: undefined, signalsFrom: undefined, asOf: false,
|
|
93
|
+
port: undefined, dataDir: '', instance: '', json: false, help: false,
|
|
94
|
+
};
|
|
50
95
|
const positional = [];
|
|
51
96
|
let i = 0;
|
|
52
97
|
while (i < args.length) {
|
|
@@ -73,6 +118,40 @@ function parseFlags(args) {
|
|
|
73
118
|
flags.limit = n;
|
|
74
119
|
break;
|
|
75
120
|
}
|
|
121
|
+
case '--memory-budget': {
|
|
122
|
+
const n = Number(args[++i]);
|
|
123
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
124
|
+
console.error('Error: --memory-budget must be a positive integer');
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
flags.memoryBudget = n;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
case '--tuning':
|
|
131
|
+
flags.tuning = args[++i];
|
|
132
|
+
break;
|
|
133
|
+
case '--tuning-file':
|
|
134
|
+
flags.tuningFile = args[++i];
|
|
135
|
+
break;
|
|
136
|
+
case '--signals-from':
|
|
137
|
+
flags.signalsFrom = args[++i];
|
|
138
|
+
break;
|
|
139
|
+
case '--as-of':
|
|
140
|
+
flags.asOf = true;
|
|
141
|
+
break;
|
|
142
|
+
case '-d':
|
|
143
|
+
case '--data-dir':
|
|
144
|
+
flags.dataDir = args[++i];
|
|
145
|
+
break;
|
|
146
|
+
case '-i':
|
|
147
|
+
case '--instance':
|
|
148
|
+
flags.instance = args[++i];
|
|
149
|
+
break;
|
|
150
|
+
case '--passphrase':
|
|
151
|
+
// Accepted so universal flags never error; these tools talk to the
|
|
152
|
+
// running server and never open the database themselves.
|
|
153
|
+
i++;
|
|
154
|
+
break;
|
|
76
155
|
case '--port': {
|
|
77
156
|
const p = parseInt(args[++i], 10);
|
|
78
157
|
if (isNaN(p) || p < 1 || p > 65535) {
|
|
@@ -106,8 +185,9 @@ function fmt(n, digits = 3) {
|
|
|
106
185
|
return n.toFixed(digits);
|
|
107
186
|
}
|
|
108
187
|
|
|
109
|
-
function printPath(label, rows) {
|
|
110
|
-
|
|
188
|
+
function printPath(label, rows, headSize) {
|
|
189
|
+
const head = typeof headSize === 'number' ? `, head ${headSize}` : '';
|
|
190
|
+
console.log(`\n${BOLD}${label}${RESET} (${rows.length} candidates${head})`);
|
|
111
191
|
if (rows.length === 0) {
|
|
112
192
|
console.log(` ${DIM}(none)${RESET}`);
|
|
113
193
|
return;
|
|
@@ -127,6 +207,73 @@ function printPath(label, rows) {
|
|
|
127
207
|
}
|
|
128
208
|
}
|
|
129
209
|
|
|
210
|
+
/** The stage trace: one line per stage entry (query → gather → … → assemble). */
|
|
211
|
+
function printTrace(trace) {
|
|
212
|
+
if (!Array.isArray(trace) || trace.length === 0) return;
|
|
213
|
+
console.log(`\n${BOLD}STAGE TRACE${RESET}`);
|
|
214
|
+
for (const entry of trace) {
|
|
215
|
+
const label = entry.label ? `${DIM}(${entry.label})${RESET} ` : '';
|
|
216
|
+
const detail = Object.entries(entry.detail || {})
|
|
217
|
+
.map(([k, v]) => `${k}=${typeof v === 'object' && v !== null ? JSON.stringify(v) : v}`)
|
|
218
|
+
.join(' ');
|
|
219
|
+
console.log(` ${CYAN}${String(entry.stage).padEnd(9)}${RESET}${label}${detail}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readJson(text, what) {
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(text);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
console.error(`${RED}Could not parse ${what} as JSON: ${err.message}${RESET}`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function readJsonFile(path, what) {
|
|
233
|
+
const fs = require('fs');
|
|
234
|
+
let text;
|
|
235
|
+
try {
|
|
236
|
+
text = fs.readFileSync(path, 'utf8');
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.error(`${RED}Could not read ${what} ${path}: ${err.message}${RESET}`);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
return readJson(text, `${what} ${path}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The tuning object from --tuning or --tuning-file (never both). */
|
|
245
|
+
function loadTuning(flags) {
|
|
246
|
+
if (flags.tuning !== undefined && flags.tuningFile !== undefined) {
|
|
247
|
+
console.error('Error: pass --tuning or --tuning-file, not both');
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
if (flags.tuning !== undefined) return readJson(flags.tuning, '--tuning');
|
|
251
|
+
if (flags.tuningFile !== undefined) return readJsonFile(flags.tuningFile, '--tuning-file');
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The signals saved in a previous --json replay. Refuses a file from another
|
|
257
|
+
* chat or turn — reusing its signals would replay a different question.
|
|
258
|
+
*/
|
|
259
|
+
function loadSignals(path, chatId, turn) {
|
|
260
|
+
const saved = readJsonFile(path, '--signals-from');
|
|
261
|
+
const result = saved.data ?? saved;
|
|
262
|
+
if (!result.signals) {
|
|
263
|
+
console.error(`${RED}${path} has no saved signals (its distillation failed); replay without --signals-from${RESET}`);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
if (result.chatId && result.chatId !== chatId) {
|
|
267
|
+
console.error(`${RED}${path} is a replay of chat ${result.chatId}, not ${chatId}${RESET}`);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
if (turn !== undefined && result.turnIndex !== undefined && result.turnIndex !== turn) {
|
|
271
|
+
console.error(`${RED}${path} is a replay of turn ${result.turnIndex}, not ${turn}${RESET}`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
return result.signals;
|
|
275
|
+
}
|
|
276
|
+
|
|
130
277
|
async function recallReplayCommand(args) {
|
|
131
278
|
const { flags, positional } = parseFlags(args);
|
|
132
279
|
|
|
@@ -140,11 +287,26 @@ async function recallReplayCommand(args) {
|
|
|
140
287
|
}
|
|
141
288
|
const chatId = positional[0];
|
|
142
289
|
|
|
143
|
-
|
|
290
|
+
let target;
|
|
291
|
+
try {
|
|
292
|
+
target = resolveServerPort({ port: flags.port, instance: flags.instance, dataDir: flags.dataDir });
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error(`Error: ${err.message}`);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
const port = target.port;
|
|
298
|
+
if (target.note) process.stderr.write(`${DIM}${target.note}${RESET}\n`);
|
|
299
|
+
|
|
300
|
+
const url = `http://localhost:${port}/api/v1/chats/${encodeURIComponent(chatId)}?action=recall-replay`;
|
|
144
301
|
const body = {};
|
|
145
302
|
if (flags.turn !== undefined) body.turnIndex = flags.turn;
|
|
146
303
|
if (flags.char) body.characterId = flags.char;
|
|
147
304
|
if (flags.limit !== undefined) body.limit = flags.limit;
|
|
305
|
+
if (flags.memoryBudget !== undefined) body.memoryBudget = flags.memoryBudget;
|
|
306
|
+
const tuning = loadTuning(flags);
|
|
307
|
+
if (tuning !== undefined) body.tuning = tuning;
|
|
308
|
+
if (flags.signalsFrom !== undefined) body.signals = loadSignals(flags.signalsFrom, chatId, flags.turn);
|
|
309
|
+
if (flags.asOf) body.asOf = true;
|
|
148
310
|
|
|
149
311
|
process.stderr.write(`${BOLD}Replaying recall${RESET} for chat ${DIM}${chatId}${RESET} via ${DIM}${url}${RESET}\n`);
|
|
150
312
|
|
|
@@ -156,8 +318,8 @@ async function recallReplayCommand(args) {
|
|
|
156
318
|
body: JSON.stringify(body),
|
|
157
319
|
});
|
|
158
320
|
} catch (err) {
|
|
159
|
-
console.error(`${RED}Could not reach Quilltap server at http://localhost:${
|
|
160
|
-
console.error(
|
|
321
|
+
console.error(`${RED}Could not reach Quilltap server at http://localhost:${port}: ${err.message}${RESET}`);
|
|
322
|
+
console.error(target.hint);
|
|
161
323
|
process.exit(1);
|
|
162
324
|
}
|
|
163
325
|
|
|
@@ -170,6 +332,11 @@ async function recallReplayCommand(args) {
|
|
|
170
332
|
}
|
|
171
333
|
if (!res.ok || payload?.success === false) {
|
|
172
334
|
console.error(`${RED}Replay failed (status ${res.status}): ${payload?.error || payload?.message || 'unknown error'}${RESET}`);
|
|
335
|
+
if (Array.isArray(payload?.details)) {
|
|
336
|
+
for (const issue of payload.details) {
|
|
337
|
+
console.error(` ${(issue.path || []).join('.') || '(body)'}: ${issue.message}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
173
340
|
process.exit(1);
|
|
174
341
|
}
|
|
175
342
|
|
|
@@ -193,9 +360,26 @@ async function recallReplayCommand(args) {
|
|
|
193
360
|
console.log(`${BOLD}Signals${RESET} ${YELLOW}distillation failed — new path ran inert${RESET}`);
|
|
194
361
|
}
|
|
195
362
|
|
|
196
|
-
|
|
197
|
-
|
|
363
|
+
const pinned = result.signalsPinned ? `${CYAN}pinned${RESET}` : `${DIM}fresh distillation${RESET}`;
|
|
364
|
+
const asOf = result.asOf
|
|
365
|
+
? `${CYAN}as of ${result.asOf}${RESET} ${DIM}(${result.excludedAfterAsOf} later memories left out)${RESET}`
|
|
366
|
+
: `${DIM}whole corpus${RESET}`;
|
|
367
|
+
console.log(`${BOLD}Harness${RESET} signals ${pinned} · ${asOf}`);
|
|
368
|
+
console.log(`${BOLD}Tuning${RESET} ${result.tuning === 'defaults' || !result.tuning ? `${DIM}defaults${RESET}` : `${YELLOW}${result.tuning}${RESET}`} ${DIM}(new path only)${RESET}`);
|
|
369
|
+
if (result.ringSource) {
|
|
370
|
+
const source = result.ringSource === 'debug-log'
|
|
371
|
+
? `${DIM}as of the turn, from its recall records${RESET}`
|
|
372
|
+
: `${YELLOW}the chat's current ring (no recall records that early)${RESET}`;
|
|
373
|
+
console.log(`${BOLD}Ring${RESET} ${(result.ring || []).length} recently whispered · ${source}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
printPath('OLD PATH (episodic signals inert)', result.oldPath || [], result.oldHeadSize);
|
|
377
|
+
printPath('NEW PATH (retrospective/window/entities live)', result.newPath || [], result.newHeadSize);
|
|
378
|
+
if (Array.isArray(result.fallbackPath)) {
|
|
379
|
+
printPath('TEXT FALLBACK (what the new path returns with no usable vector)', result.fallbackPath, result.newHeadSize);
|
|
380
|
+
}
|
|
381
|
+
printTrace(result.trace);
|
|
198
382
|
console.log('');
|
|
199
383
|
}
|
|
200
384
|
|
|
201
|
-
module.exports = { recallReplayCommand };
|
|
385
|
+
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
|
-
|
|
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.
|
|
3
|
+
"version": "4.11.0-dev.106",
|
|
4
4
|
"description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Charles Sebold",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"README.md"
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@napi-rs/canvas": "^0.
|
|
39
|
+
"@napi-rs/canvas": "^1.0.13",
|
|
40
40
|
"better-sqlite3-multiple-ciphers": "^12.11.1",
|
|
41
41
|
"node-pty": "^1.1.0",
|
|
42
42
|
"sharp": "^0.35.4",
|