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