@bike4mind/cli 2.1.0 → 3.1.0

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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/dist/{AgentHistoryStore-DdYKfx7C.mjs → AgentHistoryStore-O2gKB5YG.mjs} +7958 -7053
  3. package/dist/{ConfigStore-BygzEI6H.mjs → ConfigStore-RHqyXcSS.mjs} +10291 -6263
  4. package/dist/{ImageStore-kVo-oHoS.mjs → ImageStore-DuI3tgwQ.mjs} +8 -6
  5. package/dist/SandboxRuntimeAdapter-Bt1wPpld.mjs +2 -0
  6. package/dist/{WorkItemsClient-ClBC49y0.mjs → WorkItemsClient-cmw7WYBy.mjs} +27 -2
  7. package/dist/{buildAgent-B7cMwRAy.mjs → buildAgent-C0jmyDL4.mjs} +75 -43
  8. package/dist/commands/acpCommand.mjs +26 -35
  9. package/dist/commands/apiCommand.mjs +1 -1
  10. package/dist/commands/doctorCommand.mjs +3 -3
  11. package/dist/commands/envCommand.mjs +1 -1
  12. package/dist/commands/headlessCommand.mjs +15 -24
  13. package/dist/commands/mcpCommand.mjs +3 -3
  14. package/dist/commands/pluginCommand.mjs +2 -2
  15. package/dist/commands/updateCommand.mjs +5 -15
  16. package/dist/dist-B1bGf-yn.mjs +739 -0
  17. package/dist/index.mjs +602 -324
  18. package/dist/{package-CRNnNXxq.mjs → package-DsG8Tgvs.mjs} +1 -1
  19. package/dist/{serve-DM7ld3-o.mjs → serve-CAfCQose.mjs} +344 -154
  20. package/dist/store-BkMur6uk.mjs +2 -0
  21. package/dist/{store-CvjTpQPs.mjs → store-BxmPEI3v.mjs} +10 -0
  22. package/dist/types-I45lMh7Q.mjs +2 -0
  23. package/dist/{updateChecker-CQW8bxo6.mjs → updateChecker-CCLVVdH0.mjs} +36 -13
  24. package/package.json +24 -23
  25. package/dist/ApiClient-dIB3YQc2.mjs +0 -316
  26. package/dist/SandboxRuntimeAdapter-BmHELLuM.mjs +0 -3
  27. package/dist/store-BG3e54c8.mjs +0 -3
  28. package/dist/types-CdIKgWWe.mjs +0 -3
  29. package/dist/{bashExecute-CrdPpBqk-DCATrE-D.mjs → bashExecute-CrdPpBqk-CgK1gZa2.mjs} +1 -1
  30. package/dist/{grepSearch-BgoOOwGe-DtlV8Gn-.mjs → grepSearch-BgoOOwGe-B388GxRD.mjs} +2 -2
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { Ct as ttsResponseTooLargeSchema, D as BriefcasePromptIdSchema, F as ImagePromptResolutionSchema, N as GENERATED_IMAGE_EXTENSION_RE, P as ImageModels, St as ttsRequestSchema, T as BRIEFCASE_CATALOG_QUERIES, U as PROMPT_TEXT_MAX, Z as buildPromptContext, _ as requireApiUrl, b as AGENT_QUEST_MANIFEST, ft as questReplyText, g as parseApiUrl, j as DEFAULT_TTS_PROVIDER, n as logger, pt as replacePromptVariables, t as ConfigStore, tt as generatedAudioResponseSchema, ut as joinReplySlots, x as AGENT_QUEST_MCP_URI, xt as ttsBase64ResponseSchema, y as AGENT_QUEST_ID, yt as supportedVoiceGenerationVendor } from "./ConfigStore-BygzEI6H.mjs";
3
- import { n as NotAuthenticatedError, r as isProviderKeyFailure, t as ApiClient } from "./ApiClient-dIB3YQc2.mjs";
2
+ import { A as BRIEFCASE_CATALOG_QUERIES, B as GENERATED_IMAGE_EXTENSION_RE, C as AGENT_QUEST_ID, Ct as replacePromptVariables, H as ImagePromptResolutionSchema, L as CreateArtifactRequestSchema, M as BriefcasePromptIdSchema, Mt as ttsRequestSchema, Nt as ttsResponseTooLargeSchema, R as DEFAULT_TTS_PROVIDER, St as questReplyText, T as AGENT_QUEST_MCP_URI, V as ImageModels, X as PROMPT_TEXT_MAX, _ as logger, bt as joinReplySlots, dt as generatedAudioResponseSchema, g as parseApiUrl, h as NotAuthenticatedError, jt as ttsBase64ResponseSchema, k as ArtifactTypeSchema, kt as supportedVoiceGenerationVendor, rt as VisibilitySchema, st as buildPromptContext, t as ConfigStore, u as requireApiUrl, w as AGENT_QUEST_MANIFEST } from "./ConfigStore-RHqyXcSS.mjs";
3
+ import { a as ApiClient, i as parseRetryAfterSeconds, o as isProviderKeyFailure, r as createClient, t as B4mApiError } from "./dist-B1bGf-yn.mjs";
4
4
  import { z as z$1 } from "zod";
5
5
  import { isAxiosError } from "axios";
6
+ import { createHash, randomUUID } from "node:crypto";
6
7
  import { Writable } from "node:stream";
7
8
  import { createServer } from "node:http";
8
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -11,24 +12,35 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mc
11
12
  import { ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
12
13
  //#region src/mcp/b4mApiClient.ts
13
14
  const NOTEBOOK_ID_PATTERN = /^[a-f0-9]{24}$/i;
15
+ const STOP_REPLY_TIMEOUT_MS = 1e4;
14
16
  /**
15
17
  * An empty or dot-segment id collapses `/api/sessions/{id}` to `/api/sessions`, whose DELETE wipes every
16
18
  * notebook the caller owns, so write paths refuse anything that is not an ObjectId before any request.
17
19
  */
18
- function notebookPath(notebookId) {
20
+ function writableNotebookId(notebookId) {
19
21
  if (!NOTEBOOK_ID_PATTERN.test(notebookId)) throw new Error(`Invalid notebook id: ${JSON.stringify(notebookId)}`);
20
- return `/api/sessions/${notebookId}`;
22
+ return notebookId;
21
23
  }
22
24
  /**
23
25
  * Typed wrapper over {@link ApiClient} exposing exactly the Bike4Mind REST
24
26
  * endpoints the MCP tools call. All routes are `baseApi()` routes that accept
25
27
  * either an OAuth JWT or an instance API key, so a caller supplies whichever it
26
28
  * has via the underlying ApiClient.
29
+ *
30
+ * Operations with a public contract go through `@bike4mind/sdk` (`this.sdk`), whose fetch is the
31
+ * ApiClient's, so auth and refresh are shared. The axios `this.client` calls are routes with no public
32
+ * contract, plus four that do have one but whose v1 shape would change the MCP output: the session and
33
+ * project lists (v1 pages by cursor, the tools by page number) and getFile/getProject (the tools return the
34
+ * whole document; v1 is a narrower snake_case resource).
27
35
  */
28
36
  var B4mApiClient = class {
29
37
  constructor(baseURL, configStore, apiKey) {
30
38
  this.baseURL = baseURL;
31
39
  this.client = new ApiClient(baseURL, configStore, apiKey);
40
+ this.sdk = createClient({
41
+ baseUrl: baseURL,
42
+ fetch: this.client.fetch
43
+ });
32
44
  }
33
45
  toList(result) {
34
46
  if (Array.isArray(result)) return {
@@ -51,31 +63,37 @@ var B4mApiClient = class {
51
63
  return this.toList(result);
52
64
  }
53
65
  async getNotebook(notebookId) {
54
- return this.client.get(`/api/sessions/${encodeURIComponent(notebookId)}`);
66
+ return this.sdk.call("getSession", { params: { id: notebookId } });
55
67
  }
56
68
  async createNotebook(args) {
57
- return this.client.post("/api/sessions/create", {
58
- ...args.name ? { name: args.name } : {},
69
+ return this.sdk.call("createSession", { body: {
70
+ name: args.name,
59
71
  ...args.projectId ? { projectId: args.projectId } : {},
60
72
  ...args.dataLakeId ? { dataLakeId: args.dataLakeId } : {}
61
- });
73
+ } });
62
74
  }
63
75
  async renameNotebook(notebookId, name) {
64
- return this.client.put(notebookPath(notebookId), { name });
76
+ return this.sdk.call("updateSession", {
77
+ params: { id: writableNotebookId(notebookId) },
78
+ body: { name }
79
+ });
65
80
  }
66
81
  /** Returns the new (cloned) notebook. */
67
82
  async cloneNotebook(notebookId) {
68
- return this.client.post(`${notebookPath(notebookId)}/clone`, {});
83
+ return this.sdk.call("cloneSession", {
84
+ params: { id: writableNotebookId(notebookId) },
85
+ body: {}
86
+ });
69
87
  }
70
88
  async deleteNotebook(notebookId) {
71
- return this.client.delete(notebookPath(notebookId), { maxRedirects: 0 });
89
+ return this.sdk.call("deleteSession", {
90
+ params: { id: writableNotebookId(notebookId) },
91
+ redirect: "manual"
92
+ });
72
93
  }
73
- /**
74
- * GET /api/v1/data-lakes is cursor-paginated (flat `limit`/`cursor` params,
75
- * `{ data, next_cursor }` body), so `toList` does not apply.
76
- */
94
+ /** GET /api/v1/data-lakes is cursor-paginated (`{ data, next_cursor }` body), so `toList` does not apply. */
77
95
  async listDataLakes(args) {
78
- const result = await this.client.get("/api/v1/data-lakes", { params: {
96
+ const result = await this.sdk.call("listDataLakes", { query: {
79
97
  limit: args.limit,
80
98
  ...args.cursor ? { cursor: args.cursor } : {}
81
99
  } });
@@ -85,16 +103,21 @@ var B4mApiClient = class {
85
103
  };
86
104
  }
87
105
  async sendChat(args) {
88
- return this.client.post("/api/chat", {
106
+ const body = {
89
107
  ...args.notebookId ? { sessionId: args.notebookId } : { newConversation: true },
90
108
  message: args.message,
91
109
  ...args.model ? { model: args.model } : {},
92
110
  ...args.systemPrompt ? { systemPrompt: args.systemPrompt } : {},
93
111
  wait: false
94
- });
112
+ };
113
+ return this.sdk.call("sendChatMessage", { body });
95
114
  }
96
115
  async getQuest(questId) {
97
- return this.client.get(`/api/quests/${encodeURIComponent(questId)}`);
116
+ return await this.sdk.call("getQuest", { params: { id: questId } });
117
+ }
118
+ /** Stop one generating turn. A server that predates `questId` stops the notebook's latest turn. */
119
+ async stopReply(notebookId, questId) {
120
+ return this.client.post(`/api/sessions/${writableNotebookId(notebookId)}/chat/stop-reply`, { questId }, { timeout: STOP_REPLY_TIMEOUT_MS });
98
121
  }
99
122
  async searchKnowledgeBase(args) {
100
123
  return (await this.client.post("/api/sessions/semantic-search", {
@@ -116,81 +139,44 @@ var B4mApiClient = class {
116
139
  async getFile(fileId) {
117
140
  return this.client.get(`/api/files/${encodeURIComponent(fileId)}`);
118
141
  }
119
- /** Generate a sound effect; see {@link postGeneratedAudio} for the normalized response. */
142
+ /** Generate a sound effect. The SDK requests base64 and rebuilds an old server's raw-bytes answer. */
120
143
  async generateSoundEffect(args) {
121
- return this.postGeneratedAudio("/api/ai/sound-effects", {
144
+ const body = {
122
145
  provider: args.provider,
123
146
  text: args.text,
124
147
  ...args.durationSeconds !== void 0 ? { durationSeconds: args.durationSeconds } : {},
125
148
  ...args.promptInfluence !== void 0 ? { promptInfluence: args.promptInfluence } : {},
126
149
  ...args.format ? { format: args.format } : {}
127
- }, generatedAudioResponseSchema);
150
+ };
151
+ return generatedAudioResponseSchema.parse(await this.sdk.soundEffects(body));
128
152
  }
129
153
  async synthesizeSpeech(args) {
130
- try {
131
- return {
132
- kind: "audio",
133
- data: await this.postGeneratedAudio("/api/ai/tts", args, ttsBase64ResponseSchema)
134
- };
135
- } catch (error) {
136
- if (isAxiosError(error) && error.response?.status === 413) {
137
- const oversized = ttsResponseTooLargeSchema.safeParse(error.response.data);
138
- if (oversized.success && oversized.data.saved && oversized.data.fabFileId) {
139
- const fallbackFrom = supportedVoiceGenerationVendor.safeParse(error.response.headers?.["x-b4m-tts-provider-fallback-from"]);
140
- return {
141
- kind: "saved-too-large",
142
- data: {
143
- ...oversized.data,
144
- fabFileId: oversized.data.fabFileId
145
- },
146
- ...fallbackFrom.success ? { fallbackFrom: fallbackFrom.data } : {}
147
- };
148
- }
149
- }
150
- throw error;
151
- }
154
+ const result = await this.sdk.tts(args);
155
+ if (result.kind === "audio") return {
156
+ kind: "audio",
157
+ data: ttsBase64ResponseSchema.parse(result.data)
158
+ };
159
+ const oversized = ttsResponseTooLargeSchema.parse(result.data);
160
+ const fallbackFrom = supportedVoiceGenerationVendor.safeParse(result.fallbackFrom);
161
+ return {
162
+ kind: "saved-too-large",
163
+ data: {
164
+ ...oversized,
165
+ fabFileId: result.data.fabFileId
166
+ },
167
+ ...fallbackFrom.success ? { fallbackFrom: fallbackFrom.data } : {}
168
+ };
152
169
  }
153
170
  async generateImage(args) {
154
- return this.client.post("/api/ai/generate-image", {
171
+ const body = {
155
172
  prompt: args.prompt,
156
173
  model: args.model,
157
174
  ...args.size ? { size: args.size } : {},
158
175
  ...args.notebookId ? { sessionId: args.notebookId } : {},
159
176
  ...args.projectId ? { projectId: args.projectId } : {},
160
177
  ...args.promptResolution ? { prompt_resolution: args.promptResolution } : {}
161
- });
162
- }
163
- /**
164
- * POST a generated-audio request with `encoding: 'base64'` and normalize any
165
- * server's answer into the JSON shape `schema` describes. Base64 (never binary)
166
- * because an oversized result is a 303 in binary mode, and axios drops the
167
- * X-B4M headers when following it. A server predating the `encoding` field
168
- * ignores it and streams raw bytes with the save result in X-B4M-Audio-* headers;
169
- * those are rebuilt into the inline variant. The request is arraybuffer-typed, so
170
- * a failure body arrives as bytes; {@link decodeArrayBufferErrorBody} restores
171
- * its JSON shape for {@link mapApiError}.
172
- */
173
- async postGeneratedAudio(path, body, schema) {
174
- const response = await this.client.getAxiosInstance().post(path, {
175
- ...body,
176
- encoding: "base64"
177
- }, { responseType: "arraybuffer" }).catch((error) => {
178
- throw decodeArrayBufferErrorBody(error);
179
- });
180
- const contentType = String(response.headers["content-type"] ?? "application/octet-stream");
181
- const bytes = Buffer.from(response.data);
182
- if (/json/i.test(contentType)) return schema.parse(JSON.parse(bytes.toString("utf8")));
183
- const headerString = (value) => typeof value === "string" ? value : void 0;
184
- const saved = String(response.headers["x-b4m-audio-saved"] ?? "") === "true";
185
- return schema.parse({
186
- delivery: "inline",
187
- audio: bytes.toString("base64"),
188
- contentType,
189
- saved,
190
- fabFileId: saved ? headerString(response.headers["x-b4m-audio-fab-file-id"]) : void 0,
191
- fileName: saved ? headerString(response.headers["x-b4m-audio-file-name"]) : void 0,
192
- fileUrl: saved ? headerString(response.headers["x-b4m-audio-file-url"]) : void 0
193
- });
178
+ };
179
+ return await this.sdk.call("generateImage", { body });
194
180
  }
195
181
  async listProjects(args) {
196
182
  const result = await this.client.get("/api/projects", { params: {
@@ -206,12 +192,21 @@ var B4mApiClient = class {
206
192
  return this.client.get(`/api/projects/${encodeURIComponent(projectId)}`);
207
193
  }
208
194
  async createProject(args) {
209
- return this.client.post("/api/projects", {
195
+ const project = await this.sdk.call("createProject", { body: {
210
196
  name: args.name,
211
197
  description: args.description,
212
- ...args.sessionIds?.length ? { sessionIds: args.sessionIds } : {},
213
- ...args.fileIds?.length ? { fileIds: args.fileIds } : {}
214
- });
198
+ ...args.sessionIds?.length ? { session_ids: args.sessionIds } : {},
199
+ ...args.fileIds?.length ? { file_ids: args.fileIds } : {}
200
+ } });
201
+ return {
202
+ id: project.id,
203
+ name: project.name,
204
+ description: project.description,
205
+ sessionIds: project.session_ids,
206
+ fileIds: project.file_ids,
207
+ createdAt: project.created_at ?? void 0,
208
+ updatedAt: project.updated_at ?? void 0
209
+ };
215
210
  }
216
211
  /**
217
212
  * GET /api/artifacts takes flat `limit`/`offset` params (not the nested
@@ -232,6 +227,58 @@ var B4mApiClient = class {
232
227
  async getArtifact(artifactId) {
233
228
  return this.client.get(`/api/artifacts/${encodeURIComponent(artifactId)}`, { params: { includeContent: "true" } });
234
229
  }
230
+ /** POST /api/v1/artifacts: creates a private artifact at version 1. */
231
+ async createArtifact(args) {
232
+ const artifact = await this.sdk.call("createArtifact", { body: {
233
+ type: args.type,
234
+ title: args.title,
235
+ content: args.content,
236
+ ...args.description !== void 0 ? { description: args.description } : {},
237
+ ...args.notebookId ? { session_id: args.notebookId } : {},
238
+ ...args.projectId ? { project_id: args.projectId } : {},
239
+ ...args.tags?.length ? { tags: args.tags } : {}
240
+ } });
241
+ return {
242
+ id: artifact.id,
243
+ type: artifact.type,
244
+ title: artifact.title,
245
+ version: artifact.version,
246
+ notebookId: artifact.session_id ?? void 0,
247
+ projectId: artifact.project_id ?? void 0,
248
+ createdAt: artifact.created_at
249
+ };
250
+ }
251
+ /**
252
+ * The caller's own most recent publication of an artifact, or null. The route is
253
+ * owner-scoped for `sourceArtifactId`, so another user's page never matches.
254
+ */
255
+ async findPublishedArtifact(artifactId) {
256
+ return (await this.client.get("/api/publish/artifacts", { params: {
257
+ sourceArtifactId: artifactId,
258
+ limit: 1
259
+ } })).artifacts?.[0] ?? null;
260
+ }
261
+ /** POST /api/publish/artifact/upload-url: opens a publish draft and mints one upload URL per file. */
262
+ async requestPublishUpload(request) {
263
+ return this.client.post("/api/publish/artifact/upload-url", request);
264
+ }
265
+ /**
266
+ * PUT one draft file to the URL upload-url minted. Plain fetch, never the app
267
+ * client: that client injects auth headers, which break an S3 presigned
268
+ * signature. Self-host mints a relative proxy URL, so resolve against baseURL.
269
+ */
270
+ async uploadPublishDraftFile(uploadUrl, body, mimeType) {
271
+ const res = await fetch(new URL(uploadUrl, this.baseURL), {
272
+ method: "PUT",
273
+ headers: { "Content-Type": mimeType },
274
+ body
275
+ });
276
+ if (!res.ok) throw new Error(`publish upload failed (${res.status})`);
277
+ }
278
+ /** POST /api/publish/artifact/finalize: validates the uploaded draft and makes it live. */
279
+ async finalizePublish(draftId) {
280
+ return this.client.post("/api/publish/artifact/finalize", { draftId });
281
+ }
235
282
  /**
236
283
  * POST /api/briefcase/catalog: a key -> prompts map, one entry per query key.
237
284
  *
@@ -256,67 +303,67 @@ var B4mApiClient = class {
256
303
  */
257
304
  function mapApiError(error, baseURL, scope) {
258
305
  if (error instanceof NotAuthenticatedError) return "not authenticated: no credential configured (set B4M_API_KEY or run `b4m login`)";
306
+ const failure = httpFailure(error);
307
+ if (!failure) return error instanceof Error ? error.message : String(error);
308
+ const { status, data } = failure;
309
+ if (status === 401) {
310
+ const providerKeyMessage = providerKeyFailureMessage(data);
311
+ if (providerKeyMessage) return providerKeyMessage;
312
+ return "authentication failed (run `b4m login` or set B4M_API_KEY)";
313
+ }
314
+ if (status === 403) {
315
+ if (data?.code === "FEATURE_DISABLED") return "feature disabled on this Bike4Mind instance (ask an admin to enable it)";
316
+ if (apiErrorCode(error) === "slug_owned_by_other") return extractServerMessage(data) ?? "this publish slug belongs to another publisher";
317
+ const serverMessage = extractServerMessage(data);
318
+ if (serverMessage && /CSRF|request origin/i.test(serverMessage)) return serverMessage;
319
+ if (serverMessage && /own user scope/i.test(serverMessage)) return `${serverMessage} (publish_artifact only publishes artifacts you own)`;
320
+ const base = "API key forbidden: check the key's scopes and account access";
321
+ return scope ? `${base} (recommended scope: ${scope})` : base;
322
+ }
323
+ if (status === 404 && error instanceof B4mApiError && !extractServerMessage(data)) return `the Bike4Mind server at ${baseURL} does not have this API route; it likely predates this CLI (upgrade the server or use an older CLI)`;
324
+ if (status === 429) {
325
+ const retryAfterSeconds = apiErrorRetryAfterSeconds(error);
326
+ const base = extractServerMessage(data) || "rate limit exceeded";
327
+ return retryAfterSeconds !== void 0 ? `${base} (retry after ${retryAfterSeconds}s)` : base;
328
+ }
259
329
  if (isAxiosError(error)) {
260
- const status = error.response?.status;
261
- if (status === 401) {
262
- const providerKeyMessage = providerKeyFailureMessage(error.response?.data);
263
- if (providerKeyMessage) return providerKeyMessage;
264
- return "authentication failed (run `b4m login` or set B4M_API_KEY)";
265
- }
266
- if (status === 403) {
267
- if ((error.response?.data)?.code === "FEATURE_DISABLED") return "feature disabled on this Bike4Mind instance (ask an admin to enable it)";
268
- const csrfMessage = extractServerMessage(error.response?.data);
269
- if (csrfMessage && /CSRF|request origin/i.test(csrfMessage)) return csrfMessage;
270
- const base = "API key forbidden: check the key's scopes and account access";
271
- return scope ? `${base} (recommended scope: ${scope})` : base;
272
- }
273
- if (status === 429) {
274
- const retryAfterSeconds = parseRetryAfterSeconds(error.response?.headers?.["retry-after"]);
275
- const base = extractServerMessage(error.response?.data) || "rate limit exceeded";
276
- return retryAfterSeconds !== void 0 ? `${base} (retry after ${retryAfterSeconds}s)` : base;
277
- }
278
330
  if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT" || /timeout/i.test(error.message)) return `request to Bike4Mind at ${baseURL} timed out`;
279
331
  if (error.code === "ECONNREFUSED" || error.message.includes("ECONNREFUSED")) return `cannot reach Bike4Mind at ${baseURL}`;
280
- const serverMsg = extractServerMessage(error.response?.data);
281
- if (serverMsg) return serverMsg;
282
- return error.message;
283
332
  }
333
+ const serverMsg = extractServerMessage(data);
334
+ if (serverMsg) return serverMsg;
284
335
  return error instanceof Error ? error.message : String(error);
285
336
  }
286
337
  /**
287
- * A request made with `responseType: 'arraybuffer'` also decodes its error body
288
- * as bytes, so a JSON `{ error }` payload reaches us as a Buffer that
289
- * {@link mapApiError} can't read. Decode it back to a parsed object in place so
290
- * the server's message survives; leave a non-JSON body untouched.
338
+ * Status and body of an HTTP failure from either transport: a `B4mApiError` from the SDK calls or an
339
+ * AxiosError from the axios ones (with no status when the request never got a response). Undefined for
340
+ * anything else.
291
341
  */
292
- function decodeArrayBufferErrorBody(error) {
293
- if (!isAxiosError(error) || !error.response) return error;
294
- const { data } = error.response;
295
- if (typeof data === "string") {
296
- try {
297
- error.response.data = JSON.parse(data);
298
- } catch {}
299
- return error;
300
- }
301
- const bytes = Buffer.isBuffer(data) ? data : data instanceof ArrayBuffer ? Buffer.from(data) : ArrayBuffer.isView(data) ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : void 0;
302
- if (!bytes) return error;
303
- try {
304
- error.response.data = JSON.parse(bytes.toString("utf8"));
305
- } catch {}
306
- return error;
342
+ function httpFailure(error) {
343
+ if (error instanceof B4mApiError) return {
344
+ status: error.status,
345
+ data: error.body
346
+ };
347
+ if (isAxiosError(error)) return {
348
+ status: error.response?.status,
349
+ data: error.response?.data
350
+ };
307
351
  }
308
- /**
309
- * Normalize a Retry-After header (RFC 7231: either delta-seconds or an HTTP-date)
310
- * to a whole, non-negative number of seconds. Returns undefined when the header is
311
- * absent or parses as neither, so callers can omit the retry hint entirely.
312
- */
313
- function parseRetryAfterSeconds(value) {
314
- if (value === void 0 || value === null) return void 0;
315
- const raw = String(value).trim();
316
- if (/^\d+$/.test(raw)) return Number(raw);
317
- const dateMs = Date.parse(raw);
318
- if (Number.isNaN(dateMs)) return void 0;
319
- return Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
352
+ /** The machine-readable `code` a route put on its error body, when it set one. */
353
+ function apiErrorCode(error) {
354
+ const data = httpFailure(error)?.data;
355
+ if (!data || typeof data !== "object") return void 0;
356
+ const code = data.code;
357
+ return typeof code === "string" ? code : void 0;
358
+ }
359
+ /** The HTTP status of a failed API call from either transport. */
360
+ function apiErrorStatus(error) {
361
+ return httpFailure(error)?.status;
362
+ }
363
+ /** The Retry-After of a failed API call from either transport, in seconds. */
364
+ function apiErrorRetryAfterSeconds(error) {
365
+ if (error instanceof B4mApiError) return error.retryAfterSeconds;
366
+ return isAxiosError(error) ? parseRetryAfterSeconds(error.response?.headers?.["retry-after"]) : void 0;
320
367
  }
321
368
  /** Pull a human-readable message out of a JSON error body (`error` or `message` field). */
322
369
  function extractServerMessage(data) {
@@ -390,7 +437,7 @@ const TOOL_META = [
390
437
  {
391
438
  name: "send_message",
392
439
  title: "Send message",
393
- description: "Send a chat message and wait for the assistant reply, reporting progress while it is generated; returns the cited sources (citables) the answer was grounded in.",
440
+ description: "Send a chat message and wait for the assistant reply, reporting progress while it is generated; returns the cited sources (citables) the answer was grounded in. Fails on an errored or stopped turn, and stops the turn when the call is cancelled.",
394
441
  scope: "ai:chat"
395
442
  },
396
443
  {
@@ -434,6 +481,18 @@ const TOOL_META = [
434
481
  title: "Generate image",
435
482
  description: "Generate an image from a text prompt and wait for the render. Returns the quest and notebook ids plus each image's file name and download URL. Image names are not file ids and do not work with get_file.",
436
483
  scope: "ai:generate"
484
+ },
485
+ {
486
+ name: "create_artifact",
487
+ title: "Create artifact",
488
+ description: "Create a private artifact (diagram, chart, code, page) and return its id. There is no markdown type: put a written document in type 'html'. Pass the returned id to publish_artifact to share it.",
489
+ scope: "notebooks:write"
490
+ },
491
+ {
492
+ name: "publish_artifact",
493
+ title: "Publish artifact",
494
+ description: "Publish an artifact you own to a shareable /p/... link and return its absolute URL. Republishing an artifact updates its existing page and keeps that page's visibility unless you pass one; a first publish defaults to public.",
495
+ scope: "files:write"
437
496
  }
438
497
  ];
439
498
  TOOL_META.map((t) => t.name);
@@ -506,6 +565,21 @@ const generateImageShape = {
506
565
  projectId: z$1.string().optional().describe("Project for the new notebook when notebookId is omitted"),
507
566
  promptResolution: ImagePromptResolutionSchema.optional().describe("'auto' (default) rewrites the prompt against the notebook history; 'literal' sends it as written")
508
567
  };
568
+ const publishVisibilitySchema = VisibilitySchema.extract(["public", "private"]);
569
+ const publishArtifactShape = {
570
+ artifactId: z$1.string().min(1).describe("The artifact id"),
571
+ visibility: publishVisibilitySchema.optional().describe("Who can open the link: 'public' (anyone) or 'private' (only you); omit to keep an existing page's visibility (a first publish is public)"),
572
+ title: z$1.string().min(1).max(200).optional().describe("Page title; defaults to the artifact's title")
573
+ };
574
+ const createArtifactShape = {
575
+ type: CreateArtifactRequestSchema.shape.type.describe("Artifact type: 'html' for a written document or web page, 'mermaid' for a diagram, 'recharts' for a chart, 'svg', 'react', 'python' or 'code' for source"),
576
+ title: CreateArtifactRequestSchema.shape.title.describe("Artifact title"),
577
+ content: CreateArtifactRequestSchema.shape.content.describe("The artifact's source: HTML, Mermaid, SVG, code and so on, matching type"),
578
+ description: CreateArtifactRequestSchema.shape.description.describe("Short description"),
579
+ notebookId: notebookId("A notebook (session) you can edit, to file the artifact under").optional(),
580
+ projectId: CreateArtifactRequestSchema.shape.project_id.describe("A project you can read, to file the artifact under"),
581
+ tags: CreateArtifactRequestSchema.shape.tags.describe("Up to 20 tags")
582
+ };
509
583
  const textToSpeechShape = {
510
584
  ...ttsRequestSchema.omit({ encoding: true }).shape,
511
585
  text: ttsRequestSchema.shape.text.describe("Text to speak"),
@@ -584,24 +658,38 @@ async function createProject(client, args) {
584
658
  return projectSummary(await client.createProject(args));
585
659
  }
586
660
  /**
587
- * Queue a chat turn and poll its quest to completion. A failed turn ends `type: 'error'` with its
588
- * explanation as the reply, which is returned as-is like any other reply.
661
+ * Queue a chat turn and poll its quest to completion. A failed (`type: 'error'`) or stopped turn
662
+ * throws, so the tool reports `isError`; a cancelled call makes a best-effort stop of its quest.
589
663
  */
590
664
  async function sendMessage(client, args, { intervalMs = CHAT_POLL_INTERVAL_MS, timeoutMs = CHAT_POLL_TIMEOUT_MS, ...poll } = {}) {
591
665
  const ack = await client.sendChat(args);
592
666
  const questId = ack.id;
593
- const quest = await pollQuest(client, {
594
- questId,
595
- ref: questRef(questId, ack.sessionId),
596
- task: "chat completion",
597
- scope: "ai:chat",
598
- isFinished: isSettled
599
- }, {
600
- ...poll,
601
- timeoutMs,
602
- interval: (elapsedMs) => chatPollInterval(elapsedMs, intervalMs)
603
- });
604
- const notebookId = args.notebookId ?? ack.sessionId ?? quest.sessionId;
667
+ const sessionId = args.notebookId ?? ack.sessionId;
668
+ const ref = questRef(questId, sessionId);
669
+ let quest;
670
+ try {
671
+ quest = await pollQuest(client, {
672
+ questId,
673
+ ref,
674
+ task: "chat completion",
675
+ scope: "ai:chat",
676
+ isFinished: isSettled
677
+ }, {
678
+ ...poll,
679
+ timeoutMs,
680
+ interval: (elapsedMs) => chatPollInterval(elapsedMs, intervalMs)
681
+ });
682
+ } catch (err) {
683
+ if (poll.signal?.aborted && sessionId) await client.stopReply(sessionId, questId).catch((stopErr) => logger.warn(`mcp: stopping the cancelled turn failed (${ref}): ${stopErr instanceof Error ? stopErr.message : String(stopErr)}`));
684
+ throw err;
685
+ }
686
+ const reply = replyText(quest);
687
+ if (quest.status === "stopped") throw new Error(`chat completion was stopped (${ref})${reply ? `: ${reply}` : ""}`);
688
+ if (quest.type === "error") {
689
+ const code = quest.errorCode ? `${quest.errorCode}: ` : "";
690
+ throw new Error(`${code}${reply || "chat completion failed"} (${ref})`);
691
+ }
692
+ const notebookId = sessionId ?? quest.sessionId;
605
693
  const citables = (quest.promptMeta?.citables ?? []).map((c) => ({
606
694
  id: c.id,
607
695
  type: c.type,
@@ -612,7 +700,7 @@ async function sendMessage(client, args, { intervalMs = CHAT_POLL_INTERVAL_MS, t
612
700
  return {
613
701
  notebookId,
614
702
  questId,
615
- reply: replyText(quest),
703
+ reply,
616
704
  model: ack.model,
617
705
  citables
618
706
  };
@@ -653,9 +741,9 @@ const isSettled = (q) => q.status === "done" || q.status === "stopped" || q.type
653
741
  const isImageSettled = (q) => isSettled(q) || !q.status && !!q.images?.length;
654
742
  const replyText = (q) => questReplyText(q, joinReplySlots) ?? "";
655
743
  const questRef = (questId, notebookId) => `quest ${questId}${notebookId ? `, notebook ${notebookId}` : ""}`;
656
- const isRateLimited = (err) => isAxiosError(err) && err.response?.status === 429;
744
+ const isRateLimited = (err) => apiErrorStatus(err) === 429;
657
745
  const isPermanentApiError = (err) => {
658
- const status = isAxiosError(err) ? err.response?.status : void 0;
746
+ const status = apiErrorStatus(err);
659
747
  return status === 401 || status === 403 || status === 404;
660
748
  };
661
749
  /** Poll a queued quest until `isFinished`, the timeout, a permanent poll failure, or an abort. */
@@ -671,7 +759,7 @@ async function pollQuest(client, { questId, ref, task, scope, isFinished }, { in
671
759
  failures = 0;
672
760
  if (isFinished(quest)) return quest;
673
761
  } catch (err) {
674
- if (isRateLimited(err)) retryAfterMs = (parseRetryAfterSeconds(isAxiosError(err) && err.response?.headers?.["retry-after"]) ?? 0) * 1e3;
762
+ if (isRateLimited(err)) retryAfterMs = (apiErrorRetryAfterSeconds(err) ?? 0) * 1e3;
675
763
  else {
676
764
  failures += 1;
677
765
  if (isPermanentApiError(err) || failures >= MAX_CONSECUTIVE_POLL_FAILURES) throw new Error(`${mapApiError(err, baseURL, scope)} (${ref}; the ${task} may still complete)`);
@@ -695,7 +783,7 @@ async function pollQuest(client, { questId, ref, task, scope, isFinished }, { in
695
783
  * resolves `status: 'done'`, so `type: 'error'` (reason in `reply`) is the failure signal.
696
784
  * Generated images are not FabFiles, so there is no file id: the quest id is the handle,
697
785
  * and each image is its generated-file name plus the URL the quest poll resolves for it
698
- * (`fileUrl` is absent when the server has no CDN configured).
786
+ * (`fileUrl` is a short-lived presigned URL, valid for about an hour).
699
787
  */
700
788
  async function generateImage(client, args, { intervalMs = IMAGE_POLL_INTERVAL_MS, timeoutMs = IMAGE_POLL_TIMEOUT_MS, ...poll } = {}) {
701
789
  const ack = await client.generateImage(args);
@@ -731,6 +819,98 @@ async function generateImage(client, args, { intervalMs = IMAGE_POLL_INTERVAL_MS
731
819
  images
732
820
  };
733
821
  }
822
+ const PUBLISH_INDEX_PATH = "index.html";
823
+ const PUBLISH_INDEX_MIME = "text/html";
824
+ const PUBLISH_TITLE_MAX = 200;
825
+ const PUBLISH_FALLBACK_TITLE = "Shared artifact";
826
+ const PUBLISH_DEFAULT_VISIBILITY = "public";
827
+ const SLUG_TITLE_MAX = 48;
828
+ const SLUG_ARTIFACT_HASH_LENGTH = 8;
829
+ const SLUG_DISCRIMINATOR_LENGTH = 6;
830
+ function slugify(text) {
831
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, SLUG_TITLE_MAX).replace(/-+$/, "");
832
+ }
833
+ /**
834
+ * Stable per artifact, so republishing updates the same page in place. Artifact
835
+ * ids all share an `artifact_` prefix, so the suffix hashes the whole id rather
836
+ * than taking a prefix of it.
837
+ */
838
+ function artifactSlug(title, artifactId) {
839
+ const hash = createHash("sha256").update(artifactId).digest("hex").slice(0, SLUG_ARTIFACT_HASH_LENGTH);
840
+ return `${slugify(title) || "artifact"}-${hash}`;
841
+ }
842
+ /** The artifact-content doc carries the body as `content`; anything else has none to publish. */
843
+ function artifactContentText(content) {
844
+ if (!content || typeof content !== "object") return void 0;
845
+ const text = content.content;
846
+ return typeof text === "string" && text.trim() ? text : void 0;
847
+ }
848
+ /**
849
+ * Open the draft, retrying once under a discriminated slug when the derived slug
850
+ * already belongs to another publisher (the same fallback the web Share flow uses).
851
+ */
852
+ async function openPublishDraft(client, request) {
853
+ try {
854
+ return await client.requestPublishUpload(request);
855
+ } catch (err) {
856
+ if (apiErrorCode(err) !== "slug_owned_by_other") throw err;
857
+ const discriminator = randomUUID().replace(/-/g, "").slice(0, SLUG_DISCRIMINATOR_LENGTH);
858
+ return client.requestPublishUpload({
859
+ ...request,
860
+ slug: `${request.slug}-${discriminator}`
861
+ });
862
+ }
863
+ }
864
+ async function createArtifact(client, args) {
865
+ return client.createArtifact(args);
866
+ }
867
+ /**
868
+ * Publish an artifact: upload its RAW content and let finalize render it
869
+ * (source.artifactType), so the page matches an in-app publish byte for byte with
870
+ * no CLI-side renderer. An artifact the caller already published (from the app or
871
+ * here) is republished in place at its existing tier/scope/slug, as the in-app
872
+ * update flow does; otherwise it becomes a new user-tier page.
873
+ */
874
+ async function publishArtifact(client, args) {
875
+ const { artifact, content } = await client.getArtifact(args.artifactId);
876
+ const body = artifactContentText(content);
877
+ if (!body) throw new Error(`artifact ${artifact.id} has no content to publish`);
878
+ const artifactType = ArtifactTypeSchema.safeParse(artifact.type);
879
+ if (!artifactType.success) throw new Error(`artifact ${artifact.id} has an unsupported type (${String(artifact.type)})`);
880
+ if (typeof artifact.userId !== "string") throw new Error(`artifact ${artifact.id} has no owner to publish under`);
881
+ const storedTitle = typeof artifact.title === "string" ? artifact.title : "";
882
+ const title = (args.title ?? storedTitle).trim().slice(0, PUBLISH_TITLE_MAX) || PUBLISH_FALLBACK_TITLE;
883
+ const existing = await client.findPublishedArtifact(artifact.id);
884
+ const request = {
885
+ tier: existing?.tier ?? "user",
886
+ scopeId: existing?.scopeId ?? artifact.userId,
887
+ slug: existing?.slug ?? artifactSlug(title, artifact.id),
888
+ title,
889
+ visibility: args.visibility ?? existing?.visibility ?? PUBLISH_DEFAULT_VISIBILITY,
890
+ ...existing?.description !== void 0 && { description: existing.description },
891
+ ...existing?.commentPolicy !== void 0 && { commentPolicy: existing.commentPolicy },
892
+ source: {
893
+ kind: "bundle",
894
+ artifactId: artifact.id,
895
+ artifactType: artifactType.data
896
+ },
897
+ files: [{
898
+ path: PUBLISH_INDEX_PATH,
899
+ size: Buffer.byteLength(body),
900
+ mimeType: PUBLISH_INDEX_MIME
901
+ }]
902
+ };
903
+ const draft = existing ? await client.requestPublishUpload(request) : await openPublishDraft(client, request);
904
+ const upload = draft.uploadUrls.find((u) => u.path === PUBLISH_INDEX_PATH);
905
+ if (!upload) throw new Error(`publish draft ${draft.draftId} returned no upload URL for ${PUBLISH_INDEX_PATH}`);
906
+ await client.uploadPublishDraftFile(upload.url, body, PUBLISH_INDEX_MIME);
907
+ const published = await client.finalizePublish(draft.draftId);
908
+ return {
909
+ publicId: published.publicId,
910
+ url: new URL(published.url, client.baseURL).toString(),
911
+ visibility: published.visibility
912
+ };
913
+ }
734
914
  function toResult(value) {
735
915
  const structuredContent = value && typeof value === "object" && !Array.isArray(value) ? value : { result: value };
736
916
  return {
@@ -977,6 +1157,16 @@ function registerTools(server, client) {
977
1157
  baseURL,
978
1158
  onProgress: progressReporter(extra, () => "rendering image")
979
1159
  })));
1160
+ server.registerTool("create_artifact", {
1161
+ title: meta("create_artifact").title,
1162
+ description: meta("create_artifact").description,
1163
+ inputSchema: createArtifactShape
1164
+ }, (args) => run("notebooks:write", () => createArtifact(client, args)));
1165
+ server.registerTool("publish_artifact", {
1166
+ title: meta("publish_artifact").title,
1167
+ description: meta("publish_artifact").description,
1168
+ inputSchema: publishArtifactShape
1169
+ }, (args) => run("files:write", () => publishArtifact(client, args)));
980
1170
  }
981
1171
  //#endregion
982
1172
  //#region src/mcp/resources.ts