@bli-cockpit/cli 0.2.219 → 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.
@@ -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.219");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.221");
25
25
  return 0;
26
26
  }
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.219",
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": {