@empirical-gauzza/cli 1.0.37 → 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 +1 -1
- package/lib/attachmentCommands.js +388 -0
- package/lib/constants.js +50 -0
- package/lib/empiricalTokensFormat.js +6 -4
- package/lib/help.js +12 -0
- package/lib/http.js +160 -0
- package/lib/installers/hooks.js +1312 -1312
- 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 -1222
- package/lib/memoryResolve.js +112 -0
- package/lib/setupCommands.js +191 -14
- package/lib/workspaceCommands.js +72 -5
- package/lib/workspaceContext.js +176 -176
- package/package.json +33 -33
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Resolving "which memory did the user mean" from a --match query, shared by every memory
|
|
4
|
+
// subcommand that accepts --match. It lives in its own module so attachmentCommands.js can use
|
|
5
|
+
// it without a require cycle back through memoryCommands.js; memoryCommands.js re-exports the
|
|
6
|
+
// three names so existing importers and tests are unaffected.
|
|
7
|
+
|
|
8
|
+
const { warn } = require('./ui');
|
|
9
|
+
const { httpJson } = require('./http');
|
|
10
|
+
const { parseOption } = require('./env');
|
|
11
|
+
const { buildApiAuthHeaders } = require('./auth');
|
|
12
|
+
|
|
13
|
+
async function resolveMemoryByQuery(match, minScore, accessToken, baseUrl, filters) {
|
|
14
|
+
const queryUrl = new URL('/api/v1/memories/query', baseUrl).toString();
|
|
15
|
+
const body = { query: match, topK: 5 };
|
|
16
|
+
if (filters && Object.keys(filters).length) body.filters = filters;
|
|
17
|
+
let response;
|
|
18
|
+
try {
|
|
19
|
+
response = await httpJson(queryUrl, 'POST', body, buildApiAuthHeaders(accessToken));
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return { found: false, reason: 'error', message: error.message };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const results = (response && response.data && Array.isArray(response.data.results))
|
|
25
|
+
? response.data.results
|
|
26
|
+
: (Array.isArray(response) ? response : []);
|
|
27
|
+
|
|
28
|
+
if (!results.length) {
|
|
29
|
+
return { found: false, reason: 'no_results' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const scored = results.map((r) => ({
|
|
33
|
+
id: r.id,
|
|
34
|
+
summary: r.summary,
|
|
35
|
+
score: r.rankScore != null ? r.rankScore : (r.similarityScore != null ? r.similarityScore : 0)
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
scored.sort((a, b) => b.score - a.score);
|
|
39
|
+
|
|
40
|
+
// A caller-supplied filter (e.g. --tags) that narrows the server-side result set to exactly
|
|
41
|
+
// one memory is already a stronger disambiguation signal than the semantic score - trust it
|
|
42
|
+
// outright rather than falling through to the score check below. This matters because Atlas
|
|
43
|
+
// vector search has a real async indexing lag: a memory created moments ago can come back
|
|
44
|
+
// with score 0 (unindexed, not "not this one") even when a tag filter already proves it is
|
|
45
|
+
// the only candidate. Fixes a real bug found 2026-08-25 where delete --match failed on a
|
|
46
|
+
// memory `query` found instantly, because delete had no way to trust a filtered, unambiguous
|
|
47
|
+
// result over a stale/zero semantic score.
|
|
48
|
+
if (filters && Object.keys(filters).length && results.length === 1) {
|
|
49
|
+
const only = scored[0];
|
|
50
|
+
return { found: true, memory: { id: only.id, summary: only.summary, score: only.score } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const top = scored[0];
|
|
54
|
+
const second = scored[1];
|
|
55
|
+
|
|
56
|
+
if (top.score < minScore) {
|
|
57
|
+
return { found: false, reason: 'low_score', candidates: scored.slice(0, 3) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (second && (top.score - second.score) <= 0.05) {
|
|
61
|
+
return { found: false, reason: 'ambiguous', candidates: scored.slice(0, 3) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { found: true, memory: { id: top.id, summary: top.summary, score: top.score } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --match resolution depends on Atlas vector search, which has a real async indexing lag:
|
|
68
|
+
// a memory created moments ago can be invisible to $vectorSearch even though it is already
|
|
69
|
+
// findable by tag. Letting --match commands pass --tags narrows the candidate pool directly
|
|
70
|
+
// instead of hoping semantic ranking surfaces the right one - the fix for a real bug found
|
|
71
|
+
// 2026-08-25 where delete --match returned unrelated score-0 candidates for a memory that
|
|
72
|
+
// `query` found instantly. Deliberately --tags (plural) only, not --category: `delete` already
|
|
73
|
+
// uses singular --tag/--category to mean "bulk delete everything in this category", so adding
|
|
74
|
+
// --category here would silently reroute a --match delete into a bulk delete instead of
|
|
75
|
+
// narrowing it - --tags never collides with that bulk-mode gate.
|
|
76
|
+
function parseMatchFilters(args) {
|
|
77
|
+
const tagsRaw = parseOption(args, '--tags', '');
|
|
78
|
+
const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
|
79
|
+
const filters = {};
|
|
80
|
+
if (tags.length) filters.tags = tags;
|
|
81
|
+
return filters;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function printMemoryResolveError(result) {
|
|
85
|
+
if (result.reason === 'no_results') {
|
|
86
|
+
warn(JSON.stringify({ error: true, code: 'no_results', message: 'No memories matched the query.' }));
|
|
87
|
+
} else if (result.reason === 'low_score') {
|
|
88
|
+
warn(JSON.stringify({
|
|
89
|
+
error: true,
|
|
90
|
+
code: 'low_score',
|
|
91
|
+
message: 'Top result score was below --min-score threshold.',
|
|
92
|
+
candidates: result.candidates
|
|
93
|
+
}));
|
|
94
|
+
} else if (result.reason === 'ambiguous') {
|
|
95
|
+
warn(JSON.stringify({
|
|
96
|
+
error: true,
|
|
97
|
+
code: 'ambiguous',
|
|
98
|
+
message: 'Multiple memories matched with similar scores. Use --memory-id to target one directly.',
|
|
99
|
+
candidates: result.candidates
|
|
100
|
+
}));
|
|
101
|
+
} else if (result.reason === 'error') {
|
|
102
|
+
warn(JSON.stringify({ error: true, code: 'query_error', message: result.message }));
|
|
103
|
+
} else {
|
|
104
|
+
warn(JSON.stringify({ error: true, code: 'resolve_failed', message: 'Could not resolve memory.' }));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
resolveMemoryByQuery,
|
|
110
|
+
parseMatchFilters,
|
|
111
|
+
printMemoryResolveError
|
|
112
|
+
};
|
package/lib/setupCommands.js
CHANGED
|
@@ -55,9 +55,20 @@ function recoveryHints(error, sessionId, step = '') {
|
|
|
55
55
|
switch (error && error.code) {
|
|
56
56
|
case 'GUIDED_SETUP_VOICE_NOT_WAITING':
|
|
57
57
|
case 'GUIDED_SETUP_VOICE_STALE':
|
|
58
|
+
case 'GUIDED_SETUP_WORKSPACE_NOT_WAITING':
|
|
58
59
|
return [`See the current question and its key with: ${resume}`];
|
|
59
60
|
case 'GUIDED_SETUP_VOICE_BUSY':
|
|
61
|
+
case 'GUIDED_SETUP_WORKSPACE_BUSY':
|
|
60
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.'];
|
|
61
72
|
case 'EMPIRICAL_TOKENS_ATTEMPT_CLOSED':
|
|
62
73
|
return ['This attempt is already closed, so it cannot be continued. Start a new one with: empirical setup run <setupId>'];
|
|
63
74
|
case 'EMPIRICAL_TOKEN_KEY_OWNER_MISMATCH':
|
|
@@ -120,7 +131,23 @@ function refundLine(error) {
|
|
|
120
131
|
return null;
|
|
121
132
|
}
|
|
122
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
|
+
|
|
123
149
|
function failWith(error, pathSuffix, body) {
|
|
150
|
+
forTerminal(error, sessionIdFromPath(pathSuffix));
|
|
124
151
|
printApiError(error);
|
|
125
152
|
for (const line of recoveryHints(error, sessionIdFromPath(pathSuffix), stepOf(pathSuffix, body))) warn(line);
|
|
126
153
|
const refund = refundLine(error);
|
|
@@ -219,7 +246,9 @@ function formatQuestionLines(question, progress = {}) {
|
|
|
219
246
|
// records whatever is typed, so the end-early hint belongs to the follow-up loop alone.
|
|
220
247
|
if (p.meterLine && question.kind === 'text' && /^g\d+$/.test(key) && !question.focus) lines.push(loopHint(question, p));
|
|
221
248
|
}
|
|
222
|
-
|
|
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.');
|
|
223
252
|
if (question.kind === 'pick_one') lines.push('Reply with one number.');
|
|
224
253
|
if (question.kind === 'yes_no') lines.push('Reply yes or no.');
|
|
225
254
|
if (staleTopic) {
|
|
@@ -238,7 +267,46 @@ function workspacePlace(name) {
|
|
|
238
267
|
return /\bworkspace$/i.test(n) ? n : `${n} workspace`;
|
|
239
268
|
}
|
|
240
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
|
+
|
|
241
308
|
function formatDraftLines(question, progress = {}) {
|
|
309
|
+
if (isWorkspaceDraft(question)) return formatWorkspaceDraftLines(question);
|
|
242
310
|
const t = sanitizeForTerminal;
|
|
243
311
|
const draft = question.draft || {};
|
|
244
312
|
const confirm = question.confirm || {};
|
|
@@ -302,6 +370,10 @@ function printCurrentQuestion(result) {
|
|
|
302
370
|
}
|
|
303
371
|
printDraft(question, result.progress);
|
|
304
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
|
+
}
|
|
305
377
|
if (confirm.stale) {
|
|
306
378
|
warn(`Rebuild it with: empirical setup answer ${sid} ${key} rebuild`);
|
|
307
379
|
} else if (confirm.draftHash) {
|
|
@@ -378,6 +450,14 @@ async function offerVoicePluginInstall({ prompter, env = process.env, install =
|
|
|
378
450
|
// offer: { prompter } when a person at a terminal ran the interview; absent for scripted runs.
|
|
379
451
|
async function reportConfirmed(result, offer) {
|
|
380
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
|
+
}
|
|
381
461
|
const installId = result && result.installId;
|
|
382
462
|
if (!installId) return;
|
|
383
463
|
const status = await fetchInstallStatus(installId);
|
|
@@ -419,11 +499,26 @@ async function reportConfirmed(result, offer) {
|
|
|
419
499
|
// Spec 7.3: a priced setup returns a cost preview first. Acceptance is sent only from the explicit
|
|
420
500
|
// --accept-cost flag; a first call without it stops at the cost and starts nothing. A person at a
|
|
421
501
|
// terminal is asked instead, and a non-interactive run stops.
|
|
422
|
-
|
|
423
|
-
|
|
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 } : {}) });
|
|
424
508
|
if (!isCostPreview(first)) return reportTokensUsed(first);
|
|
425
509
|
await confirmCostOrExit(first, { prompter, warn, sanitize: sanitizeForTerminal });
|
|
426
|
-
return reportTokensUsed(await
|
|
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);
|
|
427
522
|
}
|
|
428
523
|
|
|
429
524
|
// After a paid start, say what it cost, but only when the response carries the amount. stderr, so
|
|
@@ -434,20 +529,35 @@ function reportTokensUsed(result) {
|
|
|
434
529
|
return result;
|
|
435
530
|
}
|
|
436
531
|
|
|
437
|
-
async function runNonInteractive(setupId, answersFilePath, acceptCost) {
|
|
532
|
+
async function runNonInteractive(setupId, answersFilePath, acceptCost, start) {
|
|
438
533
|
const raw = fs.readFileSync(path.resolve(answersFilePath), 'utf8');
|
|
439
534
|
const answersByQuestionKey = JSON.parse(raw);
|
|
440
535
|
|
|
441
|
-
let current = await startWithCost(setupId, { acceptCost, prompter: null });
|
|
536
|
+
let current = await startWithCost(setupId, { acceptCost, prompter: null, start });
|
|
537
|
+
hintAbandon(current);
|
|
442
538
|
let confirmed = false;
|
|
539
|
+
const reviewTurns = {};
|
|
443
540
|
|
|
444
541
|
while (current.question && current.status !== 'completed' && current.status !== 'finished_early') {
|
|
445
542
|
const question = current.question;
|
|
446
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
|
+
}
|
|
554
|
+
}
|
|
447
555
|
if (question.kind === 'confirm') {
|
|
448
556
|
// Confirming is its own step: only an explicit "confirm" for q5_confirm installs, never a stale draft.
|
|
449
557
|
const stale = Boolean(question.confirm && question.confirm.stale);
|
|
450
|
-
|
|
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) {
|
|
451
561
|
current = await callApi('POST', `/${current.sessionId}/confirm`, { draftHash: question.confirm.draftHash });
|
|
452
562
|
confirmed = true;
|
|
453
563
|
} else if (stale) {
|
|
@@ -517,34 +627,44 @@ async function sendInteractiveAnswer(current, answer) {
|
|
|
517
627
|
if (!error) return data;
|
|
518
628
|
if (error.statusCode !== 400 && error.statusCode !== 409) failWith(error, pathSuffix, body);
|
|
519
629
|
if (error.code === 'GUIDED_SETUP_CONFIRM_REQUIRED') warn('To install this draft, type yes.');
|
|
520
|
-
else warn(sanitizeForTerminal(error.serverMessage || error.message));
|
|
630
|
+
else warn(sanitizeForTerminal(cliConfirmWording(error.serverMessage || error.message, current.sessionId)));
|
|
521
631
|
return callApi('POST', pathSuffix, {});
|
|
522
632
|
}
|
|
523
633
|
|
|
524
|
-
async function runInteractive(setupId, acceptCost) {
|
|
634
|
+
async function runInteractive(setupId, acceptCost, start) {
|
|
525
635
|
const prompter = createPrompter(process.stderr);
|
|
526
636
|
let abortMessage = null;
|
|
527
637
|
let finalResult = null;
|
|
528
638
|
let confirmed = false;
|
|
529
639
|
try {
|
|
530
|
-
let current = await startWithCost(setupId, { acceptCost, prompter });
|
|
640
|
+
let current = await startWithCost(setupId, { acceptCost, prompter, start });
|
|
531
641
|
if (!current) return;
|
|
642
|
+
hintAbandon(current);
|
|
532
643
|
let emptyReplies = 0;
|
|
533
644
|
while (current.question && current.status !== 'completed' && current.status !== 'finished_early') {
|
|
534
645
|
const question = current.question;
|
|
535
646
|
if (question.kind === 'confirm') {
|
|
536
647
|
printDraft(question, current.progress);
|
|
537
|
-
const reply = (await prompter.askText(
|
|
648
|
+
const reply = (await prompter.askText(confirmPromptFor(question))).trim();
|
|
538
649
|
if (!reply) {
|
|
539
650
|
const sessionId = current.sessionId;
|
|
540
651
|
abortMessage = () => printNothingInstalled(sessionId, question.confirm && question.confirm.draftHash);
|
|
541
652
|
break;
|
|
542
653
|
}
|
|
543
|
-
|
|
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)) {
|
|
544
660
|
if (question.confirm && question.confirm.stale) {
|
|
545
661
|
warn('This draft is out of date. Type rebuild to write a new one first.');
|
|
546
662
|
continue;
|
|
547
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
|
+
}
|
|
548
668
|
current = await callApi('POST', `/${current.sessionId}/confirm`, { draftHash: question.confirm.draftHash });
|
|
549
669
|
confirmed = true;
|
|
550
670
|
break;
|
|
@@ -589,6 +709,50 @@ async function runInteractive(setupId, acceptCost) {
|
|
|
589
709
|
printJson(finalResult);
|
|
590
710
|
}
|
|
591
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);
|
|
754
|
+
}
|
|
755
|
+
|
|
592
756
|
async function runRun(args) {
|
|
593
757
|
const setupId = args[0] && !args[0].startsWith('--') ? args[0] : '';
|
|
594
758
|
if (!setupId) {
|
|
@@ -627,6 +791,18 @@ async function runFinish(args) {
|
|
|
627
791
|
printJson(result);
|
|
628
792
|
}
|
|
629
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.');
|
|
804
|
+
}
|
|
805
|
+
|
|
630
806
|
async function runResume(args) {
|
|
631
807
|
const [sessionId] = args;
|
|
632
808
|
if (!sessionId) {
|
|
@@ -656,11 +832,12 @@ async function runSetupCommand(args) {
|
|
|
656
832
|
if (sub === 'finish') return runFinish(rest);
|
|
657
833
|
if (sub === 'resume') return runResume(rest);
|
|
658
834
|
if (sub === 'confirm') return runConfirm(rest);
|
|
659
|
-
|
|
835
|
+
if (sub === 'abandon') return runAbandon(rest);
|
|
836
|
+
warn('Usage: empirical setup <list|run|answer|finish|resume|confirm|abandon> ...');
|
|
660
837
|
process.exit(1);
|
|
661
838
|
}
|
|
662
839
|
|
|
663
840
|
module.exports = {
|
|
664
|
-
refundLine, runSetupCommand, formatQuestionLines, formatDraftLines, sanitizeForTerminal,
|
|
841
|
+
refundLine, cliConfirmWording, runSetupCommand, runWorkspaceInterview, WORKSPACE_SETUP_ID, recoveryHints, formatQuestionLines, formatDraftLines, sanitizeForTerminal,
|
|
665
842
|
offerVoicePluginInstall, VOICE_PLUGIN_COMMANDS, VOICE_STANDING_INSTRUCTION
|
|
666
843
|
};
|
package/lib/workspaceCommands.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { log, ask, uiSuccess } = require('./ui');
|
|
3
|
+
const { log, warn, ask, askYesNo, uiSuccess } = require('./ui');
|
|
4
4
|
const { httpJson } = require('./http');
|
|
5
5
|
const { getEnv, parseFlag, parseOption } = require('./env');
|
|
6
6
|
const { ensureHeadlessToken, resolveAccessToken, buildApiAuthHeaders } = require('./auth');
|
|
7
7
|
const { readStoredOAuthClient } = require('./oauthClient');
|
|
8
8
|
const { deriveBaseUrlFromMcpUrl } = require('./constants');
|
|
9
9
|
const { withCliAnalyticsContext } = require('./memoryCommands');
|
|
10
|
+
const { runWorkspaceInterview, sanitizeForTerminal } = require('./setupCommands');
|
|
11
|
+
const { isInteractiveTerminal } = require('./empiricalTokensFormat');
|
|
10
12
|
const {
|
|
11
13
|
writeSelectedWorkspace,
|
|
12
14
|
clearSelectedWorkspace,
|
|
@@ -18,9 +20,38 @@ const {
|
|
|
18
20
|
WORKSPACE_ENV_VAR
|
|
19
21
|
} = require('./workspaceContext');
|
|
20
22
|
|
|
23
|
+
const WORKSPACE_CREATE_INTERVIEW_USAGE = 'Usage: empirical workspace create --interview [--answers <file.json>] [--accept-cost]. The interview asks what the workspace is for, so it takes no name.';
|
|
24
|
+
const WORKSPACE_SHAPE_USAGE = 'Usage: empirical workspace shape [--workspace-id <id>] [--answers <file.json>] [--accept-cost]. Pass --workspace-id, or at a terminal bind this directory to the workspace first with `empirical workspace use <id>` and the command will ask you to confirm it.';
|
|
25
|
+
const WORKSPACE_SHAPE_EXPLICIT_ID = 'A re-shape is charged, so a script (--answers or --accept-cost) or a run with no terminal must name the workspace itself. Pass --workspace-id <id>; the workspace this directory is bound to is not used here.';
|
|
26
|
+
|
|
27
|
+
// Which workspace `workspace shape` re-shapes. An explicit --workspace-id is used as given. The ambient
|
|
28
|
+
// binding (env, project or global) is a convenience for a person at a terminal ONLY: it is shown by NAME
|
|
29
|
+
// and source and needs a yes that names the workspace, and it is never used by a script or with
|
|
30
|
+
// --accept-cost, so a stale binding cannot aim a paid re-shape at the wrong workspace unseen.
|
|
31
|
+
// Everything said here goes to stderr (warn), so a scripted run keeps stdout for its result.
|
|
32
|
+
async function resolveShapeTarget({ requested, local, answersFile, acceptCost, interactive, fetchName, askYesNo: confirm, say = warn }) {
|
|
33
|
+
if (requested) {
|
|
34
|
+
say(`Re-shaping workspace ${sanitizeForTerminal(requested)}.`);
|
|
35
|
+
return requested;
|
|
36
|
+
}
|
|
37
|
+
if (!local || !local.workspaceId) throw new Error(WORKSPACE_SHAPE_USAGE);
|
|
38
|
+
if (answersFile || acceptCost || !interactive) throw new Error(WORKSPACE_SHAPE_EXPLICIT_ID);
|
|
39
|
+
let name = '';
|
|
40
|
+
try { name = String((await fetchName(local.workspaceId)) || ''); } catch (_error) { name = ''; }
|
|
41
|
+
// The name can come from an AI-generated purpose, and everything below reaches the terminal: sanitised.
|
|
42
|
+
name = sanitizeForTerminal(name || String(local.label || ''));
|
|
43
|
+
const id = sanitizeForTerminal(local.workspaceId);
|
|
44
|
+
const source = sanitizeForTerminal(describeSelectionSource(local).trim().replace(/^\(|\)$/g, '')) || 'a local binding';
|
|
45
|
+
const shown = name ? `"${name}" (${id})` : id;
|
|
46
|
+
say(`No --workspace-id was given. This directory is bound to ${shown}: ${source}.`);
|
|
47
|
+
const yes = await confirm(`Re-shape ${name ? `"${name}"` : `workspace ${id}`}? This starts an interview that is charged.`, false, process.stderr);
|
|
48
|
+
if (!yes) throw new Error('Nothing was started. Pass --workspace-id <id> to name the workspace.');
|
|
49
|
+
return local.workspaceId;
|
|
50
|
+
}
|
|
51
|
+
|
|
21
52
|
function isWorkspaceCommand(args) {
|
|
22
53
|
if (args[0] !== 'workspace') return false;
|
|
23
|
-
if (['list', 'current', 'select', 'use', 'unbind', 'create', 'delete'].includes(args[1])) return true;
|
|
54
|
+
if (['list', 'current', 'select', 'use', 'unbind', 'create', 'delete', 'shape'].includes(args[1])) return true;
|
|
24
55
|
return args[1] === 'invite' && ['create', 'list', 'revoke'].includes(args[2]);
|
|
25
56
|
}
|
|
26
57
|
|
|
@@ -40,7 +71,7 @@ function workspaceOptions(payload) {
|
|
|
40
71
|
}
|
|
41
72
|
|
|
42
73
|
function positionalArgs(args, startIndex) {
|
|
43
|
-
const valueOptions = new Set(['--description', '--role', '--expires-in-days', '--max-uses']);
|
|
74
|
+
const valueOptions = new Set(['--description', '--role', '--expires-in-days', '--max-uses', '--answers', '--workspace-id']);
|
|
44
75
|
const positional = [];
|
|
45
76
|
for (let index = startIndex; index < args.length; index += 1) {
|
|
46
77
|
if (valueOptions.has(args[index])) {
|
|
@@ -276,9 +307,41 @@ async function runWorkspaceCommand(args) {
|
|
|
276
307
|
return;
|
|
277
308
|
}
|
|
278
309
|
|
|
310
|
+
if (action === 'shape') {
|
|
311
|
+
// Re-shapes a team workspace you manage through the interview. resolveShapeTarget decides which: an
|
|
312
|
+
// explicit --workspace-id, or (at a terminal only, after a named yes) the bound workspace.
|
|
313
|
+
const requested = parseOption(args, '--workspace-id', '').trim();
|
|
314
|
+
const answersFile = parseOption(args, '--answers', '');
|
|
315
|
+
const acceptCost = parseFlag(args, '--accept-cost');
|
|
316
|
+
const targetWorkspaceId = await resolveShapeTarget({
|
|
317
|
+
requested,
|
|
318
|
+
local: requested ? null : resolveLocalWorkspaceSelection(),
|
|
319
|
+
answersFile,
|
|
320
|
+
acceptCost,
|
|
321
|
+
interactive: isInteractiveTerminal(),
|
|
322
|
+
fetchName: async (id) => {
|
|
323
|
+
const payload = await httpJson(teamBrainsUrl, 'GET', null, authHeaders);
|
|
324
|
+
const hit = getTeamBrains(payload).find((b) => b.workspaceId === id);
|
|
325
|
+
return hit ? hit.name : '';
|
|
326
|
+
},
|
|
327
|
+
askYesNo
|
|
328
|
+
});
|
|
329
|
+
return runWorkspaceInterview({ targetWorkspaceId, answersFile, acceptCost });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (action === 'create' && parseFlag(args, '--interview')) {
|
|
333
|
+
// Creating through the interview takes no name (it asks what the workspace is for), and it never
|
|
334
|
+
// re-shapes: with no --workspace-id it always makes a new workspace.
|
|
335
|
+
if (positionalArgs(args, 2).length || parseOption(args, '--workspace-id', '')) throw new Error(WORKSPACE_CREATE_INTERVIEW_USAGE);
|
|
336
|
+
return runWorkspaceInterview({
|
|
337
|
+
answersFile: parseOption(args, '--answers', ''),
|
|
338
|
+
acceptCost: parseFlag(args, '--accept-cost')
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
279
342
|
if (action === 'create') {
|
|
280
343
|
const name = positionalArgs(args, 2).join(' ').trim();
|
|
281
|
-
if (!name) throw new Error('Usage: empirical workspace create <name> [--description <text>]');
|
|
344
|
+
if (!name) throw new Error('Usage: empirical workspace create <name> [--description <text>] or empirical workspace create --interview');
|
|
282
345
|
const description = parseOption(args, '--description', '');
|
|
283
346
|
const created = await httpJson(
|
|
284
347
|
teamBrainsUrl,
|
|
@@ -394,5 +457,9 @@ module.exports = {
|
|
|
394
457
|
isWorkspaceCommand,
|
|
395
458
|
getWorkspaceCommandAuth,
|
|
396
459
|
resolveWorkspaceTarget,
|
|
397
|
-
runWorkspaceCommand
|
|
460
|
+
runWorkspaceCommand,
|
|
461
|
+
WORKSPACE_CREATE_INTERVIEW_USAGE,
|
|
462
|
+
WORKSPACE_SHAPE_USAGE,
|
|
463
|
+
WORKSPACE_SHAPE_EXPLICIT_ID,
|
|
464
|
+
resolveShapeTarget
|
|
398
465
|
};
|