@bli-cockpit/cli 0.2.183 → 0.2.185

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.
@@ -164,8 +164,9 @@ function eventLine(event, zone) {
164
164
  const when = event.all_day ? allDayLabel(event.starts_at, event.ends_at) : `${clock(event.starts_at, zone)}–${clock(event.ends_at, zone)}`;
165
165
  const marks = `${event.recurring ? "↻" : " "}${event.status === "tentative" ? "?" : " "}`;
166
166
  const where = event.location ? ` @ ${event.location.slice(0, 40)}` : "";
167
+ const meeting = meetingLinkText(event);
167
168
  const who = event.attendee_count > 0 ? ` (${event.attendee_count})` : "";
168
- return `${marks} ${when.padEnd(13)} ${event.title.slice(0, 52).padEnd(52)}${where}${who}`;
169
+ return `${marks} ${when.padEnd(13)} ${event.title.slice(0, 52).padEnd(52)}${where}${who}${meeting}`;
169
170
  }
170
171
  /**
171
172
  * BLI-3825: an all-day event spanning several days is ONE row from the door
@@ -229,7 +230,7 @@ async function find(command, door) {
229
230
  const when = match.next_occurrence
230
231
  ? `${dayLabel(match.next_occurrence, zone)} ${clock(match.next_occurrence, zone)}`
231
232
  : `last on ${dayLabel(match.starts_at, zone)}`;
232
- writeLine(door.io.stdout, `${match.recurring ? "↻" : " "} ${when.padEnd(20)} ${match.title.slice(0, 50).padEnd(50)} ${match.calendar_name}`);
233
+ writeLine(door.io.stdout, `${match.recurring ? "↻" : " "} ${when.padEnd(20)} ${match.title.slice(0, 50).padEnd(50)} ${match.calendar_name}${meetingLinkText(match)}`);
233
234
  }
234
235
  writeLine(door.io.stdout, "");
235
236
  writeLine(door.io.stdout, `${matches.length} match(es). Times are ${zone}.`);
@@ -307,4 +308,10 @@ async function create(command, door) {
307
308
  writeLine(door.io.stdout, `Invitations went to ${command.attendees.join(", ")} from Google.`);
308
309
  }
309
310
  return 0;
311
+ }
312
+ function meetingLinkText(event) {
313
+ const label = { meet: "Meet link", zoom: "Zoom link", teams: "Teams link" }[event.meeting_kind ?? ""] ?? "Meeting link";
314
+ const meeting = event.meeting_url ? ` ${label}: ${event.meeting_url}` : "";
315
+ console.error("[cal render] meeting link", JSON.stringify({ reason: event.meeting_url ? "meeting_link_shown" : "no_meeting_link" }));
316
+ return meeting;
310
317
  }
@@ -342,7 +342,7 @@ export const TOWER_COMMAND_HELP = [
342
342
  "notes",
343
343
  [
344
344
  "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>] [flags]",
345
- "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 4 MB. --wait polls transcription for at most 330 seconds.",
345
+ "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 1 GB. Over 4 MB (a whole meeting) the file goes straight to storage and is transcribed in the background; --wait then polls for up to 4 hours and prints progress, otherwise for at most 330 seconds.",
346
346
  "",
347
347
  "folders [--tree] [--json]: list folders and visible note counts.",
348
348
  "mkdir <path> [--json]: create a folder and any missing ancestors.",
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `cockpit notes upload` for a whole-meeting recording (BLI-12963).
3
+ *
4
+ * A recording over 4 MB cannot pass through the dashboard's upload route (a
5
+ * Vercel function accepts 4.5 MB). So the file goes straight to storage:
6
+ *
7
+ * 1. ask Tower for a signed upload URL POST /api/notes/audio/target
8
+ * 2. PUT the file from disk to that URL (streamed; never held in memory)
9
+ * 3. tell Tower it is there POST /api/notes/audio/saved
10
+ *
11
+ * Tower then transcribes it in the background and the note shows progress.
12
+ * Step 2 goes to the storage host with the signed URL ONLY: the device token is
13
+ * sent to Tower's own routes in steps 1 and 3 and never to anyone else.
14
+ *
15
+ * Nothing here throws; every failure comes back as a closed reason label and
16
+ * the sentence Tower (or this file) wrote for it.
17
+ */
18
+ import { createReadStream } from "node:fs";
19
+ import { Readable } from "node:stream";
20
+ import { writeLine } from "./cli-io.js";
21
+ import { ask, TAG } from "./notes-door.js";
22
+ import { noteAudioMime } from "./notes-file.js";
23
+ /** A signed upload URL lives two hours; the PUT must finish inside that. */
24
+ const PUT_DEADLINE_MS = 2 * 60 * 60 * 1000;
25
+ const SMALL_DEADLINE_MS = 60_000;
26
+ export async function uploadAudioDirect(door, input) {
27
+ const started = Date.now();
28
+ const mediaType = noteAudioMime(input.fileName) ?? "";
29
+ const target = await ask(door, {
30
+ path: "/api/notes/audio/target", method: "POST", label: "notes audio target", timeoutMs: SMALL_DEADLINE_MS,
31
+ body: { file_name: input.fileName, bytes: input.size, media_type: mediaType },
32
+ });
33
+ if (!target.ok)
34
+ return { ok: false, reason: target.reason, detail: target.detail };
35
+ const issued = target.body;
36
+ const url = issued.upload?.url;
37
+ if (!issued.objectKey || !url) {
38
+ return { ok: false, reason: "target_unreadable", detail: "Tower answered without an upload link." };
39
+ }
40
+ writeLine(door.io.stderr, `Uploading ${input.fileName} (${(input.size / 1_000_000).toFixed(1)} MB) straight to storage...`);
41
+ let put;
42
+ try {
43
+ put = await door.io.fetch(url, {
44
+ method: issued.upload?.method ?? "PUT",
45
+ headers: { ...(issued.upload?.headers ?? {}), "content-length": String(input.size) },
46
+ body: Readable.toWeb(createReadStream(input.filePath)),
47
+ // Node's fetch requires this for a streamed request body.
48
+ duplex: "half",
49
+ signal: AbortSignal.timeout(PUT_DEADLINE_MS),
50
+ });
51
+ }
52
+ catch (error) {
53
+ writeLine(door.io.stderr, `${TAG} storage put failed ${JSON.stringify({ reason: "storage_unreachable", bytes: input.size })}`);
54
+ return { ok: false, reason: "storage_unreachable",
55
+ detail: `Could not reach storage to upload the file (${error instanceof Error ? error.name : "error"}). Nothing was saved; try again.` };
56
+ }
57
+ if (!put.ok) {
58
+ // The storage host's reply can quote the object path; only the status leaves this function.
59
+ writeLine(door.io.stderr, `${TAG} storage put refused ${JSON.stringify({ reason: `storage_http_${put.status}`, bytes: input.size })}`);
60
+ return { ok: false, reason: `storage_http_${put.status}`,
61
+ detail: put.status === 413
62
+ ? "Storage refused the file as too large. An admin may need to raise the project's storage file size limit."
63
+ : `Storage refused the upload (HTTP ${put.status}). Nothing was saved; try again.` };
64
+ }
65
+ writeLine(door.io.stderr, `${TAG} storage put ok ${JSON.stringify({ bytes: input.size, elapsed_ms: Date.now() - started })}`);
66
+ const saved = await ask(door, {
67
+ path: "/api/notes/audio/saved", method: "POST", label: "notes audio saved", timeoutMs: SMALL_DEADLINE_MS,
68
+ body: {
69
+ object_key: issued.objectKey, file_name: input.fileName,
70
+ ...(input.exclude ? { exclusions: input.exclude } : {}),
71
+ ...(input.folder ? { folder_path: input.folder } : {}),
72
+ },
73
+ });
74
+ if (!saved.ok)
75
+ return { ok: false, reason: saved.reason, detail: saved.detail };
76
+ return { ok: true, body: saved.body };
77
+ }
@@ -39,6 +39,15 @@ export function noteAudioMime(name) {
39
39
  return mime[name.split(".").at(-1)?.toLowerCase() ?? ""];
40
40
  }
41
41
  export const NOTE_FILE_MAX_BYTES = 20 * 1024 * 1024;
42
+ /**
43
+ * The most audio that goes THROUGH the upload route (a Vercel function accepts
44
+ * 4.5 MB per request). Bigger audio goes straight to storage on a signed URL
45
+ * and is transcribed in the background (BLI-12963); this mirrors
46
+ * `AUDIO_MAX_BYTES` in the dashboard's `lib/notes/audio.ts`.
47
+ */
48
+ export const AUDIO_INLINE_MAX_BYTES = 4_000_000;
49
+ /** The most audio the direct path takes; mirrors the bucket's `file_size_limit`. */
50
+ export const AUDIO_DIRECT_MAX_BYTES = 1_000_000_000;
42
51
  /**
43
52
  * Above this, the terminal says it is working before it starts. The route's own
44
53
  * ceiling is `maxDuration = 300` because reading a note is one model call over
@@ -56,7 +65,7 @@ export function noteFileRefusalSentence(refusal, filePath) {
56
65
  case "file_empty":
57
66
  return `There is nothing in that file: ${filePath}`;
58
67
  case "file_too_big":
59
- return "That file is too big. Audio is limited to 4 MB; other notes to 20 MB.";
68
+ return "That file is too big. Audio is limited to 1 GB (4 MB for anything but `cockpit notes upload`); other notes to 20 MB.";
60
69
  case "looks_like_a_key_file":
61
70
  // The same rule the server's gate applies, said the same way: the name is
62
71
  // all it takes to decide, and looking inside to be sure would already be
@@ -70,7 +79,7 @@ export function noteFileRefusalSentence(refusal, filePath) {
70
79
  * The secret-shaped-name check runs FIRST and on the name alone — the bytes are
71
80
  * never read for a file it turns away.
72
81
  */
73
- export async function readNoteFile(filePath) {
82
+ export async function readNoteFile(filePath, options = {}) {
74
83
  const fileName = path.basename(filePath);
75
84
  const extension = path.extname(filePath).toLowerCase();
76
85
  if (isSecretLikePathSegment(fileName)) {
@@ -92,10 +101,19 @@ export async function readNoteFile(filePath) {
92
101
  }
93
102
  if (size === 0)
94
103
  return { ok: false, refusal: "file_empty", detail: "byte_size_0" };
95
- if (noteAudioMime(fileName) && size > 4_000_000) {
96
- return { ok: false, refusal: "file_too_big", detail: "file_too_large: audio limit is 4 MB (4,000,000 bytes)" };
104
+ if (noteAudioMime(fileName)) {
105
+ // Only `cockpit notes upload` has a direct-to-storage path; every other
106
+ // caller wants the bytes, and still refuses audio the route cannot take.
107
+ const ceiling = options.allowDirectAudio ? AUDIO_DIRECT_MAX_BYTES : AUDIO_INLINE_MAX_BYTES;
108
+ if (size > ceiling) {
109
+ return { ok: false, refusal: "file_too_big", detail: `file_too_large: audio limit is ${ceiling} bytes` };
110
+ }
111
+ // Big audio is never read into memory here; the upload streams it from disk.
112
+ if (size > AUDIO_INLINE_MAX_BYTES) {
113
+ return { ok: true, bytes: Buffer.alloc(0), fileName, extension: extension || "none", size, direct: true };
114
+ }
97
115
  }
98
- if (size > NOTE_FILE_MAX_BYTES) {
116
+ else if (size > NOTE_FILE_MAX_BYTES) {
99
117
  return { ok: false, refusal: "file_too_big", detail: `byte_size_${size}` };
100
118
  }
101
119
  let bytes;
@@ -105,7 +123,7 @@ export async function readNoteFile(filePath) {
105
123
  catch (error) {
106
124
  return { ok: false, refusal: "file_unreadable", detail: errorMessage(error) };
107
125
  }
108
- return { ok: true, bytes, fileName, extension: extension || "none" };
126
+ return { ok: true, bytes, fileName, extension: extension || "none", size, direct: false };
109
127
  }
110
128
  /**
111
129
  * Decodes note/env bytes to text the way an editor would, not the way UTF-8
@@ -126,11 +126,17 @@ export async function showNote(command, door) {
126
126
  writeLine(door.io.stdout, note.title);
127
127
  writeLine(door.io.stdout, `${note.meetingDate} · ${note.shelf} · ${note.fileName} · ${note.lineCount} lines`);
128
128
  if (note.participants.length > 0) {
129
- writeLine(door.io.stdout, `In the room: ${note.participants.join(", ")}`);
129
+ writeLine(door.io.stdout, `Invited: ${note.participants.join(", ")}`);
130
130
  }
131
131
  writeLine(door.io.stdout, note.visibility);
132
+ // A recording is transcribed in the background: say where that stands, and why
133
+ // when it has stopped, rather than letting the placeholder body read as the note.
134
+ if (body.audio?.transcription_status) {
135
+ const detail = body.audio.transcription_detail ? ` ${body.audio.transcription_detail}` : "";
136
+ writeLine(door.io.stdout, `Transcription: ${body.audio.transcription_status}.${detail}`);
137
+ }
132
138
  writeLine(door.io.stdout, "");
133
- writeLine(door.io.stdout, note.content);
139
+ writeLine(door.io.stdout, note.content.replace(/^In the room:/gm, "Invited:"));
134
140
  return 0;
135
141
  }
136
142
  export async function showShelf(command, door) {
@@ -6,6 +6,7 @@
6
6
  import { isInteractiveStdin, readPipedText, writeLine, askYesNo } from "./cli-io.js";
7
7
  import { NOTE_SLOW_UPLOAD_BYTES, noteAudioMime, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
8
8
  import { ask, emit, fail, sayUpload, errorText, TAG, READ_DEADLINE_MS } from "./notes-door.js";
9
+ import { uploadAudioDirect } from "./notes-audio-direct.js";
9
10
  /**
10
11
  * The upload route's own ceiling is `maxDuration = 300` — reading a note is one
11
12
  * model call over the whole file. The client waits slightly longer so the
@@ -20,7 +21,7 @@ export async function uploadNotes(command, door) {
20
21
  for (const filePath of paths) {
21
22
  // Read and screen locally BEFORE any network call — a refusal here never
22
23
  // reaches the dashboard, same discipline as `cockpit jarvis --image`.
23
- const read = await readNoteFile(filePath);
24
+ const read = await readNoteFile(filePath, { allowDirectAudio: true });
24
25
  if (!read.ok) {
25
26
  const sentence = noteFileRefusalSentence(read.refusal, filePath);
26
27
  writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
@@ -30,24 +31,36 @@ export async function uploadNotes(command, door) {
30
31
  worstExit = 1;
31
32
  continue;
32
33
  }
33
- if (read.bytes.byteLength >= NOTE_SLOW_UPLOAD_BYTES) {
34
+ if (!read.direct && read.bytes.byteLength >= NOTE_SLOW_UPLOAD_BYTES) {
34
35
  // The route reads the whole note with one model call and may take
35
36
  // minutes. A still cursor reads as a hang, so say what is happening.
36
37
  writeLine(door.io.stderr, `Reading ${read.fileName} (${Math.round(read.bytes.byteLength / 1024)} KB). This can take a couple of minutes.`);
37
38
  }
38
- const form = new FormData();
39
- form.set("file", new File([new Uint8Array(read.bytes)], read.fileName, { type: noteAudioMime(read.fileName) ?? "" }));
40
- if (command.exclude)
41
- form.set("exclusions", command.exclude);
42
- if (command.folder)
43
- form.set("folder_path", command.folder);
44
- const answer = await ask(door, {
45
- path: "/api/notes/upload",
46
- method: "POST",
47
- label: "notes upload",
48
- timeoutMs: UPLOAD_DEADLINE_MS,
49
- body: form,
50
- });
39
+ // A whole-meeting recording (over 4 MB) goes straight to storage and is
40
+ // transcribed in the background (BLI-12963); everything else keeps the
41
+ // upload route.
42
+ let answer;
43
+ if (read.direct) {
44
+ const direct = await uploadAudioDirect(door, {
45
+ filePath, fileName: read.fileName, size: read.size, exclude: command.exclude, folder: command.folder,
46
+ });
47
+ answer = direct.ok ? { ok: true, body: direct.body } : { ok: false, reason: direct.reason, detail: direct.detail };
48
+ }
49
+ else {
50
+ const form = new FormData();
51
+ form.set("file", new File([new Uint8Array(read.bytes)], read.fileName, { type: noteAudioMime(read.fileName) ?? "" }));
52
+ if (command.exclude)
53
+ form.set("exclusions", command.exclude);
54
+ if (command.folder)
55
+ form.set("folder_path", command.folder);
56
+ answer = await ask(door, {
57
+ path: "/api/notes/upload",
58
+ method: "POST",
59
+ label: "notes upload",
60
+ timeoutMs: UPLOAD_DEADLINE_MS,
61
+ body: form,
62
+ });
63
+ }
51
64
  if (!answer.ok) {
52
65
  writeLine(door.io.stderr, `${TAG} upload failed ${JSON.stringify({ reason: answer.reason })}`);
53
66
  if (!door.json)
@@ -58,7 +71,7 @@ export async function uploadNotes(command, door) {
58
71
  }
59
72
  const body = answer.body;
60
73
  if (command.wait && body.noteId && body.transcription?.status === "pending") {
61
- body.transcription = await waitForTranscription(door, body.noteId);
74
+ body.transcription = await waitForTranscription(door, body.noteId, read.direct);
62
75
  }
63
76
  if (body.transcription?.status === "failed")
64
77
  worstExit = 1;
@@ -73,7 +86,8 @@ export async function uploadNotes(command, door) {
73
86
  stored: body.stored === true,
74
87
  note_id: body.noteId ?? null,
75
88
  scope: body.scope ?? null,
76
- byte_size: read.bytes.byteLength,
89
+ byte_size: read.size,
90
+ direct: read.direct,
77
91
  extension: read.extension,
78
92
  })}`);
79
93
  }
@@ -257,22 +271,37 @@ export async function moveNote(command, door) {
257
271
  writeLine(door.io.stdout, line);
258
272
  return body.ok === true ? 0 : 1;
259
273
  }
260
- /** Bounded polling of the same note read used by the browser and MCP. */
261
- async function waitForTranscription(door, noteId) {
262
- const deadline = Date.now() + 330_000;
263
- for (let attempt = 0; attempt < 110 && Date.now() < deadline; attempt++) {
274
+ /**
275
+ * Bounded polling of the same note read used by the browser and MCP. A small
276
+ * file is transcribed inside the upload request's own budget, so five and a
277
+ * half minutes is plenty; a whole-meeting recording is transcribed by the
278
+ * background job, which can take an hour, so `--wait` there polls for up to
279
+ * four hours and prints each new progress line.
280
+ */
281
+ async function waitForTranscription(door, noteId, background = false) {
282
+ const budgetMs = background ? 4 * 60 * 60 * 1000 : 330_000;
283
+ const intervalMs = background ? 10_000 : 3000;
284
+ const deadline = Date.now() + budgetMs;
285
+ let lastDetail = "";
286
+ while (Date.now() < deadline) {
264
287
  const answer = await ask(door, { path: `/api/notes/library/${encodeURIComponent(noteId)}`,
265
288
  method: "GET", label: "notes transcription", timeoutMs: Math.min(10_000, deadline - Date.now()) });
266
289
  if (!answer.ok)
267
290
  return { status: "failed", reason: answer.reason };
268
- const status = answer.body.audio?.transcription_status;
291
+ const audio = answer.body.audio;
292
+ const status = audio?.transcription_status;
269
293
  if (status === "done")
270
294
  return { status: "done" };
271
295
  if (status?.startsWith("failed:"))
272
296
  return { status: "failed", reason: status.slice(7) };
273
297
  if (status !== "pending")
274
298
  return { status: "failed", reason: "transcription_status_unavailable" };
275
- await new Promise((resolve) => setTimeout(resolve, Math.min(3000, Math.max(0, deadline - Date.now()))));
299
+ if (audio?.transcription_detail && audio.transcription_detail !== lastDetail) {
300
+ lastDetail = audio.transcription_detail;
301
+ if (!door.json)
302
+ writeLine(door.io.stderr, lastDetail);
303
+ }
304
+ await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, Math.max(0, deadline - Date.now()))));
276
305
  }
277
306
  return { status: "failed", reason: "transcription_wait_timeout" };
278
307
  }
@@ -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.183");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.185");
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.183",
3
+ "version": "0.2.185",
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.55",
31
- "@bli-cockpit/mcp": "0.1.97",
31
+ "@bli-cockpit/mcp": "0.1.99",
32
32
  "@bli-cockpit/telemetry-core": "0.1.66"
33
33
  },
34
34
  "repository": {