@bli-cockpit/cli 0.2.53 → 0.2.55
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/dist/adapters/attribution-core-fallbacks.js +247 -0
- package/dist/adapters/attribution-core-paths.js +182 -0
- package/dist/adapters/attribution-core-score.js +159 -0
- package/dist/adapters/attribution-core-types.js +13 -0
- package/dist/adapters/attribution-core.js +13 -565
- package/dist/adapters/claude-attribution-discovery.js +186 -0
- package/dist/adapters/claude-attribution-score.js +204 -0
- package/dist/adapters/claude-attribution-signals.js +180 -0
- package/dist/adapters/claude-attribution-types.js +25 -0
- package/dist/adapters/claude-attribution.js +14 -569
- package/dist/commands/doctor-access.js +129 -0
- package/dist/commands/doctor-pipeline.js +326 -0
- package/dist/commands/doctor-registration.js +105 -0
- package/dist/commands/doctor-report.js +111 -0
- package/dist/commands/doctor-update.js +120 -0
- package/dist/commands/doctor.js +8 -753
- package/dist/commands/heartbeat.js +8 -0
- package/dist/commands/jarvis-contracts.js +8 -0
- package/dist/commands/jarvis-render.js +413 -0
- package/dist/commands/jarvis-turn.js +305 -0
- package/dist/commands/jarvis.js +23 -698
- package/dist/commands/local-args-collector-setup.js +250 -0
- package/dist/commands/local-args-collector-status.js +227 -0
- package/dist/commands/local-args-collector-work.js +175 -0
- package/dist/commands/local-args-collector.js +19 -624
- package/dist/commands/local-args-tower-admin.js +456 -0
- package/dist/commands/local-args-tower-chat.js +194 -0
- package/dist/commands/local-args-tower-pages.js +314 -0
- package/dist/commands/local-args-tower.js +13 -880
- package/dist/commands/local-help.js +10 -2
- package/dist/commands/onboard-completion.js +136 -0
- package/dist/commands/onboard-flows.js +165 -0
- package/dist/commands/onboard-setup.js +102 -0
- package/dist/commands/onboard.js +5 -392
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-counters.js +55 -0
- package/dist/commands/session-sync-health.js +8 -1
- package/dist/commands/session-sync-plan.js +47 -7
- package/dist/commands/session-sync-scan.js +4 -4
- package/dist/commands/session-sync.js +6 -0
- package/dist/commands/settings-render.js +27 -0
- package/dist/commands/sync-followups.js +5 -1
- package/dist/commands/sync.js +5 -1
- package/dist/commands/team-device-reasons.js +16 -0
- package/dist/commands/team.js +87 -7
- package/dist/evidence-upload-client.js +14 -763
- package/dist/evidence-upload-object.js +181 -0
- package/dist/evidence-upload-plan.js +233 -0
- package/dist/evidence-upload-terminal.js +309 -0
- package/dist/evidence-upload-transport.js +104 -0
- package/dist/spool/local-spool-io.js +122 -0
- package/dist/spool/local-spool-mutations.js +174 -0
- package/dist/spool/local-spool-parse.js +143 -0
- package/dist/spool/local-spool-types.js +22 -0
- package/dist/spool/local-spool.js +20 -426
- package/dist/upload-evidence-delivery-offer.js +144 -0
- package/dist/upload-evidence-delivery-reconcile.js +134 -0
- package/dist/upload-evidence-delivery-summary.js +205 -0
- package/dist/upload-evidence-delivery.js +12 -482
- package/package.json +3 -3
package/dist/commands/jarvis.js
CHANGED
|
@@ -4,76 +4,31 @@
|
|
|
4
4
|
* This file owns terminal input and output only. Identity comes from the
|
|
5
5
|
* existing paired device session, and every turn is executed by the dashboard
|
|
6
6
|
* through the same JARVIS runtime used by web chat and Slack.
|
|
7
|
-
*/
|
|
8
|
-
import { colorEnabled, dim, isInteractiveStdin, readLine, readPipedText, writeFragment, writeLine, } from "./cli-io.js";
|
|
9
|
-
import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
|
|
10
|
-
import { rememberTurnTrace, runJarvisTrace } from "./jarvis-trace.js";
|
|
11
|
-
import { loadPairedSession, towerFailureDetail, towerJsonRequest, towerRequest, } from "../tower-client.js";
|
|
12
|
-
import { readTowerTurn, streamFailureDetail, turnTimingFields, } from "../tower-stream.js";
|
|
13
|
-
/**
|
|
14
|
-
* A citation line's link, as the dashboard mints it (BLI-3570):
|
|
15
|
-
* `Source: BLI-1234 — Judge outage <https://linear.app/…>`. The words are the
|
|
16
|
-
* receipt; the link is not part of the sentence, so the terminal prints it
|
|
17
|
-
* underneath, dim, rather than dragging a 118-character URL through the answer.
|
|
18
|
-
*/
|
|
19
|
-
const CITATION_LINK = /^(\s*(?:Source|Receipt)s?:.*?)\s*<(https?:\/\/[^>\s]+)>\s*$/i;
|
|
20
|
-
/**
|
|
21
|
-
* The answer, one line at a time, so a receipt's link gets its own dim line.
|
|
22
|
-
* Every other line is printed exactly as it arrived — this never rewrites the
|
|
23
|
-
* words, only where the link sits.
|
|
24
|
-
*/
|
|
25
|
-
function writeReply(io, prefix, reply) {
|
|
26
|
-
const styled = colorEnabled(io);
|
|
27
|
-
const lines = reply.split("\n");
|
|
28
|
-
lines.forEach((line, index) => {
|
|
29
|
-
const head = index === 0 ? prefix : "";
|
|
30
|
-
const citation = line.match(CITATION_LINK);
|
|
31
|
-
if (!citation) {
|
|
32
|
-
writeLine(io.stdout, `${head}${line}`);
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
writeLine(io.stdout, `${head}${citation[1]}`);
|
|
36
|
-
writeLine(io.stdout, dim(` ${citation[2]}`, styled));
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* The same link rule, applied to text that CONTINUES a line already on screen
|
|
41
|
-
* (BLI-3517 + BLI-3570).
|
|
42
7
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* sentence the person is already reading, not a line of its own, and a regex
|
|
47
|
-
* anchored at `^` would be matching against a boundary that is not there.
|
|
48
|
-
* Every COMPLETE line after it gets the dim link treatment as usual.
|
|
8
|
+
* `runJarvis` below is the whole entry point and nothing else. Each
|
|
9
|
+
* responsibility lives in a named sibling, all still reachable only through
|
|
10
|
+
* this file (`./jarvis.js` is the address `local.ts` and the suites use):
|
|
49
11
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* The dashboard route caps a turn at 120s (`maxDuration = 120`). The client
|
|
72
|
-
* waits slightly longer so the server's own named failure wins the race
|
|
73
|
-
* whenever it manages to send one; past that, the terminal names the timeout
|
|
74
|
-
* itself rather than sitting there.
|
|
75
|
-
*/
|
|
76
|
-
const TURN_DEADLINE_MS = 125_000;
|
|
12
|
+
* - `jarvis-contracts.ts` — the parsed `cockpit jarvis` command type, and the
|
|
13
|
+
* loose reply shapes read off `/api/jarvis/cli`, `--threads` and
|
|
14
|
+
* `--history`.
|
|
15
|
+
* - `jarvis-turn.ts` — the turn engine: `sendOneTurn` (attach, request,
|
|
16
|
+
* stream, settle, print, log — the one send for both a one-shot invocation
|
|
17
|
+
* and the interactive loop below) and `readHistory` (`--threads` /
|
|
18
|
+
* `--thread <name> --history`, the same class of act with no model call).
|
|
19
|
+
* - `jarvis-render.ts` — everything around a turn that is not the network
|
|
20
|
+
* call: getting it ready to send (the question, the attachment multipart
|
|
21
|
+
* body), reporting what it did (the failure sentence, the fallback-model
|
|
22
|
+
* receipt, the latency fields), and drawing the answer and its "how I got
|
|
23
|
+
* there" trace in a terminal, live and settled — a terminal cannot
|
|
24
|
+
* unprint, which is why a reset or a correction says so on its own line
|
|
25
|
+
* instead of erasing anything.
|
|
26
|
+
*/
|
|
27
|
+
import { readLine, writeLine } from "./cli-io.js";
|
|
28
|
+
import { runJarvisTrace } from "./jarvis-trace.js";
|
|
29
|
+
import { loadPairedSession } from "../tower-client.js";
|
|
30
|
+
import { readHistory, sendOneTurn } from "./jarvis-turn.js";
|
|
31
|
+
import { resolveOneShotPrompt } from "./jarvis-render.js";
|
|
77
32
|
export async function runJarvis(command, io) {
|
|
78
33
|
// Milliseconds since this process started (`performance.now()` counts from
|
|
79
34
|
// `timeOrigin`), captured before this command does anything of its own.
|
|
@@ -150,634 +105,4 @@ export async function runJarvis(command, io) {
|
|
|
150
105
|
if (turn.reply)
|
|
151
106
|
previous = { answer: turn.reply, question: prompt };
|
|
152
107
|
}
|
|
153
|
-
}
|
|
154
|
-
/**
|
|
155
|
-
* `--threads` and `--thread <name> --history`.
|
|
156
|
-
*
|
|
157
|
-
* The whole conversation lives on the server; nothing is cached locally, so a
|
|
158
|
-
* person who moves between machines sees the same history on both. The server
|
|
159
|
-
* scopes it to the device holder's own account — this command cannot ask for
|
|
160
|
-
* anybody else's, and there is no flag that would let it.
|
|
161
|
-
*/
|
|
162
|
-
async function readHistory(context, io) {
|
|
163
|
-
const { command } = context;
|
|
164
|
-
const params = new URLSearchParams();
|
|
165
|
-
if (command.history) {
|
|
166
|
-
params.set("thread", command.thread);
|
|
167
|
-
if (command.limit)
|
|
168
|
-
params.set("limit", String(command.limit));
|
|
169
|
-
}
|
|
170
|
-
else {
|
|
171
|
-
params.set("threads", "1");
|
|
172
|
-
}
|
|
173
|
-
const result = await towerJsonRequest({
|
|
174
|
-
dashboardUrl: context.dashboardUrl,
|
|
175
|
-
path: `/api/jarvis/cli?${params.toString()}`,
|
|
176
|
-
deviceToken: context.deviceToken,
|
|
177
|
-
fetch: io.fetch,
|
|
178
|
-
method: "GET",
|
|
179
|
-
label: command.history ? "jarvis:thread" : "jarvis:threads",
|
|
180
|
-
timeoutMs: 30_000,
|
|
181
|
-
log: (line) => writeLine(io.stderr, line),
|
|
182
|
-
});
|
|
183
|
-
if (!result.ok) {
|
|
184
|
-
writeFailure(command, io, result.reason, result.detail);
|
|
185
|
-
return 1;
|
|
186
|
-
}
|
|
187
|
-
const body = result.body;
|
|
188
|
-
if (body.ok === false) {
|
|
189
|
-
writeFailure(command, io, "history_unavailable", body.reply ?? "Tower had nothing to show.");
|
|
190
|
-
return 1;
|
|
191
|
-
}
|
|
192
|
-
if (command.json) {
|
|
193
|
-
writeLine(io.stdout, JSON.stringify(body));
|
|
194
|
-
}
|
|
195
|
-
else if (command.history) {
|
|
196
|
-
writeThreadHistory(io, body);
|
|
197
|
-
}
|
|
198
|
-
else {
|
|
199
|
-
writeThreadList(io, body.threads ?? []);
|
|
200
|
-
}
|
|
201
|
-
writeLine(io.stderr, `[jarvis cli] history read ${JSON.stringify({
|
|
202
|
-
mode: command.history ? "thread" : "threads",
|
|
203
|
-
thread_count: body.threads?.length ?? null,
|
|
204
|
-
message_count: body.messages?.length ?? null,
|
|
205
|
-
truncated: body.truncated ?? null,
|
|
206
|
-
})}`);
|
|
207
|
-
return 0;
|
|
208
|
-
}
|
|
209
|
-
function writeThreadList(io, threads) {
|
|
210
|
-
if (threads.length === 0) {
|
|
211
|
-
// A real answer, not a blank. Nothing here means nothing was ever asked
|
|
212
|
-
// from this terminal, which is worth saying rather than implying.
|
|
213
|
-
writeLine(io.stdout, "No terminal conversations yet. Ask something with `cockpit jarvis`.");
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
const styled = colorEnabled(io);
|
|
217
|
-
for (const thread of threads) {
|
|
218
|
-
const turns = thread.turnCount === 1 ? "1 turn" : `${thread.turnCount ?? 0} turns`;
|
|
219
|
-
writeLine(io.stdout, `${thread.name} ${dim(`${turns} · ${thread.lastAt ?? "unknown"}`, styled)}`);
|
|
220
|
-
if (thread.preview)
|
|
221
|
-
writeLine(io.stdout, dim(` ${thread.preview}`, styled));
|
|
222
|
-
}
|
|
223
|
-
writeLine(io.stdout, "");
|
|
224
|
-
writeLine(io.stdout, dim("Replay one with `cockpit jarvis --thread <name> --history`.", styled));
|
|
225
|
-
}
|
|
226
|
-
function writeThreadHistory(io, body) {
|
|
227
|
-
const messages = body.messages ?? [];
|
|
228
|
-
if (messages.length === 0) {
|
|
229
|
-
writeLine(io.stdout, `Nothing has been said in “${body.thread ?? "that thread"}” yet.`);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
if (body.truncated) {
|
|
233
|
-
writeLine(io.stdout, dim(`Showing the most recent ${messages.length}; there is more before this (\`--limit\`).`, colorEnabled(io)));
|
|
234
|
-
}
|
|
235
|
-
for (const message of messages) {
|
|
236
|
-
const speaker = message.role === "you" ? "you" : "jarvis";
|
|
237
|
-
writeLine(io.stdout, `${speaker}> ${message.text ?? ""}`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
async function resolveOneShotPrompt(command, io) {
|
|
241
|
-
if (command.prompt)
|
|
242
|
-
return validatePrompt(command.prompt);
|
|
243
|
-
if (isInteractiveStdin(io))
|
|
244
|
-
return null;
|
|
245
|
-
const piped = await readPipedText(io.stdin, { maxChars: 4000, overflowMessage: "JARVIS questions are limited to 4000 characters." });
|
|
246
|
-
return validatePrompt(piped);
|
|
247
|
-
}
|
|
248
|
-
async function sendOneTurn(context, prompt, io) {
|
|
249
|
-
const startedAt = Date.now();
|
|
250
|
-
// BLI-3414: an attached file is read and locally screened (exists,
|
|
251
|
-
// readable, a supported extension, under the byte ceiling) BEFORE any
|
|
252
|
-
// network call — a refusal here never reaches the dashboard and is
|
|
253
|
-
// terminal, same discipline as the panel's own attached-image gate.
|
|
254
|
-
let attachment = null;
|
|
255
|
-
if (context.command.imagePath) {
|
|
256
|
-
const read = await readAttachedImage(context.command.imagePath);
|
|
257
|
-
if (!read.ok) {
|
|
258
|
-
writeAttachmentRefusal(context.command, io, read.refusal, context.command.imagePath);
|
|
259
|
-
return { exitCode: 1, reply: null };
|
|
260
|
-
}
|
|
261
|
-
attachment = read;
|
|
262
|
-
}
|
|
263
|
-
// BLI-3457: streaming is the default. A dashboard that has not shipped the
|
|
264
|
-
// NDJSON half yet answers `application/json`, which the reader takes as a
|
|
265
|
-
// single final event and names `stream_not_available` — so the terminal
|
|
266
|
-
// works against both server versions with no flag.
|
|
267
|
-
const wantsStream = context.command.stream !== false;
|
|
268
|
-
const log = (line) => writeLine(io.stderr, line);
|
|
269
|
-
// BLI-3591: everything this turn did before the question left the machine.
|
|
270
|
-
// There is no self-update probe, no floor check and no settings fetch on
|
|
271
|
-
// this path — the ONLY awaits between the command starting and the POST are
|
|
272
|
-
// the paired-session read, the piped-prompt read and an attached image, and
|
|
273
|
-
// all three are named. If this number is ever large, the step that made it
|
|
274
|
-
// large is on the same line.
|
|
275
|
-
const preRequestMs = Date.now() - startedAt;
|
|
276
|
-
const requested = await towerRequest({
|
|
277
|
-
dashboardUrl: context.dashboardUrl,
|
|
278
|
-
path: "/api/jarvis/cli",
|
|
279
|
-
deviceToken: context.deviceToken,
|
|
280
|
-
fetch: io.fetch,
|
|
281
|
-
label: "jarvis",
|
|
282
|
-
timeoutMs: TURN_DEADLINE_MS,
|
|
283
|
-
headers: wantsStream ? { accept: "application/x-ndjson" } : {},
|
|
284
|
-
body: attachment
|
|
285
|
-
? buildAttachmentForm(context.command, prompt, attachment)
|
|
286
|
-
: {
|
|
287
|
-
question: prompt,
|
|
288
|
-
thread: context.command.thread,
|
|
289
|
-
subject: context.command.subject,
|
|
290
|
-
model: context.command.model,
|
|
291
|
-
// BLI-3484: which day's page this turn is about. Sent verbatim — the
|
|
292
|
-
// dashboard decides what counts as a date and whose day it is.
|
|
293
|
-
date: context.command.date,
|
|
294
|
-
// BLI-3567: the answer this session last printed, so a correction can
|
|
295
|
-
// rewrite the paragraph it contradicts. Absent on a one-shot turn and
|
|
296
|
-
// on the first turn of a session, which is how the dashboard knows
|
|
297
|
-
// there is nothing above to revise.
|
|
298
|
-
previousAnswer: context.previous?.answer,
|
|
299
|
-
previousQuestion: context.previous?.question,
|
|
300
|
-
},
|
|
301
|
-
log,
|
|
302
|
-
});
|
|
303
|
-
if (!requested.ok) {
|
|
304
|
-
writeFailure(context.command, io, requested.reason, towerFailureDetail(requested.reason, requested.detail));
|
|
305
|
-
return { exitCode: 1, reply: null };
|
|
306
|
-
}
|
|
307
|
-
// Live trace lines go to a person as they land, never to a `--json`
|
|
308
|
-
// consumer: that contract is exactly one object on stdout, so the events are
|
|
309
|
-
// buffered and folded into the final payload instead. The same rule governs
|
|
310
|
-
// the answer's own words (BLI-3517): `--json` stays exactly one object.
|
|
311
|
-
let liveTraceLines = 0;
|
|
312
|
-
const live = createLiveAnswer(context.command, io);
|
|
313
|
-
const turn = await readTowerTurn(requested.response, {
|
|
314
|
-
startedAt,
|
|
315
|
-
log,
|
|
316
|
-
onActivity: (event) => {
|
|
317
|
-
if (context.command.json)
|
|
318
|
-
return;
|
|
319
|
-
// A trace row must never land in the middle of a half-written sentence.
|
|
320
|
-
live.interrupt();
|
|
321
|
-
if (writeActivityLine(io, event))
|
|
322
|
-
liveTraceLines += 1;
|
|
323
|
-
},
|
|
324
|
-
onToken: (event) => live.token(event),
|
|
325
|
-
// BLI-3567: the paragraph being rewritten says so while it happens. The
|
|
326
|
-
// terminal cannot mark the paragraph itself — it printed it turns ago — so
|
|
327
|
-
// the pending state is one dim line, in the same words the browsers use.
|
|
328
|
-
onRevision: (event) => {
|
|
329
|
-
if (context.command.json)
|
|
330
|
-
return;
|
|
331
|
-
if (event.revision?.status !== "pending")
|
|
332
|
-
return;
|
|
333
|
-
live.interrupt();
|
|
334
|
-
writeLine(io.stdout, dim(` ${ANSWER_UPDATING_LINE}`, colorEnabled(io)));
|
|
335
|
-
},
|
|
336
|
-
onNote: (reason, detail) => {
|
|
337
|
-
log(`[jarvis cli] stream note ${JSON.stringify({
|
|
338
|
-
reason,
|
|
339
|
-
...(detail && reason !== "ndjson_line_unparseable" ? { detail } : {}),
|
|
340
|
-
})}`);
|
|
341
|
-
},
|
|
342
|
-
});
|
|
343
|
-
if (!turn.ok) {
|
|
344
|
-
live.abandon();
|
|
345
|
-
writeFailure(context.command, io, turn.reason, streamFailureDetail(turn.reason, turn.detail));
|
|
346
|
-
return { exitCode: 1, reply: null };
|
|
347
|
-
}
|
|
348
|
-
const body = turn.final;
|
|
349
|
-
const httpStatus = typeof turn.final.httpStatus === "number" ? turn.final.httpStatus : requested.response.status;
|
|
350
|
-
if (httpStatus >= 400 || !body.ok || !body.reply) {
|
|
351
|
-
live.abandon();
|
|
352
|
-
const reason = body.error ?? body.reply ?? `http_${httpStatus}`;
|
|
353
|
-
writeFailure(context.command, io, "turn_failed", reason);
|
|
354
|
-
return { exitCode: 1, reply: null };
|
|
355
|
-
}
|
|
356
|
-
// A streaming server may leave the settled trace out of the final event
|
|
357
|
-
// because it already sent every step live; the activity we collected is that
|
|
358
|
-
// same trace, so `--json` still gets one.
|
|
359
|
-
const trace = body.trace ?? activityToTrace(turn.activity);
|
|
360
|
-
// BLI-3560: bookmark this turn so `cockpit jarvis --trace last` can open its
|
|
361
|
-
// step tree. Written before the reply is printed for no reason other than
|
|
362
|
-
// keeping the failure — which is only ever a stderr line — above the answer
|
|
363
|
-
// rather than after it.
|
|
364
|
-
await rememberTurnTrace({ traceId: body.traceId ?? null, threadId: body.traceThread ?? null }, io, context.command.homeDir);
|
|
365
|
-
if (context.command.json) {
|
|
366
|
-
writeLine(io.stdout, JSON.stringify({
|
|
367
|
-
ok: true,
|
|
368
|
-
reply: body.reply,
|
|
369
|
-
thread: body.thread ?? context.command.thread,
|
|
370
|
-
model: body.model ?? null,
|
|
371
|
-
trace,
|
|
372
|
-
subject: body.subject ?? null,
|
|
373
|
-
// BLI-3582: the server's own split of the wait. `null` from a
|
|
374
|
-
// dashboard that does not measure it yet.
|
|
375
|
-
latency: latencyFields(body.latency),
|
|
376
|
-
// BLI-3591: this side's own half of the same wait, so a consumer can
|
|
377
|
-
// put the whole journey together without a stopwatch of its own.
|
|
378
|
-
clientLatency: {
|
|
379
|
-
bootMs: context.boot.bootMs,
|
|
380
|
-
sessionMs: context.boot.sessionMs,
|
|
381
|
-
promptMs: context.boot.promptMs,
|
|
382
|
-
preRequestMs,
|
|
383
|
-
respondedMs: requested.respondedMs,
|
|
384
|
-
firstByteMs: turn.timing.firstByteMs,
|
|
385
|
-
firstFrameMs: turn.timing.firstFrameMs,
|
|
386
|
-
firstTokenMs: turn.timing.firstTokenMs,
|
|
387
|
-
elapsedMs: Date.now() - startedAt,
|
|
388
|
-
},
|
|
389
|
-
...(body.traceId ? { traceId: body.traceId } : {}),
|
|
390
|
-
}));
|
|
391
|
-
}
|
|
392
|
-
else {
|
|
393
|
-
const subject = body.subject?.displayName ? ` (${body.subject.displayName})` : "";
|
|
394
|
-
// BLI-3517: when the answer streamed, `settle` prints only what the live
|
|
395
|
-
// words did not already say — and says so out loud when the grounding gate
|
|
396
|
-
// took some of them back. When nothing streamed it prints the whole reply
|
|
397
|
-
// through `writeReply`, which is byte-for-byte what this command printed
|
|
398
|
-
// before, BLI-3570's dim link line included.
|
|
399
|
-
live.settle(body.reply, subject, body.revised === true);
|
|
400
|
-
// BLI-3567: what the correction did to the answer above, printed under
|
|
401
|
-
// this turn's reply because that is the only place a terminal has.
|
|
402
|
-
if (body.revision)
|
|
403
|
-
writeRevision(context.command, io, body.revision);
|
|
404
|
-
// Only when nothing was drawn live — otherwise every tool would print twice.
|
|
405
|
-
if (liveTraceLines === 0)
|
|
406
|
-
writeTraceBlock(io, trace);
|
|
407
|
-
writeModelReceipt(io, body.model);
|
|
408
|
-
}
|
|
409
|
-
writeLine(io.stderr, `[jarvis cli] answered ${JSON.stringify({
|
|
410
|
-
prompt_length: prompt.length,
|
|
411
|
-
reply_length: body.reply.length,
|
|
412
|
-
trace_steps: trace?.length ?? 0,
|
|
413
|
-
trace_failed: trace?.filter((step) => step.status === "failed").length ?? 0,
|
|
414
|
-
model_requested: context.command.model ?? null,
|
|
415
|
-
elapsed_ms: Date.now() - startedAt,
|
|
416
|
-
thread: context.command.thread === "main" ? "default" : "named",
|
|
417
|
-
subject: context.command.subject ? "selected" : "caller",
|
|
418
|
-
image_attached: attachment !== null,
|
|
419
|
-
image_byte_size: attachment?.bytes.byteLength ?? null,
|
|
420
|
-
streamed: turn.streamed,
|
|
421
|
-
live_trace_lines: liveTraceLines,
|
|
422
|
-
// BLI-3517: whether the words arrived live, and whether the settled
|
|
423
|
-
// answer superseded them. `streamed_chars: 0` against a streaming
|
|
424
|
-
// dashboard means the answer landed all at once.
|
|
425
|
-
streamed_chars: turn.draft.length,
|
|
426
|
-
revised: body.revised === true,
|
|
427
|
-
// BLI-3567: whether this turn was told there was an answer above it, and
|
|
428
|
-
// what the correction did to it. `sent_previous_answer: false` is a
|
|
429
|
-
// one-shot turn; a status with no revision at all is a dashboard that
|
|
430
|
-
// predates the step.
|
|
431
|
-
sent_previous_answer: Boolean(context.previous),
|
|
432
|
-
revision: body.revision?.status ?? null,
|
|
433
|
-
revision_reason: body.revision?.reason ?? null,
|
|
434
|
-
// BLI-3582: `elapsed_ms` above is the whole wait as this side felt it;
|
|
435
|
-
// these three say which part of it was the dashboard's prep, which was
|
|
436
|
-
// the model's first token, and how long the socket stayed silent before
|
|
437
|
-
// the first byte. All null against a dashboard that does not send them.
|
|
438
|
-
...latencyLogFields(body.latency),
|
|
439
|
-
// BLI-3591: the terminal's own half, on the same line, so the gap
|
|
440
|
-
// between the dashboard's first token and the first character a person
|
|
441
|
-
// sees stops being a mystery with nobody's name on it. `boot_ms` and
|
|
442
|
-
// `session_ms` are process facts (identical on every turn of an
|
|
443
|
-
// interactive session); the rest are this turn's, measured from the
|
|
444
|
-
// moment the turn started.
|
|
445
|
-
...clientTimingFields(context.boot, {
|
|
446
|
-
preRequestMs,
|
|
447
|
-
respondedMs: requested.respondedMs,
|
|
448
|
-
timing: turn.timing,
|
|
449
|
-
}),
|
|
450
|
-
})}`);
|
|
451
|
-
return { exitCode: 0, reply: body.reply };
|
|
452
|
-
}
|
|
453
|
-
/**
|
|
454
|
-
* The terminal's own spans as log fields (BLI-3591).
|
|
455
|
-
*
|
|
456
|
-
* Deliberately snake_case beside the dashboard's three, and deliberately
|
|
457
|
-
* distinct names: `model_ttft_ms` is the dashboard's clock, `first_token_ms`
|
|
458
|
-
* is this one, and confusing them is how a latency line stops being
|
|
459
|
-
* answerable.
|
|
460
|
-
*/
|
|
461
|
-
function clientTimingFields(boot, turn) {
|
|
462
|
-
return {
|
|
463
|
-
boot_ms: boot.bootMs,
|
|
464
|
-
session_ms: boot.sessionMs,
|
|
465
|
-
prompt_ms: boot.promptMs,
|
|
466
|
-
pre_request_ms: turn.preRequestMs,
|
|
467
|
-
responded_ms: turn.respondedMs,
|
|
468
|
-
...turnTimingFields(turn.timing),
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
/**
|
|
472
|
-
* The answer as it is written, in a terminal (BLI-3517).
|
|
473
|
-
*
|
|
474
|
-
* A terminal cannot unprint. That single fact decides everything here:
|
|
475
|
-
*
|
|
476
|
-
* - The words stream out under the usual `jarvis> ` prefix, printed the moment
|
|
477
|
-
* they arrive rather than after the whole turn (a measured 4-16 s of nothing
|
|
478
|
-
* before this ticket).
|
|
479
|
-
* - A `reset` — the server taking back a draft the model wrote before deciding
|
|
480
|
-
* to call a tool — cannot erase what is on screen, so it SAYS so on its own
|
|
481
|
-
* line and the replacement follows. Never a retracted sentence left standing
|
|
482
|
-
* with nothing marking it.
|
|
483
|
-
* - The settled reply is the authority. The grounding gate runs on the whole
|
|
484
|
-
* text once the stream ends, so the answer can grow (citations, a fallback
|
|
485
|
-
* notice) or change. A pure continuation is printed as the remainder; a
|
|
486
|
-
* genuine change is reprinted whole under a line saying it was revised.
|
|
487
|
-
* - `--json` never streams a fragment: that contract is exactly one object on
|
|
488
|
-
* stdout, and every method here is a no-op for it.
|
|
489
|
-
*/
|
|
490
|
-
function createLiveAnswer(command, io) {
|
|
491
|
-
const styled = colorEnabled(io);
|
|
492
|
-
const quiet = command.json === true;
|
|
493
|
-
let printed = "";
|
|
494
|
-
let opened = false;
|
|
495
|
-
const open = (subject) => {
|
|
496
|
-
if (opened)
|
|
497
|
-
return;
|
|
498
|
-
opened = true;
|
|
499
|
-
writeFragment(io.stdout, `jarvis${subject}> `);
|
|
500
|
-
};
|
|
501
|
-
return {
|
|
502
|
-
token(event) {
|
|
503
|
-
if (quiet)
|
|
504
|
-
return;
|
|
505
|
-
const text = event.text ?? "";
|
|
506
|
-
if (event.reset) {
|
|
507
|
-
if (printed) {
|
|
508
|
-
writeLine(io.stdout, "");
|
|
509
|
-
writeLine(io.stdout, dim(" — that draft was replaced —", styled));
|
|
510
|
-
opened = false;
|
|
511
|
-
}
|
|
512
|
-
printed = "";
|
|
513
|
-
}
|
|
514
|
-
if (!text)
|
|
515
|
-
return;
|
|
516
|
-
open("");
|
|
517
|
-
printed += text;
|
|
518
|
-
writeFragment(io.stdout, text);
|
|
519
|
-
},
|
|
520
|
-
interrupt() {
|
|
521
|
-
if (quiet || !opened)
|
|
522
|
-
return;
|
|
523
|
-
writeLine(io.stdout, "");
|
|
524
|
-
opened = false;
|
|
525
|
-
},
|
|
526
|
-
settle(reply, subject, revised) {
|
|
527
|
-
if (quiet)
|
|
528
|
-
return;
|
|
529
|
-
if (!printed) {
|
|
530
|
-
// Nothing streamed: a non-streaming dashboard, `--no-stream`, or a
|
|
531
|
-
// turn whose words never arrived. Exactly the pre-ticket line, dim
|
|
532
|
-
// citation link and all (BLI-3570).
|
|
533
|
-
writeReply(io, `jarvis${subject}> `, reply);
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
if (reply === printed) {
|
|
537
|
-
if (opened)
|
|
538
|
-
writeLine(io.stdout, "");
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
if (reply.startsWith(printed)) {
|
|
542
|
-
// The gate only ADDED — citations going back under the answer, a
|
|
543
|
-
// fallback notice. Print the tail and leave the words alone; the
|
|
544
|
-
// citation block is exactly the thing BLI-3570 puts its link under, and
|
|
545
|
-
// it is arriving here rather than in the stream.
|
|
546
|
-
if (!opened)
|
|
547
|
-
writeFragment(io.stdout, `jarvis${subject}> `);
|
|
548
|
-
writeReplyContinuation(io, reply.slice(printed.length));
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
if (opened)
|
|
552
|
-
writeLine(io.stdout, "");
|
|
553
|
-
writeLine(io.stdout, dim(revised ? ` — ${ANSWER_REVISED_LINE} —` : " — corrected —", styled));
|
|
554
|
-
writeReply(io, `jarvis${subject}> `, reply);
|
|
555
|
-
},
|
|
556
|
-
abandon() {
|
|
557
|
-
// The turn died with words already on screen. End the line so the
|
|
558
|
-
// failure sentence does not run on from a half-written answer.
|
|
559
|
-
if (!quiet && opened)
|
|
560
|
-
writeLine(io.stdout, "");
|
|
561
|
-
},
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* What the terminal says when the grounding gate changed an answer it had
|
|
566
|
-
* already printed. The browser's own mark says the same thing in one word
|
|
567
|
-
* (`ANSWER_REVISED_NOTE`, `apps/dashboard/src/lib/webchat/tool-trace.ts`);
|
|
568
|
-
* this package cannot import from the dashboard, so the sentence is written
|
|
569
|
-
* here in the same register rather than shared through a dependency that does
|
|
570
|
-
* not exist.
|
|
571
|
-
*/
|
|
572
|
-
const ANSWER_REVISED_LINE = "revised: I checked that against its sources and changed what they did not back up";
|
|
573
|
-
/**
|
|
574
|
-
* The three lines a correction's rewrite prints (BLI-3567).
|
|
575
|
-
*
|
|
576
|
-
* The browsers replace the paragraph where it stands. A terminal cannot —
|
|
577
|
-
* stdout is a river — so it says the same three things in sequence instead:
|
|
578
|
-
* that a rewrite is under way, what the paragraph now says, and that the
|
|
579
|
-
* printed one above it did not change. The last of those is the honest half:
|
|
580
|
-
* without it a person would have two versions on screen and no idea which one
|
|
581
|
-
* JARVIS believes.
|
|
582
|
-
*
|
|
583
|
-
* `ANSWER_UPDATING_LINE` is the terminal's copy of the browsers'
|
|
584
|
-
* `ANSWER_UPDATING_LABEL` (`apps/dashboard/src/lib/webchat/tool-trace.ts`),
|
|
585
|
-
* word for word. This package cannot import from the dashboard, so it is
|
|
586
|
-
* written out here rather than shared through a dependency that does not exist
|
|
587
|
-
* — the same arrangement `ANSWER_REVISED_LINE` above already has.
|
|
588
|
-
*/
|
|
589
|
-
const ANSWER_UPDATING_LINE = "(updating...)";
|
|
590
|
-
const ANSWER_CORRECTED_LINE = "revised after your correction";
|
|
591
|
-
const ANSWER_CORRECTED_NOTE_LINE = "The paragraph printed above is unchanged — a terminal cannot rewrite what it already " +
|
|
592
|
-
"printed. This is what it says now.";
|
|
593
|
-
const ANSWER_REWRITE_UNVERIFIED_LINE = "Your correction is recorded. I could not verify a rewrite of that paragraph against its " +
|
|
594
|
-
"sources, so it stays as it was.";
|
|
595
|
-
/**
|
|
596
|
-
* Print what a correction did to the answer above, once the turn has settled.
|
|
597
|
-
*
|
|
598
|
-
* `--json` prints nothing: that contract is exactly one object on stdout, and
|
|
599
|
-
* the revision rides it as a field. Every branch either prints or is a
|
|
600
|
-
* deliberate no-op with a stderr line behind it in `sendOneTurn`.
|
|
601
|
-
*/
|
|
602
|
-
function writeRevision(command, io, revision) {
|
|
603
|
-
if (command.json)
|
|
604
|
-
return;
|
|
605
|
-
const styled = colorEnabled(io);
|
|
606
|
-
if (revision.status === "unverified") {
|
|
607
|
-
writeLine(io.stdout, "");
|
|
608
|
-
writeLine(io.stdout, dim(` — ${ANSWER_REWRITE_UNVERIFIED_LINE}`, styled));
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
if (revision.status !== "revised")
|
|
612
|
-
return;
|
|
613
|
-
const paragraphs = Array.isArray(revision.paragraphs) ? revision.paragraphs : [];
|
|
614
|
-
const written = paragraphs.filter((one) => typeof one === "object" && one !== null && typeof one.text === "string");
|
|
615
|
-
if (written.length === 0)
|
|
616
|
-
return;
|
|
617
|
-
writeLine(io.stdout, "");
|
|
618
|
-
writeLine(io.stdout, dim(` — ${ANSWER_CORRECTED_LINE} —`, styled));
|
|
619
|
-
for (const paragraph of written)
|
|
620
|
-
writeLine(io.stdout, paragraph.text);
|
|
621
|
-
writeLine(io.stdout, dim(` ${ANSWER_CORRECTED_NOTE_LINE}`, styled));
|
|
622
|
-
}
|
|
623
|
-
/**
|
|
624
|
-
* The multipart body `/api/jarvis/cli` reads when a file is attached
|
|
625
|
-
* (BLI-3414) — same field names the request handler parses, mirroring the
|
|
626
|
-
* JSON body's fields plus one `image` file field.
|
|
627
|
-
*/
|
|
628
|
-
function buildAttachmentForm(command, prompt, attachment) {
|
|
629
|
-
const form = new FormData();
|
|
630
|
-
form.set("question", prompt);
|
|
631
|
-
form.set("thread", command.thread);
|
|
632
|
-
if (command.subject)
|
|
633
|
-
form.set("subject", command.subject);
|
|
634
|
-
if (command.model)
|
|
635
|
-
form.set("model", command.model);
|
|
636
|
-
if (command.date)
|
|
637
|
-
form.set("date", command.date);
|
|
638
|
-
form.set("image", new File([attachment.bytes], attachment.fileName, { type: attachment.mimeType }));
|
|
639
|
-
return form;
|
|
640
|
-
}
|
|
641
|
-
/** A refusal caught locally, before any request went out — never a stack trace. */
|
|
642
|
-
function writeAttachmentRefusal(command, io, refusal, filePath) {
|
|
643
|
-
const message = attachedFileRefusalSentence(refusal, filePath);
|
|
644
|
-
if (command.json) {
|
|
645
|
-
writeLine(io.stdout, JSON.stringify({ ok: false, error: refusal, detail: message }));
|
|
646
|
-
}
|
|
647
|
-
else {
|
|
648
|
-
writeLine(io.stderr, `JARVIS could not attach that file: ${message}`);
|
|
649
|
-
}
|
|
650
|
-
writeLine(io.stderr, `[jarvis cli] attachment refused ${JSON.stringify({ reason: refusal })}`);
|
|
651
|
-
}
|
|
652
|
-
/**
|
|
653
|
-
* One dim line per tool the turn called (BLI-3381): what it was, how long it
|
|
654
|
-
* took, and — for a failure — the reason, matching what the web thinking
|
|
655
|
-
* trace shows (`describeToolTraceEvent` on the server side built these
|
|
656
|
-
* labels; this only renders them). Silent when no tool ran.
|
|
657
|
-
*/
|
|
658
|
-
function writeTraceBlock(io, trace) {
|
|
659
|
-
if (!trace || trace.length === 0)
|
|
660
|
-
return;
|
|
661
|
-
for (const step of trace)
|
|
662
|
-
writeTraceLine(io, step);
|
|
663
|
-
}
|
|
664
|
-
/** The one dim trace line, drawn identically live (BLI-3457) and after the fact. */
|
|
665
|
-
function writeTraceLine(io, step) {
|
|
666
|
-
const elapsed = typeof step.elapsedMs === "number" ? ` (${step.elapsedMs}ms)` : "";
|
|
667
|
-
const failure = step.status === "failed" ? ` — failed: ${step.detail ?? "no reason given"}` : "";
|
|
668
|
-
writeLine(io.stdout, dim(` ⏺ ${step.label}${elapsed}${failure}`, colorEnabled(io)));
|
|
669
|
-
}
|
|
670
|
-
/**
|
|
671
|
-
* Draws one streamed tool call, and says whether it drew anything.
|
|
672
|
-
*
|
|
673
|
-
* Only settled steps print: a `running` event is the same tool arriving a
|
|
674
|
-
* second time, and printing both would double every line in a terminal that
|
|
675
|
-
* cannot rewrite the one above it.
|
|
676
|
-
*/
|
|
677
|
-
function writeActivityLine(io, event) {
|
|
678
|
-
if (event.status !== "done" && event.status !== "failed")
|
|
679
|
-
return false;
|
|
680
|
-
const step = activityToStep(event);
|
|
681
|
-
if (!step)
|
|
682
|
-
return false;
|
|
683
|
-
writeTraceLine(io, step);
|
|
684
|
-
return true;
|
|
685
|
-
}
|
|
686
|
-
/** The settled steps of a streamed turn, in the shape `--json` already promises. */
|
|
687
|
-
function activityToTrace(activity) {
|
|
688
|
-
const steps = [];
|
|
689
|
-
for (const event of activity) {
|
|
690
|
-
if (event.status !== "done" && event.status !== "failed")
|
|
691
|
-
continue;
|
|
692
|
-
const step = activityToStep(event);
|
|
693
|
-
if (step)
|
|
694
|
-
steps.push(step);
|
|
695
|
-
}
|
|
696
|
-
return steps;
|
|
697
|
-
}
|
|
698
|
-
function activityToStep(event) {
|
|
699
|
-
if (event.status !== "done" && event.status !== "failed")
|
|
700
|
-
return null;
|
|
701
|
-
return {
|
|
702
|
-
tool: event.tool ?? "unknown_tool",
|
|
703
|
-
label: event.label ?? event.tool ?? "A tool ran",
|
|
704
|
-
status: event.status,
|
|
705
|
-
...(typeof event.elapsedMs === "number" ? { elapsedMs: event.elapsedMs } : {}),
|
|
706
|
-
...(event.detail ? { detail: event.detail } : {}),
|
|
707
|
-
};
|
|
708
|
-
}
|
|
709
|
-
/**
|
|
710
|
-
* One line, only when the answer did not come from what was requested — a
|
|
711
|
-
* fallback, or an explicit `--model` that landed on a different model
|
|
712
|
-
* (BLI-3381). Silent otherwise, matching the web panel's own fallback notice.
|
|
713
|
-
*/
|
|
714
|
-
function writeModelReceipt(io, model) {
|
|
715
|
-
if (!model)
|
|
716
|
-
return;
|
|
717
|
-
const requested = typeof model.requestedModel === "string" ? model.requestedModel : null;
|
|
718
|
-
const answered = namedModel(model.model);
|
|
719
|
-
const mismatched = requested !== null && answered !== null && requested !== answered;
|
|
720
|
-
if (!model.fallback && !mismatched)
|
|
721
|
-
return;
|
|
722
|
-
// BLI-3467: never the word "unavailable" — this side cannot know why, and
|
|
723
|
-
// on 2026-09-01 the real cause was a healthy provider refusing our own tool
|
|
724
|
-
// schema. Say what the receipt actually reports.
|
|
725
|
-
writeLine(io.stdout, `Model: ${answered ?? "an unknown model"} answered instead of ${requested ?? "the requested model"}`);
|
|
726
|
-
}
|
|
727
|
-
/** One millisecond span off the wire, or null for anything that is not a number. */
|
|
728
|
-
function latencyMs(value) {
|
|
729
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
730
|
-
}
|
|
731
|
-
/** The latency split for `--json`, always the same three keys so a consumer can rely on them. */
|
|
732
|
-
function latencyFields(latency) {
|
|
733
|
-
return {
|
|
734
|
-
wireMs: latencyMs(latency?.wireMs),
|
|
735
|
-
prepMs: latencyMs(latency?.prepMs),
|
|
736
|
-
modelTtftMs: latencyMs(latency?.modelTtftMs),
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
/** The same three spans as log fields, snake_case like everything else on that line. */
|
|
740
|
-
function latencyLogFields(latency) {
|
|
741
|
-
const split = latencyFields(latency);
|
|
742
|
-
return {
|
|
743
|
-
wire_ms: split.wireMs,
|
|
744
|
-
prep_ms: split.prepMs,
|
|
745
|
-
model_ttft_ms: split.modelTtftMs,
|
|
746
|
-
};
|
|
747
|
-
}
|
|
748
|
-
/**
|
|
749
|
-
* The receipt's `model` field, unless it is the server's own "I could not tell"
|
|
750
|
-
* sentinel (BLI-3582).
|
|
751
|
-
*
|
|
752
|
-
* `modelReceipt` on the dashboard writes the literal string `"unknown"` when
|
|
753
|
-
* the run reported no model, and this side used to read that as a model NAME —
|
|
754
|
-
* so a turn whose model was simply unnamed printed
|
|
755
|
-
* `Model: unknown answered instead of gpt-5.6-terra`, a fallback notice for a
|
|
756
|
-
* fallback that never happened. Every streamed turn hit it. The dashboard fix
|
|
757
|
-
* makes the streamed path name the model again; this one makes the sentinel
|
|
758
|
-
* unable to invent a fallback on any dashboard version, old or new.
|
|
759
|
-
*/
|
|
760
|
-
function namedModel(value) {
|
|
761
|
-
if (typeof value !== "string")
|
|
762
|
-
return null;
|
|
763
|
-
const trimmed = value.trim();
|
|
764
|
-
if (!trimmed || trimmed.toLowerCase() === "unknown")
|
|
765
|
-
return null;
|
|
766
|
-
return trimmed;
|
|
767
|
-
}
|
|
768
|
-
function writeFailure(command, io, reason, detail) {
|
|
769
|
-
if (command.json) {
|
|
770
|
-
writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
|
771
|
-
return;
|
|
772
|
-
}
|
|
773
|
-
writeLine(io.stderr, `JARVIS could not answer: ${detail}`);
|
|
774
|
-
}
|
|
775
|
-
function validatePrompt(raw) {
|
|
776
|
-
const prompt = raw.trim();
|
|
777
|
-
if (!prompt)
|
|
778
|
-
throw new Error("JARVIS needs a non-empty question.");
|
|
779
|
-
if (prompt.length > 4000) {
|
|
780
|
-
throw new Error("JARVIS questions are limited to 4000 characters.");
|
|
781
|
-
}
|
|
782
|
-
return prompt;
|
|
783
108
|
}
|