@bli-cockpit/cli 0.2.218 → 0.2.221

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,138 @@
1
+ /**
2
+ * The single-flight lock around the heartbeat's token exchange (BLI-17994).
3
+ *
4
+ * The heartbeat is the one door a device token is offered a successor
5
+ * through, and it is called from three places: the launchd / Task Scheduler
6
+ * tick, a manual `cockpit sync`, and `cockpit doctor`. Two of them can fire in
7
+ * the same second. Without a lock both read `session.json` holding the same
8
+ * token, both knock with it, and the second's rotation request races the
9
+ * first's reply (the server answers "rotation_pending", but two writers of
10
+ * `session.json` still interleave). With it they take turns: the second one
11
+ * reads the file AFTER the first stored the pending token and knocks with that,
12
+ * which is exactly the "first use" that promotes it.
13
+ *
14
+ * The same code runs on macOS and Windows: an exclusive-create (`wx`) lock file
15
+ * in the state directory, no `flock`, no platform branch. A holder that died
16
+ * leaves a file that goes stale after a minute and is taken over.
17
+ *
18
+ * The lock is a courtesy, never a gate. The server is safe without it (a
19
+ * request can never destroy a token that is still in use), so a lock file that
20
+ * cannot be created at all (read-only volume, antivirus) lets the heartbeat go
21
+ * ahead, loudly. Only a lock that stays HELD past the wait skips this tick, and
22
+ * a heartbeat is not spooled: the next tick is fifteen minutes away.
23
+ *
24
+ * A new module here must also join `scripts/build-public-cli.mjs`
25
+ * `runtimeFiles`, or the repo tests stay green while the packed CLI breaks.
26
+ */
27
+ import crypto from "node:crypto";
28
+ import fs from "node:fs/promises";
29
+ import path from "node:path";
30
+ import { describeError } from "../health-detail.js";
31
+ import { getCollectorRuntimePaths } from "../local-state.js";
32
+ const LOCK_FILENAME = "token-rotation.lock";
33
+ /** A heartbeat holds the lock for one HTTP call (5 s timeout) plus a few file reads. */
34
+ export const TOKEN_LOCK_STALE_MS = 60_000;
35
+ /** How long a second caller waits for the first before skipping this tick's heartbeat. */
36
+ export const TOKEN_LOCK_WAIT_MS = 8_000;
37
+ const POLL_MS = 150;
38
+ export async function acquireTokenRotationLock(homeDir, options = {}) {
39
+ const paths = getCollectorRuntimePaths(homeDir);
40
+ const lockPath = path.join(paths.state_dir, LOCK_FILENAME);
41
+ const waitMs = options.waitMs ?? TOKEN_LOCK_WAIT_MS;
42
+ const staleMs = options.staleMs ?? TOKEN_LOCK_STALE_MS;
43
+ const pollMs = options.pollMs ?? POLL_MS;
44
+ const owner = crypto.randomUUID();
45
+ const started = Date.now();
46
+ try {
47
+ await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
48
+ }
49
+ catch (error) {
50
+ return unlocked("state_dir_unavailable", error, started);
51
+ }
52
+ for (;;) {
53
+ try {
54
+ const handle = await fs.open(lockPath, "wx", 0o600);
55
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, owner, at_ms: Date.now() })}\n`);
56
+ await handle.close();
57
+ const waited = Date.now() - started;
58
+ if (waited > pollMs) {
59
+ console.error("[heartbeat] waited for another heartbeat to finish its token exchange", JSON.stringify({ reason: "token_lock_acquired_after_wait", waited_ms: waited }));
60
+ }
61
+ return {
62
+ acquired: true,
63
+ waited_ms: waited,
64
+ mode: "held",
65
+ release: async () => {
66
+ // Only remove a lock that is still ours: a holder that was taken over
67
+ // as stale must not delete its successor's lock.
68
+ const current = await readOwner(lockPath);
69
+ if (current === owner)
70
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
71
+ },
72
+ };
73
+ }
74
+ catch (error) {
75
+ if (error?.code !== "EEXIST") {
76
+ return unlocked("lock_file_unwritable", error, started);
77
+ }
78
+ }
79
+ const heldAt = await readHeldAt(lockPath);
80
+ if (heldAt === "gone")
81
+ continue; // released between our create and our read: try again at once
82
+ if (Date.now() - heldAt > staleMs) {
83
+ // A holder that died: take the lock over.
84
+ console.error("[heartbeat] removing a stale token-exchange lock", JSON.stringify({ reason: "token_lock_stale_taken_over", age_ms: Date.now() - heldAt }));
85
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
86
+ continue;
87
+ }
88
+ if (Date.now() - started >= waitMs) {
89
+ console.error("[heartbeat] another heartbeat is still exchanging the device token; skipping this tick's check-in", JSON.stringify({ reason: "token_lock_busy", waited_ms: Date.now() - started, next_action: "the next tick checks in" }));
90
+ return { acquired: false, waited_ms: Date.now() - started };
91
+ }
92
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
93
+ }
94
+ }
95
+ /** Run `work` under the lock; `skipped` is the answer when the lock stays held past the wait. */
96
+ export async function withTokenRotationLock(homeDir, options, skipped, work) {
97
+ const lock = await acquireTokenRotationLock(homeDir, options);
98
+ if (!lock.acquired)
99
+ return skipped;
100
+ try {
101
+ return await work();
102
+ }
103
+ finally {
104
+ await lock.release();
105
+ }
106
+ }
107
+ function unlocked(reason, error, started) {
108
+ console.error("[heartbeat] could not create the token-exchange lock; checking in without it (the dashboard stays safe without it)", JSON.stringify({ reason: `token_lock_${reason}`, ...describeError(error) }));
109
+ return { acquired: true, waited_ms: Date.now() - started, mode: "unlocked", release: async () => undefined };
110
+ }
111
+ async function readOwner(lockPath) {
112
+ try {
113
+ const raw = JSON.parse(await fs.readFile(lockPath, "utf8"));
114
+ return typeof raw["owner"] === "string" ? raw["owner"] : null;
115
+ }
116
+ catch {
117
+ // Gone or unreadable: not provably ours, so release leaves it alone.
118
+ return null;
119
+ }
120
+ }
121
+ /** When the lock was taken (ms), `"gone"` if the file no longer exists. A half-written file is dated by its mtime. */
122
+ async function readHeldAt(lockPath) {
123
+ try {
124
+ const raw = JSON.parse(await fs.readFile(lockPath, "utf8"));
125
+ if (typeof raw["at_ms"] === "number" && Number.isFinite(raw["at_ms"]))
126
+ return raw["at_ms"];
127
+ }
128
+ catch (error) {
129
+ if (error?.code === "ENOENT")
130
+ return "gone";
131
+ }
132
+ try {
133
+ return (await fs.stat(lockPath)).mtimeMs;
134
+ }
135
+ catch {
136
+ return "gone";
137
+ }
138
+ }
@@ -1,5 +1,8 @@
1
1
  import { describeError } from "../health-detail.js";
2
+ import { writeJsonFileAtomic } from "../local-state-files.js";
2
3
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, recordDeviceTokenExpiry, toSessionReference, } from "../local-state.js";
4
+ /** The request header that tells the dashboard this build stores a rotated token (BLI-17994). */
5
+ export const TOKEN_ROTATION_HEADER = "x-cockpit-token-rotation";
3
6
  /**
4
7
  * Read the session and decide whether this tick may check in.
5
8
  *
@@ -33,7 +36,7 @@ export async function readHeartbeatSession(homeDir) {
33
36
  console.error("[heartbeat] this machine's session looks expired; checking in anyway so the dashboard can renew it", JSON.stringify({
34
37
  reason: "session_expired_locally",
35
38
  expires_at: session.expires_at ?? null,
36
- next_action: "the dashboard renews a token that lapsed inside its 30-day grace window on this call",
39
+ next_action: "a token inside its rotation window is swapped on this call; one past its date needs `cockpit doctor`",
37
40
  }));
38
41
  }
39
42
  return { usable: true, session, state: state };
@@ -66,6 +69,43 @@ export async function recordHeartbeatTokenExpiry(homeDir, response) {
66
69
  return null;
67
70
  });
68
71
  }
72
+ /**
73
+ * Store the PENDING device token the heartbeat door handed back (BLI-17994).
74
+ *
75
+ * `reply` is the parsed heartbeat body. No `pending_device_token` in it is the
76
+ * normal case (nothing was due, or a successor is already waiting) and says
77
+ * nothing. When there is one, it is written to `session.json` in ONE atomic
78
+ * rename (write a temp file, rename over the session), so no reader (the sync,
79
+ * the memory hooks, the MCP servers) ever sees a half-written file. The date is
80
+ * deliberately NOT touched: a pending token has not been used yet, so the
81
+ * server has not moved the date. The first request that carries it promotes it
82
+ * and retires the old token; the reply to that request carries the new date,
83
+ * which `recordHeartbeatTokenExpiry` stores.
84
+ *
85
+ * A reply that is malformed, or a write that fails, keeps the current token,
86
+ * which stays valid until its own date; the server offers another pending
87
+ * token after ten minutes. Never throws.
88
+ */
89
+ export async function recordHeartbeatPendingToken(homeDir, reply) {
90
+ const incoming = reply?.["pending_device_token"];
91
+ if (incoming === undefined)
92
+ return { status: "none" };
93
+ if (typeof incoming !== "string" || !/^bli_dev_[A-Za-z0-9_-]{16,}$/.test(incoming)) {
94
+ console.error("[heartbeat] the dashboard sent a device token this build does not recognise; keeping the one it has", JSON.stringify({ reason: "pending_token_malformed" }));
95
+ return { status: "skipped", reason: "pending_token_malformed" };
96
+ }
97
+ const paths = getCollectorRuntimePaths(homeDir);
98
+ try {
99
+ const session = await readLocalCollectorSessionFile(paths);
100
+ await writeJsonFileAtomic(paths.session_file, { ...session, device_token: incoming });
101
+ console.error("[heartbeat] stored the pending device token; the first request that uses it makes it current", JSON.stringify({ reason: "pending_token_stored", expires_at: session.expires_at ?? null }));
102
+ return { status: "stored" };
103
+ }
104
+ catch (error) {
105
+ console.error("[heartbeat] the pending device token could not be stored; keeping the one this machine has, which stays valid until its date", JSON.stringify({ reason: "pending_token_write_failed", ...describeError(error) }));
106
+ return { status: "skipped", reason: "pending_token_write_failed" };
107
+ }
108
+ }
69
109
  async function readTokenExpiry(response) {
70
110
  try {
71
111
  const body = (await response.json());
@@ -31,7 +31,8 @@ import { readStagingInventory } from "../disk-usage.js";
31
31
  import { describeError } from "../health-detail.js";
32
32
  import { shouldSuppressFleetReceipts, } from "../dev-build.js";
33
33
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
34
- import { noteHeartbeatRefusal, readHeartbeatSession, recordHeartbeatTokenExpiry, } from "./heartbeat-token.js";
34
+ import { noteHeartbeatRefusal, readHeartbeatSession, recordHeartbeatTokenExpiry, recordHeartbeatPendingToken, TOKEN_ROTATION_HEADER, } from "./heartbeat-token.js";
35
+ import { withTokenRotationLock } from "./heartbeat-token-lock.js";
35
36
  import { classifySyncHealthError } from "../sync-health-class.js";
36
37
  import { readMemoryReceiptFile } from "./memory-install-receipt.js";
37
38
  import { readMemoryHookCounts } from "./memory-hook-counts.js";
@@ -220,6 +221,11 @@ export async function readHeartbeatMemoryReceipt(options) {
220
221
  * `sync.err.log` alone when the dashboard says a device is quiet.
221
222
  */
222
223
  export async function sendCollectorHeartbeatBestEffort(options) {
224
+ // BLI-17994. Single-flight: a launchd tick and a manual sync take turns, so
225
+ // the second reads `session.json` AFTER the first stored its pending token.
226
+ return withTokenRotationLock(options.homeDir, options.lock, false, () => sendCollectorHeartbeatLocked(options));
227
+ }
228
+ async function sendCollectorHeartbeatLocked(options) {
223
229
  // BLI-3554. A checkout's heartbeat would move `last_seen_at` on a real fleet
224
230
  // device row, so an agent's worktree could keep a genuinely dead machine
225
231
  // looking alive to the BLI-3550 reader.
@@ -282,6 +288,9 @@ export async function sendCollectorHeartbeatBestEffort(options) {
282
288
  headers: {
283
289
  "Content-Type": "application/json",
284
290
  Authorization: `Bearer ${session.device_token}`,
291
+ // BLI-17994. "I store a renewed token if you send one": the dashboard
292
+ // only rotates for a collector that says so.
293
+ [TOKEN_ROTATION_HEADER]: "1",
285
294
  },
286
295
  body: JSON.stringify(heartbeat),
287
296
  signal: controller.signal,
@@ -293,24 +302,33 @@ export async function sendCollectorHeartbeatBestEffort(options) {
293
302
  // that decides what a failure is — a 401/403 is `auth_failed` and
294
303
  // anything else is not, read off the observed status and never off the
295
304
  // words in a message (BLI-3551's rule).
305
+ // BLI-17994. The door names why it refused (`device_token_expired`,
306
+ // `device_token_replaced`) in a `reason` field; say it here rather than
307
+ // leaving a bare status.
308
+ const refusal = await Promise.resolve().then(() => response.clone().json()).catch(() => null);
296
309
  console.error("[heartbeat] the dashboard refused this tick's heartbeat", JSON.stringify({
297
310
  reason: "heartbeat_rejected",
298
311
  health_class: classifySyncHealthError({ httpStatus: response.status }),
299
312
  http_status: response.status,
300
313
  root_count: heartbeat.roots.length,
314
+ ...(typeof refusal?.["reason"] === "string" ? { server_reason: refusal["reason"] } : {}),
301
315
  ...(response.status === 401 || response.status === 403
302
316
  ? {
303
- next_action: "past the 30-day grace window this needs `cockpit login` on this machine",
317
+ next_action: "this token expired, was replaced or was revoked: run `cockpit doctor` to pair this machine again",
304
318
  }
305
319
  : {}),
306
320
  }));
307
321
  return false;
308
322
  }
309
- // BLI-4019. The server slides `token_expires_at` out on every call it
310
- // authenticates and answers with the result; the sibling records it, so
311
- // this machine's own copy follows the server rather than drifting.
323
+ // The reply carries `token_expires_at` (and a `pending_device_token` when
324
+ // the token was near its end); the sibling records both, so this machine's
325
+ // own copy follows the server rather than drifting.
312
326
  const reply = await response.clone().json().catch(() => null);
313
327
  noteDoctorRequest(reply?.device_action);
328
+ // BLI-17994. Store a pending successor if the door handed one out (the
329
+ // date does not move: a pending token has not been used), then the date the
330
+ // door reports, which moves only when this request promoted a token.
331
+ await recordHeartbeatPendingToken(options.homeDir, reply);
314
332
  await recordHeartbeatTokenExpiry(options.homeDir, response);
315
333
  console.error("[heartbeat] checked in", JSON.stringify({
316
334
  reason: "heartbeat_recorded",
@@ -215,6 +215,7 @@ const NOTES_ACTIONS = new Set([
215
215
  "webhook",
216
216
  // Ticket 10718: the Granola cron's own last-several-runs health.
217
217
  "granola-status",
218
+ "transcription-stats",
218
219
  "bot-upcoming",
219
220
  ]);
220
221
  /** Actions whose first positional is the note it acts on. */
@@ -252,6 +253,8 @@ export function parseNotesArgs(args) {
252
253
  "--since",
253
254
  "--until",
254
255
  "--limit",
256
+ "--month",
257
+ "--offset",
255
258
  "--yes",
256
259
  "--json",
257
260
  "--tree",
@@ -266,6 +269,10 @@ export function parseNotesArgs(args) {
266
269
  // reason `--key-stdin` is: the signing secret never touches argv.
267
270
  "--secret-stdin",
268
271
  "--show-webhook",
272
+ // BLI-18006. `sync <id> --repair-truncated` re-reads Wispr Flow notes whose
273
+ // transcript was stored cut short. A FLAG, not a verb: it is the same sync
274
+ // door with a different request.
275
+ "--repair-truncated",
269
276
  ],
270
277
  valueFlags: [
271
278
  "--home",
@@ -280,6 +287,8 @@ export function parseNotesArgs(args) {
280
287
  "--since",
281
288
  "--until",
282
289
  "--limit",
290
+ "--month",
291
+ "--offset",
283
292
  "--provider",
284
293
  "--workspace",
285
294
  "--show-webhook",
@@ -290,7 +299,7 @@ export function parseNotesArgs(args) {
290
299
  const first = values.positionals[0];
291
300
  const action = (first === undefined ? "list" : first);
292
301
  if (!NOTES_ACTIONS.has(action)) {
293
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, or granola-status.`);
302
+ throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, or transcription-stats.`);
294
303
  }
295
304
  const rest = values.positionals.slice(first === undefined ? 0 : 1);
296
305
  const json = values.booleans.has("--json");
@@ -395,7 +404,30 @@ export function parseNotesArgs(args) {
395
404
  throw new Error('notes move needs --to "<shelf>", --clear-shelf, or --folder "<path>".');
396
405
  }
397
406
  }
407
+ const repairTruncated = values.booleans.has("--repair-truncated");
408
+ if (repairTruncated && action !== "sync") {
409
+ throw new Error("--repair-truncated belongs to `cockpit notes sync <account-id> --repair-truncated`.");
410
+ }
398
411
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
412
+ const month = optionalNonEmpty(values.flags.get("--month"));
413
+ if (month !== undefined) {
414
+ if (action !== "transcription-stats") {
415
+ throw new Error("--month belongs to `cockpit notes transcription-stats --month YYYY-MM`.");
416
+ }
417
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) {
418
+ throw new Error("--month is a calendar month like 2026-10.");
419
+ }
420
+ }
421
+ // BLI-18006: a long note is read in parts, like `cockpit docs read --offset`.
422
+ const offsetRaw = optionalNonEmpty(values.flags.get("--offset"));
423
+ let offset;
424
+ if (offsetRaw !== undefined) {
425
+ if (action !== "show")
426
+ throw new Error("--offset belongs to `cockpit notes show <id> --offset <characters> --limit <characters>`.");
427
+ offset = Number(offsetRaw);
428
+ if (!Number.isInteger(offset) || offset < 0)
429
+ throw new Error("--offset must be a whole number 0 or greater.");
430
+ }
399
431
  return {
400
432
  kind: "notes",
401
433
  ...(values.booleans.has("--wait") ? { wait: true } : {}),
@@ -424,6 +456,9 @@ export function parseNotesArgs(args) {
424
456
  since: optionalNonEmpty(values.flags.get("--since")),
425
457
  until: optionalNonEmpty(values.flags.get("--until")),
426
458
  ...(limit === undefined ? {} : { limit }),
459
+ ...(month ? { month } : {}),
460
+ ...(offset === undefined ? {} : { offset }),
461
+ ...(repairTruncated ? { repairTruncated: true } : {}),
427
462
  yes,
428
463
  json,
429
464
  };
@@ -374,7 +374,7 @@ export const TOWER_COMMAND_HELP = [
374
374
  "upload, paste and move accept --folder <path>; use / for Root. Shelf placement stays independent.",
375
375
  "The /meeting-notes surface, typed. Bare `cockpit notes` lists the library.",
376
376
  "list [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] — every note you may open, grouped by shelf, newest meeting first. Never a word of a note.",
377
- "show <id> — one note with its text, its shelf, and why you are allowed to see it.",
377
+ "show <id> [--offset <chars>] [--limit <chars>] — one note with its text, its shelf, and why you are allowed to see it. A long transcript can be read in parts: --offset and --limit are characters (as in `docs read`), and the answer says the note's total size and the --offset to continue from (BLI-18006).",
378
378
  "shelf [--limit <n>] — the notes YOU have put in, and what became of each.",
379
379
  "shelves — the shelves in use, with how many notes are on each.",
380
380
  "upload <paths...> [--exclude \"<sentence>\"] — put one or more local files in. Explicit paths only; your shell does any globbing, and a file that does not exist is named rather than skipped.",
@@ -395,9 +395,11 @@ export const TOWER_COMMAND_HELP = [
395
395
  "connect --provider fellow|circleback [--workspace <subdomain>] --key-stdin [--name <n>] — connect one. Fellow needs --workspace, its API is per workspace. THE API KEY IS READ FROM STDIN, never a flag: printf \"%s\" \"<key>\" | cockpit notes connect --provider fellow --workspace <subdomain> --key-stdin",
396
396
  "detach <account-id> — take it off. The stored key goes with it; the meetings it already filed stay.",
397
397
  "sync <account-id> — read that notetaker now. Prints the run's own counts and reason label, and exits 1 when the pass failed.",
398
+ "sync <account-id> --repair-truncated — re-read Wispr Flow notes that were stored with only the first part of a long transcript (the \"truncated, N chars remaining\" line), and rewrite them whole. Same door and the same bounded passes; prints found, fixed and failed with each failure's reason, and is safe to run again (BLI-18006).",
398
399
  "sync-status [<account-id>] — every notetaker's health, or one's: last successful sync, the last pass's own reason, how many meetings it carried, failures in the last 24h, and the lag between the newest meeting it has read and when it last ran (ticket 10730).",
399
400
  "bot-upcoming [--json] — your own Google Meet meetings today that the bot will join, with claim state. Decline or mark private to opt out.",
400
401
  "granola-status [--limit <n>] [--json] — the Granola cron's own last several runs (ticket 10718): reason, counts, model, spend and which lookback it used. Granola runs on a shared server-side key, not a connected account, so this is its own read rather than `accounts`.",
402
+ "transcription-stats [--month YYYY-MM] [--json] — what meeting transcription cost and did (BLI-18003): minutes, dollars (list and real), requests, failures and fallbacks per provider for the UTC month (default: this one), the spend against the monthly cap and how close the 80% message and the hold are, how many meetings are waiting for budget, which provider keys the deploy holds (by name), and how old the numbers are. Switch providers or move the cap with `cockpit settings switches set transcription.provider|transcription.fallback_chain|transcription.monthly_cap_usd <value>`.",
401
403
  "A large note is read by a model on the server and can take a couple of minutes; the terminal says so before it waits.",
402
404
  "--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
403
405
  "Sharing and moving need a signed-in session the database can see. If this deployment cannot mint one, they are refused as needs_signed_in_session rather than done with no permission check — and reads say when they came back narrower than the browser's.",
@@ -107,9 +107,19 @@ export async function listShelves(command, door) {
107
107
  }
108
108
  return 0;
109
109
  }
110
+ /** `?offset=&limit=` for `notes show`: characters, like `docs read` (BLI-18006). */
111
+ function showQuery(command) {
112
+ const query = new URLSearchParams();
113
+ if (command.offset !== undefined)
114
+ query.set("offset", String(command.offset));
115
+ if (command.limit !== undefined)
116
+ query.set("limit", String(command.limit));
117
+ const rendered = query.toString();
118
+ return rendered === "" ? "" : `?${rendered}`;
119
+ }
110
120
  export async function showNote(command, door) {
111
121
  const answer = await ask(door, {
112
- path: `/api/notes/library/${encodeURIComponent(command.noteId ?? "")}`,
122
+ path: `/api/notes/library/${encodeURIComponent(command.noteId ?? "")}${showQuery(command)}`,
113
123
  method: "GET",
114
124
  label: "notes show",
115
125
  timeoutMs: READ_DEADLINE_MS,
@@ -135,6 +145,10 @@ export async function showNote(command, door) {
135
145
  const detail = body.audio.transcription_detail ? ` ${body.audio.transcription_detail}` : "";
136
146
  writeLine(door.io.stdout, `Transcription: ${body.audio.transcription_status}.${detail}`);
137
147
  }
148
+ if (body.slice) {
149
+ const { offset, returnedChars, totalChars, nextOffset } = body.slice;
150
+ writeLine(door.io.stdout, `Characters ${offset}-${offset + returnedChars} of ${totalChars}${nextOffset === null ? " (the end)" : `; continue with --offset ${nextOffset}`}.`);
151
+ }
138
152
  writeLine(door.io.stdout, "");
139
153
  writeLine(door.io.stdout, note.content.replace(/^In the room:/gm, "Invited:"));
140
154
  return 0;
@@ -23,6 +23,11 @@ const MAX_TRANSIENT_FAILURES = 3;
23
23
  const MAX_LIVE_WAITS = 40;
24
24
  /** How long to wait for a pass another caller has live before asking again. */
25
25
  const LIVE_PASS_WAIT_MS = 5_000;
26
+ /** The repair's own counts, with every failure's reason, in one line. */
27
+ function repairLine(repair) {
28
+ const why = Object.entries(repair.reasons).map(([reason, count]) => `${reason} ${count}`).join(", ");
29
+ return `found ${repair.found}, fixed ${repair.fixed}, failed ${repair.failed}${why ? ` (${why})` : ""}, ${repair.deferred} left${repair.capped ? ", list capped" : ""}`;
30
+ }
26
31
  export async function syncNotetaker(command, door) {
27
32
  const accountId = command.accountId ?? "";
28
33
  if (accountId === "") {
@@ -39,6 +44,11 @@ export async function syncNotetaker(command, door) {
39
44
  const pollMs = Number.parseInt(env["COCKPIT_NOTES_SYNC_POLL_MS"] ?? "", 10);
40
45
  const liveWaitMs = Number.isFinite(pollMs) && pollMs >= 0 ? pollMs : LIVE_PASS_WAIT_MS;
41
46
  const totals = { stored: 0, unchanged: 0, skipped: 0 };
47
+ // BLI-18006: `--repair-truncated` asks the same door to re-read Wispr Flow notes
48
+ // that were stored cut short. Fixed ones count as "unchanged" in the totals
49
+ // (the ledger's column), failed ones as "skipped"; the repair line says which.
50
+ const repairing = command.repairTruncated === true;
51
+ const repairTotals = { fixed: 0, failed: 0, found: 0 };
42
52
  let passes = 0;
43
53
  let stalled = 0;
44
54
  let liveWaits = 0;
@@ -48,8 +58,9 @@ export async function syncNotetaker(command, door) {
48
58
  const answer = await ask(door, {
49
59
  path: `/api/notes/accounts/${encodeURIComponent(accountId)}/sync`,
50
60
  method: "POST",
51
- label: "notes sync",
61
+ label: repairing ? "notes sync (repair truncated)" : "notes sync",
52
62
  timeoutMs: WRITE_DEADLINE_MS,
63
+ ...(repairing ? { body: { repair: "truncated_transcripts" } } : {}),
53
64
  });
54
65
  if (!answer.ok) {
55
66
  // A timeout or a 5xx mid-history is the call's, not the account's: the
@@ -87,7 +98,14 @@ export async function syncNotetaker(command, door) {
87
98
  totals.unchanged += run.notesUpdated;
88
99
  totals.skipped += run.notesSkipped;
89
100
  const waiting = typeof run.remaining === "number" ? `; ${run.remaining} waiting` : "";
90
- const line = `pass ${passes}: ${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped${run.more ? `${waiting}, continuing` : ""}.`;
101
+ if (run.repair) {
102
+ repairTotals.fixed += run.repair.fixed;
103
+ repairTotals.failed += run.repair.failed;
104
+ repairTotals.found = Math.max(repairTotals.found, run.repair.found);
105
+ }
106
+ const line = run.repair
107
+ ? `pass ${passes}: ${run.reason}: repair: ${repairLine(run.repair)}${run.more ? ", continuing" : ""}.`
108
+ : `pass ${passes}: ${run.reason}: ${run.notesAdded} stored, ${run.notesUpdated} unchanged, ${run.notesSkipped} skipped${run.more ? `${waiting}, continuing` : ""}.`;
91
109
  // Progress is commentary in --json (stdout is the one answer), and the
92
110
  // answer in a terminal.
93
111
  writeLine(door.json ? door.io.stderr : door.io.stdout, line);
@@ -98,8 +116,10 @@ export async function syncNotetaker(command, door) {
98
116
  }
99
117
  if (!run.more) {
100
118
  if (door.json)
101
- return emit(door, { ok: true, passes, totals, run });
102
- writeLine(door.io.stdout, `Caught up after ${passes} pass${passes === 1 ? "" : "es"}: ${totals.stored} stored, ${totals.unchanged} unchanged, ${totals.skipped} skipped.`);
119
+ return emit(door, { ok: true, passes, totals, ...(repairing ? { repair: repairTotals } : {}), run });
120
+ writeLine(door.io.stdout, repairing
121
+ ? `Repair finished after ${passes} pass${passes === 1 ? "" : "es"}: ${repairTotals.fixed} fixed, ${repairTotals.failed} failed (${repairTotals.found} carried the marker when the first pass began).`
122
+ : `Caught up after ${passes} pass${passes === 1 ? "" : "es"}: ${totals.stored} stored, ${totals.unchanged} unchanged, ${totals.skipped} skipped.`);
103
123
  return 0;
104
124
  }
105
125
  // A pass that handled nothing and still says "more" would loop forever.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `cockpit notes transcription-stats [--month YYYY-MM] [--json]` — what meeting
3
+ * transcription cost and did (BLI-18003). Sibling of `notes.ts`, named in its header.
4
+ *
5
+ * One read of `GET /api/notes/transcription-stats`, the same door the MCP tool
6
+ * `notes_transcription_stats` and JARVIS call. The terminal prints minutes and
7
+ * dollars per provider, the month's spend against the cap, and how old the
8
+ * numbers are; `--json` prints the door's body untouched.
9
+ */
10
+ import { writeLine } from "./cli-io.js";
11
+ import { ask, emit, fail, READ_DEADLINE_MS } from "./notes-door.js";
12
+ const usd = (value) => (value === null ? "unknown" : `$${value.toFixed(value < 1 ? 4 : 2)}`);
13
+ export function ageWords(seconds) {
14
+ if (seconds === null)
15
+ return "no request yet this month";
16
+ if (seconds < 90)
17
+ return `${seconds} s ago`;
18
+ if (seconds < 5400)
19
+ return `${Math.round(seconds / 60)} min ago`;
20
+ if (seconds < 172_800)
21
+ return `${Math.round(seconds / 3600)} h ago`;
22
+ return `${Math.round(seconds / 86_400)} days ago`;
23
+ }
24
+ const STATE_WORDS = {
25
+ no_cap: "no cap set", within_cap: "within the cap", alert_line: "past the 80% line", over_cap: "past the cap, still inside the $5 allowance",
26
+ waiting: "past cap plus the allowance: new meetings wait for budget", unknown: "spend could not be read",
27
+ };
28
+ export function renderTranscriptionStats(body) {
29
+ const lines = [];
30
+ const { budget } = body;
31
+ lines.push(`Meeting transcription, ${body.month} (UTC). As of ${body.as_of}; newest request ${ageWords(body.age_seconds)}.`);
32
+ lines.push(`Spend ${usd(budget.spend_usd)} of ${budget.cap_usd === null ? "no cap" : `$${budget.cap_usd.toFixed(0)}`}`
33
+ + `${budget.percent === null ? "" : ` (${budget.percent}%)`}: ${STATE_WORDS[budget.state] ?? budget.state}.`
34
+ + `${budget.alert_sent === null ? "" : budget.alert_sent ? " The 80% message was sent." : ""}`
35
+ + `${body.waiting_for_budget ? ` ${body.waiting_for_budget} meeting(s) waiting for budget.` : ""}`);
36
+ lines.push(`In force: provider ${body.settings.provider}, fallback ${body.settings.fallback_chain.join(" > ") || "none"}; order tried: ${body.settings.order.join(" > ") || "none (no key set)"}.`);
37
+ for (const skipped of body.settings.skipped)
38
+ lines.push(`Skipped ${skipped.id}: ${skipped.reason} (${skipped.key_name} is not set on this deploy).`);
39
+ lines.push("");
40
+ const header = ["provider", "model", "requests", "failed", "fallback", "minutes", "list $", "real $", "avg ms", "key"];
41
+ const rows = body.providers.map((p) => [
42
+ p.id, p.model, String(p.requests), String(p.failed_requests), String(p.fallback_requests), p.minutes.toFixed(1),
43
+ p.list_cost_usd.toFixed(4), p.effective_cost_usd.toFixed(4), p.avg_latency_ms === null ? "-" : String(p.avg_latency_ms), p.key_present ? "set" : `missing ${p.key_name}`,
44
+ ]);
45
+ const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
46
+ const format = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
47
+ lines.push(format(header), ...rows.map(format));
48
+ lines.push(format(["total", "", String(body.totals.requests), "", "", body.totals.minutes.toFixed(1), body.totals.list_cost_usd.toFixed(4), body.totals.effective_cost_usd.toFixed(4), "", ""]));
49
+ if (body.unreadable.length > 0)
50
+ lines.push("", `Could not read: ${body.unreadable.join(", ")}. The rest is shown as read.`);
51
+ return lines;
52
+ }
53
+ export async function showTranscriptionStats(command, door) {
54
+ const query = command.month === undefined ? "" : `?month=${encodeURIComponent(command.month)}`;
55
+ const answer = await ask(door, {
56
+ path: `/api/notes/transcription-stats${query}`,
57
+ method: "GET",
58
+ label: "notes transcription-stats",
59
+ timeoutMs: READ_DEADLINE_MS,
60
+ });
61
+ if (!answer.ok)
62
+ return fail(door, answer.reason, answer.detail);
63
+ const body = answer.body;
64
+ if (door.json)
65
+ return emit(door, { ok: true, ...body });
66
+ for (const line of renderTranscriptionStats(body))
67
+ writeLine(door.io.stdout, line);
68
+ return 0;
69
+ }
@@ -36,6 +36,8 @@
36
36
  * - `notes-granola-status.ts` — ticket 10718: the Granola CRON's own health
37
37
  * (it runs on a shared server-side key, not a `notes_accounts` row), read
38
38
  * off its `granola_sync_runs` ledger.
39
+ * - `notes-transcription-stats.ts` — BLI-18003: what meeting transcription
40
+ * cost and did per provider for a month, and the spend against the cap.
39
41
  *
40
42
  * Every public name is still importable from this file.
41
43
  */
@@ -46,11 +48,13 @@ import { runFolderCommand } from "./notes-folders.js";
46
48
  import { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
47
49
  import { showBotUpcoming } from "./notes-bot-upcoming.js";
48
50
  import { showGranolaStatus } from "./notes-granola-status.js";
51
+ import { showTranscriptionStats } from "./notes-transcription-stats.js";
49
52
  import { listRecordings, showRecording } from "./notes-recordings.js";
50
53
  export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
51
54
  export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
52
55
  export { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts, showNotesWebhook, syncNotetaker, syncStatusNotetakers, } from "./notes-accounts.js";
53
56
  export { showGranolaStatus } from "./notes-granola-status.js";
57
+ export { showTranscriptionStats } from "./notes-transcription-stats.js";
54
58
  export { listRecordings, showRecording } from "./notes-recordings.js";
55
59
  export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
56
60
  export async function runNotes(command, io) {
@@ -111,5 +115,7 @@ export async function runNotes(command, io) {
111
115
  return showBotUpcoming(command, door);
112
116
  case "granola-status":
113
117
  return showGranolaStatus(command, door);
118
+ case "transcription-stats":
119
+ return showTranscriptionStats(command, door);
114
120
  }
115
121
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.218");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.221");
25
25
  return 0;
26
26
  }
27
27
 
@@ -28,14 +28,15 @@ const FIXES = {
28
28
  * the same gap has two different next actions (BLI-4019).
29
29
  *
30
30
  * `device` says "run `cockpit login`" for every lapse, and for an expired
31
- * session that is now the wrong instruction most of the time: the dashboard
32
- * slides a device token's expiry out on every call it authenticates and renews
33
- * one that lapsed inside a 30-day grace window, so the ordinary answer is to do
34
- * nothing and let the next tick fix it. Telling somebody to sign in again on a
35
- * machine that is about to heal itself is how a receipt stops being believed.
31
+ * session that can be the wrong instruction: a device token lives 30 days and
32
+ * the heartbeat swaps it for a fresh one when 7 days or fewer are left
33
+ * (BLI-17994), so a machine whose local copy merely lags heals on the next
34
+ * tick. Only a token that stays expired (the machine was offline past its date)
35
+ * needs `cockpit doctor`. Telling somebody to sign in again on a machine that
36
+ * is about to heal itself is how a receipt stops being believed.
36
37
  */
37
38
  const REASON_FIXES = {
38
- session_expired: "Renews on the next sync tick while inside the 30-day grace window; past it, run `cockpit login`.",
39
+ session_expired: "Renews on the next sync tick; if it stays expired this machine was offline too long, so run `cockpit doctor` to pair it again.",
39
40
  };
40
41
  /** Codex hooks a person switched OFF is a decision, not a fault. */
41
42
  const UNSUPPORTED_FIX = "Switched off in your own config; nothing to do.";
@@ -93,8 +93,8 @@ async function healAgentRules(command, paths, now) {
93
93
  : { step: "agent_rules_install", status: "fail", error_code: "agent_rules_still_missing", error_detail: "before=missing; after=missing" };
94
94
  }
95
95
  /**
96
- * Sign-in: the heartbeat (which ran just before this) renews a lapsed token
97
- * inside the server's grace window without a browser. If this machine is STILL
96
+ * Sign-in: the heartbeat (which ran just before this) swaps a token near its
97
+ * end for a fresh one without a browser (BLI-17994). If this machine is STILL
98
98
  * signed out or the door refused the token (401/403), only a browser can fix
99
99
  * it, so record the one action and say it once a day instead of every fifteen
100
100
  * minutes. A lapsed local date with no refusal (offline, a checkout build)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.218",
3
+ "version": "0.2.221",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.66",
31
- "@bli-cockpit/mcp": "0.1.126",
31
+ "@bli-cockpit/mcp": "0.1.128",
32
32
  "@bli-cockpit/telemetry-core": "0.1.75"
33
33
  },
34
34
  "repository": {