@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.
- package/README.md +1 -1
- package/dist/{AgentHistoryStore-DdYKfx7C.mjs → AgentHistoryStore-DVxv1kSO.mjs} +7905 -7029
- package/dist/{ConfigStore-BygzEI6H.mjs → ConfigStore-51UaP9pJ.mjs} +10645 -6760
- package/dist/{ImageStore-kVo-oHoS.mjs → ImageStore-DuI3tgwQ.mjs} +8 -6
- package/dist/SandboxRuntimeAdapter-Bt1wPpld.mjs +2 -0
- package/dist/{WorkItemsClient-ClBC49y0.mjs → WorkItemsClient-BMY0Qq1s.mjs} +27 -2
- package/dist/{buildAgent-B7cMwRAy.mjs → buildAgent-CrSRKYU_.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-BsG2Hlp-.mjs +739 -0
- package/dist/index.mjs +602 -324
- package/dist/{package-CRNnNXxq.mjs → package-DEsNSP54.mjs} +1 -1
- package/dist/{serve-DM7ld3-o.mjs → serve-BaHzVbcm.mjs} +300 -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 {
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
@@ -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
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
|
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;
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
588
|
-
*
|
|
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
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
|
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) =>
|
|
708
|
+
const isRateLimited = (err) => apiErrorStatus(err) === 429;
|
|
657
709
|
const isPermanentApiError = (err) => {
|
|
658
|
-
const status =
|
|
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 = (
|
|
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
|
|
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
|