@empirical-gauzza/cli 1.0.35 → 1.0.37

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.
@@ -12,70 +12,595 @@ const { httpJson, printApiError } = require('./http');
12
12
  const { parseOption } = require('./env');
13
13
  const { requireAccessToken, resolveApiBaseUrl, buildApiAuthHeaders } = require('./auth');
14
14
  const { warn, log, createPrompter } = require('./ui');
15
+ const { resetLabel, confirmCostOrExit, isCostPreview, isInteractiveTerminal } = require('./empiricalTokensFormat');
16
+
17
+ // What to do with a saved voice once it is installed. The same commands and the same standing
18
+ // instruction the portal and the install status show (packages/shared/src/voiceInstall.ts); the CLI
19
+ // package ships on its own, so the text is repeated here and tests/unit/cli/voicePluginOffer.test.js
20
+ // keeps the two copies equal.
21
+ const VOICE_PLUGIN_COMMANDS = {
22
+ claude: ['claude plugin marketplace add mattgauzza/empirical-memory-plugin', 'claude plugin install empirical-voice@empirical-memory --scope user'],
23
+ codex: ['codex marketplace add mattgauzza/empirical-memory-plugin', 'codex plugin add empirical-voice@empirical-memory']
24
+ };
25
+ const VOICE_STANDING_INSTRUCTION = "Before you write anything I will send or publish as myself, look up my writing voice first: call query_memories with the query 'how this person writes', tags ['writing-voice'], topK 50, and follow those rules.";
15
26
 
16
27
  function apiUrl(pathSuffix) {
17
28
  return new URL(`/api/v1/guided-setups${pathSuffix}`, resolveApiBaseUrl()).toString();
18
29
  }
19
30
 
20
- async function callApi(method, pathSuffix, body) {
31
+ // The session id a request path names, for the recovery hints below.
32
+ function sessionIdFromPath(pathSuffix) {
33
+ const m = /^\/([^/]+)\//.exec(String(pathSuffix || ''));
34
+ return m ? decodeURIComponent(m[1]) : '';
35
+ }
36
+
37
+ // The stale-draft hint depends on where the person is: rebuild at the topic step returns to the
38
+ // topic step, at the review it returns to the review.
39
+ function draftStaleHint(resume, sid, step) {
40
+ if (/^vc\d+_topic$/.test(step)) {
41
+ return `This draft is out of date, so no sample can be written from it. Answer this step with rebuild (empirical setup answer ${sid} ${step} rebuild), then pick a topic again or skip.`;
42
+ }
43
+ if (step === 'q5_confirm' || step === 'confirm') {
44
+ return `This draft is out of date. Rebuild it with empirical setup answer ${sid} q5_confirm rebuild, then read the new draft and confirm that one.`;
45
+ }
46
+ return `This draft is out of date. Run ${resume} to see the current question, answer it with rebuild, and go on from there.`;
47
+ }
48
+
49
+ // Readable next steps for the refusals a text client can recover from. The server message says
50
+ // what happened; these lines say which command to run. A stale-key refusal does not name the
51
+ // current question, so the hint points at `setup resume`, which prints it with its key.
52
+ function recoveryHints(error, sessionId, step = '') {
53
+ const sid = sanitizeForTerminal(sessionId || '<sessionId>');
54
+ const resume = `empirical setup resume ${sid}`;
55
+ switch (error && error.code) {
56
+ case 'GUIDED_SETUP_VOICE_NOT_WAITING':
57
+ case 'GUIDED_SETUP_VOICE_STALE':
58
+ return [`See the current question and its key with: ${resume}`];
59
+ case 'GUIDED_SETUP_VOICE_BUSY':
60
+ return [`Wait a moment, then pick it up with: ${resume}`];
61
+ case 'EMPIRICAL_TOKENS_ATTEMPT_CLOSED':
62
+ return ['This attempt is already closed, so it cannot be continued. Start a new one with: empirical setup run <setupId>'];
63
+ case 'EMPIRICAL_TOKEN_KEY_OWNER_MISMATCH':
64
+ return ['This request was made with a key that belongs to a different account than the one you are signed in as. Sign in with the account that started it, then try again.'];
65
+ case 'PLAN_CHANGE_FAILED':
66
+ return ['Your plan change did not go through. Check your plan, then try again.'];
67
+ case 'TRIAL_END_IN_PROGRESS':
68
+ return ['Your trial is ending right now. Wait a minute, then try again.'];
69
+ case 'PLAN_LIMIT_EMPIRICAL_TOKENS_REACHED':
70
+ return [tokensShortfallHint(error)];
71
+ case 'GUIDED_SETUP_DRAFT_STALE':
72
+ return [draftStaleHint(resume, sid, sanitizeForTerminal(step))];
73
+ case 'GUIDED_SETUP_DRAFT_CHANGED':
74
+ return [`Run ${resume} to read the latest draft and its hash (or finish the waiting step first), then confirm that draft.`];
75
+ case 'GUIDED_SETUP_NOT_READY_TO_CONFIRM':
76
+ return [`Finish the questions first. See where the setup is with: ${resume}`];
77
+ case 'GUIDED_SETUP_CONFIRM_REQUIRED':
78
+ return [`Installing is its own command: read the draft with ${resume}, then run empirical setup confirm ${sid} <draftHash>.`];
79
+ default:
80
+ return [];
81
+ }
82
+ }
83
+
84
+ // The 403's details carry remaining, needed and resetsAt. A partial shortfall (some left, not enough)
85
+ // must not read as "used all", so the numbers are quoted when the server sent them.
86
+ function tokensShortfallHint(error) {
87
+ const d = (error && error.details) || {};
88
+ const reset = resetLabel(d.resetsAt);
89
+ const known = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
90
+ if (known(d.remaining) && known(d.needed)) {
91
+ return `You have ${d.remaining.toLocaleString('en-US')} left and this needs ${d.needed.toLocaleString('en-US')}.${reset ? ` They reset on ${reset}.` : ''}`;
92
+ }
93
+ return `There are not enough Empirical Tokens left this month.${reset ? ` They reset on ${reset}.` : ' They reset at the start of next month.'}`;
94
+ }
95
+
96
+ // The step a request was for: the question key it carried, or the confirm route.
97
+ function stepOf(pathSuffix, body) {
98
+ if (body && typeof body.questionKey === 'string') return body.questionKey;
99
+ return /\/confirm$/.test(String(pathSuffix || '')) ? 'confirm' : '';
100
+ }
101
+
102
+ // A refund is stated once per run, in words. The server sends details.refunded as a boolean
103
+ // (true = this attempt's Empirical Tokens were returned); a count line is used only for a
104
+ // numeric value above one.
105
+ let refundShown = false;
106
+ function refundLine(error) {
107
+ const refunded = error && error.details && error.details.refunded;
108
+ if (refundShown) return null;
109
+ // Some refusals already say so in their own message ("Your Empirical Tokens for this attempt were
110
+ // returned."): the CLI line would only repeat it.
111
+ if (/\breturned\b/i.test(String((error && (error.serverMessage || error.message)) || ''))) return null;
112
+ if (refunded === true) {
113
+ refundShown = true;
114
+ return 'Your Empirical Tokens for this attempt were returned.';
115
+ }
116
+ if (typeof refunded === 'number' && Number.isFinite(refunded) && refunded > 1) {
117
+ refundShown = true;
118
+ return `${refunded.toLocaleString('en-US')} Empirical Tokens were returned to your balance.`;
119
+ }
120
+ return null;
121
+ }
122
+
123
+ function failWith(error, pathSuffix, body) {
124
+ printApiError(error);
125
+ for (const line of recoveryHints(error, sessionIdFromPath(pathSuffix), stepOf(pathSuffix, body))) warn(line);
126
+ const refund = refundLine(error);
127
+ if (refund) warn(refund);
128
+ process.exit(1);
129
+ }
130
+
131
+ async function tryApi(method, pathSuffix, body) {
21
132
  const accessToken = requireAccessToken();
22
133
  try {
23
134
  const response = await httpJson(apiUrl(pathSuffix), method, body, buildApiAuthHeaders(accessToken));
24
- return response && response.data ? response.data : response;
135
+ return { data: response && response.data ? response.data : response };
25
136
  } catch (error) {
26
- printApiError(error);
27
- process.exit(1);
137
+ return { error };
28
138
  }
29
139
  }
30
140
 
141
+ async function callApi(method, pathSuffix, body) {
142
+ const { data, error } = await tryApi(method, pathSuffix, body);
143
+ if (error) failWith(error, pathSuffix, body);
144
+ return data;
145
+ }
146
+
147
+ // The structured meter (progress.dimensions, which carries the person's quotes and answer text) and
148
+ // the re-pick lists (progress.repick) are for the portal's chips only; the CLI shows the meter as
149
+ // progress.meterLine. JSON.stringify leaves C1 controls unescaped, so they are escaped here.
150
+ function forJsonOutput(result) {
151
+ if (!result || typeof result !== 'object' || !result.progress || typeof result.progress !== 'object') return result;
152
+ const { dimensions: _dimensions, repick: _repick, ...progress } = result.progress;
153
+ return { ...result, progress };
154
+ }
155
+
156
+ function printJson(value) {
157
+ log(JSON.stringify(forJsonOutput(value), null, 2).replace(/[\x80-\x9f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`));
158
+ }
159
+
31
160
  async function runList() {
32
161
  const { setups } = await callApi('GET', '', undefined);
33
- log(JSON.stringify(setups, null, 2));
162
+ printJson(setups);
163
+ }
164
+
165
+ // Server and user text is printed to a terminal, so control sequences are removed first:
166
+ // whole OSC and CSI sequences, then any remaining C0/C1 control character except newline and tab.
167
+ function sanitizeForTerminal(value) {
168
+ return String(value === undefined || value === null ? '' : value)
169
+ .replace(/\u001b\][^\u0007\u001b\u009c]*(?:\u0007|\u001b\\|\u009c)?/g, '')
170
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
171
+ .replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, '');
172
+ }
173
+
174
+ // What leaving the follow-up loop leads to (the server's question.next), in the person's words.
175
+ const LOOP_EXIT_NEXT = {
176
+ avoid: 'two last steps (habits to rule out, and the common AI-writing tells), then a short sample in your voice',
177
+ tropes: 'one last step (the common AI-writing tells), then a short sample in your voice',
178
+ review: 'the draft, written for you to review'
179
+ };
180
+
181
+ function loopHint(question, progress) {
182
+ const after = LOOP_EXIT_NEXT[question.next];
183
+ if (progress.ready) {
184
+ return after ? `Type check to go on to ${after}, or keep answering.` : 'Type check to see a short sample in your voice, or keep answering.';
185
+ }
186
+ return after
187
+ ? `Type enough to stop here and go on to ${after}; with areas still missing the voice may come out inconsistent.`
188
+ : 'Type enough to stop here; with areas still missing the voice may come out inconsistent.';
189
+ }
190
+
191
+ // Voice questions as plain lines for a terminal. A preference round is a lettered list in the
192
+ // server's order; only the letter and the text of each version are printed (blind choice: never
193
+ // any other field of a version or the round). Every piece of server text is sanitised.
194
+ function formatQuestionLines(question, progress = {}) {
195
+ const t = sanitizeForTerminal;
196
+ const p = progress || {};
197
+ const lines = [t(question.prompt)];
198
+ if (question.kind === 'voice_check') {
199
+ lines.push(`Sample on "${t(question.topic)}":`, ...String(question.passage || '').split('\n').map((l) => t(l)));
200
+ }
201
+ if (question.kind === 'prefer') {
202
+ if (question.situation) lines.push(`Situation: ${t(question.situation)}`);
203
+ for (const v of question.versions || []) {
204
+ lines.push(`${t(v.letter)})`, ...String(v.text || '').split('\n').map((l) => ` ${t(l)}`));
205
+ }
206
+ }
207
+ // A topic step on a stale draft refuses topics (the server writes no sample from it).
208
+ const staleTopic = question.kind === 'pick_or_write' && question.stale;
209
+ if (Array.isArray(question.options) && !staleTopic) {
210
+ for (const o of question.options) {
211
+ const description = o.description ? `: ${t(o.description)}` : '';
212
+ lines.push(` ${t(o.id)}. ${t(o.title)}${description}`);
213
+ }
214
+ }
215
+ const key = String(question.questionKey || '');
216
+ if ((question.kind === 'text' && /^(g\d+|f_)/.test(key)) || question.kind === 'prefer') {
217
+ if (p.meterLine) lines.push(t(p.meterLine));
218
+ // During the rounds "enough" ends the comparisons only, and a question focused from the portal
219
+ // records whatever is typed, so the end-early hint belongs to the follow-up loop alone.
220
+ if (p.meterLine && question.kind === 'text' && /^g\d+$/.test(key) && !question.focus) lines.push(loopHint(question, p));
221
+ }
222
+ if (question.kind === 'pick_and_edit') lines.push('Reply with the numbers that fit, like 2,5, and optionally a | and your own changes.');
223
+ if (question.kind === 'pick_one') lines.push('Reply with one number.');
224
+ if (question.kind === 'yes_no') lines.push('Reply yes or no.');
225
+ if (staleTopic) {
226
+ lines.push('Your answers changed after this draft was written, so no sample can be written from it. Type rebuild to write a new draft first, or skip to go straight to the review.');
227
+ } else if (question.kind === 'pick_or_write') {
228
+ lines.push('Reply with one number, or a | and your own topic. Type skip to go straight to the review.');
229
+ }
230
+ if (question.kind === 'prefer') lines.push('Reply with one letter, like b, and optionally a | and why, like b | shorter is me. Type none if no version fits, skip for a different set, or enough to stop comparing.');
231
+ if (question.kind === 'voice_check') lines.push('Reply yes if it sounds like you, or no with the numbers of what is off and your own words after a |, like no 1,3 | too stiff.');
232
+ return lines;
233
+ }
234
+
235
+ // "Personal Workspace" is already a place; "Work" becomes "Work workspace".
236
+ function workspacePlace(name) {
237
+ const n = String(name || '').trim() || 'selected';
238
+ return /\bworkspace$/i.test(n) ? n : `${n} workspace`;
239
+ }
240
+
241
+ function formatDraftLines(question, progress = {}) {
242
+ const t = sanitizeForTerminal;
243
+ const draft = question.draft || {};
244
+ const confirm = question.confirm || {};
245
+ const lines = [];
246
+ const place = t(workspacePlace(confirm.workspace && confirm.workspace.name));
247
+ const memoryCount = Number.isFinite(confirm.memoryCount) ? confirm.memoryCount : 0;
248
+ const replacesCount = Number.isFinite(confirm.replacesCount) ? confirm.replacesCount : 0;
249
+ lines.push(`Hub: ${t(draft.hub)}`);
250
+ // Rules the person did not write get their own labeled block with a plain reason (absent on an
251
+ // older server, which prints every rule in one list as before).
252
+ const fixed = Array.isArray(confirm.fixedRules) ? confirm.fixedRules : [];
253
+ const fixedTexts = new Set(fixed.map((f) => f.text));
254
+ for (const r of Array.isArray(draft.rules) ? draft.rules : []) {
255
+ if (fixedTexts.has(r.text)) continue;
256
+ lines.push(`- (${t(r.kind)}${r.context ? `, ${t(r.context)}` : ''}) ${t(r.text)}`);
257
+ }
258
+ if (fixed.length > 0) {
259
+ lines.push('Rules every voice keeps (you did not write these):');
260
+ for (const f of fixed) {
261
+ lines.push(`- ${t(f.text)}`);
262
+ lines.push(` ${t(f.reason)}`);
263
+ }
264
+ if (fixed.some((f) => f.removable === true)) lines.push('To remove one that can be removed, type the change in your own words.');
265
+ }
266
+ lines.push(`${memoryCount} memories in your ${place}${replacesCount ? `; your current voice (${replacesCount} memories) will be replaced` : ''}.`);
267
+ if (confirm.fitsPlan === false) {
268
+ const free = Number.isFinite(confirm.free) ? confirm.free : 0;
269
+ lines.push(`Warning: this does not fit your plan. You have room for ${free} more memories, so the install will be refused until you upgrade for more memory or free up space under Manage memories in the portal.`);
270
+ }
271
+ if (confirm.stale) lines.push('Your answers changed after this draft was written. Type rebuild to write a new draft before installing.');
272
+ if (progress && progress.meterLine && progress.ready === false) lines.push(`${t(progress.meterLine)} The voice may come out inconsistent.`);
273
+ const vc = confirm.voiceCheck;
274
+ // The server refuses another check while the draft is stale or the checks are used up.
275
+ if (vc && vc.stale) {
276
+ lines.push(vc.canCheckAgain && !confirm.stale
277
+ ? 'The sample passage was written before your last change. Type check to see a new one.'
278
+ : 'The sample passage was written before your last change.');
279
+ }
280
+ return lines;
34
281
  }
35
282
 
36
- async function runNonInteractive(setupId, answersFilePath) {
283
+ function printQuestion(question, progress) {
284
+ for (const line of formatQuestionLines(question, progress)) warn(line);
285
+ }
286
+
287
+ function printDraft(question, progress) {
288
+ for (const line of formatDraftLines(question, progress)) warn(line);
289
+ }
290
+
291
+ // `setup resume` and `setup answer` print the result as JSON on stdout; a voice question is also
292
+ // printed as text on stderr, so a person can read the lettered list or the sample and answer it.
293
+ function printCurrentQuestion(result) {
294
+ const question = result && result.question;
295
+ if (!question || !question.kind) return;
296
+ const sid = sanitizeForTerminal(result.sessionId);
297
+ const key = sanitizeForTerminal(question.questionKey);
298
+ if (question.kind !== 'confirm') {
299
+ printQuestion(question, result.progress);
300
+ warn(`Answer with: empirical setup answer ${sid} ${key} <your answer>`);
301
+ return;
302
+ }
303
+ printDraft(question, result.progress);
304
+ const confirm = question.confirm || {};
305
+ if (confirm.stale) {
306
+ warn(`Rebuild it with: empirical setup answer ${sid} ${key} rebuild`);
307
+ } else if (confirm.draftHash) {
308
+ warn(`Install this draft with: empirical setup confirm ${sid} ${sanitizeForTerminal(confirm.draftHash)}`);
309
+ }
310
+ warn(`Ask for a change with: empirical setup answer ${sid} ${key} <the change you want>`);
311
+ }
312
+
313
+ function printNothingInstalled(sessionId, draftHash) {
314
+ warn('Nothing was installed.');
315
+ if (sessionId && draftHash) {
316
+ warn('When you are ready, confirm this exact draft with:');
317
+ warn(` empirical setup confirm ${sanitizeForTerminal(sessionId)} ${sanitizeForTerminal(draftHash)}`);
318
+ }
319
+ }
320
+
321
+ const TERMINAL_INSTALL_STATUSES = ['succeeded', 'failed', 'rolled_back', 'dead_letter', 'uninstalled'];
322
+
323
+ // The voice is written by a background job. Poll the install status a bounded number of times;
324
+ // EMPIRICAL_SETUP_POLL_MS shortens the delay for tests.
325
+ async function fetchInstallStatus(installId) {
326
+ const attempts = 10;
327
+ const configured = process.env.EMPIRICAL_SETUP_POLL_MS;
328
+ const delayMs = configured !== undefined && Number(configured) >= 0 ? Number(configured) : 3000;
329
+ const url = new URL(`/api/v1/blueprints/installs/${encodeURIComponent(installId)}`, resolveApiBaseUrl()).toString();
330
+ for (let i = 0; i < attempts; i += 1) {
331
+ try {
332
+ const response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(requireAccessToken()));
333
+ const status = response && response.data ? response.data : response;
334
+ if (status && TERMINAL_INSTALL_STATUSES.includes(String(status.jobStatus))) return status;
335
+ } catch (error) {
336
+ return { error: true, message: error && error.message ? error.message : 'unknown error' };
337
+ }
338
+ if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
339
+ }
340
+ return null;
341
+ }
342
+
343
+ // Inside Claude Code the host is known; anywhere else the person says which one.
344
+ function insideClaudeCode(env) {
345
+ return Boolean(env && (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT));
346
+ }
347
+
348
+ // The existing `empirical plugin install <host> --voice` code path, loaded only when asked for.
349
+ async function installVoiceSkillFor(host) {
350
+ const { runPluginCommand } = require('./pluginCommands');
351
+ await runPluginCommand(['install', host, '--voice']);
352
+ }
353
+
354
+ // Offered once, after the voice is installed, to a person at a terminal. Enter or anything but a
355
+ // yes installs nothing. A failure never fails the setup that already succeeded: it says what to run.
356
+ async function offerVoicePluginInstall({ prompter, env = process.env, install = installVoiceSkillFor, warn: say = warn }) {
357
+ const reply = String(await prompter.askText('Install the voice skill into Claude Code/Codex now? (y/N)')).trim();
358
+ if (!/^(y|yes)$/i.test(reply)) return false;
359
+ let hosts = ['claude'];
360
+ if (!insideClaudeCode(env)) {
361
+ const which = String(await prompter.askText('Which one? Type claude, codex, or both (Enter for both)')).trim().toLowerCase();
362
+ hosts = which === 'claude' ? ['claude'] : which === 'codex' ? ['codex'] : ['claude', 'codex'];
363
+ }
364
+ let ok = true;
365
+ for (const host of hosts) {
366
+ try {
367
+ await install(host);
368
+ } catch (error) {
369
+ ok = false;
370
+ say(`Could not install the voice skill into ${host}: ${sanitizeForTerminal(error && error.message ? error.message : 'unknown error')}`);
371
+ say('Run these yourself, in order:');
372
+ for (const line of VOICE_PLUGIN_COMMANDS[host]) say(` ${line}`);
373
+ }
374
+ }
375
+ return ok;
376
+ }
377
+
378
+ // offer: { prompter } when a person at a terminal ran the interview; absent for scripted runs.
379
+ async function reportConfirmed(result, offer) {
380
+ if (result && result.message) log(sanitizeForTerminal(result.message));
381
+ const installId = result && result.installId;
382
+ if (!installId) return;
383
+ const status = await fetchInstallStatus(installId);
384
+ if (status && status.error) {
385
+ log(`Could not check on the install right now (${sanitizeForTerminal(status.message)}). Check it later with:`);
386
+ log(` empirical blueprint status ${sanitizeForTerminal(installId)}`);
387
+ return;
388
+ }
389
+ if (status && status.jobStatus === 'succeeded') {
390
+ if (Array.isArray(status.nextPrompts) && status.nextPrompts.length > 0) {
391
+ log('');
392
+ log('Your voice is installed. Prompts you can use right away:');
393
+ status.nextPrompts.forEach((p, i) => log(`${i + 1}. ${sanitizeForTerminal(p)}`));
394
+ } else {
395
+ log('Your voice is installed.');
396
+ }
397
+ // The prompts above work in any assistant. In a coding agent the optional plugin does it
398
+ // automatically, so show the install commands the person can paste (nothing here is sent to a model).
399
+ log('');
400
+ log('In Claude Code or Codex, the optional empirical-voice plugin makes your agent use this voice automatically. Run these in order:');
401
+ for (const line of [...VOICE_PLUGIN_COMMANDS.claude, ...VOICE_PLUGIN_COMMANDS.codex]) log(` ${line}`);
402
+ log('');
403
+ log('In a chat assistant such as ChatGPT or Claude.ai there is no skill to install. Save this once in its custom instructions or memory:');
404
+ log(` ${VOICE_STANDING_INSTRUCTION}`);
405
+ if (offer && offer.prompter && isInteractiveTerminal()) {
406
+ log('');
407
+ await offerVoicePluginInstall({ prompter: offer.prompter });
408
+ }
409
+ return;
410
+ }
411
+ if (status) {
412
+ log(`The install did not finish: ${sanitizeForTerminal(status.jobStatus)}${status.failureReason ? ` (${sanitizeForTerminal(status.failureReason)})` : ''}.`);
413
+ return;
414
+ }
415
+ log('Your voice is still being written. Check on it later with:');
416
+ log(` empirical blueprint status ${sanitizeForTerminal(installId)}`);
417
+ }
418
+
419
+ // Spec 7.3: a priced setup returns a cost preview first. Acceptance is sent only from the explicit
420
+ // --accept-cost flag; a first call without it stops at the cost and starts nothing. A person at a
421
+ // terminal is asked instead, and a non-interactive run stops.
422
+ async function startWithCost(setupId, { acceptCost, prompter }) {
423
+ const first = await callApi('POST', '/start', acceptCost ? { setupId, acceptEmpiricalTokenCost: true } : { setupId });
424
+ if (!isCostPreview(first)) return reportTokensUsed(first);
425
+ await confirmCostOrExit(first, { prompter, warn, sanitize: sanitizeForTerminal });
426
+ return reportTokensUsed(await callApi('POST', '/start', { setupId, acceptEmpiricalTokenCost: true }));
427
+ }
428
+
429
+ // After a paid start, say what it cost, but only when the response carries the amount. stderr, so
430
+ // the JSON the command prints on stdout stays parseable.
431
+ function reportTokensUsed(result) {
432
+ const used = result && result.empiricalTokensCharged;
433
+ if (typeof used === 'number' && used > 0) warn(`Used ${used.toLocaleString('en-US')} Empirical Tokens.`);
434
+ return result;
435
+ }
436
+
437
+ async function runNonInteractive(setupId, answersFilePath, acceptCost) {
37
438
  const raw = fs.readFileSync(path.resolve(answersFilePath), 'utf8');
38
439
  const answersByQuestionKey = JSON.parse(raw);
39
440
 
40
- let current = await callApi('POST', '/start', { setupId });
441
+ let current = await startWithCost(setupId, { acceptCost, prompter: null });
442
+ let confirmed = false;
41
443
 
42
- while (current.status !== 'completed' && current.status !== 'finished_early') {
43
- const questionKey = current.question.questionKey;
44
- if (!Object.prototype.hasOwnProperty.call(answersByQuestionKey, questionKey)) {
45
- warn(`Answers file is missing an answer for unknown question "${questionKey}"`);
46
- process.exit(1);
444
+ while (current.question && current.status !== 'completed' && current.status !== 'finished_early') {
445
+ const question = current.question;
446
+ const questionKey = question.questionKey;
447
+ if (question.kind === 'confirm') {
448
+ // Confirming is its own step: only an explicit "confirm" for q5_confirm installs, never a stale draft.
449
+ const stale = Boolean(question.confirm && question.confirm.stale);
450
+ if (answersByQuestionKey[questionKey] === 'confirm' && !stale) {
451
+ current = await callApi('POST', `/${current.sessionId}/confirm`, { draftHash: question.confirm.draftHash });
452
+ confirmed = true;
453
+ } else if (stale) {
454
+ warn('This draft is out of date. Answer q5_confirm with rebuild first:');
455
+ warn(` empirical setup answer ${sanitizeForTerminal(current.sessionId)} q5_confirm rebuild`);
456
+ }
457
+ break; // otherwise stop here and print the draft and its hash below
458
+ }
459
+ let answer;
460
+ if (Object.prototype.hasOwnProperty.call(answersByQuestionKey, questionKey)) {
461
+ answer = answersByQuestionKey[questionKey];
462
+ } else {
463
+ answer = scriptedDefault(question);
464
+ if (answer === undefined) {
465
+ warn(`Answers file is missing an answer for unknown question "${sanitizeForTerminal(questionKey)}"`);
466
+ process.exit(1);
467
+ }
468
+ warn(`No answer for ${sanitizeForTerminal(questionKey)} in the answers file, so the run sends ${answer}.`);
47
469
  }
48
- current = await callApi('POST', `/${current.sessionId}/continue`, { answer: answersByQuestionKey[questionKey] });
470
+ current = await callApi('POST', `/${current.sessionId}/continue`, answerBody(question, answer));
49
471
  }
50
472
 
51
- log(JSON.stringify(current, null, 2));
473
+ if (confirmed) return reportConfirmed(current);
474
+ exitIfBusy(current);
475
+ printJson(current);
476
+ }
477
+
478
+ // Every answer carries the key of the question it answers, so an answer meant for a question that
479
+ // moved on (another window, a resend) is refused instead of being saved on the current one: a
480
+ // round pick saved on the wrong round would be false evidence.
481
+ function answerBody(question, answer) {
482
+ return question && question.questionKey ? { answer, questionKey: question.questionKey } : { answer };
483
+ }
484
+
485
+ // An active session with no question means a step is still being worked on (a model call can take
486
+ // a minute). That is not a finished run: say so and exit non-zero.
487
+ function exitIfBusy(result) {
488
+ if (!result || result.status !== 'active' || result.question) return;
489
+ warn('The setup is busy finishing the last step. Wait a moment, then run:');
490
+ warn(` empirical setup resume ${sanitizeForTerminal(result.sessionId)}`);
491
+ process.exit(1);
52
492
  }
53
493
 
54
- async function runInteractive(setupId) {
494
+ // Answers files written before Phase 2 have no keys for the preference rounds (p1, p2, ...) or
495
+ // the voice check (vc1_topic, vc1, ...). Those steps are optional, so a missing key ends the
496
+ // comparisons or skips the check and the run still reaches the confirm step. A stranded step
497
+ // (retry) is finished with retry.
498
+ function scriptedDefault(question) {
499
+ const key = String(question.questionKey || '');
500
+ if (question.kind === 'prefer' && /^p\d+$/.test(key)) return 'enough';
501
+ if (question.kind === 'pick_or_write' && /^vc\d+_topic$/.test(key)) return 'skip';
502
+ if (question.kind === 'voice_check' && /^vc\d+$/.test(key)) return 'skip';
503
+ // A step stranded by a failed model call: any reply finishes it.
504
+ if (key === 'retry') return 'retry';
505
+ return undefined;
506
+ }
507
+
508
+ const MAX_EMPTY_REPLIES = 3;
509
+
510
+ // An answer the server refuses at a terminal (a mistyped letter, a stale draft, a question that
511
+ // moved on in another window) is explained and the current question is read again, instead of
512
+ // ending the whole interview. Other failures (plan, sign-in, server errors) still stop the run.
513
+ async function sendInteractiveAnswer(current, answer) {
514
+ const pathSuffix = `/${current.sessionId}/continue`;
515
+ const body = answerBody(current.question, answer);
516
+ const { data, error } = await tryApi('POST', pathSuffix, body);
517
+ if (!error) return data;
518
+ if (error.statusCode !== 400 && error.statusCode !== 409) failWith(error, pathSuffix, body);
519
+ if (error.code === 'GUIDED_SETUP_CONFIRM_REQUIRED') warn('To install this draft, type yes.');
520
+ else warn(sanitizeForTerminal(error.serverMessage || error.message));
521
+ return callApi('POST', pathSuffix, {});
522
+ }
523
+
524
+ async function runInteractive(setupId, acceptCost) {
55
525
  const prompter = createPrompter(process.stderr);
526
+ let abortMessage = null;
527
+ let finalResult = null;
528
+ let confirmed = false;
56
529
  try {
57
- let current = await callApi('POST', '/start', { setupId });
530
+ let current = await startWithCost(setupId, { acceptCost, prompter });
531
+ if (!current) return;
532
+ let emptyReplies = 0;
58
533
  while (current.question && current.status !== 'completed' && current.status !== 'finished_early') {
59
- const answer = await prompter.askText(current.question.prompt);
60
- current = await callApi('POST', `/${current.sessionId}/continue`, { answer });
534
+ const question = current.question;
535
+ if (question.kind === 'confirm') {
536
+ printDraft(question, current.progress);
537
+ const reply = (await prompter.askText('Install this? (type yes to install, or type a change you want)')).trim();
538
+ if (!reply) {
539
+ const sessionId = current.sessionId;
540
+ abortMessage = () => printNothingInstalled(sessionId, question.confirm && question.confirm.draftHash);
541
+ break;
542
+ }
543
+ if (/^(y|yes)$/i.test(reply)) {
544
+ if (question.confirm && question.confirm.stale) {
545
+ warn('This draft is out of date. Type rebuild to write a new one first.');
546
+ continue;
547
+ }
548
+ current = await callApi('POST', `/${current.sessionId}/confirm`, { draftHash: question.confirm.draftHash });
549
+ confirmed = true;
550
+ break;
551
+ }
552
+ current = await sendInteractiveAnswer(current, reply);
553
+ continue;
554
+ }
555
+ printQuestion(question, current.progress);
556
+ const answer = await prompter.askText('>');
557
+ if (!answer.trim()) {
558
+ emptyReplies += 1;
559
+ if (emptyReplies >= MAX_EMPTY_REPLIES || process.stdin.readableEnded) {
560
+ const sessionId = current.sessionId;
561
+ abortMessage = () => {
562
+ warn('No answer was given, so the interview stopped here and nothing was installed.');
563
+ warn(`Pick it up again with: empirical setup resume ${sanitizeForTerminal(sessionId)}`);
564
+ };
565
+ break;
566
+ }
567
+ continue;
568
+ }
569
+ emptyReplies = 0;
570
+ current = await sendInteractiveAnswer(current, answer);
61
571
  }
62
- log(JSON.stringify(current, null, 2));
572
+ finalResult = current;
63
573
  } finally {
64
- prompter.close();
574
+ // A confirmed run keeps the prompter for the voice skill offer; it is closed below.
575
+ if (!confirmed) prompter.close();
576
+ }
577
+ if (abortMessage) {
578
+ abortMessage();
579
+ process.exit(1);
65
580
  }
581
+ if (confirmed) {
582
+ try {
583
+ return await reportConfirmed(finalResult, { prompter });
584
+ } finally {
585
+ prompter.close();
586
+ }
587
+ }
588
+ exitIfBusy(finalResult);
589
+ printJson(finalResult);
66
590
  }
67
591
 
68
592
  async function runRun(args) {
69
593
  const setupId = args[0] && !args[0].startsWith('--') ? args[0] : '';
70
594
  if (!setupId) {
71
- warn('Usage: empirical setup run <setupId> [--answers <file.json>]');
595
+ warn('Usage: empirical setup run <setupId> [--answers <file.json>] [--accept-cost]');
72
596
  process.exit(1);
73
597
  }
74
598
  const answersFile = parseOption(args, '--answers', '');
599
+ const acceptCost = args.includes('--accept-cost');
75
600
  if (answersFile) {
76
- await runNonInteractive(setupId, answersFile);
601
+ await runNonInteractive(setupId, answersFile, acceptCost);
77
602
  } else {
78
- await runInteractive(setupId);
603
+ await runInteractive(setupId, acceptCost);
79
604
  }
80
605
  }
81
606
 
@@ -85,8 +610,11 @@ async function runAnswer(args) {
85
610
  warn('Usage: empirical setup answer <sessionId> <questionKey> <value>');
86
611
  process.exit(1);
87
612
  }
88
- const result = await callApi('POST', `/${sessionId}/continue`, { answer: valueParts.join(' '), questionKey });
89
- log(JSON.stringify(result, null, 2));
613
+ // The key from `setup resume` goes with the answer, so an answer meant for a question that has
614
+ // since moved on is refused instead of being saved on the current one.
615
+ const result = await callApi('POST', `/${encodeURIComponent(sessionId)}/continue`, { answer: valueParts.join(' '), questionKey });
616
+ printJson(result);
617
+ printCurrentQuestion(result);
90
618
  }
91
619
 
92
620
  async function runFinish(args) {
@@ -95,8 +623,8 @@ async function runFinish(args) {
95
623
  warn('Usage: empirical setup finish <sessionId>');
96
624
  process.exit(1);
97
625
  }
98
- const result = await callApi('POST', `/${sessionId}/finish`, {});
99
- log(JSON.stringify(result, null, 2));
626
+ const result = await callApi('POST', `/${encodeURIComponent(sessionId)}/finish`, {});
627
+ printJson(result);
100
628
  }
101
629
 
102
630
  async function runResume(args) {
@@ -105,8 +633,19 @@ async function runResume(args) {
105
633
  warn('Usage: empirical setup resume <sessionId>');
106
634
  process.exit(1);
107
635
  }
108
- const result = await callApi('POST', `/${sessionId}/continue`, {});
109
- log(JSON.stringify(result, null, 2));
636
+ const result = await callApi('POST', `/${encodeURIComponent(sessionId)}/continue`, {});
637
+ printJson(result);
638
+ printCurrentQuestion(result);
639
+ }
640
+
641
+ async function runConfirm(args) {
642
+ const [sessionId, draftHash] = args;
643
+ if (!sessionId || !draftHash) {
644
+ warn('Usage: empirical setup confirm <sessionId> <draftHash>');
645
+ process.exit(1);
646
+ }
647
+ const result = await callApi('POST', `/${encodeURIComponent(sessionId)}/confirm`, { draftHash });
648
+ await reportConfirmed(result);
110
649
  }
111
650
 
112
651
  async function runSetupCommand(args) {
@@ -116,8 +655,12 @@ async function runSetupCommand(args) {
116
655
  if (sub === 'answer') return runAnswer(rest);
117
656
  if (sub === 'finish') return runFinish(rest);
118
657
  if (sub === 'resume') return runResume(rest);
119
- warn('Usage: empirical setup <list|run|answer|finish|resume> ...');
658
+ if (sub === 'confirm') return runConfirm(rest);
659
+ warn('Usage: empirical setup <list|run|answer|finish|resume|confirm> ...');
120
660
  process.exit(1);
121
661
  }
122
662
 
123
- module.exports = { runSetupCommand };
663
+ module.exports = {
664
+ refundLine, runSetupCommand, formatQuestionLines, formatDraftLines, sanitizeForTerminal,
665
+ offerVoicePluginInstall, VOICE_PLUGIN_COMMANDS, VOICE_STANDING_INSTRUCTION
666
+ };