@bli-cockpit/cli 0.2.89 → 0.2.91

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.
@@ -4,6 +4,7 @@
4
4
  * cockpit models # every card: documented vs measured
5
5
  * cockpit models list --json
6
6
  * cockpit models show openai:gpt-5.6-terra
7
+ * cockpit models compare openai:gpt-5.6-luna openai:gpt-5.6-terra --highlight
7
8
  *
8
9
  * Two nouns that look alike and are not: `cockpit model` is the ORG SETTING for
9
10
  * which model a slot runs on (`model show`, `model set`); `cockpit models` is
@@ -11,20 +12,47 @@
11
12
  * quirks, probe verdicts and stale marks. The help text for each says so.
12
13
  */
13
14
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
15
+ /** Two is not enough to compare against, five stops being readable (BLI-3919). */
16
+ const COMPARE_MIN = 2;
17
+ const COMPARE_MAX = 4;
14
18
  export function parseModelsArgs(args) {
15
19
  const values = parseNamedArgs(args, {
16
- allowedFlags: ["--home", "--dashboard-url", "--json"],
20
+ allowedFlags: ["--home", "--dashboard-url", "--json", "--highlight"],
17
21
  valueFlags: ["--home", "--dashboard-url"],
18
22
  });
19
- if (values.positionals.length > 2) {
20
- throw new Error("models accepts at most an action (list|show) and one model id.");
21
- }
22
- const [rawAction, rawId] = values.positionals;
23
23
  const base = {
24
24
  homeDir: optionalNonEmpty(values.flags.get("--home")),
25
25
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
26
26
  json: values.booleans.has("--json"),
27
27
  };
28
+ // `compare` is the one verb that takes several ids, so it is parsed before
29
+ // the at-most-two rule the other two live under (BLI-3919).
30
+ if (values.positionals[0] === "compare") {
31
+ const modelIds = values.positionals.slice(1).map((id) => id.trim()).filter((id) => id.length > 0);
32
+ const unique = [...new Set(modelIds)];
33
+ if (unique.length < COMPARE_MIN) {
34
+ throw new Error(`models compare needs at least ${COMPARE_MIN} model ids, e.g. ` +
35
+ "`models compare openai:gpt-5.6-luna openai:gpt-5.6-terra`.");
36
+ }
37
+ if (unique.length > COMPARE_MAX) {
38
+ throw new Error(`models compare holds at most ${COMPARE_MAX} models; ${unique.length} named. ` +
39
+ "A fifth column stops being readable.");
40
+ }
41
+ return {
42
+ kind: "models",
43
+ action: "compare",
44
+ modelIds: unique,
45
+ highlight: values.booleans.has("--highlight"),
46
+ ...base,
47
+ };
48
+ }
49
+ if (values.booleans.has("--highlight")) {
50
+ throw new Error("--highlight only means something for `models compare`.");
51
+ }
52
+ if (values.positionals.length > 2) {
53
+ throw new Error("models accepts at most an action (list|show) and one model id.");
54
+ }
55
+ const [rawAction, rawId] = values.positionals;
28
56
  if (!rawAction || rawAction === "list") {
29
57
  if (rawAction === "list" && rawId) {
30
58
  throw new Error("models list takes no model id — use `models show <provider:model>`.");
@@ -276,7 +276,7 @@ export function localSubcommandHelp(command) {
276
276
  [
277
277
  "models",
278
278
  [
279
- "Usage: cockpit models [list|show <provider:model>] [--dashboard-url <url>] [--json]",
279
+ "Usage: cockpit models [list|show <provider:model>|compare <id> <id> [...]] [--highlight] [--dashboard-url <url>] [--json]",
280
280
  "",
281
281
  "The model-card shelf: one card per model, with what the PROVIDER documents (context",
282
282
  "window, max output, price per million, reasoning modes, tool calling, structured output,",
@@ -287,6 +287,15 @@ export function localSubcommandHelp(command) {
287
287
  "STALE:<field> means a measurement contradicted the documentation — reasoning-off refused",
288
288
  "where the card says it can be turned off, the belt refused where the card says tool",
289
289
  "calling. The line under it is the command that re-reads the provider.",
290
+ "compare puts two to four cards side by side: the same rows the page at /settings/models/",
291
+ "compare renders, including `$ per 100 JARVIS turns` (our own measured tokens a turn times",
292
+ "the provider's price, with the window it was measured over beside it — `not measured`",
293
+ "where no profile exists, never a per-million price relabelled) and the bench row, which",
294
+ "appears only when every model was judged in the SAME run against the SAME baseline;",
295
+ "otherwise it says `benched separately — not a head-to-head` rather than subtracting two",
296
+ "win rates. --highlight marks the winner in each scored row (lowest wins on price and",
297
+ "latency, highest on tokens/s, most wins on the bench); a row where any column is `—` or",
298
+ "`not measured` is not scored at all, and says why.",
290
299
  "`cockpit models` is the SHELF; `cockpit model` is the org setting saying which model a",
291
300
  "slot runs on. Different nouns, one letter apart, on purpose.",
292
301
  "--json writes one object to stdout; operational lines stay on stderr.",
@@ -71,7 +71,7 @@ export function localCommandHelp(command) {
71
71
  " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
72
72
  " cockpit jarvis [question] [--prompt <question>] [--as <person>] [--date <YYYY-MM-DD>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--trace <id|last>] [--dashboard-url <url>] [--json [--show-approval-code]]",
73
73
  " cockpit model [show|set <provider:model>] [--json]",
74
- " cockpit models [list|show <provider:model>] [--dashboard-url <url>] [--json]",
74
+ " cockpit models [list|show <provider:model>|compare <provider:model> <provider:model> [...]] [--highlight] [--dashboard-url <url>] [--json]",
75
75
  " cockpit scout [start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
76
76
  " cockpit ops [status [--job <id>] [--skips] [--memory] [--models] | recompile --person <email|name|id> [--dry-run]] [--dashboard-url <url>] [--json]",
77
77
  " cockpit slack [coverage [--workspace bli|blue_pearl] [--stale-only] | read [--person <p>] [--channel <c>] [--query <text>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>]] [--json]",
@@ -27,7 +27,7 @@
27
27
  * which is the exact false green this ticket exists to remove.
28
28
  */
29
29
  import fs from "node:fs";
30
- import { memoryHookStatsFilePath, parseMemoryHookStats, summariseMemoryHookWindow, } from "@bli-cockpit/telemetry-core";
30
+ import { memoryHookCharsPercentile, memoryHookStatsFilePath, parseMemoryHookStats, summariseMemoryHookWindow, } from "@bli-cockpit/telemetry-core";
31
31
  export function readMemoryHookCounts(options) {
32
32
  const readText = options.readText ?? defaultReadText;
33
33
  const file = memoryHookStatsFilePath(options.homeDir);
@@ -55,6 +55,17 @@ export function readMemoryHookCounts(options) {
55
55
  : {}),
56
56
  hook_skipped_trivial_24h: window.skippedTrivial,
57
57
  hook_billing_exhausted_24h: window.billingExhausted,
58
+ // Ticket 3934. Reported as the bin's LOWER bound — the smaller, true
59
+ // claim — and only when something was measured. A machine that recorded
60
+ // no histogram carries no key at all, which the board reads as
61
+ // "not recorded"; a zero here would say its recalls were empty.
62
+ ...(window.charsMeasured && window.charsSamples > 0
63
+ ? {
64
+ hook_chars_p50: memoryHookCharsPercentile(window.chars, 50)?.lower ?? 0,
65
+ hook_chars_p95: memoryHookCharsPercentile(window.chars, 95)?.lower ?? 0,
66
+ hook_chars_samples: window.charsSamples,
67
+ }
68
+ : {}),
58
69
  },
59
70
  reason: "ok",
60
71
  };
@@ -6,11 +6,24 @@
6
6
  * tokens/s and last probe verdict, and a `STALE:<field>` mark wherever the two
7
7
  * disagree — with the command that fixes it on the next line.
8
8
  *
9
+ * `models show <id>` asks the same door for the SIX SECTIONS (BLI-3918) —
10
+ * what's going on, what's wrong, what's right, what should we do, double down,
11
+ * forget about it — so a terminal reads the model page a browser reads, in the
12
+ * same order, including today's spend and the `stale:<field>` markers.
13
+ *
14
+ * `models compare <id> <id> [...]` asks it for TWO TO FOUR cards side by side
15
+ * (BLI-3919): the same rows, the same `$ per 100 JARVIS turns` and the same
16
+ * bench head-to-head the page at `/settings/models/compare` renders, computed
17
+ * by one pure builder on the server. `--highlight` marks the best cell in each
18
+ * scored row; it is off by default, exactly as the page's toggle is.
19
+ *
9
20
  * **Every line comes from the server** (`/api/models/cards`, rendered by
10
- * `lib/models/model-lines.ts`). This file decides an exit code and nothing
11
- * else. A terminal that formatted a card itself would eventually disagree with
12
- * the board about what "stale" means, and the one that is wrong is always the
13
- * one somebody is reading.
21
+ * `lib/models/model-lines.ts` and `card-section-lines.ts`). This file decides an
22
+ * exit code and nothing else. A terminal that formatted a card itself would
23
+ * eventually disagree with the board about what "stale" means, and the one that
24
+ * is wrong is always the one somebody is reading. A server that has not shipped
25
+ * the sections yet sends no `section_lines`, and the whole-card `lines` are
26
+ * printed instead — never an empty screen.
14
27
  *
15
28
  * **Exit codes.** Zero when the shelf was read, whatever it says — a stale card
16
29
  * is a fact to act on, not a broken command. One when the read failed or when a
@@ -21,13 +34,29 @@ import { writeLine } from "./cli-io.js";
21
34
  import { loadPairedSession, towerJsonRequest, } from "../tower-client.js";
22
35
  /** A card read is checked-in data on the server side; it should never hang a shell. */
23
36
  const READ_DEADLINE_MS = 30_000;
37
+ /**
38
+ * Which question this invocation asks the door.
39
+ *
40
+ * `compare` sends `--highlight` along, because the winner is decided ON THE
41
+ * SERVER and each surface only decides whether to SHOW it — a terminal that
42
+ * scored the rows itself would eventually disagree with the page about which
43
+ * model is cheaper.
44
+ */
45
+ function requestPath(command) {
46
+ if (command.action === "compare") {
47
+ const ids = (command.modelIds ?? []).join(",");
48
+ return `/api/models/cards?compare=${encodeURIComponent(ids)}${command.highlight ? "&highlight=1" : ""}`;
49
+ }
50
+ if (command.action === "show" && command.modelId) {
51
+ return `/api/models/cards?id=${encodeURIComponent(command.modelId)}&sections=1`;
52
+ }
53
+ return "/api/models/cards";
54
+ }
24
55
  export async function runModels(command, io) {
25
56
  const session = await loadPairedSession("models", command.homeDir);
26
57
  const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
27
58
  const log = (line) => writeLine(io.stderr, line);
28
- const path = command.action === "show" && command.modelId
29
- ? `/api/models/cards?id=${encodeURIComponent(command.modelId)}`
30
- : "/api/models/cards";
59
+ const path = requestPath(command);
31
60
  const result = await towerJsonRequest({
32
61
  dashboardUrl,
33
62
  path,
@@ -47,7 +76,14 @@ export async function runModels(command, io) {
47
76
  writeLine(io.stdout, JSON.stringify({ ok: true, ...payload }));
48
77
  }
49
78
  else {
50
- for (const line of payload.lines ?? [])
79
+ // A server that predates a branch sends no lines for it; the next-best
80
+ // rendering is printed rather than an empty screen.
81
+ const lines = payload.compare_lines?.length
82
+ ? payload.compare_lines
83
+ : payload.section_lines?.length
84
+ ? payload.section_lines
85
+ : (payload.lines ?? []);
86
+ for (const line of lines)
51
87
  writeLine(io.stdout, line);
52
88
  }
53
89
  // Never silent about a card the server could not read: a shelf quietly one
@@ -58,7 +94,10 @@ export async function runModels(command, io) {
58
94
  writeLine(io.stderr, `[models cli] read ${JSON.stringify({
59
95
  action: command.action,
60
96
  model: command.modelId ?? null,
97
+ models: command.modelIds?.length ?? 0,
61
98
  cards: Array.isArray(payload.cards) ? payload.cards.length : payload.card ? 1 : 0,
99
+ sections: payload.section_lines?.length ? "server" : "absent",
100
+ compare: payload.compare_lines?.length ? "server" : "absent",
62
101
  unreadable: payload.unreadable?.length ?? 0,
63
102
  })}`);
64
103
  return 0;
@@ -82,5 +121,6 @@ function writeFailure(command, io, failure) {
82
121
  reason: failure.reason,
83
122
  http_status: failure.httpStatus ?? null,
84
123
  model: command.modelId ?? null,
124
+ models: command.modelIds?.length ?? 0,
85
125
  })}`);
86
126
  }
@@ -110,6 +110,11 @@ export function renderMemoryHooks(hooks, dim) {
110
110
  // printed verbatim, like every other sentence in this section.
111
111
  if (device.viaLine)
112
112
  lines.push(dim(` ${device.viaLine}`));
113
+ // Ticket 3934: how much of a person's context the recall took. Written on
114
+ // the server (`lib/ops/memory-hook-misses.ts`) and printed verbatim, like
115
+ // every other sentence in this section.
116
+ if (device.charsLine)
117
+ lines.push(dim(` ${device.charsLine}`));
113
118
  }
114
119
  return lines;
115
120
  }
@@ -29,8 +29,21 @@ function usdColumn(usd) {
29
29
  */
30
30
  export function renderSpend(spend, dim) {
31
31
  const lines = ["", `SPEND ${spend.summary ?? "(no summary)"}`];
32
+ // The subscription question, on its own line and in the server's own words
33
+ // (ticket 3934). Printed BEFORE the per-model rows because it is the number
34
+ // somebody acts on: the rows say where the money went, this says whether it
35
+ // had to be spent at all.
36
+ if (spend.offsettableLine)
37
+ lines.push(dim(` ${spend.offsettableLine}`));
32
38
  for (const row of spend.models ?? []) {
33
- lines.push(dim(` ${usdColumn(row.usd).padStart(9)} ${row.model ?? "?"} · ${row.calls ?? 0} call(s) · ${(row.bases ?? []).join("+") || "?"} · ${(row.sources ?? []).join(",") || "?"}`));
39
+ // Two dollar columns: what the provider LISTS and what the account was
40
+ // BILLED. They are equal on Fireworks and OpenRouter and differ by the
41
+ // credit divisor on OpenAI, so printing one alone was three times the bill
42
+ // for every OpenAI row and said nothing about which figure it was.
43
+ const real = typeof row.realUsd === "number" && row.realUsd !== row.usd
44
+ ? ` (${usdColumn(row.realUsd)} real)`
45
+ : "";
46
+ lines.push(dim(` ${usdColumn(row.usd).padStart(9)}${real} ${row.model ?? "?"} · ${row.calls ?? 0} call(s) · ${row.purpose ?? "?"} · ${(row.bases ?? []).join("+") || "?"} · ${(row.sources ?? []).join(",") || "?"}`));
34
47
  }
35
48
  return lines;
36
49
  }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.89");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.91");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.89",
3
+ "version": "0.2.91",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.20",
31
- "@bli-cockpit/mcp": "0.1.22",
32
- "@bli-cockpit/telemetry-core": "0.1.37"
30
+ "@bli-cockpit/memory-mcp": "0.1.21",
31
+ "@bli-cockpit/mcp": "0.1.24",
32
+ "@bli-cockpit/telemetry-core": "0.1.38"
33
33
  }
34
34
  }