@bli-cockpit/cli 0.2.37 → 0.2.39
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/commands/brief.js +133 -0
- package/dist/commands/correct.js +160 -0
- package/dist/commands/jarvis.js +215 -64
- package/dist/commands/local-args.js +570 -3
- package/dist/commands/local-help.js +172 -5
- package/dist/commands/local.js +91 -3
- package/dist/commands/notes-file.js +102 -0
- package/dist/commands/notes.js +490 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/scout-render.js +172 -0
- package/dist/commands/scout.js +158 -0
- package/dist/commands/settings-render.js +137 -0
- package/dist/commands/settings.js +377 -0
- package/dist/commands/team.js +111 -0
- package/dist/commands/tower-command.js +112 -0
- package/dist/commands/workbook-render.js +196 -0
- package/dist/commands/workbook.js +180 -0
- package/dist/tower-client.js +150 -0
- package/dist/tower-stream.js +252 -0
- package/package.json +2 -2
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit brief` — the TODAY page, read from a terminal (BLI-3458).
|
|
3
|
+
*
|
|
4
|
+
* This file owns terminal output only. The page itself, whose it is, whether
|
|
5
|
+
* this person may open it and which version they asked for are all decided by
|
|
6
|
+
* `GET /api/jarvis/brief`, which calls the SAME `loadBriefView` the website's
|
|
7
|
+
* home page calls. There is deliberately no local rendering of a `JarvisPage`
|
|
8
|
+
* here: the dashboard hands back finished text (the serializer's
|
|
9
|
+
* `TERMINAL_FLAVOR`), so the terminal and the browser cannot drift into showing
|
|
10
|
+
* two different briefs.
|
|
11
|
+
*
|
|
12
|
+
* Identity is the existing paired device session. `--for` changes WHOSE page is
|
|
13
|
+
* asked for, never who is asking — the same rule `cockpit jarvis --as` follows.
|
|
14
|
+
*/
|
|
15
|
+
import { writeLine } from "./cli-io.js";
|
|
16
|
+
import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
|
|
17
|
+
const DIM = "\x1b[2m";
|
|
18
|
+
const RESET = "\x1b[0m";
|
|
19
|
+
const REQUEST_DEADLINE_MS = 30_000;
|
|
20
|
+
export async function runBrief(command, io) {
|
|
21
|
+
const session = await loadPairedSession("brief", command.homeDir);
|
|
22
|
+
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
23
|
+
const startedAt = Date.now();
|
|
24
|
+
const result = await towerJsonRequest({
|
|
25
|
+
dashboardUrl,
|
|
26
|
+
path: `/api/jarvis/brief${queryFor(command)}`,
|
|
27
|
+
deviceToken: session.device_token,
|
|
28
|
+
fetch: io.fetch,
|
|
29
|
+
method: "GET",
|
|
30
|
+
label: "brief",
|
|
31
|
+
timeoutMs: REQUEST_DEADLINE_MS,
|
|
32
|
+
log: (line) => writeLine(io.stderr, line),
|
|
33
|
+
});
|
|
34
|
+
if (!result.ok) {
|
|
35
|
+
// `towerJsonRequest` already turned a non-2xx into the server's own
|
|
36
|
+
// sentence, which for this route is the plain-words notice a person reads
|
|
37
|
+
// on the page ("Nothing has been written for you yet."). Relayed verbatim
|
|
38
|
+
// rather than translated into a code.
|
|
39
|
+
writeFailure(command, io, result.reason, result.detail);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
const body = result.body;
|
|
43
|
+
if (!body.ok || typeof body.text !== "string") {
|
|
44
|
+
const detail = body.reply ?? body.error ?? "Tower answered without a page.";
|
|
45
|
+
writeFailure(command, io, body.error ?? "no_page", detail);
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
if (command.json) {
|
|
49
|
+
writeLine(io.stdout, JSON.stringify(body));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
writeHuman(io, command, body);
|
|
53
|
+
}
|
|
54
|
+
writeLine(io.stderr, `[brief cli] read ${JSON.stringify({
|
|
55
|
+
text_length: body.text.length,
|
|
56
|
+
cadence: body.page?.cadence ?? null,
|
|
57
|
+
visible_because: body.visibleBecause ?? null,
|
|
58
|
+
tldr: Boolean(command.tldr),
|
|
59
|
+
pinned_version: command.version != null,
|
|
60
|
+
subject: command.subject ? "selected" : "caller",
|
|
61
|
+
version_count: body.versions?.length ?? null,
|
|
62
|
+
versions_reason: body.versionsReason ?? null,
|
|
63
|
+
claim_count: body.claims?.length ?? null,
|
|
64
|
+
elapsed_ms: Date.now() - startedAt,
|
|
65
|
+
})}`);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
function queryFor(command) {
|
|
69
|
+
const params = new URLSearchParams();
|
|
70
|
+
if (command.subject)
|
|
71
|
+
params.set("p", command.subject);
|
|
72
|
+
if (command.version)
|
|
73
|
+
params.set("v", command.version);
|
|
74
|
+
if (command.tldr)
|
|
75
|
+
params.set("tldr", "1");
|
|
76
|
+
if (command.versions)
|
|
77
|
+
params.set("versions", "1");
|
|
78
|
+
if (command.claims)
|
|
79
|
+
params.set("claims", "1");
|
|
80
|
+
const query = params.toString();
|
|
81
|
+
return query ? `?${query}` : "";
|
|
82
|
+
}
|
|
83
|
+
function writeHuman(io, command, body) {
|
|
84
|
+
const page = body.page ?? {};
|
|
85
|
+
// Whose page, and — when it is not the newest — that it is a record of a
|
|
86
|
+
// moment rather than something that failed to update.
|
|
87
|
+
const whose = page.displayName ? `${page.displayName}'s page` : "This page";
|
|
88
|
+
const pinned = page.olderVersionLabel ? ` · version from ${page.olderVersionLabel}` : "";
|
|
89
|
+
writeLine(io.stdout, `${DIM}${whose}${pinned}${RESET}`);
|
|
90
|
+
writeLine(io.stdout, "");
|
|
91
|
+
writeLine(io.stdout, body.text ?? "");
|
|
92
|
+
if (body.claims && body.claims.length > 0) {
|
|
93
|
+
writeLine(io.stdout, "");
|
|
94
|
+
writeLine(io.stdout, `${DIM}Claim ids — pass one to \`cockpit correct --claim\`:${RESET}`);
|
|
95
|
+
for (const claim of body.claims) {
|
|
96
|
+
writeLine(io.stdout, `${DIM} [${claim.claimId}]${RESET} ${claim.text ?? ""}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (body.versions) {
|
|
100
|
+
writeLine(io.stdout, "");
|
|
101
|
+
if (body.versions.length === 0) {
|
|
102
|
+
// Never "there is one version". An empty list with a reason is an
|
|
103
|
+
// answer; an empty list without one is a wrong answer.
|
|
104
|
+
writeLine(io.stdout, `${DIM}Earlier versions could not be listed (${body.versionsReason ?? "no reason given"}).${RESET}`);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
writeLine(io.stdout, `${DIM}Versions — pass one to \`cockpit brief --version\`:${RESET}`);
|
|
108
|
+
for (const version of body.versions) {
|
|
109
|
+
const headline = version.headline ? ` — ${version.headline}` : "";
|
|
110
|
+
writeLine(io.stdout, `${DIM} ${version.version}/${version.of} ${version.pageId} ${version.asOf}${headline}${RESET}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!command.subject && body.people && body.people.length > 1) {
|
|
115
|
+
const others = body.people
|
|
116
|
+
.filter((person) => person.personId !== page.personId)
|
|
117
|
+
.map((person) => person.slug)
|
|
118
|
+
.filter(Boolean);
|
|
119
|
+
if (others.length > 0) {
|
|
120
|
+
writeLine(io.stdout, "");
|
|
121
|
+
writeLine(io.stdout, `${DIM}You can also open: ${others.join(", ")} (\`--for <name>\`)${RESET}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function writeFailure(command, io, reason, detail) {
|
|
126
|
+
if (command.json) {
|
|
127
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
writeLine(io.stderr, detail);
|
|
131
|
+
}
|
|
132
|
+
writeLine(io.stderr, `[brief cli] not read ${JSON.stringify({ reason })}`);
|
|
133
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit correct` — say that one line on the page is wrong (BLI-3458).
|
|
3
|
+
*
|
|
4
|
+
* Two requests, in this order, and the order matters:
|
|
5
|
+
*
|
|
6
|
+
* 1. `GET /api/jarvis/brief?claims=1` — resolve WHICH page and WHOSE, and
|
|
7
|
+
* look up the line the person named. That read is also what supplies the
|
|
8
|
+
* two facts the correction door needs and a terminal cannot know:
|
|
9
|
+
* whether the clause rests on a machine record (`observed`, which decides
|
|
10
|
+
* tier 2 vs tier 3) and the receipt links the live ground-truth check
|
|
11
|
+
* mines for an identifier.
|
|
12
|
+
* 2. `POST /api/jarvis/corrections` — the same door the panel's form uses,
|
|
13
|
+
* which checks the claim against the live record BEFORE writing, and can
|
|
14
|
+
* answer back that the record disagrees.
|
|
15
|
+
*
|
|
16
|
+
* A claim id the page does not carry is refused HERE, before any write, with
|
|
17
|
+
* the fix named — `cockpit brief --claims` prints every id. Filing it anyway
|
|
18
|
+
* would land a correction on a clause nobody can find, which is how a ledger
|
|
19
|
+
* fills with rows that mean nothing.
|
|
20
|
+
*
|
|
21
|
+
* The push-back is the point of this command, not an error path: when JARVIS
|
|
22
|
+
* says the record disagrees, that sentence is what the person came for, and it
|
|
23
|
+
* is printed on stdout with an exit code of 0 — the correction WAS filed, with
|
|
24
|
+
* its outcome recorded. Only a failure to file at all is a non-zero exit.
|
|
25
|
+
*/
|
|
26
|
+
import { isInteractiveStdin, writeLine } from "./cli-io.js";
|
|
27
|
+
import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
|
|
28
|
+
const DIM = "\x1b[2m";
|
|
29
|
+
const RESET = "\x1b[0m";
|
|
30
|
+
const REQUEST_DEADLINE_MS = 60_000;
|
|
31
|
+
const MAX_CORRECTION_LENGTH = 4000;
|
|
32
|
+
export async function runCorrect(command, io) {
|
|
33
|
+
const session = await loadPairedSession("correct", command.homeDir);
|
|
34
|
+
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
35
|
+
const log = (line) => writeLine(io.stderr, line);
|
|
36
|
+
const startedAt = Date.now();
|
|
37
|
+
const text = await resolveText(command, io);
|
|
38
|
+
if (!text) {
|
|
39
|
+
writeFailure(command, io, "no_correction_text", 'Say what is wrong: `cockpit correct --claim <id> --text "..."`, or pipe it in on stdin.');
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
// Step one: which page, whose, and does that line exist.
|
|
43
|
+
const read = await towerJsonRequest({
|
|
44
|
+
dashboardUrl,
|
|
45
|
+
path: `/api/jarvis/brief${briefQuery(command)}`,
|
|
46
|
+
deviceToken: session.device_token,
|
|
47
|
+
fetch: io.fetch,
|
|
48
|
+
method: "GET",
|
|
49
|
+
label: "correct:brief",
|
|
50
|
+
timeoutMs: REQUEST_DEADLINE_MS,
|
|
51
|
+
log,
|
|
52
|
+
});
|
|
53
|
+
if (!read.ok) {
|
|
54
|
+
writeFailure(command, io, read.reason, read.detail);
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
const brief = read.body;
|
|
58
|
+
const personId = brief.page?.personId;
|
|
59
|
+
if (!brief.ok || !personId) {
|
|
60
|
+
writeFailure(command, io, brief.error ?? "no_page", brief.reply ?? "There is no page to correct.");
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
const claim = (brief.claims ?? []).find((candidate) => candidate.claimId === command.claimId);
|
|
64
|
+
if (!claim) {
|
|
65
|
+
writeFailure(command, io, "unknown_claim", `That page has no line called “${command.claimId}”. Run \`cockpit brief --claims\` to see the ids.`);
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
// Step two: the same door the panel's form posts to.
|
|
69
|
+
const written = await towerJsonRequest({
|
|
70
|
+
dashboardUrl,
|
|
71
|
+
path: "/api/jarvis/corrections",
|
|
72
|
+
deviceToken: session.device_token,
|
|
73
|
+
fetch: io.fetch,
|
|
74
|
+
label: "correct",
|
|
75
|
+
timeoutMs: REQUEST_DEADLINE_MS,
|
|
76
|
+
log,
|
|
77
|
+
body: {
|
|
78
|
+
personId,
|
|
79
|
+
pageId: brief.page?.pageId ?? null,
|
|
80
|
+
claimId: command.claimId,
|
|
81
|
+
// The line as it reads now, so the ledger records what was disputed and
|
|
82
|
+
// the live check has the sentence to work from.
|
|
83
|
+
quotedText: claim.text ?? null,
|
|
84
|
+
correctionText: text,
|
|
85
|
+
contextLinks: claim.links ?? [],
|
|
86
|
+
clauseOnPage: true,
|
|
87
|
+
clauseIsObserved: Boolean(claim.observed),
|
|
88
|
+
...(command.supersedes ? { supersedes: command.supersedes } : {}),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
if (!written.ok) {
|
|
92
|
+
writeFailure(command, io, written.reason, written.detail);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
const filed = written.body;
|
|
96
|
+
if (!filed.correctionId) {
|
|
97
|
+
writeFailure(command, io, "not_filed", filed.error ?? "Tower did not record that correction.");
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
if (command.json) {
|
|
101
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, ...filed }));
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
writeLine(io.stdout, filed.reply ?? "Filed.");
|
|
105
|
+
if (filed.finding)
|
|
106
|
+
writeLine(io.stdout, `${DIM}The record says: ${filed.finding}${RESET}`);
|
|
107
|
+
if (filed.link)
|
|
108
|
+
writeLine(io.stdout, `${DIM}${filed.link}${RESET}`);
|
|
109
|
+
}
|
|
110
|
+
writeLine(io.stderr, `[correct cli] filed ${JSON.stringify({
|
|
111
|
+
tier: filed.tier ?? null,
|
|
112
|
+
outcome: filed.outcome ?? null,
|
|
113
|
+
pushed_back: Boolean(filed.pushBack),
|
|
114
|
+
observed_clause: Boolean(claim.observed),
|
|
115
|
+
context_links: (claim.links ?? []).length,
|
|
116
|
+
supersedes: command.supersedes != null,
|
|
117
|
+
subject: command.subject ? "selected" : "caller",
|
|
118
|
+
text_length: text.length,
|
|
119
|
+
elapsed_ms: Date.now() - startedAt,
|
|
120
|
+
})}`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
function briefQuery(command) {
|
|
124
|
+
const params = new URLSearchParams({ claims: "1" });
|
|
125
|
+
if (command.subject)
|
|
126
|
+
params.set("p", command.subject);
|
|
127
|
+
if (command.version)
|
|
128
|
+
params.set("v", command.version);
|
|
129
|
+
return `?${params.toString()}`;
|
|
130
|
+
}
|
|
131
|
+
/** `--text`, or whatever was piped in. Never a prompt — this may run headless. */
|
|
132
|
+
async function resolveText(command, io) {
|
|
133
|
+
if (command.text)
|
|
134
|
+
return command.text;
|
|
135
|
+
if (isInteractiveStdin(io))
|
|
136
|
+
return null;
|
|
137
|
+
const piped = await readAll(io.stdin);
|
|
138
|
+
const trimmed = piped.trim();
|
|
139
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
140
|
+
}
|
|
141
|
+
async function readAll(stream) {
|
|
142
|
+
stream.setEncoding("utf8");
|
|
143
|
+
let text = "";
|
|
144
|
+
for await (const chunk of stream) {
|
|
145
|
+
text += chunk;
|
|
146
|
+
if (text.length > MAX_CORRECTION_LENGTH) {
|
|
147
|
+
throw new Error(`A correction is limited to ${MAX_CORRECTION_LENGTH} characters.`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return text;
|
|
151
|
+
}
|
|
152
|
+
function writeFailure(command, io, reason, detail) {
|
|
153
|
+
if (command.json) {
|
|
154
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
writeLine(io.stderr, detail);
|
|
158
|
+
}
|
|
159
|
+
writeLine(io.stderr, `[correct cli] not filed ${JSON.stringify({ reason })}`);
|
|
160
|
+
}
|
package/dist/commands/jarvis.js
CHANGED
|
@@ -5,14 +5,28 @@
|
|
|
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 {
|
|
8
|
+
import { isInteractiveStdin, readLine, writeLine } from "./cli-io.js";
|
|
9
9
|
import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
|
|
10
|
-
import {
|
|
10
|
+
import { loadPairedSession, towerFailureDetail, towerJsonRequest, towerRequest, } from "../tower-client.js";
|
|
11
|
+
import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
|
|
11
12
|
const TRACE_DIM = "\x1b[2m";
|
|
12
13
|
const TRACE_RESET = "\x1b[0m";
|
|
14
|
+
/**
|
|
15
|
+
* The dashboard route caps a turn at 120s (`maxDuration = 120`). The client
|
|
16
|
+
* waits slightly longer so the server's own named failure wins the race
|
|
17
|
+
* whenever it manages to send one; past that, the terminal names the timeout
|
|
18
|
+
* itself rather than sitting there.
|
|
19
|
+
*/
|
|
20
|
+
const TURN_DEADLINE_MS = 125_000;
|
|
13
21
|
export async function runJarvis(command, io) {
|
|
14
|
-
const session = await loadPairedSession(command.homeDir);
|
|
22
|
+
const session = await loadPairedSession("jarvis", command.homeDir);
|
|
15
23
|
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
24
|
+
// BLI-3458: reading back what was already said. No turn, no model, no
|
|
25
|
+
// conversation-ledger write — and, on the server, only ever THIS device
|
|
26
|
+
// holder's own terminal rows.
|
|
27
|
+
if (command.threads || command.history) {
|
|
28
|
+
return readHistory({ command, dashboardUrl, deviceToken: session.device_token }, io);
|
|
29
|
+
}
|
|
16
30
|
const oneShotPrompt = await resolveOneShotPrompt(command, io);
|
|
17
31
|
if (oneShotPrompt !== null) {
|
|
18
32
|
return sendOneTurn({ command, dashboardUrl, deviceToken: session.device_token }, oneShotPrompt, io);
|
|
@@ -32,17 +46,89 @@ export async function runJarvis(command, io) {
|
|
|
32
46
|
return exitCode;
|
|
33
47
|
}
|
|
34
48
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
/**
|
|
50
|
+
* `--threads` and `--thread <name> --history`.
|
|
51
|
+
*
|
|
52
|
+
* The whole conversation lives on the server; nothing is cached locally, so a
|
|
53
|
+
* person who moves between machines sees the same history on both. The server
|
|
54
|
+
* scopes it to the device holder's own account — this command cannot ask for
|
|
55
|
+
* anybody else's, and there is no flag that would let it.
|
|
56
|
+
*/
|
|
57
|
+
async function readHistory(context, io) {
|
|
58
|
+
const { command } = context;
|
|
59
|
+
const params = new URLSearchParams();
|
|
60
|
+
if (command.history) {
|
|
61
|
+
params.set("thread", command.thread);
|
|
62
|
+
if (command.limit)
|
|
63
|
+
params.set("limit", String(command.limit));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
params.set("threads", "1");
|
|
67
|
+
}
|
|
68
|
+
const result = await towerJsonRequest({
|
|
69
|
+
dashboardUrl: context.dashboardUrl,
|
|
70
|
+
path: `/api/jarvis/cli?${params.toString()}`,
|
|
71
|
+
deviceToken: context.deviceToken,
|
|
72
|
+
fetch: io.fetch,
|
|
73
|
+
method: "GET",
|
|
74
|
+
label: command.history ? "jarvis:thread" : "jarvis:threads",
|
|
75
|
+
timeoutMs: 30_000,
|
|
76
|
+
log: (line) => writeLine(io.stderr, line),
|
|
77
|
+
});
|
|
78
|
+
if (!result.ok) {
|
|
79
|
+
writeFailure(command, io, result.reason, result.detail);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
const body = result.body;
|
|
83
|
+
if (body.ok === false) {
|
|
84
|
+
writeFailure(command, io, "history_unavailable", body.reply ?? "Tower had nothing to show.");
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
if (command.json) {
|
|
88
|
+
writeLine(io.stdout, JSON.stringify(body));
|
|
89
|
+
}
|
|
90
|
+
else if (command.history) {
|
|
91
|
+
writeThreadHistory(io, body);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
writeThreadList(io, body.threads ?? []);
|
|
95
|
+
}
|
|
96
|
+
writeLine(io.stderr, `[jarvis cli] history read ${JSON.stringify({
|
|
97
|
+
mode: command.history ? "thread" : "threads",
|
|
98
|
+
thread_count: body.threads?.length ?? null,
|
|
99
|
+
message_count: body.messages?.length ?? null,
|
|
100
|
+
truncated: body.truncated ?? null,
|
|
101
|
+
})}`);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
function writeThreadList(io, threads) {
|
|
105
|
+
if (threads.length === 0) {
|
|
106
|
+
// A real answer, not a blank. Nothing here means nothing was ever asked
|
|
107
|
+
// from this terminal, which is worth saying rather than implying.
|
|
108
|
+
writeLine(io.stdout, "No terminal conversations yet. Ask something with `cockpit jarvis`.");
|
|
109
|
+
return;
|
|
43
110
|
}
|
|
44
|
-
|
|
45
|
-
|
|
111
|
+
for (const thread of threads) {
|
|
112
|
+
const turns = thread.turnCount === 1 ? "1 turn" : `${thread.turnCount ?? 0} turns`;
|
|
113
|
+
writeLine(io.stdout, `${thread.name} ${TRACE_DIM}${turns} · ${thread.lastAt ?? "unknown"}${TRACE_RESET}`);
|
|
114
|
+
if (thread.preview)
|
|
115
|
+
writeLine(io.stdout, `${TRACE_DIM} ${thread.preview}${TRACE_RESET}`);
|
|
116
|
+
}
|
|
117
|
+
writeLine(io.stdout, "");
|
|
118
|
+
writeLine(io.stdout, `${TRACE_DIM}Replay one with \`cockpit jarvis --thread <name> --history\`.${TRACE_RESET}`);
|
|
119
|
+
}
|
|
120
|
+
function writeThreadHistory(io, body) {
|
|
121
|
+
const messages = body.messages ?? [];
|
|
122
|
+
if (messages.length === 0) {
|
|
123
|
+
writeLine(io.stdout, `Nothing has been said in “${body.thread ?? "that thread"}” yet.`);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (body.truncated) {
|
|
127
|
+
writeLine(io.stdout, `${TRACE_DIM}Showing the most recent ${messages.length}; there is more before this (\`--limit\`).${TRACE_RESET}`);
|
|
128
|
+
}
|
|
129
|
+
for (const message of messages) {
|
|
130
|
+
const speaker = message.role === "you" ? "you" : "jarvis";
|
|
131
|
+
writeLine(io.stdout, `${speaker}> ${message.text ?? ""}`);
|
|
46
132
|
}
|
|
47
133
|
}
|
|
48
134
|
async function resolveOneShotPrompt(command, io) {
|
|
@@ -68,65 +154,100 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
68
154
|
}
|
|
69
155
|
attachment = read;
|
|
70
156
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
157
|
+
// BLI-3457: streaming is the default. A dashboard that has not shipped the
|
|
158
|
+
// NDJSON half yet answers `application/json`, which the reader takes as a
|
|
159
|
+
// single final event and names `stream_not_available` — so the terminal
|
|
160
|
+
// works against both server versions with no flag.
|
|
161
|
+
const wantsStream = context.command.stream !== false;
|
|
162
|
+
const log = (line) => writeLine(io.stderr, line);
|
|
163
|
+
const requested = await towerRequest({
|
|
164
|
+
dashboardUrl: context.dashboardUrl,
|
|
165
|
+
path: "/api/jarvis/cli",
|
|
166
|
+
deviceToken: context.deviceToken,
|
|
167
|
+
fetch: io.fetch,
|
|
168
|
+
label: "jarvis",
|
|
169
|
+
timeoutMs: TURN_DEADLINE_MS,
|
|
170
|
+
headers: wantsStream ? { accept: "application/x-ndjson" } : {},
|
|
171
|
+
body: attachment
|
|
172
|
+
? buildAttachmentForm(context.command, prompt, attachment)
|
|
173
|
+
: {
|
|
174
|
+
question: prompt,
|
|
175
|
+
thread: context.command.thread,
|
|
176
|
+
subject: context.command.subject,
|
|
177
|
+
model: context.command.model,
|
|
178
|
+
},
|
|
179
|
+
log,
|
|
180
|
+
});
|
|
181
|
+
if (!requested.ok) {
|
|
182
|
+
writeFailure(context.command, io, requested.reason, towerFailureDetail(requested.reason, requested.detail));
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
// Live trace lines go to a person as they land, never to a `--json`
|
|
186
|
+
// consumer: that contract is exactly one object on stdout, so the events are
|
|
187
|
+
// buffered and folded into the final payload instead.
|
|
188
|
+
let liveTraceLines = 0;
|
|
189
|
+
const turn = await readTowerTurn(requested.response, {
|
|
190
|
+
startedAt,
|
|
191
|
+
log,
|
|
192
|
+
onActivity: (event) => {
|
|
193
|
+
if (context.command.json)
|
|
194
|
+
return;
|
|
195
|
+
if (writeActivityLine(io, event))
|
|
196
|
+
liveTraceLines += 1;
|
|
197
|
+
},
|
|
198
|
+
onNote: (reason, detail) => {
|
|
199
|
+
log(`[jarvis cli] stream note ${JSON.stringify({
|
|
200
|
+
reason,
|
|
201
|
+
...(detail && reason !== "ndjson_line_unparseable" ? { detail } : {}),
|
|
202
|
+
})}`);
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
if (!turn.ok) {
|
|
206
|
+
writeFailure(context.command, io, turn.reason, streamFailureDetail(turn.reason, turn.detail));
|
|
95
207
|
return 1;
|
|
96
208
|
}
|
|
97
|
-
const body =
|
|
98
|
-
|
|
99
|
-
|
|
209
|
+
const body = turn.final;
|
|
210
|
+
const httpStatus = typeof turn.final.httpStatus === "number" ? turn.final.httpStatus : requested.response.status;
|
|
211
|
+
if (httpStatus >= 400 || !body.ok || !body.reply) {
|
|
212
|
+
const reason = body.error ?? body.reply ?? `http_${httpStatus}`;
|
|
100
213
|
writeFailure(context.command, io, "turn_failed", reason);
|
|
101
214
|
return 1;
|
|
102
215
|
}
|
|
216
|
+
// A streaming server may leave the settled trace out of the final event
|
|
217
|
+
// because it already sent every step live; the activity we collected is that
|
|
218
|
+
// same trace, so `--json` still gets one.
|
|
219
|
+
const trace = body.trace ?? activityToTrace(turn.activity);
|
|
103
220
|
if (context.command.json) {
|
|
104
221
|
writeLine(io.stdout, JSON.stringify({
|
|
105
222
|
ok: true,
|
|
106
223
|
reply: body.reply,
|
|
107
224
|
thread: body.thread ?? context.command.thread,
|
|
108
225
|
model: body.model ?? null,
|
|
109
|
-
trace
|
|
226
|
+
trace,
|
|
110
227
|
subject: body.subject ?? null,
|
|
111
228
|
}));
|
|
112
229
|
}
|
|
113
230
|
else {
|
|
114
231
|
const subject = body.subject?.displayName ? ` (${body.subject.displayName})` : "";
|
|
115
232
|
writeLine(io.stdout, `jarvis${subject}> ${body.reply}`);
|
|
116
|
-
|
|
233
|
+
// Only when nothing was drawn live — otherwise every tool would print twice.
|
|
234
|
+
if (liveTraceLines === 0)
|
|
235
|
+
writeTraceBlock(io, trace);
|
|
117
236
|
writeModelReceipt(io, body.model);
|
|
118
237
|
}
|
|
119
238
|
writeLine(io.stderr, `[jarvis cli] answered ${JSON.stringify({
|
|
120
239
|
prompt_length: prompt.length,
|
|
121
240
|
reply_length: body.reply.length,
|
|
122
|
-
trace_steps:
|
|
123
|
-
trace_failed:
|
|
241
|
+
trace_steps: trace?.length ?? 0,
|
|
242
|
+
trace_failed: trace?.filter((step) => step.status === "failed").length ?? 0,
|
|
124
243
|
model_requested: context.command.model ?? null,
|
|
125
244
|
elapsed_ms: Date.now() - startedAt,
|
|
126
245
|
thread: context.command.thread === "main" ? "default" : "named",
|
|
127
246
|
subject: context.command.subject ? "selected" : "caller",
|
|
128
247
|
image_attached: attachment !== null,
|
|
129
248
|
image_byte_size: attachment?.bytes.byteLength ?? null,
|
|
249
|
+
streamed: turn.streamed,
|
|
250
|
+
live_trace_lines: liveTraceLines,
|
|
130
251
|
})}`);
|
|
131
252
|
return 0;
|
|
132
253
|
}
|
|
@@ -166,11 +287,53 @@ function writeAttachmentRefusal(command, io, refusal, filePath) {
|
|
|
166
287
|
function writeTraceBlock(io, trace) {
|
|
167
288
|
if (!trace || trace.length === 0)
|
|
168
289
|
return;
|
|
169
|
-
for (const step of trace)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
290
|
+
for (const step of trace)
|
|
291
|
+
writeTraceLine(io, step);
|
|
292
|
+
}
|
|
293
|
+
/** The one dim trace line, drawn identically live (BLI-3457) and after the fact. */
|
|
294
|
+
function writeTraceLine(io, step) {
|
|
295
|
+
const elapsed = typeof step.elapsedMs === "number" ? ` (${step.elapsedMs}ms)` : "";
|
|
296
|
+
const failure = step.status === "failed" ? ` — failed: ${step.detail ?? "no reason given"}` : "";
|
|
297
|
+
writeLine(io.stdout, `${TRACE_DIM} ⏺ ${step.label}${elapsed}${failure}${TRACE_RESET}`);
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Draws one streamed tool call, and says whether it drew anything.
|
|
301
|
+
*
|
|
302
|
+
* Only settled steps print: a `running` event is the same tool arriving a
|
|
303
|
+
* second time, and printing both would double every line in a terminal that
|
|
304
|
+
* cannot rewrite the one above it.
|
|
305
|
+
*/
|
|
306
|
+
function writeActivityLine(io, event) {
|
|
307
|
+
if (event.status !== "done" && event.status !== "failed")
|
|
308
|
+
return false;
|
|
309
|
+
const step = activityToStep(event);
|
|
310
|
+
if (!step)
|
|
311
|
+
return false;
|
|
312
|
+
writeTraceLine(io, step);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
/** The settled steps of a streamed turn, in the shape `--json` already promises. */
|
|
316
|
+
function activityToTrace(activity) {
|
|
317
|
+
const steps = [];
|
|
318
|
+
for (const event of activity) {
|
|
319
|
+
if (event.status !== "done" && event.status !== "failed")
|
|
320
|
+
continue;
|
|
321
|
+
const step = activityToStep(event);
|
|
322
|
+
if (step)
|
|
323
|
+
steps.push(step);
|
|
173
324
|
}
|
|
325
|
+
return steps;
|
|
326
|
+
}
|
|
327
|
+
function activityToStep(event) {
|
|
328
|
+
if (event.status !== "done" && event.status !== "failed")
|
|
329
|
+
return null;
|
|
330
|
+
return {
|
|
331
|
+
tool: event.tool ?? "unknown_tool",
|
|
332
|
+
label: event.label ?? event.tool ?? "A tool ran",
|
|
333
|
+
status: event.status,
|
|
334
|
+
...(typeof event.elapsedMs === "number" ? { elapsedMs: event.elapsedMs } : {}),
|
|
335
|
+
...(event.detail ? { detail: event.detail } : {}),
|
|
336
|
+
};
|
|
174
337
|
}
|
|
175
338
|
/**
|
|
176
339
|
* One line, only when the answer did not come from what was requested — a
|
|
@@ -185,22 +348,10 @@ function writeModelReceipt(io, model) {
|
|
|
185
348
|
const mismatched = requested !== null && answered !== null && requested !== answered;
|
|
186
349
|
if (!model.fallback && !mismatched)
|
|
187
350
|
return;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (!text)
|
|
193
|
-
return { ok: false, error: "empty_response" };
|
|
194
|
-
try {
|
|
195
|
-
const value = JSON.parse(text);
|
|
196
|
-
if (!value || typeof value !== "object") {
|
|
197
|
-
return { ok: false, error: "response_body_not_object" };
|
|
198
|
-
}
|
|
199
|
-
return value;
|
|
200
|
-
}
|
|
201
|
-
catch {
|
|
202
|
-
return { ok: false, error: "response_body_not_json" };
|
|
203
|
-
}
|
|
351
|
+
// BLI-3467: never the word "unavailable" — this side cannot know why, and
|
|
352
|
+
// on 2026-09-01 the real cause was a healthy provider refusing our own tool
|
|
353
|
+
// schema. Say what the receipt actually reports.
|
|
354
|
+
writeLine(io.stdout, `Model: ${answered ?? "an unknown model"} answered instead of ${requested ?? "the requested model"}`);
|
|
204
355
|
}
|
|
205
356
|
function writeFailure(command, io, reason, detail) {
|
|
206
357
|
if (command.json) {
|