@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.
- package/README.md +1 -1
- package/dist/{AgentHistoryStore-DdYKfx7C.mjs → AgentHistoryStore-O2gKB5YG.mjs} +7958 -7053
- package/dist/{ConfigStore-BygzEI6H.mjs → ConfigStore-RHqyXcSS.mjs} +10291 -6263
- package/dist/{ImageStore-kVo-oHoS.mjs → ImageStore-DuI3tgwQ.mjs} +8 -6
- package/dist/SandboxRuntimeAdapter-Bt1wPpld.mjs +2 -0
- package/dist/{WorkItemsClient-ClBC49y0.mjs → WorkItemsClient-cmw7WYBy.mjs} +27 -2
- package/dist/{buildAgent-B7cMwRAy.mjs → buildAgent-C0jmyDL4.mjs} +75 -43
- package/dist/commands/acpCommand.mjs +26 -35
- package/dist/commands/apiCommand.mjs +1 -1
- package/dist/commands/doctorCommand.mjs +3 -3
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +15 -24
- package/dist/commands/mcpCommand.mjs +3 -3
- package/dist/commands/pluginCommand.mjs +2 -2
- package/dist/commands/updateCommand.mjs +5 -15
- package/dist/dist-B1bGf-yn.mjs +739 -0
- package/dist/index.mjs +602 -324
- package/dist/{package-CRNnNXxq.mjs → package-DsG8Tgvs.mjs} +1 -1
- package/dist/{serve-DM7ld3-o.mjs → serve-CAfCQose.mjs} +344 -154
- package/dist/store-BkMur6uk.mjs +2 -0
- package/dist/{store-CvjTpQPs.mjs → store-BxmPEI3v.mjs} +10 -0
- package/dist/types-I45lMh7Q.mjs +2 -0
- package/dist/{updateChecker-CQW8bxo6.mjs → updateChecker-CCLVVdH0.mjs} +36 -13
- package/package.json +24 -23
- package/dist/ApiClient-dIB3YQc2.mjs +0 -316
- package/dist/SandboxRuntimeAdapter-BmHELLuM.mjs +0 -3
- package/dist/store-BG3e54c8.mjs +0 -3
- package/dist/types-CdIKgWWe.mjs +0 -3
- package/dist/{bashExecute-CrdPpBqk-DCATrE-D.mjs → bashExecute-CrdPpBqk-CgK1gZa2.mjs} +1 -1
- 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
|
|
3
|
-
import {
|
|
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
|
|
20
|
+
function writableNotebookId(notebookId) {
|
|
19
21
|
if (!NOTEBOOK_ID_PATTERN.test(notebookId)) throw new Error(`Invalid notebook id: ${JSON.stringify(notebookId)}`);
|
|
20
|
-
return
|
|
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.
|
|
66
|
+
return this.sdk.call("getSession", { params: { id: notebookId } });
|
|
55
67
|
}
|
|
56
68
|
async createNotebook(args) {
|
|
57
|
-
return this.
|
|
58
|
-
|
|
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.
|
|
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.
|
|
83
|
+
return this.sdk.call("cloneSession", {
|
|
84
|
+
params: { id: writableNotebookId(notebookId) },
|
|
85
|
+
body: {}
|
|
86
|
+
});
|
|
69
87
|
}
|
|
70
88
|
async deleteNotebook(notebookId) {
|
|
71
|
-
return this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
142
|
+
/** Generate a sound effect. The SDK requests base64 and rebuilds an old server's raw-bytes answer. */
|
|
120
143
|
async generateSoundEffect(args) {
|
|
121
|
-
|
|
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
|
-
}
|
|
150
|
+
};
|
|
151
|
+
return generatedAudioResponseSchema.parse(await this.sdk.soundEffects(body));
|
|
128
152
|
}
|
|
129
153
|
async synthesizeSpeech(args) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
const project = await this.sdk.call("createProject", { body: {
|
|
210
196
|
name: args.name,
|
|
211
197
|
description: args.description,
|
|
212
|
-
...args.sessionIds?.length ? {
|
|
213
|
-
...args.fileIds?.length ? {
|
|
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
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
|
293
|
-
if (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
588
|
-
*
|
|
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
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
|
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) =>
|
|
744
|
+
const isRateLimited = (err) => apiErrorStatus(err) === 429;
|
|
657
745
|
const isPermanentApiError = (err) => {
|
|
658
|
-
const status =
|
|
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 = (
|
|
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
|
|
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
|