quilltap 4.10.0-dev.149 → 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.
@@ -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 (default: 3000)
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: 3000,
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:${flags.port}/api/v1/chats/${encodeURIComponent(chatId)}` +
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:${flags.port}: ${err.message}${RESET}`);
290
- console.error('Start the server (npm run dev) or pass --port to match a non-default port.');
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 (default: 3000)
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: 3000, json: false, help: false,
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
- 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`;
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:${flags.port}: ${err.message}${RESET}`);
271
- 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);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "quilltap",
3
- "version": "4.10.0-dev.149",
3
+ "version": "4.10.0-dev.173",
4
4
  "description": "Self-hosted AI workspace for writers, worldbuilders, and roleplayers. Run with npx quilltap.",
5
5
  "author": {
6
6
  "name": "Charles Sebold",
@@ -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
- });