@empirical-gauzza/cli 1.0.36 → 1.0.38

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,759 @@ 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
+ case 'GUIDED_SETUP_WORKSPACE_NOT_WAITING':
59
+ return [`See the current question and its key with: ${resume}`];
60
+ case 'GUIDED_SETUP_VOICE_BUSY':
61
+ case 'GUIDED_SETUP_WORKSPACE_BUSY':
62
+ return [`Wait a moment, then pick it up with: ${resume}`];
63
+ // The workspace interview's own refusals. The server's message already carries the price of a new
64
+ // interview (it is the effective one), so these lines name no number. An abandoned interview is
65
+ // not refunded, which is said before the person is pointed at starting again.
66
+ case 'GUIDED_SETUP_WORKSPACE_SESSION_LIMIT':
67
+ return [`If a draft was already shown, you can read it with ${resume} and confirm it; the limit can be reached before there is one. Starting a new interview is charged again, and stopping this one with empirical setup abandon ${sid} does not return what it cost.`];
68
+ case 'GUIDED_SETUP_WORKSPACE_GENERATION_FAILED':
69
+ return [`Your answers are saved. Run ${resume} to see where the setup is, then answer again.`];
70
+ case 'GUIDED_SETUP_WORKSPACE_UNAVAILABLE':
71
+ return ['Nothing was started or charged. Run the command again in a few minutes.'];
72
+ case 'EMPIRICAL_TOKENS_ATTEMPT_CLOSED':
73
+ return ['This attempt is already closed, so it cannot be continued. Start a new one with: empirical setup run <setupId>'];
74
+ case 'EMPIRICAL_TOKEN_KEY_OWNER_MISMATCH':
75
+ 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.'];
76
+ case 'PLAN_CHANGE_FAILED':
77
+ return ['Your plan change did not go through. Check your plan, then try again.'];
78
+ case 'TRIAL_END_IN_PROGRESS':
79
+ return ['Your trial is ending right now. Wait a minute, then try again.'];
80
+ case 'PLAN_LIMIT_EMPIRICAL_TOKENS_REACHED':
81
+ return [tokensShortfallHint(error)];
82
+ case 'GUIDED_SETUP_DRAFT_STALE':
83
+ return [draftStaleHint(resume, sid, sanitizeForTerminal(step))];
84
+ case 'GUIDED_SETUP_DRAFT_CHANGED':
85
+ return [`Run ${resume} to read the latest draft and its hash (or finish the waiting step first), then confirm that draft.`];
86
+ case 'GUIDED_SETUP_NOT_READY_TO_CONFIRM':
87
+ return [`Finish the questions first. See where the setup is with: ${resume}`];
88
+ case 'GUIDED_SETUP_CONFIRM_REQUIRED':
89
+ return [`Installing is its own command: read the draft with ${resume}, then run empirical setup confirm ${sid} <draftHash>.`];
90
+ default:
91
+ return [];
92
+ }
93
+ }
94
+
95
+ // The 403's details carry remaining, needed and resetsAt. A partial shortfall (some left, not enough)
96
+ // must not read as "used all", so the numbers are quoted when the server sent them.
97
+ function tokensShortfallHint(error) {
98
+ const d = (error && error.details) || {};
99
+ const reset = resetLabel(d.resetsAt);
100
+ const known = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
101
+ if (known(d.remaining) && known(d.needed)) {
102
+ return `You have ${d.remaining.toLocaleString('en-US')} left and this needs ${d.needed.toLocaleString('en-US')}.${reset ? ` They reset on ${reset}.` : ''}`;
103
+ }
104
+ return `There are not enough Empirical Tokens left this month.${reset ? ` They reset on ${reset}.` : ' They reset at the start of next month.'}`;
105
+ }
106
+
107
+ // The step a request was for: the question key it carried, or the confirm route.
108
+ function stepOf(pathSuffix, body) {
109
+ if (body && typeof body.questionKey === 'string') return body.questionKey;
110
+ return /\/confirm$/.test(String(pathSuffix || '')) ? 'confirm' : '';
111
+ }
112
+
113
+ // A refund is stated once per run, in words. The server sends details.refunded as a boolean
114
+ // (true = this attempt's Empirical Tokens were returned); a count line is used only for a
115
+ // numeric value above one.
116
+ let refundShown = false;
117
+ function refundLine(error) {
118
+ const refunded = error && error.details && error.details.refunded;
119
+ if (refundShown) return null;
120
+ // Some refusals already say so in their own message ("Your Empirical Tokens for this attempt were
121
+ // returned."): the CLI line would only repeat it.
122
+ if (/\breturned\b/i.test(String((error && (error.serverMessage || error.message)) || ''))) return null;
123
+ if (refunded === true) {
124
+ refundShown = true;
125
+ return 'Your Empirical Tokens for this attempt were returned.';
126
+ }
127
+ if (typeof refunded === 'number' && Number.isFinite(refunded) && refunded > 1) {
128
+ refundShown = true;
129
+ return `${refunded.toLocaleString('en-US')} Empirical Tokens were returned to your balance.`;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ // The server words its errors for every client, and one clause tells an AGENT to call a tool. At a terminal the
135
+ // same step is a command, so that clause (and only that clause) becomes the real command.
136
+ function cliConfirmWording(text, sessionId) {
137
+ return String(text === undefined || text === null ? '' : text).replace(
138
+ /\b([Cc])all confirm_guided_setup with this session's sessionId and the draftHash shown with the draft/g,
139
+ (_m, c) => `${c === 'C' ? 'Run' : 'run'} empirical setup confirm ${sessionId || '<sessionId>'} <draftHash>`
140
+ );
141
+ }
142
+ function forTerminal(error, sessionId) {
143
+ if (!error) return error;
144
+ if (typeof error.message === 'string') error.message = cliConfirmWording(error.message, sessionId);
145
+ if (typeof error.serverMessage === 'string') error.serverMessage = cliConfirmWording(error.serverMessage, sessionId);
146
+ return error;
147
+ }
148
+
149
+ function failWith(error, pathSuffix, body) {
150
+ forTerminal(error, sessionIdFromPath(pathSuffix));
151
+ printApiError(error);
152
+ for (const line of recoveryHints(error, sessionIdFromPath(pathSuffix), stepOf(pathSuffix, body))) warn(line);
153
+ const refund = refundLine(error);
154
+ if (refund) warn(refund);
155
+ process.exit(1);
156
+ }
157
+
158
+ async function tryApi(method, pathSuffix, body) {
21
159
  const accessToken = requireAccessToken();
22
160
  try {
23
161
  const response = await httpJson(apiUrl(pathSuffix), method, body, buildApiAuthHeaders(accessToken));
24
- return response && response.data ? response.data : response;
162
+ return { data: response && response.data ? response.data : response };
25
163
  } catch (error) {
26
- printApiError(error);
27
- process.exit(1);
164
+ return { error };
28
165
  }
29
166
  }
30
167
 
168
+ async function callApi(method, pathSuffix, body) {
169
+ const { data, error } = await tryApi(method, pathSuffix, body);
170
+ if (error) failWith(error, pathSuffix, body);
171
+ return data;
172
+ }
173
+
174
+ // The structured meter (progress.dimensions, which carries the person's quotes and answer text) and
175
+ // the re-pick lists (progress.repick) are for the portal's chips only; the CLI shows the meter as
176
+ // progress.meterLine. JSON.stringify leaves C1 controls unescaped, so they are escaped here.
177
+ function forJsonOutput(result) {
178
+ if (!result || typeof result !== 'object' || !result.progress || typeof result.progress !== 'object') return result;
179
+ const { dimensions: _dimensions, repick: _repick, ...progress } = result.progress;
180
+ return { ...result, progress };
181
+ }
182
+
183
+ function printJson(value) {
184
+ log(JSON.stringify(forJsonOutput(value), null, 2).replace(/[\x80-\x9f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`));
185
+ }
186
+
31
187
  async function runList() {
32
188
  const { setups } = await callApi('GET', '', undefined);
33
- log(JSON.stringify(setups, null, 2));
189
+ printJson(setups);
190
+ }
191
+
192
+ // Server and user text is printed to a terminal, so control sequences are removed first:
193
+ // whole OSC and CSI sequences, then any remaining C0/C1 control character except newline and tab.
194
+ function sanitizeForTerminal(value) {
195
+ return String(value === undefined || value === null ? '' : value)
196
+ .replace(/\u001b\][^\u0007\u001b\u009c]*(?:\u0007|\u001b\\|\u009c)?/g, '')
197
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
198
+ .replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, '');
199
+ }
200
+
201
+ // What leaving the follow-up loop leads to (the server's question.next), in the person's words.
202
+ const LOOP_EXIT_NEXT = {
203
+ avoid: 'two last steps (habits to rule out, and the common AI-writing tells), then a short sample in your voice',
204
+ tropes: 'one last step (the common AI-writing tells), then a short sample in your voice',
205
+ review: 'the draft, written for you to review'
206
+ };
207
+
208
+ function loopHint(question, progress) {
209
+ const after = LOOP_EXIT_NEXT[question.next];
210
+ if (progress.ready) {
211
+ 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.';
212
+ }
213
+ return after
214
+ ? `Type enough to stop here and go on to ${after}; with areas still missing the voice may come out inconsistent.`
215
+ : 'Type enough to stop here; with areas still missing the voice may come out inconsistent.';
216
+ }
217
+
218
+ // Voice questions as plain lines for a terminal. A preference round is a lettered list in the
219
+ // server's order; only the letter and the text of each version are printed (blind choice: never
220
+ // any other field of a version or the round). Every piece of server text is sanitised.
221
+ function formatQuestionLines(question, progress = {}) {
222
+ const t = sanitizeForTerminal;
223
+ const p = progress || {};
224
+ const lines = [t(question.prompt)];
225
+ if (question.kind === 'voice_check') {
226
+ lines.push(`Sample on "${t(question.topic)}":`, ...String(question.passage || '').split('\n').map((l) => t(l)));
227
+ }
228
+ if (question.kind === 'prefer') {
229
+ if (question.situation) lines.push(`Situation: ${t(question.situation)}`);
230
+ for (const v of question.versions || []) {
231
+ lines.push(`${t(v.letter)})`, ...String(v.text || '').split('\n').map((l) => ` ${t(l)}`));
232
+ }
233
+ }
234
+ // A topic step on a stale draft refuses topics (the server writes no sample from it).
235
+ const staleTopic = question.kind === 'pick_or_write' && question.stale;
236
+ if (Array.isArray(question.options) && !staleTopic) {
237
+ for (const o of question.options) {
238
+ const description = o.description ? `: ${t(o.description)}` : '';
239
+ lines.push(` ${t(o.id)}. ${t(o.title)}${description}`);
240
+ }
241
+ }
242
+ const key = String(question.questionKey || '');
243
+ if ((question.kind === 'text' && /^(g\d+|f_)/.test(key)) || question.kind === 'prefer') {
244
+ if (p.meterLine) lines.push(t(p.meterLine));
245
+ // During the rounds "enough" ends the comparisons only, and a question focused from the portal
246
+ // records whatever is typed, so the end-early hint belongs to the follow-up loop alone.
247
+ if (p.meterLine && question.kind === 'text' && /^g\d+$/.test(key) && !question.focus) lines.push(loopHint(question, p));
248
+ }
249
+ // The workspace interview words its own examples (built from the options on offer, and in the 'add Name'
250
+ // form its parser reads); this Writing Voice line would show "2,5" for a list that may have two options.
251
+ if (question.kind === 'pick_and_edit' && !isWorkspaceQuestion(question)) lines.push('Reply with the numbers that fit, like 2,5, and optionally a | and your own changes.');
252
+ if (question.kind === 'pick_one') lines.push('Reply with one number.');
253
+ if (question.kind === 'yes_no') lines.push('Reply yes or no.');
254
+ if (staleTopic) {
255
+ 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.');
256
+ } else if (question.kind === 'pick_or_write') {
257
+ lines.push('Reply with one number, or a | and your own topic. Type skip to go straight to the review.');
258
+ }
259
+ 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.');
260
+ 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.');
261
+ return lines;
262
+ }
263
+
264
+ // "Personal Workspace" is already a place; "Work" becomes "Work workspace".
265
+ function workspacePlace(name) {
266
+ const n = String(name || '').trim() || 'selected';
267
+ return /\bworkspace$/i.test(n) ? n : `${n} workspace`;
268
+ }
269
+
270
+ // A workspace draft is a list of plain sentences (no hub or rules), the people to be invited, and
271
+ // anything that stops it being confirmed. The Writing Voice branch below would print "Hub: undefined".
272
+ function isWorkspaceDraft(question) {
273
+ return Boolean(question && question.draft && Array.isArray(question.draft.lines));
274
+ }
275
+
276
+ function formatWorkspaceDraftLines(question) {
277
+ const t = sanitizeForTerminal;
278
+ const confirm = question.confirm || {};
279
+ const lines = [];
280
+ for (const l of [...question.draft.lines, ...(Array.isArray(question.draft.statusSummary) ? question.draft.statusSummary : [])]) lines.push(`- ${t(l)}`);
281
+ if (Array.isArray(confirm.invites)) {
282
+ lines.push(confirm.invites.length
283
+ ? `Invites: ${confirm.invites.map((i) => `${t(i.email)} (${t(i.role)})`).join(', ')}`
284
+ : 'Invites: nobody yet.');
285
+ }
286
+ if (confirm.inviteNote) lines.push(t(confirm.inviteNote));
287
+ if (confirm.canConfirm === false) {
288
+ lines.push('This draft cannot be confirmed as it is:');
289
+ for (const b of Array.isArray(confirm.blockers) ? confirm.blockers : []) lines.push(`- ${t(b)}`);
290
+ lines.push('Type a change to fix it. Nothing has been created or changed.');
291
+ } else if (question.prompt) {
292
+ // The server's own review wording: what the person can do next (next, confirm, a change, a rename).
293
+ lines.push(workspaceReviewPrompt(question.prompt));
294
+ }
295
+ return lines;
296
+ }
297
+
298
+ // The server words the review for every client, and its one tool-call clause (how an AGENT confirms) means
299
+ // nothing at a terminal, where the person types the word that is asked for below. Everything else is the
300
+ // server's own text, unchanged, so every example in it works as typed.
301
+ function workspaceReviewPrompt(prompt) {
302
+ return sanitizeForTerminal(prompt)
303
+ .replace(/\s*\(call confirm_guided_setup[^)]*\)/, '')
304
+ .replace(/To (?:create the workspace|save these changes), call confirm_guided_setup[^.]*\.\s*/, '')
305
+ .trim();
306
+ }
307
+
308
+ function formatDraftLines(question, progress = {}) {
309
+ if (isWorkspaceDraft(question)) return formatWorkspaceDraftLines(question);
310
+ const t = sanitizeForTerminal;
311
+ const draft = question.draft || {};
312
+ const confirm = question.confirm || {};
313
+ const lines = [];
314
+ const place = t(workspacePlace(confirm.workspace && confirm.workspace.name));
315
+ const memoryCount = Number.isFinite(confirm.memoryCount) ? confirm.memoryCount : 0;
316
+ const replacesCount = Number.isFinite(confirm.replacesCount) ? confirm.replacesCount : 0;
317
+ lines.push(`Hub: ${t(draft.hub)}`);
318
+ // Rules the person did not write get their own labeled block with a plain reason (absent on an
319
+ // older server, which prints every rule in one list as before).
320
+ const fixed = Array.isArray(confirm.fixedRules) ? confirm.fixedRules : [];
321
+ const fixedTexts = new Set(fixed.map((f) => f.text));
322
+ for (const r of Array.isArray(draft.rules) ? draft.rules : []) {
323
+ if (fixedTexts.has(r.text)) continue;
324
+ lines.push(`- (${t(r.kind)}${r.context ? `, ${t(r.context)}` : ''}) ${t(r.text)}`);
325
+ }
326
+ if (fixed.length > 0) {
327
+ lines.push('Rules every voice keeps (you did not write these):');
328
+ for (const f of fixed) {
329
+ lines.push(`- ${t(f.text)}`);
330
+ lines.push(` ${t(f.reason)}`);
331
+ }
332
+ if (fixed.some((f) => f.removable === true)) lines.push('To remove one that can be removed, type the change in your own words.');
333
+ }
334
+ lines.push(`${memoryCount} memories in your ${place}${replacesCount ? `; your current voice (${replacesCount} memories) will be replaced` : ''}.`);
335
+ if (confirm.fitsPlan === false) {
336
+ const free = Number.isFinite(confirm.free) ? confirm.free : 0;
337
+ 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.`);
338
+ }
339
+ if (confirm.stale) lines.push('Your answers changed after this draft was written. Type rebuild to write a new draft before installing.');
340
+ if (progress && progress.meterLine && progress.ready === false) lines.push(`${t(progress.meterLine)} The voice may come out inconsistent.`);
341
+ const vc = confirm.voiceCheck;
342
+ // The server refuses another check while the draft is stale or the checks are used up.
343
+ if (vc && vc.stale) {
344
+ lines.push(vc.canCheckAgain && !confirm.stale
345
+ ? 'The sample passage was written before your last change. Type check to see a new one.'
346
+ : 'The sample passage was written before your last change.');
347
+ }
348
+ return lines;
349
+ }
350
+
351
+ function printQuestion(question, progress) {
352
+ for (const line of formatQuestionLines(question, progress)) warn(line);
353
+ }
354
+
355
+ function printDraft(question, progress) {
356
+ for (const line of formatDraftLines(question, progress)) warn(line);
357
+ }
358
+
359
+ // `setup resume` and `setup answer` print the result as JSON on stdout; a voice question is also
360
+ // printed as text on stderr, so a person can read the lettered list or the sample and answer it.
361
+ function printCurrentQuestion(result) {
362
+ const question = result && result.question;
363
+ if (!question || !question.kind) return;
364
+ const sid = sanitizeForTerminal(result.sessionId);
365
+ const key = sanitizeForTerminal(question.questionKey);
366
+ if (question.kind !== 'confirm') {
367
+ printQuestion(question, result.progress);
368
+ warn(`Answer with: empirical setup answer ${sid} ${key} <your answer>`);
369
+ return;
370
+ }
371
+ printDraft(question, result.progress);
372
+ const confirm = question.confirm || {};
373
+ if (isWorkspaceDraft(question) && confirm.canConfirm === false) {
374
+ warn(`Ask for a change with: empirical setup answer ${sid} ${key} <the change you want>`);
375
+ return;
376
+ }
377
+ if (confirm.stale) {
378
+ warn(`Rebuild it with: empirical setup answer ${sid} ${key} rebuild`);
379
+ } else if (confirm.draftHash) {
380
+ warn(`Install this draft with: empirical setup confirm ${sid} ${sanitizeForTerminal(confirm.draftHash)}`);
381
+ }
382
+ warn(`Ask for a change with: empirical setup answer ${sid} ${key} <the change you want>`);
383
+ }
384
+
385
+ function printNothingInstalled(sessionId, draftHash) {
386
+ warn('Nothing was installed.');
387
+ if (sessionId && draftHash) {
388
+ warn('When you are ready, confirm this exact draft with:');
389
+ warn(` empirical setup confirm ${sanitizeForTerminal(sessionId)} ${sanitizeForTerminal(draftHash)}`);
390
+ }
391
+ }
392
+
393
+ const TERMINAL_INSTALL_STATUSES = ['succeeded', 'failed', 'rolled_back', 'dead_letter', 'uninstalled'];
394
+
395
+ // The voice is written by a background job. Poll the install status a bounded number of times;
396
+ // EMPIRICAL_SETUP_POLL_MS shortens the delay for tests.
397
+ async function fetchInstallStatus(installId) {
398
+ const attempts = 10;
399
+ const configured = process.env.EMPIRICAL_SETUP_POLL_MS;
400
+ const delayMs = configured !== undefined && Number(configured) >= 0 ? Number(configured) : 3000;
401
+ const url = new URL(`/api/v1/blueprints/installs/${encodeURIComponent(installId)}`, resolveApiBaseUrl()).toString();
402
+ for (let i = 0; i < attempts; i += 1) {
403
+ try {
404
+ const response = await httpJson(url, 'GET', undefined, buildApiAuthHeaders(requireAccessToken()));
405
+ const status = response && response.data ? response.data : response;
406
+ if (status && TERMINAL_INSTALL_STATUSES.includes(String(status.jobStatus))) return status;
407
+ } catch (error) {
408
+ return { error: true, message: error && error.message ? error.message : 'unknown error' };
409
+ }
410
+ if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
411
+ }
412
+ return null;
413
+ }
414
+
415
+ // Inside Claude Code the host is known; anywhere else the person says which one.
416
+ function insideClaudeCode(env) {
417
+ return Boolean(env && (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT));
418
+ }
419
+
420
+ // The existing `empirical plugin install <host> --voice` code path, loaded only when asked for.
421
+ async function installVoiceSkillFor(host) {
422
+ const { runPluginCommand } = require('./pluginCommands');
423
+ await runPluginCommand(['install', host, '--voice']);
424
+ }
425
+
426
+ // Offered once, after the voice is installed, to a person at a terminal. Enter or anything but a
427
+ // yes installs nothing. A failure never fails the setup that already succeeded: it says what to run.
428
+ async function offerVoicePluginInstall({ prompter, env = process.env, install = installVoiceSkillFor, warn: say = warn }) {
429
+ const reply = String(await prompter.askText('Install the voice skill into Claude Code/Codex now? (y/N)')).trim();
430
+ if (!/^(y|yes)$/i.test(reply)) return false;
431
+ let hosts = ['claude'];
432
+ if (!insideClaudeCode(env)) {
433
+ const which = String(await prompter.askText('Which one? Type claude, codex, or both (Enter for both)')).trim().toLowerCase();
434
+ hosts = which === 'claude' ? ['claude'] : which === 'codex' ? ['codex'] : ['claude', 'codex'];
435
+ }
436
+ let ok = true;
437
+ for (const host of hosts) {
438
+ try {
439
+ await install(host);
440
+ } catch (error) {
441
+ ok = false;
442
+ say(`Could not install the voice skill into ${host}: ${sanitizeForTerminal(error && error.message ? error.message : 'unknown error')}`);
443
+ say('Run these yourself, in order:');
444
+ for (const line of VOICE_PLUGIN_COMMANDS[host]) say(` ${line}`);
445
+ }
446
+ }
447
+ return ok;
448
+ }
449
+
450
+ // offer: { prompter } when a person at a terminal ran the interview; absent for scripted runs.
451
+ async function reportConfirmed(result, offer) {
452
+ if (result && result.message) log(sanitizeForTerminal(result.message));
453
+ // A confirmed workspace interview has no install job: it names the workspace and what to do next.
454
+ if (result && result.workspaceId && !(result && result.installId)) {
455
+ const id = sanitizeForTerminal(result.workspaceId);
456
+ log(`Workspace id: ${id}`);
457
+ log(`Switch to it with: ${WORKSPACE_USE_LINE(id)}`);
458
+ log(`Change its shape later with: ${WORKSPACE_SHAPE_LINE(id)}`);
459
+ return;
460
+ }
461
+ const installId = result && result.installId;
462
+ if (!installId) return;
463
+ const status = await fetchInstallStatus(installId);
464
+ if (status && status.error) {
465
+ log(`Could not check on the install right now (${sanitizeForTerminal(status.message)}). Check it later with:`);
466
+ log(` empirical blueprint status ${sanitizeForTerminal(installId)}`);
467
+ return;
468
+ }
469
+ if (status && status.jobStatus === 'succeeded') {
470
+ if (Array.isArray(status.nextPrompts) && status.nextPrompts.length > 0) {
471
+ log('');
472
+ log('Your voice is installed. Prompts you can use right away:');
473
+ status.nextPrompts.forEach((p, i) => log(`${i + 1}. ${sanitizeForTerminal(p)}`));
474
+ } else {
475
+ log('Your voice is installed.');
476
+ }
477
+ // The prompts above work in any assistant. In a coding agent the optional plugin does it
478
+ // automatically, so show the install commands the person can paste (nothing here is sent to a model).
479
+ log('');
480
+ log('In Claude Code or Codex, the optional empirical-voice plugin makes your agent use this voice automatically. Run these in order:');
481
+ for (const line of [...VOICE_PLUGIN_COMMANDS.claude, ...VOICE_PLUGIN_COMMANDS.codex]) log(` ${line}`);
482
+ log('');
483
+ 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:');
484
+ log(` ${VOICE_STANDING_INSTRUCTION}`);
485
+ if (offer && offer.prompter && isInteractiveTerminal()) {
486
+ log('');
487
+ await offerVoicePluginInstall({ prompter: offer.prompter });
488
+ }
489
+ return;
490
+ }
491
+ if (status) {
492
+ log(`The install did not finish: ${sanitizeForTerminal(status.jobStatus)}${status.failureReason ? ` (${sanitizeForTerminal(status.failureReason)})` : ''}.`);
493
+ return;
494
+ }
495
+ log('Your voice is still being written. Check on it later with:');
496
+ log(` empirical blueprint status ${sanitizeForTerminal(installId)}`);
497
+ }
498
+
499
+ // Spec 7.3: a priced setup returns a cost preview first. Acceptance is sent only from the explicit
500
+ // --accept-cost flag; a first call without it stops at the cost and starts nothing. A person at a
501
+ // terminal is asked instead, and a non-interactive run stops.
502
+ //
503
+ // start carries any extra start fields. For the workspace interview that is targetWorkspaceId, the one
504
+ // explicit way to ask for a re-shape (no field means a new workspace). Its start is also where a setup
505
+ // that is switched off for the person answers 404, which is explained instead of left as a bare error.
506
+ async function startWithCost(setupId, { acceptCost, prompter, start = {} }) {
507
+ const first = await startCall({ setupId, ...start, ...(acceptCost ? { acceptEmpiricalTokenCost: true } : {}) });
508
+ if (!isCostPreview(first)) return reportTokensUsed(first);
509
+ await confirmCostOrExit(first, { prompter, warn, sanitize: sanitizeForTerminal });
510
+ return reportTokensUsed(await startCall({ setupId, ...start, acceptEmpiricalTokenCost: true }));
511
+ }
512
+
513
+ async function startCall(body) {
514
+ const { data, error } = await tryApi('POST', '/start', body);
515
+ if (!error) return data;
516
+ if (body.setupId === WORKSPACE_SETUP_ID && error.statusCode === 404) {
517
+ printApiError(error);
518
+ warn('The workspace interview is not switched on for your account, so nothing was started or charged.');
519
+ process.exit(1);
520
+ }
521
+ return failWith(error, '/start', body);
522
+ }
523
+
524
+ // After a paid start, say what it cost, but only when the response carries the amount. stderr, so
525
+ // the JSON the command prints on stdout stays parseable.
526
+ function reportTokensUsed(result) {
527
+ const used = result && result.empiricalTokensCharged;
528
+ if (typeof used === 'number' && used > 0) warn(`Used ${used.toLocaleString('en-US')} Empirical Tokens.`);
529
+ return result;
34
530
  }
35
531
 
36
- async function runNonInteractive(setupId, answersFilePath) {
532
+ async function runNonInteractive(setupId, answersFilePath, acceptCost, start) {
37
533
  const raw = fs.readFileSync(path.resolve(answersFilePath), 'utf8');
38
534
  const answersByQuestionKey = JSON.parse(raw);
39
535
 
40
- let current = await callApi('POST', '/start', { setupId });
536
+ let current = await startWithCost(setupId, { acceptCost, prompter: null, start });
537
+ hintAbandon(current);
538
+ let confirmed = false;
539
+ const reviewTurns = {};
41
540
 
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);
541
+ while (current.question && current.status !== 'completed' && current.status !== 'finished_early') {
542
+ const question = current.question;
543
+ const questionKey = question.questionKey;
544
+ if (question.kind === 'confirm' && isFirstWorkspaceReview(question) && Object.prototype.hasOwnProperty.call(answersByQuestionKey, questionKey) && answersByQuestionKey[questionKey] !== 'confirm') {
545
+ // The first review of a workspace: the file's value is the answer, so a script can reach the invites.
546
+ // A list is taken in order (a rename, then next); a single string is sent once.
547
+ const wanted = answersByQuestionKey[questionKey];
548
+ const queue = Array.isArray(wanted) ? wanted : [wanted];
549
+ const turn = (reviewTurns[questionKey] = (reviewTurns[questionKey] || 0) + 1) - 1;
550
+ if (turn < queue.length) {
551
+ current = await callApi('POST', `/${current.sessionId}/continue`, answerBody(question, queue[turn]));
552
+ continue;
553
+ }
47
554
  }
48
- current = await callApi('POST', `/${current.sessionId}/continue`, { answer: answersByQuestionKey[questionKey] });
555
+ if (question.kind === 'confirm') {
556
+ // Confirming is its own step: only an explicit "confirm" for q5_confirm installs, never a stale draft.
557
+ const stale = Boolean(question.confirm && question.confirm.stale);
558
+ // A workspace draft with blockers has no hash to confirm; the file's "confirm" is not sent for it.
559
+ const confirmable = !(question.confirm && question.confirm.canConfirm === false);
560
+ if (answersByQuestionKey[questionKey] === 'confirm' && !stale && confirmable) {
561
+ current = await callApi('POST', `/${current.sessionId}/confirm`, { draftHash: question.confirm.draftHash });
562
+ confirmed = true;
563
+ } else if (stale) {
564
+ warn('This draft is out of date. Answer q5_confirm with rebuild first:');
565
+ warn(` empirical setup answer ${sanitizeForTerminal(current.sessionId)} q5_confirm rebuild`);
566
+ }
567
+ break; // otherwise stop here and print the draft and its hash below
568
+ }
569
+ let answer;
570
+ if (Object.prototype.hasOwnProperty.call(answersByQuestionKey, questionKey)) {
571
+ answer = answersByQuestionKey[questionKey];
572
+ } else {
573
+ answer = scriptedDefault(question);
574
+ if (answer === undefined) {
575
+ warn(`Answers file is missing an answer for unknown question "${sanitizeForTerminal(questionKey)}"`);
576
+ process.exit(1);
577
+ }
578
+ warn(`No answer for ${sanitizeForTerminal(questionKey)} in the answers file, so the run sends ${answer}.`);
579
+ }
580
+ current = await callApi('POST', `/${current.sessionId}/continue`, answerBody(question, answer));
49
581
  }
50
582
 
51
- log(JSON.stringify(current, null, 2));
583
+ if (confirmed) return reportConfirmed(current);
584
+ exitIfBusy(current);
585
+ printJson(current);
586
+ }
587
+
588
+ // Every answer carries the key of the question it answers, so an answer meant for a question that
589
+ // moved on (another window, a resend) is refused instead of being saved on the current one: a
590
+ // round pick saved on the wrong round would be false evidence.
591
+ function answerBody(question, answer) {
592
+ return question && question.questionKey ? { answer, questionKey: question.questionKey } : { answer };
593
+ }
594
+
595
+ // An active session with no question means a step is still being worked on (a model call can take
596
+ // a minute). That is not a finished run: say so and exit non-zero.
597
+ function exitIfBusy(result) {
598
+ if (!result || result.status !== 'active' || result.question) return;
599
+ warn('The setup is busy finishing the last step. Wait a moment, then run:');
600
+ warn(` empirical setup resume ${sanitizeForTerminal(result.sessionId)}`);
601
+ process.exit(1);
602
+ }
603
+
604
+ // Answers files written before Phase 2 have no keys for the preference rounds (p1, p2, ...) or
605
+ // the voice check (vc1_topic, vc1, ...). Those steps are optional, so a missing key ends the
606
+ // comparisons or skips the check and the run still reaches the confirm step. A stranded step
607
+ // (retry) is finished with retry.
608
+ function scriptedDefault(question) {
609
+ const key = String(question.questionKey || '');
610
+ if (question.kind === 'prefer' && /^p\d+$/.test(key)) return 'enough';
611
+ if (question.kind === 'pick_or_write' && /^vc\d+_topic$/.test(key)) return 'skip';
612
+ if (question.kind === 'voice_check' && /^vc\d+$/.test(key)) return 'skip';
613
+ // A step stranded by a failed model call: any reply finishes it.
614
+ if (key === 'retry') return 'retry';
615
+ return undefined;
52
616
  }
53
617
 
54
- async function runInteractive(setupId) {
618
+ const MAX_EMPTY_REPLIES = 3;
619
+
620
+ // An answer the server refuses at a terminal (a mistyped letter, a stale draft, a question that
621
+ // moved on in another window) is explained and the current question is read again, instead of
622
+ // ending the whole interview. Other failures (plan, sign-in, server errors) still stop the run.
623
+ async function sendInteractiveAnswer(current, answer) {
624
+ const pathSuffix = `/${current.sessionId}/continue`;
625
+ const body = answerBody(current.question, answer);
626
+ const { data, error } = await tryApi('POST', pathSuffix, body);
627
+ if (!error) return data;
628
+ if (error.statusCode !== 400 && error.statusCode !== 409) failWith(error, pathSuffix, body);
629
+ if (error.code === 'GUIDED_SETUP_CONFIRM_REQUIRED') warn('To install this draft, type yes.');
630
+ else warn(sanitizeForTerminal(cliConfirmWording(error.serverMessage || error.message, current.sessionId)));
631
+ return callApi('POST', pathSuffix, {});
632
+ }
633
+
634
+ async function runInteractive(setupId, acceptCost, start) {
55
635
  const prompter = createPrompter(process.stderr);
636
+ let abortMessage = null;
637
+ let finalResult = null;
638
+ let confirmed = false;
56
639
  try {
57
- let current = await callApi('POST', '/start', { setupId });
640
+ let current = await startWithCost(setupId, { acceptCost, prompter, start });
641
+ if (!current) return;
642
+ hintAbandon(current);
643
+ let emptyReplies = 0;
58
644
  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 });
645
+ const question = current.question;
646
+ if (question.kind === 'confirm') {
647
+ printDraft(question, current.progress);
648
+ const reply = (await prompter.askText(confirmPromptFor(question))).trim();
649
+ if (!reply) {
650
+ const sessionId = current.sessionId;
651
+ abortMessage = () => printNothingInstalled(sessionId, question.confirm && question.confirm.draftHash);
652
+ break;
653
+ }
654
+ const firstReview = isFirstWorkspaceReview(question);
655
+ if (firstReview && /^(y|yes)$/i.test(reply)) {
656
+ warn('Yes is not one of the choices here. Type next to choose who to invite, or confirm to finish without inviting anyone.');
657
+ continue;
658
+ }
659
+ if (firstReview ? /^confirm$/i.test(reply) : /^(y|yes|confirm)$/i.test(reply)) {
660
+ if (question.confirm && question.confirm.stale) {
661
+ warn('This draft is out of date. Type rebuild to write a new one first.');
662
+ continue;
663
+ }
664
+ if (isWorkspaceDraft(question) && (question.confirm.canConfirm === false || !question.confirm.draftHash)) {
665
+ warn('This draft cannot be confirmed as it is. Type a change to fix what is listed above.');
666
+ continue;
667
+ }
668
+ current = await callApi('POST', `/${current.sessionId}/confirm`, { draftHash: question.confirm.draftHash });
669
+ confirmed = true;
670
+ break;
671
+ }
672
+ current = await sendInteractiveAnswer(current, reply);
673
+ continue;
674
+ }
675
+ printQuestion(question, current.progress);
676
+ const answer = await prompter.askText('>');
677
+ if (!answer.trim()) {
678
+ emptyReplies += 1;
679
+ if (emptyReplies >= MAX_EMPTY_REPLIES || process.stdin.readableEnded) {
680
+ const sessionId = current.sessionId;
681
+ abortMessage = () => {
682
+ warn('No answer was given, so the interview stopped here and nothing was installed.');
683
+ warn(`Pick it up again with: empirical setup resume ${sanitizeForTerminal(sessionId)}`);
684
+ };
685
+ break;
686
+ }
687
+ continue;
688
+ }
689
+ emptyReplies = 0;
690
+ current = await sendInteractiveAnswer(current, answer);
61
691
  }
62
- log(JSON.stringify(current, null, 2));
692
+ finalResult = current;
63
693
  } finally {
64
- prompter.close();
694
+ // A confirmed run keeps the prompter for the voice skill offer; it is closed below.
695
+ if (!confirmed) prompter.close();
696
+ }
697
+ if (abortMessage) {
698
+ abortMessage();
699
+ process.exit(1);
65
700
  }
701
+ if (confirmed) {
702
+ try {
703
+ return await reportConfirmed(finalResult, { prompter });
704
+ } finally {
705
+ prompter.close();
706
+ }
707
+ }
708
+ exitIfBusy(finalResult);
709
+ printJson(finalResult);
710
+ }
711
+
712
+ const WORKSPACE_SETUP_ID = 'setup_workspace';
713
+ // The commands printed after a workspace is made, built from the real id so they work as typed.
714
+ const WORKSPACE_USE_LINE = (id) => `empirical workspace use ${id}`;
715
+ const WORKSPACE_SHAPE_LINE = (id) => `empirical workspace shape --workspace-id ${id}`;
716
+
717
+ // The question at the review step. The workspace interview creates or changes a workspace, not an install.
718
+ // The first review (questionKey draft) is BEFORE the invitations, so a bare yes is not a choice there: the
719
+ // person picks next (to choose who to invite) or confirm (finish with nobody invited). The final review
720
+ // (after the invitations) is the one a yes confirms.
721
+ function isFirstWorkspaceReview(question) {
722
+ return isWorkspaceDraft(question) && question.questionKey === 'draft';
723
+ }
724
+ function confirmPromptFor(question) {
725
+ if (!isWorkspaceDraft(question)) return 'Install this? (type yes to install, or type a change you want)';
726
+ const reshape = question.confirm && question.confirm.mode === 'reshape';
727
+ if (question.confirm && question.confirm.canConfirm === false) return 'What next? (type a change you want)';
728
+ if (isFirstWorkspaceReview(question)) return `What next? (type next to choose who to invite, confirm to ${reshape ? 'save these changes' : 'create the workspace'} without inviting anyone, or type a change you want)`;
729
+ return `${reshape ? 'Apply this to the workspace?' : 'Create this workspace?'} (type yes to confirm, or type a change you want)`;
730
+ }
731
+
732
+ // Printed once after a workspace interview starts. An abandoned interview is NOT refunded, and the
733
+ // line says so, so nobody stops one expecting their Empirical Tokens back.
734
+ function hintAbandon(result) {
735
+ if (!result || !result.sessionId || !result.question) return;
736
+ if (!isWorkspaceQuestion(result.question)) return;
737
+ const sid = sanitizeForTerminal(result.sessionId);
738
+ warn(`If you want to stop this interview, run: empirical setup abandon ${sid} (what it cost is not returned).`);
739
+ }
740
+
741
+ // Workspace questions are the only ones with these keys; the Writing Voice engine has none of them.
742
+ function isWorkspaceQuestion(question) {
743
+ const key = String((question && question.questionKey) || '');
744
+ return key === 'purpose' || key === 'types' || key === 'nesting' || key === 'policy' || key === 'invites'
745
+ || key === 'draft' || /^(statuses|fields):/.test(key);
746
+ }
747
+
748
+ // The workspace interview from the workspace commands. targetWorkspaceId present means re-shape that
749
+ // workspace, absent means create a new one; nothing else decides it.
750
+ async function runWorkspaceInterview({ targetWorkspaceId, answersFile = '', acceptCost = false } = {}) {
751
+ const start = targetWorkspaceId ? { targetWorkspaceId } : {};
752
+ if (answersFile) return runNonInteractive(WORKSPACE_SETUP_ID, answersFile, acceptCost, start);
753
+ return runInteractive(WORKSPACE_SETUP_ID, acceptCost, start);
66
754
  }
67
755
 
68
756
  async function runRun(args) {
69
757
  const setupId = args[0] && !args[0].startsWith('--') ? args[0] : '';
70
758
  if (!setupId) {
71
- warn('Usage: empirical setup run <setupId> [--answers <file.json>]');
759
+ warn('Usage: empirical setup run <setupId> [--answers <file.json>] [--accept-cost]');
72
760
  process.exit(1);
73
761
  }
74
762
  const answersFile = parseOption(args, '--answers', '');
763
+ const acceptCost = args.includes('--accept-cost');
75
764
  if (answersFile) {
76
- await runNonInteractive(setupId, answersFile);
765
+ await runNonInteractive(setupId, answersFile, acceptCost);
77
766
  } else {
78
- await runInteractive(setupId);
767
+ await runInteractive(setupId, acceptCost);
79
768
  }
80
769
  }
81
770
 
@@ -85,8 +774,11 @@ async function runAnswer(args) {
85
774
  warn('Usage: empirical setup answer <sessionId> <questionKey> <value>');
86
775
  process.exit(1);
87
776
  }
88
- const result = await callApi('POST', `/${sessionId}/continue`, { answer: valueParts.join(' '), questionKey });
89
- log(JSON.stringify(result, null, 2));
777
+ // The key from `setup resume` goes with the answer, so an answer meant for a question that has
778
+ // since moved on is refused instead of being saved on the current one.
779
+ const result = await callApi('POST', `/${encodeURIComponent(sessionId)}/continue`, { answer: valueParts.join(' '), questionKey });
780
+ printJson(result);
781
+ printCurrentQuestion(result);
90
782
  }
91
783
 
92
784
  async function runFinish(args) {
@@ -95,8 +787,20 @@ async function runFinish(args) {
95
787
  warn('Usage: empirical setup finish <sessionId>');
96
788
  process.exit(1);
97
789
  }
98
- const result = await callApi('POST', `/${sessionId}/finish`, {});
99
- log(JSON.stringify(result, null, 2));
790
+ const result = await callApi('POST', `/${encodeURIComponent(sessionId)}/finish`, {});
791
+ printJson(result);
792
+ }
793
+
794
+ async function runAbandon(args) {
795
+ const [sessionId] = args;
796
+ if (!sessionId) {
797
+ warn('Usage: empirical setup abandon <sessionId>');
798
+ process.exit(1);
799
+ }
800
+ const result = await callApi('POST', `/${encodeURIComponent(sessionId)}/abandon`, {});
801
+ log(result && result.status === 'abandoned'
802
+ ? 'The interview was stopped. Nothing was created or changed, and what it cost was not returned.'
803
+ : 'That interview was already finished or stopped. Nothing was changed, and no Empirical Tokens were returned.');
100
804
  }
101
805
 
102
806
  async function runResume(args) {
@@ -105,8 +809,19 @@ async function runResume(args) {
105
809
  warn('Usage: empirical setup resume <sessionId>');
106
810
  process.exit(1);
107
811
  }
108
- const result = await callApi('POST', `/${sessionId}/continue`, {});
109
- log(JSON.stringify(result, null, 2));
812
+ const result = await callApi('POST', `/${encodeURIComponent(sessionId)}/continue`, {});
813
+ printJson(result);
814
+ printCurrentQuestion(result);
815
+ }
816
+
817
+ async function runConfirm(args) {
818
+ const [sessionId, draftHash] = args;
819
+ if (!sessionId || !draftHash) {
820
+ warn('Usage: empirical setup confirm <sessionId> <draftHash>');
821
+ process.exit(1);
822
+ }
823
+ const result = await callApi('POST', `/${encodeURIComponent(sessionId)}/confirm`, { draftHash });
824
+ await reportConfirmed(result);
110
825
  }
111
826
 
112
827
  async function runSetupCommand(args) {
@@ -116,8 +831,13 @@ async function runSetupCommand(args) {
116
831
  if (sub === 'answer') return runAnswer(rest);
117
832
  if (sub === 'finish') return runFinish(rest);
118
833
  if (sub === 'resume') return runResume(rest);
119
- warn('Usage: empirical setup <list|run|answer|finish|resume> ...');
834
+ if (sub === 'confirm') return runConfirm(rest);
835
+ if (sub === 'abandon') return runAbandon(rest);
836
+ warn('Usage: empirical setup <list|run|answer|finish|resume|confirm|abandon> ...');
120
837
  process.exit(1);
121
838
  }
122
839
 
123
- module.exports = { runSetupCommand };
840
+ module.exports = {
841
+ refundLine, cliConfirmWording, runSetupCommand, runWorkspaceInterview, WORKSPACE_SETUP_ID, recoveryHints, formatQuestionLines, formatDraftLines, sanitizeForTerminal,
842
+ offerVoicePluginInstall, VOICE_PLUGIN_COMMANDS, VOICE_STANDING_INSTRUCTION
843
+ };