@bli-cockpit/cli 0.2.49 → 0.2.51

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.
Files changed (57) hide show
  1. package/dist/adapters/raw-evidence-claude-reader.js +108 -0
  2. package/dist/adapters/raw-evidence-codex-reader.js +147 -0
  3. package/dist/adapters/raw-evidence-collection-state.js +199 -0
  4. package/dist/adapters/raw-evidence-facts.js +338 -0
  5. package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
  6. package/dist/adapters/raw-evidence-image-reader.js +107 -0
  7. package/dist/adapters/raw-evidence-sanitize.js +56 -0
  8. package/dist/adapters/raw-evidence-transcript-file.js +182 -0
  9. package/dist/adapters/raw-evidence.js +63 -1183
  10. package/dist/commands/backfill-batches.js +34 -0
  11. package/dist/commands/backfill-candidates.js +54 -0
  12. package/dist/commands/backfill-checkpoint.js +101 -0
  13. package/dist/commands/backfill-command-line.js +70 -0
  14. package/dist/commands/backfill-evidence-outcomes.js +104 -0
  15. package/dist/commands/backfill-issues.js +265 -0
  16. package/dist/commands/backfill-output.js +75 -0
  17. package/dist/commands/backfill-plan.js +71 -0
  18. package/dist/commands/backfill-reasons.js +107 -0
  19. package/dist/commands/backfill-report.js +298 -0
  20. package/dist/commands/backfill-result.js +150 -0
  21. package/dist/commands/backfill-scan.js +274 -0
  22. package/dist/commands/backfill-scope.js +114 -0
  23. package/dist/commands/backfill-session-report.js +145 -0
  24. package/dist/commands/backfill-types.js +1 -0
  25. package/dist/commands/backfill-upload.js +212 -0
  26. package/dist/commands/backfill.js +41 -1961
  27. package/dist/commands/doctor.js +57 -0
  28. package/dist/commands/jarvis-trace.js +184 -0
  29. package/dist/commands/jarvis.js +144 -4
  30. package/dist/commands/local-args-collector.js +26 -0
  31. package/dist/commands/local-args-tower.js +21 -0
  32. package/dist/commands/local-args.js +3 -1
  33. package/dist/commands/local-help.js +19 -2
  34. package/dist/commands/local.js +3 -0
  35. package/dist/commands/memory-install-claude.js +294 -0
  36. package/dist/commands/memory-install-codex.js +205 -0
  37. package/dist/commands/memory-install-contract.js +286 -0
  38. package/dist/commands/memory-install-files.js +63 -0
  39. package/dist/commands/memory-install-skills.js +121 -0
  40. package/dist/commands/memory-install-toml.js +265 -0
  41. package/dist/commands/memory-install.js +465 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/sync-followups.js +105 -0
  44. package/dist/commands/sync.js +7 -1
  45. package/dist/local-state-attributed-target.js +75 -0
  46. package/dist/local-state-config.js +147 -0
  47. package/dist/local-state-files.js +59 -0
  48. package/dist/local-state-identity.js +73 -0
  49. package/dist/local-state-pairing.js +263 -0
  50. package/dist/local-state-paths.js +61 -0
  51. package/dist/local-state-session.js +68 -0
  52. package/dist/local-state-status.js +163 -0
  53. package/dist/local-state-work-context.js +190 -0
  54. package/dist/local-state.js +34 -848
  55. package/dist/tower-client.js +3 -2
  56. package/dist/tower-stream.js +57 -3
  57. package/package.json +2 -1
@@ -10,6 +10,7 @@ import { normalizeCollectionRoots } from "../root-normalization.js";
10
10
  import { detectSecondCockpitInstall } from "../second-install.js";
11
11
  import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
12
12
  import { runBackfillCommand } from "./backfill.js";
13
+ import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-install.js";
13
14
  import { createInteractiveExecRunner } from "../process-runner.js";
14
15
  const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
15
16
  export async function runDoctor(command, io, hooks, overrides = {}) {
@@ -81,6 +82,14 @@ function doctorInvariants() {
81
82
  check: (context) => context.deps.checkAutostart(context),
82
83
  fix: (context, _state) => context.deps.fixAutostart(context),
83
84
  },
85
+ // BLI-3580. Runs right after autostart because it is the same kind of
86
+ // thing: a registration on this machine that nobody should have to be
87
+ // asked about. Its fix writes host configs, never installs software.
88
+ {
89
+ id: "memory-registered",
90
+ check: (context) => context.deps.checkMemory(context),
91
+ fix: (context, _state) => context.deps.fixMemory(context),
92
+ },
84
93
  {
85
94
  id: "backfill-complete",
86
95
  check: (context) => context.deps.checkBackfill(context),
@@ -111,6 +120,8 @@ function defaultDoctorDeps(hooks) {
111
120
  checkSingleInstall: checkSingleInstallState,
112
121
  checkAutostart: checkAutostartState,
113
122
  fixAutostart: fixAutostartState,
123
+ checkMemory: checkMemoryState,
124
+ fixMemory: fixMemoryState,
114
125
  checkBackfill: checkBackfillState,
115
126
  fixBackfill: fixBackfillState,
116
127
  checkGc: checkGcState,
@@ -307,6 +318,52 @@ async function fixAutostartState(context) {
307
318
  }
308
319
  return ok("autostart-alive", "installed", "background sync installed and running");
309
320
  }
321
+ /**
322
+ * BLI Memory's registration on this machine (BLI-3580).
323
+ *
324
+ * The step owns the CONFIG, never the software: it writes the MCP entry, the
325
+ * hooks and the Codex table only when `bli-memory-mcp` actually resolves. With
326
+ * no server it writes NOTHING and reads `skipped bin_missing` — registering
327
+ * hooks that point at an absent binary would make every Claude Code turn print
328
+ * a hook failure, which is worse than waiting a day.
329
+ */
330
+ async function checkMemoryState(context) {
331
+ const outcome = await inspectMemoryIntegration(memoryCommandFor(context), context.io);
332
+ return memoryStepState(outcome, "check");
333
+ }
334
+ async function fixMemoryState(context) {
335
+ const outcome = await installMemoryIntegration(memoryCommandFor(context), context.io);
336
+ return memoryStepState(outcome, "fix");
337
+ }
338
+ function memoryCommandFor(context) {
339
+ return {
340
+ kind: "memory",
341
+ action: "install",
342
+ homeDir: context.command.homeDir,
343
+ dashboardUrl: context.command.dashboardUrl,
344
+ dryRun: false,
345
+ json: context.command.json,
346
+ };
347
+ }
348
+ function memoryStepState(outcome, phase) {
349
+ const broken = outcome.targets.filter((target) => target.target !== "bin" && target.status === "failed");
350
+ if (broken.length > 0) {
351
+ const first = broken[0];
352
+ const message = `${first?.target ?? "memory"}: ${first?.reason ?? "failed"}`;
353
+ return phase === "fix"
354
+ ? fail("memory-registered", first?.reason ?? "memory_install_failed", message)
355
+ : needsFix("memory-registered", first?.reason ?? "memory_install_failed", message);
356
+ }
357
+ const pending = outcome.targets.filter((target) => target.target !== "bin" &&
358
+ (target.status === "missing" || target.status === "mismatch"));
359
+ if (pending.length > 0) {
360
+ return needsFix("memory-registered", "registration_incomplete", `BLI Memory is not registered with ${pending.map((target) => target.target).join(", ")}`);
361
+ }
362
+ if (!outcome.bin_found) {
363
+ return skipped("memory-registered", "bin_missing", "bli-memory-mcp is not on this machine yet; nothing was written and the next run will try again");
364
+ }
365
+ return ok("memory-registered", phase === "fix" ? "installed" : "already_installed", "BLI Memory is registered with both agent hosts");
366
+ }
310
367
  /**
311
368
  * Pure so it can be unit-tested without touching the real machine's home
312
369
  * directory (`getCollectorRuntimePaths()` defaults to `os.homedir()` and
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `cockpit jarvis --trace <id|last>` — the step tree of one turn (BLI-3560).
3
+ *
4
+ * The one thing anybody here ever opened Langfuse for, in the terminal where
5
+ * the turn was asked. Every model step, every tool call, every server-side
6
+ * memory step (BLI-3416's doubled answer was one of those and no other surface
7
+ * showed it), with how long each took, which model ran it, what it spent and
8
+ * whether it failed.
9
+ *
10
+ * ## `last` and the remembered receipt
11
+ *
12
+ * A turn now hands back its own trace id, and this module writes that id — with
13
+ * the conversation it belonged to — to one small file beside the collector's
14
+ * other state. `--trace last` reads it, so the loop is: ask a question, watch
15
+ * it come back slow, type `cockpit jarvis --trace last`. If the file is not
16
+ * there (a fresh shell, a machine that has not asked anything since the
17
+ * release), the file's remembered THREAD is used to ask the server for that
18
+ * conversation's newest turn instead — and when there is neither, the refusal
19
+ * says so in words rather than printing nothing.
20
+ *
21
+ * The file holds two opaque ids and a timestamp. No question, no answer, no
22
+ * name; it is a bookmark, not a transcript.
23
+ *
24
+ * ## The lines
25
+ *
26
+ * Rendered SERVER-SIDE and printed verbatim. That is deliberate: the ops door,
27
+ * this terminal and JARVIS's own `readTurnTrace` tool all read the same
28
+ * sentences from `lib/ops/turn-trace-lines.ts`, so an older CLI against a newer
29
+ * dashboard still prints the current tree rather than its own stale idea of one.
30
+ */
31
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
32
+ import os from "node:os";
33
+ import path from "node:path";
34
+ import { colorEnabled, dim, writeLine } from "./cli-io.js";
35
+ import { getCollectorRuntimePaths } from "../local-state.js";
36
+ import { towerFailureDetail, towerJsonRequest } from "../tower-client.js";
37
+ /** The bookmark file's name, inside the collector's own state directory. */
38
+ const LAST_TURN_FILE = "last-turn-trace.json";
39
+ function bookmarkPath(homeDir) {
40
+ const paths = getCollectorRuntimePaths(homeDir ?? os.homedir());
41
+ return path.join(paths.state_dir, LAST_TURN_FILE);
42
+ }
43
+ /**
44
+ * Remember the turn that just happened. Never throws: a bookmark that could not
45
+ * be written must not fail the answer a person already received, so the failure
46
+ * is named on stderr and the turn stands.
47
+ */
48
+ export async function rememberTurnTrace(turn, io, homeDir) {
49
+ if (!turn.traceId && !turn.threadId)
50
+ return;
51
+ const file = bookmarkPath(homeDir);
52
+ const payload = {
53
+ traceId: turn.traceId ?? null,
54
+ threadId: turn.threadId ?? null,
55
+ at: new Date().toISOString(),
56
+ };
57
+ try {
58
+ await mkdir(path.dirname(file), { recursive: true });
59
+ await writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
60
+ }
61
+ catch (error) {
62
+ writeLine(io.stderr, `[jarvis cli] could not remember this turn's trace ${JSON.stringify({
63
+ reason: "bookmark_write_failed",
64
+ error: error instanceof Error ? error.name : "unknown",
65
+ })}`);
66
+ }
67
+ }
68
+ /** The remembered turn, or null when there is not one. Never throws. */
69
+ export async function readRememberedTurn(homeDir) {
70
+ try {
71
+ const raw = await readFile(bookmarkPath(homeDir), "utf8");
72
+ const parsed = JSON.parse(raw);
73
+ const traceId = typeof parsed.traceId === "string" ? parsed.traceId : null;
74
+ const threadId = typeof parsed.threadId === "string" ? parsed.threadId : null;
75
+ if (!traceId && !threadId)
76
+ return null;
77
+ return { traceId, threadId, at: typeof parsed.at === "string" ? parsed.at : "" };
78
+ }
79
+ catch {
80
+ // A missing or unreadable bookmark is "nothing is remembered", which is the
81
+ // exact answer asked for. The caller says so out loud in a sentence.
82
+ return null;
83
+ }
84
+ }
85
+ export async function runJarvisTrace(context, io) {
86
+ const log = (line) => writeLine(io.stderr, line);
87
+ let traceId = context.requested;
88
+ let resolvedFrom = "argument";
89
+ if (context.requested === "last") {
90
+ const remembered = await readRememberedTurn(context.homeDir);
91
+ if (!remembered) {
92
+ return refuse(context, io, "no_remembered_turn", "This machine has not asked JARVIS anything since traces were turned on, so there is no last turn to open. Ask something, then run this again — or pass the trace id from the turn's receipt.");
93
+ }
94
+ if (remembered.traceId) {
95
+ traceId = remembered.traceId;
96
+ resolvedFrom = "bookmark";
97
+ }
98
+ else if (remembered.threadId) {
99
+ // The bookmark knew the conversation but not the turn (an older receipt).
100
+ // Ask the server for that conversation's newest turn rather than nothing.
101
+ const newest = await newestTraceForThread(context, remembered.threadId, io);
102
+ if (!newest) {
103
+ return refuse(context, io, "no_traces_in_thread", "That conversation has no recorded turns inside the 30-day retention. Pass a trace id, or ask something new.");
104
+ }
105
+ traceId = newest;
106
+ resolvedFrom = "thread";
107
+ }
108
+ }
109
+ const result = await towerJsonRequest({
110
+ dashboardUrl: context.dashboardUrl,
111
+ path: `/api/ops/trace/${encodeURIComponent(traceId)}`,
112
+ deviceToken: context.deviceToken,
113
+ fetch: io.fetch,
114
+ method: "GET",
115
+ label: "jarvis:trace",
116
+ timeoutMs: 30_000,
117
+ log,
118
+ });
119
+ if (!result.ok) {
120
+ return refuse(context, io, result.reason, towerFailureDetail(result.reason, result.detail));
121
+ }
122
+ const body = result.body;
123
+ if (body.ok === false) {
124
+ return refuse(context, io, body.error ?? "trace_unavailable", body.message ?? "Tower had nothing to show for that turn.");
125
+ }
126
+ if (context.json) {
127
+ writeLine(io.stdout, JSON.stringify(body));
128
+ }
129
+ else {
130
+ writeTraceTree(io, traceId, body);
131
+ }
132
+ writeLine(io.stderr, `[jarvis cli] trace read ${JSON.stringify({
133
+ resolved_from: resolvedFrom,
134
+ spans: body.trace?.spanCount ?? null,
135
+ errors: body.trace?.errorCount ?? null,
136
+ running: body.trace?.runningCount ?? null,
137
+ duration_ms: body.trace?.durationMs ?? null,
138
+ truncated: body.truncated ?? null,
139
+ })}`);
140
+ return 0;
141
+ }
142
+ async function newestTraceForThread(context, threadId, io) {
143
+ const result = await towerJsonRequest({
144
+ dashboardUrl: context.dashboardUrl,
145
+ path: `/api/ops/trace?thread=${encodeURIComponent(threadId)}&limit=1`,
146
+ deviceToken: context.deviceToken,
147
+ fetch: io.fetch,
148
+ method: "GET",
149
+ label: "jarvis:trace-thread",
150
+ timeoutMs: 30_000,
151
+ log: (line) => writeLine(io.stderr, line),
152
+ });
153
+ if (!result.ok)
154
+ return null;
155
+ const body = result.body;
156
+ return body.traces?.[0]?.traceId ?? null;
157
+ }
158
+ function writeTraceTree(io, traceId, body) {
159
+ const styled = colorEnabled(io);
160
+ writeLine(io.stdout, `Turn ${traceId}`);
161
+ if (body.headline)
162
+ writeLine(io.stdout, dim(body.headline, styled));
163
+ writeLine(io.stdout, "");
164
+ for (const line of body.lines ?? [])
165
+ writeLine(io.stdout, line);
166
+ if ((body.lines ?? []).length === 0) {
167
+ // Never a blank screen: an empty tree is a fact and it gets a sentence.
168
+ writeLine(io.stdout, "That turn recorded no steps.");
169
+ }
170
+ if (body.truncated) {
171
+ writeLine(io.stdout, "");
172
+ writeLine(io.stdout, dim("Only the first 1000 spans of this turn are shown.", styled));
173
+ }
174
+ }
175
+ function refuse(context, io, reason, detail) {
176
+ if (context.json) {
177
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
178
+ }
179
+ else {
180
+ writeLine(io.stderr, detail);
181
+ }
182
+ writeLine(io.stderr, `[jarvis cli] trace unavailable ${JSON.stringify({ reason })}`);
183
+ return 1;
184
+ }
@@ -7,8 +7,9 @@
7
7
  */
8
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";
12
13
  /**
13
14
  * A citation line's link, as the dashboard mints it (BLI-3570):
14
15
  * `Source: BLI-1234 — Judge outage <https://linear.app/…>`. The words are the
@@ -74,7 +75,12 @@ function writeReplyContinuation(io, tail) {
74
75
  */
75
76
  const TURN_DEADLINE_MS = 125_000;
76
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();
77
82
  const session = await loadPairedSession("jarvis", command.homeDir);
83
+ const sessionMs = Date.now() - sessionStartedAt;
78
84
  const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
79
85
  // BLI-3458: reading back what was already said. No turn, no model, no
80
86
  // conversation-ledger write — and, on the server, only ever THIS device
@@ -82,9 +88,32 @@ export async function runJarvis(command, io) {
82
88
  if (command.threads || command.history) {
83
89
  return readHistory({ command, dashboardUrl, deviceToken: session.device_token }, io);
84
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();
85
103
  const oneShotPrompt = await resolveOneShotPrompt(command, io);
86
104
  if (oneShotPrompt !== null) {
87
- return sendOneTurn({ command, dashboardUrl, deviceToken: session.device_token }, oneShotPrompt, io);
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);
88
117
  }
89
118
  if (command.json) {
90
119
  throw new Error("cockpit jarvis --json needs --prompt, positional text, or piped stdin.");
@@ -96,7 +125,14 @@ export async function runJarvis(command, io) {
96
125
  continue;
97
126
  if (prompt === "/exit" || prompt === "/quit")
98
127
  return 0;
99
- const exitCode = await sendOneTurn({ command, dashboardUrl, deviceToken: session.device_token }, prompt, io);
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);
100
136
  if (exitCode !== 0)
101
137
  return exitCode;
102
138
  }
@@ -216,6 +252,13 @@ async function sendOneTurn(context, prompt, io) {
216
252
  // works against both server versions with no flag.
217
253
  const wantsStream = context.command.stream !== false;
218
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;
219
262
  const requested = await towerRequest({
220
263
  dashboardUrl: context.dashboardUrl,
221
264
  path: "/api/jarvis/cli",
@@ -283,6 +326,11 @@ async function sendOneTurn(context, prompt, io) {
283
326
  // because it already sent every step live; the activity we collected is that
284
327
  // same trace, so `--json` still gets one.
285
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);
286
334
  if (context.command.json) {
287
335
  writeLine(io.stdout, JSON.stringify({
288
336
  ok: true,
@@ -291,6 +339,23 @@ async function sendOneTurn(context, prompt, io) {
291
339
  model: body.model ?? null,
292
340
  trace,
293
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 } : {}),
294
359
  }));
295
360
  }
296
361
  else {
@@ -324,9 +389,43 @@ async function sendOneTurn(context, prompt, io) {
324
389
  // dashboard means the answer landed all at once.
325
390
  streamed_chars: turn.draft.length,
326
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
+ }),
327
408
  })}`);
328
409
  return 0;
329
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
+ }
330
429
  /**
331
430
  * The answer as it is written, in a terminal (BLI-3517).
332
431
  *
@@ -524,7 +623,7 @@ function writeModelReceipt(io, model) {
524
623
  if (!model)
525
624
  return;
526
625
  const requested = typeof model.requestedModel === "string" ? model.requestedModel : null;
527
- const answered = typeof model.model === "string" ? model.model : null;
626
+ const answered = namedModel(model.model);
528
627
  const mismatched = requested !== null && answered !== null && requested !== answered;
529
628
  if (!model.fallback && !mismatched)
530
629
  return;
@@ -533,6 +632,47 @@ function writeModelReceipt(io, model) {
533
632
  // schema. Say what the receipt actually reports.
534
633
  writeLine(io.stdout, `Model: ${answered ?? "an unknown model"} answered instead of ${requested ?? "the requested model"}`);
535
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
+ }
536
676
  function writeFailure(command, io, reason, detail) {
537
677
  if (command.json) {
538
678
  writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
@@ -570,6 +570,32 @@ export function parseAgentRulesArgs(args) {
570
570
  json: values.booleans.has("--json"),
571
571
  };
572
572
  }
573
+ /**
574
+ * BLI-3580. `install` is the default action on purpose: this command exists to
575
+ * be run unasked (by `do-everything` and by the sync tick), and a bare
576
+ * `cockpit memory` should do the thing rather than print usage.
577
+ */
578
+ export function parseMemoryArgs(args) {
579
+ const values = parseNamedArgs(args, {
580
+ allowedFlags: ["--home", "--dashboard-url", "--dry-run", "--json"],
581
+ valueFlags: ["--home", "--dashboard-url"],
582
+ });
583
+ if (values.positionals.length > 1) {
584
+ throw new Error("memory accepts at most one action (install|status).");
585
+ }
586
+ const action = values.positionals[0] ?? "install";
587
+ if (action !== "install" && action !== "status") {
588
+ throw new Error("memory action must be install or status.");
589
+ }
590
+ return {
591
+ kind: "memory",
592
+ action,
593
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
594
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
595
+ dryRun: values.booleans.has("--dry-run"),
596
+ json: values.booleans.has("--json"),
597
+ };
598
+ }
573
599
  function parseAgentRulesHost(value) {
574
600
  const host = value?.trim().toLowerCase() || "all";
575
601
  if (host === "codex" || host === "claude" || host === "all")
@@ -32,6 +32,8 @@ export function parseJarvisArgs(args) {
32
32
  // something new. Neither takes a turn or reaches the model.
33
33
  "--threads",
34
34
  "--history",
35
+ // BLI-3560: reading back what a turn DID — the step tree of one turn.
36
+ "--trace",
35
37
  "--limit",
36
38
  "--json",
37
39
  ],
@@ -42,6 +44,7 @@ export function parseJarvisArgs(args) {
42
44
  "--as",
43
45
  "--thread",
44
46
  "--model",
47
+ "--trace",
45
48
  "--image",
46
49
  "--file",
47
50
  "--date",
@@ -87,6 +90,23 @@ export function parseJarvisArgs(args) {
87
90
  if ((threads || history) && date) {
88
91
  throw new Error("jarvis --threads and --history replay what was said; they bind no page.");
89
92
  }
93
+ // BLI-3560. `--trace` reads back what a turn DID, the same class of act as
94
+ // `--threads` and `--history`, and refused alongside them for the same
95
+ // reason: a command that quietly asked a question while you asked to see one
96
+ // is worse than a refusal.
97
+ const trace = optionalNonEmpty(values.flags.get("--trace"));
98
+ if (trace && (threads || history)) {
99
+ throw new Error("jarvis --trace shows one turn's steps; --threads and --history replay what was said. Pass one.");
100
+ }
101
+ if (trace && (flaggedPrompt || positionalPrompt)) {
102
+ throw new Error("jarvis --trace shows a turn that already ran; it does not take a question.");
103
+ }
104
+ if (trace && image) {
105
+ throw new Error("jarvis --trace does not take an attachment.");
106
+ }
107
+ if (trace !== undefined && !/^(last|[A-Za-z0-9_-]{1,64})$/.test(trace)) {
108
+ throw new Error("jarvis --trace takes `last` or a trace id (up to 64 letters, numbers, underscores or hyphens).");
109
+ }
90
110
  return {
91
111
  kind: "jarvis",
92
112
  homeDir: optionalNonEmpty(values.flags.get("--home")),
@@ -96,6 +116,7 @@ export function parseJarvisArgs(args) {
96
116
  thread,
97
117
  threads,
98
118
  history,
119
+ ...(trace ? { trace } : {}),
99
120
  limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit"),
100
121
  // BLI-3381: no client-side allowlist — the dashboard forwards this key
101
122
  // to the inference server's own allowlist and relays its refusal.
@@ -1,4 +1,4 @@
1
- import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
1
+ import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
2
2
  import { parseBriefArgs, parseCorrectArgs, parseJarvisArgs, parseModelArgs, parseNotesArgs, parseOpsArgs, parseScoutArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
3
3
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
4
4
  // `local-auth.ts` import it from here — so it stays exported from this address
@@ -80,6 +80,8 @@ export function parseLocalArgs(argv) {
80
80
  return parseAutostartArgs(argv.slice(1));
81
81
  case "agent-rules":
82
82
  return parseAgentRulesArgs(argv.slice(1));
83
+ case "memory":
84
+ return parseMemoryArgs(argv.slice(1));
83
85
  case "release":
84
86
  return parseReleaseArgs(argv.slice(1));
85
87
  default:
@@ -38,6 +38,7 @@ export const rootCommandNames = new Set([
38
38
  "serve",
39
39
  "autostart",
40
40
  "agent-rules",
41
+ "memory",
41
42
  "release",
42
43
  ]);
43
44
  export function localCommandHelp(command) {
@@ -56,7 +57,7 @@ export function localCommandHelp(command) {
56
57
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
57
58
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
58
59
  " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
59
- " 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>]] [--dashboard-url <url>] [--json]",
60
+ " 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]",
60
61
  " cockpit model [show|set <provider:model>] [--json]",
61
62
  " cockpit scout [start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
62
63
  " cockpit ops [status [--job <id>] [--skips] | recompile --person <email|name|id> [--dry-run]] [--dashboard-url <url>] [--json]",
@@ -74,6 +75,7 @@ export function localCommandHelp(command) {
74
75
  " cockpit serve [--port <port>] [--workspace <path>]",
75
76
  " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
76
77
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
78
+ " cockpit memory [install|status] [--dashboard-url <url>] [--dry-run] [--json]",
77
79
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
78
80
  "",
79
81
  `Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
@@ -279,7 +281,7 @@ function localSubcommandHelp(command) {
279
281
  [
280
282
  "jarvis",
281
283
  [
282
- "Usage: 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>]] [--dashboard-url <url>] [--json]",
284
+ "Usage: 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]",
283
285
  "",
284
286
  "Chats with the same JARVIS used by Tower web chat and the BLI Slack DM.",
285
287
  "--as selects the existing website person space; it changes who the chat is about, never who is authenticated.",
@@ -294,6 +296,7 @@ function localSubcommandHelp(command) {
294
296
  "A turn that outlives the dashboard's own ceiling is reported as turn_timed_out — an incomplete answer is never printed as a finished one.",
295
297
  "The command uses the existing paired device identity. It cannot override the caller, team, role, or person scope.",
296
298
  "--threads lists the terminal conversations on this account; --thread <name> --history replays one (the newest --limit <n> messages). Neither takes a question, and neither can reach anybody else's conversations.",
299
+ "--trace <id|last> prints the step tree of one turn — every model step, tool call and server-side memory step, with how long each took, which model ran it, the tokens it spent and what failed. `last` opens the turn this machine asked most recently. Like --threads it takes no question and reaches no model.",
297
300
  "Run `cockpit login` first if this machine is not paired.",
298
301
  ],
299
302
  ],
@@ -529,6 +532,20 @@ function localSubcommandHelp(command) {
529
532
  "Action defaults to `install`.",
530
533
  ],
531
534
  ],
535
+ [
536
+ "memory",
537
+ [
538
+ "Usage: cockpit memory [install|status] [--dashboard-url <url>] [--dry-run] [--json]",
539
+ "",
540
+ "Registers BLI Memory on this machine: the `bli-memory` MCP server plus the",
541
+ "recall/save hooks, for Claude Code (~/.claude.json, ~/.claude/settings.json)",
542
+ "and for Codex (~/.codex/config.toml, ~/.codex/skills/bli-memory/).",
543
+ "Idempotent: it merges with what is already there, never duplicates its own",
544
+ "entries, and reads the stored config back before reporting success.",
545
+ "`do-everything` runs it, and the sync tick re-runs it at most once a day.",
546
+ "Action defaults to `install`. See docs/runbooks/bli-memory-install.md.",
547
+ ],
548
+ ],
532
549
  [
533
550
  "release",
534
551
  [
@@ -30,6 +30,7 @@ import { runNotes } from "./notes.js";
30
30
  import { runServe } from "./serve.js";
31
31
  import { runAutostart } from "./autostart-command.js";
32
32
  import { runAgentRules } from "./agent-rules-command.js";
33
+ import { runMemoryInstall } from "./memory-install.js";
33
34
  import { parseLocalArgs } from "./local-args.js";
34
35
  // `./local.js` is the published entry point for this command surface: the
35
36
  // public CLI's generated root, commands/root.ts, doctor.ts and the test suite
@@ -125,6 +126,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
125
126
  return await runAutostart(command, io);
126
127
  case "agent-rules":
127
128
  return await runAgentRules(command, io);
129
+ case "memory":
130
+ return await runMemoryInstall(command, io);
128
131
  case "release":
129
132
  return await runRelease(command, io);
130
133
  }