quilltap 4.10.0-dev.96 → 4.10.0
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 +69 -11
- package/bin/quilltap.js +19 -2
- 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 +23 -4
- package/lib/completion/zsh.template +32 -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 +396 -76
- 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/migrations-commands.js +18 -4
- package/lib/recall-replay-command.js +193 -11
- package/lib/text-codec.js +30 -1
- package/package.json +1 -1
|
@@ -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 };
|
|
@@ -43,6 +43,19 @@ function parseFlags(args) {
|
|
|
43
43
|
|
|
44
44
|
// ---------- migration list parsing ----------
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* A migration's id is either a string literal on the object (`id: 'foo-v1'`)
|
|
48
|
+
* or a module constant (`id: MIGRATION_ID` beside `const MIGRATION_ID = 'foo-v1'`).
|
|
49
|
+
* Missing the constant form drops those migrations from "source", which then
|
|
50
|
+
* report as retired on every instance that ran them.
|
|
51
|
+
*/
|
|
52
|
+
function extractMigrationId(fileContent) {
|
|
53
|
+
const constMatch = fileContent.match(/^\s*const\s+MIGRATION_ID\s*(?::\s*\w+\s*)?=\s*['"]([^'"]+)['"]/m);
|
|
54
|
+
if (constMatch && /^\s*id:\s*MIGRATION_ID\b/m.test(fileContent)) return constMatch[1];
|
|
55
|
+
const literalMatch = fileContent.match(/^\s*id:\s*['"]([^'"]+)['"]/m);
|
|
56
|
+
return literalMatch ? literalMatch[1] : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
// Reads the registered migration list from migrations/scripts/index.ts and resolves each
|
|
47
60
|
// entry's real `id:` value by reading the imported source file. Heuristic camelCase→kebab
|
|
48
61
|
// conversion is unreliable (some migrations omit `-v1`, others use `-v2`), so we always
|
|
@@ -95,11 +108,11 @@ function extractMigrationsFromSource() {
|
|
|
95
108
|
const filePath = path.join(scriptsDir, info.filePath + '.ts');
|
|
96
109
|
if (!fs.existsSync(filePath)) continue;
|
|
97
110
|
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
|
98
|
-
const
|
|
99
|
-
if (!
|
|
111
|
+
const id = extractMigrationId(fileContent);
|
|
112
|
+
if (!id) continue;
|
|
100
113
|
migrations.push({
|
|
101
|
-
id
|
|
102
|
-
description: info.comment ||
|
|
114
|
+
id,
|
|
115
|
+
description: info.comment || id,
|
|
103
116
|
});
|
|
104
117
|
}
|
|
105
118
|
|
|
@@ -321,4 +334,5 @@ async function migrationsCommand(args) {
|
|
|
321
334
|
|
|
322
335
|
module.exports = {
|
|
323
336
|
migrationsCommand,
|
|
337
|
+
extractMigrationId,
|
|
324
338
|
};
|