@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.
- package/bin/empirical.js +6 -0
- package/lib/attachmentCommands.js +388 -0
- package/lib/auth.js +16 -2
- package/lib/blueprintCommands.js +23 -3
- package/lib/constants.js +722 -670
- package/lib/empiricalTokensFormat.js +68 -0
- package/lib/help.js +23 -1
- package/lib/http.js +171 -3
- package/lib/installWizard.js +3 -1
- package/lib/installers/coreMemoryBlock.js +2 -1
- package/lib/installers/hooks.js +1312 -1312
- package/lib/installers/nativePlugin.js +1 -1
- package/lib/installers/promptBlocks.js +9 -0
- package/lib/installers/skill-variants/SKILL.both.md +20 -0
- package/lib/installers/skill-variants/SKILL.cli.md +231 -221
- package/lib/installers/skill-variants/SKILL.mcp.md +18 -0
- package/lib/memoryCommands.js +1267 -1220
- package/lib/memoryResolve.js +112 -0
- package/lib/pluginCommands.js +3 -2
- package/lib/setupCommands.js +751 -31
- package/lib/usageCommands.js +52 -0
- package/lib/workspaceCommands.js +72 -5
- package/lib/workspaceContext.js +176 -176
- package/package.json +1 -1
package/lib/setupCommands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
60
|
-
|
|
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
|
-
|
|
692
|
+
finalResult = current;
|
|
63
693
|
} finally {
|
|
64
|
-
prompter.
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
840
|
+
module.exports = {
|
|
841
|
+
refundLine, cliConfirmWording, runSetupCommand, runWorkspaceInterview, WORKSPACE_SETUP_ID, recoveryHints, formatQuestionLines, formatDraftLines, sanitizeForTerminal,
|
|
842
|
+
offerVoicePluginInstall, VOICE_PLUGIN_COMMANDS, VOICE_STANDING_INSTRUCTION
|
|
843
|
+
};
|