@bike4mind/cli 2.1.0 → 3.0.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-DVxv1kSO.mjs} +7905 -7029
  3. package/dist/{ConfigStore-BygzEI6H.mjs → ConfigStore-51UaP9pJ.mjs} +10645 -6760
  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-BMY0Qq1s.mjs} +27 -2
  7. package/dist/{buildAgent-B7cMwRAy.mjs → buildAgent-CrSRKYU_.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-BsG2Hlp-.mjs +739 -0
  17. package/dist/index.mjs +602 -324
  18. package/dist/{package-CRNnNXxq.mjs → package-DEsNSP54.mjs} +1 -1
  19. package/dist/{serve-DM7ld3-o.mjs → serve-BaHzVbcm.mjs} +300 -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,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  //#region package.json
3
- var version = "2.1.0";
3
+ var version = "3.0.0";
4
4
  //#endregion
5
5
  export { version as t };
@@ -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, At as ttsBase64ResponseSchema, B as ImageModels, C as AGENT_QUEST_ID, L as DEFAULT_TTS_PROVIDER, M as BriefcasePromptIdSchema, Mt as ttsResponseTooLargeSchema, Ot as supportedVoiceGenerationVendor, St as replacePromptVariables, T as AGENT_QUEST_MCP_URI, V as ImagePromptResolutionSchema, Y as PROMPT_TEXT_MAX, _ as logger, g as parseApiUrl, h as NotAuthenticatedError, jt as ttsRequestSchema, k as ArtifactTypeSchema, nt as VisibilitySchema, ot as buildPromptContext, t as ConfigStore, u as requireApiUrl, ut as generatedAudioResponseSchema, w as AGENT_QUEST_MANIFEST, xt as questReplyText, yt as joinReplySlots, z as GENERATED_IMAGE_EXTENSION_RE } from "./ConfigStore-51UaP9pJ.mjs";
3
+ import { a as ApiClient, i as parseRetryAfterSeconds, o as isProviderKeyFailure, r as createClient, t as B4mApiError } from "./dist-BsG2Hlp-.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
@@ -233,6 +228,37 @@ var B4mApiClient = class {
233
228
  return this.client.get(`/api/artifacts/${encodeURIComponent(artifactId)}`, { params: { includeContent: "true" } });
234
229
  }
235
230
  /**
231
+ * The caller's own most recent publication of an artifact, or null. The route is
232
+ * owner-scoped for `sourceArtifactId`, so another user's page never matches.
233
+ */
234
+ async findPublishedArtifact(artifactId) {
235
+ return (await this.client.get("/api/publish/artifacts", { params: {
236
+ sourceArtifactId: artifactId,
237
+ limit: 1
238
+ } })).artifacts?.[0] ?? null;
239
+ }
240
+ /** POST /api/publish/artifact/upload-url: opens a publish draft and mints one upload URL per file. */
241
+ async requestPublishUpload(request) {
242
+ return this.client.post("/api/publish/artifact/upload-url", request);
243
+ }
244
+ /**
245
+ * PUT one draft file to the URL upload-url minted. Plain fetch, never the app
246
+ * client: that client injects auth headers, which break an S3 presigned
247
+ * signature. Self-host mints a relative proxy URL, so resolve against baseURL.
248
+ */
249
+ async uploadPublishDraftFile(uploadUrl, body, mimeType) {
250
+ const res = await fetch(new URL(uploadUrl, this.baseURL), {
251
+ method: "PUT",
252
+ headers: { "Content-Type": mimeType },
253
+ body
254
+ });
255
+ if (!res.ok) throw new Error(`publish upload failed (${res.status})`);
256
+ }
257
+ /** POST /api/publish/artifact/finalize: validates the uploaded draft and makes it live. */
258
+ async finalizePublish(draftId) {
259
+ return this.client.post("/api/publish/artifact/finalize", { draftId });
260
+ }
261
+ /**
236
262
  * POST /api/briefcase/catalog: a key -> prompts map, one entry per query key.
237
263
  *
238
264
  * The route runs csrfProtection, which exempts API-key requests but rejects a
@@ -256,67 +282,67 @@ var B4mApiClient = class {
256
282
  */
257
283
  function mapApiError(error, baseURL, scope) {
258
284
  if (error instanceof NotAuthenticatedError) return "not authenticated: no credential configured (set B4M_API_KEY or run `b4m login`)";
285
+ const failure = httpFailure(error);
286
+ if (!failure) return error instanceof Error ? error.message : String(error);
287
+ const { status, data } = failure;
288
+ if (status === 401) {
289
+ const providerKeyMessage = providerKeyFailureMessage(data);
290
+ if (providerKeyMessage) return providerKeyMessage;
291
+ return "authentication failed (run `b4m login` or set B4M_API_KEY)";
292
+ }
293
+ if (status === 403) {
294
+ if (data?.code === "FEATURE_DISABLED") return "feature disabled on this Bike4Mind instance (ask an admin to enable it)";
295
+ if (apiErrorCode(error) === "slug_owned_by_other") return extractServerMessage(data) ?? "this publish slug belongs to another publisher";
296
+ const serverMessage = extractServerMessage(data);
297
+ if (serverMessage && /CSRF|request origin/i.test(serverMessage)) return serverMessage;
298
+ if (serverMessage && /own user scope/i.test(serverMessage)) return `${serverMessage} (publish_artifact only publishes artifacts you own)`;
299
+ const base = "API key forbidden: check the key's scopes and account access";
300
+ return scope ? `${base} (recommended scope: ${scope})` : base;
301
+ }
302
+ 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)`;
303
+ if (status === 429) {
304
+ const retryAfterSeconds = apiErrorRetryAfterSeconds(error);
305
+ const base = extractServerMessage(data) || "rate limit exceeded";
306
+ return retryAfterSeconds !== void 0 ? `${base} (retry after ${retryAfterSeconds}s)` : base;
307
+ }
259
308
  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
309
  if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT" || /timeout/i.test(error.message)) return `request to Bike4Mind at ${baseURL} timed out`;
279
310
  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
311
  }
312
+ const serverMsg = extractServerMessage(data);
313
+ if (serverMsg) return serverMsg;
284
314
  return error instanceof Error ? error.message : String(error);
285
315
  }
286
316
  /**
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.
317
+ * Status and body of an HTTP failure from either transport: a `B4mApiError` from the SDK calls or an
318
+ * AxiosError from the axios ones (with no status when the request never got a response). Undefined for
319
+ * anything else.
291
320
  */
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;
321
+ function httpFailure(error) {
322
+ if (error instanceof B4mApiError) return {
323
+ status: error.status,
324
+ data: error.body
325
+ };
326
+ if (isAxiosError(error)) return {
327
+ status: error.response?.status,
328
+ data: error.response?.data
329
+ };
307
330
  }
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));
331
+ /** The machine-readable `code` a route put on its error body, when it set one. */
332
+ function apiErrorCode(error) {
333
+ const data = httpFailure(error)?.data;
334
+ if (!data || typeof data !== "object") return void 0;
335
+ const code = data.code;
336
+ return typeof code === "string" ? code : void 0;
337
+ }
338
+ /** The HTTP status of a failed API call from either transport. */
339
+ function apiErrorStatus(error) {
340
+ return httpFailure(error)?.status;
341
+ }
342
+ /** The Retry-After of a failed API call from either transport, in seconds. */
343
+ function apiErrorRetryAfterSeconds(error) {
344
+ if (error instanceof B4mApiError) return error.retryAfterSeconds;
345
+ return isAxiosError(error) ? parseRetryAfterSeconds(error.response?.headers?.["retry-after"]) : void 0;
320
346
  }
321
347
  /** Pull a human-readable message out of a JSON error body (`error` or `message` field). */
322
348
  function extractServerMessage(data) {
@@ -390,7 +416,7 @@ const TOOL_META = [
390
416
  {
391
417
  name: "send_message",
392
418
  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.",
419
+ 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
420
  scope: "ai:chat"
395
421
  },
396
422
  {
@@ -434,6 +460,12 @@ const TOOL_META = [
434
460
  title: "Generate image",
435
461
  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
462
  scope: "ai:generate"
463
+ },
464
+ {
465
+ name: "publish_artifact",
466
+ title: "Publish artifact",
467
+ 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.",
468
+ scope: "files:write"
437
469
  }
438
470
  ];
439
471
  TOOL_META.map((t) => t.name);
@@ -506,6 +538,12 @@ const generateImageShape = {
506
538
  projectId: z$1.string().optional().describe("Project for the new notebook when notebookId is omitted"),
507
539
  promptResolution: ImagePromptResolutionSchema.optional().describe("'auto' (default) rewrites the prompt against the notebook history; 'literal' sends it as written")
508
540
  };
541
+ const publishVisibilitySchema = VisibilitySchema.extract(["public", "private"]);
542
+ const publishArtifactShape = {
543
+ artifactId: z$1.string().min(1).describe("The artifact id"),
544
+ 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)"),
545
+ title: z$1.string().min(1).max(200).optional().describe("Page title; defaults to the artifact's title")
546
+ };
509
547
  const textToSpeechShape = {
510
548
  ...ttsRequestSchema.omit({ encoding: true }).shape,
511
549
  text: ttsRequestSchema.shape.text.describe("Text to speak"),
@@ -584,24 +622,38 @@ async function createProject(client, args) {
584
622
  return projectSummary(await client.createProject(args));
585
623
  }
586
624
  /**
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.
625
+ * Queue a chat turn and poll its quest to completion. A failed (`type: 'error'`) or stopped turn
626
+ * throws, so the tool reports `isError`; a cancelled call makes a best-effort stop of its quest.
589
627
  */
590
628
  async function sendMessage(client, args, { intervalMs = CHAT_POLL_INTERVAL_MS, timeoutMs = CHAT_POLL_TIMEOUT_MS, ...poll } = {}) {
591
629
  const ack = await client.sendChat(args);
592
630
  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;
631
+ const sessionId = args.notebookId ?? ack.sessionId;
632
+ const ref = questRef(questId, sessionId);
633
+ let quest;
634
+ try {
635
+ quest = await pollQuest(client, {
636
+ questId,
637
+ ref,
638
+ task: "chat completion",
639
+ scope: "ai:chat",
640
+ isFinished: isSettled
641
+ }, {
642
+ ...poll,
643
+ timeoutMs,
644
+ interval: (elapsedMs) => chatPollInterval(elapsedMs, intervalMs)
645
+ });
646
+ } catch (err) {
647
+ 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)}`));
648
+ throw err;
649
+ }
650
+ const reply = replyText(quest);
651
+ if (quest.status === "stopped") throw new Error(`chat completion was stopped (${ref})${reply ? `: ${reply}` : ""}`);
652
+ if (quest.type === "error") {
653
+ const code = quest.errorCode ? `${quest.errorCode}: ` : "";
654
+ throw new Error(`${code}${reply || "chat completion failed"} (${ref})`);
655
+ }
656
+ const notebookId = sessionId ?? quest.sessionId;
605
657
  const citables = (quest.promptMeta?.citables ?? []).map((c) => ({
606
658
  id: c.id,
607
659
  type: c.type,
@@ -612,7 +664,7 @@ async function sendMessage(client, args, { intervalMs = CHAT_POLL_INTERVAL_MS, t
612
664
  return {
613
665
  notebookId,
614
666
  questId,
615
- reply: replyText(quest),
667
+ reply,
616
668
  model: ack.model,
617
669
  citables
618
670
  };
@@ -653,9 +705,9 @@ const isSettled = (q) => q.status === "done" || q.status === "stopped" || q.type
653
705
  const isImageSettled = (q) => isSettled(q) || !q.status && !!q.images?.length;
654
706
  const replyText = (q) => questReplyText(q, joinReplySlots) ?? "";
655
707
  const questRef = (questId, notebookId) => `quest ${questId}${notebookId ? `, notebook ${notebookId}` : ""}`;
656
- const isRateLimited = (err) => isAxiosError(err) && err.response?.status === 429;
708
+ const isRateLimited = (err) => apiErrorStatus(err) === 429;
657
709
  const isPermanentApiError = (err) => {
658
- const status = isAxiosError(err) ? err.response?.status : void 0;
710
+ const status = apiErrorStatus(err);
659
711
  return status === 401 || status === 403 || status === 404;
660
712
  };
661
713
  /** Poll a queued quest until `isFinished`, the timeout, a permanent poll failure, or an abort. */
@@ -671,7 +723,7 @@ async function pollQuest(client, { questId, ref, task, scope, isFinished }, { in
671
723
  failures = 0;
672
724
  if (isFinished(quest)) return quest;
673
725
  } catch (err) {
674
- if (isRateLimited(err)) retryAfterMs = (parseRetryAfterSeconds(isAxiosError(err) && err.response?.headers?.["retry-after"]) ?? 0) * 1e3;
726
+ if (isRateLimited(err)) retryAfterMs = (apiErrorRetryAfterSeconds(err) ?? 0) * 1e3;
675
727
  else {
676
728
  failures += 1;
677
729
  if (isPermanentApiError(err) || failures >= MAX_CONSECUTIVE_POLL_FAILURES) throw new Error(`${mapApiError(err, baseURL, scope)} (${ref}; the ${task} may still complete)`);
@@ -695,7 +747,7 @@ async function pollQuest(client, { questId, ref, task, scope, isFinished }, { in
695
747
  * resolves `status: 'done'`, so `type: 'error'` (reason in `reply`) is the failure signal.
696
748
  * Generated images are not FabFiles, so there is no file id: the quest id is the handle,
697
749
  * 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).
750
+ * (`fileUrl` is a short-lived presigned URL, valid for about an hour).
699
751
  */
700
752
  async function generateImage(client, args, { intervalMs = IMAGE_POLL_INTERVAL_MS, timeoutMs = IMAGE_POLL_TIMEOUT_MS, ...poll } = {}) {
701
753
  const ack = await client.generateImage(args);
@@ -731,6 +783,95 @@ async function generateImage(client, args, { intervalMs = IMAGE_POLL_INTERVAL_MS
731
783
  images
732
784
  };
733
785
  }
786
+ const PUBLISH_INDEX_PATH = "index.html";
787
+ const PUBLISH_INDEX_MIME = "text/html";
788
+ const PUBLISH_TITLE_MAX = 200;
789
+ const PUBLISH_FALLBACK_TITLE = "Shared artifact";
790
+ const PUBLISH_DEFAULT_VISIBILITY = "public";
791
+ const SLUG_TITLE_MAX = 48;
792
+ const SLUG_ARTIFACT_HASH_LENGTH = 8;
793
+ const SLUG_DISCRIMINATOR_LENGTH = 6;
794
+ function slugify(text) {
795
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, SLUG_TITLE_MAX).replace(/-+$/, "");
796
+ }
797
+ /**
798
+ * Stable per artifact, so republishing updates the same page in place. Artifact
799
+ * ids all share an `artifact_` prefix, so the suffix hashes the whole id rather
800
+ * than taking a prefix of it.
801
+ */
802
+ function artifactSlug(title, artifactId) {
803
+ const hash = createHash("sha256").update(artifactId).digest("hex").slice(0, SLUG_ARTIFACT_HASH_LENGTH);
804
+ return `${slugify(title) || "artifact"}-${hash}`;
805
+ }
806
+ /** The artifact-content doc carries the body as `content`; anything else has none to publish. */
807
+ function artifactContentText(content) {
808
+ if (!content || typeof content !== "object") return void 0;
809
+ const text = content.content;
810
+ return typeof text === "string" && text.trim() ? text : void 0;
811
+ }
812
+ /**
813
+ * Open the draft, retrying once under a discriminated slug when the derived slug
814
+ * already belongs to another publisher (the same fallback the web Share flow uses).
815
+ */
816
+ async function openPublishDraft(client, request) {
817
+ try {
818
+ return await client.requestPublishUpload(request);
819
+ } catch (err) {
820
+ if (apiErrorCode(err) !== "slug_owned_by_other") throw err;
821
+ const discriminator = randomUUID().replace(/-/g, "").slice(0, SLUG_DISCRIMINATOR_LENGTH);
822
+ return client.requestPublishUpload({
823
+ ...request,
824
+ slug: `${request.slug}-${discriminator}`
825
+ });
826
+ }
827
+ }
828
+ /**
829
+ * Publish an artifact: upload its RAW content and let finalize render it
830
+ * (source.artifactType), so the page matches an in-app publish byte for byte with
831
+ * no CLI-side renderer. An artifact the caller already published (from the app or
832
+ * here) is republished in place at its existing tier/scope/slug, as the in-app
833
+ * update flow does; otherwise it becomes a new user-tier page.
834
+ */
835
+ async function publishArtifact(client, args) {
836
+ const { artifact, content } = await client.getArtifact(args.artifactId);
837
+ const body = artifactContentText(content);
838
+ if (!body) throw new Error(`artifact ${artifact.id} has no content to publish`);
839
+ const artifactType = ArtifactTypeSchema.safeParse(artifact.type);
840
+ if (!artifactType.success) throw new Error(`artifact ${artifact.id} has an unsupported type (${String(artifact.type)})`);
841
+ if (typeof artifact.userId !== "string") throw new Error(`artifact ${artifact.id} has no owner to publish under`);
842
+ const storedTitle = typeof artifact.title === "string" ? artifact.title : "";
843
+ const title = (args.title ?? storedTitle).trim().slice(0, PUBLISH_TITLE_MAX) || PUBLISH_FALLBACK_TITLE;
844
+ const existing = await client.findPublishedArtifact(artifact.id);
845
+ const request = {
846
+ tier: existing?.tier ?? "user",
847
+ scopeId: existing?.scopeId ?? artifact.userId,
848
+ slug: existing?.slug ?? artifactSlug(title, artifact.id),
849
+ title,
850
+ visibility: args.visibility ?? existing?.visibility ?? PUBLISH_DEFAULT_VISIBILITY,
851
+ ...existing?.description !== void 0 && { description: existing.description },
852
+ ...existing?.commentPolicy !== void 0 && { commentPolicy: existing.commentPolicy },
853
+ source: {
854
+ kind: "bundle",
855
+ artifactId: artifact.id,
856
+ artifactType: artifactType.data
857
+ },
858
+ files: [{
859
+ path: PUBLISH_INDEX_PATH,
860
+ size: Buffer.byteLength(body),
861
+ mimeType: PUBLISH_INDEX_MIME
862
+ }]
863
+ };
864
+ const draft = existing ? await client.requestPublishUpload(request) : await openPublishDraft(client, request);
865
+ const upload = draft.uploadUrls.find((u) => u.path === PUBLISH_INDEX_PATH);
866
+ if (!upload) throw new Error(`publish draft ${draft.draftId} returned no upload URL for ${PUBLISH_INDEX_PATH}`);
867
+ await client.uploadPublishDraftFile(upload.url, body, PUBLISH_INDEX_MIME);
868
+ const published = await client.finalizePublish(draft.draftId);
869
+ return {
870
+ publicId: published.publicId,
871
+ url: new URL(published.url, client.baseURL).toString(),
872
+ visibility: published.visibility
873
+ };
874
+ }
734
875
  function toResult(value) {
735
876
  const structuredContent = value && typeof value === "object" && !Array.isArray(value) ? value : { result: value };
736
877
  return {
@@ -977,6 +1118,11 @@ function registerTools(server, client) {
977
1118
  baseURL,
978
1119
  onProgress: progressReporter(extra, () => "rendering image")
979
1120
  })));
1121
+ server.registerTool("publish_artifact", {
1122
+ title: meta("publish_artifact").title,
1123
+ description: meta("publish_artifact").description,
1124
+ inputSchema: publishArtifactShape
1125
+ }, (args) => run("files:write", () => publishArtifact(client, args)));
980
1126
  }
981
1127
  //#endregion
982
1128
  //#region src/mcp/resources.ts
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export { o as useCliStore } from "./store-BxmPEI3v.mjs";