@bike4mind/cli 1.0.0 → 1.2.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 (36) hide show
  1. package/README.md +1 -0
  2. package/bin/bike4mind-cli.mjs +0 -5
  3. package/dist/AgentHistoryStore-B2NEOvSW.mjs +18014 -0
  4. package/dist/{ApiClient-EUTyn5yu.mjs → ApiClient-D0fQ2FT6.mjs} +2 -2
  5. package/dist/{BubblewrapRuntime-CkL9-gnG.mjs → BubblewrapRuntime-5bLPTwEC.mjs} +2 -2
  6. package/dist/{ConfigStore-DHNgFdOu.mjs → ConfigStore-qV7NrCgZ.mjs} +2259 -1283
  7. package/dist/{ProxyManager-C1-lgzEU.mjs → ProxyManager-B0-RuR2w.mjs} +21 -4
  8. package/dist/{SandboxOrchestrator-C8uleDn2.mjs → SandboxOrchestrator-BcUv9fQ3.mjs} +47 -13
  9. package/dist/{SandboxRuntimeAdapter-ChGlxSGQ.mjs → SandboxRuntimeAdapter-BgLUVTJL.mjs} +2 -2
  10. package/dist/{SandboxRuntimeAdapter-CKelGICD.mjs → SandboxRuntimeAdapter-BmHELLuM.mjs} +1 -1
  11. package/dist/{SeatbeltRuntime-Qqt19cAN.mjs → SeatbeltRuntime-C_Y8q8Mr.mjs} +9 -1
  12. package/dist/buildAgent-C-C-VGff.mjs +2001 -0
  13. package/dist/commands/acpCommand.mjs +36 -13
  14. package/dist/commands/apiCommand.mjs +1 -1
  15. package/dist/commands/doctorCommand.mjs +1 -1
  16. package/dist/commands/envCommand.mjs +1 -1
  17. package/dist/commands/headlessCommand.mjs +92 -42
  18. package/dist/commands/mcpCommand.mjs +8 -13
  19. package/dist/commands/pluginCommand.mjs +9 -15
  20. package/dist/commands/updateCommand.mjs +1 -1
  21. package/dist/{createFile-DPv180yF-BnWFIxey.mjs → createFile-B8bur5Rb-CVzCarEA.mjs} +2 -2
  22. package/dist/{deleteFile-BdjUwUQF-B3XOJmg3.mjs → deleteFile-9B3gW_Nb-DG2sovIl.mjs} +2 -2
  23. package/dist/{globFiles-DjfDGaUK-CNR8pMRC.mjs → globFiles-CwJ8qmYo-BR5b2KvO.mjs} +3 -2
  24. package/dist/{grepSearch-BaYUfIYs-n0XKoGnL.mjs → grepSearch-BgoOOwGe-DtlV8Gn-.mjs} +3 -3
  25. package/dist/index.mjs +638 -129
  26. package/dist/{package-7a45-Svr.mjs → package-CGZIoxcs.mjs} +1 -1
  27. package/dist/{pathValidation-D8tjkQXE-1HwvsuYT.mjs → pathValidation-BRqf4HFX-CHwtwp3O.mjs} +7 -3
  28. package/dist/{serve-CavAHPdQ.mjs → serve-CPXqcEZr.mjs} +2 -2
  29. package/dist/types-CdIKgWWe.mjs +3 -0
  30. package/dist/{types-LyRNHOiS.mjs → types-F61_hxmG.mjs} +2 -0
  31. package/package.json +28 -29
  32. package/dist/AgentHistoryStore-CrRb8cMt.mjs +0 -38586
  33. package/dist/ProxyManager-B1jFWL7b.mjs +0 -3
  34. package/dist/SandboxOrchestrator-BFPVpmB5.mjs +0 -3
  35. package/dist/buildAgent-CJrkEG0M.mjs +0 -824
  36. package/dist/types-CqscS34o.mjs +0 -3
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { t as DEFAULT_SANDBOX_CONFIG } from "./types-LyRNHOiS.mjs";
3
- import "crypto";
2
+ import { t as DEFAULT_SANDBOX_CONFIG } from "./types-F61_hxmG.mjs";
3
+ import { createHmac } from "crypto";
4
4
  import { existsSync, promises } from "fs";
5
5
  import os, { homedir } from "os";
6
6
  import path from "path";
7
7
  import { v4 } from "uuid";
8
+ import * as path$1 from "node:path";
9
+ import * as fs$1 from "node:fs";
8
10
  import * as z$2 from "zod";
9
11
  import z, { ZodError, z as z$1 } from "zod";
10
12
  import dayjs from "dayjs";
@@ -13,124 +15,10 @@ import utc from "dayjs/plugin/utc.js";
13
15
  import relativeTime from "dayjs/plugin/relativeTime.js";
14
16
  import localizedFormat from "dayjs/plugin/localizedFormat.js";
15
17
  import { isAxiosError } from "axios";
16
- import fs$1 from "fs/promises";
18
+ import { homedir as homedir$1 } from "node:os";
19
+ import fs from "fs/promises";
17
20
  process.env.APP_NAME;
18
21
  process.env.WEBSITE_URL;
19
- const extractSnippetMeta = (content) => {
20
- const snippetRegex = /<!--snippet-meta\s*(\{[\s\S]*?\})\s*-->[\n\s]*([\s\S]*?)(?=<!--snippet-meta|$)/g;
21
- const sections = [];
22
- let lastIndex = 0;
23
- let match;
24
- while ((match = snippetRegex.exec(content)) !== null) {
25
- if (match.index > lastIndex) {
26
- const textBefore = content.slice(lastIndex, match.index).trim();
27
- if (textBefore) sections.push({
28
- type: "text",
29
- content: textBefore
30
- });
31
- }
32
- try {
33
- const meta = JSON.parse(match[1]);
34
- const snippetContent = match[2].trim();
35
- if (meta && snippetContent) sections.push({
36
- type: "snippet",
37
- meta,
38
- content: snippetContent
39
- });
40
- } catch (e) {
41
- console.error("Error parsing snippet meta:", e);
42
- }
43
- lastIndex = match.index + match[0].length;
44
- }
45
- if (lastIndex < content.length) {
46
- const remainingText = content.slice(lastIndex).trim();
47
- if (remainingText) sections.push({
48
- type: "text",
49
- content: remainingText
50
- });
51
- }
52
- return { sections };
53
- };
54
- function getHeader$1(headers, name) {
55
- if (!headers || typeof headers !== "object") return null;
56
- if (typeof headers.get === "function") {
57
- const val = headers.get(name);
58
- return typeof val === "string" ? val : null;
59
- }
60
- const value = headers[name] ?? headers[name.toLowerCase()];
61
- return typeof value === "string" ? value : null;
62
- }
63
- function parseNumber(value) {
64
- if (value === null || value === void 0) return null;
65
- const num = Number(value);
66
- return Number.isFinite(num) ? num : null;
67
- }
68
- /**
69
- * Parse rate limit headers from an HTTP response.
70
- *
71
- * Reads standard headers:
72
- * - `X-RateLimit-Limit` - max requests per window
73
- * - `X-RateLimit-Remaining` - requests left
74
- * - `X-RateLimit-Reset` - Unix epoch seconds when the window resets
75
- * - `Retry-After` - seconds to wait (on 429 responses), or an HTTP-date
76
- */
77
- function parseRateLimitHeaders(headers) {
78
- const limitStr = getHeader$1(headers, "X-RateLimit-Limit") ?? getHeader$1(headers, "x-ratelimit-limit");
79
- const remainingStr = getHeader$1(headers, "X-RateLimit-Remaining") ?? getHeader$1(headers, "x-ratelimit-remaining");
80
- const resetStr = getHeader$1(headers, "X-RateLimit-Reset") ?? getHeader$1(headers, "x-ratelimit-reset");
81
- const retryAfterStr = getHeader$1(headers, "Retry-After") ?? getHeader$1(headers, "retry-after");
82
- const limit = parseNumber(limitStr);
83
- const remaining = parseNumber(remainingStr);
84
- let resetAt = null;
85
- if (resetStr !== null) {
86
- const resetNum = Number(resetStr);
87
- if (Number.isFinite(resetNum)) resetAt = /* @__PURE__ */ new Date(resetNum * 1e3);
88
- else {
89
- const parsed = new Date(resetStr);
90
- if (!isNaN(parsed.getTime())) resetAt = parsed;
91
- }
92
- }
93
- let retryAfterMs = null;
94
- if (retryAfterStr !== null) {
95
- const retryNum = Number(retryAfterStr);
96
- if (Number.isFinite(retryNum)) retryAfterMs = retryNum * 1e3;
97
- else {
98
- const parsed = new Date(retryAfterStr);
99
- if (!isNaN(parsed.getTime())) retryAfterMs = Math.max(0, parsed.getTime() - Date.now());
100
- }
101
- }
102
- let usagePercent = null;
103
- if (limit !== null && limit > 0 && remaining !== null && remaining >= 0) usagePercent = Math.round((limit - remaining) / limit * 100);
104
- return {
105
- limit,
106
- remaining,
107
- resetAt,
108
- retryAfterMs,
109
- usagePercent
110
- };
111
- }
112
- function isNearLimit(info, thresholdPercent = 80) {
113
- if (info.usagePercent === null) return false;
114
- return info.usagePercent >= thresholdPercent;
115
- }
116
- /**
117
- * Build a structured log object for rate limit events.
118
- * Used by all integration clients to emit consistent log lines.
119
- */
120
- function buildRateLimitLogEntry(integration, endpoint, info, wasThrottled = false) {
121
- return {
122
- type: wasThrottled ? "RATE_LIMIT_ERROR" : "RATE_LIMIT",
123
- integration,
124
- endpoint,
125
- limit: info.limit,
126
- remaining: info.remaining,
127
- resetAt: info.resetAt?.toISOString() ?? null,
128
- retryAfterMs: info.retryAfterMs,
129
- usagePercent: info.usagePercent,
130
- wasThrottled,
131
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
132
- };
133
- }
134
22
  //#endregion
135
23
  //#region ../../b4m-core/hearth/dist/index.mjs
136
24
  /**
@@ -278,23 +166,14 @@ z$1.object({
278
166
  });
279
167
  //#endregion
280
168
  //#region ../../b4m-core/common/dist/index.mjs
281
- let HttpStatus = /* @__PURE__ */ function(HttpStatus) {
282
- HttpStatus[HttpStatus["Ok"] = 200] = "Ok";
283
- HttpStatus[HttpStatus["Created"] = 201] = "Created";
284
- HttpStatus[HttpStatus["BadRequest"] = 400] = "BadRequest";
285
- HttpStatus[HttpStatus["Unauthorized"] = 401] = "Unauthorized";
286
- HttpStatus[HttpStatus["Forbidden"] = 403] = "Forbidden";
287
- HttpStatus[HttpStatus["NotFound"] = 404] = "NotFound";
288
- HttpStatus[HttpStatus["Conflict"] = 409] = "Conflict";
289
- HttpStatus[HttpStatus["UnprocessableEntity"] = 422] = "UnprocessableEntity";
290
- HttpStatus[HttpStatus["TooManyRequests"] = 429] = "TooManyRequests";
291
- HttpStatus[HttpStatus["InternalServerError"] = 500] = "InternalServerError";
292
- HttpStatus[HttpStatus["BadGateway"] = 502] = "BadGateway";
293
- return HttpStatus;
294
- }({});
295
169
  var HTTPError = class extends Error {
296
170
  statusCode;
297
171
  additionalInfo;
172
+ /**
173
+ * Set on a 5xx that reports a third party's failure the server handled correctly (a source site
174
+ * timing out), so `errorHandler` logs it at warn rather than paging as a server fault.
175
+ */
176
+ expected;
298
177
  constructor(statusCode, message, additionalInfo) {
299
178
  super(message);
300
179
  this.statusCode = statusCode;
@@ -326,47 +205,6 @@ var UnprocessableEntityError = class extends HTTPError {
326
205
  this.name = "UnprocessableEntityError";
327
206
  }
328
207
  };
329
- var BadRequestError = class extends HTTPError {
330
- additionalInfo;
331
- constructor(message, additionalInfo) {
332
- super(400, message, additionalInfo);
333
- this.additionalInfo = additionalInfo;
334
- this.name = "BadRequestError";
335
- }
336
- };
337
- var UnauthorizedError = class extends HTTPError {
338
- additionalInfo;
339
- constructor(message, additionalInfo) {
340
- super(401, message, additionalInfo);
341
- this.additionalInfo = additionalInfo;
342
- this.name = "UnauthorizedError";
343
- }
344
- };
345
- var ForbiddenError = class extends HTTPError {
346
- additionalInfo;
347
- constructor(message, additionalInfo) {
348
- super(403, message, additionalInfo);
349
- this.additionalInfo = additionalInfo;
350
- this.name = "ForbiddenError";
351
- }
352
- };
353
- var TooManyRequestsError = class extends HTTPError {
354
- additionalInfo;
355
- constructor(message, additionalInfo) {
356
- super(429, message, additionalInfo);
357
- this.additionalInfo = additionalInfo;
358
- this.name = "TooManyRequestsError";
359
- }
360
- };
361
- var CorruptedFileError = class extends HTTPError {
362
- additionalInfo;
363
- constructor(fileName, fileType, corruptionDetails, additionalInfo) {
364
- const message = `File '${fileName}' (${fileType}) appears to be corrupted${corruptionDetails ? `: ${corruptionDetails}` : ""}. Please try uploading the file again.`;
365
- super(422, message, additionalInfo);
366
- this.additionalInfo = additionalInfo;
367
- this.name = "CorruptedFileError";
368
- }
369
- };
370
208
  function isZodError(err) {
371
209
  return Boolean(err && (err instanceof ZodError || err.name === "ZodError"));
372
210
  }
@@ -759,19 +597,7 @@ z$1.object({
759
597
  content: z$1.string(),
760
598
  metadata: ArtifactMetadataSchema.optional()
761
599
  });
762
- /**
763
- * Regex sub-pattern (as a string) that matches the attribute portion of an
764
- * `<artifact ...>` opening tag. It handles:
765
- * - newlines inside the attribute list (AI sometimes wraps long tags),
766
- * - `>` characters inside double- or single-quoted attribute values.
767
- *
768
- * Exported as a string (not a compiled RegExp) so each consumer can
769
- * compose it into their own regex with the flags they need, avoiding
770
- * shared mutable `lastIndex` state.
771
- *
772
- * Usage: `new RegExp('<artifact\\s+(' + ARTIFACT_ATTRS_PATTERN + ')>...')`
773
- */
774
- const ARTIFACT_ATTRS_PATTERN = String.raw`(?:[^>"']|"[^"]*"|'[^']*')*`;
600
+ String.raw`(?:[^>"']|"[^"]*"|'[^']*')*`;
775
601
  const ClaudeArtifactMimeTypes = {
776
602
  REACT: "application/vnd.ant.react",
777
603
  HTML: "text/html",
@@ -785,45 +611,6 @@ const ClaudeArtifactMimeTypes = {
785
611
  PYTHON: "application/vnd.ant.python",
786
612
  BLOG_DRAFT: "application/vnd.b4m.blog-draft"
787
613
  };
788
- /**
789
- * Map a MIME type (or AI-provider artifact-type string) to an internal {@link ArtifactType}.
790
- *
791
- * Single source of truth - consumed by the artifact parsers (b4m-core/utils + client) and the
792
- * tool_result dedup in ChatCompletionProcess. Exact blessed-type matches first (case-insensitive,
793
- * since MIME types are), then language/format inference; returns null if unrecognized.
794
- *
795
- * This previously lived as three hand-maintained copies that drifted - e.g. the lattice
796
- * tool emits `application/vnd.b4m.lattice` but a copy matched `application/vnd.ant.lattice`,
797
- * letting lattice tool_result artifacts dodge the dedup set.
798
- */
799
- function mapMimeTypeToArtifactType(mimeType) {
800
- if (!mimeType) return null;
801
- const normalized = mimeType.toLowerCase().trim();
802
- switch (normalized) {
803
- case ClaudeArtifactMimeTypes.REACT.toLowerCase(): return "react";
804
- case ClaudeArtifactMimeTypes.HTML.toLowerCase(): return "html";
805
- case ClaudeArtifactMimeTypes.SVG.toLowerCase(): return "svg";
806
- case ClaudeArtifactMimeTypes.MERMAID.toLowerCase(): return "mermaid";
807
- case ClaudeArtifactMimeTypes.RECHARTS.toLowerCase(): return "recharts";
808
- case ClaudeArtifactMimeTypes.CHESS.toLowerCase(): return "chess";
809
- case ClaudeArtifactMimeTypes.CODE.toLowerCase(): return "code";
810
- case ClaudeArtifactMimeTypes.MARKDOWN.toLowerCase(): return "code";
811
- case ClaudeArtifactMimeTypes.LATTICE.toLowerCase(): return "lattice";
812
- case ClaudeArtifactMimeTypes.PYTHON.toLowerCase(): return "python";
813
- case ClaudeArtifactMimeTypes.BLOG_DRAFT.toLowerCase(): return "blog-draft";
814
- }
815
- if (normalized.includes("jsx") || normalized.includes("react")) return "react";
816
- if (normalized.includes("javascript") || normalized.includes("typescript")) return "code";
817
- if (normalized.includes("python") || normalized === "text/x-python") return "python";
818
- if (normalized.includes("java") || normalized.includes("c++") || normalized.includes("rust") || normalized.includes("go") || normalized.includes("ruby") || normalized.includes("php") || normalized.includes("swift") || normalized.includes("kotlin") || normalized.includes("csharp") || normalized.includes("c#")) return "code";
819
- if (normalized.includes("html") || normalized.includes("xhtml")) return "html";
820
- if (normalized.includes("svg")) return "svg";
821
- if (normalized.includes("markdown") || normalized.includes("md")) return "code";
822
- if (normalized.includes("mermaid")) return "mermaid";
823
- if (normalized.includes("recharts") || normalized.includes("chart")) return "recharts";
824
- if (normalized.includes("chess")) return "chess";
825
- return null;
826
- }
827
614
  let KnowledgeType = /* @__PURE__ */ function(KnowledgeType) {
828
615
  /**
829
616
  * A knowledge that is from a URL.
@@ -858,6 +645,19 @@ let KnowledgeType = /* @__PURE__ */ function(KnowledgeType) {
858
645
  * fails the build.
859
646
  */
860
647
  const QUEST_ERROR_CODES = ["insufficient_credits", "spend_cap_exceeded"];
648
+ /**
649
+ * Single source of truth for `IChatHistoryItem.type` / quest `type`. Restated by hand in
650
+ * `schemas/actions.ts`, `schemas/chat.ts`, and `notebookExportService/types.ts` before this
651
+ * const existed, which is exactly how `voice_transcript` went missing from the export contract
652
+ * once already - derive from this tuple instead of retyping the union.
653
+ */
654
+ const CHAT_HISTORY_ITEM_TYPES = [
655
+ "message",
656
+ "oob",
657
+ "error",
658
+ "system",
659
+ "voice_transcript"
660
+ ];
861
661
  z$1.union([
862
662
  z$1.literal("string"),
863
663
  z$1.literal("number"),
@@ -982,6 +782,12 @@ const IMAGE_SIZE_CONSTRAINTS = {
982
782
  "2160x3840"
983
783
  ],
984
784
  defaultSize: "1024x1024",
785
+ /**
786
+ * Accepted by the API, and what generate sends when no size is asked for, but it is not a
787
+ * resolution - so it is deliberately out of `sizes`, the preset list the size picker renders.
788
+ * The one spelling: schemas/openai.ts and utils/imageSizes.ts both read it from here.
789
+ */
790
+ autoSize: "auto",
985
791
  /** Constraints for custom/flexible sizes */
986
792
  constraints: {
987
793
  maxEdge: 3840,
@@ -990,6 +796,28 @@ const IMAGE_SIZE_CONSTRAINTS = {
990
796
  edgeMultiple: 16,
991
797
  maxAspectRatio: 3
992
798
  }
799
+ },
800
+ /** Also the only sizes the variation endpoint accepts - variations are dall-e-2 only. */
801
+ DALL_E_2: {
802
+ sizes: [
803
+ "256x256",
804
+ "512x512",
805
+ "1024x1024"
806
+ ],
807
+ defaultSize: "1024x1024"
808
+ },
809
+ /**
810
+ * dall-e-3 is no longer in ImageModels, but the generate path still accepts its sizes
811
+ * for callers holding a persisted one. Reached by LEGACY_DALL_E_3_MODEL_ID rather than an
812
+ * enum member; isSupportedImageSize measures each dall-e tier against its own list.
813
+ */
814
+ DALL_E_3: {
815
+ sizes: [
816
+ "1024x1024",
817
+ "1792x1024",
818
+ "1024x1792"
819
+ ],
820
+ defaultSize: "1024x1024"
993
821
  }
994
822
  };
995
823
  /**
@@ -1071,6 +899,7 @@ let ChatModels = /* @__PURE__ */ function(ChatModels) {
1071
899
  ChatModels["CLAUDE_4_8_OPUS"] = "claude-opus-4-8";
1072
900
  ChatModels["CLAUDE_FABLE_5"] = "claude-fable-5";
1073
901
  ChatModels["CLAUDE_5_OPUS"] = "claude-opus-5";
902
+ ChatModels["CLAUDE_5_5_OPUS"] = "claude-opus-5-5";
1074
903
  ChatModels["JURASSIC2_ULTRA"] = "ai21.j2-ultra-v1";
1075
904
  ChatModels["JURASSIC2_MID"] = "ai21.j2-mid-v1";
1076
905
  ChatModels["GEMINI_3_5_FLASH"] = "gemini-3.5-flash";
@@ -1112,12 +941,6 @@ let ChatModels = /* @__PURE__ */ function(ChatModels) {
1112
941
  const CHAT_MODELS = Object.values(ChatModels);
1113
942
  const supportedChatModels = z$1.enum(ChatModels);
1114
943
  /**
1115
- * Every `ChatModels` Gemini entry is named `gemini...` (see the GEMINI block above) - a prefix
1116
- * check tracks that naming convention automatically as new Gemini models are added, unlike an
1117
- * explicit id list that would need updating in lockstep and could silently miss one.
1118
- */
1119
- const isGeminiModelId = (model) => model.startsWith("gemini");
1120
- /**
1121
944
  * Models that support the reasoning_effort parameter.
1122
945
  * o1-preview and o1-mini do NOT support reasoning_effort.
1123
946
  */
@@ -1138,137 +961,7 @@ const REASONING_SUPPORTED_MODELS = /* @__PURE__ */ new Set([
1138
961
  "gpt-5.6-luna",
1139
962
  "gpt-5.6-terra"
1140
963
  ]);
1141
- /**
1142
- * GPT-5-family reasoning models whose tool calling breaks on
1143
- * `/v1/chat/completions` when `reasoning_effort` is also sent. OpenAI requires
1144
- * this combination to go through `/v1/responses` instead. The failure mode
1145
- * differs by model:
1146
- * - GPT-5.4 (and -mini/-nano) hard-reject with a 400:
1147
- * "Function tools with reasoning_effort are not supported for <model> in
1148
- * /v1/chat/completions. Please use /v1/responses instead."
1149
- * - GPT-5 / -mini / -nano / 5.1 / 5.2 return 200 but silently degrade: the
1150
- * model *narrates* the tool call in its text ("Calling the tool now...")
1151
- * instead of emitting a real `tool_calls` entry, so no tool ever executes.
1152
- * This surfaced as the /opti optimizer's "Draft with AI" doing nothing on
1153
- * GPT-5: the same request on a model with `reasoning_effort`
1154
- * dropped (or on Claude) fires the tool correctly.
1155
- *
1156
- * We drop `reasoning_effort` when tools are sent for these models so tool
1157
- * calling continues to work on `/v1/chat/completions`. Dropping it only forgoes
1158
- * explicit effort control - the model still reasons at its default.
1159
- *
1160
- * NOTE: for the base GPT-5 narrator family (`RESPONSES_API_TOOL_MODELS`), the
1161
- * adapter now routes tool turns to `/v1/responses` instead - where reasoning +
1162
- * tools work together, so `reasoning_effort` is kept. This drop remains as
1163
- * defense-in-depth for the (now-unreached) chat path and covers the GPT-5.4
1164
- * family, which is NOT routed to Responses (its drop-path already works).
1165
- *
1166
- * O-series reasoning models (o1/o3/o4) are intentionally excluded: they call
1167
- * tools correctly with `reasoning_effort` on `/v1/chat/completions`.
1168
- *
1169
- * Invariant: every member here MUST also be in `REASONING_SUPPORTED_MODELS`.
1170
- * The gate in `openaiBackend.ts` short-circuits when a model doesn't support
1171
- * reasoning at all, so adding a non-reasoning model here would make the gate
1172
- * a no-op and silently leak `reasoning_effort` to the request.
1173
- */
1174
- const REASONING_EFFORT_INCOMPATIBLE_WITH_TOOLS_MODELS = /* @__PURE__ */ new Set([
1175
- "gpt-5",
1176
- "gpt-5-mini",
1177
- "gpt-5-nano",
1178
- "gpt-5.1",
1179
- "gpt-5.2",
1180
- "gpt-5.4",
1181
- "gpt-5.4-mini",
1182
- "gpt-5.4-nano",
1183
- "gpt-5.6-sol",
1184
- "gpt-5.6-luna",
1185
- "gpt-5.6-terra"
1186
- ]);
1187
- /**
1188
- * GPT-5 reasoning models that silently *narrate* tool calls on
1189
- * `/v1/chat/completions` (return 200 with the call written as text instead of a
1190
- * real `tool_calls` entry, so nothing executes). The adapter
1191
- * routes these to OpenAI's `/v1/responses` API when function tools are present,
1192
- * where reasoning + tools work together and `reasoning_effort` can be kept.
1193
- *
1194
- * Deliberately excludes the GPT-5.4 family: it *hard-errors* (400) on that
1195
- * combination and is already handled by dropping `reasoning_effort` on the chat
1196
- * path (see `REASONING_EFFORT_INCOMPATIBLE_WITH_TOOLS_MODELS`), so it stays on
1197
- * the working chat path to keep this routing's blast radius small. Also excludes
1198
- * `*-chat-latest` (non-reasoning) and O-series (tools work there already).
1199
- *
1200
- * Invariant: every member MUST also be in `REASONING_EFFORT_INCOMPATIBLE_WITH_TOOLS_MODELS`
1201
- * so the chat path still drops `reasoning_effort` as a fallback if routing is bypassed.
1202
- */
1203
- const RESPONSES_API_TOOL_MODELS = /* @__PURE__ */ new Set([
1204
- "gpt-5",
1205
- "gpt-5-mini",
1206
- "gpt-5-nano",
1207
- "gpt-5.1",
1208
- "gpt-5.2",
1209
- "gpt-5.6-sol",
1210
- "gpt-5.6-luna",
1211
- "gpt-5.6-terra"
1212
- ]);
1213
- /**
1214
- * Models that only support temperature=1 (no custom temperature).
1215
- * Includes:
1216
- * - All reasoning models (OpenAI requires temp=1 when reasoning is active)
1217
- * - chat-latest variants that enforce this constraint
1218
- * - GPT-5.5, which rejects custom temperature even though it does not expose
1219
- * reasoning controls
1220
- */
1221
- const FIXED_TEMPERATURE_MODELS = /* @__PURE__ */ new Set([
1222
- ...Array.from(REASONING_SUPPORTED_MODELS),
1223
- "gpt-5.1-chat-latest",
1224
- "gpt-5.2-chat-latest",
1225
- "gpt-5.5"
1226
- ]);
1227
- /**
1228
- * Models that do not accept the temperature parameter at all.
1229
- * The API will reject requests that include temperature for these models.
1230
- */
1231
- const NO_TEMPERATURE_MODELS = /* @__PURE__ */ new Set([
1232
- "claude-opus-4-7",
1233
- "global.anthropic.claude-opus-4-7",
1234
- "claude-opus-4-8",
1235
- "global.anthropic.claude-opus-4-8",
1236
- "claude-sonnet-5",
1237
- "global.anthropic.claude-sonnet-5",
1238
- "claude-fable-5",
1239
- "claude-opus-5",
1240
- "kimi-k3",
1241
- "kimi-k2.7-code",
1242
- "kimi-k2.7-code-highspeed",
1243
- "kimi-k2.6",
1244
- "kimi-k2.5",
1245
- "deepseek-flash",
1246
- "deepseek-v4-pro"
1247
- ]);
1248
- /**
1249
- * Models whose safety classifiers can decline a request with `stop_reason: 'refusal'`
1250
- * (HTTP 200, empty or partial content) - Claude Fable 5's GA classifiers target research
1251
- * biology and most cybersecurity content and occasionally false-positive on benign adjacent
1252
- * work. Per Anthropic's GA guidance a refusal from these is opt-in recoverable: rather than
1253
- * surfacing a hard refusal, the backend throws so the completion loop's existing fallback
1254
- * machinery continues the request on Opus 5 (whose classifiers intervene far less often).
1255
- * A refusal from any *other* model is a genuine decline and surfaces unchanged. Keep in
1256
- * sync with the `claude-fable-5` fallback preference chain in `adminSettings/fallback.ts`.
1257
- */
1258
- const REFUSAL_FALLBACK_MODELS = /* @__PURE__ */ new Set(["claude-fable-5"]);
1259
- /**
1260
- * Bedrock-hosted Claude models that do NOT support prompt caching (`cache_control`).
1261
- * Sending `cache_control` to these models triggers a Bedrock deserialization error:
1262
- * `tools.N.cache_control: Extra inputs are not permitted`
1263
- *
1264
- * AWS Bedrock added prompt caching for Claude 3.5 Haiku and Claude 3.7 Sonnet (and later);
1265
- * the OG Claude 3 Haiku and the v1 Claude 3.5 Sonnet were not retrofitted.
1266
- *
1267
- * Keep this set narrow - default behavior is to apply caching when `cacheStrategy.enableCaching`
1268
- * is true. Add a model here only when we have concrete evidence (a Bedrock validation error)
1269
- * that it rejects `cache_control`.
1270
- */
1271
- const BEDROCK_NO_PROMPT_CACHING_MODELS = /* @__PURE__ */ new Set(["anthropic.claude-3-haiku-20240307-v1:0", "anthropic.claude-3-5-sonnet-20240620-v1:0"]);
964
+ [...Array.from(REASONING_SUPPORTED_MODELS)];
1272
965
  /**
1273
966
  * Speech to Text Models
1274
967
  *
@@ -1314,13 +1007,6 @@ z$1.enum({
1314
1007
  ...SpeechToTextModels,
1315
1008
  ...VideoModels
1316
1009
  });
1317
- /** Returns true if the model is deprecated on or before the provided date (default: now). */
1318
- const isModelDeprecated = (model, now = /* @__PURE__ */ new Date()) => {
1319
- if (!model.deprecationDate) return false;
1320
- const todayYMD = new Date(now.toISOString().slice(0, 10));
1321
- const cutoff = /* @__PURE__ */ new Date(model.deprecationDate + "T00:00:00Z");
1322
- return todayYMD.getTime() >= cutoff.getTime();
1323
- };
1324
1010
  /**
1325
1011
  * Valid status values for sub-quests.
1326
1012
  * Canonical vocabulary - the mongoose schema, zod schemas, and client all
@@ -1516,7 +1202,14 @@ const RealtimeVoiceUsageTransaction = BaseCreditTransaction.extend({
1516
1202
  });
1517
1203
  const ToolUsageTransaction = BaseCreditTransaction.extend({
1518
1204
  type: z$1.literal("tool_usage"),
1519
- model: z$1.string(),
1205
+ /**
1206
+ * The model that actually incurred the tool cost (e.g. 'gpt-image-2'), NOT the chat
1207
+ * model of the quest that ran the tool. The row is one aggregate over every charging
1208
+ * tool call in the quest, so this is set only when exactly one model charged; a quest
1209
+ * that charged on two or more models leaves it unset rather than naming one of them.
1210
+ * Per-call attribution always lives on the `feature: 'tool'` UsageEventModel rows.
1211
+ */
1212
+ model: z$1.string().optional(),
1520
1213
  questId: z$1.string(),
1521
1214
  sessionId: z$1.string()
1522
1215
  });
@@ -1602,8 +1295,8 @@ z$1.object({
1602
1295
  ownerType: z$1.enum(CreditHolderType),
1603
1296
  sessionId: z$1.string().optional(),
1604
1297
  /**
1605
- * Data lake this call is 1:1 attributable to (ingestion embeds only - a query
1606
- * embedding can span multiple lakes and is never attributed here). Unset for
1298
+ * Data lake this call is 1:1 attributable to (ingestion embeds and research-run judge calls -
1299
+ * a query embedding can span multiple lakes and is never attributed here). Unset for
1607
1300
  * every other feature/call.
1608
1301
  */
1609
1302
  dataLakeId: z$1.string().optional(),
@@ -1650,7 +1343,8 @@ z$1.object({
1650
1343
  "ok",
1651
1344
  "error",
1652
1345
  "timeout",
1653
- "refusal"
1346
+ "refusal",
1347
+ "degenerate"
1654
1348
  ]).default("ok"),
1655
1349
  latencyMs: z$1.number().optional(),
1656
1350
  /** Originating surface (web/cli/api/agent/system); unset => unclassified. */
@@ -1757,7 +1451,6 @@ const FIELD_GROUPS = [
1757
1451
  "dispatch",
1758
1452
  "availability"
1759
1453
  ];
1760
- const isFieldGroup = (value) => FIELD_GROUPS.includes(value);
1761
1454
  /** YYYY-MM-DD, the format every date-ish ModelInfo field already uses. */
1762
1455
  const CALENDAR_DATE = z$1.string().regex(/^\d{4}-\d{2}-\d{2}$/, "expected a YYYY-MM-DD calendar date");
1763
1456
  const ReasoningWrite = z$1.strictObject({
@@ -1975,6 +1668,7 @@ const MODEL_INFO_FIELD_GROUP_OF = {
1975
1668
  backend: "identity",
1976
1669
  contextWindow: "limits",
1977
1670
  max_tokens: "limits",
1671
+ maxOutputTokensDerived: "limits",
1978
1672
  can_think: "reasoning",
1979
1673
  thinkingStyle: "reasoning",
1980
1674
  can_stream: "modalities",
@@ -2247,6 +1941,16 @@ const DiscoveryPriceSkip = z$1.object({
2247
1941
  modelId: z$1.string().min(1),
2248
1942
  reason: z$1.string()
2249
1943
  });
1944
+ /**
1945
+ * A configured source the run never attempted, and why. Produced by
1946
+ * `skipReasonFor` in runModelDiscovery (services/modelDiscoveryService); the
1947
+ * reason stays a plain string rather than that module's SourceSkipReason union
1948
+ * so a new reason needs no schema or UI change.
1949
+ */
1950
+ const DiscoverySkippedSource = z$1.object({
1951
+ name: z$1.string().min(1),
1952
+ reason: z$1.string()
1953
+ });
2250
1954
  const DiscoveryLifecycleTransition = z$1.object({
2251
1955
  modelId: z$1.string().min(1),
2252
1956
  /** Absent when no row in force carried a lifecycle for this model. */
@@ -2303,6 +2007,8 @@ z$1.object({
2303
2007
  */
2304
2008
  mode: z$1.enum(DISCOVERY_RUN_MODES).optional(),
2305
2009
  sources: z$1.array(DiscoverySourceReport).optional(),
2010
+ /** Configured sources this run never attempted; disjoint from `sources`. */
2011
+ skippedSources: z$1.array(DiscoverySkippedSource).optional(),
2306
2012
  joinCoverage: z$1.array(DiscoveryJoinCoverage).optional(),
2307
2013
  /** Ids no aggregator matched: a work item, not a log line. */
2308
2014
  unmatchedIds: z$1.array(z$1.string()).optional(),
@@ -2419,6 +2125,59 @@ let McpServerName = /* @__PURE__ */ function(McpServerName) {
2419
2125
  McpServerName["Notion"] = "notion";
2420
2126
  return McpServerName;
2421
2127
  }({});
2128
+ const ROLLUP_DAY_MS = 864e5;
2129
+ /**
2130
+ * Reads a rollup bound as a UTC instant. The schema below accepts an offset-less value (what a
2131
+ * date picker emits), and a bare `new Date` would read that in the host's local zone - so the
2132
+ * same query would cover a different window depending on where it ran.
2133
+ */
2134
+ function parseFeedbackRollupBound(value) {
2135
+ return new Date(/([Zz]|[+-]\d{2}:?\d{2})$/.test(value) ? value : `${value}Z`);
2136
+ }
2137
+ z$1.object({
2138
+ from: z$1.iso.datetime({
2139
+ offset: true,
2140
+ local: true
2141
+ }),
2142
+ to: z$1.iso.datetime({
2143
+ offset: true,
2144
+ local: true
2145
+ })
2146
+ }).refine((query) => parseFeedbackRollupBound(query.from) < parseFeedbackRollupBound(query.to), {
2147
+ message: "from must be strictly before to",
2148
+ path: ["from"]
2149
+ }).refine((query) => parseFeedbackRollupBound(query.to).getTime() - parseFeedbackRollupBound(query.from).getTime() < 366 * ROLLUP_DAY_MS, {
2150
+ message: `window must not exceed 366 days`,
2151
+ path: ["to"]
2152
+ });
2153
+ const feedbackCountBucketSchema = z$1.object({
2154
+ key: z$1.string(),
2155
+ count: z$1.number()
2156
+ });
2157
+ const orgFeedbackSummaryCountsSchema = z$1.object({
2158
+ totals: z$1.object({ count: z$1.number() }),
2159
+ byDay: z$1.array(z$1.object({
2160
+ day: z$1.string(),
2161
+ count: z$1.number()
2162
+ })),
2163
+ bySubject: z$1.array(feedbackCountBucketSchema),
2164
+ byType: z$1.array(feedbackCountBucketSchema),
2165
+ byStatus: z$1.array(feedbackCountBucketSchema),
2166
+ byTag: z$1.array(feedbackCountBucketSchema),
2167
+ byTagTruncated: z$1.boolean().optional()
2168
+ });
2169
+ z$1.object({
2170
+ summaryJobId: z$1.string(),
2171
+ organizationId: z$1.string(),
2172
+ range: z$1.object({
2173
+ from: z$1.string(),
2174
+ to: z$1.string()
2175
+ }),
2176
+ generatedAt: z$1.string(),
2177
+ model: z$1.string(),
2178
+ summary: z$1.string(),
2179
+ counts: orgFeedbackSummaryCountsSchema
2180
+ });
2422
2181
  /**
2423
2182
  * Check if a value is a placeholder (not configured or SST default).
2424
2183
  * Uses case-insensitive comparison and trims whitespace to prevent bypass attempts.
@@ -2467,10 +2226,16 @@ function isPlaceholderApiKey(value) {
2467
2226
  /**
2468
2227
  * Lake lifecycle. Stable states (draft/active/archived/deleted) plus transitional
2469
2228
  * states (archiving/unarchiving/restoring/deleting/purging) that exist to drive UI and make a crashed
2470
- * mid-operation observable. draft -> active is one-way. It happens implicitly once the lake
2471
- * holds its first member file (see `activateIfDraft` below), and unconditionally when an
2472
- * archived or deleted lake is restored, which is how an empty lake can end up active.
2229
+ * mid-operation observable.
2473
2230
  *
2231
+ * draft <-> active is a DELIBERATE, two-way move: `promoteDataLake` publishes a draft (the only
2232
+ * door onto `activateIfDraft` below) and `demoteDataLake` reverses it. Neither fires as a side
2233
+ * effect of adding content - a draft lake that fills up with files stays a draft, excluded from
2234
+ * grounding, until an owner or admin explicitly promotes it. Restoring an archived or
2235
+ * deleted lake still lands unconditionally on `active`, which is how an empty lake can end up
2236
+ * active without ever passing through an explicit promote.
2237
+ *
2238
+
2474
2239
  * `purging` is the one transitional state that is NOT recoverable by retrying the same action:
2475
2240
  * it is claimed the moment a phase-2 hard delete is ACCEPTED (#1744), before the background
2476
2241
  * sweep runs, so that `listDeletedDataLakes` stops offering Restore on a lake whose
@@ -2500,6 +2265,7 @@ const DATA_LAKE_STABLE_STATUSES = [
2500
2265
  "deleted"
2501
2266
  ];
2502
2267
  DATA_LAKE_STATUSES.filter((s) => !DATA_LAKE_STABLE_STATUSES.includes(s));
2268
+ const DATA_LAKE_ORIGINS = ["curated", "connector-fed"];
2503
2269
  z$1.object({
2504
2270
  /**
2505
2271
  * The granting lake's Mongo `_id`. ALWAYS a persisted DB lake: a hardcoded/fallback lake has no
@@ -2547,6 +2313,14 @@ z$1.object({
2547
2313
  removedAt: z$1.date(),
2548
2314
  expiresAt: z$1.date()
2549
2315
  });
2316
+ /** Mirrors the read model's vocabulary deliberately (aliased, not re-declared, so the two can
2317
+ * never drift) - an audit reader learns one principal shape for both halves of the trail. */
2318
+ const LAKE_CONFIG_CHANGE_PRINCIPAL_KINDS = [
2319
+ "user",
2320
+ "agent",
2321
+ "apiKey",
2322
+ "system"
2323
+ ];
2550
2324
  /**
2551
2325
  * Every `IDataLake` field, classified as audited or not. A TOTAL map keyed by `keyof IDataLake`,
2552
2326
  * exactly like `LAKE_FIELD_VISIBILITY` in redactLakeForActor.ts and for the same reason: a list of
@@ -2575,6 +2349,7 @@ const LAKE_CONFIG_FIELD_AUDIT = {
2575
2349
  auditQueryTextEnabled: "audited",
2576
2350
  lakeMemoryEnabled: "audited",
2577
2351
  status: "audited",
2352
+ origin: "audited",
2578
2353
  createdByUserId: "audited",
2579
2354
  lastUpdatedByUserId: "excluded",
2580
2355
  fileCount: "excluded",
@@ -2582,15 +2357,59 @@ const LAKE_CONFIG_FIELD_AUDIT = {
2582
2357
  totalChunkedChars: "excluded",
2583
2358
  embeddingSpendMicroUsd: "excluded",
2584
2359
  lastSyncAt: "excluded",
2360
+ lastHealthCheckedAt: "excluded",
2361
+ lastInconsistencyScanAt: "excluded",
2585
2362
  filesDeletedAt: "excluded",
2586
2363
  filesArchivedAt: "excluded",
2587
2364
  lakeMemoryExtractionAt: "excluded",
2588
2365
  lakeMemoryCursor: "excluded",
2589
2366
  lakeMemoryPurgedAt: "excluded",
2590
2367
  inconsistencyReport: "excluded",
2591
- inconsistencyComputedAt: "excluded"
2368
+ inconsistencyComputedAt: "excluded",
2369
+ modelInconsistencyRunAt: "excluded"
2592
2370
  };
2593
2371
  [...Object.keys(LAKE_CONFIG_FIELD_AUDIT).filter((field) => LAKE_CONFIG_FIELD_AUDIT[field] === "audited")];
2372
+ z$1.object({
2373
+ dataLakeId: z$1.string(),
2374
+ /** Denormalized from the lake at offer time: a recipient route can filter by it without a join. */
2375
+ organizationId: z$1.string().nullish(),
2376
+ /** The actor who made the offer - always the eventual `grantedByUserId` of an accepted transfer. */
2377
+ offeredByUserId: z$1.string(),
2378
+ recipientUserId: z$1.string(),
2379
+ status: z$1.enum([
2380
+ "pending",
2381
+ "accepted",
2382
+ "declined",
2383
+ "cancelled",
2384
+ "expired"
2385
+ ]),
2386
+ expiresAt: z$1.date(),
2387
+ /** Set by the atomic `resolve` when the offer leaves `pending`; null while it is still open. */
2388
+ resolvedAt: z$1.date().nullish(),
2389
+ /**
2390
+ * The lake's effective owner ids AT OFFER TIME. Accept re-resolves them and refuses if they moved,
2391
+ * so an offer can never apply a transfer over another transfer (or a departure succession) that
2392
+ * happened in between. Snapshotted rather than recomputed because "stale" is exactly the case
2393
+ * where today's answer differs from the one the offer was made under.
2394
+ */
2395
+ priorOwnerUserIds: z$1.array(z$1.string()),
2396
+ offeredVia: z$1.enum([
2397
+ "grant-owner",
2398
+ "creator",
2399
+ "platform-admin",
2400
+ "org-admin"
2401
+ ]),
2402
+ /**
2403
+ * The principal to attribute the applied transfer to, resolved by the route at OFFER time (only a
2404
+ * route can tell an API key from a session). Carried so the eventual audit row keeps naming the
2405
+ * same principal the offer was made under, rather than whichever principal happens to accept.
2406
+ */
2407
+ auditPrincipal: z$1.object({
2408
+ principalKind: z$1.enum(LAKE_CONFIG_CHANGE_PRINCIPAL_KINDS),
2409
+ principalId: z$1.string(),
2410
+ onBehalfOfUserId: z$1.string().optional()
2411
+ }).optional()
2412
+ });
2594
2413
  /**
2595
2414
  * SRE Agent Trio - Shared Types
2596
2415
  *
@@ -2942,32 +2761,7 @@ let SupportedFabFileMimeTypes = /* @__PURE__ */ function(SupportedFabFileMimeTyp
2942
2761
  SupportedFabFileMimeTypes["CONF"] = "text/plain";
2943
2762
  return SupportedFabFileMimeTypes;
2944
2763
  }({});
2945
- /**
2946
- * The canonical set of MIME types the ingest pipeline can actually chunk +
2947
- * vectorize. Kept in lockstep with the `SmartChunker` switch in
2948
- * `@bike4mind/fab-pipeline`.
2949
- */
2950
- const SUPPORTED_FAB_FILE_MIME_TYPES = new Set(Object.values(SupportedFabFileMimeTypes));
2951
- /**
2952
- * Type guard: is a claimed MIME type one we actually support ingesting?
2953
- *
2954
- * Used to gate uploads so unsupported/binary files (e.g. `.exe`) are rejected
2955
- * with a clear error instead of being stored and silently "vectorized" into 0
2956
- * chunks. Node-free so it can run on both the client (upload UI) and server
2957
- * (ingest endpoints).
2958
- */
2959
- function isSupportedFabFileMimeType(mimeType) {
2960
- return !!mimeType && SUPPORTED_FAB_FILE_MIME_TYPES.has(mimeType);
2961
- }
2962
- /**
2963
- * Is this an audio MIME type? Matches any `audio/*` (not just the known set)
2964
- * so the LLM-exclusion and vectorization-skip guards fail safe for any audio
2965
- * subtype a provider might emit.
2966
- */
2967
- function isAudioMimeType(mimeType) {
2968
- if (!mimeType) return false;
2969
- return mimeType.split(";")[0].trim().toLowerCase().startsWith("audio/");
2970
- }
2764
+ new Set(Object.values(SupportedFabFileMimeTypes));
2971
2765
  /** Reads back the classifier set by the tagged-error helpers above; `undefined` for untagged errors. */
2972
2766
  function getQuestErrorCode(error) {
2973
2767
  const code = error?.additionalInfo?.errorCode;
@@ -3046,231 +2840,23 @@ const AGENT_QUEST_MANIFEST = {
3046
2840
  }
3047
2841
  };
3048
2842
  /**
3049
- * The catalog row in force for a model and unit at a given time: newest
3050
- * effectiveFrom <= at AMONG ROWS OF THAT UNIT. Units are independent price
3051
- * streams - a newer per_minute row must never shadow the in-force per_token
3052
- * row (that would silently revert token billing to the adapter literal).
3053
- * Rows are append-only, so this is the whole time-travel story (see
3054
- * ModelPriceTypes).
3055
- */
3056
- function resolveModelPriceRow(rows, modelId, unit, at) {
3057
- let inForce;
3058
- for (const row of rows) {
3059
- if (row.modelId !== modelId || row.unit !== unit) continue;
3060
- if (row.effectiveFrom.getTime() > at.getTime()) continue;
3061
- if (!inForce || row.effectiveFrom.getTime() > inForce.effectiveFrom.getTime()) inForce = row;
3062
- }
3063
- return inForce;
3064
- }
3065
- /**
3066
- * Overlay catalog prices onto assembled ModelInfo. Only per_token rows apply
3067
- * here (they feed getTextModelCost via ModelInfo.pricing); per_minute and
3068
- * per_image rows are consumed by their own settlement paths. A model with no
3069
- * per_token row in force keeps its adapter literal - the fallback that keeps
3070
- * zero-config self-host deployments working.
3071
- */
3072
- function applyModelPriceCatalog(models, rows, at = /* @__PURE__ */ new Date()) {
3073
- if (rows.length === 0) return models;
3074
- return models.map((model) => {
3075
- if (model.type !== "text") return model;
3076
- const row = resolveModelPriceRow(rows, model.id, "per_token", at);
3077
- if (!row) return model;
3078
- const pricing = {};
3079
- for (const [threshold, tier] of Object.entries(row.pricing)) pricing[Number(threshold)] = tier;
3080
- return {
3081
- ...model,
3082
- pricing
3083
- };
3084
- });
3085
- }
3086
- /**
3087
2843
  * Input headroom the chat path holds back on top of a request's reserved output.
3088
2844
  * safeInputWindow (ChatCompletionProcess) subtracts it, so a text entry whose output
3089
2845
  * reserve leaves less than this has no room for a prompt at all.
3090
2846
  */
3091
2847
  const CONTEXT_WINDOW_SAFETY_BUFFER_TOKENS = 1e3;
3092
2848
  /**
3093
- * Fallback context window for a media row whose catalog value is not usable (a discovery feed's
3094
- * literal 0, meaning "not applicable" rather than a real budget - see isMediaModelType). Shared by
3095
- * effectiveContextWindow (@bike4mind/utils, server) and useTokenLimits (apps/client, browser) so
3096
- * the two do not drift onto different placeholder numbers for the same "unknown" case.
3097
- */
3098
- const DEFAULT_UNKNOWN_CONTEXT_WINDOW = 2e5;
3099
- /**
3100
- * The model types this build's ModelInfo consumers narrow on. ModelRecord.type is
3101
- * wider (embedding / tts / realtime-voice), so the read path drops and counts any
3102
- * record outside this set: an old build must degrade to "I do not see the new video
3103
- * models", never to a runtime narrowing failure.
2849
+ * The modalities promptMeta.model.type is allowed to record. Deliberately NARROWER than
2850
+ * MODEL_INFO_TYPES: 'speech-to-text' is served by its own transcription route, never by a
2851
+ * completion, so recording it would put a value in the field that no reader narrows on.
2852
+ * PromptMetaModelSchema.type (schemas/promptMeta) builds its z.enum from this const, and
2853
+ * QuestModelType (apps/client admin reporting) is an alias of the type - one source, three uses.
3104
2854
  */
3105
- const MODEL_INFO_TYPES = [
2855
+ const PROMPT_META_MODEL_TYPES = [
3106
2856
  "text",
3107
2857
  "image",
3108
- "speech-to-text",
3109
2858
  "video"
3110
2859
  ];
3111
- const isRenderableModelType = (type) => MODEL_INFO_TYPES.includes(type);
3112
- /**
3113
- * Whether a ModelInfo type returns media (image/video) rather than tokens. Shared by
3114
- * safeInputWindow/effectiveContextWindow (@bike4mind/utils, server) and useTokenLimits
3115
- * (apps/client, browser) so "what counts as media" cannot drift between the two halves of the
3116
- * same guard - both need it, and common is the one package already safe to import from either.
3117
- */
3118
- const isMediaModelType = (type) => type === "image" || type === "video";
3119
- /**
3120
- * The record -> ModelInfo adapter: one place where every ModelInfo field a
3121
- * catalog record does not carry gets its default. Each default degrades to the
3122
- * visible, recoverable behavior rather than the silent or expensive one.
3123
- *
3124
- * Pricing is never sourced here: applyModelPriceCatalog overlays the ModelPrice
3125
- * rows afterwards, and an empty map trips the [UNPRICED_MODEL] alarm on first
3126
- * billed use, which is the intended fail-loud path.
3127
- */
3128
- function toModelInfo(record) {
3129
- const retired = record.lifecycle?.status === "retired";
3130
- const disabled = record.disabled === true || record.autoDisabled === true || retired;
3131
- return {
3132
- id: record.id,
3133
- type: record.type,
3134
- name: record.name,
3135
- backend: record.backend,
3136
- contextWindow: record.contextWindow,
3137
- max_tokens: record.maxOutputTokens ?? Math.min(record.contextWindow, 4096),
3138
- pricing: {},
3139
- can_stream: record.canStream,
3140
- can_think: record.reasoning?.supported ?? false,
3141
- thinkingStyle: toThinkingStyle(record),
3142
- adapterFamily: record.adapterFamily,
3143
- dispatchProfile: record.dispatchProfile,
3144
- supportsVision: record.supportsVision,
3145
- supportsTools: record.supportsTools,
3146
- supportsImageVariation: record.supportsImageVariation ?? false,
3147
- supportsSafetyTolerance: record.supportsSafetyTolerance,
3148
- freeToRun: record.freeToRun,
3149
- private: record.private ?? false,
3150
- disabled,
3151
- disabledReason: record.disabledReason ?? record.autoDisabledReason ?? (retired ? "retired by the provider" : void 0),
3152
- deprecationDate: record.lifecycle?.deprecationDate,
3153
- replacedBy: record.lifecycle?.replacedBy,
3154
- trainingCutoff: record.trainingCutoff,
3155
- releaseDate: record.releaseDate,
3156
- logoFile: record.logoFile,
3157
- rank: record.rank,
3158
- description: record.description,
3159
- isSlowModel: record.isSlowModel
3160
- };
3161
- }
3162
- /**
3163
- * ModelInfo.thinkingStyle only describes the two Anthropic request shapes. Other
3164
- * reasoning styles map to undefined rather than to a wrong shape; the backends
3165
- * treat unset as their own default.
3166
- */
3167
- function toThinkingStyle(record) {
3168
- switch (record.reasoning?.style) {
3169
- case "anthropic-adaptive": return "adaptive";
3170
- case "anthropic-legacy": return "legacy";
3171
- default: return;
3172
- }
3173
- }
3174
- function fromThinkingStyle(style) {
3175
- if (style === "adaptive") return "anthropic-adaptive";
3176
- if (style === "legacy") return "anthropic-legacy";
3177
- }
3178
- /** Who makes the model, when the id namespace says so: [region.]<vendor>.<model>. */
3179
- const BEDROCK_REGION_PREFIX = /^(us|eu|apac|global)\./;
3180
- const VENDOR_BY_BACKEND = {
3181
- ["openai"]: "openai",
3182
- ["anthropic"]: "anthropic",
3183
- ["gemini"]: "google",
3184
- ["xai"]: "xai",
3185
- ["kimi"]: "moonshotai",
3186
- ["deepseek"]: "deepseek",
3187
- ["bfl"]: "black-forest-labs",
3188
- ["aws"]: "amazon",
3189
- ["voyageai"]: "voyageai",
3190
- ["ollama"]: "ollama",
3191
- ["local-image"]: "local",
3192
- ["bedrock"]: "amazon"
3193
- };
3194
- /**
3195
- * ModelInfo carries no vendor (that is one of the four disagreeing taxonomies
3196
- * this catalog replaces), so the inverse adapter derives it: the backend answers
3197
- * it for direct providers, and for Bedrock the id namespace does.
3198
- */
3199
- /**
3200
- * Bedrock id prefixes that name the same maker as a different string. AWS spells
3201
- * Kimi K2.5 `moonshotai.` and K2 Thinking `moonshot.`, so the raw prefix would
3202
- * file one vendor's two models under two vendors and split them in the admin
3203
- * dashboard. Canonicalized to the spelling the direct backend and the models.dev
3204
- * provider both use.
3205
- */
3206
- const BEDROCK_VENDOR_ALIASES = { moonshot: "moonshotai" };
3207
- function inferVendor(info) {
3208
- if (info.backend === "bedrock") {
3209
- const withoutRegion = String(info.id).replace(BEDROCK_REGION_PREFIX, "");
3210
- const dot = withoutRegion.indexOf(".");
3211
- if (dot > 0) {
3212
- const prefix = withoutRegion.slice(0, dot);
3213
- return BEDROCK_VENDOR_ALIASES[prefix] ?? prefix;
3214
- }
3215
- }
3216
- return VENDOR_BY_BACKEND[info.backend] ?? String(info.backend);
3217
- }
3218
- /**
3219
- * ModelInfo -> ModelRecord, the inverse of toModelInfo. Two callers: the
3220
- * fallback seed generator (adapter literals become seed rows) and the merge's
3221
- * base tier (a seeded model becomes a record that a catalog row can then claim
3222
- * groups of).
3223
- *
3224
- * `pricing` has no ModelInfo spelling here and is deliberately absent rather
3225
- * than guessed: catalog rows never carry it. The dispatch group round-trips
3226
- * (ModelInfo carries it since dispatch consumes it), but no feed may author it -
3227
- * a wrong value there mis-routes a request, so it stays seed- or
3228
- * operator-sourced, or comes from the seed-side DispatchResolver.
3229
- *
3230
- * Round-tripping normalizes the optional booleans toModelInfo defaults
3231
- * (can_think, private, disabled, supportsImageVariation): undefined becomes an
3232
- * explicit false. That is only observable for a model whose merged record a
3233
- * catalog row actually owns a group of.
3234
- */
3235
- function toModelRecord(info) {
3236
- return {
3237
- id: info.id,
3238
- vendor: inferVendor(info),
3239
- backend: info.backend,
3240
- type: info.type,
3241
- name: info.name,
3242
- contextWindow: info.contextWindow,
3243
- maxOutputTokens: info.max_tokens,
3244
- canStream: info.can_stream,
3245
- reasoning: info.can_think === void 0 && info.thinkingStyle === void 0 ? void 0 : {
3246
- supported: info.can_think === true,
3247
- style: fromThinkingStyle(info.thinkingStyle)
3248
- },
3249
- adapterFamily: info.adapterFamily,
3250
- dispatchProfile: info.dispatchProfile,
3251
- supportsVision: info.supportsVision,
3252
- supportsTools: info.supportsTools,
3253
- supportsImageVariation: info.supportsImageVariation,
3254
- supportsSafetyTolerance: info.supportsSafetyTolerance,
3255
- lifecycle: {
3256
- ...info.deprecationDate ? {
3257
- status: "deprecated",
3258
- deprecationDate: info.deprecationDate
3259
- } : { status: "active" },
3260
- ...info.replacedBy ? { replacedBy: info.replacedBy } : {}
3261
- },
3262
- description: info.description,
3263
- logoFile: info.logoFile,
3264
- rank: info.rank,
3265
- isSlowModel: info.isSlowModel,
3266
- trainingCutoff: info.trainingCutoff,
3267
- releaseDate: info.releaseDate,
3268
- private: info.private,
3269
- freeToRun: info.freeToRun,
3270
- disabled: info.disabled,
3271
- disabledReason: info.disabledReason
3272
- };
3273
- }
3274
2860
  const DEFAULT_PRICE_MARGIN = 1.2;
3275
2861
  const DEFAULT_USD_TO_CREDITS_RATE = 6e-4;
3276
2862
  /**
@@ -3318,140 +2904,6 @@ const CREDITS_PER_USD_COST = (() => {
3318
2904
  console.warn(`[pricing] Env-configured margin/rate derive ${derived} credits per USD cost; using defaults`);
3319
2905
  return Math.round(DEFAULT_PRICE_MARGIN / DEFAULT_USD_TO_CREDITS_RATE);
3320
2906
  })();
3321
- /**
3322
- * Converts a USD cost to credits, including markup, rounding UP (minimum 1).
3323
- *
3324
- * Use for reservations, eligibility checks, estimates, and display - anywhere
3325
- * a deterministic, conservative number is needed. Final settlement of variable
3326
- * usage should use usdToCreditsStochastic so users pay the exact fraction in
3327
- * expectation instead of the round-up.
3328
- *
3329
- * Examples (defaults):
3330
- * $1 USD = 2000 credits (1.2x markup at $0.0006/credit)
3331
- * $0.001 USD = 2 credits
3332
- * $0.0001 USD = 1 credit (minimum)
3333
- *
3334
- * @param rate - Credits per $1, defaults to the platform-wide CREDITS_PER_USD_COST.
3335
- * Pass an override for a provider-specific admin-tunable rate (e.g. a
3336
- * billing-sensitive external compute path priced independently of the
3337
- * platform default).
3338
- */
3339
- const usdToCredits = (usd, rate = CREDITS_PER_USD_COST) => {
3340
- return Math.max(1, Math.ceil(usd * rate));
3341
- };
3342
- /**
3343
- * Uniform draw in [0, 1) from the platform CSPRNG. Billing draws MUST be
3344
- * unpredictable: a caller who can foresee the stream could time requests to
3345
- * land on the no-charge side of every draw. Node >= 19 and all browsers
3346
- * expose globalThis.crypto; the Math.random fallback exists only so tests
3347
- * and exotic runtimes do not crash, and it warns once.
3348
- */
3349
- let warnedNonCryptoRng = false;
3350
- const cryptoUniform = () => {
3351
- const cryptoObj = globalThis.crypto;
3352
- if (cryptoObj?.getRandomValues) {
3353
- const buf = /* @__PURE__ */ new Uint32Array(1);
3354
- cryptoObj.getRandomValues(buf);
3355
- return buf[0] / 4294967296;
3356
- }
3357
- if (!warnedNonCryptoRng) {
3358
- warnedNonCryptoRng = true;
3359
- console.warn("[pricing] globalThis.crypto unavailable; billing rounding falls back to Math.random");
3360
- }
3361
- return Math.random();
3362
- };
3363
- /**
3364
- * Converts a USD cost to credits with markup using UNBIASED stochastic
3365
- * rounding: the integer part is always charged, and the fractional part
3366
- * charges one extra credit with probability equal to the fraction.
3367
- * E[charge] equals the exact fractional cost at every call size - no
3368
- * round-up overcharge, no 1-credit minimum, and no free-below-threshold
3369
- * leak. Because draws are independent and priced at exact cost, no calling
3370
- * pattern (splitting, retrying, aborting) changes expected cost.
3371
- *
3372
- * Server-side settlement only. Do NOT use for display, estimates, or
3373
- * reservations (it is non-deterministic; use usdToCredits). Zero or
3374
- * non-finite cost charges 0.
3375
- *
3376
- * @param usd - The raw provider cost in USD to convert
3377
- * @param rng - Uniform [0,1) source, injectable for tests; defaults to CSPRNG
3378
- * @param rate - Credits per $1, defaults to the platform-wide CREDITS_PER_USD_COST.
3379
- * Callers settling a provider-specific admin-tunable rate must pass the SAME
3380
- * rate value that was used at reservation time (snapshot it, don't re-read
3381
- * a live admin setting), or the settlement math mixes scales.
3382
- */
3383
- const usdToCreditsStochastic = (usd, rng = cryptoUniform, rate = CREDITS_PER_USD_COST) => {
3384
- const raw = usd * rate;
3385
- if (!Number.isFinite(raw) || raw <= 0) return 0;
3386
- const base = Math.floor(raw);
3387
- const fraction = raw - base;
3388
- return base + (rng() < fraction ? 1 : 0);
3389
- };
3390
- /**
3391
- * Output tokens the pre-flight credit hold prices when a request's max_tokens
3392
- * ceiling exceeds it.
3393
- *
3394
- * max_tokens is a *ceiling*, not a prediction: adaptive reasoning models stop at
3395
- * end_turn well short of it, so pricing the hold at the full window (128K on the
3396
- * flagship models) reserved several thousand credits per turn regardless of answer
3397
- * length. The excess was always refunded at settlement, but the hold IS the
3398
- * insufficient-funds gate, so users whose balance sat between their real cost and
3399
- * the worst case were falsely blocked.
3400
- *
3401
- * 16K covers the realistic long answer with headroom - the largest replies seen in
3402
- * practice are HTML-artifact turns at roughly 10-11K output tokens (see
3403
- * buildThinkingParams in llm-adapters/thinkingParams.ts, whose max_tokens floor was
3404
- * sized off the same measurement).
3405
- *
3406
- * UNDER-RESERVATION IS ACCEPTED, NOT PREVENTED. A turn that emits more than this
3407
- * settles as a shortfall debit at reconciliation, which is already a supported path
3408
- * (provider-basis settlement could always exceed a hold priced on the local
3409
- * estimate). The two sites clamp the shortfall differently: chat's
3410
- * computeSettlementDelta (services/llm/ChatCompletionProcess.ts) floors the debit at
3411
- * the balance snapshot taken at its OWN admission and reports the remainder as
3412
- * writtenOffCredits; cliCompletions.ts does an unclamped $inc on success and only
3413
- * logs an ALERT if it lands negative. Neither is a non-negativity guarantee: the
3414
- * chat snapshot predates any sibling turn's spend (see that function's own "best
3415
- * effort" note), and when the shortfall still fits the stale snapshot the debit
3416
- * applies in full - writtenOffCredits 0, and no BILLING_SHORTFALL_CLAMP log - so a
3417
- * concurrent holder can land negative there too. Either way the resulting balance
3418
- * fails the *next* turn's own admission gate - but that bound is per turn, not per
3419
- * holder: turns admitted concurrently are each checked against the balance at their
3420
- * own admission and settle against a snapshot that predates their siblings' spend,
3421
- * so a holder running turns in parallel can be shorted once per in-flight turn, not
3422
- * once total.
3423
- */
3424
- const PREFLIGHT_RESERVATION_OUTPUT_TOKENS = 16384;
3425
- /**
3426
- * Reservation ceiling for models that spend reasoning tokens inside their output
3427
- * budget (see reasonsWithinOutputBudget in llm-adapters/thinkingParams.ts). Those
3428
- * tokens bill as output on top of the visible answer, so the 16K figure above -
3429
- * which was measured on visible artifact size alone - under-reserves them badly.
3430
- *
3431
- * Deliberately below ADAPTIVE_THINKING_MAX_TOKENS_FLOOR (64K), which sizes the
3432
- * request's real ceiling and therefore has to cover the worst case: exceeding it
3433
- * truncates a reply mid-tag, an unrecoverable failure. A hold has no such duty -
3434
- * exceeding it settles as a shortfall debit - so it is sized for the long turn
3435
- * (roughly 3x the largest observed visible answer, leaving the rest for the trace)
3436
- * rather than the worst one, which is what keeps the gate off affordable requests.
3437
- */
3438
- const PREFLIGHT_RESERVATION_REASONING_OUTPUT_TOKENS = 32768;
3439
- /**
3440
- * Output-token figure to price a pre-flight credit hold at, given the max_tokens
3441
- * this request will actually send. Never raises the caller's ceiling: a request
3442
- * that asks for less than the cap holds only what it can possibly spend.
3443
- *
3444
- * Reserving only, never gating: the per-member org credit cap is still priced on
3445
- * the unshrunk ceiling at both call sites, since that check has no settlement
3446
- * counterpart to correct an under-estimate. That is a strictly larger figure than
3447
- * the hold, not a true upper bound on the turn - it prices one model round trip,
3448
- * at the uncached input rate, on the primary model - so it still under-counts a
3449
- * multi-round tool loop, a cache-write turn, or a fallback hop onto pricier pricing.
3450
- *
3451
- * @param reasonsWithinOutputBudget - reasonsWithinOutputBudget(modelInfo); passed as
3452
- * a boolean because common cannot import llm-adapters.
3453
- */
3454
- const reservationOutputTokens = (requestedMaxTokens, reasonsWithinOutputBudget = false) => Math.min(requestedMaxTokens, reasonsWithinOutputBudget ? PREFLIGHT_RESERVATION_REASONING_OUTPUT_TOKENS : PREFLIGHT_RESERVATION_OUTPUT_TOKENS);
3455
2907
  z.enum([
3456
2908
  "openai",
3457
2909
  "test",
@@ -3511,7 +2963,22 @@ const PromptBatchQuerySchema = z$1.object({
3511
2963
  personal: z$1.boolean().optional()
3512
2964
  });
3513
2965
  z$1.object({ queries: z$1.array(PromptBatchQuerySchema).min(1).max(32).refine((qs) => new Set(qs.map((q) => q.key)).size === qs.length, { message: "Batch query keys must be unique" }) });
3514
- z$1.object({
2966
+ /**
2967
+ * Request schema for POST /api/chat - the simplified external chat surface.
2968
+ *
2969
+ * Shared between the Next.js API handler (apps/client/pages/api/chat.ts, which
2970
+ * validates req.body with this exact object) and the OpenAPI registry
2971
+ * (b4m-core/common/src/openapi), so the published contract cannot drift from
2972
+ * what the handler actually accepts. The model is optional here and resolved
2973
+ * server-side from admin settings when omitted.
2974
+ *
2975
+ * Public-API rule: no `.catch()` / top-level `.transform()`. Both silently mutate
2976
+ * caller input (fail-quiet) and are opaque to zod-to-openapi. `historyCount` uses
2977
+ * `.default()` (fail loud on a bad value); unknown tool ids are filtered in the
2978
+ * handler (see filterKnownTools) instead of by a schema transform. This keeps the
2979
+ * schema fully OpenAPI-representable with no doc projection needed.
2980
+ */
2981
+ const SimplifiedChatRequestSchema = z$1.object({
3515
2982
  sessionId: z$1.string().nullish(),
3516
2983
  message: z$1.string(),
3517
2984
  organizationId: z$1.string().optional(),
@@ -3535,18 +3002,28 @@ z$1.object({
3535
3002
  "grounded",
3536
3003
  "surface"
3537
3004
  ]).optional(),
3538
- skip_auto_offers: z$1.boolean().optional().describe("Suppress tools the server would otherwise attach on its own for this session (the knowledge-base search offer, in-app view navigation, blog drafting/editing/publishing, and skill invocation). Tools you request explicitly are unaffected. One system-prompt block goes with them: withholding in-app view navigation also drops the view-registry block that exists only to describe it. No other prompt content changes. This does not switch off retrieval: a session with forced knowledge retrieval still retrieves, and documents already attached to the session are still placed in the prompt directly. Any promptMode suppresses these too, so false has no effect alongside one."),
3005
+ skip_auto_offers: z$1.boolean().optional().describe("Suppress tools the server would otherwise attach on its own for this session (the knowledge-base search offer, in-app view navigation, blog drafting/editing/publishing, skill invocation, and every tool from a connected MCP server). Native tools you request explicitly in `tools` are unaffected, but an MCP server tool cannot be named through this field, so with this flag set a caller with an MCP server connected sees none of its tools regardless of `tools`. One system-prompt block goes with them: withholding in-app view navigation also drops the view-registry block that exists only to describe it. No other prompt content changes. This does not switch off retrieval: a session with forced knowledge retrieval still retrieves, and documents already attached to the session are still placed in the prompt directly. Any promptMode suppresses these too, so false has no effect alongside one."),
3539
3006
  includePromptDetails: z$1.boolean().optional(),
3540
3007
  includeSystemPrompt: z$1.boolean().optional(),
3541
3008
  systemPrompt: z$1.string().max(PROMPT_TEXT_MAX).optional().describe("System-prompt text for this request only, never persisted. Rendered as a defended block appended after every other system-prompt source, with prose instructing the model to defer to organization, session and data-lake guidance. Over the cap is a 422, never truncated.")
3542
3009
  });
3543
- z$1.object({
3010
+ /**
3011
+ * Async ACK returned on the default (wait:false) path of POST /api/chat. The
3012
+ * `type`/`errorCode` pair below is the same classifier the `wait: true` body and
3013
+ * the polled quest (`GET /api/quests/{id}`) carry, so it is modelled once here -
3014
+ * the rest of those two bodies is NOT described by this schema. The
3015
+ * handler assembles the ack body inline (apps/client/pages/api/chat.ts), so
3016
+ * this schema MUST stay in sync with that `res.json({...})` shape.
3017
+ */
3018
+ const ChatAckSchema = z$1.object({
3544
3019
  id: z$1.string(),
3545
3020
  status: z$1.string(),
3546
3021
  message_received: z$1.boolean(),
3547
3022
  timestamp: z$1.string(),
3548
3023
  model: z$1.string(),
3549
3024
  message: z$1.string().optional(),
3025
+ type: z$1.enum(CHAT_HISTORY_ITEM_TYPES).optional(),
3026
+ errorCode: z$1.enum(QUEST_ERROR_CODES).optional(),
3550
3027
  tools: z$1.object({
3551
3028
  toolMode: z$1.enum(["fast", "smart"]).optional(),
3552
3029
  autoSelectedTools: z$1.array(z$1.string()).optional(),
@@ -3560,6 +3037,44 @@ z$1.object({
3560
3037
  })
3561
3038
  });
3562
3039
  /**
3040
+ * The quest a `wait: false` caller polls at `GET /api/quests/{id}` - the outcome
3041
+ * of the turn the ACK above only acknowledged.
3042
+ *
3043
+ * Deliberately the OUTCOME SUBSET, not the whole quest: that endpoint is a plain
3044
+ * handler rather than a contract, so this models only what decides whether the
3045
+ * turn succeeded, and a poll body carries further fields (`images`, `files`,
3046
+ * `toolPayloads`, `promptMeta`, ...). Must stay in sync with that handler's
3047
+ * `res.json` shape (apps/client/pages/api/quests/[id]/index.ts) - unlike a
3048
+ * contract-registered request/response schema, nothing validates this at
3049
+ * runtime. The "parses against the published ChatQuestPollResultSchema"
3050
+ * integration test (index.integration.test.ts) only proves the handler's
3051
+ * CURRENT response satisfies this schema - a non-strict `z.object` strips
3052
+ * unknown keys rather than rejecting them, and only `id` is required, so a
3053
+ * field the handler starts returning without a matching addition here keeps
3054
+ * that test green. The real per-field coverage lives in the sibling
3055
+ * assertions in that same test file; a shape addition still needs a schema
3056
+ * update by hand.
3057
+ *
3058
+ * A failed turn is still `status: 'done'` with the failure text in `reply`, so
3059
+ * `reply` alone cannot tell an answer from a failure - `type` and `errorCode` are
3060
+ * what separate a CLASSIFIED failure. A run recovered from a timeout with partial
3061
+ * content is not one of those: `terminalRecoveryFor` (questTimeoutRecovery.ts)
3062
+ * flips only `status` to preserve the surviving content, so it polls back as
3063
+ * `type: 'message'` even though it never finished.
3064
+ */
3065
+ const ChatQuestPollResultSchema = z$1.object({
3066
+ id: z$1.string(),
3067
+ status: z$1.enum([
3068
+ "stopped",
3069
+ "running",
3070
+ "done"
3071
+ ]).optional(),
3072
+ type: z$1.enum(CHAT_HISTORY_ITEM_TYPES).optional(),
3073
+ errorCode: z$1.enum(QUEST_ERROR_CODES).optional(),
3074
+ reply: z$1.string().nullable().optional(),
3075
+ replies: z$1.array(z$1.string()).optional()
3076
+ });
3077
+ /**
3563
3078
  * Reusable JSON error envelope (plain; the OpenAPI layer annotates it).
3564
3079
  *
3565
3080
  * Must stay in sync with the published `ErrorResponse` component
@@ -3578,7 +3093,18 @@ const ApiErrorSchema = z$1.object({
3578
3093
  */
3579
3094
  name: z$1.string().optional()
3580
3095
  });
3581
- ApiErrorSchema.extend({ errorCode: z$1.literal("insufficient_credits").optional() });
3096
+ /**
3097
+ * Error envelope for the 422 a credit-metered endpoint returns for two unrelated
3098
+ * reasons: "your body is invalid" and "you cannot afford this". `errorCode` is
3099
+ * what separates them - `insufficientCreditsError` (see insufficientCredits.ts)
3100
+ * tags the credit case, so its absence means an ordinary validation failure.
3101
+ *
3102
+ * Derived from `ApiErrorSchema` rather than re-declaring `error`/`request_id`:
3103
+ * both of those 422s are *thrown*, so errorHandler serves the body and adds
3104
+ * `name`. Extending is what keeps that documented here (and what drops it again
3105
+ * on the sunset date) instead of leaving a bespoke copy behind to drift.
3106
+ */
3107
+ const InsufficientCreditsErrorSchema = ApiErrorSchema.extend({ errorCode: z$1.literal("insufficient_credits").optional() });
3582
3108
  const supportedVoiceGenerationVendor = z.enum(["openai", "elevenlabs"]);
3583
3109
  const voiceOutputFormatSchema = z.enum([
3584
3110
  "mp3",
@@ -3589,13 +3115,12 @@ const voiceOutputFormatSchema = z.enum([
3589
3115
  "pcm"
3590
3116
  ]);
3591
3117
  const voiceResponseEncodingSchema = z.enum(["binary", "base64"]);
3592
- const TTS_MAX_INPUT_CHARS = {
3118
+ const TTS_ABSOLUTE_MAX_INPUT_CHARS = Math.max(...Object.values({
3593
3119
  openai: 4096,
3594
3120
  elevenlabs: 1e4
3595
- };
3596
- const TTS_ABSOLUTE_MAX_INPUT_CHARS = Math.max(...Object.values(TTS_MAX_INPUT_CHARS));
3121
+ }));
3597
3122
  const ttsLanguageCodeSchema = z.string().regex(/^[a-z]{2}$/, "languageCode must be a lowercase ISO 639-1 code, e.g. \"en\" or \"ja\"");
3598
- z.object({
3123
+ const ttsRequestSchema = z.object({
3599
3124
  text: z.string().min(1).max(TTS_ABSOLUTE_MAX_INPUT_CHARS),
3600
3125
  provider: supportedVoiceGenerationVendor.optional(),
3601
3126
  model: z.string().optional(),
@@ -3621,7 +3146,18 @@ const audioSaveSkippedReasonSchema = z.enum([
3621
3146
  "file_too_large",
3622
3147
  "error"
3623
3148
  ]);
3624
- z.object({
3149
+ /**
3150
+ * JSON body of `POST /api/ai/tts` when the caller asks for `encoding: 'base64'`.
3151
+ * The default `binary` encoding returns raw audio bytes instead and has no JSON
3152
+ * shape.
3153
+ *
3154
+ * The save + provider fields are all optional because the handler spreads them in
3155
+ * only when they apply: the save fields are absent when no copy was attempted
3156
+ * (`preview: true`, or the saveGeneratedAudio preference is off), and
3157
+ * `provider`/`fallbackFrom` appear only when the requested provider was
3158
+ * unavailable and another one stood in.
3159
+ */
3160
+ const ttsBase64ResponseSchema = z.object({
3625
3161
  /** Base64-encoded audio payload. */
3626
3162
  audio: z.string(),
3627
3163
  format: voiceOutputFormatSchema,
@@ -3635,7 +3171,32 @@ z.object({
3635
3171
  /** The originally requested provider that could not serve the request. */
3636
3172
  fallbackFrom: supportedVoiceGenerationVendor.optional()
3637
3173
  });
3638
- ApiErrorSchema.extend({
3174
+ /**
3175
+ * Error body for `POST /api/ai/tts`, shared by the 401, 422, 429 and 502; the 413
3176
+ * has a shape of its own (`ttsResponseTooLargeSchema`). `errorCode` is present only
3177
+ * on the conditions that carry a classifier; an ordinary validation 422 has none.
3178
+ *
3179
+ * Extends `ApiErrorSchema` because what decides whether a body carries the fields
3180
+ * errorHandler adds - `request_id`, and `name` until its 2026-12-01 sunset - is
3181
+ * whether the body was THROWN, not which status it wears, and three of these four
3182
+ * statuses are reachable both ways:
3183
+ *
3184
+ * - 401: thrown by apiKeyAuth on a rejected key; written by `auth` when no
3185
+ * credential was presented at all, and by the handler for
3186
+ * `provider_not_configured` and for an upstream credential rejection
3187
+ * (`provider_rejected`).
3188
+ * - 422: thrown by request validation and by the char-limit / format guards;
3189
+ * written by the handler for `insufficient_credits` and for an upstream 422.
3190
+ * - 429: thrown by apiKeyRateLimit; written by the handler on an upstream 429.
3191
+ * - 502: only ever written.
3192
+ *
3193
+ * So those two are genuinely optional here, and splitting this per status would be
3194
+ * wrong for the first three. The 502 does advertise both without ever sending them;
3195
+ * that is not worth a fourth error schema on one route, and `request_id` missing from
3196
+ * this handler's written bodies is a gap in the handler rather than something to
3197
+ * enshrine in a schema.
3198
+ */
3199
+ const ttsErrorResponseSchema = ApiErrorSchema.extend({
3639
3200
  provider: supportedVoiceGenerationVendor.optional(),
3640
3201
  errorCode: z.enum([
3641
3202
  "insufficient_credits",
@@ -3643,7 +3204,21 @@ ApiErrorSchema.extend({
3643
3204
  "provider_rejected"
3644
3205
  ]).optional()
3645
3206
  });
3646
- z.object({
3207
+ /**
3208
+ * 413 body: the audio was generated and billed but exceeds the serverless
3209
+ * response-size cap. When a browsable copy was saved, `fileUrl` is how the caller
3210
+ * retrieves the audio it paid for.
3211
+ *
3212
+ * Not derived from `ApiErrorSchema`: every 413 on this route is written, never
3213
+ * thrown, so errorHandler never serves one and `name` is genuinely absent rather than
3214
+ * optional - a stronger claim than `ttsErrorResponseSchema` can make for its own
3215
+ * statuses, see the note there. Two writers, and only the first matches the paragraph
3216
+ * above: the exceedsTtsResponseLimit guard, and the upstream-4xx passthrough relaying
3217
+ * a provider 413, where nothing was generated or billed and there is no `fileUrl`. An
3218
+ * oversized *request* body is a third 413 that never reaches this schema at all -
3219
+ * Next's own body parser answers it in plain text before the router runs.
3220
+ */
3221
+ const ttsResponseTooLargeSchema = z.object({
3647
3222
  error: z.string(),
3648
3223
  provider: supportedVoiceGenerationVendor,
3649
3224
  saved: z.literal(true).optional(),
@@ -3656,7 +3231,15 @@ z.enum(["openai"]);
3656
3231
  * New vendors are added here and in the `aiSoundService` factory.
3657
3232
  */
3658
3233
  const supportedSoundGenerationVendor = z.enum(["elevenlabs"]);
3659
- z.object({
3234
+ /**
3235
+ * Inbound request body for `POST /api/ai/sound-effects`.
3236
+ *
3237
+ * `durationSeconds` and `promptInfluence` bounds mirror the ElevenLabs
3238
+ * sound-generation limits (0.5-30s for the default eleven_text_to_sound_v2
3239
+ * model, prompt influence 0-1). `format` is the provider-specific output
3240
+ * encoding token (e.g. `mp3_44100_128`).
3241
+ */
3242
+ const soundEffectsRequestSchema = z.object({
3660
3243
  provider: supportedSoundGenerationVendor.default("elevenlabs"),
3661
3244
  text: z.string().min(1).max(1e3),
3662
3245
  durationSeconds: z.number().min(.5).max(30).optional(),
@@ -3674,13 +3257,23 @@ const supportedMusicGenerationVendor = z.enum(["elevenlabs"]);
3674
3257
  * change the request surface needs to accept it.
3675
3258
  */
3676
3259
  const supportedMusicModel = z.enum(["music_v1"]);
3677
- const DEFAULT_MUSIC_MODEL_ID = "music_v1";
3678
- z.object({
3260
+ /**
3261
+ * Inbound request body for `POST /api/ai/music`.
3262
+ *
3263
+ * `lengthMs` upper bound is capped below the ElevenLabs Music API ceiling to fit
3264
+ * the serving function's time budget (see MAX_MUSIC_LENGTH_MS). It carries a
3265
+ * default rather than being optional so the billed
3266
+ * length is always known up front (the reserve/settle path needs a deterministic
3267
+ * cost before generation) and the route can force that exact length on the
3268
+ * provider. `format` is the provider-specific output encoding token (e.g.
3269
+ * `mp3_44100_128`).
3270
+ */
3271
+ const musicRequestSchema = z.object({
3679
3272
  provider: supportedMusicGenerationVendor.default("elevenlabs"),
3680
3273
  prompt: z.string().min(1).max(2e3),
3681
3274
  lengthMs: z.number().int().min(3e3).max(12e4).default(1e4),
3682
3275
  forceInstrumental: z.boolean().optional(),
3683
- modelId: supportedMusicModel.default(DEFAULT_MUSIC_MODEL_ID),
3276
+ modelId: supportedMusicModel.default("music_v1"),
3684
3277
  format: z.string().optional()
3685
3278
  });
3686
3279
  VIDEO_SIZE_CONSTRAINTS.SORA.durations;
@@ -3776,6 +3369,41 @@ const AGENT_EXECUTION_STATUSES = [
3776
3369
  "aborted"
3777
3370
  ];
3778
3371
  /**
3372
+ * Why a session summarization happened, stamped on `ISession.summaryTrigger`. Single source for the
3373
+ * places that each used to spell this list out: the Session zod schema (schemas/actions.ts), the
3374
+ * entity type (types/entities/SessionTypes.ts), the Mongoose path (packages/database SessionModel)
3375
+ * and the session.summarize event payload (apps/client server/utils/eventBus.ts). They drifted -
3376
+ * the Mongoose enum said 'milestone'/'growth' for two values nothing produces - and a drift there
3377
+ * is invisible on the update path, because BaseModel's findOneAndUpdate writes without
3378
+ * runValidators.
3379
+ *
3380
+ * Those surfaces now name PERSISTED_SESSION_SUMMARY_TRIGGERS below, not the full union: a stored
3381
+ * field may only carry a reason a run HAPPENED. 'throttling' is the exception that forced the
3382
+ * split - shouldSummarizeSession (b4m-core/services ChatCompletionFeatures) returns it as the
3383
+ * reason it DECLINED to summarize, so it describes no run and belongs to a decision, not a
3384
+ * document. It is typed by SummarizationDecision there, not by the session field.
3385
+ *
3386
+ * 'manual' means someone asked for one notebook's summary. The admin sweep (apps/client
3387
+ * server/events/spider.ts) summarizes every un-summarized notebook of the admin who ran it in one
3388
+ * billed pass, so it stamps 'spider' instead: without that, one deliberate click and a whole sweep
3389
+ * are indistinguishable when someone investigates unexpected summarization spend.
3390
+ */
3391
+ /**
3392
+ * The triggers a document may actually carry - every reason a summarization HAPPENED. The event
3393
+ * payload and createSessionParametersSchema both name this list rather than the full union below,
3394
+ * so 'throttling' cannot be published, cannot be stored, and therefore cannot reach a copy path.
3395
+ * Add a new reason-it-happened here, not to SESSION_SUMMARY_TRIGGERS, and every one of those
3396
+ * boundaries picks it up.
3397
+ */
3398
+ const PERSISTED_SESSION_SUMMARY_TRIGGERS = [
3399
+ "manual",
3400
+ "project",
3401
+ "earlyMilestone",
3402
+ "contentGrowth",
3403
+ "spider"
3404
+ ];
3405
+ [...PERSISTED_SESSION_SUMMARY_TRIGGERS];
3406
+ /**
3779
3407
  * Operator allow-list for the client-authored Mongo filter carried on a `subscribe_query` frame.
3780
3408
  *
3781
3409
  * The WS data-subscribe handler forwards that filter to `Model.find` and persists it on the
@@ -4037,11 +3665,24 @@ const QuestExportProgressAction = z$1.object({
4037
3665
  "failed"
4038
3666
  ]),
4039
3667
  progress: z$1.number(),
4040
- detail: z$1.string().optional(),
4041
- downloadUrl: z$1.string().optional(),
4042
- filename: z$1.string().optional(),
3668
+ detail: z$1.string().optional(),
3669
+ downloadUrl: z$1.string().optional(),
3670
+ filename: z$1.string().optional(),
3671
+ errorMessage: z$1.string().optional(),
3672
+ droppedQuestCount: z$1.number().optional(),
3673
+ clientId: z$1.string().optional()
3674
+ });
3675
+ const OrgFeedbackSummaryProgressAction = z$1.object({
3676
+ action: z$1.literal("org_feedback_summary_progress"),
3677
+ summaryJobId: z$1.string(),
3678
+ organizationId: z$1.string(),
3679
+ status: z$1.enum([
3680
+ "processing",
3681
+ "completed",
3682
+ "failed"
3683
+ ]),
3684
+ progress: z$1.number(),
4043
3685
  errorMessage: z$1.string().optional(),
4044
- droppedQuestCount: z$1.number().optional(),
4045
3686
  clientId: z$1.string().optional()
4046
3687
  });
4047
3688
  const SpiderProgressUpdateAction = z$1.object({
@@ -4128,13 +3769,7 @@ const StreamedChatCompletionAction = z$1.object({
4128
3769
  replies: z$1.array(z$1.string()).optional(),
4129
3770
  images: z$1.array(z$1.string()).optional(),
4130
3771
  videos: z$1.array(z$1.string()).optional(),
4131
- type: z$1.enum([
4132
- "message",
4133
- "oob",
4134
- "error",
4135
- "system",
4136
- "voice_transcript"
4137
- ]),
3772
+ type: z$1.enum(CHAT_HISTORY_ITEM_TYPES),
4138
3773
  status: z$1.enum([
4139
3774
  "stopped",
4140
3775
  "running",
@@ -4935,18 +4570,13 @@ const SessionCreatedAction = shareableDocumentSchema.extend({
4935
4570
  claudeConversationId: z$1.string().optional(),
4936
4571
  summary: z$1.string().optional(),
4937
4572
  summaryAt: z$1.date().optional(),
4938
- summaryTrigger: z$1.enum([
4939
- "manual",
4940
- "project",
4941
- "earlyMilestone",
4942
- "contentGrowth",
4943
- "throttling"
4944
- ]).optional(),
4573
+ summaryTrigger: z$1.enum(PERSISTED_SESSION_SUMMARY_TRIGGERS).optional(),
4945
4574
  deletedAt: z$1.date().optional(),
4946
4575
  tags: z$1.array(z$1.object({
4947
4576
  name: z$1.string(),
4948
4577
  strength: z$1.number()
4949
4578
  })).optional(),
4579
+ taggedAt: z$1.date().optional(),
4950
4580
  clonedSourceId: z$1.string().nullable().optional(),
4951
4581
  forkedSourceId: z$1.string().nullable().optional(),
4952
4582
  isAutoNamed: z$1.boolean().optional(),
@@ -5137,7 +4767,14 @@ const PermissionRequestAction = z$1.object({
5137
4767
  executionId: z$1.string(),
5138
4768
  toolName: z$1.string(),
5139
4769
  toolInput: z$1.unknown(),
5140
- iteration: z$1.number()
4770
+ iteration: z$1.number(),
4771
+ /**
4772
+ * Provider tool_use id of the specific gated call this card is asking about.
4773
+ * The client echoes it back on `permission_response` so the server can bind
4774
+ * the answer to THIS pause rather than the latest one that happens to share
4775
+ * a tool name - see `handlePermissionResponse`'s toolCallId check.
4776
+ */
4777
+ toolCallId: z$1.string().optional()
5141
4778
  });
5142
4779
  const ChildExecutionSnapshotSchema = z$1.lazy(() => z$1.object({
5143
4780
  executionId: z$1.string(),
@@ -5159,7 +4796,8 @@ const ReconnectResultAction = z$1.object({
5159
4796
  pendingPermission: z$1.object({
5160
4797
  toolName: z$1.string(),
5161
4798
  toolInput: z$1.unknown(),
5162
- requestedAt: z$1.union([z$1.string(), z$1.date()])
4799
+ requestedAt: z$1.union([z$1.string(), z$1.date()]),
4800
+ toolCallId: z$1.string().optional()
5163
4801
  }).optional(),
5164
4802
  totalCreditsUsed: z$1.number().optional(),
5165
4803
  iterationCount: z$1.number().optional(),
@@ -5208,6 +4846,7 @@ z$1.discriminatedUnion("action", [
5208
4846
  ResearchTaskStatusUpdateAction,
5209
4847
  NotebookCurationProgressUpdateAction,
5210
4848
  QuestExportProgressAction,
4849
+ OrgFeedbackSummaryProgressAction,
5211
4850
  SpiderProgressUpdateAction,
5212
4851
  SpiderCompleteAction,
5213
4852
  SpiderErrorAction,
@@ -5250,7 +4889,29 @@ z$1.discriminatedUnion("action", [
5250
4889
  PermissionRequestAction,
5251
4890
  ReconnectResultAction
5252
4891
  ]);
5253
- z$1.object({
4892
+ /**
4893
+ * Public wire schemas for the agent-executor (ReAct) endpoints:
4894
+ * `POST /api/v1/agent-executions` and `GET /api/v1/agent-executions/{id}`.
4895
+ *
4896
+ * These are the REST twin of the WebSocket `agent_execute` command surface
4897
+ * (apps/client/server/websocket/agentExecute.ts). Both transports funnel into the
4898
+ * same `startAgentExecution` service, but the wire shapes are deliberately separate:
4899
+ * the WS payload carries UI-only fields (routing provenance, an optimistic-bubble
4900
+ * back-reference) that must never become published API surface, and public fields are
4901
+ * snake_case per CONVENTIONS.md section 2 while the WS command is camelCase.
4902
+ *
4903
+ * Public-API rules apply here: no `.catch()`, no top-level `.transform()`.
4904
+ */
4905
+ /**
4906
+ * Request body for `POST /api/v1/agent-executions`.
4907
+ *
4908
+ * `session_id` is required rather than defaulted (unlike `POST /api/chat`, which falls
4909
+ * back to the caller's last notebook): the session is what determines which agent
4910
+ * profile the executor builds, so guessing it would silently change the run's
4911
+ * behaviour. Everything else is optional and falls back to admin defaults or the
4912
+ * agent's own orchestration profile.
4913
+ */
4914
+ const AgentExecutionStartRequestSchema = z$1.object({
5254
4915
  session_id: z$1.string().min(1),
5255
4916
  message: z$1.string().min(1),
5256
4917
  /** Falls back to the deployment's default chat model when omitted. */
@@ -5307,7 +4968,11 @@ z$1.object({
5307
4968
  */
5308
4969
  enable_artifacts: z$1.boolean().optional()
5309
4970
  });
5310
- z$1.object({
4971
+ /**
4972
+ * 202 ACK for `POST /api/v1/agent-executions`. The run is fire-and-forget: nothing is
4973
+ * streamed back over REST, so the caller polls `poll_url` until `status` is terminal.
4974
+ */
4975
+ const AgentExecutionAckSchema = z$1.object({
5311
4976
  id: z$1.string(),
5312
4977
  status: z$1.literal("pending"),
5313
4978
  session_id: z$1.string(),
@@ -5337,7 +5002,15 @@ const AgentExecutionStepSchema = z$1.object({
5337
5002
  /** Set on `action` steps: the tool the agent invoked. */
5338
5003
  tool_name: z$1.string().optional()
5339
5004
  });
5340
- z$1.object({
5005
+ /**
5006
+ * Poll response for `GET /api/v1/agent-executions/{id}`.
5007
+ *
5008
+ * `steps` is the live trace: it grows while the run is in flight (read from the
5009
+ * checkpoint) and freezes at the final one. `answer` is null until the run reaches a
5010
+ * terminal status, and stays null on `failed` / `aborted` - where `error` carries the
5011
+ * reason instead.
5012
+ */
5013
+ const AgentExecutionStatusResponseSchema = z$1.object({
5341
5014
  id: z$1.string(),
5342
5015
  status: z$1.enum([
5343
5016
  "pending",
@@ -5369,7 +5042,8 @@ z$1.object({
5369
5042
  created_at: z$1.string(),
5370
5043
  updated_at: z$1.string()
5371
5044
  });
5372
- z$1.object({ id: z$1.string().min(1) });
5045
+ /** Path parameter for `GET /api/v1/agent-executions/{id}`. */
5046
+ const AgentExecutionIdParamSchema = z$1.object({ id: z$1.string().min(1) });
5373
5047
  /**
5374
5048
  * Tool schema matching ICompletionOptionTools.toolSchema. The Zod surface only
5375
5049
  * covers wire-format fields (toolFn is server-side). Replaces the historical
@@ -5419,7 +5093,17 @@ const CompletionMessageSchema = z$1.object({
5419
5093
  content: z$1.union([z$1.string(), z$1.array(z$1.any())]),
5420
5094
  cache: z$1.boolean().optional()
5421
5095
  });
5422
- z$1.object({
5096
+ /**
5097
+ * Schema for CLI LLM completion requests
5098
+ * Shared between Next.js API route (dev) and Lambda function (production)
5099
+ *
5100
+ * `response_format`, `stream`, `tools`, `temperature`, and `max_tokens` are all
5101
+ * accepted at the top level (OpenAI-compatible, matching how every major LLM
5102
+ * SDK shapes a completion request) AND nested under `options` (legacy shape).
5103
+ * Use `normalizeCompletionRequest()` to collapse both surfaces into the
5104
+ * canonical `options.<field>` location before downstream consumption.
5105
+ */
5106
+ const CompletionRequestSchema = z$1.object({
5423
5107
  model: z$1.string(),
5424
5108
  messages: z$1.array(CompletionMessageSchema),
5425
5109
  response_format: ResponseFormatSchema.optional(),
@@ -5466,18 +5150,42 @@ const CompletionContentEventSchema = z$1.object({
5466
5150
  stopReason: z$1.string().optional(),
5467
5151
  thinking: z$1.array(z$1.any()).optional()
5468
5152
  });
5153
+ /**
5154
+ * The in-band terminal failure frame. Because this endpoint flushes SSE headers
5155
+ * before it authenticates or prices anything, EVERY failure past the malformed-body
5156
+ * check arrives here under a 200 - there is no pre-stream 422 on this surface the
5157
+ * way there is on /api/chat and /api/embed/chat.
5158
+ *
5159
+ * `code` is the machine-readable classifier, drawn from QUEST_ERROR_CODES (itself a
5160
+ * narrowing of the platform-wide API_ERROR_CODES, so it cannot drift from the codes
5161
+ * the JSON surfaces use). It is what makes mid-generation credit exhaustion
5162
+ * detectable: the `message` is prose and callers must never match on it. Absent for
5163
+ * unclassified failures, so treat it as optional.
5164
+ *
5165
+ * The field is `code` rather than the conventions' `errorCode` because this frame
5166
+ * shipped that way and is a published wire shape; the vocabulary is what had to be
5167
+ * shared, not the key.
5168
+ */
5469
5169
  const CompletionSseErrorEventSchema = z$1.object({
5470
5170
  type: z$1.literal("error"),
5471
5171
  message: z$1.string(),
5472
5172
  requestId: z$1.string().optional(),
5473
- code: z$1.string().optional()
5173
+ code: z$1.enum(QUEST_ERROR_CODES).optional()
5474
5174
  });
5475
- z$1.union([
5175
+ /** One `data:` event in the `text/event-stream` completions response. */
5176
+ const CompletionStreamEventSchema = z$1.union([
5476
5177
  CompletionMetaEventSchema,
5477
5178
  CompletionContentEventSchema,
5478
5179
  CompletionSseErrorEventSchema
5479
5180
  ]);
5480
- z$1.object({
5181
+ /**
5182
+ * Server-side tool execution schemas for POST /api/ai/v1/tools.
5183
+ *
5184
+ * Plain Zod (no `.openapi()`) so any runtime can import them; the OpenAPI layer
5185
+ * annotates them via the contract. The tool-name enum MUST stay in sync with
5186
+ * SUPPORTED_TOOLS in apps/client/server/cli/toolsHandler.shared.ts.
5187
+ */
5188
+ const ToolExecutionRequestSchema = z$1.object({
5481
5189
  toolName: z$1.enum([
5482
5190
  "weather_info",
5483
5191
  "web_search",
@@ -6156,21 +5864,9 @@ const ALL_IMAGE_MODELS = [
6156
5864
  ...XAI_IMAGE_MODELS,
6157
5865
  ...GEMINI_IMAGE_MODELS
6158
5866
  ];
6159
- const OPENAI_GPT_IMAGE_1_IMAGE_SIZES = [
6160
- "1024x1024",
6161
- "1024x1536",
6162
- "1536x1024"
6163
- ];
6164
- const OPENAI_GPT_IMAGE_2_IMAGE_SIZES = [
6165
- "1024x1024",
6166
- "1536x1024",
6167
- "1024x1536",
6168
- "2048x2048",
6169
- "2048x1152",
6170
- "3840x2160",
6171
- "2160x3840",
6172
- "auto"
6173
- ];
5867
+ const OPENAI_GPT_IMAGE_1_IMAGE_SIZES = IMAGE_SIZE_CONSTRAINTS.GPT_IMAGE_1.sizes;
5868
+ /** The UI presets plus the tier's non-resolution `autoSize`, which the picker cannot render. */
5869
+ const OPENAI_GPT_IMAGE_2_IMAGE_SIZES = [...IMAGE_SIZE_CONSTRAINTS.GPT_IMAGE_2.sizes, IMAGE_SIZE_CONSTRAINTS.GPT_IMAGE_2.autoSize];
6174
5870
  const BFL_IMAGE_SIZES = ["1024x768"];
6175
5871
  const OPENAI_IMAGE_SIZES = [...OPENAI_GPT_IMAGE_1_IMAGE_SIZES];
6176
5872
  const ALL_IMAGE_SIZES = [
@@ -6180,16 +5876,34 @@ const ALL_IMAGE_SIZES = [
6180
5876
  ];
6181
5877
  z$1.enum(OPENAI_IMAGE_SIZES);
6182
5878
  const ImageSizeSchema = z$1.union([z$1.enum(ALL_IMAGE_SIZES), z$1.string().regex(/^\d+x\d+$/, { error: "Size must be in format 'widthxheight'" })]);
6183
- const OpenAIImageQualitySchema = z$1.enum([
5879
+ const OPENAI_IMAGE_QUALITIES = [
6184
5880
  "standard",
6185
5881
  "hd",
6186
5882
  "low",
6187
5883
  "medium",
6188
5884
  "high",
6189
5885
  "auto"
6190
- ]);
5886
+ ];
5887
+ const OpenAIImageQualitySchema = z$1.enum(OPENAI_IMAGE_QUALITIES);
5888
+ OPENAI_IMAGE_QUALITIES.filter((quality) => quality !== "auto");
6191
5889
  const OpenAIImageStyleSchema = z$1.enum(["vivid", "natural"]);
6192
5890
  /**
5891
+ * Alpha handling for gpt-image renders. `transparent` only yields real alpha when the
5892
+ * output format also carries an alpha channel (png or webp) - OpenAI rejects it alongside
5893
+ * jpeg. Ignored by every other provider (see OpenAIImageService.generate).
5894
+ */
5895
+ const OpenAIImageBackgroundSchema = z$1.enum([
5896
+ "transparent",
5897
+ "opaque",
5898
+ "auto"
5899
+ ]);
5900
+ /** Container for a generated image. `webp` is gpt-image only; BFL and Gemini take png/jpeg. */
5901
+ const ImageOutputFormatSchema = z$1.enum([
5902
+ "png",
5903
+ "jpeg",
5904
+ "webp"
5905
+ ]);
5906
+ /**
6193
5907
  * Maps legacy/removed image model IDs to their current replacements.
6194
5908
  * Prevents Zod validation failures when clients send stale persisted model names.
6195
5909
  *
@@ -6232,6 +5946,7 @@ const OpenAIImageGenerationInput = z$1.object({
6232
5946
  response_format: z$1.enum(["b64_json", "url"]).optional(),
6233
5947
  size: ImageSizeSchema.nullable().optional(),
6234
5948
  style: OpenAIImageStyleSchema.optional(),
5949
+ background: OpenAIImageBackgroundSchema.nullable().optional(),
6235
5950
  width: z$1.number().optional(),
6236
5951
  height: z$1.number().optional(),
6237
5952
  aspect_ratio: z$1.string().optional()
@@ -6347,17 +6062,30 @@ const SessionTagSchema = z$1.object({
6347
6062
  name: z$1.string(),
6348
6063
  strength: z$1.number()
6349
6064
  });
6350
- z$1.object({
6065
+ /**
6066
+ * Request schema for PUT /api/sessions/{id}. This is the exact field allowlist
6067
+ * sessionService.updateSession enforces (b4m-core/services/src/sessionService/update.ts,
6068
+ * which extends this schema with `id`) - shared so the public contract can never
6069
+ * document a field the service silently drops, or vice versa.
6070
+ */
6071
+ const SessionUpdateRequestSchema = z$1.object({
6351
6072
  name: z$1.string().min(1).optional(),
6352
6073
  knowledgeIds: z$1.array(z$1.string()).optional(),
6353
6074
  artifactIds: z$1.array(z$1.string()).optional(),
6354
6075
  tags: z$1.array(SessionTagSchema).optional(),
6355
6076
  lastUsedModel: z$1.string().min(1).nullish().describe("Pin a specific model id, or omit/send null to leave the current pin unchanged. Sending null does NOT clear it."),
6356
6077
  forceKnowledgeRetrieval: z$1.boolean().optional(),
6078
+ lakeScope: z$1.array(z$1.string()).nullable().optional().describe("The data lakes this session grounds on, as lake tags (the `datalakeTag` of each lake from GET /api/data-lakes). Send a list to ground only on those lakes, `[]` to ground on no lake at all, or `null` to clear the choice so retrieval falls back to every lake you can reach. Omit to leave the current choice unchanged. Tags naming a lake you cannot reach are ignored at retrieval time rather than rejected here. Narrowing the scope does not by itself turn retrieval on: pair it with `forceKnowledgeRetrieval: true` for a session that is not already grounded. Conversely `[]` leaves a grounded session nothing to retrieve from, so its forced retrieval is skipped rather than run against every lake."),
6357
6079
  propagateToProjects: z$1.boolean().optional().describe("Defaults to true when omitted. When knowledgeIds grows, the newly-added file ids are also appended to every project that contains this session, granting every member of that project access to those files. This propagation is append-only and cannot be undone through the UI - pass false if newly-attached files should not be shared with the project.")
6358
6080
  });
6359
- z$1.object({ id: z$1.string().min(1) });
6360
- z$1.object({
6081
+ /** Path parameter for session-scoped endpoints, e.g. GET/PUT /api/sessions/{id}. */
6082
+ const SessionIdParamSchema = z$1.object({ id: z$1.string().min(1) });
6083
+ /**
6084
+ * Practical response subset for PUT /api/sessions/{id} - the fields a caller needs to
6085
+ * confirm an update took effect. ISession (types/entities/SessionTypes.ts) carries many
6086
+ * more server-internal fields not documented as public API surface here.
6087
+ */
6088
+ const SessionResponseSchema = z$1.object({
6361
6089
  id: z$1.string(),
6362
6090
  name: z$1.string(),
6363
6091
  userId: z$1.string(),
@@ -6365,6 +6093,8 @@ z$1.object({
6365
6093
  artifactIds: z$1.array(z$1.string()).optional(),
6366
6094
  tags: z$1.array(SessionTagSchema).optional(),
6367
6095
  forceKnowledgeRetrieval: z$1.boolean().optional(),
6096
+ retrievalTags: z$1.array(z$1.string()).optional(),
6097
+ lakeScopeExplicit: z$1.boolean().optional().describe("True when `retrievalTags` is a deliberate choice, so an empty list means \"no lake\" rather than \"any\"."),
6368
6098
  lastUsedModel: z$1.string().nullish(),
6369
6099
  firstCreated: z$1.date(),
6370
6100
  lastUpdated: z$1.date()
@@ -6484,15 +6214,6 @@ const CHUNK_STALL_REASONS = [
6484
6214
  "unchunkedPaused"
6485
6215
  ];
6486
6216
  /**
6487
- * Whether a file is stalled by the convergence kill switch, by any arm. THE predicate every
6488
- * reader uses, so adding a stall reason reaches health, convergence and retrieval without separate
6489
- * comparisons drifting apart. Also the in-memory mirror of a Mongo
6490
- * `chunkStallReason: { $in: [...CHUNK_STALL_REASONS] }`.
6491
- */
6492
- function isChunkStalled(reason) {
6493
- return CHUNK_STALL_REASONS.includes(reason);
6494
- }
6495
- /**
6496
6217
  * Which reasons leave the file with NO passages, as opposed to passages with no vectors. A `Record`
6497
6218
  * over every reason rather than a hand-written subset array: a new stall reason then cannot compile
6498
6219
  * until it is classified, where a member missing from a literal array would just make a health count
@@ -6519,76 +6240,7 @@ const CHUNK_STALL_NOTICES = {
6519
6240
  };
6520
6241
  CHUNK_STALL_NOTICES.vectorizePaused;
6521
6242
  CHUNK_STALL_NOTICES.rechunkPaused;
6522
- /**
6523
- * TRANSITIONAL, and the ONE stall predicate every RETRIEVAL path must use until #2016's migration
6524
- * has run in every environment. Reads the new field, then falls back to the legacy prose that the
6525
- * pre-migration rows still carry in `notes`.
6526
- *
6527
- * It exists for the FORWARD window only: `migratorInvocation` is a `dependsOn` of the web stack
6528
- * only (infra/web.ts); the queue stack has none, so the executor can serve forced retrieval and
6529
- * `knowledge_base_search` while rows still carry the marker in `notes` and no `chunkStallReason`. A
6530
- * row stalled by the chunk arm then reads as a plain unindexed file: `isRetrievalExcluded` drops it
6531
- * upstream of the withhold on a vectorizedOnly lake, and `partitionByIndexAvailability` calls it
6532
- * servable everywhere else. The turn answers around a passage-less file and reports FULL coverage -
6533
- * the silent degradation this whole path exists to prevent.
6534
- *
6535
- * A code ROLLBACK is the mirror image and this arm CANNOT cover it: the rows are already migrated
6536
- * (`chunkStallReason` set, `notes` unset) and the code restored is pre-#2016, which does not contain
6537
- * this function. Nothing reverts the data on its own either - `migratorInvocation` only ever runs
6538
- * `up` and `migrate down` is a manual CLI step - so `migrate down` is a REQUIRED step of any
6539
- * rollback past #2016, not an optional tidy-up. What this arm does buy is that `down()` is safe to
6540
- * run FIRST: whichever stack is still new keeps honoring the prose it restores, so a staggered
6541
- * rollback has no window where a restored marker is invisible. `down()` is a PARTIAL restore
6542
- * though - it skips a row whose owner typed a note after `up()`, and that row grades as unstalled
6543
- * on both stacks once the field is dropped. See its own comment.
6544
- *
6545
- * Deliberately NOT used by the grading/health/UI readers: they are gated behind the web stack, and
6546
- * a legacy row there renders the notice line AND the identical text as the owner's note.
6547
- *
6548
- * Mirrored in Mongo by `buildFabFileSearchQuery`'s `vectorizedOnly` exemption. Delete the legacy arm
6549
- * from both together, one release after the migration has landed everywhere.
6550
- *
6551
- * Pinned to the two reasons the migration backfilled rather than every notice: `unchunkedPaused`
6552
- * postdates it, so no row carries its prose, and including it would read an owner who happens to type
6553
- * that sentence into `notes` as stalled.
6554
- */
6555
- const LEGACY_CHUNK_STALL_NOTES = [CHUNK_STALL_NOTICES.vectorizePaused, CHUNK_STALL_NOTICES.rechunkPaused];
6556
- function isChunkStalledFile(file) {
6557
- return isChunkStalled(file.chunkStallReason) || LEGACY_CHUNK_STALL_NOTES.includes(file.notes ?? "");
6558
- }
6559
- /**
6560
- * `FabFile.chunkRebuildRequestedAt`: stamped by `resetChunkStateByIds` in the SAME write that
6561
- * clears a file's chunk rollups, so "this file's passages are being rebuilt" can never be lost the
6562
- * way the pair of steps that creates the state can be. The reset and the queue send are two
6563
- * operations - kill the producer between them, or lose the consumer's marker write, and the file
6564
- * sits at `chunkCount: 0` with `error: null` and no stall reason, a shape indistinguishable from an
6565
- * image or a still-uploading row. It then drops out of lake health's denominator, out of the
6566
- * convergence plan and out of the retrieval withhold at the same moment: every rollup says its
6567
- * passages are gone, and nothing reports it.
6568
- *
6569
- * Deliberately NOT the `rechunkPaused` stall reason pre-written by the producer, which is the obvious
6570
- * fix and the wrong one: that marker means "halted, needs an administrator", so a file awaiting an
6571
- * ORDINARY rebuild would read to every reader as permanently paused for the whole rebuild - search
6572
- * would tell readers it does not return on its own, health would hard-fail P3, and "Rebuild
6573
- * passages" would offer to repair a file that is already repairing. A flag that cries wolf on the
6574
- * normal path is worse than the rare window it closes.
6575
- *
6576
- * So the two facts are distinct states, and the consumer UPGRADES one to the other: pending means
6577
- * "in flight, returns on its own", the paused note means "halted, needs intervention". A LOST
6578
- * upgrade therefore degrades to mislabelled-but-visible rather than invisible, which is the trade
6579
- * this field exists to make - invisibility is the real harm, labelling is secondary.
6580
- *
6581
- * A dedicated field on purpose, and the precedent #2016 followed for the other two machine-written
6582
- * facts: while they all shared `notes` every writer of that field clobbered the others, including
6583
- * the user's own note.
6584
- *
6585
- * Cleared by `commitFabFileChunks` (the rebuild landed) and by the chunk handler's pause write (the
6586
- * rebuild was halted instead). A file carrying `error` is settled regardless - see
6587
- * `isMemberIndexingInFlight`, which is where the precedence between these three lives.
6588
- */
6589
- function isChunkRebuildPending(requestedAt) {
6590
- return requestedAt !== null && requestedAt !== void 0 && requestedAt !== "";
6591
- }
6243
+ CHUNK_STALL_NOTICES.vectorizePaused, CHUNK_STALL_NOTICES.rechunkPaused;
6592
6244
  /** Ceiling so "adjustable" cannot mean "unbounded" in either direction. */
6593
6245
  const LAKE_ACCESS_AUDIT_RETENTION_MAX_DAYS = 2555;
6594
6246
  /**
@@ -6683,46 +6335,7 @@ function defaultEmbeddingModelForEnv() {
6683
6335
  const hasOllama = !!process.env.OLLAMA_BASE_URL?.trim();
6684
6336
  const hasCloudEmbeddingKey = !isPlaceholderApiKey(process.env.OPENAI_API_KEY) || !isPlaceholderApiKey(process.env.VOYAGE_API_KEY);
6685
6337
  if (selfHost && hasOllama && !hasCloudEmbeddingKey) return "qwen3-embedding:0.6b";
6686
- return "text-embedding-ada-002";
6687
- }
6688
- /**
6689
- * True when this deployment can embed with no provider API key at all: a cloud stage reaches
6690
- * Bedrock through its task/execution role's AWS credentials.
6691
- *
6692
- * Requires POSITIVE evidence of an execution role rather than merely "not self-host". A plain
6693
- * `next dev` session and a CI job both leave B4M_SELF_HOST unset while holding no AWS credentials
6694
- * at all, so an absence test would send them to the Bedrock SDK for an opaque `CredentialsProvider
6695
- * Error` in place of the actionable OPENAI_KEY_MISSING_MESSAGE naming the key to set - the same
6696
- * actionable-to-opaque trade this fallback exists to avoid, just in a different keyless place.
6697
- *
6698
- * BOTH runtimes must be covered, and they carry different markers. The Lambdas (vectorize
6699
- * subscriber, the crons, the Next API routes) get AWS_LAMBDA_FUNCTION_NAME; ChatCompletion is a
6700
- * Fargate service (infra/chatCompletion.ts) and gets the ECS task-role URI instead. Since
6701
- * knowledgeBaseSearch runs inside that container, keying on the Lambda marker alone would leave
6702
- * chat knowledge-base search failing on exactly the keyless stages this fallback is for.
6703
- *
6704
- * SST_RESOURCE_App is the third arm and the one this repo can prove: SST sets it on anything it
6705
- * links, Lambda and Service alike (infra/chatCompletion.ts:129 and infra/agentExecutor.ts:54 both
6706
- * note that linking alone exposes SST_RESOURCE_*). It covers the Fargate task whether or not the
6707
- * ECS credential URI is present, and it is absent from a plain `next dev` and from CI, which is
6708
- * the case that matters. `sst dev` does set it - correctly, since that session runs against real
6709
- * AWS credentials.
6710
- *
6711
- * Self-host is excluded outright because it has no such role - its keyless path is the local
6712
- * Ollama embedder (`isLocalEmbedderAvailable` in toolAvailability.ts), not Bedrock.
6713
- *
6714
- * Answers "is Bedrock reachable here", NOT "should we use it" - a keyed stage is keyless-capable
6715
- * too, so this must only ever be asked ALONGSIDE a resolved credential table that came back empty.
6716
- * `resolveEmbeddingWithKeylessFallback` is where the two questions are paired for callers free to
6717
- * choose the model, and is what such a caller should use instead of asking this directly. The
6718
- * direct callers are the ones that additionally need the answer BEFORE resolving, to decide
6719
- * policy: toolAvailability reports whether embedding-backed tools are usable at all, and
6720
- * data-lakes/semantic-search decides whether a substitution is permitted for this request before
6721
- * it knows whether one is needed. Both still pair it with the table.
6722
- */
6723
- function hasKeylessCloudEmbedder() {
6724
- if (process.env.B4M_SELF_HOST === "true") return false;
6725
- return !!(process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || process.env.SST_RESOURCE_App);
6338
+ return "text-embedding-3-small";
6726
6339
  }
6727
6340
  z$1.union([
6728
6341
  z$1.enum(OpenAIEmbeddingModel),
@@ -6732,15 +6345,15 @@ z$1.union([
6732
6345
  ]);
6733
6346
  /**
6734
6347
  * The measured per-space floors, rendered for an admin-facing description (e.g. "75 for
6735
- * text-embedding-ada-002, 35 for text-embedding-3-small").
6348
+ * text-embedding-ada-002, 58 for text-embedding-3-small").
6736
6349
  *
6737
- * Rendered rather than written out in prose because these numbers are expected to move - 35 is
6738
- * provisional until it is re-derived against a production lake - and a description that restates
6739
- * the table is a wrong number shown to operators the moment it drifts, with nothing failing.
6350
+ * Rendered rather than written out in prose because these numbers move as each space is re-measured
6351
+ * against a production lake, and a description that restates the table is a wrong number shown to
6352
+ * operators the moment it drifts, with nothing failing.
6740
6353
  */
6741
6354
  const forcedRetrievalFloorsBySpaceSummary = Object.entries({
6742
6355
  ["text-embedding-ada-002"]: 75,
6743
- ["text-embedding-3-small"]: 35
6356
+ ["text-embedding-3-small"]: 58
6744
6357
  }).map(([space, pct]) => `${pct} for ${space}`).join(", ");
6745
6358
  /**
6746
6359
  * Default text for the artifact-emission system prompt. Single source of truth used BOTH as the
@@ -6915,20 +6528,6 @@ Call \`search_knowledge_base\` BEFORE answering when that library would settle t
6915
6528
  Do not search when the answer is already in front of you or out of scope: general knowledge (definitions, mathematics, established theory, public facts); anything answerable from this conversation, from an attached document, or from content already retrieved for you this turn; or a request to transform, summarize or reformat text the user has just supplied. If the library has already been searched on this turn, do not search it again for the same question - a repeat spends a round trip to return the same passages.
6916
6529
 
6917
6530
  When a search does not turn up what was asked for, say so plainly rather than filling the gap from training data, and never imply an answer came from the user's documents when it did not.`;
6918
- /**
6919
- * Default text for the formatting system message. Runtime fallback used by
6920
- * `includeHardcodedSystemMessage` (b4m-core/utils/src/llm/utils.ts) when the `FormatPromptTemplate`
6921
- * admin setting is blank; that setting's own default is intentionally '' - keep this the sole home.
6922
- *
6923
- * Deliberately scoped to formatting ONLY. The previous wording ("Adhere to specific formatting
6924
- * requests...") read as a general compliance instruction and bled into WHETHER to answer: as the
6925
- * only system content it roughly halved refusal quality. The opening clause is the fix - it fences
6926
- * this message off from the answer/abstain decision. Injected only when `UseFormatPrompt` is on.
6927
- *
6928
- * NOTE: a stored settings row pins its own wording, so changing this default does not reach an
6929
- * existing deployment that has already saved a value - the row must be edited in admin settings too.
6930
- */
6931
- const FORMAT_PROMPT_TEMPLATE = `Formatting only - nothing here decides whether or how fully to answer. Format replies to maintain the integrity of the requested style; default to markdown for text. Preserve proper structure for poems, songs, or haikus. When the user specifies an output format (e.g. TypeScript), use that format for the parts you do answer.`;
6932
6531
  z$1.enum([
6933
6532
  "openaiDemoKey",
6934
6533
  "anthropicDemoKey",
@@ -6972,13 +6571,16 @@ z$1.enum([
6972
6571
  "EnableDataLakeSlackAdd",
6973
6572
  "EnableDataLakeGroundingMode",
6974
6573
  "EnableLakeMemory",
6574
+ "EnableLakeModelInconsistencyDetection",
6975
6575
  "EnableDataLakeVectorSearch",
6976
6576
  "EnableRetrievalSupersessionCollapse",
6977
6577
  "PauseLakeConvergence",
6978
6578
  "LakeConvergenceBulkChangeSharePct",
6979
6579
  "EnforceLakeReadGrants",
6980
6580
  "EnableDataLakeDrivePoll",
6581
+ "EnableDataLakeGitHub",
6981
6582
  "EnforceLakeAdmission",
6583
+ "EnforceLakeOriginOnIngest",
6982
6584
  "EnableBriefcase",
6983
6585
  "EnableBriefcaseDefault",
6984
6586
  "EnableImageTemplates",
@@ -7078,6 +6680,7 @@ z$1.enum([
7078
6680
  "kbSearchMinRelevancePct",
7079
6681
  "forcedRetrievalRelativeFloorPct",
7080
6682
  "forcedRetrievalMinSimilarityPct",
6683
+ "forcedRetrievalSpreadFloorPct",
7081
6684
  "dataLakeEmbeddingSpendEnabled",
7082
6685
  "dataLakeEmbeddingBudgetPerRunUsd",
7083
6686
  "dataLakeEmbeddingBudgetPerLakeUsd",
@@ -7183,7 +6786,13 @@ const IntentClassifierConfigSchema = z$1.object({
7183
6786
  fallbackModels: z$1.array(z$1.string()).default(["gemini-2.5-flash-lite", "gpt-5.4-nano"])
7184
6787
  });
7185
6788
  const OrchestrationDefaultsSchema = z$1.object({
7186
- /** Tool names the synthetic profile is allowed to invoke. */
6789
+ /**
6790
+ * Tool names the synthetic profile is allowed to invoke. A DEFAULT toolbelt, not a gate:
6791
+ * an agentless chat dispatch ships the user's ambient Smart Tools and the executor UNIONS
6792
+ * them onto this list (`pickEffectiveEnabledTools`), so narrowing this narrows what the
6793
+ * agent brings of its own rather than capping what the user may select. `deniedTools` below
6794
+ * is the gate.
6795
+ */
7187
6796
  allowedTools: z$1.array(z$1.string()).default([
7188
6797
  "web_search",
7189
6798
  "retrieve_knowledge_content",
@@ -7315,7 +6924,7 @@ function makeNumberSetting(config) {
7315
6924
  return {
7316
6925
  ...config,
7317
6926
  type: "number",
7318
- schema: numberSchema.prefault(config.defaultValue ?? 0)
6927
+ schema: z$1.preprocess((val) => val === null || typeof val === "string" && val.trim() === "" ? void 0 : val, numberSchema.prefault(config.defaultValue ?? 0))
7319
6928
  };
7320
6929
  }
7321
6930
  function makeBooleanSetting(config) {
@@ -7392,6 +7001,17 @@ const RapidReplySettingsSchema = z$1.object({
7392
7001
  lastUpdated: /* @__PURE__ */ new Date()
7393
7002
  })
7394
7003
  });
7004
+ /**
7005
+ * Canonical repository and branch the What's New generator reads from.
7006
+ *
7007
+ * Every default in the What's New path (zod schema, settings registry, server
7008
+ * config service, cron/backfill fallbacks, admin form seed) must resolve here.
7009
+ * A stale slug is invisible in production: GitHubService.listMergedPullRequests
7010
+ * returns [] for a repository outside the connection allowlist, so generation
7011
+ * records "no PRs today" instead of an error and the surface silently goes dark.
7012
+ */
7013
+ const WHATS_NEW_DEFAULT_REPOSITORY = "Bike4Mind/bike4mind";
7014
+ const WHATS_NEW_DEFAULT_TARGET_BRANCH = "prod";
7395
7015
  const L = {
7396
7016
  temperature: {
7397
7017
  min: 0,
@@ -7495,8 +7115,8 @@ const WhatsNewConfigSchema = z$1.object({
7495
7115
  maxCommitMessageLength: z$1.number().min(L.maxCommitMessageLength.min).max(L.maxCommitMessageLength.max).default(L.maxCommitMessageLength.default),
7496
7116
  maxPRBodyLength: z$1.number().min(L.maxPRBodyLength.min).max(L.maxPRBodyLength.max).default(L.maxPRBodyLength.default),
7497
7117
  maxChangelogLength: z$1.number().min(L.maxChangelogLength.min).max(L.maxChangelogLength.max).default(L.maxChangelogLength.default),
7498
- repository: z$1.string().regex(/^[\w.-]+\/[\w.-]+$/, "Must be in owner/repo format (e.g., MyOrg/my-repo)").default("MillionOnMars/lumina5"),
7499
- targetBranch: z$1.string().regex(/^[\w./-]+$/, "Must be a valid branch name").default("prod"),
7118
+ repository: z$1.string().regex(/^[\w.-]+\/[\w.-]+$/, "Must be in owner/repo format (e.g., MyOrg/my-repo)").default(WHATS_NEW_DEFAULT_REPOSITORY),
7119
+ targetBranch: z$1.string().regex(/^[\w./-]+$/, "Must be a valid branch name").default(WHATS_NEW_DEFAULT_TARGET_BRANCH),
7500
7120
  promptTemplate: z$1.string().min(L.promptTemplate.min, `Prompt template must be at least ${L.promptTemplate.min} characters`).max(L.promptTemplate.max, `Prompt template cannot exceed ${L.promptTemplate.max.toLocaleString()} characters`).trim().optional()
7501
7121
  });
7502
7122
  const WhatsNewSyncConfigSchema = z$1.object({
@@ -7934,8 +7554,12 @@ const API_SERVICE_GROUPS = {
7934
7554
  order: 10
7935
7555
  },
7936
7556
  {
7937
- key: "dataLakeSearchMaxChunksPerFile",
7557
+ key: "forcedRetrievalSpreadFloorPct",
7938
7558
  order: 11
7559
+ },
7560
+ {
7561
+ key: "dataLakeSearchMaxChunksPerFile",
7562
+ order: 12
7939
7563
  }
7940
7564
  ]
7941
7565
  },
@@ -8929,6 +8553,16 @@ const settingsMap = {
8929
8553
  order: 91,
8930
8554
  dependsOn: "EnableDataLakes"
8931
8555
  }),
8556
+ EnableLakeModelInconsistencyDetection: makeBooleanSetting({
8557
+ key: "EnableLakeModelInconsistencyDetection",
8558
+ name: "Data Lakes: Model-driven contradiction pass",
8559
+ defaultValue: false,
8560
+ description: "Gate for the model-driven reading pass (#3057) that finds cross-document contradictions the lexical pattern rules cannot - two documents stating incompatible things in ordinary prose. Off by default: unlike the free lexical pass, this reads corpus content through an LLM, so it costs real money per run. Findings land in the same durable findings collection (detector: 'model') as the lexical pass, triggered the same way (POST /api/data-lakes/:id/inconsistencies?detector=model), gated separately here and rate-limited far lower per caller. Detect only - see the guardrail on corpusInconsistency.ts.",
8561
+ category: "Experimental",
8562
+ group: API_SERVICE_GROUPS.EXPERIMENTAL.id,
8563
+ order: 98,
8564
+ dependsOn: "EnableDataLakes"
8565
+ }),
8932
8566
  EnableDataLakeVectorSearch: makeBooleanSetting({
8933
8567
  key: "EnableDataLakeVectorSearch",
8934
8568
  name: "Data Lakes: Use Atlas $vectorSearch",
@@ -9000,6 +8634,16 @@ const settingsMap = {
9000
8634
  order: 95,
9001
8635
  dependsOn: "EnableDataLakes"
9002
8636
  }),
8637
+ EnableDataLakeGitHub: makeBooleanSetting({
8638
+ key: "EnableDataLakeGitHub",
8639
+ name: "Data Lakes: GitHub repository source",
8640
+ defaultValue: false,
8641
+ description: "Server-side gate for connecting a GitHub repository to a data lake through the read-only GitHub App (contents:read + metadata:read on the one selected repository). Off by default while the connect, ingest and purge pieces land dark; every GitHub lake route answers 403 until it is on.",
8642
+ category: "Experimental",
8643
+ group: API_SERVICE_GROUPS.EXPERIMENTAL.id,
8644
+ order: 96,
8645
+ dependsOn: "EnableDataLakes"
8646
+ }),
9003
8647
  EnforceLakeAdmission: makeBooleanSetting({
9004
8648
  key: "EnforceLakeAdmission",
9005
8649
  name: "Data Lakes: Enforce the admission contract",
@@ -9015,6 +8659,21 @@ const settingsMap = {
9015
8659
  "lake"
9016
8660
  ] }
9017
8661
  }),
8662
+ EnforceLakeOriginOnIngest: makeBooleanSetting({
8663
+ key: "EnforceLakeOriginOnIngest",
8664
+ name: "Data Lakes: Enforce curated-lake origin on ingest",
8665
+ defaultValue: true,
8666
+ description: "ON by default: unattended ingest (the Drive folder sync) refuses to add content to a lake whose owner declared it curated. OFF makes the refusal advisory and lets the write through. Unlike the admission contract this ships ON, because it refuses on an explicit owner declaration rather than a heuristic, and because the origin backfill marks every lake that currently has a connector as connector-fed - so at rollout this refuses nothing that exists. The lake rung is the one that matters; the org and owner rungs disable it across every lake in that scope at once. A flip is not instantaneous: the settings cache is per-instance, so it applies immediately on the instance that served the change and within ~5 min elsewhere.",
8667
+ category: "Experimental",
8668
+ group: API_SERVICE_GROUPS.EXPERIMENTAL.id,
8669
+ order: 97,
8670
+ dependsOn: "EnableDataLakes",
8671
+ scope: { settableAt: [
8672
+ "organization",
8673
+ "owner",
8674
+ "lake"
8675
+ ] }
8676
+ }),
9018
8677
  EnableBriefcase: makeBooleanSetting({
9019
8678
  key: "EnableBriefcase",
9020
8679
  name: "Enable Briefcase",
@@ -10192,7 +9851,7 @@ const settingsMap = {
10192
9851
  description: "Most chunks from any ONE source document a data-lake semantic search may return in its top-K. A diversity guard for CONTESTED slots: where several documents answer the question, it stops the best-scoring one from taking slots the others could have filled. It is NOT a fix for severe crowding - the cap redistributes only among the candidates retrieval already returned, so a document that supplies enough of the top-scoring chunks to fill that pool on its own is one the cap cannot change at all. On a corpus of book-length documents, expect enabling this to change little beyond widening the vector-search request. 0 (default) disables the cap, byte-identical to behavior before this setting existed. The cap never SHRINKS a result set - once the spread-out picks are in, any slots still open are backfilled with the highest-scoring chunks the cap held back, so a lake whose only match is one document still returns a full top-K. A value at or above the result count is also a no-op, since nothing can ever be held back. Below it, each retrieval stream's candidate pool is widened to a fixed multiple of the result count so the cap has a spread to choose from. The scanned corpus itself does not grow (that is bounded separately), but the vector-search backends are asked for that many more matches, and a larger in-memory ranking pool costs some CPU. 2-3 is the useful range; 1 serves one passage per document, which suits a corpus of many short documents and starves a question whose answer spans one long one. The chat knowledge-base path ranks more passages than it serves, so it applies the cap a second time at the count it actually serves - otherwise the spread-out picks, which are by definition the lowest-scoring ones admitted, would land in the passages that path discards.",
10193
9852
  category: "AI",
10194
9853
  group: API_SERVICE_GROUPS.EMBEDDING.id,
10195
- order: 11,
9854
+ order: 12,
10196
9855
  scope: { settableAt: ["organization", "owner"] }
10197
9856
  }),
10198
9857
  forcedRetrievalCharBudget: makeNumberSetting({
@@ -10275,12 +9934,25 @@ const settingsMap = {
10275
9934
  min: 1,
10276
9935
  max: 100,
10277
9936
  int: true,
10278
- description: `Absolute minimum cosine similarity, as a percent, a chunk must clear to be injected on a Data-Lake-mode turn. This is a sanity floor for genuinely unrelated content, NOT the ranking gate - the relative floor above does the ranking. LEAVE IT AT 75 UNLESS YOU HAVE MEASURED YOUR OWN CORPUS: a raw cosine means nothing outside the embedding model it was fitted to, so while this reads 75 the server ignores it and applies the floor measured for whichever model your documents are actually embedded with (${forcedRetrievalFloorsBySpaceSummary}, and no absolute floor at all for a model nobody has measured - the relative floor still applies). Set any other value and the server uses exactly that, in every space, which is yours to get right: 75 against text-embedding-3-small sits above that band entirely and returns nothing on every query. Where this floor lands inside your band decides a lot - on one measured corpus 74 / 75 / 76 swung recall 91% / 65% / 40% - and the same 75 that is a cliff on one lake rejects nothing at all on another. Re-measure after changing the embedding model; the sweep tool is packages/scripts/retrieval/forcedFloorSweep.ts.`,
9937
+ description: `Absolute minimum cosine similarity, as a percent, a chunk must clear to be injected on a Data-Lake-mode turn. This is a sanity floor for genuinely unrelated content, NOT the ranking gate - the relative floor above does the ranking. LEAVE IT AT 75 UNLESS YOU HAVE MEASURED YOUR OWN CORPUS: a raw cosine means nothing outside the embedding model it was fitted to, so while this reads 75 the server ignores it and applies the floor measured for whichever model your documents are actually embedded with (${forcedRetrievalFloorsBySpaceSummary}, and no absolute floor at all for a model nobody has measured - the relative floor still applies). Set any other value and the server uses exactly that, in every space, which is yours to get right: 75 against text-embedding-3-small sits above that band entirely and returns nothing on every query. Where this floor lands inside your band decides a lot - on one measured corpus 74 / 75 / 76 swung recall 91% / 65% / 40% - and the same 75 that is a cliff on one lake rejects nothing at all on another. Re-measure after changing the embedding model.`,
10279
9938
  category: "AI",
10280
9939
  group: API_SERVICE_GROUPS.EMBEDDING.id,
10281
9940
  order: 10,
10282
9941
  scope: { settableAt: ["organization", "owner"] }
10283
9942
  }),
9943
+ forcedRetrievalSpreadFloorPct: makeNumberSetting({
9944
+ key: "forcedRetrievalSpreadFloorPct",
9945
+ name: "Forced Retrieval Spread Floor (%)",
9946
+ defaultValue: 0,
9947
+ min: 0,
9948
+ max: 100,
9949
+ int: true,
9950
+ description: "How far below the best-scoring passage of the SAME turn a chunk may score and still be injected, as a percent of the gap between that best score and a typical one (this turn's median). 0 (the default) disables it. This is the only one of the three floors whose cut depends on the QUESTION rather than on where the score band sits: a question one document answers sharply leaves its answer far above the median and admits few passages, while a broad question leaves many passages bunched near the top and admits many. The other two floors cannot tell those turns apart, which is why retrieved volume otherwise tracks the character budget instead of the question. Because it is measured in units of the band rather than against a fixed cosine, the same value means the same thing in every embedding space and does not need re-tuning when the embedding model changes. Lower is stricter (10 keeps only passages within a tenth of the way down to the median); 100 cuts at the median itself. It can never empty a turn - the best passage always clears its own cutoff. Ships off because no magnitude has been measured yet; measure one offline before turning it on.",
9951
+ category: "AI",
9952
+ group: API_SERVICE_GROUPS.EMBEDDING.id,
9953
+ order: 11,
9954
+ scope: { settableAt: ["organization", "owner"] }
9955
+ }),
10284
9956
  LakeAccessAuditRetentionDays: makeNumberSetting({
10285
9957
  key: "LakeAccessAuditRetentionDays",
10286
9958
  name: "Lake Access Audit Retention (days)",
@@ -10708,8 +10380,8 @@ const settingsMap = {
10708
10380
  maxCommitMessageLength: 200,
10709
10381
  maxPRBodyLength: 500,
10710
10382
  maxChangelogLength: 1e3,
10711
- repository: "MillionOnMars/lumina5",
10712
- targetBranch: "main"
10383
+ repository: WHATS_NEW_DEFAULT_REPOSITORY,
10384
+ targetBranch: WHATS_NEW_DEFAULT_TARGET_BRANCH
10713
10385
  },
10714
10386
  description: "Configuration for automated What's New modal generation, including LLM model selection, prompt parameters, validation rules, and content sanitization limits.",
10715
10387
  category: "Admin",
@@ -11281,7 +10953,14 @@ const TokensBySourceSchema = z$1.object({
11281
10953
  fabFiles: z$1.number(),
11282
10954
  urlContent: z$1.number(),
11283
10955
  toolSchemas: z$1.number(),
11284
- userPrompt: z$1.number()
10956
+ userPrompt: z$1.number(),
10957
+ /**
10958
+ * Lake-sourced content injected this turn (forced retrieval + the lake-memory hot card), split out
10959
+ * of `systemPrompts`. Optional and absent means UNKNOWN, never zero - telemetry captured before
10960
+ * this bucket existed has no value to report and must not be read as "no lake content". Mirrors
10961
+ * PromptMetaTokensBySourceSchema in promptMeta.ts.
10962
+ */
10963
+ lakeRetrieval: z$1.number().optional()
11285
10964
  });
11286
10965
  z$1.object({
11287
10966
  /** gen_ai.usage.input_tokens */
@@ -11528,6 +11207,7 @@ const PromptMetaModelParametersSchema = z$1.object({
11528
11207
  prompt_upsampling: z$1.boolean().optional(),
11529
11208
  seed: z$1.number().optional(),
11530
11209
  output_format: z$1.string().optional(),
11210
+ background: z$1.string().optional(),
11531
11211
  response_format: z$1.string().optional(),
11532
11212
  seconds: z$1.number().optional(),
11533
11213
  model: z$1.string().optional()
@@ -11535,11 +11215,7 @@ const PromptMetaModelParametersSchema = z$1.object({
11535
11215
  const PromptMetaModelSchema = z$1.object({
11536
11216
  name: z$1.string(),
11537
11217
  parameters: PromptMetaModelParametersSchema.optional(),
11538
- type: z$1.enum([
11539
- "text",
11540
- "image",
11541
- "video"
11542
- ]).optional(),
11218
+ type: z$1.enum(PROMPT_META_MODEL_TYPES).optional(),
11543
11219
  backend: z$1.string().optional(),
11544
11220
  contextWindow: z$1.number().optional(),
11545
11221
  maxTokens: z$1.number().optional(),
@@ -11593,7 +11269,16 @@ const PromptMetaTokensBySourceSchema = z$1.object({
11593
11269
  fabFiles: z$1.number(),
11594
11270
  urlContent: z$1.number(),
11595
11271
  toolSchemas: z$1.number(),
11596
- userPrompt: z$1.number()
11272
+ userPrompt: z$1.number(),
11273
+ /**
11274
+ * Lake-sourced content injected this turn - forced retrieval (`knowledge_retrieval`) plus the
11275
+ * lake-memory hot card (`lake_memory`) - moved out of the `systemPrompts` residual so the
11276
+ * breakdown can price the lake separately. Optional, and absent means UNKNOWN, never zero: turns
11277
+ * recorded before this field existed carry no value and none can be backfilled, because the only
11278
+ * evidence was the residual this split had not yet made. Same absent-is-unknown rule as
11279
+ * `retrieval.injected` below.
11280
+ */
11281
+ lakeRetrieval: z$1.number().optional()
11597
11282
  });
11598
11283
  const PromptMetaContextSchema = z$1.object({
11599
11284
  attachedFiles: z$1.array(PromptMetaAttachedFileSchema).optional(),
@@ -11765,7 +11450,28 @@ const CitableSourceSchema = z$1.object({
11765
11450
  practiceAreas: z$1.array(z$1.string()).optional(),
11766
11451
  chunkId: z$1.string().optional(),
11767
11452
  relevanceScore: z$1.number().optional(),
11768
- fullContext: z$1.string().optional()
11453
+ fullContext: z$1.string().optional(),
11454
+ /** web_search's own thumbnail/image cluster for this source, gated on `withImages`. */
11455
+ thumbnail: z$1.string().optional(),
11456
+ images: z$1.array(z$1.string()).optional(),
11457
+ /**
11458
+ * Ids of the other cited sources this one provably disagrees with (#3041). Declared rather
11459
+ * than left to the loose object, for the same reason chunkId/fullContext are: a writer that
11460
+ * stamps the wrong shape should fail here, not render a badge that silently names nobody.
11461
+ */
11462
+ conflictsWith: z$1.array(z$1.string()).optional(),
11463
+ /** web_search's provider-located place (WebSearchPlace), the only source of map coordinates. */
11464
+ place: z$1.object({
11465
+ id: z$1.string(),
11466
+ name: z$1.string(),
11467
+ lat: z$1.number(),
11468
+ lng: z$1.number(),
11469
+ rating: z$1.number().optional(),
11470
+ reviews: z$1.number().optional(),
11471
+ category: z$1.string().optional(),
11472
+ address: z$1.string().optional(),
11473
+ thumbnail: z$1.string().optional()
11474
+ }).optional()
11769
11475
  }).optional()
11770
11476
  });
11771
11477
  /**
@@ -11903,12 +11609,28 @@ const RetrievalSummarySchema = z$1.object({
11903
11609
  * the per-turn routing question is about, and before this it was indistinguishable from a turn
11904
11610
  * where forced retrieval was never configured at all.
11905
11611
  */
11906
- forcedSkipReason: z$1.enum(["attached_files", "personal_corpus"]).optional(),
11612
+ forcedSkipReason: z$1.enum([
11613
+ "attached_files",
11614
+ "personal_corpus",
11615
+ "no_lake_scope"
11616
+ ]).optional(),
11907
11617
  /** Which retrieval-capable surface(s) ran this turn, e.g. 'lake-memory', 'knowledgeBaseSearch'. */
11908
11618
  surfaces: z$1.array(z$1.string()),
11909
11619
  /** Lakes resolved at the moment retrieval ran, stamped point-in-time (not read live from the session). */
11910
11620
  dataLakeTags: z$1.array(z$1.string()),
11911
11621
  /**
11622
+ * The subset of `dataLakeTags` that actually put files into the ranked scope. `dataLakeTags`
11623
+ * alone says which lakes were REQUESTED, which read as "searched" while a lake could contribute
11624
+ * nothing - the retrieval budget used to be spent in file order and starved whichever lake
11625
+ * sorted last.
11626
+ *
11627
+ * Optional because attribution is best-effort: a file matched by a lake's prefix/membership arm
11628
+ * can carry no reversible `datalake:` tag (see attributeAccessedLakes), and the producer omits
11629
+ * this rather than reporting an inconclusive scope as "no lake contributed". Absent means
11630
+ * unknown, NOT none.
11631
+ */
11632
+ dataLakeTagsWithCandidates: z$1.array(z$1.string()).optional(),
11633
+ /**
11912
11634
  * The lake scope the turn's retrieval surfaces WOULD have searched, resolved at the seed site
11913
11635
  * whether or not any of them ran: the caller's accessible lakes narrowed to the session
11914
11636
  * (narrowLakeAccessToSession), or empty where the corpus is personal and the lake arms are
@@ -11995,16 +11717,25 @@ const RetrievalSummarySchema = z$1.object({
11995
11717
  * not "what reached the model": `ranked.length` and `scored.length` in KnowledgeRetrievalFeature
11996
11718
  * - the candidates left after the absolute similarity floor, and after the relative floor
11997
11719
  * trims them. `chunks` is what survived the char budget on top of that, so the three
11998
- * numbers bracket two independent trimmers:
11720
+ * numbers bracket three independent trimmers:
11999
11721
  *
12000
- * pre -> [relative floor] -> post -> [char budget] -> chunks
11722
+ * pre -> [relative floor] -> post -> [spread floor] -> postSpread -> [char budget] -> chunks
12001
11723
  *
12002
11724
  * They exist so a low `chunks` is diagnosable - a small corpus and a floor that trimmed a large
12003
- * pool end in the same `chunks`. `pre - post` is the floor's own effect and nothing else;
11725
+ * pool end in the same `chunks`. `pre - post` is the relative floor's own effect and nothing
11726
+ * else, and `post - postSpread` the spread floor's;
12004
11727
  * `pre - chunks` is NOT, because the budget trims the same walk. Both optional: only forced
12005
11728
  * retrieval computes a ranked pool, a surface without one (lake memory, the knowledge tools)
12006
- * never writes either, and absence must not read as zero candidates. SUMMED like `chunks`, with
12007
- * the same absent-is-not-zero handling as `topScore`.
11729
+ * never writes any of them, and absence must not read as zero candidates. SUMMED like `chunks`,
11730
+ * with the same absent-is-not-zero handling as `topScore`.
11731
+ *
11732
+ * `backgroundScore` is the median of every score the turn compared, and `postSpreadFloorCandidates`
11733
+ * what is left once the spread floor cuts against it. Recorded even while that floor is OFF (its
11734
+ * shipped default), in which case `postSpread` equals `post` and the pair degenerates to a
11735
+ * diagnostic: `topScore - backgroundScore` is the turn's signal spread, and the distribution of
11736
+ * that quantity over production traffic is what a value for `forcedRetrievalSpreadFloorPct` has to
11737
+ * be chosen from. NOT comparable across embedding spaces as an absolute number, for the same
11738
+ * reason `topScore` is not; the RATIO of the two floors' cuts is.
12008
11739
  *
12009
11740
  * COMPARE THE PAIR ONLY TO ITSELF, never to `chunks`, unless `surfaces` is forced retrieval
12010
11741
  * alone. `chunks` and `chars` sum across ALL surfaces while this pair is forced-only, so a mixed
@@ -12026,7 +11757,9 @@ const RetrievalSummarySchema = z$1.object({
12026
11757
  chars: z$1.number(),
12027
11758
  topScore: z$1.number().optional(),
12028
11759
  preRelativeFloorCandidates: z$1.number().optional(),
12029
- postRelativeFloorCandidates: z$1.number().optional()
11760
+ postRelativeFloorCandidates: z$1.number().optional(),
11761
+ postSpreadFloorCandidates: z$1.number().optional(),
11762
+ backgroundScore: z$1.number().optional()
12030
11763
  }).optional(),
12031
11764
  /**
12032
11765
  * Could the corpus in scope have answered this turn, whether or not the model went looking?
@@ -12125,7 +11858,44 @@ const RetrievalSummarySchema = z$1.object({
12125
11858
  * absence is weaker evidence than presence. Date-bound any rollup: turns predating this field
12126
11859
  * carry nothing, and no backfill is possible - a past turn's grant rows have moved on.
12127
11860
  */
12128
- grantedLakeIdsUsed: z$1.array(z$1.string()).optional()
11861
+ grantedLakeIdsUsed: z$1.array(z$1.string()).optional(),
11862
+ /**
11863
+ * How many lakes were excluded from this turn's scope because the caller lacks the access to
11864
+ * search them, and why (#3055). Resolved at the seed alongside `lakeScope`, from a dedicated
11865
+ * count-only query (see excludedByAccessCount on getDynamicDataLakeAccess - NOT derived from
11866
+ * the candidate set `lakeScope` comes from, which already has the gate enforced datastore-side
11867
+ * and so cannot see this population).
11868
+ *
11869
+ * ABSENT MEANS NOT RECORDED, never "nothing was excluded" - a turn with nothing excluded records
11870
+ * `count: 0` explicitly. Three distinct causes collapse into this one absent state and are not
11871
+ * distinguishable from it: a turn predating this field, a turn whose `retrieval` was written
11872
+ * only by a tool arm rather than by the seed, and the count-only query itself failing or not
11873
+ * being wired on this host (mirrors `lakeViewComplete`'s contract on the access resolver: a
11874
+ * failure must report unknown, never a false zero).
11875
+ *
11876
+ * COUNT AND REASON ONLY, DELIBERATELY. Never a lake id, name, or tag: the caller may not be
11877
+ * permitted to know a given excluded lake exists at all, and this field must stay safe to show
11878
+ * them regardless of which specific lake(s) it is counting. `reason` is a closed enum, not free
11879
+ * text - prose could leak a lake's identity through phrasing - so a future exclusion cause (e.g.
11880
+ * an archived or quota-limited lake) adds an enum value here rather than a description.
11881
+ *
11882
+ * 'access' is the only reason today: the caller's org membership or the lake's public listing
11883
+ * surfaced it as a candidate (they could see it exists) but they hold neither its own
11884
+ * gate/entitlement nor an ownership or grant exception for it.
11885
+ *
11886
+ * A session-preauthorized lake (unionPreauthorizedLakeAccess) that is ALSO gate-dropped from
11887
+ * this account-wide count is corrected, not merely narrow: the seed's targeted measurement
11888
+ * (measureIdentityNamedExclusion, ChatCompletionProcess's promptMeta seed) excludes exactly the
11889
+ * tags this turn successfully admitted via preauthorization before running the gate query, so an
11890
+ * admitted-and-searched lake never reports here as excluded. This account-wide number itself
11891
+ * (excludedByAccessCount on getDynamicDataLakeAccess) is still computed before that union and is
11892
+ * NOT corrected the same way - only the per-turn targeted measurement is, which is what a
11893
+ * preauthorized session's own narrowing always uses (see sessionNamesALake's call site).
11894
+ */
11895
+ excludedLakes: z$1.object({
11896
+ count: z$1.number().int().nonnegative(),
11897
+ reason: z$1.enum(["access"])
11898
+ }).optional()
12129
11899
  });
12130
11900
  /**
12131
11901
  * Why a grounded turn's library scan stopped short of the whole library.
@@ -12264,9 +12034,18 @@ z$1.object({
12264
12034
  schemaVersion: z$1.number().int().positive(),
12265
12035
  /** Product identifier: 'vibeswire', 'bike4mind', 'stocksandvibes', 'k2kanji', etc. */
12266
12036
  productId: z$1.string().min(1).max(64),
12267
- /** Product's internal user ID */
12037
+ /**
12038
+ * Product's internal user ID, or OVERWATCH_ANONYMOUS_USER_ID when the event has no
12039
+ * identified user. See the session and identity conventions below.
12040
+ */
12268
12041
  userId: z$1.string().min(1).max(256),
12269
- /** Session identifier for retention/funnel analysis */
12042
+ /**
12043
+ * Visit identifier. Overwatch counts distinct values of this per product as the first
12044
+ * stage of its acquisition funnel, so one value per visit is what makes that count a
12045
+ * count of visits. An emitter that cannot tell which visit a request belongs to sends
12046
+ * OVERWATCH_UNKNOWN_SESSION_ID rather than a value of its own invention - see the
12047
+ * conventions below.
12048
+ */
12270
12049
  sessionId: z$1.string().min(1).max(256),
12271
12050
  /** Event type: 'session_start', 'signup', 'feature_used', etc. */
12272
12051
  event: z$1.string().min(1).max(128),
@@ -12283,6 +12062,153 @@ z$1.object({
12283
12062
  z$1.boolean()
12284
12063
  ])).refine((v) => JSON.stringify(v).length <= 1024, "metadata must be ≤ 1KB serialized").optional()
12285
12064
  });
12065
+ /**
12066
+ * Public wire schema for `GET /api/v1/me` - the caller's own identity and
12067
+ * commercial state, and nothing else.
12068
+ *
12069
+ * Deliberately narrower than the user document `/api/identify` returns: no email,
12070
+ * no tags, no internal flags. Everything here answers one of three questions a
12071
+ * downstream app has to ask before it spends - who is this, what have they paid
12072
+ * for, can they afford the next call.
12073
+ *
12074
+ * Public-API rules apply: snake_case wire fields, no `.catch()`, no top-level
12075
+ * `.transform()`.
12076
+ */
12077
+ /**
12078
+ * Rung on the B4M plan ladder the caller currently sits on.
12079
+ *
12080
+ * `free` means no active subscription. `other` is still a paying customer: either
12081
+ * an active plan that is not on the ladder (`SubscriptionPlanDetail.tier` is
12082
+ * optional - a standalone product omits it so it stays out of the cross-plan
12083
+ * change flow), or one whose Stripe price the deployment can no longer name, as
12084
+ * happens to a subscriber grandfathered on a superseded price. `subscription` is
12085
+ * `null` in that second case.
12086
+ *
12087
+ * Gate on `tier !== 'free'` for "is this caller paying" and on `subscription.
12088
+ * price_id` for "which product" - NOT on `basic`/`pro`, whose ordinals come from
12089
+ * the internal change-flow ladder and do not track a plan's marketing name (the
12090
+ * Professional plan occupies the `basic` rung today).
12091
+ */
12092
+ const ME_TIERS = [
12093
+ "free",
12094
+ "basic",
12095
+ "pro",
12096
+ "other"
12097
+ ];
12098
+ /** The caller's active subscription, or `null` when they have none the deployment can name. */
12099
+ const MeSubscriptionSchema = z$1.object({
12100
+ plan_name: z$1.string(),
12101
+ price_id: z$1.string(),
12102
+ interval: z$1.enum(["monthly", "yearly"]),
12103
+ /** ISO 8601. When the current billing period ends - not a cancellation date. */
12104
+ current_period_ends_at: z$1.string()
12105
+ });
12106
+ const MeResponseSchema = z$1.object({
12107
+ /** Stable B4M user id. Safe to key an integrator's own records on. */
12108
+ id: z$1.string(),
12109
+ /** Display name. Never the email address. */
12110
+ name: z$1.string(),
12111
+ tier: z$1.enum(ME_TIERS),
12112
+ subscription: MeSubscriptionSchema.nullable(),
12113
+ credits: z$1.object({
12114
+ /**
12115
+ * Spendable credits on the caller's PERSONAL ledger. Organization pools are not
12116
+ * included - a call billed to an organization draws on a balance this number
12117
+ * does not describe.
12118
+ */
12119
+ balance: z$1.number() }),
12120
+ /** Entitlement keys the caller currently holds, e.g. `base`. */
12121
+ entitlements: z$1.array(z$1.string())
12122
+ });
12123
+ /**
12124
+ * Every tool name in `b4mLLMTools`. Exhaustive by annotation - do not widen the type.
12125
+ */
12126
+ const CORE_TOOL_SIDE_EFFECTS = {
12127
+ dice_roll: "none",
12128
+ weather_info: "none",
12129
+ web_search: "none",
12130
+ web_fetch: "none",
12131
+ wolfram_alpha: "none",
12132
+ deep_research: "none",
12133
+ math_evaluate: "none",
12134
+ current_datetime: "none",
12135
+ prompt_enhancement: "none",
12136
+ wikipedia_on_this_day: "none",
12137
+ moon_phase: "none",
12138
+ sunrise_sunset: "none",
12139
+ iss_tracker: "none",
12140
+ planet_visibility: "none",
12141
+ search_knowledge_base: "none",
12142
+ retrieve_knowledge_content: "none",
12143
+ count_knowledge_base: "none",
12144
+ describe_knowledge_base: "none",
12145
+ chess_engine: "none",
12146
+ fmp_financial_data: "none",
12147
+ bob_panel_read: "none",
12148
+ skill: "none",
12149
+ recharts: "local",
12150
+ mermaid_chart: "local",
12151
+ navigate_view: "local",
12152
+ generate_jupyter_notebook: "local",
12153
+ optihashi_schedule: "local",
12154
+ optihashi_formulate: "local",
12155
+ optihashi_edit_problem: "local",
12156
+ image_generation: "external",
12157
+ edit_image: "external",
12158
+ music_generation: "external",
12159
+ audio_generation: "external",
12160
+ excel_generation: "external",
12161
+ edit_file: "external",
12162
+ blog_publish: "external",
12163
+ blog_edit: "external",
12164
+ blog_draft: "external",
12165
+ delegate_to_agent: "external"
12166
+ };
12167
+ /**
12168
+ * Tool names that reach the pipeline from outside `b4mLLMTools` - the CLI tool set, the
12169
+ * Slack tool set, and premium-overlay tools supplied at runtime via the `externalTools`
12170
+ * merge. No enum to key off, so these get no compile-time exhaustiveness; an unlisted name
12171
+ * falls through to the gated default, which is the correct failure direction.
12172
+ */
12173
+ const EXTERNAL_REGISTRY_SIDE_EFFECTS = {
12174
+ file_read: "none",
12175
+ glob_files: "none",
12176
+ grep_search: "none",
12177
+ recent_changes: "none",
12178
+ check_shell_output: "none",
12179
+ list_background_shells: "none",
12180
+ lattice_query: "none",
12181
+ lattice_explain: "none",
12182
+ ask_user_question: "local",
12183
+ create_file: "external",
12184
+ edit_local_file: "external",
12185
+ delete_file: "external",
12186
+ bash_execute: "external",
12187
+ write_shell_stdin: "external",
12188
+ kill_background_shell: "external",
12189
+ lattice_create_model: "external",
12190
+ lattice_add_entity: "external",
12191
+ lattice_set_value: "external",
12192
+ lattice_create_rule: "external",
12193
+ slackbot_help: "none",
12194
+ list_curated_files: "none",
12195
+ notebook_status: "none",
12196
+ share_curated_file: "external",
12197
+ notebook_new: "external",
12198
+ confirm_pending_action: "external",
12199
+ cancel_pending_action: "external",
12200
+ mission_status: "none",
12201
+ optihashi_decompose: "local",
12202
+ optihashi_solve: "local",
12203
+ send_slack_message: "external",
12204
+ coordinate_task: "external",
12205
+ code_execute: "external",
12206
+ video_generation: "external"
12207
+ };
12208
+ ({
12209
+ ...CORE_TOOL_SIDE_EFFECTS,
12210
+ ...EXTERNAL_REGISTRY_SIDE_EFFECTS
12211
+ });
12286
12212
  const InternalTeamMemberSchema = z$1.object({
12287
12213
  name: z$1.string().min(1, "Name is required"),
12288
12214
  phone: z$1.string().min(1, "Phone is required"),
@@ -12827,22 +12753,17 @@ const DATA_LAKES = [{
12827
12753
  }
12828
12754
  })()];
12829
12755
  new Set(DATA_LAKES.map((l) => l.id));
12830
- /**
12831
- * Canonical normalization for entitlement keys + `requiredEntitlement` values - the ONE
12832
- * rule, applied at write time (create/update/stamp) and at match time. Mirrors the
12833
- * entitlement registry's `normalizeTag` (trim + lowercase) so a value authored in any
12834
- * casing matches the lowercase keys the resolver produces.
12835
- */
12836
- const normalizeEntitlementKey = (key) => key.trim().toLowerCase();
12837
12756
  const sha256Regex = /^[a-f0-9]{64}$/;
12757
+ const requiredUserTagValue = z.string().trim().min(1).max(100).refine((s) => !/[,;]/.test(s), "User tag must be a single tag with no commas or semicolons (e.g. \"vip\" or \"Sales Team\")");
12838
12758
  z.object({
12839
12759
  name: z.string().min(1).max(200),
12840
12760
  slug: z.string().min(2).max(60).regex(DATA_LAKE_SLUG_REGEX, "Slug must be lowercase alphanumeric with hyphens (e.g. \"my-data-lake\")"),
12841
12761
  description: z.string().max(2e3).optional(),
12842
12762
  fileTagPrefix: z.string().trim().min(2).max(30).refine((s) => s.endsWith(":"), "Tag prefix must end with \":\" (e.g. \"acme:\")").refine((s) => !hasBlankTagPrefixSegment(s), "Tag prefix segments must be non-empty (e.g. \"acme:\" or \"acme:legal:\")").refine((s) => !isReservedTagPrefix(s), `Tag prefix cannot use the reserved "${DATALAKE_TAG_PREFIX}" namespace`),
12843
- requiredUserTag: z.string().min(1).max(100).optional(),
12763
+ requiredUserTag: requiredUserTagValue.optional(),
12844
12764
  requiredEntitlement: z.string().min(3).max(100).refine((s) => s.includes(":") && s.split(":").every((part) => part.length > 0), "Entitlement key must be namespaced with non-empty parts (e.g. \"product:pro\")").optional(),
12845
- organizationId: z.string().optional()
12765
+ organizationId: z.string().optional(),
12766
+ origin: z.enum(DATA_LAKE_ORIGINS).optional()
12846
12767
  });
12847
12768
  z.object({
12848
12769
  name: z.string().min(1).max(200).optional(),
@@ -12850,11 +12771,12 @@ z.object({
12850
12771
  systemPrompt: z.string().optional(),
12851
12772
  preferredSystemPromptId: z.union([z.literal(""), z.string().min(1).max(200)]).optional(),
12852
12773
  groundingMode: z.enum(DATA_LAKE_GROUNDING_MODES).optional(),
12853
- requiredUserTag: z.union([z.literal(""), z.string().min(1).max(100)]).optional(),
12774
+ requiredUserTag: z.union([z.literal(""), requiredUserTagValue]).optional(),
12854
12775
  requiredEntitlement: z.union([z.literal(""), z.string().min(3).max(100).refine((s) => s.includes(":") && s.split(":").every((part) => part.length > 0), "Entitlement key must be namespaced with non-empty parts (e.g. \"product:pro\")")]).optional(),
12855
12776
  auditQueryTextEnabled: z.boolean().optional(),
12856
12777
  lakeMemoryEnabled: z.boolean().optional(),
12857
- requiredPassageTokenTarget: z.number().int().min(64).max(OVERSIZED_PASSAGE_TOKEN_THRESHOLD).nullable().optional()
12778
+ requiredPassageTokenTarget: z.number().int().min(64).max(OVERSIZED_PASSAGE_TOKEN_THRESHOLD).nullable().optional(),
12779
+ origin: z.enum(DATA_LAKE_ORIGINS).optional()
12858
12780
  });
12859
12781
  z.object({
12860
12782
  groundingMode: z.enum(DATA_LAKE_GROUNDING_MODES).optional(),
@@ -12989,14 +12911,387 @@ const ImageTemplateSettingsSchema = z$1.object({
12989
12911
  safety_tolerance: z$1.number().min(0).max(6).optional(),
12990
12912
  prompt_upsampling: z$1.boolean().optional()
12991
12913
  });
12992
- z$1.object({
12993
- name: z$1.string().min(1).max(100),
12994
- description: z$1.string().max(500).optional(),
12995
- category: z$1.string().max(50).optional(),
12996
- model: ImageTemplateModelSchema,
12997
- settings: ImageTemplateSettingsSchema
12998
- }).omit({ model: true }).partial();
12999
- z$1.union([ToolExecutionResponseSchema, ApiErrorSchema]);
12914
+ z$1.object({
12915
+ name: z$1.string().min(1).max(100),
12916
+ description: z$1.string().max(500).optional(),
12917
+ category: z$1.string().max(50).optional(),
12918
+ model: ImageTemplateModelSchema,
12919
+ settings: ImageTemplateSettingsSchema
12920
+ }).omit({ model: true }).partial();
12921
+ /**
12922
+ * Identity factory that pins an endpoint contract's type. The `const` type
12923
+ * parameter preserves the concrete request-schema type so downstream adapters
12924
+ * can infer the validated body type (`z.infer<contract['request']>`) rather than
12925
+ * collapsing to the `z.ZodTypeAny` constraint.
12926
+ *
12927
+ * Otherwise deliberately a no-op at runtime (no registry side effect, no
12928
+ * `.openapi()`) so a contract stays a plain, transport-agnostic value that any
12929
+ * runtime can import - the one exception is the `pathParams`/`queryParams`
12930
+ * overlap check below, a plain assertion with no side effect of its own.
12931
+ */
12932
+ function defineEndpoint(contract) {
12933
+ if (contract.pathParams && contract.queryParams) {
12934
+ const queryKeys = new Set(Object.keys(contract.queryParams.shape));
12935
+ const overlap = Object.keys(contract.pathParams.shape).filter((key) => queryKeys.has(key));
12936
+ if (overlap.length > 0) throw new Error(`defineEndpoint(${contract.operationId}): pathParams and queryParams both declare ${JSON.stringify(overlap)}. Next merges both into req.query by name, so the path segment would silently win over the query value. Rename one side.`);
12937
+ }
12938
+ return contract;
12939
+ }
12940
+ defineEndpoint({
12941
+ method: "post",
12942
+ path: "/api/chat",
12943
+ operationId: "sendChatMessage",
12944
+ summary: "Send a chat message",
12945
+ description: "Sends a message to the AI and creates a quest to process it. By default (async) the call returns immediately with a quest id; poll `GET /api/quests/{id}` for the reply. Send `wait: true` to block until the reply is ready and receive it inline. A tool that produced machine-readable state reports it under `toolPayloads` - an array of `{ type, payload }` entries in emission order, alongside (never instead of) the prose reply - on the `wait: true` body and on the polled quest. A turn can FAIL after the ACK - reported on the polled quest as `type: \"error\"`, since the ACK was already sent. A terminal `status: \"stopped\"` (a missing session, a user-cancelled turn) is ALSO a failure even without `type: \"error\"` - it carries an explanatory string in `reply`/`replies` rather than an answer. `type` is the failure signal for the classified failure classes (an abort, a provider timeout, credit exhaustion); `errorCode` is an optional refinement present only when the failure is a classified billing reason. A recovered stuck quest is NOT in that list: one that still has renderable content resolves as a success by design. `QUEST_ERROR_CODES` has two members, but only `insufficient_credits` is raised as a quest errorCode by any current throw site on this endpoint - `spend_cap_exceeded` is thrown only by the embed chat route's pre-flight, which fires outside the process try/catch that would classify it onto a quest. A caller must treat `type: \"error\"` OR a terminal `status: \"stopped\"` as failure even when `errorCode` is absent, and must not read `reply` as an answer without checking those first. Authenticate with an API key (`b4m_live_`) or a JWT.",
12946
+ tags: ["AI"],
12947
+ auth: "apiKeyOrJwt",
12948
+ scopes: ["ai:chat", "ai:generate"],
12949
+ request: SimplifiedChatRequestSchema,
12950
+ requestExample: {
12951
+ message: "How do I reset my password?",
12952
+ toolMode: "smart"
12953
+ },
12954
+ emitsRateLimitHeaders: true,
12955
+ responses: {
12956
+ 200: {
12957
+ description: "Message accepted - NOT a completed turn. The default (async) path returns this queued ACK; the outcome arrives on `GET /api/quests/{id}` (see the `sendChatMessage200PollResult` schema). With `wait: true` the body additionally carries the completed reply (`response`/`responses`), `toolPayloads`, `createdAt`, and `performance` timings - fields not modelled here yet; the synchronous response shape is a follow-up. A turn that FAILS still resolves with `200`, never a 4xx, on both that `wait: true` body and the polled quest (`GET /api/quests/{id}`) - the prose explaining why lands in `reply`/`response` like any other answer, so the reply text alone cannot tell a failure from an answer. `type` is the field that can: both surfaces carry it unconditionally, so match on `type: \"error\"` first - it covers credit exhaustion, a provider timeout or overload, and an in-process aborted turn; a real answer carries the turn's actual completion type instead (`\"message\"` for an ordinary reply). Two related states do NOT set `type: \"error\"`: a user-cancelled turn resolves as `status: \"stopped\"` with `type` left at `\"message\"`, and a recovered stuck quest that still has renderable content resolves as a success (`status: \"done\"`, no error) by design, to avoid destroying content to report a failure. `errorCode` then names the failure reason, but only for the billing failures that have one - `\"insufficient_credits\"` today; it is absent on every other `type: \"error\"` turn, so never use its absence to infer success. On a real answer `errorCode` is absent from the `wait: true` body. Contrast the tts/music/soundEffects contracts, which reject synchronously with a 422 carrying the same `errorCode` vocabulary.",
12958
+ schema: ChatAckSchema,
12959
+ pollResult: {
12960
+ schema: ChatQuestPollResultSchema,
12961
+ description: "Outcome fields of the quest polled at `GET /api/quests/{id}` after this ACK. A finished turn that failed is `status: \"done\"` with `type: \"error\"` and the failure text in `reply`, so a caller reading `reply` alone cannot tell a failure from an answer - check `type` first, and also treat a terminal `status: \"stopped\"` (a missing session, a user-cancelled turn) as failure even though it never sets `type`. `errorCode` is an optional refinement of `type: \"error\"`, present only for a classified billing failure; credit exhaustion arrives here as `insufficient_credits`, the same vocabulary the synchronous 422s on `/api/ai/music`, `/api/ai/sound-effects` and `/api/ai/tts` use. `QUEST_ERROR_CODES` publishes a second member, `spend_cap_exceeded`, but no current throw site on this endpoint raises it as a quest errorCode: its only one, the embed chat route's pre-flight 422, fires outside the process try/catch that would classify it onto the quest. Most `type: \"error\"` turns - an abort, a provider timeout or overload - have NO `errorCode`; its absence does not mean success, only that the failure is unclassified. A recovered stuck quest that still has renderable content is not a failure at all: it keeps `type: \"message\"` even though it did not finish, so a caller gets the content rather than an error. The poll body carries further fields not modelled here, including `images`, `files`, `toolPayloads`, `promptMeta`, and the attachment report (`attachmentNotices`/`attachmentDelivery`) - only the outcome subset is modelled here.",
12962
+ example: {
12963
+ id: "664f1c2b9a1e4d0012ab34cd",
12964
+ status: "done",
12965
+ type: "error",
12966
+ errorCode: "insufficient_credits",
12967
+ reply: "You're out of credits. This request needs about 12 credits, but only 3 are available."
12968
+ }
12969
+ }
12970
+ },
12971
+ 400: {
12972
+ description: "No usable default chat model is configured and none was supplied.",
12973
+ schema: ApiErrorSchema
12974
+ },
12975
+ 404: {
12976
+ description: "No notebook/session exists to attach the message to.",
12977
+ schema: ApiErrorSchema
12978
+ },
12979
+ 422: {
12980
+ description: "Request body failed schema validation.",
12981
+ schema: ApiErrorSchema
12982
+ },
12983
+ 429: {
12984
+ description: "Per-user rate limit exceeded.",
12985
+ schema: ApiErrorSchema
12986
+ }
12987
+ },
12988
+ codeSample: {
12989
+ authToken: "b4m_live_<key>",
12990
+ streaming: false,
12991
+ body: {
12992
+ message: "How do I reset my password?",
12993
+ toolMode: "smart"
12994
+ }
12995
+ }
12996
+ });
12997
+ defineEndpoint({
12998
+ method: "post",
12999
+ path: "/api/v1/agent-executions",
13000
+ operationId: "startAgentExecution",
13001
+ summary: "Start an agent execution",
13002
+ description: "Runs the tool-using agent (ReAct) loop against a session - the same pipeline the product UI's Agent Mode toggle dispatches to, and the REST equivalent of the `agent_execute` WebSocket command. The run is asynchronous: this returns `202` with an execution id, and the caller polls `GET /api/v1/agent-executions/{id}` until `status` is terminal (`completed`, `failed`, or `aborted`). Nothing is streamed back over REST - for live iteration events, use the WebSocket route instead. Omit `agent_id` to get the profile the session's own surface resolves to, which is what reproduces the in-app toggle. The final reply is also written to the session as a normal chat message, so it appears in history. Naming a tool in `tools` also PRE-APPROVES it for the run: there is no interactive client to answer a permission prompt, so a run that calls an approval-gated tool you did not name fails with that tool named in `error` rather than hanging. Authenticate with an API key (`b4m_live_`) or a JWT.",
13003
+ tags: ["AI"],
13004
+ auth: "apiKeyOrJwt",
13005
+ scopes: ["ai:chat", "ai:generate"],
13006
+ request: AgentExecutionStartRequestSchema,
13007
+ requestExample: {
13008
+ session_id: "<sessionId>",
13009
+ message: "Audit this data set and summarize what stands out."
13010
+ },
13011
+ emitsRateLimitHeaders: true,
13012
+ responses: {
13013
+ 202: {
13014
+ description: "Run accepted and dispatched. Poll `tracking_info.poll_url` for status and the answer.",
13015
+ schema: AgentExecutionAckSchema
13016
+ },
13017
+ 400: {
13018
+ description: "No usable default chat model is configured and none was supplied.",
13019
+ schema: ApiErrorSchema
13020
+ },
13021
+ 404: {
13022
+ description: "No session with the given `session_id` is owned by the caller, or `organization_id` names an organization the caller does not belong to. \"Exists but is not yours\" is reported as 404 too, so neither session ids nor org membership can be probed through this endpoint.",
13023
+ schema: ApiErrorSchema
13024
+ },
13025
+ 409: {
13026
+ description: "The caller already has the maximum number of agent runs in flight - a per-user cap shared with runs started from the product UI. Wait for one to reach a terminal status before starting another; aborting a run is currently only possible over the WebSocket route.",
13027
+ schema: ApiErrorSchema
13028
+ },
13029
+ 429: {
13030
+ description: "Per-user rate limit exceeded.",
13031
+ schema: ApiErrorSchema
13032
+ },
13033
+ 502: {
13034
+ description: "The executor could not be dispatched. The run did not start, so a retry is safe.",
13035
+ schema: ApiErrorSchema
13036
+ }
13037
+ },
13038
+ codeSample: {
13039
+ authToken: "b4m_live_<key>",
13040
+ streaming: false,
13041
+ body: {
13042
+ session_id: "<sessionId>",
13043
+ message: "Audit this data set and summarize what stands out."
13044
+ }
13045
+ }
13046
+ });
13047
+ defineEndpoint({
13048
+ method: "get",
13049
+ path: "/api/v1/agent-executions/{id}",
13050
+ operationId: "getAgentExecution",
13051
+ summary: "Get an agent execution",
13052
+ description: "Returns the status, reasoning trace, and (once terminal) the final answer of a run started by `POST /api/v1/agent-executions`. `steps` grows while the run is in flight, so polling this endpoint is also how a REST caller follows the loop. Safe (GET) requests on this route are exempt from the per-day API-key quota so polling a single run costs one daily slot, not one per poll; the per-minute burst limit still applies.",
13053
+ tags: ["AI"],
13054
+ auth: "apiKeyOrJwt",
13055
+ scopes: ["ai:chat", "ai:generate"],
13056
+ pathParams: AgentExecutionIdParamSchema,
13057
+ emitsRateLimitHeaders: true,
13058
+ responses: {
13059
+ 200: {
13060
+ description: "The execution, its trace, and its answer if it has finished.",
13061
+ schema: AgentExecutionStatusResponseSchema
13062
+ },
13063
+ 404: {
13064
+ description: "No execution with that id is visible to the caller.",
13065
+ schema: ApiErrorSchema
13066
+ },
13067
+ 429: {
13068
+ description: "Per-user rate limit exceeded.",
13069
+ schema: ApiErrorSchema
13070
+ }
13071
+ },
13072
+ codeSample: {
13073
+ authToken: "b4m_live_<key>",
13074
+ streaming: false,
13075
+ body: {}
13076
+ }
13077
+ });
13078
+ defineEndpoint({
13079
+ method: "put",
13080
+ path: "/api/sessions/{id}",
13081
+ operationId: "updateSession",
13082
+ summary: "Update a session",
13083
+ description: "Updates a session (called a \"notebook\" in the product UI): its name, attached knowledge files, tags, or retrieval settings. Set `knowledgeIds` and `forceKnowledgeRetrieval: true` together to enable grounded retrieval for `POST /api/chat` against this session - retrieval is gated by these session fields, not by the chat request. `lakeScope` narrows that retrieval to a chosen set of data lakes; omit it to leave the current choice unchanged, or send `null` to clear it so retrieval reaches every lake you can access. Authenticate with an API key (`b4m_live_`) or a JWT. Warning: adding to `knowledgeIds` shares those files with every member of every project containing this session by default (see `propagateToProjects`), and that sharing cannot be undone through the UI.",
13084
+ tags: ["Sessions"],
13085
+ auth: "apiKeyOrJwt",
13086
+ scopes: ["notebooks:write"],
13087
+ pathParams: SessionIdParamSchema,
13088
+ request: SessionUpdateRequestSchema,
13089
+ emitsRateLimitHeaders: true,
13090
+ requestExample: {
13091
+ knowledgeIds: ["<fabFileId>"],
13092
+ forceKnowledgeRetrieval: true
13093
+ },
13094
+ responses: {
13095
+ 200: {
13096
+ description: "The updated session.",
13097
+ schema: SessionResponseSchema
13098
+ },
13099
+ 404: {
13100
+ description: "No session exists with the given id.",
13101
+ schema: ApiErrorSchema
13102
+ }
13103
+ },
13104
+ codeSample: {
13105
+ authToken: "b4m_live_<key>",
13106
+ streaming: false,
13107
+ body: {
13108
+ knowledgeIds: ["<fabFileId>"],
13109
+ forceKnowledgeRetrieval: true
13110
+ }
13111
+ }
13112
+ });
13113
+ defineEndpoint({
13114
+ method: "post",
13115
+ path: "/api/ai/v1/tools",
13116
+ operationId: "executeTool",
13117
+ summary: "Execute a server-side tool",
13118
+ description: "Runs one of the built-in server-side tools (`weather_info`, `web_search`, `web_fetch`) and returns its result as JSON. Authenticate with a JWT access token only - API keys are NOT accepted on this endpoint. Rate-limited to 100 requests/hour. `request_id` echoes the X-Request-ID response header.",
13119
+ tags: ["AI"],
13120
+ auth: "jwtOnly",
13121
+ request: ToolExecutionRequestSchema,
13122
+ requestExample: {
13123
+ toolName: "web_search",
13124
+ input: { query: "how to reset a password" }
13125
+ },
13126
+ responses: {
13127
+ 200: {
13128
+ description: "Tool executed successfully (`success` is always true here).",
13129
+ schema: ToolExecutionResponseSchema,
13130
+ example: {
13131
+ success: true,
13132
+ result: { summary: "Top results for the query." },
13133
+ executionTimeMs: 842,
13134
+ request_id: "abc-123"
13135
+ }
13136
+ },
13137
+ 400: {
13138
+ description: "Malformed JSON body.",
13139
+ schema: ApiErrorSchema
13140
+ },
13141
+ 401: {
13142
+ description: "Missing or invalid JWT (an API key is rejected here).",
13143
+ schema: ApiErrorSchema
13144
+ },
13145
+ 429: {
13146
+ description: "Rate limit exceeded (100 requests/hour).",
13147
+ schema: ApiErrorSchema
13148
+ },
13149
+ 500: {
13150
+ description: "Tool execution failed (`success: false` with `error`) or an unexpected server error.",
13151
+ schema: z$1.union([ToolExecutionResponseSchema, ApiErrorSchema]),
13152
+ bespokeErrorShape: "A tool that ran but failed returns the full ToolExecutionResponse (success: false), not an error envelope."
13153
+ }
13154
+ },
13155
+ codeSample: {
13156
+ authToken: "<access_token>",
13157
+ streaming: false,
13158
+ body: {
13159
+ toolName: "web_search",
13160
+ input: { query: "how to reset a password" }
13161
+ }
13162
+ }
13163
+ });
13164
+ defineEndpoint({
13165
+ method: "post",
13166
+ path: "/api/ai/v1/completions",
13167
+ operationId: "createCompletion",
13168
+ summary: "Create a chat completion",
13169
+ description: "OpenAI-compatible completion. The response is ALWAYS an SSE stream (`text/event-stream`), regardless of the `stream` flag: a `meta` event, then `content`/`tool_use` events carrying `usage`/`credits`, terminated by `data: [DONE]`. Once the stream has opened the HTTP status stays 200 and failures arrive as an in-band `error` event. The terminal event carries `stopReason`; treat `max_tokens` as a TRUNCATED reply rather than a complete one, and note that omitting `max_tokens` on the request lets the server size the output ceiling for the model (recommended for reasoning models, which spend thinking tokens inside that ceiling). A message `content` may be a string or an array of parts; image parts are accepted in OpenAI Chat (`image_url`), OpenAI Responses (`input_image`) or Anthropic (`image` with a `source`) form and are translated to whatever the target model speaks. Authenticate with an API key (`b4m_live_`) or a JWT.\n\nBILLING FAILURES. Headers are flushed before authentication or pricing, so unlike the JSON surfaces this endpoint has no pre-stream `422` + `errorCode: \"insufficient_credits\"` to pair with: credit exhaustion ALWAYS arrives as the in-band `error` event, whether it is caught by the reservation before the first token or by settlement mid-generation. Branch on that event's `code` (`insufficient_credits` - buy credits; `spend_cap_exceeded` - the owner is solvent but this key hit its admin-set ceiling, so raise the cap), never on `message`, which is prose and may change. `code` is absent on unclassified failures.",
13170
+ tags: ["AI"],
13171
+ auth: "apiKeyOrJwt",
13172
+ scopes: ["ai:chat", "ai:generate"],
13173
+ request: CompletionRequestSchema,
13174
+ requestExample: {
13175
+ model: "claude-opus-4-8",
13176
+ messages: [{
13177
+ role: "user",
13178
+ content: "How do I reset my password?"
13179
+ }],
13180
+ max_tokens: 500
13181
+ },
13182
+ streaming: true,
13183
+ responses: {
13184
+ 200: {
13185
+ description: "SSE stream of completion events (see the CompletionStreamEvent shape).",
13186
+ contentType: "text/event-stream",
13187
+ schema: CompletionStreamEventSchema,
13188
+ example: {
13189
+ type: "content",
13190
+ text: "To reset your password, click 'Forgot password' on the login screen.",
13191
+ usage: {
13192
+ inputTokens: 42,
13193
+ outputTokens: 12
13194
+ },
13195
+ credits: {
13196
+ used: 1,
13197
+ usdCost: 37e-5
13198
+ },
13199
+ stopReason: "end_turn"
13200
+ }
13201
+ },
13202
+ 400: {
13203
+ description: "Malformed JSON body (rejected before the stream opens).",
13204
+ schema: ApiErrorSchema
13205
+ }
13206
+ },
13207
+ codeSample: {
13208
+ authToken: "b4m_live_<key>",
13209
+ streaming: true,
13210
+ body: {
13211
+ model: "claude-opus-4-8",
13212
+ messages: [{
13213
+ role: "user",
13214
+ content: "How do I reset my password?"
13215
+ }],
13216
+ max_tokens: 500
13217
+ }
13218
+ }
13219
+ });
13220
+ defineEndpoint({
13221
+ method: "post",
13222
+ path: "/api/ai/tts",
13223
+ operationId: "synthesizeSpeech",
13224
+ summary: "Synthesize speech from text",
13225
+ description: "Generates speech from text using OpenAI or ElevenLabs. The default `encoding: \"binary\"` streams raw audio bytes with an `audio/*` Content-Type; `encoding: \"base64\"` returns JSON instead. When the requested provider has no usable key (or the provider rejects it), another configured provider stands in and the substitution is reported via `provider`/`fallbackFrom` and the `X-B4M-Tts-Provider*` headers. Input length is capped per provider (OpenAI 4096 characters, ElevenLabs 10000), and an output `format` the chosen provider cannot produce is rejected with a 422 before any provider cost is incurred. Generated audio is saved to the file browser by default (opt out per-user via the saveGeneratedAudio preference, or per-call with `preview`); the outcome is reported via `saved`/`fabFileId` and the `X-B4M-Audio-*` headers. Authenticate with an API key (`b4m_live_`) or a JWT.",
13226
+ tags: ["Audio"],
13227
+ auth: "apiKeyOrJwt",
13228
+ scopes: ["ai:generate"],
13229
+ request: ttsRequestSchema,
13230
+ requestExample: {
13231
+ text: "Your password has been reset.",
13232
+ provider: "openai",
13233
+ voice: "alloy",
13234
+ format: "mp3"
13235
+ },
13236
+ responses: {
13237
+ 200: {
13238
+ description: "Speech synthesized. The default `encoding: \"binary\"` returns raw audio bytes whose Content-Type follows the requested `format` (`audio/mpeg` for mp3, else `audio/wav`, `audio/opus`, `audio/aac`, `audio/flac`, `audio/pcm`); `encoding: \"base64\"` returns the JSON body.",
13239
+ schema: ttsBase64ResponseSchema,
13240
+ example: {
13241
+ audio: "SUQzBAAAAAAA...",
13242
+ format: "mp3",
13243
+ contentType: "audio/mpeg",
13244
+ saved: true,
13245
+ fabFileId: "664f1c2b9a1e4d0012ab34cd"
13246
+ },
13247
+ alsoReturns: [
13248
+ { contentType: "audio/mpeg" },
13249
+ { contentType: "audio/wav" },
13250
+ { contentType: "audio/opus" },
13251
+ { contentType: "audio/aac" },
13252
+ { contentType: "audio/flac" },
13253
+ { contentType: "audio/pcm" }
13254
+ ],
13255
+ headers: {
13256
+ "X-B4M-Tts-Provider": "The provider that produced the audio. Present only when a fallback happened.",
13257
+ "X-B4M-Tts-Provider-Fallback-From": "The originally requested provider that could not serve the request. Present only on a fallback.",
13258
+ "X-B4M-Audio-Saved": "Whether a browsable copy was saved to the file browser (\"true\"/\"false\").",
13259
+ "X-B4M-Audio-Fab-File-Id": "Id of the saved file. Present only when the copy was saved."
13260
+ }
13261
+ },
13262
+ 401: {
13263
+ description: "Missing/invalid credentials, or no provider has a usable key (`provider_not_configured`).",
13264
+ schema: ttsErrorResponseSchema
13265
+ },
13266
+ 413: {
13267
+ description: "The audio was generated (and billed) but is too large to return over this endpoint. Retrieve it from `fileUrl` when a browsable copy was saved.",
13268
+ schema: ttsResponseTooLargeSchema
13269
+ },
13270
+ 422: {
13271
+ description: "Request body failed validation, the text exceeds the provider character limit, the provider cannot produce the requested `format`, or the caller cannot afford the synthesis - the last of those is the only one tagged `errorCode: \"insufficient_credits\"`, so match on the classifier rather than the status to tell a billing failure from a bad request.",
13272
+ schema: ttsErrorResponseSchema
13273
+ },
13274
+ 429: {
13275
+ description: "The provider rate-limited the request.",
13276
+ schema: ttsErrorResponseSchema
13277
+ },
13278
+ 502: {
13279
+ description: "The provider failed to generate speech.",
13280
+ schema: ttsErrorResponseSchema
13281
+ }
13282
+ },
13283
+ emitsRateLimitHeaders: true,
13284
+ codeSample: {
13285
+ authToken: "b4m_live_<key>",
13286
+ streaming: false,
13287
+ body: {
13288
+ text: "Your password has been reset.",
13289
+ provider: "openai",
13290
+ voice: "alloy",
13291
+ encoding: "base64"
13292
+ }
13293
+ }
13294
+ });
13000
13295
  /**
13001
13296
  * Response details shared by the endpoints that return generated audio as raw
13002
13297
  * bytes (music, sound effects). Not a contract - just the pieces both of their
@@ -13033,8 +13328,179 @@ const generatedAudioBody = () => ({
13033
13328
  alsoReturns: GENERATED_AUDIO_CONTENT_TYPES.slice(1).map((contentType) => ({ contentType })),
13034
13329
  headers: GENERATED_AUDIO_SAVE_HEADERS
13035
13330
  });
13036
- ({ ...generatedAudioBody() });
13037
- ({ ...generatedAudioBody() });
13331
+ defineEndpoint({
13332
+ method: "post",
13333
+ path: "/api/ai/music",
13334
+ operationId: "generateMusic",
13335
+ summary: "Generate background music",
13336
+ description: "Generates an instrumental or vocal background-music track from a text prompt and returns the raw audio bytes. `lengthMs` (3000-120000, default 10000) is forced on the provider, so the generated track always matches the billed length; credits are reserved before generation and refunded if it fails. Generated audio is saved to the file browser by default (opt out via the saveGeneratedAudio preference); the outcome is reported via the `X-B4M-Audio-Saved` / `X-B4M-Audio-Fab-File-Id` / `X-B4M-Audio-File-Url` response headers - use `X-B4M-Audio-File-Url` to fetch the saved copy, since `GET /api/files/{id}` fails closed until moderation completes. Authenticate with an API key (`b4m_live_`) or a JWT.",
13337
+ tags: ["Audio"],
13338
+ auth: "apiKeyOrJwt",
13339
+ scopes: ["ai:generate"],
13340
+ request: musicRequestSchema,
13341
+ requestExample: {
13342
+ prompt: "calm lo-fi study beat with soft piano",
13343
+ lengthMs: 3e4,
13344
+ forceInstrumental: true
13345
+ },
13346
+ responses: {
13347
+ 200: {
13348
+ description: "Raw audio bytes; the Content-Type follows the requested `format` (mp3 by default).",
13349
+ ...generatedAudioBody()
13350
+ },
13351
+ 400: {
13352
+ description: "The billing user or organization could not be resolved.",
13353
+ schema: ApiErrorSchema
13354
+ },
13355
+ 422: {
13356
+ description: "Request body failed validation, or the caller cannot afford the track - the latter is tagged `errorCode: \"insufficient_credits\"` (the balance is short, or the org member credit cap is exhausted).",
13357
+ schema: InsufficientCreditsErrorSchema
13358
+ },
13359
+ 502: {
13360
+ description: "The provider failed to generate the track; reserved credits are refunded.",
13361
+ schema: ApiErrorSchema
13362
+ },
13363
+ 503: {
13364
+ description: "No provider API key is configured for this deployment.",
13365
+ schema: ApiErrorSchema
13366
+ }
13367
+ },
13368
+ emitsRateLimitHeaders: true,
13369
+ codeSample: {
13370
+ authToken: "b4m_live_<key>",
13371
+ streaming: false,
13372
+ body: {
13373
+ prompt: "calm lo-fi study beat with soft piano",
13374
+ lengthMs: 3e4,
13375
+ forceInstrumental: true
13376
+ }
13377
+ }
13378
+ });
13379
+ defineEndpoint({
13380
+ method: "post",
13381
+ path: "/api/ai/sound-effects",
13382
+ operationId: "generateSoundEffect",
13383
+ summary: "Generate a sound effect",
13384
+ description: "Generates a short sound effect from a text description and returns the raw audio bytes. Omitting `durationSeconds` lets the provider pick the length (and bills at its default); `promptInfluence` trades prompt fidelity (1) against variation (0). Credits are reserved before generation and refunded if it fails. Generated audio is saved to the file browser by default (opt out via the saveGeneratedAudio preference); the outcome is reported via the `X-B4M-Audio-Saved` / `X-B4M-Audio-Fab-File-Id` / `X-B4M-Audio-File-Url` response headers - use `X-B4M-Audio-File-Url` to fetch the saved copy, since `GET /api/files/{id}` fails closed until moderation completes. Authenticate with an API key (`b4m_live_`) or a JWT.",
13385
+ tags: ["Audio"],
13386
+ auth: "apiKeyOrJwt",
13387
+ scopes: ["ai:generate"],
13388
+ request: soundEffectsRequestSchema,
13389
+ requestExample: {
13390
+ text: "heavy wooden door creaking open",
13391
+ durationSeconds: 3,
13392
+ promptInfluence: .5
13393
+ },
13394
+ responses: {
13395
+ 200: {
13396
+ description: "Raw audio bytes; the Content-Type follows the requested `format` (mp3 by default).",
13397
+ ...generatedAudioBody()
13398
+ },
13399
+ 400: {
13400
+ description: "The billing user or organization could not be resolved.",
13401
+ schema: ApiErrorSchema
13402
+ },
13403
+ 422: {
13404
+ description: "Request body failed validation, or the caller cannot afford the effect - the latter is tagged `errorCode: \"insufficient_credits\"` (the balance is short, or the org member credit cap is exhausted).",
13405
+ schema: InsufficientCreditsErrorSchema
13406
+ },
13407
+ 502: {
13408
+ description: "The provider failed to generate the effect; reserved credits are refunded.",
13409
+ schema: ApiErrorSchema
13410
+ },
13411
+ 503: {
13412
+ description: "No provider API key is configured for this deployment.",
13413
+ schema: ApiErrorSchema
13414
+ }
13415
+ },
13416
+ emitsRateLimitHeaders: true,
13417
+ codeSample: {
13418
+ authToken: "b4m_live_<key>",
13419
+ streaming: false,
13420
+ body: {
13421
+ text: "heavy wooden door creaking open",
13422
+ durationSeconds: 3,
13423
+ promptInfluence: .5
13424
+ }
13425
+ }
13426
+ });
13427
+ defineEndpoint({
13428
+ method: "get",
13429
+ path: "/api/v1/me",
13430
+ operationId: "getMe",
13431
+ summary: "Get the authenticated caller",
13432
+ description: "Returns the authenticated caller: stable id, display name, plan tier, personal credit balance, and entitlement keys. The subject is always the credential holder - this endpoint accepts no user id, owner id, or impersonation parameter of any kind, so a key can only ever read its own owner. `credits.balance` is the caller's personal ledger; a call billed to an organization draws on a pool this number does not describe. Gate on `tier != \"free\"` for \"is this caller paying\" and on `subscription.price_id` for which product - the `basic`/`pro` rungs come from an internal plan ladder and do not track a plan's marketing name. Responses are never cacheable. Authenticate with an API key (`b4m_live_`) carrying `me:read`, or a JWT.",
13433
+ tags: ["Account"],
13434
+ auth: "apiKeyOrJwt",
13435
+ scopes: ["me:read"],
13436
+ emitsRateLimitHeaders: true,
13437
+ responses: {
13438
+ 200: {
13439
+ description: "The caller, their tier, their personal credit balance, and their entitlement keys.",
13440
+ schema: MeResponseSchema,
13441
+ example: {
13442
+ id: "507f1f77bcf86cd799439011",
13443
+ name: "Ada Lovelace",
13444
+ tier: "basic",
13445
+ subscription: {
13446
+ plan_name: "Professional",
13447
+ price_id: "price_123",
13448
+ interval: "monthly",
13449
+ current_period_ends_at: "2026-10-18T00:00:00.000Z"
13450
+ },
13451
+ credits: { balance: 31667 },
13452
+ entitlements: ["base"]
13453
+ }
13454
+ },
13455
+ 429: {
13456
+ description: "Per-user rate limit exceeded.",
13457
+ schema: ApiErrorSchema
13458
+ }
13459
+ },
13460
+ codeSample: {
13461
+ authToken: "b4m_live_<key>",
13462
+ streaming: false,
13463
+ body: {}
13464
+ }
13465
+ });
13466
+ /**
13467
+ * The fence language the model writes to place an inline map of search-result places in a reply.
13468
+ *
13469
+ * Same contract as SEARCH_RESULT_CARDS_LANGUAGE (searchResultCards.ts): `\w`-only and lowercase,
13470
+ * taught by WEB_SEARCH_MAP_PROMPT (@bike4mind/services), rendered by the reply renderer
13471
+ * (apps/client .../Session/PromptReplies.tsx), and rewritten to a plain list for every other
13472
+ * surface by `stripSearchResultCardFences`.
13473
+ */
13474
+ const LOCATION_MAP_LANGUAGE = "b4m_map";
13475
+ function googleMapsSearchUrl(name, placeId) {
13476
+ const params = new URLSearchParams({
13477
+ api: "1",
13478
+ query: name
13479
+ });
13480
+ if (placeId?.startsWith("ChIJ")) params.set("query_place_id", placeId);
13481
+ return `https://www.google.com/maps/search/?${params.toString()}`;
13482
+ }
13483
+ /**
13484
+ * The fence language the model writes to place image cards inline in a reply.
13485
+ *
13486
+ * Two families of consumer must agree on this constant:
13487
+ * - WEB_SEARCH_CARDS_PROMPT (@bike4mind/services) teaches the model to emit it, and the reply
13488
+ * renderer (apps/client .../Session/PromptReplies.tsx) intercepts it to render cards instead
13489
+ * of raw text.
13490
+ * - Every other surface that reads reply markdown for an export, download, copy, publish, or
13491
+ * Slack delivery must strip the fenced block entirely with `stripSearchResultCardFences`
13492
+ * below, rather than passing the model-authored card JSON through verbatim. See that
13493
+ * function's own doc comment for the current list of call sites.
13494
+ *
13495
+ * MUST contain only `\w` characters: both the renderer (`/language-(\w+)/`) and the notebook
13496
+ * curation extractor (```` /```(\w+)?/ ````) capture the language with `\w+`, so a hyphen would
13497
+ * silently truncate this and the cards would never render or be skipped.
13498
+ *
13499
+ * MUST already be lowercase: some consumers lowercase the language they capture before
13500
+ * comparing, so an uppercase-containing value would pass the `\w`-only rule above while silently
13501
+ * breaking that comparison.
13502
+ */
13503
+ const SEARCH_RESULT_CARDS_LANGUAGE = "b4m_cards";
13038
13504
  z$1.enum(["user", "convergence"]).optional().catch(void 0), z$1.string().optional();
13039
13505
  /**
13040
13506
  * Blessed, self-hosted artifact library script paths (root-relative).
@@ -13096,6 +13562,7 @@ const OPTIONAL_DEP_BLESSED_SCRIPT_PATHS = Object.values({
13096
13562
  }
13097
13563
  }).map((d) => d.path);
13098
13564
  [...REACT_BLESSED_SCRIPT_PATHS, ...OPTIONAL_DEP_BLESSED_SCRIPT_PATHS];
13565
+ [...OPENAI_IMAGE_MODELS, ...GEMINI_IMAGE_MODELS];
13099
13566
  const DashboardParamsSchema = z$1.object({
13100
13567
  dashboardDataSources: z$1.array(z$1.object({
13101
13568
  sourceName: z$1.string(),
@@ -13139,11 +13606,21 @@ OpenAIImageGenerationInput.extend({
13139
13606
  height: z$1.number().optional(),
13140
13607
  aspect_ratio: z$1.string().optional(),
13141
13608
  fabFileIds: z$1.array(z$1.string()).prefault([]),
13609
+ /**
13610
+ * Extra gpt-image "style anchor" images, as fabFile ids. They ride alongside the primary
13611
+ * input image (the first image-type entry in `fabFileIds`) rather than replacing it, and
13612
+ * OpenAI receives them in this order - which matters, because a mask always applies to the
13613
+ * first image in the array. fabFile ids rather than URLs so the existing access +
13614
+ * moderation gates (findAccessibleInIds, isImageServeable) still apply. Ignored by every
13615
+ * non-gpt-image provider. Repeated ids collapse to one anchor. See MAX_REFERENCE_IMAGES for
13616
+ * why the cap is 4 and not OpenAI's 16.
13617
+ */
13618
+ referenceImageFabFileIds: z$1.array(z$1.string()).max(4).optional(),
13142
13619
  tools: z$1.array(z$1.union([b4mLLMTools, z$1.string()])).optional(),
13143
13620
  safety_tolerance: BFLSafetyToleranceSchema,
13144
13621
  prompt_upsampling: z$1.boolean().optional(),
13145
13622
  seed: z$1.number().nullable().optional(),
13146
- output_format: z$1.enum(["jpeg", "png"]).nullable().optional(),
13623
+ output_format: ImageOutputFormatSchema.nullable().optional(),
13147
13624
  /** Resolved by the API route's prompt resolver. Defaults to 'fresh' for first-turn or sessions with no prior image. */
13148
13625
  intent: PromptIntentSchema.optional(),
13149
13626
  promptEnhancement: z$1.object({
@@ -13161,7 +13638,7 @@ OpenAIImageGenerationInput.extend({
13161
13638
  const GenerateImageToolCallSchema = OpenAIImageGenerationInput.extend({
13162
13639
  safety_tolerance: z$1.number().optional(),
13163
13640
  prompt_upsampling: z$1.boolean().optional(),
13164
- output_format: z$1.enum(["jpeg", "png"]).nullable().optional(),
13641
+ output_format: ImageOutputFormatSchema.nullable().optional(),
13165
13642
  seed: z$1.number().nullable().optional(),
13166
13643
  editModel: z$1.string().optional()
13167
13644
  }).omit({ prompt: true });
@@ -13198,15 +13675,19 @@ OpenAIImageGenerationInput.extend({
13198
13675
  organizationId: z$1.string().nullable().optional(),
13199
13676
  aspect_ratio: z$1.string().optional(),
13200
13677
  fabFileIds: z$1.array(z$1.string()).prefault([]),
13201
- image: z$1.string()
13678
+ /**
13679
+ * Extra gpt-image "style anchor" images, as fabFile ids. On this endpoint `fabFileIds` is
13680
+ * the inpainting mask, not an image input, so references need their own field: they are
13681
+ * appended after `image` (the edit source), and OpenAI applies the mask to the first entry
13682
+ * of that array - i.e. always to `image`, never to a reference. fabFile ids rather than URLs
13683
+ * so the existing access + moderation gates (findAccessibleInIds, isImageServeable) still
13684
+ * apply. Ignored by BFL and Gemini. Repeated ids collapse to one anchor. See
13685
+ * MAX_REFERENCE_IMAGES for why the cap is 4, not 16.
13686
+ */
13687
+ referenceImageFabFileIds: z$1.array(z$1.string()).max(4).optional(),
13688
+ image: z$1.string(),
13689
+ output_format: ImageOutputFormatSchema.nullable().optional()
13202
13690
  });
13203
- const isUnlimitedHistory = (historyCount) => historyCount === -1;
13204
- /**
13205
- * A usable number for callers that need one (page size, overflow math, telemetry). Unlimited
13206
- * history has no count, so it resolves to the default page size, which is what an unwindowed
13207
- * request has always actually fetched.
13208
- */
13209
- const resolveHistoryFetchLimit = (historyCount) => isUnlimitedHistory(historyCount) || historyCount == null ? 14 : historyCount;
13210
13691
  z$1.object({
13211
13692
  /** Notebook session ID */
13212
13693
  sessionId: z$1.string(),
@@ -13232,6 +13713,13 @@ z$1.object({
13232
13713
  message: z$1.string(),
13233
13714
  messageFileIds: z$1.array(z$1.string()).prefault([]),
13234
13715
  questId: z$1.string().optional(),
13716
+ /**
13717
+ * Correct-and-retry: the quest whose answer the user says was wrong. Produces a NEW quest
13718
+ * carrying the user's correction, rather than overwriting the flagged one the way `questId`
13719
+ * (retry) does - the original answer has to survive for the evaluation-pair export to read it.
13720
+ * Validated server-side against the resolved session before it is persisted.
13721
+ */
13722
+ correctsQuestId: z$1.string().optional(),
13235
13723
  /** Extra context messages to include in the conversation from external sources */
13236
13724
  extraContextMessages: z$1.array(z$1.object({
13237
13725
  role: z$1.enum([
@@ -13267,11 +13755,19 @@ z$1.object({
13267
13755
  * was the only switch for the offer and it also strips every authored prompt, so no caller could
13268
13756
  * have an arm that went unoffered AND kept the abstention licence.
13269
13757
  *
13270
- * Gates the three auto-add sites (the knowledge offer in resolveEnabledTools, the navigate_view
13758
+ * Gates the auto-add sites (the knowledge offer in resolveEnabledTools, the navigate_view
13271
13759
  * auto-add, the blog/skill gate), unioned with `Boolean(promptMode)` by
13272
13760
  * resolveSkipAutoOffers. A force-on, not an override: `false` under a promptMode still suppresses.
13273
13761
  * Withholding navigate_view also drops the viewRegistry system block, which only describes it.
13274
13762
  *
13763
+ * `buildSharedTools`' `offerOnlyNamedTools` (see sharedToolBuilder.ts) reads the same union for
13764
+ * the same reason: MCP tools merged past the `enabledTools` filter are withheld too, since the
13765
+ * caller never named them either. Unlike the auto-add sites, this one can still be reached per
13766
+ * tool - a caller with `session.enabledTools` (not the public `tools` field, which
13767
+ * `filterKnownTools` strips non-native ids from before this flag is even consulted) can name one
13768
+ * MCP tool by its namespaced `server__tool` id and keep it while every unnamed sibling is
13769
+ * withheld.
13770
+ *
13275
13771
  * Withholds the OFFER, not knowledge: `session.forceKnowledgeRetrieval` is untouched, and an
13276
13772
  * already-attached corpus is inlined rather than deferred to the tool. An arm that must see no
13277
13773
  * knowledge at all also needs a session with no attachments and forced retrieval off.
@@ -13713,6 +14209,18 @@ const ArtifactVersionMetaSchema = z$1.object({
13713
14209
  }),
13714
14210
  sha256Index: z$1.string()
13715
14211
  });
14212
+ /** One no-sign-in share link. `token` is the capability itself and is stripped from every
14213
+ * serialized response, so it is optional here: an owner-facing read carries the metadata
14214
+ * (id to revoke by, timestamps, per-link view count) with no token at all. `id` is the
14215
+ * subdocument `_id`, rendered as a string. */
14216
+ const ShareTokenEntrySchema = z$1.object({
14217
+ id: z$1.string().optional(),
14218
+ token: z$1.string().optional(),
14219
+ createdAt: z$1.date().optional(),
14220
+ revokedAt: z$1.date().nullish(),
14221
+ viewCount: z$1.int().nonnegative().prefault(0),
14222
+ lastViewedAt: z$1.date().nullish()
14223
+ });
13716
14224
  /** Reserved slugs - must include every tier URL token so a slug can't shadow routing. */
13717
14225
  const RESERVED_SLUGS = [
13718
14226
  "api",
@@ -13836,6 +14344,10 @@ z$1.object({
13836
14344
  shareToken: z$1.string().optional(),
13837
14345
  /** When `shareToken` was last minted/rotated; drives the owner-facing "link created" surface. */
13838
14346
  shareTokenUpdatedAt: z$1.date().nullish(),
14347
+ /** Every share link ever minted, revoked ones included. Mirrors the two fields above during
14348
+ * the rollout and becomes the source of truth once the backfill has run everywhere; `token`
14349
+ * is stripped from serialized responses, so an owner-facing read sees only the metadata. */
14350
+ shareTokens: z$1.array(ShareTokenEntrySchema).prefault([]),
13839
14351
  /** Collaboration gate: who (among viewers) may annotate. Orthogonal to
13840
14352
  * `visibility`. Defaults to `none` (read-only) until the owner opts in. */
13841
14353
  commentPolicy: CommentPolicySchema.prefault("none"),
@@ -13866,6 +14378,9 @@ z$1.object({
13866
14378
  declaredApiEndpoints: z$1.array(z$1.string()).prefault([]),
13867
14379
  /** Rendered body snapshot for reply/fabfile viewer pages (markdown or text). */
13868
14380
  renderedBody: z$1.string().optional(),
14381
+ /** Snapshot of the source reply's citables (reply source only), so a `b4m_map` fence in
14382
+ * `renderedBody` can still resolve its place ids after the source Quest is edited or deleted. */
14383
+ citables: z$1.array(CitableSourceSchema).optional(),
13869
14384
  publishedAt: z$1.date(),
13870
14385
  previousVersionMeta: ArtifactVersionMetaSchema.optional(),
13871
14386
  /** Full version history (oldest to newest); each entry's bytes are archived at
@@ -14119,56 +14634,71 @@ z$1.object({
14119
14634
  createdAt: z$1.union([z$1.string(), z$1.date()]).optional(),
14120
14635
  updatedAt: z$1.union([z$1.string(), z$1.date()]).optional()
14121
14636
  });
14637
+ ClaudeArtifactMimeTypes.RECHARTS, ClaudeArtifactMimeTypes.MERMAID, ClaudeArtifactMimeTypes.LATTICE, ClaudeArtifactMimeTypes.BLOG_DRAFT, ClaudeArtifactMimeTypes.CHESS;
14638
+ [...IMAGE_SIZE_CONSTRAINTS.DALL_E_2.sizes, ...IMAGE_SIZE_CONSTRAINTS.DALL_E_3.sizes];
14122
14639
  /**
14123
- * Length of `text` in Unicode CODE POINTS - the unit `IFabFileChunk.charLength` is stored in.
14124
- * Matches MongoDB's `$strLenCP`, which is what lets the char-length backfill
14125
- * (packages/scripts/datalake/backfill-chunk-char-length.ts) compute the same number server-side
14126
- * without reading chunk text out of the database. Deliberately NOT `text.length` (UTF-16 code
14127
- * units): the two differ on astral characters (surrogate pairs), and the write path and the
14128
- * backfill must agree exactly.
14640
+ * Zero-width space spliced into a defanged marker. It is invisible wherever the text is
14641
+ * rendered, so escaping never changes what the reader sees - only what the parser matches.
14129
14642
  */
14130
- const countCodePoints = (text) => {
14131
- let count = 0;
14132
- for (const _ch of text) count++;
14133
- return count;
14134
- };
14135
- function isGPTImageModel(model) {
14136
- if (!model) return false;
14137
- return OPENAI_IMAGE_MODELS.includes(model) || model.startsWith("gpt-image-");
14643
+ const ZERO_WIDTH_SPACE = "​";
14644
+ /**
14645
+ * Defangs think-marker-shaped substrings inside provider-authored reasoning text so they can
14646
+ * never be mistaken for the real control markers adapters wrap around that same text.
14647
+ *
14648
+ * A reasoning delta is provider output, not our control plane - a model can say `<think>` or
14649
+ * `</think>` as literal content (reasoning about the protocol itself, or a leading/trailing
14650
+ * `</think>` from a provider that already delimits its own monologue). Adapters wrap the whole
14651
+ * delta in real markers via plain string concatenation, so an unescaped literal is
14652
+ * indistinguishable from a genuine open/close once it lands in the same string. Call this on
14653
+ * every raw reasoning delta before it is concatenated with THINK_OPEN_TAG/THINK_CLOSE_TAG.
14654
+ */
14655
+ function escapeThinkMarkers(text) {
14656
+ if (!text) return text;
14657
+ return text.replace(/<(\/?)think>/g, `<${ZERO_WIDTH_SPACE}$1think>`);
14138
14658
  }
14139
- /** Returns true specifically for gpt-image-2 (including versioned snapshots like gpt-image-2-2026-04-21). */
14140
- function isGPTImage2Model(model) {
14141
- if (!model) return false;
14142
- return model === "gpt-image-2" || model.startsWith("gpt-image-2");
14659
+ /** One less than the longer marker's length: the most characters a real marker prefix can span. */
14660
+ const MAX_PARTIAL_MARKER_LENGTH = 7;
14661
+ /** Length of the longest suffix of `text` that is a proper prefix of either marker token. */
14662
+ function partialMarkerSuffixLength(text) {
14663
+ const max = Math.min(MAX_PARTIAL_MARKER_LENGTH, text.length);
14664
+ for (let len = max; len > 0; len--) {
14665
+ const suffix = text.slice(-len);
14666
+ if ("<think>".startsWith(suffix) || "</think>".startsWith(suffix)) return len;
14667
+ }
14668
+ return 0;
14143
14669
  }
14144
14670
  /**
14145
- * Whether a user can access a model. Access is any-of (mirrors the Q3b data-lake
14146
- * rule, `getAccessibleDataLakes`): a non-admin reaches the model via
14147
- * `allowedUserTags ∩ userTags` OR `allowedEntitlements ∩ entitlementKeys`.
14671
+ * Stateful counterpart to escapeThinkMarkers for text that arrives in streamed pieces.
14148
14672
  *
14149
- * `entitlementKeys` is optional - when omitted/empty the entitlement branch is
14150
- * inert, so a model with no `allowedEntitlements` behaves exactly as before
14151
- * (tag-only). This lets a tag-less subscriber reach an entitlement-gated model
14152
- * while leaving every existing tag-gated model unchanged (zero regression).
14673
+ * escapeThinkMarkers alone is only safe on a complete string: adapters call it once per
14674
+ * delta, but a provider is free to split a marker-shaped substring across two adjacent
14675
+ * deltas (e.g. 'wrote <th' then 'ink> tag'). Escaping each half independently leaves both
14676
+ * halves unescaped, and concatenating them reassembles a literal `<think>`/`</think>` that
14677
+ * is then indistinguishable from the real control marker wrapped around the same text.
14153
14678
  *
14154
- * Pure + zero-dependency (only types + the shared key normalizer), so it lives
14155
- * in `@bike4mind/common` as the SINGLE source of truth - imported by the core
14156
- * `@bike4mind/utils` re-export (server/services) AND the client
14157
- * `useAccessibleModels` hook, which previously kept a hand-rolled twin "to avoid
14158
- * AWS SDK imports". Common is browser-safe, so there is no longer any reason to
14159
- * duplicate the logic.
14160
- */
14161
- function isModelAccessible(model, userTags, isAdmin = false, entitlementKeys = []) {
14162
- if (!model.enabled) return false;
14163
- if (isAdmin) return true;
14164
- const normalizedUserTags = userTags.map((tag) => tag.toLowerCase());
14165
- const normalizedAllowedTags = (model.allowedUserTags ?? []).map((tag) => tag.toLowerCase());
14166
- if (normalizedUserTags.some((tag) => normalizedAllowedTags.includes(tag))) return true;
14167
- const normalizedKeys = entitlementKeys.map(normalizeEntitlementKey);
14168
- const normalizedAllowedEntitlements = (model.allowedEntitlements ?? []).map(normalizeEntitlementKey);
14169
- return normalizedKeys.some((key) => normalizedAllowedEntitlements.includes(key));
14679
+ * This holds back any trailing substring of the buffered text that could still extend into
14680
+ * a marker (up to `<think>`/`</think>`'s length minus one) until the next push resolves it
14681
+ * one way or the other, or flush() is called at the end of the reasoning span.
14682
+ */
14683
+ function createThinkMarkerEscaper() {
14684
+ let pending = "";
14685
+ return {
14686
+ push(chunk) {
14687
+ if (!chunk) return "";
14688
+ const combined = pending + chunk;
14689
+ const holdLength = partialMarkerSuffixLength(combined);
14690
+ const safeLength = combined.length - holdLength;
14691
+ const safe = combined.slice(0, safeLength);
14692
+ pending = combined.slice(safeLength);
14693
+ return escapeThinkMarkers(safe);
14694
+ },
14695
+ flush() {
14696
+ const remaining = pending;
14697
+ pending = "";
14698
+ return escapeThinkMarkers(remaining);
14699
+ }
14700
+ };
14170
14701
  }
14171
- [...OPENAI_IMAGE_MODELS, ...GEMINI_IMAGE_MODELS];
14172
14702
  /** Generation was cut off against the output-token ceiling. */
14173
14703
  const TRUNCATED_FINISH_REASON = "max_tokens";
14174
14704
  /**
@@ -14195,6 +14725,115 @@ function isEarlyStop(stopReason) {
14195
14725
  return !!stopReason && EARLY_STOP_FINISH_REASONS.has(stopReason);
14196
14726
  }
14197
14727
  /**
14728
+ * Signs the image URLs `web_search` writes into its tool output, so `/api/search-image` (the
14729
+ * same-origin proxy that fetches them server-side) can verify a URL actually came from our own
14730
+ * search-provider results, not from a hostile page's snippet text steering the model into writing
14731
+ * an attacker-controlled URL with exfiltrated data in the query string.
14732
+ *
14733
+ * The signature is a trailing `&b4mSig=<hex>` (or `?b4mSig=<hex>` with no prior query string)
14734
+ * appended by plain string concatenation - never by reparsing the URL through `URLSearchParams`,
14735
+ * which re-serializes every existing param (e.g. turning a literal space into `+`) and would send
14736
+ * a byte-different URL to a host whose own signature (an imgix/S3-presigned link) covers the exact
14737
+ * query string the provider returned. Anchoring the signature to the END of the string, and
14738
+ * requiring it to be the only occurrence, is what makes verification unambiguous: anything a
14739
+ * tamperer appends after signing - including a second `b4mSig` - changes what's left of the anchor
14740
+ * match, which changes the canonical text, which invalidates the signature. There is deliberately
14741
+ * no "read the first/last `b4mSig` param and ignore the rest" step, since that step is exactly
14742
+ * where an earlier version of this file's bypass lived (verify read the URL's first `b4mSig` via
14743
+ * `searchParams.get` while signing canonicalized by deleting *every* `b4mSig`, so a validly-signed
14744
+ * URL with a second, attacker-authored `b4mSig` appended still verified, and was then forwarded to
14745
+ * `safeFetch` with that attacker data still attached).
14746
+ *
14747
+ * The model is already told to copy an `Images:` line's URL verbatim, so no new instruction is
14748
+ * needed for the signature to survive.
14749
+ *
14750
+ * The signature has no expiry: a stored reply, citable, published page, or curation transcript
14751
+ * persists indefinitely, and nothing re-signs a URL on read, so a TTL would make every card
14752
+ * permanently break once it lapsed rather than bound anything meaningful. What actually bounds
14753
+ * a leaked signed URL's usefulness is the same thing that bounds the route otherwise: it's only
14754
+ * reachable at all behind `jwtOnly` auth (apps/client/pages/api/search-image.ts) and is capped by
14755
+ * a per-user rate limit there.
14756
+ */
14757
+ const SIGNATURE_PARAM = "b4mSig";
14758
+ const TRAILING_SIGNATURE_RE = new RegExp(`[?&]${SIGNATURE_PARAM}=([0-9a-f]{32})$`);
14759
+ const KNOWN_PLACEHOLDER_SECRETS = /* @__PURE__ */ new Set([
14760
+ "",
14761
+ "my-secret-placeholder-value",
14762
+ "not-configured"
14763
+ ]);
14764
+ /**
14765
+ * True for an empty or known-placeholder signing secret. Shared so any caller that would
14766
+ * otherwise sign-and-fail (e.g. web_search deciding whether to pay for image results at all)
14767
+ * uses the same definition `verifyImageUrlSignature` fails closed on, rather than a second one
14768
+ * that could drift out of sync.
14769
+ */
14770
+ function isPlaceholderImageSigningSecret(secret) {
14771
+ return KNOWN_PLACEHOLDER_SECRETS.has(secret ?? "");
14772
+ }
14773
+ function computeSignature(canonical, secret) {
14774
+ return createHmac("sha256", secret).update(canonical).digest("hex").slice(0, 32);
14775
+ }
14776
+ /**
14777
+ * Appends expiry + signature query params by string concatenation (never reparses or
14778
+ * re-serializes the existing query string - see the file-level comment). Returns the URL
14779
+ * unchanged if it fails to parse as a URL, or if it already carries a trailing signature.
14780
+ */
14781
+ function signImageUrl(rawUrl, secret) {
14782
+ try {
14783
+ new URL(rawUrl);
14784
+ } catch {
14785
+ return rawUrl;
14786
+ }
14787
+ if (TRAILING_SIGNATURE_RE.test(rawUrl)) return rawUrl;
14788
+ const separator = rawUrl.includes("?") ? "&" : "?";
14789
+ const signature = computeSignature(rawUrl, secret);
14790
+ return `${rawUrl}${separator}${SIGNATURE_PARAM}=${signature}`;
14791
+ }
14792
+ /**
14793
+ * promptMeta.functionCalls fields that must never reach a viewer who only holds a
14794
+ * "read this conversation" grant - a session share, a live subscription, a bug-report
14795
+ * egress to a third party (Slack/email), or a session clone made by a share holder
14796
+ * (b4m-core/services/src/sessionService/clone.ts, which copies quest.promptMeta wholesale
14797
+ * onto a session the sharee then OWNS - a share/subscribe grant must not become a durable
14798
+ * unredacted copy just because the copy has a new owner).
14799
+ *
14800
+ * `returnValue` is the verbatim output of a tool call the OWNER's turn made - up to 8000 chars
14801
+ * per call (see recordToolResult.ts) - and a tool can read the owner's private corpus, files, or
14802
+ * connected integrations. A share/subscribe grant authorizes reading the conversation the owner
14803
+ * had, not re-reading whatever the owner's tools touched on the owner's behalf. `error` is
14804
+ * currently unwritten but carries the same class of content on the failure path, so it is
14805
+ * redacted alongside it.
14806
+ *
14807
+ * This list is the single source of truth: add a field here and every response boundary that
14808
+ * routes through {@link redactFunctionCallsForViewer} inherits the redaction - PROVIDED the field
14809
+ * is a top-level, directly-owned-content field like these two. The redaction itself is a shallow
14810
+ * `delete` per field (see below), so a future owner-only value nested inside another field (e.g. a
14811
+ * private blob inside `parameters`) would NOT be caught by adding its name here; that shape needs
14812
+ * its own per-field handling, not just a new entry in this array.
14813
+ *
14814
+ * Adjacent unredacted shape, not yet a leak: `promptMeta.executionTracking.steps[].result`/
14815
+ * `.error` (PromptMetaZodSchema) are the same class of owner-only content but nothing writes them
14816
+ * today, so there is nothing to redact yet. A future writer landing there bypasses this list
14817
+ * entirely unless it is added here too.
14818
+ */
14819
+ const OWNER_ONLY_FUNCTION_CALL_FIELDS = ["returnValue", "error"];
14820
+ /**
14821
+ * Verbatim retrieved passage text on a citation chip (#3038). Owner-only for exactly the reason
14822
+ * `returnValue` is: it is a slice of a document the OWNER's retrieval read on the owner's behalf,
14823
+ * and a share/subscribe/clone grant authorizes reading the conversation, not re-reading the
14824
+ * owner's corpus through it. The sibling `chunkId` is deliberately kept - an opaque id is not
14825
+ * content, and `citables[].id` (the file id) is already unredacted beside it.
14826
+ *
14827
+ * `conflictsWith` (#3041) is kept for the same reason, recorded here so the next reader does not
14828
+ * re-derive it: it holds `fabFileId`s of other cited sources, so it adds a RELATIONSHIP between
14829
+ * chips the viewer can already see rather than a slice of the owner's corpus. That holds only while
14830
+ * the field stays ids - a future version carrying the conflicting SENTENCES (the detector has them:
14831
+ * InconsistencyEvidence.excerpt) would be `fullContext`'s class exactly and would have to join the
14832
+ * list below rather than ride along inside this one.
14833
+ */
14834
+ const OWNER_ONLY_CITABLE_METADATA_FIELDS = ["fullContext"];
14835
+ [...OWNER_ONLY_FUNCTION_CALL_FIELDS.map((field) => `promptMeta.functionCalls.${field}`), ...OWNER_ONLY_CITABLE_METADATA_FIELDS.map((field) => `promptMeta.citables.metadata.${field}`)];
14836
+ /**
14198
14837
  * Trigger-word validation, shared between client form and server agent
14199
14838
  * endpoints so the validation rules can't drift.
14200
14839
  *
@@ -14227,47 +14866,7 @@ z$1.array(triggerWordSchema).max(20, "Up to 20 trigger words allowed.").transfor
14227
14866
  }
14228
14867
  return out;
14229
14868
  });
14230
- /**
14231
- * Serve gate for uploaded FabFiles: hold-until-scanned, fail-closed on ALL mime
14232
- * types, not just images. A file is serveable only once moderation has run to
14233
- * completion on it, REGARDLESS of its declared `mimeType`.
14234
- *
14235
- * Why gate non-images too: `mimeType` is client-declared at upload time and is only
14236
- * corrected by the S3 scan ~1-2s later (see `moderateUploadedFile`'s byte-sniffing). If this
14237
- * gate special-cased "non-images always serveable" based on that same untrusted declared
14238
- * mimeType, a file uploaded as `application/pdf` but actually a PNG (or vice versa) would be
14239
- * served during that window before the sniff/scan ever runs. Gating on `moderationStatus`
14240
- * alone closes that window for every file, image or not.
14241
- *
14242
- * `moderationStatus` semantics:
14243
- * - 'clean' -> serveable
14244
- * - 'pending' | 'scanning' -> NOT serveable (not yet through the scan)
14245
- * - 'blocked' -> NOT serveable (confirmed block / unscannable format)
14246
- * - null | undefined -> NOT serveable (fail-closed; legacy rows are
14247
- * backfilled to 'clean', see backfill-fabfile-moderation-status.ts)
14248
- *
14249
- * Non-image files (PDFs, docs, text, ...) are NOT scanned by Rekognition, but they still
14250
- * pass through `moderateUploadedFile`/`objectCreated`, which resolves them to 'clean'
14251
- * immediately (no image bytes to hold on), so the hold is brief (one S3 event round trip),
14252
- * not an indefinite block.
14253
- */
14254
- function isImageServeable(f) {
14255
- return f.moderationStatus === "clean";
14256
- }
14257
- /**
14258
- * Is this mime type an image? `image/svg+xml` counts, since vision models receive it
14259
- * as an image.
14260
- *
14261
- * The repo has ~50 inline `startsWith('image/')` checks and they disagree on the edges
14262
- * (case, null handling). Only the ones on the attachment pipeline - composer upload,
14263
- * chat context assembly, attachment capability warnings - have been converted here.
14264
- * Icon pickers, avatar validation and resize eligibility still carry their own copies:
14265
- * same question, unrelated subsystems, and folding them in would have made this a
14266
- * repo-wide diff.
14267
- */
14268
- function isImageAttachment(mimeType) {
14269
- return typeof mimeType === "string" && mimeType.toLowerCase().startsWith("image/");
14270
- }
14869
+ IMAGE_SIZE_CONSTRAINTS.BFL.minWidth, IMAGE_SIZE_CONSTRAINTS.BFL.maxWidth;
14271
14870
  Array.from(new Set([
14272
14871
  {
14273
14872
  id: "opti.root",
@@ -15174,71 +15773,10 @@ Array.from(new Set([
15174
15773
  const [, top] = v.target.split("/");
15175
15774
  return `/${top}`;
15176
15775
  })));
15177
- function getHeader(headers, name) {
15178
- if (!headers || typeof headers !== "object") return null;
15179
- if (typeof headers.get === "function") {
15180
- const value = headers.get(name);
15181
- return typeof value === "string" ? value : null;
15182
- }
15183
- const value = headers[name] ?? headers[name.toLowerCase()];
15184
- return typeof value === "string" ? value : null;
15185
- }
15186
- function parseCount(value) {
15187
- if (value === null) return null;
15188
- const trimmed = value.trim();
15189
- if (!trimmed) return null;
15190
- const parsed = Number(trimmed);
15191
- return Number.isFinite(parsed) ? parsed : null;
15192
- }
15193
- const UNIT_MS = {
15194
- ms: 1,
15195
- s: 1e3,
15196
- m: 6e4,
15197
- h: 36e5
15198
- };
15199
- const DURATION_PART = /(\d+(?:\.\d+)?)(ms|h|m|s)/g;
15200
- /**
15201
- * Parse a Go-style duration ("6ms", "0s", "1m30s", "1h2m3s") to milliseconds.
15202
- *
15203
- * Exported for its own tests: it is the part of this module that can be wrong in a way the
15204
- * numbers still look plausible.
15205
- */
15206
- function parseDurationMs(value) {
15207
- if (typeof value !== "string") return null;
15208
- const trimmed = value.trim();
15209
- if (!trimmed) return null;
15210
- DURATION_PART.lastIndex = 0;
15211
- let total = 0;
15212
- let matched = 0;
15213
- let consumed = 0;
15214
- for (const part of trimmed.matchAll(DURATION_PART)) {
15215
- total += Number(part[1]) * UNIT_MS[part[2]];
15216
- consumed += part[0].length;
15217
- matched += 1;
15218
- }
15219
- if (matched === 0 || consumed !== trimmed.length) return null;
15220
- return total;
15221
- }
15222
- /** Read both rate-limit dimensions off a provider response. */
15223
- function parseEmbeddingRateLimitHeaders(headers) {
15224
- return {
15225
- limitTokens: parseCount(getHeader(headers, "x-ratelimit-limit-tokens")),
15226
- limitRequests: parseCount(getHeader(headers, "x-ratelimit-limit-requests")),
15227
- remainingTokens: parseCount(getHeader(headers, "x-ratelimit-remaining-tokens")),
15228
- remainingRequests: parseCount(getHeader(headers, "x-ratelimit-remaining-requests")),
15229
- resetTokensMs: parseDurationMs(getHeader(headers, "x-ratelimit-reset-tokens")),
15230
- resetRequestsMs: parseDurationMs(getHeader(headers, "x-ratelimit-reset-requests"))
15231
- };
15232
- }
15233
- /** True when the provider reported at least one usable ceiling. */
15234
- function hasUsableLimits(snapshot) {
15235
- return snapshot.limitTokens !== null || snapshot.limitRequests !== null;
15236
- }
15237
15776
  dayjs.extend(utc);
15238
15777
  dayjs.extend(timezone);
15239
15778
  dayjs.extend(relativeTime);
15240
15779
  dayjs.extend(localizedFormat);
15241
- var dayjsConfig_default = dayjs;
15242
15780
  /**
15243
15781
  * Default retryable errors for LLM API calls
15244
15782
  */
@@ -15526,6 +16064,225 @@ function getEnvironmentName(configApiConfig) {
15526
16064
  if (/^https?:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/i.test(endpoint.url)) return "Local Dev";
15527
16065
  return "Self-Hosted";
15528
16066
  }
16067
+ //#endregion
16068
+ //#region src/utils/validateSessionId.ts
16069
+ /**
16070
+ * Session and resume ids arrive from the environment (`B4M_SESSION_ID`,
16071
+ * `B4M_RESUME_ID`) and are used as filesystem path components by the session
16072
+ * store and the debug logger. Restrict them to a strict charset (which still
16073
+ * covers UUIDs) so a hostile launcher cannot traverse out of the base dir via
16074
+ * e.g. `B4M_SESSION_ID=../config`.
16075
+ */
16076
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
16077
+ function isValidSessionId(value) {
16078
+ return SESSION_ID_PATTERN.test(value);
16079
+ }
16080
+ //#endregion
16081
+ //#region src/config/toolSafety.ts
16082
+ /**
16083
+ * Tool safety categories determine when permission is required
16084
+ */
16085
+ const ToolCategorySchema = z$1.enum([
16086
+ "auto_approve",
16087
+ "prompt_always",
16088
+ "prompt_default"
16089
+ ]);
16090
+ z$1.object({
16091
+ categories: z$1.record(z$1.string(), ToolCategorySchema),
16092
+ trustedTools: z$1.array(z$1.string())
16093
+ });
16094
+ /**
16095
+ * Default tool categories
16096
+ *
16097
+ * Categories:
16098
+ * - auto_approve: Safe tools that don't need permission (math, search, datetime)
16099
+ * - prompt_always: Dangerous tools that ALWAYS need permission, cannot be trusted (file edits, shell commands)
16100
+ * - prompt_default: Tools that prompt by default but users can trust them (file reads, searches)
16101
+ */
16102
+ const DEFAULT_TOOL_CATEGORIES = {
16103
+ math_evaluate: "auto_approve",
16104
+ current_datetime: "auto_approve",
16105
+ dice_roll: "auto_approve",
16106
+ prompt_enhancement: "auto_approve",
16107
+ find_definition: "auto_approve",
16108
+ ask_user_question: "auto_approve",
16109
+ weather_info: "prompt_default",
16110
+ edit_file: "prompt_always",
16111
+ edit_local_file: "prompt_always",
16112
+ create_file: "prompt_always",
16113
+ delete_file: "prompt_always",
16114
+ shell_execute: "prompt_always",
16115
+ bash_execute: "prompt_always",
16116
+ write_shell_stdin: "prompt_always",
16117
+ kill_background_shell: "prompt_always",
16118
+ git_commit: "prompt_always",
16119
+ git_push: "prompt_always",
16120
+ skill: "prompt_always",
16121
+ web_search: "prompt_default",
16122
+ check_shell_output: "prompt_default",
16123
+ list_background_shells: "prompt_default",
16124
+ web_fetch: "prompt_default",
16125
+ deep_research: "prompt_default",
16126
+ file_read: "prompt_default",
16127
+ grep_search: "prompt_default",
16128
+ glob_files: "prompt_default",
16129
+ get_file_tree: "prompt_default",
16130
+ get_file_structure: "prompt_default",
16131
+ git_status: "prompt_default",
16132
+ git_diff: "prompt_default",
16133
+ git_log: "prompt_default",
16134
+ git_branch: "prompt_default"
16135
+ };
16136
+ /**
16137
+ * Get the category for a tool
16138
+ * Returns 'prompt_default' if tool is not in the default categories
16139
+ */
16140
+ function getToolCategory(toolName, customCategories) {
16141
+ if (toolName.startsWith("agent_hook:") || toolName.startsWith("skill_hook:")) return "prompt_always";
16142
+ if (customCategories && toolName in customCategories) return customCategories[toolName];
16143
+ if (toolName in DEFAULT_TOOL_CATEGORIES) return DEFAULT_TOOL_CATEGORIES[toolName];
16144
+ if (toolName.startsWith("mcp__")) return "prompt_always";
16145
+ return "prompt_default";
16146
+ }
16147
+ /**
16148
+ * Check if a tool can be trusted (not prompt_always)
16149
+ */
16150
+ function canTrustTool(toolName, customCategories) {
16151
+ return getToolCategory(toolName, customCategories) !== "prompt_always";
16152
+ }
16153
+ /**
16154
+ * Check if a tool is read-only (safe for parallel execution).
16155
+ * Write tools (prompt_always category) must always be sequential.
16156
+ *
16157
+ * @param toolName - Name of the tool to check
16158
+ * @param customCategories - Optional custom category overrides
16159
+ * @returns true if the tool is read-only, false if it's a write tool
16160
+ */
16161
+ function isReadOnlyTool(toolName, customCategories) {
16162
+ return getToolCategory(toolName, customCategories) !== "prompt_always";
16163
+ }
16164
+ const PROJECT_CONTEXT_FILES = [
16165
+ "CLAUDE.local.md",
16166
+ "CLAUDE.md",
16167
+ "AGENTS.md",
16168
+ "AI.local.md",
16169
+ "AI.md",
16170
+ "INSTRUCTIONS.md"
16171
+ ];
16172
+ const GLOBAL_CONTEXT_FILES = ["AI.local.md", "AI.md"];
16173
+ /**
16174
+ * Format file size for display
16175
+ */
16176
+ function formatFileSize(bytes) {
16177
+ if (bytes < 1024) return `${bytes}B`;
16178
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)}KB`;
16179
+ return `${(bytes / 1048576).toFixed(1)}MB`;
16180
+ }
16181
+ /**
16182
+ * Try to read a context file from a directory
16183
+ *
16184
+ * Security: Only reads regular files (not directories or symlinks) within the specified directory.
16185
+ * Files must be under 100KB to prevent abuse. Symlinks are rejected to prevent reading
16186
+ * files outside the intended directory.
16187
+ *
16188
+ * @param dir - The directory to read from (must be a controlled location)
16189
+ * @param filename - The filename to read (must not contain path separators)
16190
+ * @param source - Whether this is a 'global' or 'project' context file
16191
+ * @returns The file result, an error object, or null if file doesn't exist
16192
+ */
16193
+ function tryReadContextFile(dir, filename, source) {
16194
+ const filePath = path$1.join(dir, filename);
16195
+ try {
16196
+ const stats = fs$1.lstatSync(filePath);
16197
+ if (stats.isDirectory()) return null;
16198
+ if (stats.isSymbolicLink()) return { error: `${source === "global" ? "Global" : "Project"} ${filename} is a symlink (not allowed for security)` };
16199
+ if (stats.size > 102400) return { error: `${source === "global" ? "Global" : "Project"} ${filename} exceeds 100KB limit (${formatFileSize(stats.size)})` };
16200
+ return {
16201
+ filename,
16202
+ content: fs$1.readFileSync(filePath, "utf-8"),
16203
+ source,
16204
+ path: filePath
16205
+ };
16206
+ } catch (err) {
16207
+ if (err.code === "ENOENT") return null;
16208
+ if (err.code === "EACCES") return { error: `Cannot read ${source} ${filename}: permission denied` };
16209
+ return { error: `Cannot read ${source} ${filename}: ${err instanceof Error ? err.message : "Unknown error"}` };
16210
+ }
16211
+ }
16212
+ /**
16213
+ * Find the first context file in a directory from a list of candidates
16214
+ */
16215
+ function findContextFile(dir, candidates, source) {
16216
+ for (const filename of candidates) {
16217
+ const result = tryReadContextFile(dir, filename, source);
16218
+ if (result === null) continue;
16219
+ if ("error" in result) return {
16220
+ result: null,
16221
+ error: result.error
16222
+ };
16223
+ return {
16224
+ result,
16225
+ error: null
16226
+ };
16227
+ }
16228
+ return {
16229
+ result: null,
16230
+ error: null
16231
+ };
16232
+ }
16233
+ /**
16234
+ * Merge global and project context into a single string
16235
+ */
16236
+ function mergeContextContent(global, project) {
16237
+ if (global && project) return `${global.content}\n\n---\n\n${project.content}`;
16238
+ if (global) return global.content;
16239
+ if (project) return project.content;
16240
+ return "";
16241
+ }
16242
+ /**
16243
+ * Load context files from global and project directories
16244
+ *
16245
+ * Global files are loaded from ~/.bike4mind/
16246
+ * Project files are loaded from `projectDir`. When it is null the project layer
16247
+ * is SKIPPED entirely (not defaulted to cwd) - the folder-trust gate passes null
16248
+ * for an untrusted project so its repo context file is never loaded.
16249
+ *
16250
+ * Returns the first matching file from each layer based on priority order
16251
+ */
16252
+ async function loadContextFiles(projectDir) {
16253
+ const errors = [];
16254
+ const globalDir = path$1.join(homedir$1(), ".bike4mind");
16255
+ const [globalResult, projectResult] = await Promise.all([Promise.resolve(findContextFile(globalDir, GLOBAL_CONTEXT_FILES, "global")), Promise.resolve(projectDir ? findContextFile(projectDir, PROJECT_CONTEXT_FILES, "project") : {
16256
+ result: null,
16257
+ error: null
16258
+ })]);
16259
+ if (globalResult.error) errors.push(globalResult.error);
16260
+ if (projectResult.error) errors.push(projectResult.error);
16261
+ const mergedContent = mergeContextContent(globalResult.result, projectResult.result);
16262
+ return {
16263
+ globalContext: globalResult.result,
16264
+ projectContext: projectResult.result,
16265
+ mergedContent,
16266
+ errors
16267
+ };
16268
+ }
16269
+ /**
16270
+ * Extract "# Compact Instructions" or "## Compact Instructions" section from CLAUDE.md content
16271
+ *
16272
+ * This section provides project-specific instructions for how conversations should be
16273
+ * summarized when compacting context.
16274
+ *
16275
+ * @param contextContent - The merged context content from CLAUDE.md files
16276
+ * @returns The extracted instructions content, or undefined if not found
16277
+ */
16278
+ function extractCompactInstructions(contextContent) {
16279
+ const match = contextContent.match(/^#{1,2}\s*Compact\s*Instructions\s*$/im);
16280
+ if (!match || match.index === void 0) return;
16281
+ const startIndex = match.index + match[0].length;
16282
+ const remainingContent = contextContent.slice(startIndex);
16283
+ const endIndex = remainingContent.match(/^#{1,2}\s+\S/m)?.index ?? remainingContent.length;
16284
+ return remainingContent.slice(0, endIndex).trim() || void 0;
16285
+ }
15529
16286
  const logger = class Logger {
15530
16287
  static {
15531
16288
  this.instance = null;
@@ -15544,9 +16301,10 @@ const logger = class Logger {
15544
16301
  * Initialize the logger with a session ID
15545
16302
  */
15546
16303
  async initialize(sessionId) {
16304
+ if (!isValidSessionId(sessionId)) throw new Error(`Invalid session id "${sessionId}": must match ${SESSION_ID_PATTERN.source}`);
15547
16305
  this.sessionId = sessionId;
15548
16306
  const debugDir = path.join(os.homedir(), ".bike4mind", "debug");
15549
- await fs$1.mkdir(debugDir, { recursive: true });
16307
+ await fs.mkdir(debugDir, { recursive: true });
15550
16308
  this.logFilePath = path.join(debugDir, `${sessionId}.txt`);
15551
16309
  await this.writeToFile("INFO", "=== CLI SESSION START ===");
15552
16310
  }
@@ -15598,7 +16356,7 @@ const logger = class Logger {
15598
16356
  if (!this.fileLoggingEnabled || !this.logFilePath) return;
15599
16357
  try {
15600
16358
  const logEntry = `[${(/* @__PURE__ */ new Date()).toISOString().replace("T", " ").substring(0, 19)}] [${level}] ${message}\n`;
15601
- await fs$1.appendFile(this.logFilePath, logEntry, "utf-8");
16359
+ await fs.appendFile(this.logFilePath, logEntry, "utf-8");
15602
16360
  } catch (error) {
15603
16361
  console.error("File logging failed:", error);
15604
16362
  }
@@ -15715,11 +16473,11 @@ const logger = class Logger {
15715
16473
  if (!this.fileLoggingEnabled) return;
15716
16474
  try {
15717
16475
  const debugDir = path.join(os.homedir(), ".bike4mind", "debug");
15718
- const files = await fs$1.readdir(debugDir);
16476
+ const files = await fs.readdir(debugDir);
15719
16477
  const thirtyDaysAgo = Date.now() - 2592e6;
15720
16478
  for (const file of files) {
15721
16479
  const filePath = path.join(debugDir, file);
15722
- if ((await fs$1.stat(filePath)).mtime.getTime() < thirtyDaysAgo) await fs$1.unlink(filePath);
16480
+ if ((await fs.stat(filePath)).mtime.getTime() < thirtyDaysAgo) await fs.unlink(filePath);
15723
16481
  }
15724
16482
  } catch (error) {
15725
16483
  console.error("Failed to cleanup old logs:", error);
@@ -15943,6 +16701,7 @@ const CliConfigSchema = z$1.object({
15943
16701
  hearth: z$1.boolean().optional()
15944
16702
  }).catchall(z$1.boolean())).optional().prefault({}),
15945
16703
  trustedTools: z$1.array(z$1.string()).optional().prefault([]),
16704
+ trustedProjects: z$1.array(z$1.string()).optional().prefault([]),
15946
16705
  sandbox: SandboxConfigSchema.optional(),
15947
16706
  additionalDirectories: z$1.array(z$1.string()).optional().prefault([]),
15948
16707
  fallbackModels: z$1.array(z$1.string()).optional()
@@ -16051,6 +16810,7 @@ const DEFAULT_CONFIG = {
16051
16810
  config: {}
16052
16811
  },
16053
16812
  trustedTools: [],
16813
+ trustedProjects: [],
16054
16814
  additionalDirectories: []
16055
16815
  };
16056
16816
  /**
@@ -16071,6 +16831,19 @@ function findProjectConfigDir(startDir = process.cwd()) {
16071
16831
  return process.cwd();
16072
16832
  }
16073
16833
  /**
16834
+ * Canonicalize a path via realpath, returning null if it doesn't exist or can't
16835
+ * be resolved. Used so a symlinked or relative project root is compared against
16836
+ * the trust set by its real location, and a resolve failure fails safe
16837
+ * (untrusted) rather than crashing the launch.
16838
+ */
16839
+ async function safeRealpath(p) {
16840
+ try {
16841
+ return await promises.realpath(p);
16842
+ } catch {
16843
+ return null;
16844
+ }
16845
+ }
16846
+ /**
16074
16847
  * Load project config from .bike4mind/config.json
16075
16848
  * Returns null if file doesn't exist (this is normal - config.json is optional)
16076
16849
  */
@@ -16178,32 +16951,89 @@ function mergeMcpServers(...serverArrays) {
16178
16951
  for (const servers of serverArrays) if (servers) for (const server of servers) serverMap.set(server.name, server);
16179
16952
  return Array.from(serverMap.values());
16180
16953
  }
16954
+ /** Sandbox modes ordered from weakest to strongest posture. */
16955
+ const SANDBOX_MODE_RANK = {
16956
+ disabled: 0,
16957
+ "auto-allow": 1,
16958
+ permissions: 2
16959
+ };
16960
+ function intersectStrings(a, b) {
16961
+ const set = new Set(a);
16962
+ return b.filter((x) => set.has(x));
16963
+ }
16964
+ function unionStrings(a, b) {
16965
+ return Array.from(/* @__PURE__ */ new Set([...a, ...b]));
16966
+ }
16967
+ /** True when `target` is `root` itself or nested under it (no `..` escape). */
16968
+ function isWithin(root, target) {
16969
+ const rel = path.relative(root, target);
16970
+ return rel === "" || !rel.startsWith("..") && !path.isAbsolute(rel);
16971
+ }
16181
16972
  /**
16182
- * Deep-merge sandbox configs. Later values override earlier ones.
16183
- * Arrays (allowedReadPaths, deniedPaths, excludedCommands) are replaced, not concatenated.
16973
+ * Merge a repo-sourced sandbox override onto the user's base posture so it can
16974
+ * only ever TIGHTEN it, never loosen it. A repo layer may enable the sandbox,
16975
+ * raise the mode (disabled < auto-allow < permissions) but never select
16976
+ * auto-allow itself, add denied paths, narrow read paths / domains / excluded
16977
+ * commands, turn the network filter on, and force allowUnsandboxedCommands off.
16978
+ * Every loosening value is ignored with a warning. `platform` is ignored.
16184
16979
  */
16185
- function mergeSandboxConfig(base, override) {
16186
- const resolved = base ?? DEFAULT_SANDBOX_CONFIG;
16187
- if (!override) return resolved;
16188
- return {
16189
- enabled: override.enabled ?? resolved.enabled,
16190
- mode: override.mode ?? resolved.mode,
16191
- filesystem: {
16192
- ...resolved.filesystem,
16193
- ...override.filesystem ?? {}
16194
- },
16195
- network: {
16196
- ...resolved.network,
16197
- ...override.network ?? {}
16198
- },
16199
- excludedCommands: override.excludedCommands ?? resolved.excludedCommands,
16200
- allowUnsandboxedCommands: override.allowUnsandboxedCommands ?? resolved.allowUnsandboxedCommands,
16201
- platform: override.platform ?? resolved.platform
16980
+ function tightenSandbox(base, repo) {
16981
+ const b = base ?? DEFAULT_SANDBOX_CONFIG;
16982
+ if (!repo) return b;
16983
+ const warn = (what) => logger.warn(`Ignoring repo sandbox override that would loosen posture: ${what}`);
16984
+ const result = {
16985
+ ...b,
16986
+ filesystem: { ...b.filesystem },
16987
+ network: { ...b.network }
16202
16988
  };
16989
+ if (repo.enabled === true) result.enabled = true;
16990
+ else if (repo.enabled === false && b.enabled) warn("sandbox.enabled=false");
16991
+ if (repo.mode !== void 0) if (repo.mode === "auto-allow") warn("sandbox.mode=auto-allow");
16992
+ else if (SANDBOX_MODE_RANK[repo.mode] >= SANDBOX_MODE_RANK[b.mode]) result.mode = repo.mode;
16993
+ else warn(`sandbox.mode=${repo.mode} weaker than ${b.mode}`);
16994
+ if (repo.filesystem) {
16995
+ const fsr = repo.filesystem;
16996
+ if (fsr.deniedPaths) result.filesystem.deniedPaths = unionStrings(result.filesystem.deniedPaths, fsr.deniedPaths);
16997
+ if (fsr.allowedReadPaths) result.filesystem.allowedReadPaths = intersectStrings(result.filesystem.allowedReadPaths, fsr.allowedReadPaths);
16998
+ if (fsr.writeOnlyToWorkingDir === true) result.filesystem.writeOnlyToWorkingDir = true;
16999
+ else if (fsr.writeOnlyToWorkingDir === false && b.filesystem.writeOnlyToWorkingDir) warn("filesystem.writeOnlyToWorkingDir=false");
17000
+ }
17001
+ if (repo.network) {
17002
+ const nr = repo.network;
17003
+ if (nr.enabled === true) result.network.enabled = true;
17004
+ else if (nr.enabled === false && b.network.enabled) warn("network.enabled=false");
17005
+ if (nr.allowedDomains) result.network.allowedDomains = intersectStrings(result.network.allowedDomains, nr.allowedDomains);
17006
+ }
17007
+ if (repo.excludedCommands) result.excludedCommands = intersectStrings(result.excludedCommands, repo.excludedCommands);
17008
+ if (repo.allowUnsandboxedCommands === false) result.allowUnsandboxedCommands = false;
17009
+ else if (repo.allowUnsandboxedCommands === true && !b.allowUnsandboxedCommands) warn("allowUnsandboxedCommands=true");
17010
+ if (result.enabled && result.mode === "disabled") result.mode = "permissions";
17011
+ if (!result.enabled) result.mode = "disabled";
17012
+ return result;
16203
17013
  }
16204
17014
  /**
16205
- * Merge configs with priority: global -> project -> local
16206
- * Each layer overrides the previous one
17015
+ * Fold repo-sourced MCP servers under the global set so a repo entry can never
17016
+ * REPLACE a same-named global server. Global definitions always win; repo
17017
+ * entries only fill names global does not already use. Later repo layers win
17018
+ * over earlier ones for names global does not define.
17019
+ */
17020
+ function mergeMcpServersGlobalWins(global, ...repoLayers) {
17021
+ const byName = /* @__PURE__ */ new Map();
17022
+ for (const layer of repoLayers) {
17023
+ if (!layer) continue;
17024
+ for (const server of layer) byName.set(server.name, server);
17025
+ }
17026
+ if (global) for (const server of global) byName.set(server.name, server);
17027
+ return Array.from(byName.values());
17028
+ }
17029
+ /**
17030
+ * Merge configs with priority: global -> project -> local, with the invariant
17031
+ * that repo layers may only TIGHTEN the user's global security posture, never
17032
+ * loosen it. Sandbox goes through `tightenSandbox`; the repo `trustedTools`
17033
+ * union is filtered to tools that can actually be trusted and aren't globally
17034
+ * disabled; and any tool a repo tries to re-enable while it is disabled is
17035
+ * dropped. `mcpServers` is NOT merged here - callers fold repo servers in via
17036
+ * `mergeMcpServersGlobalWins` where all repo layers are visible together.
16207
17037
  */
16208
17038
  function mergeConfigs(global, project, local) {
16209
17039
  const merged = { ...global };
@@ -16222,8 +17052,7 @@ function mergeConfigs(global, project, local) {
16222
17052
  ...project.tools.config
16223
17053
  }
16224
17054
  };
16225
- if (project.mcpServers) merged.mcpServers = mergeMcpServers(merged.mcpServers, project.mcpServers);
16226
- if (project.sandbox) merged.sandbox = mergeSandboxConfig(merged.sandbox, project.sandbox);
17055
+ if (project.sandbox) merged.sandbox = tightenSandbox(merged.sandbox, project.sandbox);
16227
17056
  }
16228
17057
  if (local) {
16229
17058
  if (local.trustedTools) {
@@ -16238,9 +17067,15 @@ function mergeConfigs(global, project, local) {
16238
17067
  ...merged.preferences,
16239
17068
  ...local.preferences
16240
17069
  };
16241
- if (local.mcpServers) merged.mcpServers = mergeMcpServers(merged.mcpServers, local.mcpServers);
16242
- if (local.sandbox) merged.sandbox = mergeSandboxConfig(merged.sandbox, local.sandbox);
17070
+ if (local.sandbox) merged.sandbox = tightenSandbox(merged.sandbox, local.sandbox);
16243
17071
  }
17072
+ const disabledSet = new Set(merged.tools.disabled);
17073
+ merged.tools = {
17074
+ ...merged.tools,
17075
+ enabled: merged.tools.enabled.filter((t) => !disabledSet.has(t))
17076
+ };
17077
+ const globalTrusted = new Set(global.trustedTools || []);
17078
+ merged.trustedTools = (merged.trustedTools || []).filter((t) => globalTrusted.has(t) || canTrustTool(t) && !disabledSet.has(t));
16244
17079
  return merged;
16245
17080
  }
16246
17081
  /**
@@ -16270,6 +17105,12 @@ var ConfigStore = class {
16270
17105
  constructor(configPath) {
16271
17106
  this.config = null;
16272
17107
  this.projectConfigDir = null;
17108
+ this.globalConfig = null;
17109
+ this.projectRealPath = null;
17110
+ this.projectTrusted = false;
17111
+ this.rawProjectConfig = null;
17112
+ this.rawProjectLocalConfig = null;
17113
+ this.rawMcpJsonServers = null;
16273
17114
  this.configPath = configPath || path.join(homedir(), ".bike4mind", "config.json");
16274
17115
  }
16275
17116
  /**
@@ -16336,35 +17177,33 @@ var ConfigStore = class {
16336
17177
  trustedTools: validated.trustedTools || []
16337
17178
  };
16338
17179
  } catch (error) {
16339
- if (error.code === "ENOENT") globalConfig = { ...DEFAULT_CONFIG };
17180
+ if (error.code === "ENOENT") globalConfig = structuredClone(DEFAULT_CONFIG);
16340
17181
  else if (error instanceof z$1.ZodError) {
16341
17182
  console.error("Global config validation error:", error.issues);
16342
17183
  console.error("Using default configuration");
16343
- globalConfig = { ...DEFAULT_CONFIG };
17184
+ globalConfig = structuredClone(DEFAULT_CONFIG);
16344
17185
  } else throw error;
16345
17186
  }
16346
- let projectConfig = null;
16347
- let projectLocalConfig = null;
16348
- let mcpJsonServers = null;
17187
+ this.globalConfig = globalConfig;
17188
+ this.projectConfigDir = null;
17189
+ this.projectRealPath = null;
17190
+ this.projectTrusted = false;
17191
+ this.rawProjectConfig = null;
17192
+ this.rawProjectLocalConfig = null;
17193
+ this.rawMcpJsonServers = null;
16349
17194
  if (process.env.B4M_NO_PROJECT_CONFIG !== "1") {
16350
17195
  this.projectConfigDir = findProjectConfigDir();
16351
17196
  if (this.projectConfigDir) {
16352
- projectConfig = await loadProjectConfig(this.projectConfigDir);
16353
- projectLocalConfig = await loadProjectLocalConfig(this.projectConfigDir);
16354
- mcpJsonServers = await loadMcpJsonConfig(this.projectConfigDir);
16355
- if (projectConfig) logger.debug(`📁 Project config loaded from: ${this.projectConfigDir}/.bike4mind/`);
16356
- if (mcpJsonServers && mcpJsonServers.length > 0) logger.debug(`📁 Project MCP config loaded from: ${this.projectConfigDir}/.mcp.json`);
17197
+ this.projectRealPath = await safeRealpath(this.projectConfigDir) ?? this.projectConfigDir;
17198
+ this.projectTrusted = (globalConfig.trustedProjects || []).includes(this.projectRealPath);
17199
+ if (this.projectTrusted) {
17200
+ const loaded = await this.loadProjectLayers();
17201
+ if (loaded.hasConfig) logger.debug(`📁 Project config loaded from: ${this.projectConfigDir}/.bike4mind/`);
17202
+ if (loaded.mcpCount > 0) logger.debug(`📁 Project MCP config loaded from: ${this.projectConfigDir}/.mcp.json`);
17203
+ }
16357
17204
  }
16358
- } else this.projectConfigDir = null;
16359
- const mergedConfig = mergeConfigs(globalConfig, projectConfig, projectLocalConfig);
16360
- if (mcpJsonServers && mcpJsonServers.length > 0) mergedConfig.mcpServers = mergeMcpServers(mcpJsonServers, mergedConfig.mcpServers);
16361
- const mcpConfigFile = process.env.B4M_MCP_CONFIG_FILE;
16362
- if (mcpConfigFile) {
16363
- const injected = await loadMcpConfigFile(mcpConfigFile);
16364
- if (process.env.B4M_STRICT_MCP_CONFIG === "1") mergedConfig.mcpServers = injected ?? [];
16365
- else if (injected) mergedConfig.mcpServers = mergeMcpServers(mergedConfig.mcpServers, injected);
16366
17205
  }
16367
- this.config = mergedConfig;
17206
+ this.config = await this.computeMerged();
16368
17207
  return this.config;
16369
17208
  } catch (error) {
16370
17209
  if (error.code === "ENOENT") return this.reset();
@@ -16378,6 +17217,116 @@ var ConfigStore = class {
16378
17217
  }
16379
17218
  }
16380
17219
  /**
17220
+ * Load the raw repo config layers for the current project root. Called only
17221
+ * for a trusted root; stores them on `this` so trust changes can re-merge
17222
+ * without re-reading the global config file.
17223
+ */
17224
+ async loadProjectLayers() {
17225
+ if (!this.projectConfigDir) return {
17226
+ hasConfig: false,
17227
+ mcpCount: 0
17228
+ };
17229
+ this.rawProjectConfig = await loadProjectConfig(this.projectConfigDir);
17230
+ this.rawProjectLocalConfig = await loadProjectLocalConfig(this.projectConfigDir);
17231
+ this.rawMcpJsonServers = await loadMcpJsonConfig(this.projectConfigDir);
17232
+ return {
17233
+ hasConfig: !!this.rawProjectConfig,
17234
+ mcpCount: this.rawMcpJsonServers?.length ?? 0
17235
+ };
17236
+ }
17237
+ /**
17238
+ * Build the merged effective config from the global layer plus the raw repo
17239
+ * layers - but only when the project is trusted, so an untrusted root
17240
+ * contributes nothing. Repo MCP servers are folded in global-wins (a repo
17241
+ * name can never replace a global server); an explicit `--mcp-config` is host-
17242
+ * injected (not repo-sourced) and keeps its override-by-name / strict scope.
17243
+ */
17244
+ async computeMerged() {
17245
+ const global = this.globalConfig;
17246
+ const project = this.projectTrusted ? this.rawProjectConfig : null;
17247
+ const local = this.projectTrusted ? this.rawProjectLocalConfig : null;
17248
+ const mcpJson = this.projectTrusted ? this.rawMcpJsonServers : null;
17249
+ const merged = mergeConfigs(global, project, local);
17250
+ merged.mcpServers = mergeMcpServersGlobalWins(global.mcpServers, mcpJson, project?.mcpServers, local?.mcpServers);
17251
+ const mcpConfigFile = process.env.B4M_MCP_CONFIG_FILE;
17252
+ if (mcpConfigFile) {
17253
+ const injected = await loadMcpConfigFile(mcpConfigFile);
17254
+ if (process.env.B4M_STRICT_MCP_CONFIG === "1") merged.mcpServers = injected ?? [];
17255
+ else if (injected) merged.mcpServers = mergeMcpServers(merged.mcpServers, injected);
17256
+ }
17257
+ return merged;
17258
+ }
17259
+ /** Whether the current project root is trusted (folder-trust gate). */
17260
+ isProjectTrusted() {
17261
+ return this.projectTrusted;
17262
+ }
17263
+ /** Canonicalized project root discovered this session, or null. */
17264
+ getProjectRealPath() {
17265
+ return this.projectRealPath;
17266
+ }
17267
+ /** The realpath'd roots the user has explicitly trusted. */
17268
+ getTrustedProjects() {
17269
+ return this.globalConfig?.trustedProjects ? [...this.globalConfig.trustedProjects] : [];
17270
+ }
17271
+ /**
17272
+ * Whether the current project root ships any repo-committed b4m files that the
17273
+ * trust gate governs. Used to decide whether the startup trust prompt is even
17274
+ * worth showing (nothing to gate = no prompt).
17275
+ */
17276
+ projectHasB4mFiles() {
17277
+ const root = this.projectConfigDir;
17278
+ if (!root) return false;
17279
+ return [
17280
+ [".bike4mind", "config.json"],
17281
+ [".bike4mind", "local.json"],
17282
+ [".bike4mind", "agents"],
17283
+ [".bike4mind", "commands"],
17284
+ [".mcp.json"],
17285
+ [".claude", "agents"],
17286
+ [".claude", "skills"],
17287
+ [".claude", "commands"],
17288
+ ...PROJECT_CONTEXT_FILES.map((name) => [name])
17289
+ ].some((parts) => existsSync(path.join(root, ...parts)));
17290
+ }
17291
+ /**
17292
+ * Trust a project root (default: the current one). Persists the realpath'd
17293
+ * root to the global `trustedProjects` and, when it's the current root, loads
17294
+ * its repo layers and re-merges so they take effect for this session.
17295
+ */
17296
+ async trustProject(root) {
17297
+ await this.load();
17298
+ const target = root ? await safeRealpath(root) : this.projectRealPath;
17299
+ if (!target) return false;
17300
+ const g = this.globalConfig;
17301
+ if (!g.trustedProjects) g.trustedProjects = [];
17302
+ if (!g.trustedProjects.includes(target)) g.trustedProjects.push(target);
17303
+ if (target === this.projectRealPath && this.projectConfigDir && process.env.B4M_NO_PROJECT_CONFIG !== "1") {
17304
+ this.projectTrusted = true;
17305
+ await this.loadProjectLayers();
17306
+ }
17307
+ await this.save();
17308
+ return true;
17309
+ }
17310
+ /**
17311
+ * Revoke trust for a project root (default: the current one). Next launch
17312
+ * re-prompts and repo layers stay inert until re-trusted. Revoking the current
17313
+ * root drops its raw layers so nothing repo-sourced survives in this session.
17314
+ */
17315
+ async untrustProject(root) {
17316
+ await this.load();
17317
+ const target = root ? await safeRealpath(root) : this.projectRealPath;
17318
+ if (!target) return;
17319
+ const g = this.globalConfig;
17320
+ g.trustedProjects = (g.trustedProjects || []).filter((p) => p !== target);
17321
+ if (target === this.projectRealPath) {
17322
+ this.projectTrusted = false;
17323
+ this.rawProjectConfig = null;
17324
+ this.rawProjectLocalConfig = null;
17325
+ this.rawMcpJsonServers = null;
17326
+ }
17327
+ await this.save();
17328
+ }
17329
+ /**
16381
17330
  * Read the features map straight from the global config file, bypassing the
16382
17331
  * in-memory cache. save() merges over this so concurrent writers (the
16383
17332
  * interactive session vs `b4m plugin add`) don't clobber each other's keys.
@@ -16412,47 +17361,52 @@ var ConfigStore = class {
16412
17361
  /**
16413
17362
  * Save configuration to disk
16414
17363
  */
16415
- async save(config) {
17364
+ async save(config, opts) {
16416
17365
  await this.init();
17366
+ if (!this.globalConfig) await this.load();
17367
+ const global = this.globalConfig;
16417
17368
  if (config) {
16418
- const existingConfig = await this.load();
16419
- this.config = {
16420
- ...existingConfig,
16421
- ...config,
16422
- auth: config.auth !== void 0 ? config.auth : existingConfig.auth,
17369
+ const { mcpServers, trustedTools, additionalDirectories, trustedProjects, tools, sandbox, ...rest } = config;
17370
+ this.globalConfig = {
17371
+ ...global,
17372
+ ...rest,
17373
+ auth: "auth" in config ? config.auth : global.auth,
16423
17374
  preferences: {
16424
- ...existingConfig.preferences,
17375
+ ...global.preferences,
16425
17376
  ...config.preferences || {}
16426
17377
  },
16427
- tools: {
16428
- ...existingConfig.tools,
16429
- ...config.tools || {}
16430
- },
16431
17378
  toolApiKeys: {
16432
- ...existingConfig.toolApiKeys,
17379
+ ...global.toolApiKeys,
16433
17380
  ...config.toolApiKeys || {}
16434
17381
  },
16435
- features: this.mergeFeatures(await this.readDiskFeatures(), existingConfig.features, config.features ?? existingConfig.features)
17382
+ features: opts?.clearFeatures ? {} : this.mergeFeatures(await this.readDiskFeatures(), global.features, config.features ?? global.features)
16436
17383
  };
16437
- }
16438
- if (!this.config) throw new Error("No configuration to save");
17384
+ } else this.globalConfig = {
17385
+ ...global,
17386
+ features: opts?.clearFeatures ? {} : await this.readDiskFeatures()
17387
+ };
16439
17388
  try {
16440
- await promises.writeFile(this.configPath, JSON.stringify(this.config, null, 2), "utf-8");
17389
+ await promises.writeFile(this.configPath, JSON.stringify(this.globalConfig, null, 2), "utf-8");
16441
17390
  await promises.chmod(this.configPath, 384);
16442
17391
  } catch (error) {
16443
17392
  console.error("Failed to save config:", error);
16444
17393
  throw error;
16445
17394
  }
17395
+ this.config = await this.computeMerged();
16446
17396
  }
16447
17397
  /**
16448
17398
  * Reset configuration to defaults
16449
17399
  */
16450
17400
  async reset() {
16451
- this.config = {
16452
- ...DEFAULT_CONFIG,
17401
+ this.globalConfig = {
17402
+ ...structuredClone(DEFAULT_CONFIG),
16453
17403
  userId: v4()
16454
17404
  };
16455
- await this.save();
17405
+ this.projectTrusted = false;
17406
+ this.rawProjectConfig = null;
17407
+ this.rawProjectLocalConfig = null;
17408
+ this.rawMcpJsonServers = null;
17409
+ await this.save(void 0, { clearFeatures: true });
16456
17410
  return this.config;
16457
17411
  }
16458
17412
  /**
@@ -16468,56 +17422,71 @@ var ConfigStore = class {
16468
17422
  await this.save(updates);
16469
17423
  }
16470
17424
  /**
17425
+ * Persist a sandbox config to the GLOBAL layer (the `/sandbox` handlers' path).
17426
+ * Sandbox is excluded from the generic `save()` allowlist so it flows only
17427
+ * through here - a merged-config save() can never launder a repo-tightened
17428
+ * sandbox into the user's global default.
17429
+ */
17430
+ async saveSandboxConfig(sandbox) {
17431
+ await this.load();
17432
+ this.globalConfig.sandbox = structuredClone(sandbox);
17433
+ await this.save();
17434
+ }
17435
+ /**
16471
17436
  * Add MCP server configuration
16472
17437
  */
16473
17438
  async addMcpServer(server) {
16474
- const config = await this.load();
16475
- config.mcpServers = config.mcpServers.filter((s) => s.name !== server.name);
16476
- config.mcpServers.push(server);
16477
- await this.save(config);
17439
+ await this.load();
17440
+ const g = this.globalConfig;
17441
+ g.mcpServers = g.mcpServers.filter((s) => s.name !== server.name);
17442
+ g.mcpServers.push(server);
17443
+ await this.save();
16478
17444
  }
16479
17445
  /**
16480
17446
  * Remove MCP server configuration
16481
17447
  */
16482
17448
  async removeMcpServer(name) {
16483
- const config = await this.load();
16484
- config.mcpServers = config.mcpServers.filter((s) => s.name !== name);
16485
- await this.save(config);
17449
+ await this.load();
17450
+ const g = this.globalConfig;
17451
+ g.mcpServers = g.mcpServers.filter((s) => s.name !== name);
17452
+ await this.save();
16486
17453
  }
16487
17454
  /**
16488
17455
  * Enable/disable MCP server
16489
17456
  */
16490
17457
  async toggleMcpServer(name, enabled) {
16491
- const config = await this.load();
16492
- const server = config.mcpServers.find((s) => s.name === name);
17458
+ await this.load();
17459
+ const server = this.globalConfig.mcpServers.find((s) => s.name === name);
16493
17460
  if (server) {
16494
17461
  server.enabled = enabled;
16495
- await this.save(config);
17462
+ await this.save();
16496
17463
  }
16497
17464
  }
16498
17465
  /**
16499
17466
  * Add a tool to trusted tools list
16500
17467
  */
16501
17468
  async trustTool(toolName) {
16502
- const config = await this.load();
16503
- if (!config.trustedTools) config.trustedTools = [];
16504
- if (!config.trustedTools.includes(toolName)) {
16505
- config.trustedTools.push(toolName);
16506
- await this.save(config);
17469
+ await this.load();
17470
+ const g = this.globalConfig;
17471
+ if (!g.trustedTools) g.trustedTools = [];
17472
+ if (!g.trustedTools.includes(toolName)) {
17473
+ g.trustedTools.push(toolName);
17474
+ await this.save();
16507
17475
  }
16508
17476
  }
16509
17477
  /**
16510
17478
  * Remove a tool from trusted tools list
16511
17479
  */
16512
17480
  async untrustTool(toolName) {
16513
- const config = await this.load();
16514
- if (config.trustedTools) {
16515
- config.trustedTools = config.trustedTools.filter((t) => t !== toolName);
16516
- await this.save(config);
17481
+ await this.load();
17482
+ const g = this.globalConfig;
17483
+ if (g.trustedTools) {
17484
+ g.trustedTools = g.trustedTools.filter((t) => t !== toolName);
17485
+ await this.save();
16517
17486
  }
16518
17487
  }
16519
17488
  /**
16520
- * Get list of trusted tools
17489
+ * Get list of trusted tools (merged effective view)
16521
17490
  */
16522
17491
  async getTrustedTools() {
16523
17492
  return (await this.load()).trustedTools || [];
@@ -16526,9 +17495,9 @@ var ConfigStore = class {
16526
17495
  * Clear all trusted tools
16527
17496
  */
16528
17497
  async clearTrustedTools() {
16529
- const config = await this.load();
16530
- config.trustedTools = [];
16531
- await this.save(config);
17498
+ await this.load();
17499
+ this.globalConfig.trustedTools = [];
17500
+ await this.save();
16532
17501
  }
16533
17502
  /**
16534
17503
  * Get authentication tokens
@@ -16540,17 +17509,17 @@ var ConfigStore = class {
16540
17509
  * Set authentication tokens
16541
17510
  */
16542
17511
  async setAuthTokens(tokens) {
16543
- const config = await this.load();
16544
- config.auth = tokens;
16545
- await this.save(config);
17512
+ await this.load();
17513
+ this.globalConfig.auth = tokens;
17514
+ await this.save();
16546
17515
  }
16547
17516
  /**
16548
17517
  * Clear authentication tokens (logout)
16549
17518
  */
16550
17519
  async clearAuthTokens() {
16551
- const config = await this.load();
16552
- config.auth = void 0;
16553
- await this.save(config);
17520
+ await this.load();
17521
+ this.globalConfig.auth = void 0;
17522
+ await this.save();
16554
17523
  }
16555
17524
  /**
16556
17525
  * Check if user is authenticated
@@ -16571,10 +17540,9 @@ var ConfigStore = class {
16571
17540
  * Pass null to reset to the build-time default service.
16572
17541
  */
16573
17542
  async setCustomApiUrl(url) {
16574
- const config = await this.load();
16575
- if (url === null) config.apiConfig = void 0;
16576
- else config.apiConfig = { customUrl: url };
16577
- await this.save(config);
17543
+ await this.load();
17544
+ this.globalConfig.apiConfig = url === null ? void 0 : { customUrl: url };
17545
+ await this.save();
16578
17546
  }
16579
17547
  /**
16580
17548
  * Switch the active API environment, caching auth tokens per-environment so
@@ -16591,8 +17559,9 @@ var ConfigStore = class {
16591
17559
  * preserve the previous `auth` and defeat the per-env swap.
16592
17560
  */
16593
17561
  async switchApiEnvironment(target) {
16594
- const config = await this.load();
16595
- const prevKey = normalizeEnvKey(config.apiConfig?.customUrl || getDefaultApiUrl());
17562
+ await this.load();
17563
+ const g = this.globalConfig;
17564
+ const prevKey = normalizeEnvKey(g.apiConfig?.customUrl || getDefaultApiUrl());
16596
17565
  let newUrl;
16597
17566
  let newApiConfig;
16598
17567
  if (target === "prod") {
@@ -16611,16 +17580,15 @@ var ConfigStore = class {
16611
17580
  url: newUrl,
16612
17581
  envName,
16613
17582
  changed: false,
16614
- authenticated: hasValidAuth(config.auth)
17583
+ authenticated: hasValidAuth(g.auth)
16615
17584
  };
16616
- const authByEnv = { ...config.authByEnv || {} };
16617
- if (config.auth) authByEnv[prevKey] = config.auth;
17585
+ const authByEnv = { ...g.authByEnv || {} };
17586
+ if (g.auth) authByEnv[prevKey] = g.auth;
16618
17587
  else delete authByEnv[prevKey];
16619
17588
  const restored = authByEnv[newKey];
16620
- config.apiConfig = newApiConfig;
16621
- config.authByEnv = authByEnv;
16622
- config.auth = restored;
16623
- config.features = await this.readDiskFeatures();
17589
+ g.apiConfig = newApiConfig;
17590
+ g.authByEnv = authByEnv;
17591
+ g.auth = restored;
16624
17592
  await this.save();
16625
17593
  return {
16626
17594
  url: newUrl,
@@ -16708,40 +17676,48 @@ var ConfigStore = class {
16708
17676
  * Persists to global config
16709
17677
  */
16710
17678
  async addDirectory(dirPath) {
16711
- const config = await this.load();
16712
- if (!config.additionalDirectories) config.additionalDirectories = [];
17679
+ await this.load();
17680
+ const g = this.globalConfig;
17681
+ if (!g.additionalDirectories) g.additionalDirectories = [];
16713
17682
  const resolvedPath = path.resolve(dirPath);
16714
- if (!config.additionalDirectories.includes(resolvedPath)) {
16715
- config.additionalDirectories.push(resolvedPath);
16716
- await this.save(config);
17683
+ if (!g.additionalDirectories.includes(resolvedPath)) {
17684
+ g.additionalDirectories.push(resolvedPath);
17685
+ await this.save();
16717
17686
  }
16718
17687
  }
16719
17688
  /**
16720
17689
  * Remove a directory from the allowed directories list
16721
17690
  */
16722
17691
  async removeDirectory(dirPath) {
16723
- const config = await this.load();
16724
- if (config.additionalDirectories) {
17692
+ await this.load();
17693
+ const g = this.globalConfig;
17694
+ if (g.additionalDirectories) {
16725
17695
  const resolvedPath = path.resolve(dirPath);
16726
- config.additionalDirectories = config.additionalDirectories.filter((d) => path.resolve(d) !== resolvedPath);
16727
- await this.save(config);
17696
+ g.additionalDirectories = g.additionalDirectories.filter((d) => path.resolve(d) !== resolvedPath);
17697
+ await this.save();
16728
17698
  }
16729
17699
  }
16730
17700
  /**
16731
- * Get all additional directories (merged from global + project configs)
16732
- * Returns resolved absolute paths
17701
+ * Get all additional directories (global config + trusted-project config).
17702
+ * Returns resolved absolute paths. Project-declared directories are included
17703
+ * ONLY when the project is trusted, and each must resolve inside the project
17704
+ * root (a repo cannot widen file access beyond its own tree).
16733
17705
  */
16734
17706
  async getAdditionalDirectories() {
16735
- const config = await this.load();
17707
+ await this.load();
17708
+ const g = this.globalConfig;
16736
17709
  const dirs = /* @__PURE__ */ new Set();
16737
- if (config.additionalDirectories) for (const dir of config.additionalDirectories) dirs.add(path.resolve(dir));
16738
- const projectConfig = await this.loadRawProjectConfig();
16739
- if (projectConfig?.additionalDirectories) {
16740
- const projectRoot = this.projectConfigDir || process.cwd();
16741
- for (const dir of projectConfig.additionalDirectories) dirs.add(path.resolve(projectRoot, dir));
17710
+ if (g.additionalDirectories) for (const dir of g.additionalDirectories) dirs.add(path.resolve(dir));
17711
+ if (this.projectTrusted && this.rawProjectConfig?.additionalDirectories) {
17712
+ const projectRoot = this.projectRealPath || this.projectConfigDir;
17713
+ if (projectRoot) for (const dir of this.rawProjectConfig.additionalDirectories) {
17714
+ const real = await safeRealpath(path.resolve(projectRoot, dir));
17715
+ if (real && isWithin(projectRoot, real)) dirs.add(real);
17716
+ else logger.warn(`Ignoring project additionalDirectory outside project root: ${dir}`);
17717
+ }
16742
17718
  }
16743
17719
  return Array.from(dirs);
16744
17720
  }
16745
17721
  };
16746
17722
  //#endregion
16747
- export { SupportedFabFileMimeTypes as $, actorKindSchema as $t, HTTPError as A, isModelDeprecated as At, OPENAI_GPT_IMAGE_1_IMAGE_SIZES as B, parseEmbeddingRateLimitHeaders as Bt, DEFAULT_MUSIC_MODEL_ID as C, isGPTImage2Model as Ct, FIXED_TEMPERATURE_MODELS as D, isImageServeable as Dt, FIELD_GROUP_OF as E, isImageAttachment as Et, MODEL_INFO_FIELD_GROUP_OF as F, isUnlimitedHistory as Ft, PermissionDeniedError as G, toModelInfo as Gt, OllamaEmbeddingModel as H, resolveHistoryFetchLimit as Ht, McpServerName as I, isUserInitiatedAbort as It, REFUSAL_FALLBACK_MODELS as J, usdToCreditsStochastic as Jt, REASONING_EFFORT_INCOMPATIBLE_WITH_TOOLS_MODELS as K, toModelRecord as Kt, ModelBackend as L, isZodError as Lt, IMAGE_SIZE_CONSTRAINTS as M, isRenderableModelType as Mt, ImageModels as N, isRetryableError as Nt, FORMAT_PROMPT_TEMPLATE as O, isMediaModelType as Ot, InternalServerError as P, isSupportedFabFileMimeType as Pt, SpeechToTextModels as Q, actorKindMarker as Qt, NO_TEMPERATURE_MODELS as R, mapMimeTypeToArtifactType as Rt, CorruptedFileError as S, isFieldGroup as St, DEGENERATE_FINISH_REASON as T, isGeminiModelId as Tt, OpenAIEmbeddingModel as U, secureParameters as Ut, OPENAI_GPT_IMAGE_2_IMAGE_SIZES as V, reservationOutputTokens as Vt, PROMPT_TEXT_MAX as W, settingsMap as Wt, REVIEW_GATE_STATUS_VALUES as X, ACTOR_COLOR_SLOTS as Xt, RESPONSES_API_TOOL_MODELS as Y, withRetry as Yt, SUBQUEST_STATUS_VALUES as Z, actorColorIndex as Zt, BadRequestError as _, hasUsableLimits as _t, getCreditsUrl as a, VideoModels as at, CREDIT_DEDUCT_TRANSACTION_TYPES as b, isChunkStalledFile as bt, requireApiUrl as c, applyModelPriceCatalog as ct, AGENT_QUEST_MANIFEST as d, dayjsConfig_default as dt, selfClaimedActorKindSchema as en, TTS_MAX_INPUT_CHARS as et, AGENT_QUEST_MCP_URI as f, defaultEmbeddingModelForEnv as ft, BFL_SAFETY_TOLERANCE as g, hasKeylessCloudEmbedder as gt, BEDROCK_NO_PROMPT_CACHING_MODELS as h, getRetryAfterMs as ht, LOCAL_DEV_URL as i, parseRateLimitHeaders as in, VIDEO_SIZE_CONSTRAINTS as it, HttpStatus as j, isPlaceholderApiKey as jt, ForbiddenError as k, isModelAccessible as kt, resolveApiEndpoint as l, calculateRetryDelay as lt, ApiKeyType as m, getQuestErrorCode as mt, logger as n, extractSnippetMeta as nn, UnauthorizedError as nt, getEnvironmentName as o, VoyageAIEmbeddingModel as ot, ARTIFACT_ATTRS_PATTERN as p, getMcpProviderMetadata as pt, REASONING_SUPPORTED_MODELS as q, usdToCredits as qt, ApiEndpointUnconfiguredError as r, isNearLimit as rn, UnprocessableEntityError as rt, parseApiUrl as s, WORK_ITEM_STATUSES as st, ConfigStore as t, buildRateLimitLogEntry as tn, TooManyRequestsError as tt, AGENT_QUEST_ID as u, countCodePoints as ut, BedrockEmbeddingModel as v, isAudioMimeType as vt, DEFAULT_UNKNOWN_CONTEXT_WINDOW as w, isGPTImageModel as wt, ChatModels as x, isEarlyStop as xt, CONTEXT_WINDOW_SAFETY_BUFFER_TOKENS as y, isChunkRebuildPending as yt, NotFoundError as z, obfuscateApiKey as zt };
17723
+ export { withRetry as $, NotFoundError as A, WORK_ITEM_STATUSES as B, CREDIT_DEDUCT_TRANSACTION_TYPES as C, MODEL_INFO_FIELD_GROUP_OF as D, LOCATION_MAP_LANGUAGE as E, REVIEW_GATE_STATUS_VALUES as F, googleMapsSearchUrl as G, escapeThinkMarkers as H, SEARCH_RESULT_CARDS_LANGUAGE as I, isRetryableError as J, isEarlyStop as K, SUBQUEST_STATUS_VALUES as L, OpenAIEmbeddingModel as M, PROMPT_TEXT_MAX as N, McpServerName as O, PermissionDeniedError as P, signImageUrl as Q, SupportedFabFileMimeTypes as R, CONTEXT_WINDOW_SAFETY_BUFFER_TOKENS as S, DEGENERATE_FINISH_REASON as T, getMcpProviderMetadata as U, createThinkMarkerEscaper as V, getQuestErrorCode as W, obfuscateApiKey as X, isUserInitiatedAbort as Y, secureParameters as Z, AGENT_QUEST_ID as _, canTrustTool as a, ApiKeyType as b, SESSION_ID_PATTERN as c, LOCAL_DEV_URL as d, ACTOR_COLOR_SLOTS as et, getCreditsUrl as f, resolveApiEndpoint as g, requireApiUrl as h, loadContextFiles as i, selfClaimedActorKindSchema as it, OllamaEmbeddingModel as j, ModelBackend as k, isValidSessionId as l, parseApiUrl as m, logger as n, actorKindMarker as nt, getToolCategory as o, getEnvironmentName as p, isPlaceholderImageSigningSecret as q, extractCompactInstructions as r, actorKindSchema as rt, isReadOnlyTool as s, ConfigStore as t, actorColorIndex as tt, ApiEndpointUnconfiguredError as u, AGENT_QUEST_MANIFEST as v, ChatModels as w, BedrockEmbeddingModel as x, AGENT_QUEST_MCP_URI as y, VoyageAIEmbeddingModel as z };