@bli-cockpit/cli 0.2.48 → 0.2.50
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/raw-evidence-attribution-gaps.js +133 -0
- package/dist/adapters/raw-evidence-claude-reader.js +108 -0
- package/dist/adapters/raw-evidence-codex-reader.js +147 -0
- package/dist/adapters/raw-evidence-collection-state.js +199 -0
- package/dist/adapters/raw-evidence-facts.js +338 -0
- package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
- package/dist/adapters/raw-evidence-image-reader.js +107 -0
- package/dist/adapters/raw-evidence-sanitize.js +56 -0
- package/dist/adapters/raw-evidence-transcript-file.js +182 -0
- package/dist/adapters/raw-evidence.js +94 -1203
- package/dist/autostart-contract.js +79 -0
- package/dist/autostart-darwin-plist.js +265 -0
- package/dist/autostart-darwin.js +171 -0
- package/dist/autostart-windows-scripts.js +310 -0
- package/dist/autostart-windows-task-xml.js +260 -0
- package/dist/autostart-windows.js +237 -0
- package/dist/autostart-xml.js +23 -0
- package/dist/autostart.js +35 -1148
- package/dist/commands/agent-rules-command.js +55 -0
- package/dist/commands/agent-session-report.js +290 -0
- package/dist/commands/analyze.js +131 -0
- package/dist/commands/autostart-command.js +105 -0
- package/dist/commands/backfill-batches.js +34 -0
- package/dist/commands/backfill-candidates.js +54 -0
- package/dist/commands/backfill-checkpoint.js +101 -0
- package/dist/commands/backfill-command-line.js +70 -0
- package/dist/commands/backfill-evidence-outcomes.js +104 -0
- package/dist/commands/backfill-issues.js +265 -0
- package/dist/commands/backfill-output.js +75 -0
- package/dist/commands/backfill-plan.js +71 -0
- package/dist/commands/backfill-reasons.js +107 -0
- package/dist/commands/backfill-report.js +298 -0
- package/dist/commands/backfill-result.js +150 -0
- package/dist/commands/backfill-scan.js +274 -0
- package/dist/commands/backfill-scope.js +114 -0
- package/dist/commands/backfill-session-report.js +145 -0
- package/dist/commands/backfill-types.js +1 -0
- package/dist/commands/backfill-upload.js +212 -0
- package/dist/commands/backfill.js +58 -1705
- package/dist/commands/cli-io.js +13 -0
- package/dist/commands/doctor.js +57 -0
- package/dist/commands/jarvis-trace.js +184 -0
- package/dist/commands/jarvis.js +323 -7
- package/dist/commands/local-arg-values.js +169 -0
- package/dist/commands/local-args-collector.js +604 -0
- package/dist/commands/local-args-tower.js +891 -0
- package/dist/commands/local-args.js +10 -1549
- package/dist/commands/local-help.js +30 -5
- package/dist/commands/local.js +21 -1786
- package/dist/commands/login.js +53 -0
- package/dist/commands/logout.js +66 -0
- package/dist/commands/memory-install-claude.js +294 -0
- package/dist/commands/memory-install-codex.js +205 -0
- package/dist/commands/memory-install-contract.js +231 -0
- package/dist/commands/memory-install-files.js +63 -0
- package/dist/commands/memory-install-skills.js +121 -0
- package/dist/commands/memory-install-toml.js +265 -0
- package/dist/commands/memory-install.js +378 -0
- package/dist/commands/onboard-receipts.js +66 -0
- package/dist/commands/onboard-report.js +274 -0
- package/dist/commands/onboard.js +449 -0
- package/dist/commands/ops-render.js +36 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/serve.js +13 -0
- package/dist/commands/session-sync.js +513 -534
- package/dist/commands/settings-render.js +28 -0
- package/dist/commands/settings.js +66 -2
- package/dist/commands/start.js +47 -0
- package/dist/commands/sync-followups.js +308 -0
- package/dist/commands/sync.js +387 -0
- package/dist/local-state-attributed-target.js +75 -0
- package/dist/local-state-config.js +147 -0
- package/dist/local-state-files.js +59 -0
- package/dist/local-state-identity.js +73 -0
- package/dist/local-state-pairing.js +263 -0
- package/dist/local-state-paths.js +61 -0
- package/dist/local-state-session.js +68 -0
- package/dist/local-state-status.js +163 -0
- package/dist/local-state-work-context.js +190 -0
- package/dist/local-state.js +34 -848
- package/dist/tower-client.js +3 -2
- package/dist/tower-stream.js +76 -6
- package/package.json +2 -1
package/dist/commands/jarvis.js
CHANGED
|
@@ -5,10 +5,68 @@
|
|
|
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
7
|
*/
|
|
8
|
-
import { colorEnabled, dim, isInteractiveStdin, readLine, readPipedText, writeLine, } from "./cli-io.js";
|
|
8
|
+
import { colorEnabled, dim, isInteractiveStdin, readLine, readPipedText, writeFragment, writeLine, } from "./cli-io.js";
|
|
9
9
|
import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
|
|
10
|
+
import { rememberTurnTrace, runJarvisTrace } from "./jarvis-trace.js";
|
|
10
11
|
import { loadPairedSession, towerFailureDetail, towerJsonRequest, towerRequest, } from "../tower-client.js";
|
|
11
|
-
import { readTowerTurn, streamFailureDetail, } from "../tower-stream.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
|
+
*
|
|
43
|
+
* When the answer streamed, the citation block arrives after the last token,
|
|
44
|
+
* so `settle` has a tail to print rather than a whole reply. The first fragment
|
|
45
|
+
* is deliberately never tested against `CITATION_LINK`: it is the rest of a
|
|
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.
|
|
49
|
+
*
|
|
50
|
+
* A citation that was already streamed inline cannot be split — a terminal
|
|
51
|
+
* cannot unprint — and that is the honest limit of this, not a bug to chase.
|
|
52
|
+
*/
|
|
53
|
+
function writeReplyContinuation(io, tail) {
|
|
54
|
+
const styled = colorEnabled(io);
|
|
55
|
+
const [first = "", ...rest] = tail.split("\n");
|
|
56
|
+
writeFragment(io.stdout, first);
|
|
57
|
+
for (const line of rest) {
|
|
58
|
+
writeLine(io.stdout, "");
|
|
59
|
+
const citation = line.match(CITATION_LINK);
|
|
60
|
+
if (!citation) {
|
|
61
|
+
writeFragment(io.stdout, line);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
writeFragment(io.stdout, citation[1]);
|
|
65
|
+
writeLine(io.stdout, "");
|
|
66
|
+
writeFragment(io.stdout, dim(` ${citation[2]}`, styled));
|
|
67
|
+
}
|
|
68
|
+
writeLine(io.stdout, "");
|
|
69
|
+
}
|
|
12
70
|
/**
|
|
13
71
|
* The dashboard route caps a turn at 120s (`maxDuration = 120`). The client
|
|
14
72
|
* waits slightly longer so the server's own named failure wins the race
|
|
@@ -17,7 +75,12 @@ import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
|
|
|
17
75
|
*/
|
|
18
76
|
const TURN_DEADLINE_MS = 125_000;
|
|
19
77
|
export async function runJarvis(command, io) {
|
|
78
|
+
// Milliseconds since this process started (`performance.now()` counts from
|
|
79
|
+
// `timeOrigin`), captured before this command does anything of its own.
|
|
80
|
+
const bootMs = Math.round(performance.now());
|
|
81
|
+
const sessionStartedAt = Date.now();
|
|
20
82
|
const session = await loadPairedSession("jarvis", command.homeDir);
|
|
83
|
+
const sessionMs = Date.now() - sessionStartedAt;
|
|
21
84
|
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
22
85
|
// BLI-3458: reading back what was already said. No turn, no model, no
|
|
23
86
|
// conversation-ledger write — and, on the server, only ever THIS device
|
|
@@ -25,9 +88,32 @@ export async function runJarvis(command, io) {
|
|
|
25
88
|
if (command.threads || command.history) {
|
|
26
89
|
return readHistory({ command, dashboardUrl, deviceToken: session.device_token }, io);
|
|
27
90
|
}
|
|
91
|
+
// BLI-3560: reading back what a turn DID. Same class of act, same absence of
|
|
92
|
+
// a model call — this one asks the ops trace door rather than the ledger.
|
|
93
|
+
if (command.trace) {
|
|
94
|
+
return runJarvisTrace({
|
|
95
|
+
dashboardUrl,
|
|
96
|
+
deviceToken: session.device_token,
|
|
97
|
+
requested: command.trace,
|
|
98
|
+
json: command.json,
|
|
99
|
+
homeDir: command.homeDir,
|
|
100
|
+
}, io);
|
|
101
|
+
}
|
|
102
|
+
const promptStartedAt = Date.now();
|
|
28
103
|
const oneShotPrompt = await resolveOneShotPrompt(command, io);
|
|
29
104
|
if (oneShotPrompt !== null) {
|
|
30
|
-
return sendOneTurn({
|
|
105
|
+
return sendOneTurn({
|
|
106
|
+
command,
|
|
107
|
+
dashboardUrl,
|
|
108
|
+
deviceToken: session.device_token,
|
|
109
|
+
boot: {
|
|
110
|
+
bootMs,
|
|
111
|
+
sessionMs,
|
|
112
|
+
// Only meaningful when the question was piped; an argument costs
|
|
113
|
+
// nothing to read and reporting a stdin wait for it would be a lie.
|
|
114
|
+
promptMs: command.prompt ? null : Date.now() - promptStartedAt,
|
|
115
|
+
},
|
|
116
|
+
}, oneShotPrompt, io);
|
|
31
117
|
}
|
|
32
118
|
if (command.json) {
|
|
33
119
|
throw new Error("cockpit jarvis --json needs --prompt, positional text, or piped stdin.");
|
|
@@ -39,7 +125,14 @@ export async function runJarvis(command, io) {
|
|
|
39
125
|
continue;
|
|
40
126
|
if (prompt === "/exit" || prompt === "/quit")
|
|
41
127
|
return 0;
|
|
42
|
-
const exitCode = await sendOneTurn({
|
|
128
|
+
const exitCode = await sendOneTurn({
|
|
129
|
+
command,
|
|
130
|
+
dashboardUrl,
|
|
131
|
+
deviceToken: session.device_token,
|
|
132
|
+
// The typing wait belongs to the person, not to the turn, so no prompt
|
|
133
|
+
// span is reported for an interactive turn.
|
|
134
|
+
boot: { bootMs, sessionMs, promptMs: null },
|
|
135
|
+
}, prompt, io);
|
|
43
136
|
if (exitCode !== 0)
|
|
44
137
|
return exitCode;
|
|
45
138
|
}
|
|
@@ -159,6 +252,13 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
159
252
|
// works against both server versions with no flag.
|
|
160
253
|
const wantsStream = context.command.stream !== false;
|
|
161
254
|
const log = (line) => writeLine(io.stderr, line);
|
|
255
|
+
// BLI-3591: everything this turn did before the question left the machine.
|
|
256
|
+
// There is no self-update probe, no floor check and no settings fetch on
|
|
257
|
+
// this path — the ONLY awaits between the command starting and the POST are
|
|
258
|
+
// the paired-session read, the piped-prompt read and an attached image, and
|
|
259
|
+
// all three are named. If this number is ever large, the step that made it
|
|
260
|
+
// large is on the same line.
|
|
261
|
+
const preRequestMs = Date.now() - startedAt;
|
|
162
262
|
const requested = await towerRequest({
|
|
163
263
|
dashboardUrl: context.dashboardUrl,
|
|
164
264
|
path: "/api/jarvis/cli",
|
|
@@ -186,17 +286,22 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
186
286
|
}
|
|
187
287
|
// Live trace lines go to a person as they land, never to a `--json`
|
|
188
288
|
// consumer: that contract is exactly one object on stdout, so the events are
|
|
189
|
-
// buffered and folded into the final payload instead.
|
|
289
|
+
// buffered and folded into the final payload instead. The same rule governs
|
|
290
|
+
// the answer's own words (BLI-3517): `--json` stays exactly one object.
|
|
190
291
|
let liveTraceLines = 0;
|
|
292
|
+
const live = createLiveAnswer(context.command, io);
|
|
191
293
|
const turn = await readTowerTurn(requested.response, {
|
|
192
294
|
startedAt,
|
|
193
295
|
log,
|
|
194
296
|
onActivity: (event) => {
|
|
195
297
|
if (context.command.json)
|
|
196
298
|
return;
|
|
299
|
+
// A trace row must never land in the middle of a half-written sentence.
|
|
300
|
+
live.interrupt();
|
|
197
301
|
if (writeActivityLine(io, event))
|
|
198
302
|
liveTraceLines += 1;
|
|
199
303
|
},
|
|
304
|
+
onToken: (event) => live.token(event),
|
|
200
305
|
onNote: (reason, detail) => {
|
|
201
306
|
log(`[jarvis cli] stream note ${JSON.stringify({
|
|
202
307
|
reason,
|
|
@@ -205,12 +310,14 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
205
310
|
},
|
|
206
311
|
});
|
|
207
312
|
if (!turn.ok) {
|
|
313
|
+
live.abandon();
|
|
208
314
|
writeFailure(context.command, io, turn.reason, streamFailureDetail(turn.reason, turn.detail));
|
|
209
315
|
return 1;
|
|
210
316
|
}
|
|
211
317
|
const body = turn.final;
|
|
212
318
|
const httpStatus = typeof turn.final.httpStatus === "number" ? turn.final.httpStatus : requested.response.status;
|
|
213
319
|
if (httpStatus >= 400 || !body.ok || !body.reply) {
|
|
320
|
+
live.abandon();
|
|
214
321
|
const reason = body.error ?? body.reply ?? `http_${httpStatus}`;
|
|
215
322
|
writeFailure(context.command, io, "turn_failed", reason);
|
|
216
323
|
return 1;
|
|
@@ -219,6 +326,11 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
219
326
|
// because it already sent every step live; the activity we collected is that
|
|
220
327
|
// same trace, so `--json` still gets one.
|
|
221
328
|
const trace = body.trace ?? activityToTrace(turn.activity);
|
|
329
|
+
// BLI-3560: bookmark this turn so `cockpit jarvis --trace last` can open its
|
|
330
|
+
// step tree. Written before the reply is printed for no reason other than
|
|
331
|
+
// keeping the failure — which is only ever a stderr line — above the answer
|
|
332
|
+
// rather than after it.
|
|
333
|
+
await rememberTurnTrace({ traceId: body.traceId ?? null, threadId: body.traceThread ?? null }, io, context.command.homeDir);
|
|
222
334
|
if (context.command.json) {
|
|
223
335
|
writeLine(io.stdout, JSON.stringify({
|
|
224
336
|
ok: true,
|
|
@@ -227,11 +339,33 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
227
339
|
model: body.model ?? null,
|
|
228
340
|
trace,
|
|
229
341
|
subject: body.subject ?? null,
|
|
342
|
+
// BLI-3582: the server's own split of the wait. `null` from a
|
|
343
|
+
// dashboard that does not measure it yet.
|
|
344
|
+
latency: latencyFields(body.latency),
|
|
345
|
+
// BLI-3591: this side's own half of the same wait, so a consumer can
|
|
346
|
+
// put the whole journey together without a stopwatch of its own.
|
|
347
|
+
clientLatency: {
|
|
348
|
+
bootMs: context.boot.bootMs,
|
|
349
|
+
sessionMs: context.boot.sessionMs,
|
|
350
|
+
promptMs: context.boot.promptMs,
|
|
351
|
+
preRequestMs,
|
|
352
|
+
respondedMs: requested.respondedMs,
|
|
353
|
+
firstByteMs: turn.timing.firstByteMs,
|
|
354
|
+
firstFrameMs: turn.timing.firstFrameMs,
|
|
355
|
+
firstTokenMs: turn.timing.firstTokenMs,
|
|
356
|
+
elapsedMs: Date.now() - startedAt,
|
|
357
|
+
},
|
|
358
|
+
...(body.traceId ? { traceId: body.traceId } : {}),
|
|
230
359
|
}));
|
|
231
360
|
}
|
|
232
361
|
else {
|
|
233
362
|
const subject = body.subject?.displayName ? ` (${body.subject.displayName})` : "";
|
|
234
|
-
|
|
363
|
+
// BLI-3517: when the answer streamed, `settle` prints only what the live
|
|
364
|
+
// words did not already say — and says so out loud when the grounding gate
|
|
365
|
+
// took some of them back. When nothing streamed it prints the whole reply
|
|
366
|
+
// through `writeReply`, which is byte-for-byte what this command printed
|
|
367
|
+
// before, BLI-3570's dim link line included.
|
|
368
|
+
live.settle(body.reply, subject, body.revised === true);
|
|
235
369
|
// Only when nothing was drawn live — otherwise every tool would print twice.
|
|
236
370
|
if (liveTraceLines === 0)
|
|
237
371
|
writeTraceBlock(io, trace);
|
|
@@ -250,9 +384,150 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
250
384
|
image_byte_size: attachment?.bytes.byteLength ?? null,
|
|
251
385
|
streamed: turn.streamed,
|
|
252
386
|
live_trace_lines: liveTraceLines,
|
|
387
|
+
// BLI-3517: whether the words arrived live, and whether the settled
|
|
388
|
+
// answer superseded them. `streamed_chars: 0` against a streaming
|
|
389
|
+
// dashboard means the answer landed all at once.
|
|
390
|
+
streamed_chars: turn.draft.length,
|
|
391
|
+
revised: body.revised === true,
|
|
392
|
+
// BLI-3582: `elapsed_ms` above is the whole wait as this side felt it;
|
|
393
|
+
// these three say which part of it was the dashboard's prep, which was
|
|
394
|
+
// the model's first token, and how long the socket stayed silent before
|
|
395
|
+
// the first byte. All null against a dashboard that does not send them.
|
|
396
|
+
...latencyLogFields(body.latency),
|
|
397
|
+
// BLI-3591: the terminal's own half, on the same line, so the gap
|
|
398
|
+
// between the dashboard's first token and the first character a person
|
|
399
|
+
// sees stops being a mystery with nobody's name on it. `boot_ms` and
|
|
400
|
+
// `session_ms` are process facts (identical on every turn of an
|
|
401
|
+
// interactive session); the rest are this turn's, measured from the
|
|
402
|
+
// moment the turn started.
|
|
403
|
+
...clientTimingFields(context.boot, {
|
|
404
|
+
preRequestMs,
|
|
405
|
+
respondedMs: requested.respondedMs,
|
|
406
|
+
timing: turn.timing,
|
|
407
|
+
}),
|
|
253
408
|
})}`);
|
|
254
409
|
return 0;
|
|
255
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* The terminal's own spans as log fields (BLI-3591).
|
|
413
|
+
*
|
|
414
|
+
* Deliberately snake_case beside the dashboard's three, and deliberately
|
|
415
|
+
* distinct names: `model_ttft_ms` is the dashboard's clock, `first_token_ms`
|
|
416
|
+
* is this one, and confusing them is how a latency line stops being
|
|
417
|
+
* answerable.
|
|
418
|
+
*/
|
|
419
|
+
function clientTimingFields(boot, turn) {
|
|
420
|
+
return {
|
|
421
|
+
boot_ms: boot.bootMs,
|
|
422
|
+
session_ms: boot.sessionMs,
|
|
423
|
+
prompt_ms: boot.promptMs,
|
|
424
|
+
pre_request_ms: turn.preRequestMs,
|
|
425
|
+
responded_ms: turn.respondedMs,
|
|
426
|
+
...turnTimingFields(turn.timing),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The answer as it is written, in a terminal (BLI-3517).
|
|
431
|
+
*
|
|
432
|
+
* A terminal cannot unprint. That single fact decides everything here:
|
|
433
|
+
*
|
|
434
|
+
* - The words stream out under the usual `jarvis> ` prefix, printed the moment
|
|
435
|
+
* they arrive rather than after the whole turn (a measured 4-16 s of nothing
|
|
436
|
+
* before this ticket).
|
|
437
|
+
* - A `reset` — the server taking back a draft the model wrote before deciding
|
|
438
|
+
* to call a tool — cannot erase what is on screen, so it SAYS so on its own
|
|
439
|
+
* line and the replacement follows. Never a retracted sentence left standing
|
|
440
|
+
* with nothing marking it.
|
|
441
|
+
* - The settled reply is the authority. The grounding gate runs on the whole
|
|
442
|
+
* text once the stream ends, so the answer can grow (citations, a fallback
|
|
443
|
+
* notice) or change. A pure continuation is printed as the remainder; a
|
|
444
|
+
* genuine change is reprinted whole under a line saying it was revised.
|
|
445
|
+
* - `--json` never streams a fragment: that contract is exactly one object on
|
|
446
|
+
* stdout, and every method here is a no-op for it.
|
|
447
|
+
*/
|
|
448
|
+
function createLiveAnswer(command, io) {
|
|
449
|
+
const styled = colorEnabled(io);
|
|
450
|
+
const quiet = command.json === true;
|
|
451
|
+
let printed = "";
|
|
452
|
+
let opened = false;
|
|
453
|
+
const open = (subject) => {
|
|
454
|
+
if (opened)
|
|
455
|
+
return;
|
|
456
|
+
opened = true;
|
|
457
|
+
writeFragment(io.stdout, `jarvis${subject}> `);
|
|
458
|
+
};
|
|
459
|
+
return {
|
|
460
|
+
token(event) {
|
|
461
|
+
if (quiet)
|
|
462
|
+
return;
|
|
463
|
+
const text = event.text ?? "";
|
|
464
|
+
if (event.reset) {
|
|
465
|
+
if (printed) {
|
|
466
|
+
writeLine(io.stdout, "");
|
|
467
|
+
writeLine(io.stdout, dim(" — that draft was replaced —", styled));
|
|
468
|
+
opened = false;
|
|
469
|
+
}
|
|
470
|
+
printed = "";
|
|
471
|
+
}
|
|
472
|
+
if (!text)
|
|
473
|
+
return;
|
|
474
|
+
open("");
|
|
475
|
+
printed += text;
|
|
476
|
+
writeFragment(io.stdout, text);
|
|
477
|
+
},
|
|
478
|
+
interrupt() {
|
|
479
|
+
if (quiet || !opened)
|
|
480
|
+
return;
|
|
481
|
+
writeLine(io.stdout, "");
|
|
482
|
+
opened = false;
|
|
483
|
+
},
|
|
484
|
+
settle(reply, subject, revised) {
|
|
485
|
+
if (quiet)
|
|
486
|
+
return;
|
|
487
|
+
if (!printed) {
|
|
488
|
+
// Nothing streamed: a non-streaming dashboard, `--no-stream`, or a
|
|
489
|
+
// turn whose words never arrived. Exactly the pre-ticket line, dim
|
|
490
|
+
// citation link and all (BLI-3570).
|
|
491
|
+
writeReply(io, `jarvis${subject}> `, reply);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (reply === printed) {
|
|
495
|
+
if (opened)
|
|
496
|
+
writeLine(io.stdout, "");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (reply.startsWith(printed)) {
|
|
500
|
+
// The gate only ADDED — citations going back under the answer, a
|
|
501
|
+
// fallback notice. Print the tail and leave the words alone; the
|
|
502
|
+
// citation block is exactly the thing BLI-3570 puts its link under, and
|
|
503
|
+
// it is arriving here rather than in the stream.
|
|
504
|
+
if (!opened)
|
|
505
|
+
writeFragment(io.stdout, `jarvis${subject}> `);
|
|
506
|
+
writeReplyContinuation(io, reply.slice(printed.length));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (opened)
|
|
510
|
+
writeLine(io.stdout, "");
|
|
511
|
+
writeLine(io.stdout, dim(revised ? ` — ${ANSWER_REVISED_LINE} —` : " — corrected —", styled));
|
|
512
|
+
writeReply(io, `jarvis${subject}> `, reply);
|
|
513
|
+
},
|
|
514
|
+
abandon() {
|
|
515
|
+
// The turn died with words already on screen. End the line so the
|
|
516
|
+
// failure sentence does not run on from a half-written answer.
|
|
517
|
+
if (!quiet && opened)
|
|
518
|
+
writeLine(io.stdout, "");
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* What the terminal says when the grounding gate changed an answer it had
|
|
524
|
+
* already printed. The browser's own mark says the same thing in one word
|
|
525
|
+
* (`ANSWER_REVISED_NOTE`, `apps/dashboard/src/lib/webchat/tool-trace.ts`);
|
|
526
|
+
* this package cannot import from the dashboard, so the sentence is written
|
|
527
|
+
* here in the same register rather than shared through a dependency that does
|
|
528
|
+
* not exist.
|
|
529
|
+
*/
|
|
530
|
+
const ANSWER_REVISED_LINE = "revised: I checked that against its sources and changed what they did not back up";
|
|
256
531
|
/**
|
|
257
532
|
* The multipart body `/api/jarvis/cli` reads when a file is attached
|
|
258
533
|
* (BLI-3414) — same field names the request handler parses, mirroring the
|
|
@@ -348,7 +623,7 @@ function writeModelReceipt(io, model) {
|
|
|
348
623
|
if (!model)
|
|
349
624
|
return;
|
|
350
625
|
const requested = typeof model.requestedModel === "string" ? model.requestedModel : null;
|
|
351
|
-
const answered =
|
|
626
|
+
const answered = namedModel(model.model);
|
|
352
627
|
const mismatched = requested !== null && answered !== null && requested !== answered;
|
|
353
628
|
if (!model.fallback && !mismatched)
|
|
354
629
|
return;
|
|
@@ -357,6 +632,47 @@ function writeModelReceipt(io, model) {
|
|
|
357
632
|
// schema. Say what the receipt actually reports.
|
|
358
633
|
writeLine(io.stdout, `Model: ${answered ?? "an unknown model"} answered instead of ${requested ?? "the requested model"}`);
|
|
359
634
|
}
|
|
635
|
+
/** One millisecond span off the wire, or null for anything that is not a number. */
|
|
636
|
+
function latencyMs(value) {
|
|
637
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
638
|
+
}
|
|
639
|
+
/** The latency split for `--json`, always the same three keys so a consumer can rely on them. */
|
|
640
|
+
function latencyFields(latency) {
|
|
641
|
+
return {
|
|
642
|
+
wireMs: latencyMs(latency?.wireMs),
|
|
643
|
+
prepMs: latencyMs(latency?.prepMs),
|
|
644
|
+
modelTtftMs: latencyMs(latency?.modelTtftMs),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/** The same three spans as log fields, snake_case like everything else on that line. */
|
|
648
|
+
function latencyLogFields(latency) {
|
|
649
|
+
const split = latencyFields(latency);
|
|
650
|
+
return {
|
|
651
|
+
wire_ms: split.wireMs,
|
|
652
|
+
prep_ms: split.prepMs,
|
|
653
|
+
model_ttft_ms: split.modelTtftMs,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* The receipt's `model` field, unless it is the server's own "I could not tell"
|
|
658
|
+
* sentinel (BLI-3582).
|
|
659
|
+
*
|
|
660
|
+
* `modelReceipt` on the dashboard writes the literal string `"unknown"` when
|
|
661
|
+
* the run reported no model, and this side used to read that as a model NAME —
|
|
662
|
+
* so a turn whose model was simply unnamed printed
|
|
663
|
+
* `Model: unknown answered instead of gpt-5.6-terra`, a fallback notice for a
|
|
664
|
+
* fallback that never happened. Every streamed turn hit it. The dashboard fix
|
|
665
|
+
* makes the streamed path name the model again; this one makes the sentinel
|
|
666
|
+
* unable to invent a fallback on any dashboard version, old or new.
|
|
667
|
+
*/
|
|
668
|
+
function namedModel(value) {
|
|
669
|
+
if (typeof value !== "string")
|
|
670
|
+
return null;
|
|
671
|
+
const trimmed = value.trim();
|
|
672
|
+
if (!trimmed || trimmed.toLowerCase() === "unknown")
|
|
673
|
+
return null;
|
|
674
|
+
return trimmed;
|
|
675
|
+
}
|
|
360
676
|
function writeFailure(command, io, reason, detail) {
|
|
361
677
|
if (command.json) {
|
|
362
678
|
writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value readers every subcommand's decision table is written in terms of:
|
|
3
|
+
* one pass over `argv` into named flags and positionals, then one small
|
|
4
|
+
* question per value — is it non-empty, is it a URL, is it an email, is it a
|
|
5
|
+
* positive integer, is it a value this schema accepts.
|
|
6
|
+
*
|
|
7
|
+
* Split out of commands/local-args.ts (BLI-3578) so each parser reads as its
|
|
8
|
+
* own decision table rather than as parsing mixed with coercion. Moved
|
|
9
|
+
* verbatim: no logic changed, and `normalizeUrl` is still re-exported from
|
|
10
|
+
* `./local-args.js` for the callers that had it there.
|
|
11
|
+
*
|
|
12
|
+
* `rejectServiceRoleLikeArgument` runs on every token, flag or positional, and
|
|
13
|
+
* is why a service-role credential cannot enter a collector command from the
|
|
14
|
+
* command line at all.
|
|
15
|
+
*/
|
|
16
|
+
const WORK_ROOT_FLAGS = ["--repo", "--workspace"];
|
|
17
|
+
export function parseNamedArgs(args, options) {
|
|
18
|
+
const allowed = new Set(options.allowedFlags);
|
|
19
|
+
const valueFlags = new Set(options.valueFlags);
|
|
20
|
+
const flags = new Map();
|
|
21
|
+
const flagValues = new Map();
|
|
22
|
+
const booleans = new Set();
|
|
23
|
+
const positionals = [];
|
|
24
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
25
|
+
const arg = args[index] ?? "";
|
|
26
|
+
rejectServiceRoleLikeArgument(arg);
|
|
27
|
+
if (!arg.startsWith("--")) {
|
|
28
|
+
positionals.push(arg);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const [flag, inlineValue] = arg.split("=", 2);
|
|
32
|
+
if (!allowed.has(flag))
|
|
33
|
+
throw new Error(`Unknown flag: ${flag}`);
|
|
34
|
+
if (valueFlags.has(flag)) {
|
|
35
|
+
const value = inlineValue ?? args[index + 1];
|
|
36
|
+
if (!value || value.startsWith("--")) {
|
|
37
|
+
throw new Error(`${flag} requires a value.`);
|
|
38
|
+
}
|
|
39
|
+
rejectServiceRoleLikeArgument(value);
|
|
40
|
+
flags.set(flag, value);
|
|
41
|
+
const existing = flagValues.get(flag) ?? [];
|
|
42
|
+
existing.push(value);
|
|
43
|
+
flagValues.set(flag, existing);
|
|
44
|
+
if (inlineValue === undefined)
|
|
45
|
+
index += 1;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
if (inlineValue !== undefined)
|
|
49
|
+
throw new Error(`${flag} does not accept a value.`);
|
|
50
|
+
booleans.add(flag);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { flags, flagValues, booleans, positionals };
|
|
54
|
+
}
|
|
55
|
+
export function workRootFlagValue(values) {
|
|
56
|
+
const provided = WORK_ROOT_FLAGS.filter((flag) => values.flags.has(flag));
|
|
57
|
+
if (provided.length === 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
const uniqueValues = new Set(provided.map((flag) => values.flags.get(flag)).filter(Boolean));
|
|
60
|
+
if (uniqueValues.size > 1) {
|
|
61
|
+
throw new Error("--repo and --workspace must point to the same path.");
|
|
62
|
+
}
|
|
63
|
+
return values.flags.get("--workspace") ?? values.flags.get("--repo");
|
|
64
|
+
}
|
|
65
|
+
export function workRootFlagValues(values) {
|
|
66
|
+
const roots = [];
|
|
67
|
+
for (const flag of WORK_ROOT_FLAGS) {
|
|
68
|
+
roots.push(...(values.flagValues.get(flag) ?? []));
|
|
69
|
+
}
|
|
70
|
+
return roots;
|
|
71
|
+
}
|
|
72
|
+
export function optionalNonEmptyList(values) {
|
|
73
|
+
const filtered = values
|
|
74
|
+
.map((value) => optionalNonEmpty(value))
|
|
75
|
+
.filter((value) => Boolean(value));
|
|
76
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
77
|
+
}
|
|
78
|
+
export function assertNoPositionals(positionals, command) {
|
|
79
|
+
if (positionals.length > 0) {
|
|
80
|
+
throw new Error(`${command} does not accept positional arguments.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function optionalNonEmpty(value) {
|
|
84
|
+
const trimmed = value?.trim();
|
|
85
|
+
return trimmed ? trimmed : undefined;
|
|
86
|
+
}
|
|
87
|
+
export function optionalUrl(value) {
|
|
88
|
+
return value === undefined ? undefined : normalizeUrl(value);
|
|
89
|
+
}
|
|
90
|
+
export function optionalEmail(value) {
|
|
91
|
+
const trimmed = value?.trim().toLowerCase();
|
|
92
|
+
if (!trimmed)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (!trimmed.includes("@")) {
|
|
95
|
+
throw new Error("--email must be a valid email address.");
|
|
96
|
+
}
|
|
97
|
+
return trimmed;
|
|
98
|
+
}
|
|
99
|
+
export function optionalPositiveInteger(value, flag) {
|
|
100
|
+
if (value === undefined)
|
|
101
|
+
return undefined;
|
|
102
|
+
const parsed = Number(value);
|
|
103
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
104
|
+
throw new Error(`${flag} must be a positive integer.`);
|
|
105
|
+
}
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
export function optionalConfidence(value, flag) {
|
|
109
|
+
if (value === undefined)
|
|
110
|
+
return undefined;
|
|
111
|
+
const parsed = Number(value);
|
|
112
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
|
|
113
|
+
throw new Error(`${flag} must be a number between 0 and 1.`);
|
|
114
|
+
}
|
|
115
|
+
return parsed;
|
|
116
|
+
}
|
|
117
|
+
export function optionalSchemaValue(schema, value, flag) {
|
|
118
|
+
const trimmed = optionalNonEmpty(value);
|
|
119
|
+
if (!trimmed)
|
|
120
|
+
return undefined;
|
|
121
|
+
const parsed = schema.safeParse(trimmed);
|
|
122
|
+
if (!parsed.success || parsed.data === undefined) {
|
|
123
|
+
throw new Error(`${flag} has an unsupported value.`);
|
|
124
|
+
}
|
|
125
|
+
return parsed.data;
|
|
126
|
+
}
|
|
127
|
+
export function normalizeUrl(value) {
|
|
128
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
129
|
+
if (!trimmed)
|
|
130
|
+
throw new Error("URL value cannot be empty.");
|
|
131
|
+
return trimmed;
|
|
132
|
+
}
|
|
133
|
+
function rejectServiceRoleLikeArgument(value) {
|
|
134
|
+
if (!looksLikeServiceRoleSecret(value))
|
|
135
|
+
return;
|
|
136
|
+
throw new Error("Service-role credentials are not accepted by local collector commands.");
|
|
137
|
+
}
|
|
138
|
+
function looksLikeServiceRoleSecret(value) {
|
|
139
|
+
if (serviceCredentialNamePattern().test(value))
|
|
140
|
+
return true;
|
|
141
|
+
const parts = value.split(".");
|
|
142
|
+
if (parts.length !== 3)
|
|
143
|
+
return false;
|
|
144
|
+
try {
|
|
145
|
+
const payload = Buffer.from(base64UrlToBase64(parts[1] ?? ""), "base64").toString("utf8");
|
|
146
|
+
return serviceCredentialPayloadPattern().test(payload);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Deliberately silent (BLI-3238), and it must stay silent: this is the
|
|
150
|
+
// "is this argument a service-role JWT?" test, so a value that will not
|
|
151
|
+
// decode is simply not one. Anything logged here would be a fragment of a
|
|
152
|
+
// credential.
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function serviceCredentialNamePattern() {
|
|
157
|
+
return new RegExp([
|
|
158
|
+
["SUPABASE", "SERVICE", "ROLE", "KEY"].join("[_-]?"),
|
|
159
|
+
["service", "role"].join("[_-]?"),
|
|
160
|
+
].join("|"), "i");
|
|
161
|
+
}
|
|
162
|
+
function serviceCredentialPayloadPattern() {
|
|
163
|
+
const privilegedRole = ["service", "role"].join("_");
|
|
164
|
+
return new RegExp(`"role"\\s*:\\s*"${privilegedRole}"`);
|
|
165
|
+
}
|
|
166
|
+
function base64UrlToBase64(value) {
|
|
167
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
168
|
+
return `${normalized}${"=".repeat((4 - (normalized.length % 4)) % 4)}`;
|
|
169
|
+
}
|