@bli-cockpit/mcp 0.1.98 → 0.1.99

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.
@@ -110,7 +110,7 @@ export function registerNotesTools(server, deps) {
110
110
  }
111
111
  const invited = note.participants.length > 0 ? `\nInvited: ${note.participants.join(", ")}` : "";
112
112
  return textResult(`${note.title}\n${note.meetingDate} · ${note.shelf} · ${note.fileName} · ${note.lineCount} lines`
113
- + `${invited}\n${note.visibility}${body.audio ? "\nTranscription: " + body.audio.transcription_status : ""}\n\n${note.content}${narrowingNote(body)}`, { scope: body.scope ?? null, note, audio: body.audio ?? null });
113
+ + `${invited}\n${note.visibility}${body.audio ? "\nTranscription: " + body.audio.transcription_status + (body.audio.transcription_detail ? ` (${body.audio.transcription_detail})` : "") : ""}\n\n${note.content}${narrowingNote(body)}`, { scope: body.scope ?? null, note, audio: body.audio ?? null });
114
114
  }));
115
115
  register("notes_shelf", {
116
116
  title: "Your own Tower notes shelf",
@@ -25,14 +25,26 @@
25
25
  * it manages to send one. An MCP client's own deadline may still be shorter —
26
26
  * the description says so rather than letting a cut call read as a refusal.
27
27
  */
28
+ import { createReadStream } from "node:fs";
28
29
  import { readFile, stat } from "node:fs/promises";
29
30
  import path from "node:path";
31
+ import { Readable } from "node:stream";
30
32
  import { isSecretLikePathSegment } from "@bli-cockpit/telemetry-core";
31
33
  import { z } from "zod";
32
34
  import { callAgentDoor } from "./agent-door.js";
33
35
  import { CONFIRM_INPUT, doorFailureText, errorResult, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
34
36
  /** Mirrors `UPLOADED_NOTE_MAX_BYTES`; a courtesy check, not a second authority. */
35
37
  export const NOTE_FILE_MAX_BYTES = 20 * 1024 * 1024;
38
+ /**
39
+ * Audio over this goes straight to storage and is transcribed in the
40
+ * background (BLI-12963); at or under it, the upload route takes it. Mirrors
41
+ * `AUDIO_MAX_BYTES` in the dashboard's `lib/notes/audio.ts`.
42
+ */
43
+ const AUDIO_INLINE_MAX_BYTES = 4_000_000;
44
+ /** Mirrors the bucket's `file_size_limit`. */
45
+ const AUDIO_DIRECT_MAX_BYTES = 1_000_000_000;
46
+ /** A signed upload URL lives two hours. */
47
+ const STORAGE_PUT_DEADLINE_MS = 2 * 60 * 60 * 1000;
36
48
  /** The route's own ceiling is 300 s. Wait past it so its sentence wins. */
37
49
  const UPLOAD_DEADLINE_MS = 305_000;
38
50
  const WRITE_DEADLINE_MS = 60_000;
@@ -77,13 +89,18 @@ async function readNoteFile(filePath) {
77
89
  }
78
90
  if (size === 0)
79
91
  return refuse("file_empty");
80
- if (/\.(mp3|m4a|wav|webm|ogg)$/i.test(fileName) && size > 4_000_000) {
81
- return { ok: false, refusal: "file_too_big", sentence: "file_too_large: Audio is limited to 4 MB (4,000,000 bytes)." };
92
+ if (/\.(mp3|m4a|wav|webm|ogg)$/i.test(fileName)) {
93
+ if (size > AUDIO_DIRECT_MAX_BYTES) {
94
+ return { ok: false, refusal: "file_too_big", sentence: "file_too_large: Audio is limited to 1 GB (1,000,000,000 bytes)." };
95
+ }
96
+ // Big audio is never read into memory; the upload streams it from disk.
97
+ if (size > AUDIO_INLINE_MAX_BYTES)
98
+ return { ok: true, bytes: Buffer.alloc(0), fileName, size, direct: true };
82
99
  }
83
- if (size > NOTE_FILE_MAX_BYTES)
100
+ else if (size > NOTE_FILE_MAX_BYTES)
84
101
  return refuse("file_too_big");
85
102
  try {
86
- return { ok: true, bytes: await readFile(filePath), fileName };
103
+ return { ok: true, bytes: await readFile(filePath), fileName, size, direct: false };
87
104
  }
88
105
  catch {
89
106
  return refuse("file_unreadable");
@@ -108,11 +125,59 @@ async function postForm(deps, session, tool, form) {
108
125
  ? textResult(text, { ok: true, noteId: body.noteId ?? null, scope: body.scope ?? null, transcription: body.transcription ?? null })
109
126
  : errorResult(text);
110
127
  }
128
+ /**
129
+ * A whole-meeting recording: signed URL from Tower, the file streamed from disk
130
+ * to storage, then Tower told it is there (BLI-12963). Tower transcribes it in
131
+ * the background; poll notes_show for `transcription_detail` and the final
132
+ * transcript. The storage PUT carries the signed URL only, never the device
133
+ * token.
134
+ */
135
+ async function postAudioDirect(deps, session, read, filePath, mediaType, extras) {
136
+ const target = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/audio/target", { file_name: read.fileName, bytes: read.size, media_type: mediaType }, WRITE_DEADLINE_MS);
137
+ if (!target.ok)
138
+ return errorResult(doorFailureText("notes_upload", target));
139
+ const issued = target.body;
140
+ if (!issued.objectKey || !issued.upload?.url) {
141
+ return errorResult("Refused (target_unreadable): Tower answered without an upload link. Nothing was stored.");
142
+ }
143
+ let put;
144
+ try {
145
+ put = await deps.fetchImpl(issued.upload.url, {
146
+ method: issued.upload.method ?? "PUT",
147
+ headers: { ...(issued.upload.headers ?? {}), "content-length": String(read.size) },
148
+ body: Readable.toWeb(createReadStream(filePath)),
149
+ duplex: "half",
150
+ signal: AbortSignal.timeout(STORAGE_PUT_DEADLINE_MS),
151
+ });
152
+ }
153
+ catch (error) {
154
+ console.error("[notes_upload] storage put failed", JSON.stringify({ reason: "storage_unreachable", bytes: read.size, error_name: error?.name ?? "Error" }));
155
+ return errorResult("Refused (storage_unreachable): could not reach storage to upload the file. Nothing was saved; try again.");
156
+ }
157
+ if (!put.ok) {
158
+ console.error("[notes_upload] storage put refused", JSON.stringify({ reason: `storage_http_${put.status}`, bytes: read.size }));
159
+ return errorResult(put.status === 413
160
+ ? "Refused (storage_http_413): storage refused the file as too large. An admin may need to raise the project's storage file size limit."
161
+ : `Refused (storage_http_${put.status}): storage refused the upload. Nothing was saved; try again.`);
162
+ }
163
+ const saved = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/audio/saved", {
164
+ object_key: issued.objectKey, file_name: read.fileName,
165
+ ...(extras.exclude ? { exclusions: extras.exclude } : {}),
166
+ ...(extras.folder ? { folder_path: extras.folder } : {}),
167
+ }, WRITE_DEADLINE_MS);
168
+ if (!saved.ok)
169
+ return errorResult(doorFailureText("notes_upload", saved));
170
+ const body = saved.body;
171
+ const text = saidByTower(body);
172
+ return body.stored === true
173
+ ? textResult(text, { ok: true, noteId: body.noteId ?? null, scope: body.scope ?? null, transcription: body.transcription ?? null })
174
+ : errorResult(text);
175
+ }
111
176
  export function registerNotesWriteTools(server, deps) {
112
177
  const register = registrarFor(server);
113
178
  register("notes_upload", {
114
179
  title: "Put a local file in as a Tower meeting note",
115
- description: "Uploads a transcript, note, or MP3/M4A/WAV/WebM/OGG audio file from this machine. Audio is limited to 4 MB and returns a note id plus transcription status immediately. Poll notes_show for completion. "
180
+ description: "Uploads a transcript, note, or MP3/M4A/WAV/WebM/OGG audio file from this machine. Audio up to 4 MB goes through the upload door; a longer recording (up to 1 GB, a whole meeting) is sent straight to storage and transcribed in the background. Either way it returns a note id plus transcription status immediately. Poll notes_show for completion and progress (a long meeting takes several minutes). "
116
181
  + "A name that looks like a key or credential file is refused without being opened; so is a missing, empty or "
117
182
  + "over-20-MB file. Tower reads the whole note with one model call, so a large file can take a couple of "
118
183
  + "minutes and your client may give up first — the upload keeps going; notes_list will show it.",
@@ -131,9 +196,15 @@ export function registerNotesWriteTools(server, deps) {
131
196
  const read = await readNoteFile(filePath);
132
197
  if (!read.ok)
133
198
  return errorResult(`Refused (${read.refusal}): ${read.sentence} Nothing was sent.`);
134
- const form = new FormData();
135
199
  const audioTypes = { mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav", webm: "audio/webm", ogg: "audio/ogg" };
136
200
  const mediaType = audioTypes[path.extname(read.fileName).slice(1).toLowerCase()] ?? "";
201
+ if (read.direct) {
202
+ return postAudioDirect(deps, session, read, filePath, mediaType, {
203
+ ...(args.exclude ? { exclude: String(args.exclude) } : {}),
204
+ ...(args.folder ? { folder: String(args.folder) } : {}),
205
+ });
206
+ }
207
+ const form = new FormData();
137
208
  form.set("file", new File([new Uint8Array(read.bytes)], read.fileName, { type: mediaType }));
138
209
  if (args.exclude)
139
210
  form.set("exclusions", String(args.exclude));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.98",
3
+ "version": "0.1.99",
4
4
  "private": false,
5
5
  "description": "bli-tower: an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), the daily page (brief_*), meeting notes (notes_*), the ops board (ops_status/slack_*), settings/team/model, Scout and the workbook, plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
6
6
  "type": "module",