@bli-cockpit/cli 0.2.184 → 0.2.186

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.
@@ -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,9 @@
1
+ /** Cross-project search always names the project, after the date and before the author. */
2
+ export function memoryProjectPrefix(row) {
3
+ if (typeof row["project"] !== "string" || !row["project"])
4
+ return "";
5
+ const date = typeof row["createdAt"] === "string" ? new Date(row["createdAt"]) : null;
6
+ const day = date && !Number.isNaN(date.getTime()) ? `[${date.toISOString().slice(0, 10)}] ` : "";
7
+ const author = typeof row["savedBy"] === "string" && row["savedBy"] ? `(from ${row["savedBy"]}) ` : "";
8
+ return `${day}[${row["project"]}] ${author}`;
9
+ }
@@ -42,6 +42,7 @@
42
42
  * `ps` shows an argument list, shell history keeps it, and a memory with a
43
43
  * newline in it cannot be quoted correctly anyway.
44
44
  */
45
+ import { memoryProjectPrefix } from "./memory-project-label.js";
45
46
  import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.js";
46
47
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
47
48
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
@@ -208,7 +209,7 @@ async function searchMemory(command, door) {
208
209
  }
209
210
  else {
210
211
  for (const row of results) {
211
- writeLine(door.io.stdout, `${similarityLabel(row)}${oneLine(row["memory"])}`);
212
+ writeLine(door.io.stdout, `${similarityLabel(row)}${memoryProjectPrefix(row)}${oneLine(row["memory"])}`);
212
213
  writeLine(door.io.stdout, ` ${String(row["id"] ?? "")} ${String(row["containerTag"] ?? "")}${filedByLabel(row)}`);
213
214
  }
214
215
  }
@@ -217,7 +218,7 @@ async function searchMemory(command, door) {
217
218
  writeLine(door.io.stdout, "Recent context:");
218
219
  for (const row of recent) {
219
220
  const author = typeof row["savedBy"] === "string" && row["savedBy"] ? `(from ${row["savedBy"]}) ` : "";
220
- writeLine(door.io.stdout, ` - ${author}${oneLine(row["preview"] ?? row["memory"])}`);
221
+ writeLine(door.io.stdout, ` - ${memoryProjectPrefix(row)}${row["project"] ? "" : author}${oneLine(row["preview"] ?? row["memory"])}`);
221
222
  }
222
223
  }
223
224
  if (body.degraded) {
@@ -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
@@ -129,6 +129,12 @@ export async function showNote(command, door) {
129
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
139
  writeLine(door.io.stdout, note.content.replace(/^In the room:/gm, "Invited:"));
134
140
  return 0;
@@ -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,38 @@ 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
+ const pause = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
305
+ await (door.io.sleep ? door.io.sleep(pause) : new Promise((resolve) => setTimeout(resolve, pause)));
276
306
  }
277
307
  return { status: "failed", reason: "transcription_wait_timeout" };
278
308
  }
@@ -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.184");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.186");
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.184",
3
+ "version": "0.2.186",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,8 +27,8 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.55",
31
- "@bli-cockpit/mcp": "0.1.98",
30
+ "@bli-cockpit/memory-mcp": "0.1.56",
31
+ "@bli-cockpit/mcp": "0.1.99",
32
32
  "@bli-cockpit/telemetry-core": "0.1.66"
33
33
  },
34
34
  "repository": {