@bli-cockpit/cli 0.2.38 → 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.
@@ -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
+ }
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { isInteractiveStdin, readLine, writeLine } from "./cli-io.js";
9
9
  import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
10
- import { loadPairedSession, towerFailureDetail, towerRequest, } from "../tower-client.js";
10
+ import { loadPairedSession, towerFailureDetail, towerJsonRequest, towerRequest, } from "../tower-client.js";
11
11
  import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
12
12
  const TRACE_DIM = "\x1b[2m";
13
13
  const TRACE_RESET = "\x1b[0m";
@@ -21,6 +21,12 @@ const TURN_DEADLINE_MS = 125_000;
21
21
  export async function runJarvis(command, io) {
22
22
  const session = await loadPairedSession("jarvis", command.homeDir);
23
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
+ }
24
30
  const oneShotPrompt = await resolveOneShotPrompt(command, io);
25
31
  if (oneShotPrompt !== null) {
26
32
  return sendOneTurn({ command, dashboardUrl, deviceToken: session.device_token }, oneShotPrompt, io);
@@ -40,6 +46,91 @@ export async function runJarvis(command, io) {
40
46
  return exitCode;
41
47
  }
42
48
  }
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;
110
+ }
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 ?? ""}`);
132
+ }
133
+ }
43
134
  async function resolveOneShotPrompt(command, io) {
44
135
  if (command.prompt)
45
136
  return validatePrompt(command.prompt);
@@ -257,7 +348,10 @@ function writeModelReceipt(io, model) {
257
348
  const mismatched = requested !== null && answered !== null && requested !== answered;
258
349
  if (!model.fallback && !mismatched)
259
350
  return;
260
- writeLine(io.stdout, `Model: ${answered ?? "an unknown model"} answered (${requested ?? "the requested model"} unavailable)`);
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"}`);
261
355
  }
262
356
  function writeFailure(command, io, reason, detail) {
263
357
  if (command.json) {