quilltap 4.10.0-dev.146 → 4.10.0-dev.173
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 +32 -12
- package/bin/quilltap.js +7 -1
- package/lib/__tests__/memories-maintain-command.test.js +276 -0
- package/lib/__tests__/server-port-resolution.test.js +122 -0
- package/lib/anchor-probe-command.js +48 -17
- package/lib/completion/bash.template +12 -8
- package/lib/completion/fish.template +7 -2
- package/lib/completion/zsh.template +8 -3
- package/lib/db-commands.js +19 -3
- package/lib/graph-integrity.js +108 -5
- package/lib/lock-helpers.js +66 -0
- package/lib/memories-commands.js +320 -79
- package/lib/memories-maintain-command.js +238 -0
- package/lib/memories-server.js +86 -0
- package/lib/memory-diff-command.js +28 -6
- package/lib/recall-replay-command.js +65 -6
- package/package.json +1 -1
- package/lib/__tests__/memories-consolidate-command.test.js +0 -66
- package/lib/memories-consolidate-command.js +0 -131
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `quilltap memories maintain` — run the memory maintenance job by hand:
|
|
5
|
+
* fold near-duplicates → consolidate → demote → retire → mirror, any subset.
|
|
6
|
+
*
|
|
7
|
+
* The CLI's database access is read-only and cannot call a model, so this
|
|
8
|
+
* resolves the character locally and asks the running server
|
|
9
|
+
* (POST /api/v1/memories?action=maintain). A dry run is rehearsed in the
|
|
10
|
+
* server process and prints what each phase would do; a real run is queued
|
|
11
|
+
* as one MEMORY_MAINTENANCE background job per character.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { postMemoriesAction } = require('./memories-server');
|
|
15
|
+
|
|
16
|
+
// The phase names, the cap's bounds and maxClustersPerRun's bounds are the
|
|
17
|
+
// server's (lib/memory/maintenance/types.ts, lib/schemas/settings.types.ts).
|
|
18
|
+
// The CLI keeps no copy of them: the request goes as typed, and the server's
|
|
19
|
+
// 400 — whose details postMemoriesAction prints — says what it refused.
|
|
20
|
+
|
|
21
|
+
const RESET = '\x1b[0m';
|
|
22
|
+
const BOLD = '\x1b[1m';
|
|
23
|
+
const DIM = '\x1b[2m';
|
|
24
|
+
const GREEN = '\x1b[32m';
|
|
25
|
+
const YELLOW = '\x1b[33m';
|
|
26
|
+
const CYAN = '\x1b[36m';
|
|
27
|
+
|
|
28
|
+
function clip(text, n) {
|
|
29
|
+
const flat = String(text || '').replace(/\s+/g, ' ').trim();
|
|
30
|
+
return flat.length > n ? flat.slice(0, n - 1) + '…' : flat;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function short(id) {
|
|
34
|
+
return String(id || '').slice(0, 8);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function bucketLabel(bucket) {
|
|
38
|
+
if (!bucket) return 'about no one';
|
|
39
|
+
if (bucket.kind === 'self') return 'about self';
|
|
40
|
+
if (bucket.kind === 'other') return `about ${bucket.subjectName}`;
|
|
41
|
+
return 'about no one';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse `--phases a,b,…` into a list (trimmed, duplicates dropped). Empty or
|
|
46
|
+
* absent means "every phase" and returns null. The server checks the names
|
|
47
|
+
* and always runs them in its own order.
|
|
48
|
+
*/
|
|
49
|
+
function parsePhases(value) {
|
|
50
|
+
if (value == null || value === '') return null;
|
|
51
|
+
const asked = [...new Set(String(value).split(',').map((p) => p.trim()).filter(Boolean))];
|
|
52
|
+
return asked.length === 0 ? null : asked;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the `?action=maintain` request body from parsed CLI flags.
|
|
57
|
+
* `characterId` null means every character the user owns.
|
|
58
|
+
*/
|
|
59
|
+
function buildMaintainBody(characterId, flags) {
|
|
60
|
+
const body = {};
|
|
61
|
+
if (characterId) body.characterId = characterId;
|
|
62
|
+
body.dryRun = flags.dryRun === true;
|
|
63
|
+
const phases = parsePhases(flags.phases);
|
|
64
|
+
if (phases) body.phases = phases;
|
|
65
|
+
|
|
66
|
+
const settings = {};
|
|
67
|
+
if (flags.max > 0) settings.maxClustersPerRun = flags.max;
|
|
68
|
+
if (flags.threshold >= 0) settings.consolidation = { clusterThreshold: flags.threshold };
|
|
69
|
+
if (flags.cap != null) settings.cap = flags.cap;
|
|
70
|
+
if (Object.keys(settings).length > 0) body.settings = settings;
|
|
71
|
+
return body;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------- rendering ----------
|
|
75
|
+
|
|
76
|
+
/** Pretty-print a ConsolidationReport as cluster → members → digests. */
|
|
77
|
+
function renderConsolidation(report, write) {
|
|
78
|
+
const stats = report.stats || {};
|
|
79
|
+
if (report.skippedReason) {
|
|
80
|
+
write(` ${YELLOW}Skipped: ${report.skippedReason}${RESET}\n`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const clusters = report.clusters || [];
|
|
84
|
+
clusters.forEach((cluster, idx) => {
|
|
85
|
+
write(`\n ${BOLD}Cluster ${idx + 1}${RESET} ${DIM}(${bucketLabel(cluster.bucket)}, ${cluster.clusterKind}, ${cluster.status})${RESET}\n`);
|
|
86
|
+
if (cluster.error) write(` ${YELLOW}${cluster.error}${RESET}\n`);
|
|
87
|
+
const standalone = new Set(cluster.keepStandalone || []);
|
|
88
|
+
(cluster.memberIds || []).forEach((id, i) => {
|
|
89
|
+
const tag = standalone.has(id) ? ` ${DIM}[kept standalone]${RESET}` : '';
|
|
90
|
+
const content = (cluster.memberContents || [])[i];
|
|
91
|
+
write(` ${DIM}-${RESET} ${DIM}${short(id)}${RESET} ${clip(content, 160)}${tag}\n`);
|
|
92
|
+
});
|
|
93
|
+
for (const d of cluster.digests || []) {
|
|
94
|
+
write(` ${GREEN}=>${RESET} ${CYAN}${d.action === 'update' ? 'update digest' : 'new digest'}${RESET} ` +
|
|
95
|
+
`${DIM}(imp ${Number(d.importance).toFixed(2)}, from ${(d.memberIds || []).length})${RESET}\n`);
|
|
96
|
+
write(` ${clip(d.content, 400)}\n`);
|
|
97
|
+
}
|
|
98
|
+
for (const c of cluster.contradictions || []) {
|
|
99
|
+
write(` ${YELLOW}contradiction${RESET}: ${clip(c.note, 200)} ${DIM}(${short(c.olderId)} -> ${short(c.newerId)})${RESET}\n`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
if (clusters.length === 0) write(' (no clusters worth folding)\n');
|
|
103
|
+
write(` ${DIM}candidates ${stats.candidates ?? 0}, clusters found ${stats.clustersFound ?? 0}, ` +
|
|
104
|
+
`attempted ${stats.clustersAttempted ?? 0}, deferred ${stats.clustersDeferred ?? 0}, ` +
|
|
105
|
+
`${((stats.durationMs ?? 0) / 1000).toFixed(1)}s${stats.budgetExhausted ? ', time budget exhausted' : ''}${RESET}\n`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderFold(fold, write) {
|
|
109
|
+
write(` eligible ${fold.eligible ?? 0}, groups ${(fold.groups || []).length}, folded ${fold.folded ?? 0}` +
|
|
110
|
+
`${fold.deferred ? `, ${fold.deferred} rows deferred` : ''}` +
|
|
111
|
+
`${fold.groupsDeferred ? `, ${fold.groupsDeferred} groups deferred` : ''}` +
|
|
112
|
+
`${fold.groupsFailed ? `, ${YELLOW}${fold.groupsFailed} groups failed${RESET}` : ''}\n`);
|
|
113
|
+
for (const g of fold.groups || []) {
|
|
114
|
+
const absorbed = (g.absorbedIds || []).map(short).join(', ');
|
|
115
|
+
write(` ${short(g.survivorId)} ${GREEN}<=${RESET} ${absorbed} ${DIM}(min cosine ${Number(g.minCosine).toFixed(3)})${RESET}\n`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderDemote(d, write) {
|
|
120
|
+
write(` cap ${d.cap}, hot ${d.hotTotal} (demotable ${d.demotable}, exempt ${d.protected}), ` +
|
|
121
|
+
`excess ${d.excess}, demoted ${d.demoted}` +
|
|
122
|
+
`${d.highestScoreDemoted != null ? ` ${DIM}(highest score demoted ${Number(d.highestScoreDemoted).toFixed(3)})${RESET}` : ''}\n`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderRetire(r, write) {
|
|
126
|
+
write(` retention ${r.retentionDays} days, retired ${r.retired}\n`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderMirror(m, write) {
|
|
130
|
+
const subjects = m.subjects || [];
|
|
131
|
+
write(` ${subjects.length} subject${subjects.length === 1 ? '' : 's'}${subjects.length ? `: ${subjects.join(', ')}` : ''}` +
|
|
132
|
+
`, files written ${m.filesWritten ?? 0}${m.deferredToCommit ? ' (deferred to the parent commit)' : ''}\n`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Pretty-print one character's MaintenanceReport: phase lines, then per-phase detail. */
|
|
136
|
+
function renderMaintenanceReport(report, write = (s) => process.stdout.write(s)) {
|
|
137
|
+
const who = report.characterName || short(report.characterId);
|
|
138
|
+
write(`${BOLD}Maintenance ${report.dryRun ? 'dry run' : 'report'}${RESET}${who ? ` for ${who}` : ''}\n`);
|
|
139
|
+
if (report.skippedReason) {
|
|
140
|
+
write(`${YELLOW}Skipped: ${report.skippedReason}${RESET}\n`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
for (const p of report.phases || []) {
|
|
144
|
+
const status = p.status === 'ran' ? `${GREEN}ran${RESET} ` : `${DIM}skipped${RESET}`;
|
|
145
|
+
const why = p.skippedBecause ? ` ${DIM}(${p.skippedBecause})${RESET}` : '';
|
|
146
|
+
write(` ${p.phase.padEnd(22)} ${status}${why} ${DIM}${p.durationMs ?? 0}ms${RESET}\n`);
|
|
147
|
+
}
|
|
148
|
+
if (report.fold) { write(`\n${BOLD}Fold near-duplicates${RESET}\n`); renderFold(report.fold, write); }
|
|
149
|
+
if (report.consolidation) { write(`\n${BOLD}Consolidate${RESET}\n`); renderConsolidation(report.consolidation, write); }
|
|
150
|
+
if (report.demote) { write(`\n${BOLD}Demote${RESET}\n`); renderDemote(report.demote, write); }
|
|
151
|
+
if (report.retire) { write(`\n${BOLD}Retire${RESET}\n`); renderRetire(report.retire, write); }
|
|
152
|
+
if (report.mirror) { write(`\n${BOLD}Mirror${RESET}\n`); renderMirror(report.mirror, write); }
|
|
153
|
+
if (report.durationMs != null) write(`\n${DIM}${(report.durationMs / 1000).toFixed(1)}s${RESET}\n`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One table row per character for a user-wide dry run. */
|
|
157
|
+
function renderReportsTable(reports, write = (s) => process.stdout.write(s)) {
|
|
158
|
+
const dash = '-';
|
|
159
|
+
const rows = (reports || []).map((r) => {
|
|
160
|
+
if (r.skippedReason) {
|
|
161
|
+
return [r.characterName || short(r.characterId), `skipped: ${r.skippedReason}`, '', '', '', ''];
|
|
162
|
+
}
|
|
163
|
+
const ran = (r.phases || []).filter((p) => p.status === 'ran').map((p) => p.phase).join(',') || 'none';
|
|
164
|
+
return [
|
|
165
|
+
r.characterName || short(r.characterId),
|
|
166
|
+
ran,
|
|
167
|
+
r.fold ? `${(r.fold.groups || []).length}/${r.fold.folded ?? 0}` : dash,
|
|
168
|
+
r.demote ? `${r.demote.demotable}/${r.demote.cap} -${r.demote.demoted}` : dash,
|
|
169
|
+
r.retire ? String(r.retire.retired) : dash,
|
|
170
|
+
r.mirror ? String((r.mirror.subjects || []).length) : dash,
|
|
171
|
+
];
|
|
172
|
+
});
|
|
173
|
+
const header = ['character', 'phases ran', 'fold grp/folded', 'demote demotable/cap -n', 'retire', 'mirror subj'];
|
|
174
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((row) => String(row[i]).length)));
|
|
175
|
+
const line = (cells) => cells.map((c, i) => String(c).padEnd(widths[i])).join(' ').trimEnd() + '\n';
|
|
176
|
+
write(`${BOLD}Maintenance dry run across ${rows.length} character${rows.length === 1 ? '' : 's'}${RESET}\n`);
|
|
177
|
+
write(line(header));
|
|
178
|
+
write(line(widths.map((w) => '-'.repeat(w))));
|
|
179
|
+
for (const row of rows) write(line(row));
|
|
180
|
+
if (rows.length === 0) write('(no characters)\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------- command ----------
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {object} flags parsed memories flags
|
|
187
|
+
* @param {(flags: object) => Promise<string>} resolveCharacterId resolves --character to a UUID
|
|
188
|
+
*/
|
|
189
|
+
async function cmdMaintain(flags, resolveCharacterId) {
|
|
190
|
+
if (!flags.character) {
|
|
191
|
+
throw new Error("memories maintain requires --character <name|id|all> ('all' for every character).");
|
|
192
|
+
}
|
|
193
|
+
let characterId = null;
|
|
194
|
+
if (flags.character !== 'all') {
|
|
195
|
+
characterId = await resolveCharacterId(flags);
|
|
196
|
+
if (!/^[0-9a-fA-F-]{36}$/.test(characterId)) {
|
|
197
|
+
throw new Error(`could not resolve --character to a UUID (got "${characterId}").`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const body = buildMaintainBody(characterId, flags);
|
|
201
|
+
|
|
202
|
+
const payload = await postMemoriesAction(flags, 'maintain', body, {
|
|
203
|
+
why: 'Maintenance runs inside the server (the model, the embeddings and the only database writer live there).',
|
|
204
|
+
announce: flags.json ? undefined : (url) => process.stderr.write(
|
|
205
|
+
`${BOLD}${body.dryRun ? 'Rehearsing' : 'Queuing'} maintenance${RESET}` +
|
|
206
|
+
`${body.phases ? ` [${body.phases.join(',')}]` : ''} via ${DIM}${url}${RESET}\n`
|
|
207
|
+
),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (flags.json) {
|
|
211
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (body.dryRun) {
|
|
216
|
+
if (payload && payload.report) renderMaintenanceReport(payload.report);
|
|
217
|
+
else renderReportsTable(payload && payload.reports);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const jobIds = (payload && payload.jobIds) || (payload && payload.jobId ? [payload.jobId] : []);
|
|
222
|
+
if (jobIds.length === 0) {
|
|
223
|
+
console.log('No maintenance jobs were queued (no characters to maintain).');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
console.log(`Maintenance queued as ${jobIds.length} job${jobIds.length === 1 ? '' : 's'}:`);
|
|
227
|
+
for (const id of jobIds) console.log(` ${id}`);
|
|
228
|
+
console.log("They run in the background; watch logs/combined.log or 'quilltap memories status'.");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
cmdMaintain,
|
|
233
|
+
parsePhases,
|
|
234
|
+
buildMaintainBody,
|
|
235
|
+
renderMaintenanceReport,
|
|
236
|
+
renderReportsTable,
|
|
237
|
+
renderConsolidation,
|
|
238
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one way a `quilltap memories` verb asks the running server to do what
|
|
5
|
+
* the CLI cannot: anything that calls a model, computes an embedding or
|
|
6
|
+
* writes. The CLI's own database access is read-only; `maintain` and
|
|
7
|
+
* `validate --repair` resolve what they can locally and POST the rest to
|
|
8
|
+
* `/api/v1/memories?action=<action>`.
|
|
9
|
+
*
|
|
10
|
+
* The port comes from `resolveServerPort` (lock-helpers): an explicit
|
|
11
|
+
* `--port`, else the port recorded in the `--instance` / `--data-dir`
|
|
12
|
+
* instance's lock, else 3000.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { resolveServerPort } = require('./lock-helpers');
|
|
16
|
+
|
|
17
|
+
const DIM = '\x1b[2m';
|
|
18
|
+
const RESET = '\x1b[0m';
|
|
19
|
+
|
|
20
|
+
/** Resolve the server port for a memories verb, printing the lock note (if any) to stderr. */
|
|
21
|
+
function resolveMemoriesServerPort(flags, { quiet = false } = {}) {
|
|
22
|
+
const target = resolveServerPort({
|
|
23
|
+
port: flags.port,
|
|
24
|
+
instance: flags.instance || undefined,
|
|
25
|
+
dataDir: flags.dataDir || undefined,
|
|
26
|
+
});
|
|
27
|
+
if (target.note && !quiet) process.stderr.write(`${DIM}${target.note}${RESET}\n`);
|
|
28
|
+
return target;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* POST `body` to `/api/v1/memories?action=<action>` and return the parsed
|
|
33
|
+
* payload. Throws an Error with a readable message on a connection failure
|
|
34
|
+
* or a non-2xx response.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} flags parsed memories flags (port / instance / dataDir)
|
|
37
|
+
* @param {string} action the `?action=` name
|
|
38
|
+
* @param {object} body JSON request body
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {string} [opts.why] one line on why the server is needed
|
|
41
|
+
* @param {(msg: string) => void} [opts.announce] called with the URL before the request
|
|
42
|
+
* @param {typeof fetch} [opts.fetchImpl] for tests
|
|
43
|
+
*/
|
|
44
|
+
async function postMemoriesAction(flags, action, body, opts = {}) {
|
|
45
|
+
const { why, announce, fetchImpl = fetch } = opts;
|
|
46
|
+
const target = resolveMemoriesServerPort(flags);
|
|
47
|
+
const url = `http://localhost:${target.port}/api/v1/memories?action=${encodeURIComponent(action)}`;
|
|
48
|
+
if (announce) announce(url);
|
|
49
|
+
|
|
50
|
+
let res;
|
|
51
|
+
try {
|
|
52
|
+
res = await fetchImpl(url, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify(body),
|
|
56
|
+
});
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Could not reach the Quilltap server at http://localhost:${target.port}: ${err.message}\n` +
|
|
60
|
+
(why ? `${why}\n` : '') +
|
|
61
|
+
target.hint
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let payload = null;
|
|
66
|
+
try {
|
|
67
|
+
const text = await res.text();
|
|
68
|
+
payload = text ? JSON.parse(text) : null;
|
|
69
|
+
} catch { /* leave payload null */ }
|
|
70
|
+
|
|
71
|
+
if (!res.ok) {
|
|
72
|
+
let msg = (payload && payload.error) || `HTTP ${res.status}`;
|
|
73
|
+
if (payload && Array.isArray(payload.details) && payload.details.length > 0) {
|
|
74
|
+
msg += ': ' + payload.details
|
|
75
|
+
.map((d) => `${Array.isArray(d.path) && d.path.length ? d.path.join('.') + ' ' : ''}${d.message || ''}`.trim())
|
|
76
|
+
.join('; ');
|
|
77
|
+
}
|
|
78
|
+
if (res.status === 400 && /unknown action|invalid action/i.test(msg)) {
|
|
79
|
+
msg += ` (is the server older than this CLI? '${action}' needs a matching Quilltap server)`;
|
|
80
|
+
}
|
|
81
|
+
throw new Error(msg);
|
|
82
|
+
}
|
|
83
|
+
return payload;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { postMemoriesAction, resolveMemoriesServerPort };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { resolveServerPort } = require('./lock-helpers');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* `quilltap memory-diff <chatId>` — dump existing memories for a chat and
|
|
5
7
|
* stream a dry-run re-extraction from the running server, writing both to
|
|
@@ -45,7 +47,8 @@ Options:
|
|
|
45
47
|
-d, --data-dir <path> Override data directory (instance root)
|
|
46
48
|
-i, --instance <name> Use a registered instance (see 'quilltap instances')
|
|
47
49
|
--passphrase <pass> Decrypt .dbkey if peppered
|
|
48
|
-
--port <number> Server port for API calls
|
|
50
|
+
--port <number> Server port for API calls. Default: the port recorded in the
|
|
51
|
+
--instance/--data-dir server's lock file, else 3000
|
|
49
52
|
--out <dir> Output directory (default: cwd)
|
|
50
53
|
--concurrency <number> Parallel turns to process (default: 4, max: 32).
|
|
51
54
|
Cloud cheap-LLMs can handle 8–16; keep this low
|
|
@@ -65,7 +68,7 @@ function parseFlags(args) {
|
|
|
65
68
|
dataDir: '',
|
|
66
69
|
instance: '',
|
|
67
70
|
passphrase: '',
|
|
68
|
-
port:
|
|
71
|
+
port: undefined,
|
|
69
72
|
out: process.cwd(),
|
|
70
73
|
concurrency: 4,
|
|
71
74
|
help: false,
|
|
@@ -256,6 +259,16 @@ async function memoryDiffCommand(args) {
|
|
|
256
259
|
|
|
257
260
|
const chatId = positional[0];
|
|
258
261
|
|
|
262
|
+
let target;
|
|
263
|
+
try {
|
|
264
|
+
target = resolveServerPort({ port: flags.port, instance: flags.instance, dataDir: flags.dataDir });
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.error(`Error: ${err.message}`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
const port = target.port;
|
|
270
|
+
if (target.note) process.stderr.write(`${DIM}${target.note}${RESET}\n`);
|
|
271
|
+
|
|
259
272
|
if (!fs.existsSync(flags.out)) {
|
|
260
273
|
try {
|
|
261
274
|
fs.mkdirSync(flags.out, { recursive: true });
|
|
@@ -276,7 +289,7 @@ async function memoryDiffCommand(args) {
|
|
|
276
289
|
|
|
277
290
|
// -------- 2. Stream dry-run re-extraction from the server ----------------
|
|
278
291
|
const url =
|
|
279
|
-
`http://localhost:${
|
|
292
|
+
`http://localhost:${port}/api/v1/chats/${encodeURIComponent(chatId)}` +
|
|
280
293
|
`?action=extract-memories-dry-run&concurrency=${flags.concurrency}`;
|
|
281
294
|
process.stderr.write(
|
|
282
295
|
`${BOLD}Streaming re-extraction${RESET} (concurrency ${CYAN}${flags.concurrency}${RESET}) from ${DIM}${url}${RESET}\n`
|
|
@@ -286,8 +299,8 @@ async function memoryDiffCommand(args) {
|
|
|
286
299
|
try {
|
|
287
300
|
res = await fetch(url, { method: 'POST' });
|
|
288
301
|
} catch (err) {
|
|
289
|
-
console.error(`${RED}Could not reach Quilltap server at http://localhost:${
|
|
290
|
-
console.error(
|
|
302
|
+
console.error(`${RED}Could not reach Quilltap server at http://localhost:${port}: ${err.message}${RESET}`);
|
|
303
|
+
console.error(target.hint);
|
|
291
304
|
process.exit(1);
|
|
292
305
|
}
|
|
293
306
|
|
|
@@ -328,6 +341,15 @@ async function memoryDiffCommand(args) {
|
|
|
328
341
|
`${GREEN}${event.candidatesAdded}${RESET} candidate(s)\n`
|
|
329
342
|
);
|
|
330
343
|
break;
|
|
344
|
+
case 'fold':
|
|
345
|
+
// The fold passes run after every turn, over the history a regenerate
|
|
346
|
+
// would rebuild; their candidates arrive with turnIndex null.
|
|
347
|
+
process.stderr.write(
|
|
348
|
+
` ${CYAN}[${event.pass}]${RESET} ${event.slices} slice(s): ` +
|
|
349
|
+
`${GREEN}${event.candidatesAdded}${RESET} candidate(s)` +
|
|
350
|
+
`${event.skippedReason ? ` ${DIM}(${event.skippedReason})${RESET}` : ''}\n`
|
|
351
|
+
);
|
|
352
|
+
break;
|
|
331
353
|
case 'turn-error':
|
|
332
354
|
process.stderr.write(
|
|
333
355
|
` ${RED}[${event.index + 1}/${turnCount}] FAILED${RESET}: ${event.error}\n`
|
|
@@ -368,4 +390,4 @@ async function memoryDiffCommand(args) {
|
|
|
368
390
|
if (fatal) process.exit(2);
|
|
369
391
|
}
|
|
370
392
|
|
|
371
|
-
module.exports = { memoryDiffCommand };
|
|
393
|
+
module.exports = { memoryDiffCommand, parseFlags };
|
|
@@ -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,8 +34,14 @@ 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)
|
|
@@ -57,7 +69,8 @@ Options:
|
|
|
57
69
|
fresh (non-deterministic) distillation
|
|
58
70
|
--as-of Search only memories created before the turn's
|
|
59
71
|
opening message (the corpus as it stood then)
|
|
60
|
-
--port <number> Server port for API calls
|
|
72
|
+
--port <number> Server port for API calls. Default: the port recorded in the
|
|
73
|
+
--instance/--data-dir server's lock file, else 3000
|
|
61
74
|
--json Print the raw JSON result instead of tables
|
|
62
75
|
-h, --help Show this help
|
|
63
76
|
|
|
@@ -75,7 +88,7 @@ function parseFlags(args) {
|
|
|
75
88
|
const flags = {
|
|
76
89
|
turn: undefined, char: undefined, limit: undefined, memoryBudget: undefined,
|
|
77
90
|
tuning: undefined, tuningFile: undefined, signalsFrom: undefined, asOf: false,
|
|
78
|
-
port:
|
|
91
|
+
port: undefined, dataDir: '', instance: '', json: false, help: false,
|
|
79
92
|
};
|
|
80
93
|
const positional = [];
|
|
81
94
|
let i = 0;
|
|
@@ -124,6 +137,19 @@ function parseFlags(args) {
|
|
|
124
137
|
case '--as-of':
|
|
125
138
|
flags.asOf = true;
|
|
126
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;
|
|
127
153
|
case '--port': {
|
|
128
154
|
const p = parseInt(args[++i], 10);
|
|
129
155
|
if (isNaN(p) || p < 1 || p > 65535) {
|
|
@@ -179,6 +205,19 @@ function printPath(label, rows, headSize) {
|
|
|
179
205
|
}
|
|
180
206
|
}
|
|
181
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
|
+
|
|
182
221
|
function readJson(text, what) {
|
|
183
222
|
try {
|
|
184
223
|
return JSON.parse(text);
|
|
@@ -246,7 +285,17 @@ async function recallReplayCommand(args) {
|
|
|
246
285
|
}
|
|
247
286
|
const chatId = positional[0];
|
|
248
287
|
|
|
249
|
-
|
|
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`;
|
|
250
299
|
const body = {};
|
|
251
300
|
if (flags.turn !== undefined) body.turnIndex = flags.turn;
|
|
252
301
|
if (flags.char) body.characterId = flags.char;
|
|
@@ -267,8 +316,8 @@ async function recallReplayCommand(args) {
|
|
|
267
316
|
body: JSON.stringify(body),
|
|
268
317
|
});
|
|
269
318
|
} catch (err) {
|
|
270
|
-
console.error(`${RED}Could not reach Quilltap server at http://localhost:${
|
|
271
|
-
console.error(
|
|
319
|
+
console.error(`${RED}Could not reach Quilltap server at http://localhost:${port}: ${err.message}${RESET}`);
|
|
320
|
+
console.error(target.hint);
|
|
272
321
|
process.exit(1);
|
|
273
322
|
}
|
|
274
323
|
|
|
@@ -315,10 +364,20 @@ async function recallReplayCommand(args) {
|
|
|
315
364
|
: `${DIM}whole corpus${RESET}`;
|
|
316
365
|
console.log(`${BOLD}Harness${RESET} signals ${pinned} · ${asOf}`);
|
|
317
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
|
+
}
|
|
318
373
|
|
|
319
374
|
printPath('OLD PATH (episodic signals inert)', result.oldPath || [], result.oldHeadSize);
|
|
320
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);
|
|
321
380
|
console.log('');
|
|
322
381
|
}
|
|
323
382
|
|
|
324
|
-
module.exports = { recallReplayCommand };
|
|
383
|
+
module.exports = { recallReplayCommand, parseFlags };
|
package/package.json
CHANGED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `quilltap memories consolidate`: flag parsing, request body, report rendering.
|
|
3
|
-
*
|
|
4
|
-
* @jest-environment node
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
'use strict';
|
|
8
|
-
|
|
9
|
-
const { parseFlags } = require('../memories-commands');
|
|
10
|
-
const { buildConsolidateBody, renderReport } = require('../memories-consolidate-command');
|
|
11
|
-
|
|
12
|
-
describe('memories consolidate flags', () => {
|
|
13
|
-
it('parses verb, character, dry-run, max, threshold and port', () => {
|
|
14
|
-
const { flags, positional } = parseFlags([
|
|
15
|
-
'consolidate', '--instance', 'Friday', '--character', 'Friday',
|
|
16
|
-
'--dry-run', '--max', '12', '--threshold', '0.8', '--port', '3005', '--json',
|
|
17
|
-
]);
|
|
18
|
-
expect(positional).toEqual(['consolidate']);
|
|
19
|
-
expect(flags).toMatchObject({
|
|
20
|
-
instance: 'Friday', character: 'Friday', dryRun: true, max: 12, threshold: 0.8, port: 3005, json: true,
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('defaults to a real run with no overrides', () => {
|
|
25
|
-
const { flags } = parseFlags(['consolidate', '--character', 'X']);
|
|
26
|
-
expect(flags.dryRun).toBe(false);
|
|
27
|
-
expect(buildConsolidateBody('id', flags)).toEqual({ characterId: 'id', dryRun: false });
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it('builds the request body from the flags', () => {
|
|
31
|
-
const { flags } = parseFlags(['consolidate', '--dry-run', '--max', '5', '--threshold', '0.65']);
|
|
32
|
-
expect(buildConsolidateBody('abc', flags)).toEqual({
|
|
33
|
-
characterId: 'abc', dryRun: true, maxClustersPerRun: 5, clusterThreshold: 0.65,
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe('renderReport', () => {
|
|
39
|
-
it('prints cluster, members and the proposed digest', () => {
|
|
40
|
-
let out = '';
|
|
41
|
-
renderReport({
|
|
42
|
-
characterName: 'Ariadne', dryRun: true,
|
|
43
|
-
clusters: [{
|
|
44
|
-
bucket: { kind: 'other', subjectName: 'Calvin', subjectCharacterId: 'c' },
|
|
45
|
-
clusterKind: 'semantic', status: 'planned',
|
|
46
|
-
memberIds: ['aaaaaaaa-1', 'bbbbbbbb-2'], memberContents: ['Calvin likes tea', 'Calvin drinks tea daily'],
|
|
47
|
-
keepStandalone: ['bbbbbbbb-2'], contradictions: [{ olderId: 'aaaaaaaa-1', newerId: 'bbbbbbbb-2', note: 'tea vs coffee' }],
|
|
48
|
-
digests: [{ id: 'd1', action: 'create', content: 'Calvin is a devoted tea drinker', importance: 0.6, memberIds: ['aaaaaaaa-1'] }],
|
|
49
|
-
}],
|
|
50
|
-
stats: { candidates: 40, clustersFound: 3, clustersAttempted: 1, clustersDeferred: 2, durationMs: 1500 },
|
|
51
|
-
}, (s) => { out += s; });
|
|
52
|
-
expect(out).toContain('Ariadne');
|
|
53
|
-
expect(out).toContain('about Calvin');
|
|
54
|
-
expect(out).toContain('Calvin likes tea');
|
|
55
|
-
expect(out).toContain('[kept standalone]');
|
|
56
|
-
expect(out).toContain('Calvin is a devoted tea drinker');
|
|
57
|
-
expect(out).toContain('tea vs coffee');
|
|
58
|
-
expect(out).toContain('candidates 40');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('reports a skipped run', () => {
|
|
62
|
-
let out = '';
|
|
63
|
-
renderReport({ dryRun: true, skippedReason: 'no-llm', clusters: [], stats: {} }, (s) => { out += s; });
|
|
64
|
-
expect(out).toContain('Skipped: no-llm');
|
|
65
|
-
});
|
|
66
|
-
});
|