@bike4mind/cli 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/dist/{AgentHistoryStore-CrRb8cMt.mjs → AgentHistoryStore-kT9eNMRO.mjs} +2007 -1122
  2. package/dist/{ApiClient-EUTyn5yu.mjs → ApiClient-BOWpVvTq.mjs} +2 -2
  3. package/dist/{ConfigStore-DHNgFdOu.mjs → ConfigStore-DdHHCH2t.mjs} +1435 -248
  4. package/dist/{SandboxOrchestrator-BFPVpmB5.mjs → SandboxOrchestrator-BbMDgjzr.mjs} +1 -1
  5. package/dist/{SandboxOrchestrator-C8uleDn2.mjs → SandboxOrchestrator-CIegJrCj.mjs} +1 -1
  6. package/dist/{buildAgent-CJrkEG0M.mjs → buildAgent-B_kArQ_Y.mjs} +18 -9
  7. package/dist/commands/acpCommand.mjs +34 -13
  8. package/dist/commands/apiCommand.mjs +1 -1
  9. package/dist/commands/doctorCommand.mjs +1 -1
  10. package/dist/commands/envCommand.mjs +1 -1
  11. package/dist/commands/headlessCommand.mjs +73 -33
  12. package/dist/commands/mcpCommand.mjs +8 -13
  13. package/dist/commands/pluginCommand.mjs +9 -15
  14. package/dist/commands/updateCommand.mjs +1 -1
  15. package/dist/{createFile-DPv180yF-BnWFIxey.mjs → createFile-B8bur5Rb-CVzCarEA.mjs} +2 -2
  16. package/dist/{deleteFile-BdjUwUQF-B3XOJmg3.mjs → deleteFile-9B3gW_Nb-DG2sovIl.mjs} +2 -2
  17. package/dist/{globFiles-DjfDGaUK-CNR8pMRC.mjs → globFiles-CwJ8qmYo-BR5b2KvO.mjs} +3 -2
  18. package/dist/{grepSearch-BaYUfIYs-n0XKoGnL.mjs → grepSearch-BgoOOwGe-DtlV8Gn-.mjs} +3 -3
  19. package/dist/index.mjs +212 -47
  20. package/dist/{package-7a45-Svr.mjs → package-Bqg2LSnH.mjs} +1 -1
  21. package/dist/{pathValidation-D8tjkQXE-1HwvsuYT.mjs → pathValidation-BRqf4HFX-CHwtwp3O.mjs} +7 -3
  22. package/dist/{serve-CavAHPdQ.mjs → serve-DO9Edl5S.mjs} +2 -2
  23. package/dist/types-CdIKgWWe.mjs +3 -0
  24. package/dist/{types-LyRNHOiS.mjs → types-F61_hxmG.mjs} +2 -0
  25. package/package.json +28 -28
  26. package/dist/types-CqscS34o.mjs +0 -3
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { $ as SupportedFabFileMimeTypes, A as HTTPError, At as isModelDeprecated, B as OPENAI_GPT_IMAGE_1_IMAGE_SIZES, Bt as parseEmbeddingRateLimitHeaders, Ct as isGPTImage2Model, D as FIXED_TEMPERATURE_MODELS, Dt as isImageServeable, E as FIELD_GROUP_OF, Et as isImageAttachment, F as MODEL_INFO_FIELD_GROUP_OF, Ft as isUnlimitedHistory, G as PermissionDeniedError, Gt as toModelInfo, H as OllamaEmbeddingModel, Ht as resolveHistoryFetchLimit, I as McpServerName, It as isUserInitiatedAbort, J as REFUSAL_FALLBACK_MODELS, Jt as usdToCreditsStochastic, K as REASONING_EFFORT_INCOMPATIBLE_WITH_TOOLS_MODELS, Kt as toModelRecord, L as ModelBackend, Lt as isZodError, M as IMAGE_SIZE_CONSTRAINTS, Mt as isRenderableModelType, N as ImageModels, Nt as isRetryableError, O as FORMAT_PROMPT_TEMPLATE, Ot as isMediaModelType, P as InternalServerError, Pt as isSupportedFabFileMimeType, Q as SpeechToTextModels, R as NO_TEMPERATURE_MODELS, Rt as mapMimeTypeToArtifactType, S as CorruptedFileError, St as isFieldGroup, Tt as isGeminiModelId, U as OpenAIEmbeddingModel, Ut as secureParameters, V as OPENAI_GPT_IMAGE_2_IMAGE_SIZES, Vt as reservationOutputTokens, Wt as settingsMap, Y as RESPONSES_API_TOOL_MODELS, Yt as withRetry, _ as BadRequestError, _t as hasUsableLimits, at as VideoModels, bt as isChunkStalledFile, ct as applyModelPriceCatalog, dt as dayjsConfig_default, et as TTS_MAX_INPUT_CHARS, ft as defaultEmbeddingModelForEnv, g as BFL_SAFETY_TOLERANCE, gt as hasKeylessCloudEmbedder, h as BEDROCK_NO_PROMPT_CACHING_MODELS, ht as getRetryAfterMs, in as parseRateLimitHeaders, it as VIDEO_SIZE_CONSTRAINTS, j as HttpStatus, jt as isPlaceholderApiKey, k as ForbiddenError, kt as isModelAccessible, lt as calculateRetryDelay, m as ApiKeyType, mt as getQuestErrorCode, n as logger, nn as extractSnippetMeta, nt as UnauthorizedError, ot as VoyageAIEmbeddingModel, p as ARTIFACT_ATTRS_PATTERN, pt as getMcpProviderMetadata, q as REASONING_SUPPORTED_MODELS, qt as usdToCredits, rn as isNearLimit, rt as UnprocessableEntityError, st as WORK_ITEM_STATUSES, tn as buildRateLimitLogEntry, tt as TooManyRequestsError, ut as countCodePoints, v as BedrockEmbeddingModel, vt as isAudioMimeType, w as DEFAULT_UNKNOWN_CONTEXT_WINDOW, wt as isGPTImageModel, x as ChatModels, y as CONTEXT_WINDOW_SAFETY_BUFFER_TOKENS, yt as isChunkRebuildPending, z as NotFoundError, zt as obfuscateApiKey } from "./ConfigStore-DHNgFdOu.mjs";
3
- import { n as isPathAllowed, t as assertPathAllowed } from "./pathValidation-D8tjkQXE-1HwvsuYT.mjs";
2
+ import { $ as RESPONSES_API_TOOL_MODELS, $t as secureParameters, A as DEFAULT_UNKNOWN_CONTEXT_WINDOW, At as isGPTImage2Model, B as InternalServerError, Bt as isRetryableError, C as BadRequestError, Ct as hasUsableLimits, D as ChatModels, Dt as isChunkStalledFile, Et as isChunkRebuildPending, F as ForbiddenError, Ft as isMediaModelType, G as NotFoundError, Gt as isZodError, H as McpServerName, Ht as isSupportedImageSize, I as HTTPError, It as isModelAccessible, Jt as parseEmbeddingRateLimitHeaders, K as OllamaEmbeddingModel, Kt as mapMimeTypeToArtifactType, L as HttpStatus, Lt as isModelDeprecated, M as FIELD_GROUP_OF, Mt as isGeminiModelId, N as FIXED_TEMPERATURE_MODELS, Nt as isImageAttachment, O as CorruptedFileError, P as FORMAT_PROMPT_TEMPLATE, Pt as isImageServeable, Q as REFUSAL_FALLBACK_MODELS, Qt as resolveHistoryFetchLimit, R as IMAGE_SIZE_CONSTRAINTS, Rt as isPlaceholderApiKey, S as BFL_SAFETY_TOLERANCE, St as hasKeylessCloudEmbedder, T as CONTEXT_WINDOW_SAFETY_BUFFER_TOKENS, Tt as isBflUltraImageModel, U as ModelBackend, Ut as isUnlimitedHistory, V as MODEL_INFO_FIELD_GROUP_OF, Vt as isSupportedFabFileMimeType, W as NO_TEMPERATURE_MODELS, Wt as isUserInitiatedAbort, X as REASONING_EFFORT_INCOMPATIBLE_WITH_TOOLS_MODELS, Xt as reservationOutputTokens, Y as PermissionDeniedError, Yt as readZipEntryBounded, Z as REASONING_SUPPORTED_MODELS, Zt as resolveGptImageGenerateSize, _t as defaultEmbeddingModelForEnv, a as canTrustTool, an as usdToCreditsStochastic, at as TooManyRequestsError, b as AudioMimeType, bt as getQuestErrorCode, ct as VIDEO_SIZE_CONSTRAINTS, dt as WORK_ITEM_STATUSES, en as settingsMap, fn as buildRateLimitLogEntry, ft as applyModelPriceCatalog, gt as dayjsConfig_default, hn as parseRateLimitHeaders, ht as countCodePoints, i as loadContextFiles, in as usdToCredits, it as TTS_MAX_INPUT_CHARS, jt as isGPTImageModel, kt as isFieldGroup, lt as VideoModels, mn as isNearLimit, mt as capForParse, n as logger, nn as toModelRecord, nt as SpeechToTextModels, o as getToolCategory, on as withRetry, ot as UnauthorizedError, pn as extractSnippetMeta, pt as calculateRetryDelay, q as OpenAIEmbeddingModel, qt as obfuscateApiKey, rn as toNonWebpOutputFormat, rt as SupportedFabFileMimeTypes, s as isReadOnlyTool, st as UnprocessableEntityError, tn as toModelInfo, ut as VoyageAIEmbeddingModel, v as ARTIFACT_ATTRS_PATTERN, vt as fallbackImageSize, w as BedrockEmbeddingModel, wt as isAudioMimeType, x as BEDROCK_NO_PROMPT_CACHING_MODELS, xt as getRetryAfterMs, y as ApiKeyType, yt as getMcpProviderMetadata, z as ImageModels, zt as isRenderableModelType } from "./ConfigStore-DdHHCH2t.mjs";
3
+ import { n as isPathAllowed, t as assertPathAllowed } from "./pathValidation-BRqf4HFX-CHwtwp3O.mjs";
4
4
  import { n as isTerminalShellStatus, t as getShellSessionManager } from "./ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs";
5
5
  import { execFile, execFileSync, spawn } from "child_process";
6
6
  import { createHash, randomBytes } from "crypto";
7
- import fs, { existsSync, promises, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "fs";
7
+ import fs, { existsSync, promises, readFileSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from "fs";
8
8
  import os, { homedir } from "os";
9
9
  import path, { dirname, join } from "path";
10
10
  import { v4 } from "uuid";
@@ -50,7 +50,6 @@ import { diffLines } from "diff";
50
50
  import fs$1, { mkdir, readFile, stat, writeFile } from "fs/promises";
51
51
  import matter from "gray-matter";
52
52
  import { parse } from "shell-quote";
53
- import { homedir as homedir$1 } from "node:os";
54
53
  import { EventEmitter } from "events";
55
54
  import { CloudWatchClient, PutMetricDataCommand, StandardUnit } from "@aws-sdk/client-cloudwatch";
56
55
  import { fileURLToPath } from "url";
@@ -67,7 +66,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
67
66
  import { getDomain } from "tldts";
68
67
  import * as dotenv from "dotenv";
69
68
  import { createHash as createHash$1 } from "node:crypto";
70
- import invert from "lodash/invert.js";
71
69
  import * as util from "node:util";
72
70
  import * as zlib from "node:zlib";
73
71
  import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
@@ -165,7 +163,7 @@ function crawlDirectory(projectRoot, maxDepth = 10, maxFiles = 2e4, ig) {
165
163
  /**
166
164
  * Format file size to human readable format
167
165
  */
168
- function formatFileSize$1(bytes) {
166
+ function formatFileSize(bytes) {
169
167
  if (bytes < 1024) return `${bytes} B`;
170
168
  if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
171
169
  if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
@@ -429,17 +427,17 @@ const COMMANDS = [
429
427
  },
430
428
  {
431
429
  name: "trust",
432
- description: "Trust a tool (won't ask permission again)",
433
- args: "<tool-name>"
430
+ description: "Trust a tool, or `folder` to trust this project's repo config/agents/skills/MCP",
431
+ args: "<tool-name|folder>"
434
432
  },
435
433
  {
436
434
  name: "untrust",
437
- description: "Remove tool from trusted list",
438
- args: "<tool-name>"
435
+ description: "Remove a tool from the trusted list, or `folder` to revoke project trust",
436
+ args: "<tool-name|folder>"
439
437
  },
440
438
  {
441
439
  name: "trusted",
442
- description: "List all trusted tools"
440
+ description: "Show folder-trust status and list all trusted tools"
443
441
  },
444
442
  {
445
443
  name: "usage",
@@ -675,142 +673,6 @@ function searchCommands(query, commands = COMMANDS) {
675
673
  return new Fuse(commands, fuseOptions).search(query).map((result) => result.item);
676
674
  }
677
675
  //#endregion
678
- //#region src/utils/constants.ts
679
- /**
680
- * Common human name suffixes that should NOT trigger file autocomplete
681
- * Examples: @john.jr, @mary.phd, @bob.iii
682
- */
683
- const NAME_SUFFIXES = [
684
- "jr",
685
- "sr",
686
- "ii",
687
- "iii",
688
- "iv",
689
- "v",
690
- "phd",
691
- "md",
692
- "esq"
693
- ];
694
- /**
695
- * Type-safe check if a string is a name suffix
696
- */
697
- function isNameSuffix(value) {
698
- return NAME_SUFFIXES.includes(value);
699
- }
700
- //#endregion
701
- //#region src/utils/processFileReferences.ts
702
- /**
703
- * Regular expression to match @path references
704
- * Matches @ followed by a path-like string (not containing spaces)
705
- * Only matches @ at start of string or after whitespace
706
- */
707
- const FILE_REFERENCE_REGEX = /(?:^|\s)@([^\s@]+)/g;
708
- /**
709
- * Check if a string looks like a file path (not an email or username)
710
- * A file path contains / or . (file extension) at the end
711
- */
712
- function looksLikeFilePath(ref) {
713
- if (ref.includes("/") || ref.includes(path$1.sep)) return true;
714
- const extensionMatch = /\.(\w+)$/.exec(ref);
715
- if (extensionMatch) {
716
- const ext = extensionMatch[1].toLowerCase();
717
- if (isNameSuffix(ext)) return false;
718
- if (ext.length > 10) return false;
719
- return true;
720
- }
721
- return false;
722
- }
723
- /**
724
- * Extract all file references from a message
725
- * Only treats @reference as a file if it looks like a path (contains / or has file extension)
726
- */
727
- function extractFileReferences(message) {
728
- const references = [];
729
- FILE_REFERENCE_REGEX.lastIndex = 0;
730
- let match;
731
- while ((match = FILE_REFERENCE_REGEX.exec(message)) !== null) {
732
- const ref = match[1];
733
- if (looksLikeFilePath(ref)) references.push(ref);
734
- }
735
- return references;
736
- }
737
- /**
738
- * Read file contents safely
739
- */
740
- function readFileContents(filePath) {
741
- const cwd = process.cwd();
742
- const isAbsolutePath = path$1.isAbsolute(filePath);
743
- if (filePath.includes("..")) return { error: `Security: Path traversal detected in "${filePath}"` };
744
- const absolutePath = isAbsolutePath ? path$1.normalize(filePath) : path$1.resolve(cwd, filePath);
745
- if (!isAbsolutePath && !isPathWithinCwd(filePath)) return { error: `Security: Relative path "${filePath}" escapes the current working directory` };
746
- if (!fs$2.existsSync(absolutePath)) return { error: `File not found: "${filePath}"` };
747
- const stats = fs$2.statSync(absolutePath);
748
- if (stats.isDirectory()) try {
749
- return {
750
- content: `(Directory with ${fs$2.readdirSync(absolutePath).length} items. Use file tools to explore if needed.)`,
751
- size: 0
752
- };
753
- } catch (err) {
754
- return { error: `Cannot read directory "${filePath}": ${err instanceof Error ? err.message : "Unknown error"}` };
755
- }
756
- if (stats.size > 10485760) return { error: `File too large: "${filePath}" is ${formatFileSize$1(stats.size)} (max ${formatFileSize$1(MAX_FILE_SIZE$4)})` };
757
- if (isBinaryFile(filePath)) return { error: `Binary file: "${filePath}" cannot be included as text content` };
758
- try {
759
- return {
760
- content: fs$2.readFileSync(absolutePath, "utf-8"),
761
- size: stats.size
762
- };
763
- } catch (err) {
764
- return { error: `Cannot read file "${filePath}": ${err instanceof Error ? err.message : "Unknown error"}` };
765
- }
766
- }
767
- /**
768
- * Format file content block for injection
769
- */
770
- function formatFileBlock(filePath, content, size, isDirectory) {
771
- if (isDirectory) return `
772
- --- Directory Reference: ${filePath} ---
773
- ${content}
774
- --- End of ${filePath} ---`;
775
- return `
776
- --- Referenced File: ${filePath} (${formatFileSize$1(size)}) ---
777
- ${content}
778
- --- End of ${filePath} ---`;
779
- }
780
- /**
781
- * Process file references in a message
782
- * Extracts @path references and injects file contents
783
- */
784
- async function processFileReferences(message) {
785
- const references = extractFileReferences(message);
786
- const errors = [];
787
- const fileBlocks = [];
788
- for (const ref of references) {
789
- const result = readFileContents(ref);
790
- if ("error" in result) {
791
- errors.push(result.error);
792
- continue;
793
- }
794
- const isDirectory = result.size === 0 && result.content.startsWith("(Directory");
795
- fileBlocks.push(formatFileBlock(ref, result.content, result.size, isDirectory));
796
- }
797
- if (fileBlocks.length === 0) return {
798
- content: message,
799
- errors
800
- };
801
- return {
802
- content: message + "\n" + fileBlocks.join("\n"),
803
- errors
804
- };
805
- }
806
- /**
807
- * Check if a message contains any file references
808
- */
809
- function hasFileReferences(message) {
810
- FILE_REFERENCE_REGEX.lastIndex = 0;
811
- return FILE_REFERENCE_REGEX.test(message);
812
- }
813
- //#endregion
814
676
  //#region ../../b4m-core/services/dist/rolldown-runtime-BBjsoOtd.mjs
815
677
  var __defProp$2 = Object.defineProperty;
816
678
  var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
@@ -992,7 +854,10 @@ const getEffectiveLLMApiKeys = async (userId, adapters, options) => {
992
854
  "voyageApiKey",
993
855
  "ollamaBackend",
994
856
  "EnableOllama"
995
- ], { adminSettings: db.adminSettings }, { logger })]);
857
+ ], { adminSettings: db.adminSettings }, {
858
+ logger,
859
+ skipCache: options?.skipCache
860
+ })]);
996
861
  const userKeyMap = /* @__PURE__ */ new Map();
997
862
  userApiKeys.forEach((key) => userKeyMap.set(key.type, key));
998
863
  const openaiUserKey = userKeyMap.get(ApiKeyType.openai) || null;
@@ -1128,26 +993,71 @@ var Logger = class Logger {
1128
993
  shouldLog(level) {
1129
994
  return Logger.LOG_LEVELS[level] >= Logger.LOG_LEVELS[this.minLevel];
1130
995
  }
996
+ static errorReplacer(_key, value) {
997
+ if (value instanceof Error) return {
998
+ ...value,
999
+ name: value.name,
1000
+ message: value.message,
1001
+ stack: value.stack
1002
+ };
1003
+ return value;
1004
+ }
1131
1005
  /**
1132
1006
  * Safely stringify a value, handling circular references
1133
1007
  */
1134
1008
  safeStringify(value, indent) {
1135
1009
  try {
1136
- return JSON.stringify(value, null, indent);
1010
+ return JSON.stringify(value, Logger.errorReplacer, indent);
1137
1011
  } catch {
1138
1012
  return "[Circular]";
1139
1013
  }
1140
1014
  }
1141
1015
  /**
1142
- * Parse log arguments to extract message and optional metadata
1016
+ * An object carrying structured fields, as opposed to a message part
1017
+ * (Errors and arrays stay in the message so their shape survives).
1018
+ */
1019
+ isMetadataArg(value) {
1020
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
1021
+ try {
1022
+ return !(value instanceof Error);
1023
+ } catch {
1024
+ return false;
1025
+ }
1026
+ }
1027
+ /**
1028
+ * Leading-position metadata is held to a stricter bar than trailing: a class
1029
+ * instance (Date, Map, ...) spreads to `{}` in output(), so lifting one out of
1030
+ * the message would erase it rather than structure it.
1031
+ */
1032
+ isPlainObject(value) {
1033
+ if (!this.isMetadataArg(value)) return false;
1034
+ try {
1035
+ const proto = Object.getPrototypeOf(value);
1036
+ return proto === Object.prototype || proto === null;
1037
+ } catch {
1038
+ return false;
1039
+ }
1040
+ }
1041
+ /**
1042
+ * Parse log arguments to extract message and optional metadata.
1043
+ * Metadata may be the last argument (`msg, meta`) or, pino-style, the first
1044
+ * (`meta, msg`); a trailing object wins when a call supplies both.
1143
1045
  */
1144
1046
  parseArgs(args, errorAware = false) {
1145
1047
  if (args.length === 0) return { message: "" };
1048
+ let metadata;
1049
+ let messageArgs = args;
1146
1050
  const lastArg = args[args.length - 1];
1147
- const hasMetadata = args.length > 1 && typeof lastArg === "object" && lastArg !== null && !Array.isArray(lastArg) && !(lastArg instanceof Error);
1148
- const metadata = hasMetadata ? lastArg : void 0;
1051
+ const firstArg = args[0];
1052
+ if (args.length > 1 && this.isMetadataArg(lastArg)) {
1053
+ metadata = lastArg;
1054
+ messageArgs = args.slice(0, -1);
1055
+ } else if (args.length > 1 && this.isPlainObject(firstArg) && args.slice(1).every((a) => a === null || typeof a !== "object")) {
1056
+ metadata = firstArg;
1057
+ messageArgs = args.slice(1);
1058
+ }
1149
1059
  return {
1150
- message: (hasMetadata ? args.slice(0, -1) : args).map((a) => {
1060
+ message: messageArgs.map((a) => {
1151
1061
  if (errorAware && a instanceof Error) return a.stack || a.message;
1152
1062
  return typeof a === "string" ? a : this.safeStringify(a);
1153
1063
  }).join(" "),
@@ -1540,8 +1450,8 @@ const headerOrNull = (httpResponse, name) => {
1540
1450
  };
1541
1451
  /**
1542
1452
  * The ceilings `generateEmbeddingBatch` splits on, at module scope and exported because a cost
1543
- * PREFLIGHT has to model the same split before it spends (packages/scripts/retrieval/capturePlan.ts).
1544
- * A second copy of these numbers in a script cannot track a provider change.
1453
+ * PREFLIGHT has to model the same split before it spends. A second copy of these numbers in a
1454
+ * script cannot track a provider change.
1545
1455
  */
1546
1456
  const OPENAI_MAX_INPUTS_PER_REQUEST = 2048;
1547
1457
  const OPENAI_MAX_TOKENS_PER_INPUT = 8192;
@@ -2223,13 +2133,12 @@ function resolveEmbeddingConfig(provider, keyTable) {
2223
2133
  * admin setting. A caller that must hit one specific vector space MUST keep using
2224
2134
  * `resolveEmbeddingConfig` and fail, because a fallback there would silently compare or write
2225
2135
  * across incompatible spaces:
2226
- * - V2 mementos are pinned to MEMENTO_EMBEDDING_MODEL at 512 truncated dims (see embedding.ts);
2227
- * - V1 mementos (mementoEmbedding.ts, getRelevantMementos.ts) read the admin default and so LOOK
2228
- * free to choose, but neither live write path stamps `Memento.embeddingModel` - only the
2229
- * reembedMementos backfill does. Their vectors are ranked by in-process cosine with no width
2230
- * guard and no Atlas index, so a substitution here would drop 1024-dim vectors into a field
2231
- * holding 1536-dim ones with nothing recording which is which, and nothing able to tell them
2232
- * apart afterwards. Stamping V1 is the prerequisite for including it, not this helper.
2136
+ * - V1 and V2 mementos are BOTH now pinned to MEMENTO_EMBEDDING_MODEL at 512 truncated dims (see
2137
+ * mementoEmbedding.ts, getRelevantMementos.ts, embedding.ts) - V1 used to read the admin default
2138
+ * instead, which is exactly the substitution this bullet warns against, so this helper must
2139
+ * never be reintroduced on that path. Their vectors are ranked by in-process cosine with no
2140
+ * width guard and no Atlas index, so nothing here would catch a wrong-space substitution before
2141
+ * it silently corrupted the comparison.
2233
2142
  * - alternateModelAnn embeds one query per model bucket to match each chunk's recorded stamp.
2234
2143
  *
2235
2144
  * Returns the model actually used, so callers stamp what they embedded with rather than what they
@@ -2259,6 +2168,49 @@ function resolveEmbeddingWithKeylessFallback(model, keyTable) {
2259
2168
  model: BedrockEmbeddingModel.TITAN_TEXT_EMBEDDINGS_V2
2260
2169
  };
2261
2170
  }
2171
+ /**
2172
+ * Bounds on PPTX zip extraction. A .pptx is a zip; a crafted one can pack far more slide
2173
+ * entries than any real deck, and each entry can inflate ~1000x when decompressed (zip-bomb
2174
+ * shape). The slide-count and per-entry caps bound each item, but an attacker controls their
2175
+ * PRODUCT, so two aggregate budgets bound the extraction as a whole:
2176
+ *
2177
+ * - MAX_PPTX_TOTAL_XML_BYTES caps the decompression one upload can drive, letting the per-item
2178
+ * numbers stay generous. 32 MB is ~1,000 slides of real slide XML, which runs tens of KB per
2179
+ * slide (media lives in separate zip entries).
2180
+ * - MAX_PPTX_TEXT_CHARS caps the extracted text accumulated across slides. This is NOT implied by
2181
+ * the XML budget: tiktoken traps on a string of that size, so `fullText` has to be bounded on
2182
+ * its own before chunkText tokenizes it. 2M characters is ~500k tokens, orders of magnitude past
2183
+ * any real deck.
2184
+ *
2185
+ * Crossing either stops the walk with a warning rather than failing the file, so a deck that is
2186
+ * merely huge still contributes everything read up to that point.
2187
+ */
2188
+ const MAX_PPTX_SLIDES = 5e3;
2189
+ const MAX_SLIDE_XML_BYTES = 16777216;
2190
+ const MAX_PPTX_TOTAL_XML_BYTES = 33554432;
2191
+ const MAX_PPTX_TEXT_CHARS = 2e6;
2192
+ const RUN_OPEN_TAG_RE = /<a:t(?:\s[^>]*)?>/g;
2193
+ const extractSlideRunTexts = (xml) => {
2194
+ const CLOSE = "</a:t>";
2195
+ const texts = [];
2196
+ let cursor = 0;
2197
+ while (cursor < xml.length) {
2198
+ const open = xml.indexOf("<a:t", cursor);
2199
+ if (open === -1 || open + 4 >= xml.length) break;
2200
+ const afterName = xml[open + 4];
2201
+ if (afterName !== ">" && !/\s/.test(afterName)) {
2202
+ cursor = open + 4;
2203
+ continue;
2204
+ }
2205
+ const openEnd = xml.indexOf(">", open + 4);
2206
+ if (openEnd === -1) break;
2207
+ const close = xml.indexOf(CLOSE, openEnd + 1);
2208
+ if (close === -1) break;
2209
+ texts.push(xml.slice(openEnd + 1, close).replace(RUN_OPEN_TAG_RE, ""));
2210
+ cursor = close + 6;
2211
+ }
2212
+ return texts;
2213
+ };
2262
2214
  const ChunkSchema = z$1.object({
2263
2215
  text: z$1.string(),
2264
2216
  tokenCount: z$1.number()
@@ -2600,14 +2552,38 @@ var SmartChunker = class {
2600
2552
  }
2601
2553
  async chunkPPTX(content) {
2602
2554
  const zip = await JSZip.loadAsync(content);
2603
- const slidePaths = Object.keys(zip.files).filter((p) => /^ppt\/slides\/slide\d+\.xml$/.test(p)).sort((a, b) => {
2555
+ const allSlidePaths = Object.keys(zip.files).filter((p) => /^ppt\/slides\/slide\d+\.xml$/.test(p)).sort((a, b) => {
2604
2556
  return parseInt(a.match(/slide(\d+)\.xml$/)?.[1] ?? "0", 10) - parseInt(b.match(/slide(\d+)\.xml$/)?.[1] ?? "0", 10);
2605
2557
  });
2558
+ const slidePaths = allSlidePaths.slice(0, MAX_PPTX_SLIDES);
2559
+ if (allSlidePaths.length > slidePaths.length) this.logger.warn(`PPTX declares ${allSlidePaths.length} slides; only the first ${MAX_PPTX_SLIDES} are chunked`);
2606
2560
  const decodeXmlEntities = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
2607
2561
  const slideTexts = [];
2562
+ let totalXmlBytes = 0;
2563
+ let totalTextChars = 0;
2608
2564
  for (let i = 0; i < slidePaths.length; i++) {
2609
- const text = ((await zip.files[slidePaths[i]].async("string")).match(/<a:t(?:\s[^>]*)?>([\s\S]*?)<\/a:t>/g) ?? []).map((r) => decodeXmlEntities(r.replace(/<a:t(?:\s[^>]*)?>|<\/a:t>/g, ""))).join(" ").replace(/\s+/g, " ").trim();
2610
- if (text) slideTexts.push(`Slide ${i + 1}: ${text}`);
2565
+ const entry = zip.files[slidePaths[i]];
2566
+ const entryCap = Math.min(MAX_SLIDE_XML_BYTES, MAX_PPTX_TOTAL_XML_BYTES - totalXmlBytes);
2567
+ const read = await readZipEntryBounded(entry, entryCap);
2568
+ if (!read.ok) {
2569
+ if (entryCap < MAX_SLIDE_XML_BYTES) {
2570
+ this.logger.warn(`PPTX slide XML exhausted the ${MAX_PPTX_TOTAL_XML_BYTES}-byte total budget at slide ${i + 1}; remaining slides are not chunked`);
2571
+ break;
2572
+ }
2573
+ this.logger.warn(`Skipping oversized PPTX slide ${i + 1} (over ${MAX_SLIDE_XML_BYTES} bytes decompressed)`);
2574
+ continue;
2575
+ }
2576
+ totalXmlBytes += read.byteLength;
2577
+ const text = extractSlideRunTexts(read.text).map((r) => decodeXmlEntities(r)).join(" ").replace(/\s+/g, " ").trim();
2578
+ if (text) {
2579
+ const kept = text.slice(0, MAX_PPTX_TEXT_CHARS - totalTextChars);
2580
+ slideTexts.push(`Slide ${i + 1}: ${kept}`);
2581
+ totalTextChars += kept.length;
2582
+ if (totalTextChars >= MAX_PPTX_TEXT_CHARS) {
2583
+ this.logger.warn(`PPTX extracted text reached the ${MAX_PPTX_TEXT_CHARS}-character cap at slide ${i + 1}; remaining slides are not chunked`);
2584
+ break;
2585
+ }
2586
+ }
2611
2587
  }
2612
2588
  const fullText = slideTexts.join("\n\n");
2613
2589
  if (!fullText.trim()) {
@@ -3379,12 +3355,321 @@ async function fetchWithoutRedirects(url, timeoutMs) {
3379
3355
  }
3380
3356
  const BLOCK_LEVEL_SELECTOR = `*:not(${"a, span, em, strong, b, i, u, code, kbd, samp, var, sub, sup, small, abbr, cite, q, time, mark, s, del, ins, bdi, bdo, wbr, ruby, rt, rp".split(", ").join("):not(")}):not(td):not(th)`;
3381
3357
  /**
3358
+ * Elements that carry UI rather than prose, removed before extraction.
3359
+ *
3360
+ * Two principles only, deliberately narrow - `nav`/`header`/`footer`/`aside` are NOT here, because
3361
+ * pages do put real content in the last two and a full boilerplate pass is a different job:
3362
+ * - `aria-hidden`/`hidden`: the page itself says this is not content to be read. That is what
3363
+ * catches the duplicated tooltip labels modern doc sites render next to every icon button
3364
+ * ("Collapse sidebar", "Search or ask Copilot"), which are plain `<span>`s with no other signal.
3365
+ * - interactive controls, native or via the equivalent ARIA role: a control's label is an
3366
+ * instruction to the reader, not part of the document.
3367
+ */
3368
+ const NON_CONTENT_SELECTOR = [
3369
+ "[aria-hidden=\"true\"]",
3370
+ "[hidden]",
3371
+ "button",
3372
+ "input",
3373
+ "select",
3374
+ "textarea",
3375
+ "option",
3376
+ "optgroup",
3377
+ "datalist",
3378
+ "label",
3379
+ "dialog",
3380
+ "template",
3381
+ "svg",
3382
+ "[role=\"button\"]",
3383
+ "[role=\"search\"]",
3384
+ "[role=\"searchbox\"]",
3385
+ "[role=\"combobox\"]",
3386
+ "[role=\"listbox\"]",
3387
+ "[role=\"menu\"]",
3388
+ "[role=\"menubar\"]",
3389
+ "[role=\"tablist\"]",
3390
+ "[role=\"toolbar\"]",
3391
+ "[role=\"dialog\"]",
3392
+ "[role=\"alertdialog\"]",
3393
+ "[role=\"tooltip\"]",
3394
+ "[role=\"radiogroup\"]"
3395
+ ].join(", ");
3396
+ /**
3397
+ * What counts as a control when judging a control strip. Broader than `NON_CONTENT_SELECTOR`,
3398
+ * because a link is content in prose but a control in a nav bar - `a[href]` is the only reason the
3399
+ * strip rule can see an unmarked `<div>` of nav links as chrome at all.
3400
+ */
3401
+ const CONTROL_SELECTOR = "a[href], button, input, select, textarea, summary, [role=\"button\"], [role=\"link\"], [role=\"tab\"], [role=\"menuitem\"], [role=\"option\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"]";
3402
+ /**
3403
+ * Containers a control strip can be. Headings and `<p>` are excluded: `<h2><a>Title</a></h2>` is
3404
+ * content. `<table>`/`<tbody>`/`<tr>` and `<li>` are excluded too: a row of short linked cells is
3405
+ * how an ordinary reference table looks, never a nav bar, and a list is judged at the `<ul>`/`<ol>`
3406
+ * level as a whole rather than letting one busy `<li>` speak for it.
3407
+ */
3408
+ const STRIP_CONTAINER_SELECTOR = "div, span, ul, ol, nav, header, footer, aside, section, form";
3409
+ /**
3410
+ * Ancestor tags that mark "this element sits inside running prose", not "this element is a
3411
+ * standalone block". A `span` wrapping two inline links in the middle of a sentence looks
3412
+ * structurally identical to a toolbar to `isControlStrip` - same tag, same two-control shape - but
3413
+ * removing it deletes words out of a sentence rather than a block of chrome, which reads as fluent,
3414
+ * complete prose with a fact silently missing. A strip candidate found inside one of these is
3415
+ * declined outright, before `isControlStrip` ever runs, since content ancestry is a stronger signal
3416
+ * than anything the candidate's own subtree can show.
3417
+ */
3418
+ const PROSE_ANCESTOR_SELECTOR = "p, h1, h2, h3, h4, h5, h6, li, dt, dd, blockquote, figcaption, caption";
3419
+ /**
3420
+ * Minimum controls for a `<ul>`/`<ol>` candidate specifically - higher than the general
3421
+ * `MIN_STRIP_CONTROLS` below. A bare two-item list is exactly as likely to be two related content
3422
+ * links (a "see also" pair) as it is a nav, and unlike a `<nav>`/`<header>`/`<footer>` landmark - which
3423
+ * already declares itself as chrome by tag - a plain `<ul>` carries no such signal. Landmark tags and
3424
+ * `<div>`/`<span>` keep the lower threshold: a two-item breadcrumb or tab strip ("Home / Docs") is
3425
+ * common and short by nature.
3426
+ */
3427
+ const MIN_LIST_STRIP_CONTROLS = 3;
3428
+ /**
3429
+ * Minimum controls for any other strip candidate (`div`, `span`, `nav`, `header`, `footer`, `aside`,
3430
+ * `section`, `form`) - what keeps `<div><a>An article title</a></div>` on a card, a single link
3431
+ * card being indistinguishable in shape from a one-item nav. `<li>` is not itself a
3432
+ * `STRIP_CONTAINER_SELECTOR` tag, so a list item is never a candidate this constant adjudicates at
3433
+ * all; a list is judged as a whole at the `<ul>`/`<ol>` level via `MIN_LIST_STRIP_CONTROLS` instead.
3434
+ */
3435
+ const MIN_STRIP_CONTROLS = 2;
3436
+ /**
3437
+ * Longest a single control's label may be before the group stops looking like a control strip.
3438
+ * Nav items, tabs and toolbar buttons are a word or three; anything longer is prose in a link.
3439
+ */
3440
+ const MAX_CONTROL_LABEL_CHARS = 40;
3441
+ /**
3442
+ * How much non-control text a strip candidate may still carry before it stops looking like chrome.
3443
+ * Requiring exactly zero (the previous rule) let a single stray word - a wordmark, a version
3444
+ * string, a bare "Menu" - defeat the whole strip and leak the nav into stored content. Budgeted
3445
+ * small and absolute, at wordmark scale rather than sentence scale.
3446
+ *
3447
+ * Only granted to a candidate matching `LANDMARK_CHROME_SELECTOR` - see there for why a `div`/
3448
+ * `span`/`section` candidate does not get this budget at all.
3449
+ */
3450
+ const MAX_NON_CONTROL_TEXT_CHARS = 15;
3451
+ /**
3452
+ * Tags and roles that self-declare as chrome regardless of what they contain - the only
3453
+ * candidates `MAX_NON_CONTROL_TEXT_CHARS`'s wordmark-scale budget applies to. A `div`/`span`/
3454
+ * `section`/`ul`/`ol`/`form` carries no such signal and is exactly where a CMS renders a short,
3455
+ * genuine callout ("Related: <a>X</a> and <a>Y</a>."): granting it the same budget let a
3456
+ * self-contained sentence-plus-links block clear `isControlStrip` on its own short lead-in text
3457
+ * and get deleted whole, with nothing downstream able to tell it happened. Those tags instead
3458
+ * fall back to requiring non-control text be separator punctuation only (see `isControlStrip`),
3459
+ * same as every candidate did before this budget existed.
3460
+ */
3461
+ const LANDMARK_CHROME_SELECTOR = "nav, header, footer, aside, [role=\"navigation\"], [role=\"banner\"], [role=\"contentinfo\"], [role=\"complementary\"], [role=\"search\"]";
3462
+ /**
3463
+ * How much of a container's nesting depth (from the document root, so a page with no `<main>`
3464
+ * and one that has it are budgeted the same way) the strip check will still climb to evaluate.
3465
+ * `isControlStrip` scans a candidate's ENTIRE subtree, so checking every container in a deeply
3466
+ * nested document is quadratic in nesting depth - and nesting is entirely up to whatever HTML the
3467
+ * fetched URL happens to return. Nesting past this depth stops being checked as a strip candidate
3468
+ * rather than being paid for on every level.
3469
+ *
3470
+ * Sized well clear of real layout nesting - the deepest control group measured live against
3471
+ * react.dev, tailwindcss.com and docs.github.com sits at 16 - but a control strip nested deeper
3472
+ * than this is a real, deliberate gap: it is never evaluated at all, at any depth from here to its
3473
+ * leaves, since every one of its descendants is at least as deep. Closing that gap properly needs
3474
+ * either a much higher cap (which reopens the cost problem this constant exists to bound) or
3475
+ * skipping only the expensive subtree scan while still descending past the cap - out of scope
3476
+ * here; see the boundary test pinning today's behavior instead of leaving it undocumented.
3477
+ */
3478
+ const MAX_STRIP_CONTAINER_DEPTH = 32;
3479
+ /**
3480
+ * How much of the scope's own surviving text has to remain, after chrome pruning, before that
3481
+ * pruning is trusted. Below this, pruning is treated as having taken real content down with it -
3482
+ * see `pruneChromeFromScope`. Sized between a bare boilerplate remnant (a copyright line, a
3483
+ * "Further reading." label - fifteen to twenty characters) and a real one-sentence page ("The
3484
+ * chapter itself, in prose." - twenty-nine): short enough that a genuinely tiny real page still
3485
+ * survives, long enough that what a footer or a stray label leaves behind on its own doesn't.
3486
+ *
3487
+ * Known limitation: an absolute count cannot always tell a genuine short sentence from a
3488
+ * same-length piece of boilerplate (a copyright line can be as long as an intro sentence) - see
3489
+ * `pruneChromeFromScope` for why the alternative (weighing the bar against how much was removed)
3490
+ * was tried and reverted.
3491
+ */
3492
+ const MIN_SURVIVING_CONTENT_CHARS = 20;
3493
+ const squash = (text) => text.replace(/\s+/g, " ").trim();
3494
+ /**
3495
+ * Nesting depth of `element` below `within`, walking parent pointers directly rather than through
3496
+ * cheerio's `.parents()` (which itself re-walks the chain with wrapper allocation at every step) -
3497
+ * this runs once per strip candidate, so it has to stay cheap even though `isControlStrip` itself
3498
+ * is not.
3499
+ */
3500
+ function depthWithin(element, within) {
3501
+ let depth = 0;
3502
+ let current = element.parent;
3503
+ while (current && current !== within) {
3504
+ depth++;
3505
+ current = current.parent;
3506
+ }
3507
+ return depth;
3508
+ }
3509
+ /**
3510
+ * True when `element` has an ancestor (below `boundary`, exclusive) that marks it as sitting inside
3511
+ * running prose rather than being a standalone block - see `PROSE_ANCESTOR_SELECTOR`. Walks parent
3512
+ * pointers directly for the same reason `depthWithin` does: this runs once per strip candidate.
3513
+ */
3514
+ function hasProseAncestor($, element, boundary) {
3515
+ let current = element.parent;
3516
+ while (current && current !== boundary) {
3517
+ if ($(current).is(PROSE_ANCESTOR_SELECTOR)) return true;
3518
+ current = current.parent;
3519
+ }
3520
+ return false;
3521
+ }
3522
+ /**
3523
+ * True when `element` sits directly between two pieces of running text - a non-whitespace text
3524
+ * node as its immediately preceding or following sibling. That is the tag-agnostic version of
3525
+ * "this element sits inside running prose": `PROSE_ANCESTOR_SELECTOR` only protects a candidate
3526
+ * whose ANCESTOR is one of a fixed list of tags (`p`, headings, `li`, ...), so the same inline
3527
+ * `<span>` wrapping two links reads as protected prose inside a `<p>` but as a standalone chrome
3528
+ * candidate inside a `<div>`, `<section>` or `<td>` - none of which are prose landmarks, but all of
3529
+ * which routinely hold hand-written or CMS-rendered sentences. A text-node sibling is the
3530
+ * strongest tag-independent signal that removing `element` would leave a dangling sentence rather
3531
+ * than delete a block of chrome, regardless of what its parent is called.
3532
+ */
3533
+ function hasAdjacentProseText(element) {
3534
+ const node = element;
3535
+ const isNonWhitespaceText = (sibling) => {
3536
+ const candidate = sibling;
3537
+ return !!candidate && candidate.type === "text" && squash(candidate.data ?? "").length > 0;
3538
+ };
3539
+ return isNonWhitespaceText(node.prev) || isNonWhitespaceText(node.next);
3540
+ }
3541
+ /**
3542
+ * True when an element is a group of adjacent controls with no prose of its own - a nav bar, a
3543
+ * breadcrumb row, a tab strip, a footer link column, a sandbox toolbar.
3544
+ *
3545
+ * Needs `MIN_LIST_STRIP_CONTROLS` for a `<ul>`/`<ol>` candidate and `MIN_STRIP_CONTROLS` otherwise -
3546
+ * see those constants for why the two differ. It also has to run BEFORE the controls themselves
3547
+ * are removed, or the evidence is gone: react.dev's `Fork` link only reads as chrome because the
3548
+ * `Reload` and `Clear` buttons share its toolbar.
3549
+ *
3550
+ * "No prose of its own" tolerates the punctuation sites use to separate items, so a `A | B | C`
3551
+ * nav still qualifies, and now also a small budget of non-separator text - see
3552
+ * `MAX_NON_CONTROL_TEXT_CHARS`.
3553
+ */
3554
+ function isControlStrip($, element) {
3555
+ const $element = $(element);
3556
+ if (!squash($element.text())) return false;
3557
+ const controls = $element.find(CONTROL_SELECTOR);
3558
+ const isList = $element.is("ul, ol");
3559
+ if (controls.length < (isList ? MIN_LIST_STRIP_CONTROLS : MIN_STRIP_CONTROLS)) return false;
3560
+ for (const control of controls.toArray()) {
3561
+ const label = squash($(control).text());
3562
+ if (label.length > MAX_CONTROL_LABEL_CHARS || /[.!?]\s/.test(label)) return false;
3563
+ }
3564
+ const strippedOutsideControls = $element.clone().find(CONTROL_SELECTOR).remove().end().text().replace(/[\s|\u00b7\u2022/,:;-]+/g, "");
3565
+ const budget = $element.is(LANDMARK_CHROME_SELECTOR) ? MAX_NON_CONTROL_TEXT_CHARS : 0;
3566
+ return strippedOutsideControls.length <= budget;
3567
+ }
3568
+ /**
3569
+ * Removes control strips and non-content elements from `scope`, together, with ONE rollback
3570
+ * covering both.
3571
+ *
3572
+ * Both prunings are done via a placeholder swap rather than an outright `remove()`, so either can
3573
+ * be undone. They are decided together - not the strip rule with its own guard and the non-content
3574
+ * removal with none - because a subtree that is real content by itself can sit entirely inside a
3575
+ * `label`/`dialog`/`aria-hidden` wrapper (a client framework's whole-page aria-hidden mount, an
3576
+ * article rendered inside a `<dialog>`), and pruning each half separately let the second one erase
3577
+ * what the first had just decided to protect.
3578
+ *
3579
+ * The bar for trusting the prune is "enough of the scope's own text survives"
3580
+ * (`MIN_SURVIVING_CONTENT_CHARS`), not "any text survives at all": a page that is mostly a link
3581
+ * directory routinely carries a footer copyright line or a "Further reading." label alongside it,
3582
+ * and treating either as proof the prune was safe defeats the guard in exactly the case it exists
3583
+ * for. Below the bar, everything pruned in this call is restored.
3584
+ *
3585
+ * A fixed character count cannot fully replace judging whether surviving text is real content or
3586
+ * boilerplate (a copyright line and a short genuine sentence can be the same length) - that needs
3587
+ * the density/boilerplate pass this ticket explicitly scopes out. It is deliberately NOT relative
3588
+ * to how much was pruned either: a legitimate strip removal is very often far larger than the
3589
+ * genuine prose sitting next to it (a 40-item nav beside a one-sentence intro, or GitHub's own
3590
+ * aria-hidden tooltip spans beside a paragraph), so "survives >= removed" would roll back exactly
3591
+ * the pages this function exists to clean.
3592
+ *
3593
+ * Returns whether the prune was kept, so a caller working scope-by-scope (see `mainContentScope`)
3594
+ * knows whether THIS scope still has enough of its own content to be trusted at all.
3595
+ */
3596
+ function pruneChromeFromScope($, scope) {
3597
+ const documentRoot = $.root().get(0);
3598
+ const strips = [];
3599
+ scope.find(STRIP_CONTAINER_SELECTOR).each((_index, element) => {
3600
+ if (strips.some((strip) => $.contains(strip, element))) return;
3601
+ if (documentRoot && depthWithin(element, documentRoot) > MAX_STRIP_CONTAINER_DEPTH) return;
3602
+ if (documentRoot && hasProseAncestor($, element, documentRoot)) return;
3603
+ if (hasAdjacentProseText(element)) return;
3604
+ if (isControlStrip($, element)) strips.push(element);
3605
+ });
3606
+ const stripPlaceholders = strips.map((strip) => {
3607
+ const placeholder = $("<div></div>");
3608
+ $(strip).replaceWith(placeholder);
3609
+ return placeholder;
3610
+ });
3611
+ const nonContentEls = scope.find(NON_CONTENT_SELECTOR).toArray();
3612
+ const nonContentPlaceholders = nonContentEls.map((element) => {
3613
+ const placeholder = $("<div></div>");
3614
+ $(element).replaceWith(placeholder);
3615
+ return placeholder;
3616
+ });
3617
+ const survives = squash(scope.text()).length >= MIN_SURVIVING_CONTENT_CHARS;
3618
+ if (survives) {
3619
+ for (const placeholder of stripPlaceholders) placeholder.remove();
3620
+ for (const placeholder of nonContentPlaceholders) placeholder.remove();
3621
+ } else {
3622
+ nonContentPlaceholders.forEach((placeholder, index) => placeholder.replaceWith(nonContentEls[index]));
3623
+ stripPlaceholders.forEach((placeholder, index) => placeholder.replaceWith(strips[index]));
3624
+ }
3625
+ return survives;
3626
+ }
3627
+ /**
3628
+ * The scope to extract from: the document's own main-content landmark when it declares exactly one
3629
+ * AND still has enough of its own content once chrome pruning runs against it - otherwise the
3630
+ * whole document.
3631
+ *
3632
+ * This is the half of the fix that handles chrome with no other tell - a sticky sub-header of icon
3633
+ * buttons, a site footer carrying a survey and a privacy link. Trusting the page's own `<main>` also
3634
+ * answers the "real content in `<aside>`/`<footer>`" case for free, and better than a rule about
3635
+ * those tags could: an `<aside>` or `<footer>` INSIDE `main` is kept, one outside it is site chrome
3636
+ * by the page's own declaration. A document with no `main` keeps the previous whole-document scope.
3637
+ *
3638
+ * Checked twice, before AND after pruning. The first check (`main.text().trim()`) only rules out a
3639
+ * `<main>` that is LITERALLY empty - a client-rendered app shipping `<main></main>` with its real
3640
+ * content elsewhere. It does not rule out a `<main>` that is truthy for the wrong reason: a loading
3641
+ * placeholder ("Loading...") with the real article outside it, or a `<main>` whose only content IS
3642
+ * a nav bar, so pruning empties it and the real prose living outside `<main>` is never looked at.
3643
+ * The second check is `pruneChromeFromScope`'s own return value once it has actually run against
3644
+ * the candidate - if pruning leaves `<main>` without enough of its own text, `<main>` is abandoned
3645
+ * (its pruning already rolled back by that call) and the whole document is scanned instead, this
3646
+ * time seeing everything `<main>` would have hidden from it.
3647
+ */
3648
+ function mainContentScope($) {
3649
+ const main = $("main, [role=\"main\"]");
3650
+ if (main.length === 1 && main.text().trim()) {
3651
+ const scope = main;
3652
+ if (pruneChromeFromScope($, scope)) return scope;
3653
+ }
3654
+ const root = $.root();
3655
+ pruneChromeFromScope($, root);
3656
+ return root;
3657
+ }
3658
+ /**
3382
3659
  * Extract readable text from the WHOLE document, not just `<p>` elements. The single collector
3383
3660
  * this replaced was `<p>`-only and fell back to the raw HTML when it found none: on a page whose
3384
3661
  * content isn't inside `<p>` (an RFC page using `<pre>`) that meant the fallback fired and stored
3385
3662
  * markup verbatim; on a page with real substance in headings, list items, table cells or code
3386
3663
  * blocks alongside its `<p>`s, that content was silently dropped.
3387
3664
  *
3665
+ * Because it reads the whole document, page chrome that the `<p>`-only collector dropped by
3666
+ * accident now has to be dropped on purpose, or nav bars, search widgets, cookie banners, footer
3667
+ * link columns and button labels get chunked and embedded alongside the article. Two rules do
3668
+ * that - see `isControlStrip` and `NON_CONTENT_SELECTOR` for why each one is shaped the way it is -
3669
+ * applied together by `mainContentScope` (via `pruneChromeFromScope`) against whichever scope it
3670
+ * settles on, with its own rollback if pruning went too far. The strip rule MUST run before the
3671
+ * control removal, since it recognises a strip by the controls in it.
3672
+ *
3388
3673
  * `head` (title/meta/script/style all live there, and the caller already reads `<title>`
3389
3674
  * separately) plus any stray `script`/`style`/`noscript` outside it are removed before extraction,
3390
3675
  * so none of that reaches what gets embedded. `<pre>` content is pulled out and stashed BEFORE the
@@ -3398,25 +3683,26 @@ const BLOCK_LEVEL_SELECTOR = `*:not(${"a, span, em, strong, b, i, u, code, kbd,
3398
3683
  */
3399
3684
  function extractReadableText($) {
3400
3685
  $("head, script, style, noscript").remove();
3401
- $("br").replaceWith("\n");
3686
+ const scope = mainContentScope($);
3687
+ scope.find("br").replaceWith("\n");
3402
3688
  const nonce = Math.random().toString(36).slice(2) + Date.now().toString(36);
3403
3689
  const markerFor = (index) => `\uE000PRE${nonce}_${index}\uE000`;
3404
3690
  const markerPattern = new RegExp(`\\uE000PRE${nonce}_(\\d+)\\uE000`, "g");
3405
3691
  const preBlocks = [];
3406
- $("pre").each((_index, element) => {
3692
+ scope.find("pre").each((_index, element) => {
3407
3693
  const text = $(element).text();
3408
3694
  if (text) {
3409
3695
  preBlocks.push(text);
3410
3696
  $(element).replaceWith(`${markerFor(preBlocks.length - 1)}\n`);
3411
3697
  } else $(element).remove();
3412
3698
  });
3413
- $("td, th").each((_index, cell) => {
3699
+ scope.find("td, th").each((_index, cell) => {
3414
3700
  $(cell).after(" ");
3415
3701
  });
3416
- $(BLOCK_LEVEL_SELECTOR).each((_index, element) => {
3702
+ scope.find(BLOCK_LEVEL_SELECTOR).each((_index, element) => {
3417
3703
  $(element).after("\n");
3418
3704
  });
3419
- return $.root().text().split("\n").map((line) => line.replace(/[ \t]+/g, " ").trim()).filter(Boolean).join("\n").replace(markerPattern, (match, indexStr) => {
3705
+ return scope.text().split("\n").map((line) => line.replace(/[ \t]+/g, " ").trim()).filter(Boolean).join("\n").replace(markerPattern, (match, indexStr) => {
3420
3706
  const index = Number(indexStr);
3421
3707
  return index >= 0 && index < preBlocks.length ? preBlocks[index] : match;
3422
3708
  });
@@ -3641,7 +3927,7 @@ new Map([
3641
3927
  ...Object.values(OLLAMA_EMBEDDING_MODEL_MAP)
3642
3928
  ].map((info) => [info.model, info.dimensions[0]]));
3643
3929
  //#endregion
3644
- //#region ../../b4m-core/services/dist/webfetch-Bd1fAkDp.mjs
3930
+ //#region ../../b4m-core/services/dist/webfetch-6ldm7GFM.mjs
3645
3931
  const htmlToMarkdown = (html, _isArxiv = false) => {
3646
3932
  const turndownService = new turndown({
3647
3933
  headingStyle: "atx",
@@ -4475,7 +4761,7 @@ const weatherTool = {
4475
4761
  })
4476
4762
  };
4477
4763
  //#endregion
4478
- //#region ../../b4m-core/services/dist/toolGenerators-rEEkksUF.mjs
4764
+ //#region ../../b4m-core/services/dist/toolGenerators-DdYxeFQq.mjs
4479
4765
  const diceRoll = async (parameters) => {
4480
4766
  if (!parameters?.sides || !parameters?.times) throw new Error("Tool dice roll: Missing required parameters");
4481
4767
  return sum(times(parameters.times, () => random(1, parameters.sides))).toString();
@@ -4670,30 +4956,7 @@ const mathTool = {
4670
4956
  name: "math_evaluate",
4671
4957
  description: `Evaluate mathematical expressions using mathjs syntax. Supports arithmetic, algebra, trigonometry, calculus, and statistics. Supports multi-step calculations with semicolon-separated statements sharing a scope (e.g., "x = 5; y = 10; x * y" returns 50). IMPORTANT: Use simple mathematical notation only - no loops or programming constructs.
4672
4958
 
4673
- **LaTeX Rendering Support:**
4674
- When showing mathematical work, equations, or formulas in your response, use LaTeX syntax for professional rendering:
4675
-
4676
- - **Inline math:** Use $equation$ for math within text
4677
- Example: "The solution is $x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$ from the quadratic formula."
4678
-
4679
- - **Display math:** Use $$equation$$ for centered block equations
4680
- Example:
4681
- $$
4682
- \\int_0^\\infty e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}
4683
- $$
4684
-
4685
- **Common LaTeX commands:**
4686
- - Fractions: \\frac{numerator}{denominator}
4687
- - Square roots: \\sqrt{x} or \\sqrt[n]{x}
4688
- - Superscripts: x^2 or x^{10}
4689
- - Subscripts: x_i or x_{ij}
4690
- - Greek: \\alpha, \\beta, \\gamma, \\Delta, \\Sigma
4691
- - Integrals: \\int_a^b, \\iint, \\oint
4692
- - Summations: \\sum_{i=1}^n
4693
- - Limits: \\lim_{x \\to \\infty}
4694
- - Matrices: \\begin{bmatrix} a & b \\\\ c & d \\end{bmatrix}
4695
-
4696
- Always use LaTeX for mathematical notation to ensure clear, professional presentation. The LaTeX syntax is part of your response text - no tool call needed for rendering.`,
4959
+ Present mathematical work in your response using LaTeX: $...$ inline, $$...$$ for display equations. Inline spans are only rendered as math when they contain a backslash command, so write $\\int_0^3 x^2 dx = 9$ rather than $x^2 = 9$. That rendering is part of your response text, not a tool call.`,
4697
4960
  parameters: {
4698
4961
  type: "object",
4699
4962
  properties: {
@@ -5015,7 +5278,7 @@ const promptEnhancementTool = {
5015
5278
  * NOT usable for artifact ids (`artifact_<...>`), which are matched on a string `id` field rather
5016
5279
  * than `_id` - see `createArtifactId` in @bike4mind/common.
5017
5280
  */
5018
- function isObjectIdShaped(id) {
5281
+ function isObjectIdShaped$1(id) {
5019
5282
  return isObjectIdOrHexString(id);
5020
5283
  }
5021
5284
  let _showUserQuestion = null;
@@ -5137,7 +5400,7 @@ const askUserQuestionTool = {
5137
5400
  * re-export them without pulling the full tool graph. `index.ts` re-exports them
5138
5401
  * so the server barrel's public API is unchanged.
5139
5402
  */
5140
- const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inlinedAttachmentIds, fullyInlinedAttachmentIds, suppressLakeArms, sessionRetrievalTags, sessionPreauthorizedLakeIds, questId, getAbortSignal }, storage, imageGenerateStorage, statusUpdate, onStart, onFinish, llm, config, model, imageProcessorLambdaName, tools, allowedDirectories, entitlementKeys = [], sessionId, codeMinifier, availableModels, onToolLlmUsage) => {
5403
+ const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inlinedAttachmentIds, fullyInlinedAttachmentIds, suppressLakeArms, sessionRetrievalTags, sessionLakeScopeExplicit, sessionPreauthorizedLakeIds, questId, getAbortSignal }, storage, imageGenerateStorage, statusUpdate, onStart, onFinish, llm, config, model, imageProcessorLambdaName, tools, allowedDirectories, entitlementKeys = [], sessionId, codeMinifier, availableModels, onToolLlmUsage) => {
5141
5404
  const context = {
5142
5405
  userId,
5143
5406
  user,
@@ -5161,6 +5424,7 @@ const generateTools = (userId, user, logger, { db, retrievalFilter, kbScope, inl
5161
5424
  fullyInlinedAttachmentIds,
5162
5425
  suppressLakeArms,
5163
5426
  sessionRetrievalTags,
5427
+ sessionLakeScopeExplicit,
5164
5428
  sessionPreauthorizedLakeIds,
5165
5429
  codeMinifier,
5166
5430
  availableModels,
@@ -5306,152 +5570,6 @@ const generateMcpToolsFromCache = (serverName, cachedTools, callTool) => {
5306
5570
  //#endregion
5307
5571
  //#region ../../b4m-core/services/dist/llm/tools/cliTools.mjs
5308
5572
  /**
5309
- * Whitespace-only normalization applied on every minified read (both the AST and
5310
- * fallback paths): normalize line endings, strip trailing whitespace, and collapse
5311
- * runs of blank lines. Never touches non-whitespace bytes, so it can never change
5312
- * program meaning - the safe worst case is "no reduction."
5313
- */
5314
- function normalizeWhitespace(source) {
5315
- const lines = source.replace(/\r\n?/g, "\n").split("\n").map((line) => line.replace(/[ \t]+$/, ""));
5316
- const collapsed = [];
5317
- let blankRun = 0;
5318
- for (const line of lines) {
5319
- if (line === "") {
5320
- blankRun++;
5321
- if (blankRun > 1) continue;
5322
- } else blankRun = 0;
5323
- collapsed.push(line);
5324
- }
5325
- while (collapsed.length && collapsed[0] === "") collapsed.shift();
5326
- while (collapsed.length && collapsed[collapsed.length - 1] === "") collapsed.pop();
5327
- return collapsed.join("\n");
5328
- }
5329
- /** Rough token estimate (~4 chars/token) used only to report savings, not for billing. */
5330
- function estimateTokens(text) {
5331
- return Math.ceil(text.length / 4);
5332
- }
5333
- /**
5334
- * Produce a minified view of `raw`. Tries AST comment-stripping via the injected
5335
- * `codeMinifier` (comments gone) and always finishes with whitespace normalization;
5336
- * if the minifier is absent or declines (unsupported/unparsable language) it falls
5337
- * back to whitespace-only normalization with comments preserved. Never mutates disk.
5338
- */
5339
- async function minifyFileContent(raw, filePath, codeMinifier) {
5340
- const ext = path.extname(filePath).toLowerCase();
5341
- const stripped = codeMinifier ? await codeMinifier(raw, ext).catch(() => null) : null;
5342
- const content = normalizeWhitespace(stripped ?? raw);
5343
- const tokensSaved = Math.max(0, estimateTokens(raw) - estimateTokens(content));
5344
- return {
5345
- content,
5346
- strippedComments: stripped !== null,
5347
- tokensSaved
5348
- };
5349
- }
5350
- const MAX_FILE_SIZE$3 = 10485760;
5351
- async function readFileContent(params, allowedDirectories, codeMinifier) {
5352
- const { path: filePath, encoding = "utf-8", offset = 0, limit, minified = false } = params;
5353
- const resolvedPath = assertPathAllowed(filePath, allowedDirectories, "read");
5354
- if (!existsSync(resolvedPath)) throw new Error(`File not found: ${filePath}`);
5355
- const stats = statSync(resolvedPath);
5356
- if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`);
5357
- if (stats.size > MAX_FILE_SIZE$3) throw new Error(`File too large: ${(stats.size / 1024 / 1024).toFixed(2)}MB (max ${MAX_FILE_SIZE$3 / 1024 / 1024}MB)`);
5358
- if (await checkIfBinary(resolvedPath) && encoding === "utf-8") throw new Error(`File appears to be binary. Use encoding 'base64' to read binary files, or specify a different encoding.`);
5359
- const content = await promises.readFile(resolvedPath, encoding);
5360
- if (typeof content === "string" && minified && encoding === "utf-8" && stats.size > 1024) {
5361
- const { content: minifiedContent, strippedComments, tokensSaved } = await minifyFileContent(content, resolvedPath, codeMinifier);
5362
- return `${`[Minified view of ${filePath} - ${strippedComments ? "comments and blank-line noise stripped" : "whitespace normalized (comments kept)"}; ~${tokensSaved} tokens saved. No line numbers. Use file_read WITHOUT minified for exact text/comments before editing.]\n`}\n${minifiedContent}`;
5363
- }
5364
- if (typeof content === "string") {
5365
- const lines = content.split("\n");
5366
- const totalLines = lines.length;
5367
- if (offset < 0) throw new Error(`Invalid offset: ${offset}. Offset must be 0 or greater.`);
5368
- if (offset >= totalLines) return `No content to show. File has ${totalLines} lines, but offset is ${offset}.\n(offset is 0-based, so valid range is 0-${Math.max(0, totalLines - 1)})`;
5369
- if (limit !== void 0 && limit > 0) {
5370
- const endLine = Math.min(offset + limit, totalLines);
5371
- const paginatedContent = lines.slice(offset, endLine).join("\n");
5372
- if (endLine < totalLines) {
5373
- const nextOffset = endLine;
5374
- return `${paginatedContent}\n\n... Showing lines ${offset + 1}-${endLine} of ${totalLines} total lines (${stats.size} bytes total).\nTo read more, use offset: ${nextOffset}`;
5375
- }
5376
- return `${paginatedContent}\n\n... Showing lines ${offset + 1}-${endLine} of ${totalLines} total lines (${stats.size} bytes total). End of file reached.`;
5377
- }
5378
- if (offset > 0) return `${lines.slice(offset).join("\n")}\n\n... Showing lines ${offset + 1}-${totalLines} of ${totalLines} total lines (${stats.size} bytes total).`;
5379
- return content;
5380
- }
5381
- return `[Binary content, ${stats.size} bytes, base64 encoded]\n${content}`;
5382
- }
5383
- /**
5384
- * Simple binary file detection by reading first 8KB and checking for null bytes
5385
- */
5386
- async function checkIfBinary(filePath) {
5387
- const buffer = Buffer.alloc(8192);
5388
- const fd = await promises.open(filePath, "r");
5389
- try {
5390
- const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
5391
- return buffer.slice(0, bytesRead).includes(0);
5392
- } finally {
5393
- await fd.close();
5394
- }
5395
- }
5396
- const fileReadTool = {
5397
- name: "file_read",
5398
- implementation: (context) => ({
5399
- toolFn: async (value) => {
5400
- const params = value;
5401
- context.logger.info("📄 FileRead: Reading file", { path: params.path });
5402
- try {
5403
- const content = await readFileContent(params, context.allowedDirectories, context.codeMinifier);
5404
- const { resolvedPath: validatedPath } = isPathAllowed(params.path, context.allowedDirectories);
5405
- const stats = statSync(validatedPath);
5406
- context.logger.info("✅ FileRead: Success", {
5407
- path: params.path,
5408
- size: stats.size,
5409
- lines: typeof content === "string" ? content.split("\n").length : "binary"
5410
- });
5411
- return content;
5412
- } catch (error) {
5413
- context.logger.error("❌ FileRead: Failed", error);
5414
- return `Error reading file: ${error instanceof Error ? error.message : String(error)}`;
5415
- }
5416
- },
5417
- toolSchema: {
5418
- name: "file_read",
5419
- description: "Read the contents of a file from the local filesystem. Supports text files with various encodings. Files are restricted to the current working directory and subdirectories for security. IMPORTANT: Read files completely by default (without offset/limit). Only use offset/limit for extremely large files (thousands of lines) that exceed context limits. Never re-read the same file multiple times - refer to previous reads in conversation history instead.",
5420
- parameters: {
5421
- type: "object",
5422
- properties: {
5423
- path: {
5424
- type: "string",
5425
- description: "Path to the file to read (relative to current working directory or absolute path within working directory)"
5426
- },
5427
- encoding: {
5428
- type: "string",
5429
- description: "File encoding (default: utf-8). Use base64 for binary files.",
5430
- enum: [
5431
- "utf-8",
5432
- "ascii",
5433
- "base64"
5434
- ]
5435
- },
5436
- offset: {
5437
- type: "number",
5438
- description: "OPTIONAL: For text files, the 0-based line number to start reading from. Only use for extremely large files (thousands of lines) that cannot fit in context. Default behavior is to read the entire file, which is preferred for most cases."
5439
- },
5440
- limit: {
5441
- type: "number",
5442
- description: "OPTIONAL: Maximum number of lines to read from offset. Only use for extremely large files (thousands of lines) that cannot fit in context. Default behavior is to read the entire file, which is preferred for most cases."
5443
- },
5444
- minified: {
5445
- type: "boolean",
5446
- description: "OPTIONAL (default false): return a token-economy view with comments and blank-line noise stripped (code/logic fully retained). Use ONLY to scan large comment-heavy source cheaply. This view has NO line numbers and is not byte-exact - always re-read WITHOUT minified for exact text, comments, or line numbers, and before editing. Ignored for binary/base64 reads and combined with offset/limit."
5447
- }
5448
- },
5449
- required: ["path"]
5450
- }
5451
- }
5452
- })
5453
- };
5454
- /**
5455
5573
  * Validated fuzzy fallback for `edit_local_file` string matching.
5456
5574
  *
5457
5575
  * This module is pure: no I/O, no `any`. It is invoked ONLY after an exact
@@ -5711,6 +5829,152 @@ function fuzzyMatch(content, oldString, newString) {
5711
5829
  }
5712
5830
  return null;
5713
5831
  }
5832
+ /**
5833
+ * Whitespace-only normalization applied on every minified read (both the AST and
5834
+ * fallback paths): normalize line endings, strip trailing whitespace, and collapse
5835
+ * runs of blank lines. Never touches non-whitespace bytes, so it can never change
5836
+ * program meaning - the safe worst case is "no reduction."
5837
+ */
5838
+ function normalizeWhitespace(source) {
5839
+ const lines = source.replace(/\r\n?/g, "\n").split("\n").map((line) => line.replace(/[ \t]+$/, ""));
5840
+ const collapsed = [];
5841
+ let blankRun = 0;
5842
+ for (const line of lines) {
5843
+ if (line === "") {
5844
+ blankRun++;
5845
+ if (blankRun > 1) continue;
5846
+ } else blankRun = 0;
5847
+ collapsed.push(line);
5848
+ }
5849
+ while (collapsed.length && collapsed[0] === "") collapsed.shift();
5850
+ while (collapsed.length && collapsed[collapsed.length - 1] === "") collapsed.pop();
5851
+ return collapsed.join("\n");
5852
+ }
5853
+ /** Rough token estimate (~4 chars/token) used only to report savings, not for billing. */
5854
+ function estimateTokens(text) {
5855
+ return Math.ceil(text.length / 4);
5856
+ }
5857
+ /**
5858
+ * Produce a minified view of `raw`. Tries AST comment-stripping via the injected
5859
+ * `codeMinifier` (comments gone) and always finishes with whitespace normalization;
5860
+ * if the minifier is absent or declines (unsupported/unparsable language) it falls
5861
+ * back to whitespace-only normalization with comments preserved. Never mutates disk.
5862
+ */
5863
+ async function minifyFileContent(raw, filePath, codeMinifier) {
5864
+ const ext = path.extname(filePath).toLowerCase();
5865
+ const stripped = codeMinifier ? await codeMinifier(raw, ext).catch(() => null) : null;
5866
+ const content = normalizeWhitespace(stripped ?? raw);
5867
+ const tokensSaved = Math.max(0, estimateTokens(raw) - estimateTokens(content));
5868
+ return {
5869
+ content,
5870
+ strippedComments: stripped !== null,
5871
+ tokensSaved
5872
+ };
5873
+ }
5874
+ const MAX_FILE_SIZE$3 = 10485760;
5875
+ async function readFileContent(params, allowedDirectories, codeMinifier) {
5876
+ const { path: filePath, encoding = "utf-8", offset = 0, limit, minified = false } = params;
5877
+ const resolvedPath = assertPathAllowed(filePath, allowedDirectories, "read");
5878
+ if (!existsSync(resolvedPath)) throw new Error(`File not found: ${filePath}`);
5879
+ const stats = statSync(resolvedPath);
5880
+ if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`);
5881
+ if (stats.size > MAX_FILE_SIZE$3) throw new Error(`File too large: ${(stats.size / 1024 / 1024).toFixed(2)}MB (max ${MAX_FILE_SIZE$3 / 1024 / 1024}MB)`);
5882
+ if (await checkIfBinary(resolvedPath) && encoding === "utf-8") throw new Error(`File appears to be binary. Use encoding 'base64' to read binary files, or specify a different encoding.`);
5883
+ const content = await promises.readFile(resolvedPath, encoding);
5884
+ if (typeof content === "string" && minified && encoding === "utf-8" && stats.size > 1024) {
5885
+ const { content: minifiedContent, strippedComments, tokensSaved } = await minifyFileContent(content, resolvedPath, codeMinifier);
5886
+ return `${`[Minified view of ${filePath} - ${strippedComments ? "comments and blank-line noise stripped" : "whitespace normalized (comments kept)"}; ~${tokensSaved} tokens saved. No line numbers. Use file_read WITHOUT minified for exact text/comments before editing.]\n`}\n${minifiedContent}`;
5887
+ }
5888
+ if (typeof content === "string") {
5889
+ const lines = content.split("\n");
5890
+ const totalLines = lines.length;
5891
+ if (offset < 0) throw new Error(`Invalid offset: ${offset}. Offset must be 0 or greater.`);
5892
+ if (offset >= totalLines) return `No content to show. File has ${totalLines} lines, but offset is ${offset}.\n(offset is 0-based, so valid range is 0-${Math.max(0, totalLines - 1)})`;
5893
+ if (limit !== void 0 && limit > 0) {
5894
+ const endLine = Math.min(offset + limit, totalLines);
5895
+ const paginatedContent = lines.slice(offset, endLine).join("\n");
5896
+ if (endLine < totalLines) {
5897
+ const nextOffset = endLine;
5898
+ return `${paginatedContent}\n\n... Showing lines ${offset + 1}-${endLine} of ${totalLines} total lines (${stats.size} bytes total).\nTo read more, use offset: ${nextOffset}`;
5899
+ }
5900
+ return `${paginatedContent}\n\n... Showing lines ${offset + 1}-${endLine} of ${totalLines} total lines (${stats.size} bytes total). End of file reached.`;
5901
+ }
5902
+ if (offset > 0) return `${lines.slice(offset).join("\n")}\n\n... Showing lines ${offset + 1}-${totalLines} of ${totalLines} total lines (${stats.size} bytes total).`;
5903
+ return content;
5904
+ }
5905
+ return `[Binary content, ${stats.size} bytes, base64 encoded]\n${content}`;
5906
+ }
5907
+ /**
5908
+ * Simple binary file detection by reading first 8KB and checking for null bytes
5909
+ */
5910
+ async function checkIfBinary(filePath) {
5911
+ const buffer = Buffer.alloc(8192);
5912
+ const fd = await promises.open(filePath, "r");
5913
+ try {
5914
+ const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
5915
+ return buffer.slice(0, bytesRead).includes(0);
5916
+ } finally {
5917
+ await fd.close();
5918
+ }
5919
+ }
5920
+ const fileReadTool = {
5921
+ name: "file_read",
5922
+ implementation: (context) => ({
5923
+ toolFn: async (value) => {
5924
+ const params = value;
5925
+ context.logger.info("📄 FileRead: Reading file", { path: params.path });
5926
+ try {
5927
+ const content = await readFileContent(params, context.allowedDirectories, context.codeMinifier);
5928
+ const { resolvedPath: validatedPath } = isPathAllowed(params.path, context.allowedDirectories);
5929
+ const stats = statSync(validatedPath);
5930
+ context.logger.info("✅ FileRead: Success", {
5931
+ path: params.path,
5932
+ size: stats.size,
5933
+ lines: typeof content === "string" ? content.split("\n").length : "binary"
5934
+ });
5935
+ return content;
5936
+ } catch (error) {
5937
+ context.logger.error("❌ FileRead: Failed", error);
5938
+ return `Error reading file: ${error instanceof Error ? error.message : String(error)}`;
5939
+ }
5940
+ },
5941
+ toolSchema: {
5942
+ name: "file_read",
5943
+ description: "Read the contents of a file from the local filesystem. Supports text files with various encodings. Files are restricted to the current working directory and subdirectories for security. IMPORTANT: Read files completely by default (without offset/limit). Only use offset/limit for extremely large files (thousands of lines) that exceed context limits. Never re-read the same file multiple times - refer to previous reads in conversation history instead.",
5944
+ parameters: {
5945
+ type: "object",
5946
+ properties: {
5947
+ path: {
5948
+ type: "string",
5949
+ description: "Path to the file to read (relative to current working directory or absolute path within working directory)"
5950
+ },
5951
+ encoding: {
5952
+ type: "string",
5953
+ description: "File encoding (default: utf-8). Use base64 for binary files.",
5954
+ enum: [
5955
+ "utf-8",
5956
+ "ascii",
5957
+ "base64"
5958
+ ]
5959
+ },
5960
+ offset: {
5961
+ type: "number",
5962
+ description: "OPTIONAL: For text files, the 0-based line number to start reading from. Only use for extremely large files (thousands of lines) that cannot fit in context. Default behavior is to read the entire file, which is preferred for most cases."
5963
+ },
5964
+ limit: {
5965
+ type: "number",
5966
+ description: "OPTIONAL: Maximum number of lines to read from offset. Only use for extremely large files (thousands of lines) that cannot fit in context. Default behavior is to read the entire file, which is preferred for most cases."
5967
+ },
5968
+ minified: {
5969
+ type: "boolean",
5970
+ description: "OPTIONAL (default false): return a token-economy view with comments and blank-line noise stripped (code/logic fully retained). Use ONLY to scan large comment-heavy source cheaply. This view has NO line numbers and is not byte-exact - always re-read WITHOUT minified for exact text, comments, or line numbers, and before editing. Ignored for binary/base64 reads and combined with offset/limit."
5971
+ }
5972
+ },
5973
+ required: ["path"]
5974
+ }
5975
+ }
5976
+ })
5977
+ };
5714
5978
  function generateDiff(original, modified) {
5715
5979
  const differences = diffLines(original, modified);
5716
5980
  let diffString = "";
@@ -6630,7 +6894,7 @@ const latticeAddEntityTool = {
6630
6894
  createdAt: /* @__PURE__ */ new Date(),
6631
6895
  updatedAt: /* @__PURE__ */ new Date()
6632
6896
  };
6633
- if (context.db.latticeModels && modelId && isObjectIdShaped(modelId)) try {
6897
+ if (context.db.latticeModels && modelId && isObjectIdShaped$1(modelId)) try {
6634
6898
  const model = await context.db.latticeModels.findById(modelId);
6635
6899
  if (model && model.userId === context.userId) {
6636
6900
  const existingIndex = model.data.entities.findIndex((e) => e.id === entityId);
@@ -6774,7 +7038,7 @@ const latticeSetValueTool = {
6774
7038
  else if (rawValue.toLowerCase() === "true") value = true;
6775
7039
  else if (rawValue.toLowerCase() === "false") value = false;
6776
7040
  const entityId = entityName.toLowerCase().replace(/\s+/g, "_");
6777
- if (context.db.latticeModels && modelId && isObjectIdShaped(modelId)) try {
7041
+ if (context.db.latticeModels && modelId && isObjectIdShaped$1(modelId)) try {
6778
7042
  const model = await context.db.latticeModels.findById(modelId);
6779
7043
  if (model && model.userId === context.userId) {
6780
7044
  const entity = model.data.entities.find((e) => e.id === entityId || e.name === entityName);
@@ -6909,7 +7173,7 @@ const latticeCreateRuleTool = {
6909
7173
  };
6910
7174
  const outputEntityId = parsedRule.outputEntity.toLowerCase().replace(/\s+/g, "_");
6911
7175
  let entityCreatedMessage = "";
6912
- if (context.db.latticeModels && modelId && isObjectIdShaped(modelId)) try {
7176
+ if (context.db.latticeModels && modelId && isObjectIdShaped$1(modelId)) try {
6913
7177
  const model = await context.db.latticeModels.findById(modelId);
6914
7178
  if (model && model.userId === context.userId) {
6915
7179
  if (!model.data.entities.some((e) => e.id === outputEntityId || e.name.toLowerCase() === parsedRule.outputEntity.toLowerCase()) && parsedRule.outputEntity !== "unknown") {
@@ -7264,10 +7528,10 @@ const latticeToolDefinitions = {
7264
7528
  */
7265
7529
  const getCliOnlyTools = async () => {
7266
7530
  const [{ createFileTool }, { globFilesTool }, { grepSearchTool }, { deleteFileTool }, { bashExecuteTool }] = await Promise.all([
7267
- import("./createFile-DPv180yF-BnWFIxey.mjs"),
7268
- import("./globFiles-DjfDGaUK-CNR8pMRC.mjs"),
7269
- import("./grepSearch-BaYUfIYs-n0XKoGnL.mjs"),
7270
- import("./deleteFile-BdjUwUQF-B3XOJmg3.mjs"),
7531
+ import("./createFile-B8bur5Rb-CVzCarEA.mjs"),
7532
+ import("./globFiles-CwJ8qmYo-BR5b2KvO.mjs"),
7533
+ import("./grepSearch-BgoOOwGe-DtlV8Gn-.mjs"),
7534
+ import("./deleteFile-9B3gW_Nb-DG2sovIl.mjs"),
7271
7535
  import("./bashExecute-CrdPpBqk-DCATrE-D.mjs")
7272
7536
  ]);
7273
7537
  return {
@@ -7288,6 +7552,150 @@ const getCliOnlyTools = async () => {
7288
7552
  };
7289
7553
  };
7290
7554
  //#endregion
7555
+ //#region src/utils/constants.ts
7556
+ /**
7557
+ * Common human name suffixes that should NOT trigger file autocomplete
7558
+ * Examples: @john.jr, @mary.phd, @bob.iii
7559
+ */
7560
+ const NAME_SUFFIXES = [
7561
+ "jr",
7562
+ "sr",
7563
+ "ii",
7564
+ "iii",
7565
+ "iv",
7566
+ "v",
7567
+ "phd",
7568
+ "md",
7569
+ "esq"
7570
+ ];
7571
+ /**
7572
+ * Type-safe check if a string is a name suffix
7573
+ */
7574
+ function isNameSuffix(value) {
7575
+ return NAME_SUFFIXES.includes(value);
7576
+ }
7577
+ //#endregion
7578
+ //#region src/utils/processFileReferences.ts
7579
+ /**
7580
+ * Regular expression to match @path references
7581
+ * Matches @ followed by a path-like string (not containing spaces)
7582
+ * Only matches @ at start of string or after whitespace
7583
+ */
7584
+ const FILE_REFERENCE_REGEX = /(?:^|\s)@([^\s@]+)/g;
7585
+ /**
7586
+ * Check if a string looks like a file path (not an email or username)
7587
+ * A file path contains / or . (file extension) at the end
7588
+ */
7589
+ function looksLikeFilePath(ref) {
7590
+ if (ref.includes("/") || ref.includes(path$1.sep)) return true;
7591
+ const extensionMatch = /\.(\w+)$/.exec(ref);
7592
+ if (extensionMatch) {
7593
+ const ext = extensionMatch[1].toLowerCase();
7594
+ if (isNameSuffix(ext)) return false;
7595
+ if (ext.length > 10) return false;
7596
+ return true;
7597
+ }
7598
+ return false;
7599
+ }
7600
+ /**
7601
+ * Extract all file references from a message
7602
+ * Only treats @reference as a file if it looks like a path (contains / or has file extension)
7603
+ */
7604
+ function extractFileReferences(message) {
7605
+ const references = [];
7606
+ FILE_REFERENCE_REGEX.lastIndex = 0;
7607
+ let match;
7608
+ while ((match = FILE_REFERENCE_REGEX.exec(message)) !== null) {
7609
+ const ref = match[1];
7610
+ if (looksLikeFilePath(ref)) references.push(ref);
7611
+ }
7612
+ return references;
7613
+ }
7614
+ /**
7615
+ * Read file contents safely.
7616
+ *
7617
+ * When `confineTo` is provided (agent-driven references, e.g. the skill tool
7618
+ * expanding `@file` in a model- or repo-authored body), every path - absolute
7619
+ * included - is confined through the shared realpath validator against the
7620
+ * working directory plus those extra allowed dirs, so `@/etc/passwd` is denied.
7621
+ * When it is omitted (a human typing `@path` in the prompt), the legacy
7622
+ * cwd-relative check applies and absolute paths the user typed are honored.
7623
+ */
7624
+ function readFileContents(filePath, confineTo) {
7625
+ const cwd = process.cwd();
7626
+ const isAbsolutePath = path$1.isAbsolute(filePath);
7627
+ if (filePath.includes("..")) return { error: `Security: Path traversal detected in "${filePath}"` };
7628
+ if (confineTo !== void 0 && !isPathAllowed(filePath, confineTo).allowed) return { error: `Access denied: Cannot read files outside allowed directories: "${filePath}"` };
7629
+ const absolutePath = isAbsolutePath ? path$1.normalize(filePath) : path$1.resolve(cwd, filePath);
7630
+ if (confineTo === void 0 && !isAbsolutePath && !isPathWithinCwd(filePath)) return { error: `Security: Relative path "${filePath}" escapes the current working directory` };
7631
+ if (!fs$2.existsSync(absolutePath)) return { error: `File not found: "${filePath}"` };
7632
+ const stats = fs$2.statSync(absolutePath);
7633
+ if (stats.isDirectory()) try {
7634
+ return {
7635
+ content: `(Directory with ${fs$2.readdirSync(absolutePath).length} items. Use file tools to explore if needed.)`,
7636
+ size: 0
7637
+ };
7638
+ } catch (err) {
7639
+ return { error: `Cannot read directory "${filePath}": ${err instanceof Error ? err.message : "Unknown error"}` };
7640
+ }
7641
+ if (stats.size > 10485760) return { error: `File too large: "${filePath}" is ${formatFileSize(stats.size)} (max ${formatFileSize(MAX_FILE_SIZE$4)})` };
7642
+ if (isBinaryFile(filePath)) return { error: `Binary file: "${filePath}" cannot be included as text content` };
7643
+ try {
7644
+ return {
7645
+ content: fs$2.readFileSync(absolutePath, "utf-8"),
7646
+ size: stats.size
7647
+ };
7648
+ } catch (err) {
7649
+ return { error: `Cannot read file "${filePath}": ${err instanceof Error ? err.message : "Unknown error"}` };
7650
+ }
7651
+ }
7652
+ /**
7653
+ * Format file content block for injection
7654
+ */
7655
+ function formatFileBlock(filePath, content, size, isDirectory) {
7656
+ if (isDirectory) return `
7657
+ --- Directory Reference: ${filePath} ---
7658
+ ${content}
7659
+ --- End of ${filePath} ---`;
7660
+ return `
7661
+ --- Referenced File: ${filePath} (${formatFileSize(size)}) ---
7662
+ ${content}
7663
+ --- End of ${filePath} ---`;
7664
+ }
7665
+ /**
7666
+ * Process file references in a message
7667
+ * Extracts @path references and injects file contents
7668
+ */
7669
+ async function processFileReferences(message, confineTo) {
7670
+ const references = extractFileReferences(message);
7671
+ const errors = [];
7672
+ const fileBlocks = [];
7673
+ for (const ref of references) {
7674
+ const result = readFileContents(ref, confineTo);
7675
+ if ("error" in result) {
7676
+ errors.push(result.error);
7677
+ continue;
7678
+ }
7679
+ const isDirectory = result.size === 0 && result.content.startsWith("(Directory");
7680
+ fileBlocks.push(formatFileBlock(ref, result.content, result.size, isDirectory));
7681
+ }
7682
+ if (fileBlocks.length === 0) return {
7683
+ content: message,
7684
+ errors
7685
+ };
7686
+ return {
7687
+ content: message + "\n" + fileBlocks.join("\n"),
7688
+ errors
7689
+ };
7690
+ }
7691
+ /**
7692
+ * Check if a message contains any file references
7693
+ */
7694
+ function hasFileReferences(message) {
7695
+ FILE_REFERENCE_REGEX.lastIndex = 0;
7696
+ return FILE_REFERENCE_REGEX.test(message);
7697
+ }
7698
+ //#endregion
7291
7699
  //#region src/storage/SessionStore.ts
7292
7700
  /**
7293
7701
  * Manages conversation sessions stored as JSON files
@@ -7956,6 +8364,7 @@ async function findMarkdownFiles(directory, visitedRealPaths = /* @__PURE__ */ n
7956
8364
  var CustomCommandStore = class {
7957
8365
  constructor(projectRoot, options = {}) {
7958
8366
  this.commands = /* @__PURE__ */ new Map();
8367
+ this.projectTrusted = false;
7959
8368
  this.remoteSource = options.remoteSource;
7960
8369
  const home = os.homedir();
7961
8370
  const root = projectRoot || process.cwd();
@@ -7979,7 +8388,7 @@ var CustomCommandStore = class {
7979
8388
  async loadCommands() {
7980
8389
  this.commands.clear();
7981
8390
  for (const dir of this.globalCommandsDirs) await this.loadCommandsFromDirectory(dir, "global");
7982
- for (const dir of this.projectCommandsDirs) await this.loadCommandsFromDirectory(dir, "project");
8391
+ if (this.projectTrusted) for (const dir of this.projectCommandsDirs) await this.loadCommandsFromDirectory(dir, "project");
7983
8392
  await this.mergeRemoteCommands();
7984
8393
  }
7985
8394
  /**
@@ -7992,6 +8401,13 @@ var CustomCommandStore = class {
7992
8401
  this.remoteSource = source;
7993
8402
  }
7994
8403
  /**
8404
+ * Set whether the project root is trusted. When false, `loadCommands()` skips
8405
+ * the project command/skill directories. Call before `loadCommands()`.
8406
+ */
8407
+ setProjectTrusted(trusted) {
8408
+ this.projectTrusted = trusted;
8409
+ }
8410
+ /**
7995
8411
  * Fetch remote skills and merge them into the loaded map under any name
7996
8412
  * not already taken by a local file. The sole precedence-enforcement path -
7997
8413
  * `loadCommands()` calls this after the local scans, and the production CLI
@@ -8231,87 +8647,6 @@ var RemoteSkillSource = class {
8231
8647
  }
8232
8648
  };
8233
8649
  //#endregion
8234
- //#region src/config/toolSafety.ts
8235
- /**
8236
- * Tool safety categories determine when permission is required
8237
- */
8238
- const ToolCategorySchema = z$1.enum([
8239
- "auto_approve",
8240
- "prompt_always",
8241
- "prompt_default"
8242
- ]);
8243
- z$1.object({
8244
- categories: z$1.record(z$1.string(), ToolCategorySchema),
8245
- trustedTools: z$1.array(z$1.string())
8246
- });
8247
- /**
8248
- * Default tool categories
8249
- *
8250
- * Categories:
8251
- * - auto_approve: Safe tools that don't need permission (math, search, datetime)
8252
- * - prompt_always: Dangerous tools that ALWAYS need permission, cannot be trusted (file edits, shell commands)
8253
- * - prompt_default: Tools that prompt by default but users can trust them (file reads, searches)
8254
- */
8255
- const DEFAULT_TOOL_CATEGORIES = {
8256
- math_evaluate: "auto_approve",
8257
- current_datetime: "auto_approve",
8258
- dice_roll: "auto_approve",
8259
- prompt_enhancement: "auto_approve",
8260
- find_definition: "auto_approve",
8261
- ask_user_question: "auto_approve",
8262
- weather_info: "prompt_default",
8263
- edit_file: "prompt_always",
8264
- edit_local_file: "prompt_always",
8265
- create_file: "prompt_always",
8266
- delete_file: "prompt_always",
8267
- shell_execute: "prompt_always",
8268
- bash_execute: "prompt_always",
8269
- write_shell_stdin: "prompt_always",
8270
- kill_background_shell: "prompt_always",
8271
- git_commit: "prompt_always",
8272
- git_push: "prompt_always",
8273
- web_search: "prompt_default",
8274
- check_shell_output: "prompt_default",
8275
- list_background_shells: "prompt_default",
8276
- web_fetch: "prompt_default",
8277
- deep_research: "prompt_default",
8278
- file_read: "prompt_default",
8279
- grep_search: "prompt_default",
8280
- glob_files: "prompt_default",
8281
- get_file_tree: "prompt_default",
8282
- get_file_structure: "prompt_default",
8283
- git_status: "prompt_default",
8284
- git_diff: "prompt_default",
8285
- git_log: "prompt_default",
8286
- git_branch: "prompt_default"
8287
- };
8288
- /**
8289
- * Get the category for a tool
8290
- * Returns 'prompt_default' if tool is not in the default categories
8291
- */
8292
- function getToolCategory(toolName, customCategories) {
8293
- if (customCategories && toolName in customCategories) return customCategories[toolName];
8294
- if (toolName in DEFAULT_TOOL_CATEGORIES) return DEFAULT_TOOL_CATEGORIES[toolName];
8295
- return "prompt_default";
8296
- }
8297
- /**
8298
- * Check if a tool can be trusted (not prompt_always)
8299
- */
8300
- function canTrustTool(toolName, customCategories) {
8301
- return getToolCategory(toolName, customCategories) !== "prompt_always";
8302
- }
8303
- /**
8304
- * Check if a tool is read-only (safe for parallel execution).
8305
- * Write tools (prompt_always category) must always be sequential.
8306
- *
8307
- * @param toolName - Name of the tool to check
8308
- * @param customCategories - Optional custom category overrides
8309
- * @returns true if the tool is read-only, false if it's a write tool
8310
- */
8311
- function isReadOnlyTool(toolName, customCategories) {
8312
- return getToolCategory(toolName, customCategories) !== "prompt_always";
8313
- }
8314
- //#endregion
8315
8650
  //#region src/core/skillsPrompt.ts
8316
8651
  /**
8317
8652
  * Get the display name for a skill
@@ -8788,10 +9123,30 @@ async function generateFileDiffPreview(args) {
8788
9123
  }
8789
9124
  }
8790
9125
  /**
8791
- * Generate a preview for edit_local_file (string replacement)
9126
+ * Generate a preview for edit_local_file (string replacement).
9127
+ *
9128
+ * Shows the ACTUAL span edit_local_file will delete and its replacement, not
9129
+ * just the model's typed old_string. A block-anchor (fuzzy) match can span more
9130
+ * lines than old_string names, so previewing old_string alone would let a wider
9131
+ * region be replaced than the user approved. Mirrors the tool's own match order:
9132
+ * exact substring first, then the shared fuzzy matcher.
8792
9133
  */
8793
- function generateEditLocalFilePreview(args) {
8794
- const diffLines = Diff.createPatch(args.path, args.old_string, args.new_string, "Current", "Proposed", { context: 3 }).split("\n").slice(4);
9134
+ async function generateEditLocalFilePreview(args) {
9135
+ let deleted = args.old_string;
9136
+ let inserted = args.new_string;
9137
+ try {
9138
+ if (existsSync(args.path)) {
9139
+ const currentContent = await readFile(args.path, "utf-8");
9140
+ if (!currentContent.includes(args.old_string)) {
9141
+ const fuzzy = fuzzyMatch(currentContent, args.old_string, args.new_string);
9142
+ if (fuzzy) {
9143
+ deleted = fuzzy.matchedText;
9144
+ inserted = fuzzy.replacement;
9145
+ }
9146
+ }
9147
+ }
9148
+ } catch {}
9149
+ const diffLines = Diff.createPatch(args.path, deleted, inserted, "Current", "Proposed", { context: 3 }).split("\n").slice(4);
8795
9150
  return `[Edit in: ${args.path}]\n\n${diffLines.join("\n")}`;
8796
9151
  }
8797
9152
  /**
@@ -9127,252 +9482,12 @@ async function runShellCommand(options) {
9127
9482
  });
9128
9483
  }
9129
9484
  //#endregion
9130
- //#region src/agents/hookExecutor.ts
9131
- const DEFAULT_HOOK_TIMEOUT_SECONDS = 60;
9132
- /**
9133
- * Execute a single command hook
9134
- *
9135
- * @param hook - Hook definition to execute
9136
- * @param context - Context to pass to the hook
9137
- * @returns Hook execution result
9138
- */
9139
- async function executeCommandHook(hook, context) {
9140
- if (!hook.command) return { decision: "allow" };
9141
- const timeoutSeconds = hook.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS;
9142
- const result = await runShellCommand({
9143
- command: hook.command,
9144
- cwd: context.cwd,
9145
- timeoutMs: timeoutSeconds * 1e3,
9146
- env: {
9147
- ...process.env,
9148
- B4M_PROJECT_DIR: context.cwd,
9149
- B4M_AGENT_NAME: context.agent_name,
9150
- B4M_SESSION_ID: context.session_id
9151
- },
9152
- stdin: JSON.stringify(context)
9153
- });
9154
- if (result.timedOut) return {
9155
- decision: "deny",
9156
- reason: `Hook timed out after ${timeoutSeconds}s`
9157
- };
9158
- if (result.exitCode === null) {
9159
- console.warn(`Hook execution error: ${result.stderr}`);
9160
- return { decision: "allow" };
9161
- }
9162
- if (result.exitCode === 2) return {
9163
- decision: "deny",
9164
- reason: result.stderr.trim() || "Hook blocked execution"
9165
- };
9166
- if (result.exitCode !== 0) {
9167
- console.warn(`Hook exited with code ${result.exitCode}: ${result.stderr.trim()}`);
9168
- return { decision: "allow" };
9169
- }
9170
- try {
9171
- const parsed = JSON.parse(result.stdout.trim());
9172
- return {
9173
- decision: parsed.decision || "allow",
9174
- reason: parsed.reason,
9175
- updatedInput: parsed.updatedInput
9176
- };
9177
- } catch {
9178
- return { decision: "allow" };
9179
- }
9180
- }
9181
- /**
9182
- * Maximum allowed length for regex patterns to prevent ReDoS attacks
9183
- */
9184
- const MAX_PATTERN_LENGTH = 200;
9185
- /**
9186
- * Check if a tool name matches a regex pattern.
9187
- *
9188
- * Uses raw regex patterns (e.g. "Edit|Write", "bash_.*"), unlike toolFilter.ts
9189
- * which uses wildcard patterns (e.g. "mcp__github__*"). Regex allows more
9190
- * powerful matching in hook definitions.
9191
- *
9192
- * Security: patterns are length-limited to prevent ReDoS.
9193
- *
9194
- * @param toolName - The tool name to check
9195
- * @param pattern - Regex pattern to match against
9196
- * @returns true if the tool matches the pattern
9197
- */
9198
- function matchesToolPattern$1(toolName, pattern) {
9199
- if (pattern.length > MAX_PATTERN_LENGTH) {
9200
- console.warn(`Hook pattern exceeds max length (${MAX_PATTERN_LENGTH}), skipping: ${pattern.slice(0, 50)}...`);
9201
- return false;
9202
- }
9203
- try {
9204
- return new RegExp(`^${pattern}$`).test(toolName);
9205
- } catch {
9206
- return false;
9207
- }
9208
- }
9209
- /**
9210
- * Execute all matching hooks for an event
9211
- *
9212
- * @param hooks - Array of hook matchers to evaluate
9213
- * @param context - Context to pass to matching hooks
9214
- * @returns Aggregated hook result
9215
- */
9216
- async function executeHooks(hooks, context) {
9217
- if (!hooks || hooks.length === 0) return { decision: "allow" };
9218
- const matchingHooks = [];
9219
- for (const matcher of hooks) if (!matcher.matcher || !context.tool_name || matchesToolPattern$1(context.tool_name, matcher.matcher)) matchingHooks.push(...matcher.hooks);
9220
- if (matchingHooks.length === 0) return { decision: "allow" };
9221
- const results = await Promise.all(matchingHooks.filter((hook) => hook.type === "command").map((hook) => executeCommandHook(hook, context)));
9222
- for (const result of results) if (result.decision === "deny" || result.decision === "block") return result;
9223
- let updatedInput;
9224
- for (const result of results) if (result.updatedInput) updatedInput = {
9225
- ...updatedInput,
9226
- ...result.updatedInput
9227
- };
9228
- return {
9229
- decision: "allow",
9230
- updatedInput
9231
- };
9232
- }
9233
- /**
9234
- * Build hook context from orchestrator state
9235
- */
9236
- function buildHookContext(params) {
9237
- return {
9238
- session_id: params.sessionId,
9239
- agent_name: params.agentName,
9240
- cwd: params.cwd,
9241
- hook_event_name: params.hookEventName,
9242
- tool_name: params.toolName,
9243
- tool_input: params.toolInput,
9244
- tool_use_id: params.toolUseId,
9245
- tool_result: params.toolResult,
9246
- error: params.error
9247
- };
9248
- }
9249
- //#endregion
9250
- //#region src/agents/types.ts
9251
- /**
9252
- * Type definitions for the Unified Markdown-Based Agent System
9253
- *
9254
- * This module defines types for:
9255
- * - Agent definitions parsed from markdown files
9256
- * - Frontmatter schema for agent configuration
9257
- * - Lifecycle hooks for agents
9258
- * - Tool filtering patterns
9259
- */
9260
- /**
9261
- * Error thrown when a hook blocks tool execution
9262
- *
9263
- * This error is used to stop the agent gracefully when a PreToolUse
9264
- * or PostToolUse hook returns a 'block' decision.
9265
- */
9266
- var HookBlockedError = class extends Error {
9267
- constructor(toolName, reason) {
9268
- super(`Hook blocked execution of ${toolName}: ${reason || "No reason provided"}`);
9269
- this.name = "HookBlockedError";
9270
- this.toolName = toolName;
9271
- }
9272
- };
9273
- /**
9274
- * Tools that are ALWAYS denied for spawned agents
9275
- * Prevents agent chaining and other dangerous patterns
9276
- */
9277
- const ALWAYS_DENIED_FOR_AGENTS = [
9278
- "agent_delegate",
9279
- "create_dynamic_agent",
9280
- "coordinate_task",
9281
- "resume_agent"
9282
- ];
9283
- /**
9284
- * Default retry configuration for agent execution
9285
- */
9286
- const DEFAULT_RETRY_CONFIG = {
9287
- maxRetries: 2,
9288
- initialDelayMs: 1e3
9289
- };
9290
- /**
9291
- * Schema for a command hook definition
9292
- */
9293
- const CommandHookSchema = z$1.object({
9294
- type: z$1.literal("command"),
9295
- command: z$1.string().min(1, "Command is required for command hooks"),
9296
- timeout: z$1.number().optional()
9297
- });
9298
- /**
9299
- * Schema for a prompt hook definition
9300
- */
9301
- const PromptHookSchema = z$1.object({
9302
- type: z$1.literal("prompt"),
9303
- prompt: z$1.string().min(1, "Prompt is required for prompt hooks"),
9304
- timeout: z$1.number().optional()
9305
- });
9306
- /**
9307
- * Schema for a single hook definition (discriminated union)
9308
- * Ensures command hooks require 'command' field and prompt hooks require 'prompt' field
9309
- */
9310
- const HookDefinitionSchema = z$1.discriminatedUnion("type", [CommandHookSchema, PromptHookSchema]);
9311
- /**
9312
- * Schema for a hook matcher with its hooks
9313
- */
9314
- const HookMatcherSchema = z$1.object({
9315
- matcher: z$1.string().optional(),
9316
- hooks: z$1.array(HookDefinitionSchema)
9317
- });
9318
- /**
9319
- * Schema for agent hooks configuration
9320
- */
9321
- const AgentHooksSchema = z$1.object({
9322
- PreToolUse: z$1.array(HookMatcherSchema).optional(),
9323
- PostToolUse: z$1.array(HookMatcherSchema).optional(),
9324
- PostToolUseFailure: z$1.array(HookMatcherSchema).optional(),
9325
- Stop: z$1.array(HookMatcherSchema).optional()
9326
- }).optional();
9327
- /**
9328
- * Schema for validating agent frontmatter
9329
- */
9330
- const AgentFrontmatterSchema = z$1.object({
9331
- description: z$1.string().min(1, "Agent description is required"),
9332
- model: z$1.string().optional(),
9333
- "allowed-tools": z$1.array(z$1.string()).optional(),
9334
- "denied-tools": z$1.array(z$1.string()).optional(),
9335
- skills: z$1.array(z$1.string()).optional(),
9336
- "max-iterations": z$1.object({
9337
- quick: z$1.int().positive().optional(),
9338
- medium: z$1.int().positive().optional(),
9339
- very_thorough: z$1.int().positive().optional()
9340
- }).optional(),
9341
- "default-thoroughness": z$1.enum([
9342
- "quick",
9343
- "medium",
9344
- "very_thorough"
9345
- ]).optional(),
9346
- variables: z$1.record(z$1.string(), z$1.string()).optional(),
9347
- hooks: AgentHooksSchema,
9348
- retry: z$1.object({
9349
- maxRetries: z$1.int().nonnegative().optional(),
9350
- initialDelay: z$1.number().positive().optional()
9351
- }).optional(),
9352
- "shared-context": z$1.array(z$1.enum(["read", "write"])).optional()
9353
- });
9354
- /**
9355
- * Default iteration limits for agents
9356
- */
9357
- const DEFAULT_MAX_ITERATIONS = {
9358
- quick: 4,
9359
- medium: 10,
9360
- very_thorough: 20
9361
- };
9362
- /**
9363
- * Default model for agents
9364
- */
9365
- const DEFAULT_AGENT_MODEL = ChatModels.CLAUDE_4_5_HAIKU;
9366
- /**
9367
- * Default thoroughness level
9368
- */
9369
- const DEFAULT_THOROUGHNESS = "medium";
9370
- //#endregion
9371
9485
  //#region src/config/commandRisk.ts
9372
9486
  const RISK_ORDER = {
9373
9487
  low: 0,
9374
- medium: 1,
9375
- high: 2
9488
+ unclassified: 1,
9489
+ medium: 2,
9490
+ high: 3
9376
9491
  };
9377
9492
  /**
9378
9493
  * Wrapper programs that execute another program passed as their arguments.
@@ -9839,7 +9954,7 @@ function skipToInnerProgram(args, onPrivEscalation) {
9839
9954
  * into `-c` interpreter code and `eval` arguments.
9840
9955
  */
9841
9956
  function classifySimpleCommand(args, reasons, depth) {
9842
- let level = "low";
9957
+ let level = "unclassified";
9843
9958
  const escalatorIndex = args.findIndex((arg) => ESCALATORS_WITH_COMMAND_FLAG.has(programName(arg)));
9844
9959
  if (escalatorIndex !== -1) {
9845
9960
  const code = commandFlagValue(args, escalatorIndex);
@@ -10093,6 +10208,285 @@ function writesToBlockDevice(tokens) {
10093
10208
  return false;
10094
10209
  }
10095
10210
  //#endregion
10211
+ //#region src/utils/commandPermission.ts
10212
+ /**
10213
+ * Gate a hook's shell command through the same permission path as bash_execute,
10214
+ * BEFORE the command runs. Hooks are prompt_always-equivalent: they may be
10215
+ * allowed once or for the session, but never permanently trusted. Trusting the
10216
+ * project folder loads the hook definitions; it does not pre-authorize the shell
10217
+ * commands they carry.
10218
+ */
10219
+ async function requestShellCommandPermission(toolName, command, cwd, deps) {
10220
+ const { permissionManager, promptFn } = deps;
10221
+ if (!permissionManager.needsPermission(toolName)) return { allowed: true };
10222
+ const risk = classifyCommandRisk(command);
10223
+ const reasons = risk.reasons.length ? `\nReasons: ${risk.reasons.join("; ")}` : "";
10224
+ const preview = `Hook shell command [${risk.level} risk] in ${cwd}:\n${command}${reasons}`;
10225
+ const { action } = await promptFn(toolName, {
10226
+ command,
10227
+ cwd
10228
+ }, preview);
10229
+ switch (action) {
10230
+ case "allow-session":
10231
+ permissionManager.trustToolForSession(toolName);
10232
+ return { allowed: true };
10233
+ case "allow-once":
10234
+ case "allow-always": return { allowed: true };
10235
+ default: return {
10236
+ allowed: false,
10237
+ reason: "Hook command denied by user"
10238
+ };
10239
+ }
10240
+ }
10241
+ //#endregion
10242
+ //#region src/agents/hookExecutor.ts
10243
+ const DEFAULT_HOOK_TIMEOUT_SECONDS = 60;
10244
+ /**
10245
+ * Execute a single command hook
10246
+ *
10247
+ * @param hook - Hook definition to execute
10248
+ * @param context - Context to pass to the hook
10249
+ * @returns Hook execution result
10250
+ */
10251
+ async function executeCommandHook(hook, context, perm) {
10252
+ if (!hook.command) return { decision: "allow" };
10253
+ if (perm) {
10254
+ const decision = await requestShellCommandPermission(`agent_hook:${context.hook_event_name}`, hook.command, context.cwd, perm);
10255
+ if (!decision.allowed) return {
10256
+ decision: "deny",
10257
+ reason: decision.reason || "Hook command denied"
10258
+ };
10259
+ }
10260
+ const timeoutSeconds = hook.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS;
10261
+ const result = await runShellCommand({
10262
+ command: hook.command,
10263
+ cwd: context.cwd,
10264
+ timeoutMs: timeoutSeconds * 1e3,
10265
+ env: {
10266
+ ...process.env,
10267
+ B4M_PROJECT_DIR: context.cwd,
10268
+ B4M_AGENT_NAME: context.agent_name,
10269
+ B4M_SESSION_ID: context.session_id
10270
+ },
10271
+ stdin: JSON.stringify(context)
10272
+ });
10273
+ if (result.timedOut) return {
10274
+ decision: "deny",
10275
+ reason: `Hook timed out after ${timeoutSeconds}s`
10276
+ };
10277
+ if (result.exitCode === null) {
10278
+ console.warn(`Hook execution error: ${result.stderr}`);
10279
+ return { decision: "allow" };
10280
+ }
10281
+ if (result.exitCode === 2) return {
10282
+ decision: "deny",
10283
+ reason: result.stderr.trim() || "Hook blocked execution"
10284
+ };
10285
+ if (result.exitCode !== 0) {
10286
+ console.warn(`Hook exited with code ${result.exitCode}: ${result.stderr.trim()}`);
10287
+ return { decision: "allow" };
10288
+ }
10289
+ try {
10290
+ const parsed = JSON.parse(result.stdout.trim());
10291
+ return {
10292
+ decision: parsed.decision || "allow",
10293
+ reason: parsed.reason,
10294
+ updatedInput: parsed.updatedInput
10295
+ };
10296
+ } catch {
10297
+ return { decision: "allow" };
10298
+ }
10299
+ }
10300
+ /**
10301
+ * Maximum allowed length for regex patterns to prevent ReDoS attacks
10302
+ */
10303
+ const MAX_PATTERN_LENGTH = 200;
10304
+ /**
10305
+ * Check if a tool name matches a regex pattern.
10306
+ *
10307
+ * Uses raw regex patterns (e.g. "Edit|Write", "bash_.*"), unlike toolFilter.ts
10308
+ * which uses wildcard patterns (e.g. "mcp__github__*"). Regex allows more
10309
+ * powerful matching in hook definitions.
10310
+ *
10311
+ * Security: patterns are length-limited to prevent ReDoS.
10312
+ *
10313
+ * @param toolName - The tool name to check
10314
+ * @param pattern - Regex pattern to match against
10315
+ * @returns true if the tool matches the pattern
10316
+ */
10317
+ function matchesToolPattern$1(toolName, pattern) {
10318
+ if (pattern.length > MAX_PATTERN_LENGTH) {
10319
+ console.warn(`Hook pattern exceeds max length (${MAX_PATTERN_LENGTH}), skipping: ${pattern.slice(0, 50)}...`);
10320
+ return false;
10321
+ }
10322
+ try {
10323
+ return new RegExp(`^${pattern}$`).test(toolName);
10324
+ } catch {
10325
+ return false;
10326
+ }
10327
+ }
10328
+ /**
10329
+ * Execute all matching hooks for an event
10330
+ *
10331
+ * @param hooks - Array of hook matchers to evaluate
10332
+ * @param context - Context to pass to matching hooks
10333
+ * @returns Aggregated hook result
10334
+ */
10335
+ async function executeHooks(hooks, context, perm) {
10336
+ if (!hooks || hooks.length === 0) return { decision: "allow" };
10337
+ const matchingHooks = [];
10338
+ for (const matcher of hooks) if (!matcher.matcher || !context.tool_name || matchesToolPattern$1(context.tool_name, matcher.matcher)) matchingHooks.push(...matcher.hooks);
10339
+ if (matchingHooks.length === 0) return { decision: "allow" };
10340
+ const results = await Promise.all(matchingHooks.filter((hook) => hook.type === "command").map((hook) => executeCommandHook(hook, context, perm)));
10341
+ for (const result of results) if (result.decision === "deny" || result.decision === "block") return result;
10342
+ let updatedInput;
10343
+ for (const result of results) if (result.updatedInput) updatedInput = {
10344
+ ...updatedInput,
10345
+ ...result.updatedInput
10346
+ };
10347
+ return {
10348
+ decision: "allow",
10349
+ updatedInput
10350
+ };
10351
+ }
10352
+ /**
10353
+ * Build hook context from orchestrator state
10354
+ */
10355
+ function buildHookContext(params) {
10356
+ return {
10357
+ session_id: params.sessionId,
10358
+ agent_name: params.agentName,
10359
+ cwd: params.cwd,
10360
+ hook_event_name: params.hookEventName,
10361
+ tool_name: params.toolName,
10362
+ tool_input: params.toolInput,
10363
+ tool_use_id: params.toolUseId,
10364
+ tool_result: params.toolResult,
10365
+ error: params.error
10366
+ };
10367
+ }
10368
+ //#endregion
10369
+ //#region src/agents/types.ts
10370
+ /**
10371
+ * Type definitions for the Unified Markdown-Based Agent System
10372
+ *
10373
+ * This module defines types for:
10374
+ * - Agent definitions parsed from markdown files
10375
+ * - Frontmatter schema for agent configuration
10376
+ * - Lifecycle hooks for agents
10377
+ * - Tool filtering patterns
10378
+ */
10379
+ /**
10380
+ * Error thrown when a hook blocks tool execution
10381
+ *
10382
+ * This error is used to stop the agent gracefully when a PreToolUse
10383
+ * or PostToolUse hook returns a 'block' decision.
10384
+ */
10385
+ var HookBlockedError = class extends Error {
10386
+ constructor(toolName, reason) {
10387
+ super(`Hook blocked execution of ${toolName}: ${reason || "No reason provided"}`);
10388
+ this.name = "HookBlockedError";
10389
+ this.toolName = toolName;
10390
+ }
10391
+ };
10392
+ /**
10393
+ * Tools that are ALWAYS denied for spawned agents
10394
+ * Prevents agent chaining and other dangerous patterns
10395
+ */
10396
+ const ALWAYS_DENIED_FOR_AGENTS = [
10397
+ "agent_delegate",
10398
+ "create_dynamic_agent",
10399
+ "coordinate_task",
10400
+ "resume_agent"
10401
+ ];
10402
+ /**
10403
+ * Default retry configuration for agent execution
10404
+ */
10405
+ const DEFAULT_RETRY_CONFIG = {
10406
+ maxRetries: 2,
10407
+ initialDelayMs: 1e3
10408
+ };
10409
+ /**
10410
+ * Schema for a command hook definition
10411
+ */
10412
+ const CommandHookSchema = z$1.object({
10413
+ type: z$1.literal("command"),
10414
+ command: z$1.string().min(1, "Command is required for command hooks"),
10415
+ timeout: z$1.number().optional()
10416
+ });
10417
+ /**
10418
+ * Schema for a prompt hook definition
10419
+ */
10420
+ const PromptHookSchema = z$1.object({
10421
+ type: z$1.literal("prompt"),
10422
+ prompt: z$1.string().min(1, "Prompt is required for prompt hooks"),
10423
+ timeout: z$1.number().optional()
10424
+ });
10425
+ /**
10426
+ * Schema for a single hook definition (discriminated union)
10427
+ * Ensures command hooks require 'command' field and prompt hooks require 'prompt' field
10428
+ */
10429
+ const HookDefinitionSchema = z$1.discriminatedUnion("type", [CommandHookSchema, PromptHookSchema]);
10430
+ /**
10431
+ * Schema for a hook matcher with its hooks
10432
+ */
10433
+ const HookMatcherSchema = z$1.object({
10434
+ matcher: z$1.string().optional(),
10435
+ hooks: z$1.array(HookDefinitionSchema)
10436
+ });
10437
+ /**
10438
+ * Schema for agent hooks configuration
10439
+ */
10440
+ const AgentHooksSchema = z$1.object({
10441
+ PreToolUse: z$1.array(HookMatcherSchema).optional(),
10442
+ PostToolUse: z$1.array(HookMatcherSchema).optional(),
10443
+ PostToolUseFailure: z$1.array(HookMatcherSchema).optional(),
10444
+ Stop: z$1.array(HookMatcherSchema).optional()
10445
+ }).optional();
10446
+ /**
10447
+ * Schema for validating agent frontmatter
10448
+ */
10449
+ const AgentFrontmatterSchema = z$1.object({
10450
+ description: z$1.string().min(1, "Agent description is required"),
10451
+ model: z$1.string().optional(),
10452
+ "allowed-tools": z$1.array(z$1.string()).optional(),
10453
+ "denied-tools": z$1.array(z$1.string()).optional(),
10454
+ skills: z$1.array(z$1.string()).optional(),
10455
+ "max-iterations": z$1.object({
10456
+ quick: z$1.int().positive().optional(),
10457
+ medium: z$1.int().positive().optional(),
10458
+ very_thorough: z$1.int().positive().optional()
10459
+ }).optional(),
10460
+ "default-thoroughness": z$1.enum([
10461
+ "quick",
10462
+ "medium",
10463
+ "very_thorough"
10464
+ ]).optional(),
10465
+ variables: z$1.record(z$1.string(), z$1.string()).optional(),
10466
+ hooks: AgentHooksSchema,
10467
+ retry: z$1.object({
10468
+ maxRetries: z$1.int().nonnegative().optional(),
10469
+ initialDelay: z$1.number().positive().optional()
10470
+ }).optional(),
10471
+ "shared-context": z$1.array(z$1.enum(["read", "write"])).optional()
10472
+ });
10473
+ /**
10474
+ * Default iteration limits for agents
10475
+ */
10476
+ const DEFAULT_MAX_ITERATIONS = {
10477
+ quick: 4,
10478
+ medium: 10,
10479
+ very_thorough: 20
10480
+ };
10481
+ /**
10482
+ * Default model for agents
10483
+ */
10484
+ const DEFAULT_AGENT_MODEL = ChatModels.CLAUDE_4_5_HAIKU;
10485
+ /**
10486
+ * Default thoroughness level
10487
+ */
10488
+ const DEFAULT_THOROUGHNESS = "medium";
10489
+ //#endregion
10096
10490
  //#region src/config/shellCommandFields.ts
10097
10491
  /**
10098
10492
  * Shell-like tools whose free-text command argument must be run through the
@@ -10106,7 +10500,10 @@ function writesToBlockDevice(tokens) {
10106
10500
  * - the interactive/host gate (utils/toolsAdapter.ts), and
10107
10501
  * - the headless protocol's risk classifier (commands/headlessProtocol.ts).
10108
10502
  */
10109
- const SHELL_LIKE_TOOL_COMMAND_FIELDS = { bash_execute: "command" };
10503
+ const SHELL_LIKE_TOOL_COMMAND_FIELDS = {
10504
+ bash_execute: "command",
10505
+ write_shell_stdin: "chars"
10506
+ };
10110
10507
  //#endregion
10111
10508
  //#region ../../b4m-core/utils/dist/globMatches.mjs
10112
10509
  /**
@@ -10437,7 +10834,7 @@ function wrapToolWithPermission(tool, permissionManager, showPermissionPrompt, a
10437
10834
  if (!isPathAccessDenial(msg)) throw err;
10438
10835
  result = msg;
10439
10836
  }
10440
- cleanupSandboxFiles(effectiveArgs?._sandboxCleanup);
10837
+ cleanupSandboxFiles(isSandboxed ? sandboxedArgs?._sandboxCleanup : void 0);
10441
10838
  await captureViolations(isSandboxed, result, args?.command, sandboxOrchestrator);
10442
10839
  result = await retrySandboxFailure(isSandboxed, result, toolName, args, apiClient, originalFn, showPermissionPrompt);
10443
10840
  result = await retryPathAccessDenial(result, toolName, effectiveArgs, allowedDirectories, configStore, apiClient, originalFn, showPermissionPrompt);
@@ -10486,6 +10883,14 @@ function wrapToolWithPermission(tool, permissionManager, showPermissionPrompt, a
10486
10883
  };
10487
10884
  }
10488
10885
  /**
10886
+ * Route a set of otherwise-raw tools through the permission wrapper. This is the
10887
+ * single choke-point every tool the model can call must pass through; anything
10888
+ * constructed outside generateCliTools is wrapped here before it reaches the agent.
10889
+ */
10890
+ function wrapTools(tools, deps) {
10891
+ return tools.map((tool) => wrapToolWithPermission(tool, deps.permissionManager, deps.showPermissionPrompt, deps.agentContext, deps.configStore, deps.apiClient, deps.sandboxOrchestrator, deps.allowedDirectories, deps.interactionModeOverride));
10892
+ }
10893
+ /**
10489
10894
  * Detect whether a tool result indicates a sandbox-specific runtime failure.
10490
10895
  * Returns true for errors originating from sandbox-exec (macOS) or bwrap (Linux).
10491
10896
  */
@@ -10541,13 +10946,17 @@ async function retryPathAccessDenial(result, toolName, args, allowedDirectories,
10541
10946
  if (!allowedDirectories || !isPathAccessDenial(result)) return result;
10542
10947
  const grantDir = deriveGrantDirectory(toolName, args);
10543
10948
  if (!grantDir) return result;
10544
- if (allowedDirectories.includes(grantDir)) return result;
10545
- const response = await showPermissionPrompt(toolName, args, `🔒 DIRECTORY ACCESS — "${toolName}" needs a path outside the current workspace.\n\n- Grant access to this directory:\n ${grantDir}\n- "Allow for this session" grants access until the CLI exits.\n- "Always allow" also saves it to your config so it persists across sessions.`);
10949
+ let resolvedGrantDir = grantDir;
10950
+ try {
10951
+ resolvedGrantDir = realpathSync(grantDir);
10952
+ } catch {}
10953
+ if (allowedDirectories.includes(resolvedGrantDir)) return result;
10954
+ const response = await showPermissionPrompt(toolName, args, `🔒 DIRECTORY ACCESS — "${toolName}" needs a path outside the current workspace.\n\n- Grant access to this directory:\n ${resolvedGrantDir}\n- "Allow for this session" grants access until the CLI exits.\n- "Always allow" also saves it to your config so it persists across sessions.`, "directory-grant");
10546
10955
  if (response.action === "deny") return result;
10547
10956
  const oneShot = response.action === "allow-once";
10548
- allowedDirectories.push(grantDir);
10957
+ allowedDirectories.push(resolvedGrantDir);
10549
10958
  if (response.action === "allow-always") try {
10550
- await configStore.addDirectory(grantDir);
10959
+ await configStore.addDirectory(resolvedGrantDir);
10551
10960
  } catch {}
10552
10961
  try {
10553
10962
  return await executeTool(toolName, args, apiClient, originalFn);
@@ -10555,7 +10964,7 @@ async function retryPathAccessDenial(result, toolName, args, allowedDirectories,
10555
10964
  return err instanceof Error ? err.message : String(err);
10556
10965
  } finally {
10557
10966
  if (oneShot) {
10558
- const idx = allowedDirectories.lastIndexOf(grantDir);
10967
+ const idx = allowedDirectories.lastIndexOf(resolvedGrantDir);
10559
10968
  if (idx !== -1) allowedDirectories.splice(idx, 1);
10560
10969
  }
10561
10970
  }
@@ -10601,7 +11010,7 @@ function prependRiskBanner(basePreview, reasons) {
10601
11010
  */
10602
11011
  async function generateToolPreview(toolName, args, isSandboxed) {
10603
11012
  try {
10604
- if (toolName === "edit_local_file" && args?.path && args?.old_string && typeof args?.new_string === "string") return generateEditLocalFilePreview({
11013
+ if (toolName === "edit_local_file" && args?.path && args?.old_string && typeof args?.new_string === "string") return await generateEditLocalFilePreview({
10605
11014
  path: args.path,
10606
11015
  old_string: args.old_string,
10607
11016
  new_string: args.new_string
@@ -10623,20 +11032,25 @@ async function generateToolPreview(toolName, args, isSandboxed) {
10623
11032
  }
10624
11033
  /**
10625
11034
  * Persist an "allow-always" trust decision to project-local or global config.
11035
+ *
11036
+ * Only writes the repo's project-local layer when the folder is TRUSTED. An
11037
+ * untrusted root never re-reads those layers (computeMerged gates on trust), so
11038
+ * persisting there would silently lose the decision on the next launch AND drop a
11039
+ * .bike4mind/local.json into a repo the user just declined to trust. Untrusted
11040
+ * (the default) falls back to the global layer, which is always honored.
10626
11041
  */
10627
11042
  async function persistToolTrust(toolName, permissionManager, configStore) {
10628
11043
  if (!permissionManager.trustTool(toolName)) return;
10629
- if (configStore.getProjectConfigDir()) try {
11044
+ if (configStore.getProjectConfigDir() && configStore.isProjectTrusted()) try {
10630
11045
  await configStore.initProjectConfig();
10631
11046
  const existingLocal = await configStore.loadRawProjectLocalConfig() || {};
10632
11047
  await configStore.saveProjectLocalConfig({
10633
11048
  ...existingLocal,
10634
11049
  trustedTools: [...existingLocal.trustedTools || [], toolName]
10635
11050
  });
10636
- } catch {
10637
- await configStore.trustTool(toolName);
10638
- }
10639
- else await configStore.trustTool(toolName);
11051
+ return;
11052
+ } catch {}
11053
+ await configStore.trustTool(toolName);
10640
11054
  }
10641
11055
  /**
10642
11056
  * Wrap a tool with lifecycle hooks (PreToolUse, PostToolUse, PostToolUseFailure).
@@ -10660,7 +11074,7 @@ function wrapToolWithHooks(tool, hooks, hookContext) {
10660
11074
  hookEventName: "PreToolUse",
10661
11075
  toolName,
10662
11076
  toolInput: args
10663
- }));
11077
+ }), hookContext.permission);
10664
11078
  if (preResult.decision === "deny") return `Tool execution denied by hook: ${preResult.reason || "No reason provided"}`;
10665
11079
  if (preResult.decision === "block") throw new HookBlockedError(toolName, preResult.reason);
10666
11080
  if (preResult.updatedInput) finalArgs = {
@@ -10680,7 +11094,7 @@ function wrapToolWithHooks(tool, hooks, hookContext) {
10680
11094
  toolName,
10681
11095
  toolInput: finalArgs,
10682
11096
  error: error.message
10683
- }));
11097
+ }), hookContext.permission);
10684
11098
  }
10685
11099
  throw err;
10686
11100
  }
@@ -10691,7 +11105,7 @@ function wrapToolWithHooks(tool, hooks, hookContext) {
10691
11105
  toolName,
10692
11106
  toolInput: finalArgs,
10693
11107
  toolResult: observation
10694
- }));
11108
+ }), hookContext.permission);
10695
11109
  if (postResult.decision === "block") throw new HookBlockedError(toolName, postResult.reason);
10696
11110
  }
10697
11111
  return observation;
@@ -10870,7 +11284,8 @@ var PermissionManager = class {
10870
11284
  * because the sandbox provides the security boundary.
10871
11285
  */
10872
11286
  needsPermission(toolName, options) {
10873
- const category = getToolCategory(toolName, Object.fromEntries(this.customCategories));
11287
+ const categoryMap = Object.fromEntries(this.customCategories);
11288
+ const category = getToolCategory(toolName, categoryMap);
10874
11289
  if (this.deniedTools.has(toolName)) return true;
10875
11290
  if (category === "auto_approve") return false;
10876
11291
  if (options?.isSandboxed && toolName === "bash_execute" && this.isSandboxAutoAllow()) return false;
@@ -10911,7 +11326,8 @@ var PermissionManager = class {
10911
11326
  * Get the category for a tool
10912
11327
  */
10913
11328
  getCategory(toolName) {
10914
- return getToolCategory(toolName, Object.fromEntries(this.customCategories));
11329
+ const categoryMap = Object.fromEntries(this.customCategories);
11330
+ return getToolCategory(toolName, categoryMap);
10915
11331
  }
10916
11332
  /**
10917
11333
  * Check if a tool can be trusted (not in prompt_always category or denied by project)
@@ -10972,124 +11388,6 @@ var PermissionManager = class {
10972
11388
  };
10973
11389
  }
10974
11390
  };
10975
- const PROJECT_CONTEXT_FILES = [
10976
- "CLAUDE.local.md",
10977
- "CLAUDE.md",
10978
- "AGENTS.md",
10979
- "AI.local.md",
10980
- "AI.md",
10981
- "INSTRUCTIONS.md"
10982
- ];
10983
- const GLOBAL_CONTEXT_FILES = ["AI.local.md", "AI.md"];
10984
- /**
10985
- * Format file size for display
10986
- */
10987
- function formatFileSize(bytes) {
10988
- if (bytes < 1024) return `${bytes}B`;
10989
- if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)}KB`;
10990
- return `${(bytes / 1048576).toFixed(1)}MB`;
10991
- }
10992
- /**
10993
- * Try to read a context file from a directory
10994
- *
10995
- * Security: Only reads regular files (not directories or symlinks) within the specified directory.
10996
- * Files must be under 100KB to prevent abuse. Symlinks are rejected to prevent reading
10997
- * files outside the intended directory.
10998
- *
10999
- * @param dir - The directory to read from (must be a controlled location)
11000
- * @param filename - The filename to read (must not contain path separators)
11001
- * @param source - Whether this is a 'global' or 'project' context file
11002
- * @returns The file result, an error object, or null if file doesn't exist
11003
- */
11004
- function tryReadContextFile(dir, filename, source) {
11005
- const filePath = path$1.join(dir, filename);
11006
- try {
11007
- const stats = fs$2.lstatSync(filePath);
11008
- if (stats.isDirectory()) return null;
11009
- if (stats.isSymbolicLink()) return { error: `${source === "global" ? "Global" : "Project"} ${filename} is a symlink (not allowed for security)` };
11010
- if (stats.size > 102400) return { error: `${source === "global" ? "Global" : "Project"} ${filename} exceeds 100KB limit (${formatFileSize(stats.size)})` };
11011
- return {
11012
- filename,
11013
- content: fs$2.readFileSync(filePath, "utf-8"),
11014
- source,
11015
- path: filePath
11016
- };
11017
- } catch (err) {
11018
- if (err.code === "ENOENT") return null;
11019
- if (err.code === "EACCES") return { error: `Cannot read ${source} ${filename}: permission denied` };
11020
- return { error: `Cannot read ${source} ${filename}: ${err instanceof Error ? err.message : "Unknown error"}` };
11021
- }
11022
- }
11023
- /**
11024
- * Find the first context file in a directory from a list of candidates
11025
- */
11026
- function findContextFile(dir, candidates, source) {
11027
- for (const filename of candidates) {
11028
- const result = tryReadContextFile(dir, filename, source);
11029
- if (result === null) continue;
11030
- if ("error" in result) return {
11031
- result: null,
11032
- error: result.error
11033
- };
11034
- return {
11035
- result,
11036
- error: null
11037
- };
11038
- }
11039
- return {
11040
- result: null,
11041
- error: null
11042
- };
11043
- }
11044
- /**
11045
- * Merge global and project context into a single string
11046
- */
11047
- function mergeContextContent(global, project) {
11048
- if (global && project) return `${global.content}\n\n---\n\n${project.content}`;
11049
- if (global) return global.content;
11050
- if (project) return project.content;
11051
- return "";
11052
- }
11053
- /**
11054
- * Load context files from global and project directories
11055
- *
11056
- * Global files are loaded from ~/.bike4mind/
11057
- * Project files are loaded from the project directory (or cwd if null)
11058
- *
11059
- * Returns the first matching file from each layer based on priority order
11060
- */
11061
- async function loadContextFiles(projectDir) {
11062
- const errors = [];
11063
- const globalDir = path$1.join(homedir$1(), ".bike4mind");
11064
- const projectDirectory = projectDir || process.cwd();
11065
- const [globalResult, projectResult] = await Promise.all([Promise.resolve(findContextFile(globalDir, GLOBAL_CONTEXT_FILES, "global")), Promise.resolve(findContextFile(projectDirectory, PROJECT_CONTEXT_FILES, "project"))]);
11066
- if (globalResult.error) errors.push(globalResult.error);
11067
- if (projectResult.error) errors.push(projectResult.error);
11068
- const mergedContent = mergeContextContent(globalResult.result, projectResult.result);
11069
- return {
11070
- globalContext: globalResult.result,
11071
- projectContext: projectResult.result,
11072
- mergedContent,
11073
- errors
11074
- };
11075
- }
11076
- /**
11077
- * Extract "# Compact Instructions" or "## Compact Instructions" section from CLAUDE.md content
11078
- *
11079
- * This section provides project-specific instructions for how conversations should be
11080
- * summarized when compacting context.
11081
- *
11082
- * @param contextContent - The merged context content from CLAUDE.md files
11083
- * @returns The extracted instructions content, or undefined if not found
11084
- */
11085
- function extractCompactInstructions(contextContent) {
11086
- const match = contextContent.match(/^#{1,2}\s*Compact\s*Instructions\s*$/im);
11087
- if (!match || match.index === void 0) return;
11088
- const startIndex = match.index + match[0].length;
11089
- const remainingContent = contextContent.slice(startIndex);
11090
- const endIndex = remainingContent.match(/^#{1,2}\s+\S/m)?.index ?? remainingContent.length;
11091
- return remainingContent.slice(0, endIndex).trim() || void 0;
11092
- }
11093
11391
  //#endregion
11094
11392
  //#region ../../b4m-core/agents/dist/index.mjs
11095
11393
  /**
@@ -14688,7 +14986,14 @@ function resolveAgentName(agentType, agentStore) {
14688
14986
  * @param context - Context variables available to the script
14689
14987
  * @returns Output from the script or error message
14690
14988
  */
14691
- async function executeHook(script, context) {
14989
+ async function executeHook(script, phase, context, perm) {
14990
+ if (perm) {
14991
+ const decision = await requestShellCommandPermission(`skill_hook:${phase}`, script, process.cwd(), perm);
14992
+ if (!decision.allowed) return {
14993
+ success: false,
14994
+ output: decision.reason || "Hook command denied"
14995
+ };
14996
+ }
14692
14997
  const result = await runShellCommand({
14693
14998
  command: script,
14694
14999
  cwd: process.cwd(),
@@ -14773,6 +15078,10 @@ function parseArguments(argsString) {
14773
15078
  */
14774
15079
  function createSkillTool(deps) {
14775
15080
  const { customCommandStore } = deps;
15081
+ const hookPerm = deps.permissionManager && deps.promptFn ? {
15082
+ permissionManager: deps.permissionManager,
15083
+ promptFn: deps.promptFn
15084
+ } : void 0;
14776
15085
  return {
14777
15086
  toolFn: async (args) => {
14778
15087
  const params = args;
@@ -14789,16 +15098,16 @@ function createSkillTool(deps) {
14789
15098
  throw new Error(`skill: "${skillName}" not found. Available skills: ${available || "none"}`);
14790
15099
  }
14791
15100
  if (command.hooks?.["pre-invoke"]) {
14792
- const hookResult = await executeHook(command.hooks["pre-invoke"], {
15101
+ const hookResult = await executeHook(command.hooks["pre-invoke"], "pre-invoke", {
14793
15102
  skillName,
14794
15103
  args: argsString
14795
- });
15104
+ }, hookPerm);
14796
15105
  if (!hookResult.success) throw new Error(`Pre-invoke hook failed: ${hookResult.output}`);
14797
15106
  }
14798
15107
  try {
14799
15108
  const argsArray = params.args ? parseArguments(params.args) : [];
14800
15109
  let expandedBody = substituteArguments(command.body, argsArray);
14801
- const processed = await processFileReferences(expandedBody);
15110
+ const processed = await processFileReferences(expandedBody, deps.allowedDirectories ?? []);
14802
15111
  expandedBody = processed.content;
14803
15112
  if (processed.errors.length > 0) expandedBody += `\n\n**File reference errors:**\n${processed.errors.map((e) => `- ${e}`).join("\n")}`;
14804
15113
  let result;
@@ -14823,22 +15132,22 @@ function createSkillTool(deps) {
14823
15132
  result = `## Skill Executed: /${skillName} (via ${agentConfig.name} agent)\n\n${agentResult.summary}`;
14824
15133
  } else result = `## Skill Loaded: /${skillName}\n\n${expandedBody}\n\n---\n*Follow the instructions above. This skill was invoked programmatically.*`;
14825
15134
  if (command.hooks?.["post-invoke"]) {
14826
- const hookResult = await executeHook(command.hooks["post-invoke"], {
15135
+ const hookResult = await executeHook(command.hooks["post-invoke"], "post-invoke", {
14827
15136
  skillName,
14828
15137
  args: argsString,
14829
15138
  result
14830
- });
15139
+ }, hookPerm);
14831
15140
  if (!hookResult.success) logger.warn(`Post-invoke hook warning: ${hookResult.output}`);
14832
15141
  }
14833
15142
  return result;
14834
15143
  } catch (error) {
14835
15144
  if (command.hooks?.["on-error"]) {
14836
15145
  const errorMessage = error instanceof Error ? error.message : String(error);
14837
- const hookResult = await executeHook(command.hooks["on-error"], {
15146
+ const hookResult = await executeHook(command.hooks["on-error"], "on-error", {
14838
15147
  skillName,
14839
15148
  args: argsString,
14840
15149
  error: errorMessage
14841
- });
15150
+ }, hookPerm);
14842
15151
  if (hookResult.output) logger.warn(`On-error hook output: ${hookResult.output}`);
14843
15152
  }
14844
15153
  throw error;
@@ -15089,12 +15398,6 @@ async function getRipgrepPath() {
15089
15398
  cachedRgPath = rgPath;
15090
15399
  return rgPath;
15091
15400
  }
15092
- function isPathWithinWorkspace(targetPath, baseCwd) {
15093
- const resolvedTarget = path.resolve(targetPath);
15094
- const resolvedBase = path.resolve(baseCwd);
15095
- const relativePath = path.relative(resolvedBase, resolvedTarget);
15096
- return !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
15097
- }
15098
15401
  /** Escape special regex characters in the symbol name */
15099
15402
  function escapeRegex$1(str) {
15100
15403
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -15129,12 +15432,12 @@ function isLikelyDefinition(line) {
15129
15432
  if (/\b(jest|vi|sinon)\.(mock|stub|spy)\b/.test(trimmed)) return false;
15130
15433
  return true;
15131
15434
  }
15132
- async function findDefinitions(params) {
15435
+ async function findDefinitions(params, allowedDirectories) {
15133
15436
  const { symbol_name, kind, search_path } = params;
15134
15437
  if (!symbol_name || !symbol_name.trim()) throw new Error("symbol_name is required");
15135
15438
  const baseCwd = process.cwd();
15136
- const targetDir = search_path ? path.resolve(baseCwd, search_path) : baseCwd;
15137
- if (!isPathWithinWorkspace(targetDir, baseCwd)) throw new Error(`Path validation failed: "${search_path}" resolves outside the allowed workspace directory`);
15439
+ const requestedDir = search_path ? path.resolve(baseCwd, search_path) : baseCwd;
15440
+ const targetDir = assertPathAllowed(requestedDir, allowedDirectories, "search");
15138
15441
  try {
15139
15442
  if (!(await stat(targetDir)).isDirectory()) throw new Error(`Path is not a directory: ${search_path}`);
15140
15443
  } catch (error) {
@@ -15148,6 +15451,7 @@ async function findDefinitions(params) {
15148
15451
  "50",
15149
15452
  "--max-filesize",
15150
15453
  "5M",
15454
+ "--",
15151
15455
  buildDefinitionPattern(symbol_name.trim(), kind),
15152
15456
  targetDir
15153
15457
  ];
@@ -15200,10 +15504,10 @@ async function findDefinitions(params) {
15200
15504
  }
15201
15505
  return result.trim();
15202
15506
  }
15203
- function createFindDefinitionTool() {
15507
+ function createFindDefinitionTool(allowedDirectories) {
15204
15508
  return {
15205
15509
  toolFn: async (args) => {
15206
- return findDefinitions(args);
15510
+ return findDefinitions(args, allowedDirectories);
15207
15511
  },
15208
15512
  toolSchema: {
15209
15513
  name: "find_definition",
@@ -15625,14 +15929,14 @@ function formatSection(lines, title, items, format) {
15625
15929
  //#endregion
15626
15930
  //#region src/tools/getFileStructure/index.ts
15627
15931
  const MAX_FILE_SIZE$1 = 10485760;
15628
- function createGetFileStructureTool() {
15932
+ function createGetFileStructureTool(allowedDirectories) {
15629
15933
  return {
15630
15934
  toolFn: async (value) => {
15631
15935
  const params = value;
15632
15936
  try {
15633
- const cwd = process.cwd();
15634
- const resolvedPath = path.resolve(cwd, params.path);
15635
- if (!resolvedPath.startsWith(cwd)) return "Error: Access denied - cannot read files outside of current working directory";
15937
+ const validation = isPathAllowed(params.path, allowedDirectories);
15938
+ if (!validation.allowed) return "Access denied: Cannot read files outside allowed directories.";
15939
+ const resolvedPath = validation.resolvedPath;
15636
15940
  if (!existsSync(resolvedPath)) return `Error: File not found: ${params.path}`;
15637
15941
  const stats = statSync(resolvedPath);
15638
15942
  if (stats.isDirectory()) return `Error: Path is a directory, not a file: ${params.path}`;
@@ -16135,6 +16439,7 @@ var dist_exports = /* @__PURE__ */ __exportAll$3({
16135
16439
  DEEPSEEK_MAX_STOP_SEQUENCES: () => 16,
16136
16440
  DEEPSEEK_MODELS: () => DEEPSEEK_MODELS,
16137
16441
  DEEPSEEK_THINKING_TOP_P_FLOOR: () => DEEPSEEK_THINKING_TOP_P_FLOOR,
16442
+ DEFAULT_ACQUIRE_TIMEOUT_MS: () => DEFAULT_ACQUIRE_TIMEOUT_MS,
16138
16443
  DEFAULT_MAX_TOOL_CALLS: () => 10,
16139
16444
  DEFAULT_REALTIME_VOICE_MODEL: () => DEFAULT_REALTIME_VOICE_MODEL,
16140
16445
  DEGENERATE_STREAM_MESSAGE: () => DEGENERATE_STREAM_MESSAGE,
@@ -16152,12 +16457,15 @@ var dist_exports = /* @__PURE__ */ __exportAll$3({
16152
16457
  KimiBackend: () => KimiBackend,
16153
16458
  LlamaBedrockBackend: () => LlamaBedrockBackend,
16154
16459
  LocalImageBackend: () => LocalImageBackend,
16460
+ MAX_CONCURRENT_ANTHROPIC_CALLS: () => 15,
16461
+ MAX_QUEUED_PER_TENANT: () => 100,
16155
16462
  MODEL_SUNSET_NAMESPACE: () => MODEL_SUNSET_NAMESPACE,
16156
16463
  MoonshotBedrockBackend: () => MoonshotBedrockBackend,
16157
16464
  OllamaBackend: () => OllamaBackend,
16158
16465
  OpenAIBackend: () => OpenAIBackend,
16159
16466
  PipelineTimer: () => PipelineTimer,
16160
16467
  REALTIME_VOICE_PRICING: () => REALTIME_VOICE_PRICING,
16468
+ SemaphoreBusyError: () => SemaphoreBusyError,
16161
16469
  THINKING_ANSWER_HEADROOM_TOKENS: () => THINKING_ANSWER_HEADROOM_TOKENS,
16162
16470
  TitanBedrockBackend: () => TitanBedrockBackend,
16163
16471
  UndifferentiatedBedrockBackend: () => UndifferentiatedBedrockBackend,
@@ -16210,12 +16518,14 @@ var dist_exports = /* @__PURE__ */ __exportAll$3({
16210
16518
  setModelCatalogProvider: () => setModelCatalogProvider,
16211
16519
  setModelPriceRowsProvider: () => setModelPriceRowsProvider,
16212
16520
  splitCacheInclusiveInput: () => splitCacheInclusiveInput,
16521
+ staticPriceBackends: () => staticPriceBackends,
16213
16522
  stripAllToolBlocks: () => stripAllToolBlocks,
16214
16523
  stripToolDependentMessages: () => stripToolDependentMessages,
16215
16524
  toDeepSeekEffort: () => toDeepSeekEffort,
16216
16525
  toKimiEffort: () => toKimiEffort,
16217
16526
  toProviderEndUserId: () => toProviderEndUserId,
16218
- updateReplacedByOverlay: () => updateReplacedByOverlay
16527
+ updateReplacedByOverlay: () => updateReplacedByOverlay,
16528
+ usableTokenCount: () => usableTokenCount
16219
16529
  });
16220
16530
  /**
16221
16531
  * A tool failure that must end the turn rather than be fed back to the model as a
@@ -17079,6 +17389,58 @@ function systemContentToText(content) {
17079
17389
  if (!Array.isArray(content)) return "";
17080
17390
  return content.filter((block) => block?.type === "text").map((block) => block.text ?? "").filter((text) => text.trim() !== "").join("\n");
17081
17391
  }
17392
+ /** Data URLs carry the bytes inline; Anthropic wants them as a base64 source, not a url source. */
17393
+ const DATA_URL = /^data:([^;,]+)(?:;[^,]*)?;base64,(.+)$/s;
17394
+ /**
17395
+ * Translate canonical B4M content (see normalizeMultimodalMessages) into Anthropic
17396
+ * block params. Only `image_url` needs real work - Anthropic has no such block, so a
17397
+ * data URL becomes a base64 source and an http(s) URL becomes a url source. Every
17398
+ * other block (text, image, tool_use, tool_result, thinking) is already structurally
17399
+ * the SDK's shape and passes through with its `cache_control` stamp intact.
17400
+ *
17401
+ * An image we cannot translate is dropped with a warning: Anthropic rejects the whole
17402
+ * request over one unknown block, which would lose the text too.
17403
+ */
17404
+ function toAnthropicContent(content, logger) {
17405
+ if (!Array.isArray(content)) return content;
17406
+ const blocks = [];
17407
+ for (const block of content) {
17408
+ if (!block || block.type !== "image_url") {
17409
+ blocks.push(block);
17410
+ continue;
17411
+ }
17412
+ const { image_url: imageUrl, type: _type, ...rest } = block;
17413
+ const url = imageUrl?.url;
17414
+ if (!url) {
17415
+ logger?.warn("[AnthropicBackend] Dropping image_url block with no url.");
17416
+ continue;
17417
+ }
17418
+ const dataUrl = DATA_URL.exec(url);
17419
+ if (dataUrl) blocks.push({
17420
+ ...rest,
17421
+ type: "image",
17422
+ source: {
17423
+ type: "base64",
17424
+ media_type: dataUrl[1],
17425
+ data: dataUrl[2]
17426
+ }
17427
+ });
17428
+ else if (/^https?:\/\//i.test(url)) blocks.push({
17429
+ ...rest,
17430
+ type: "image",
17431
+ source: {
17432
+ type: "url",
17433
+ url
17434
+ }
17435
+ });
17436
+ else logger?.warn("[AnthropicBackend] Dropping image_url block; Anthropic accepts only http(s) or base64 data URLs.");
17437
+ }
17438
+ if (blocks.length === 0 && content.length > 0) blocks.push({
17439
+ type: "text",
17440
+ text: "[image omitted: unsupported image format]"
17441
+ });
17442
+ return blocks;
17443
+ }
17082
17444
  /**
17083
17445
  * max_tokens floor for adaptive reasoning models (Claude 4.7+/Opus 5). These
17084
17446
  * models self-manage extended thinking *within* max_tokens, which is a ceiling
@@ -17106,6 +17468,10 @@ const THINKING_ANSWER_HEADROOM_TOKENS = 1e3;
17106
17468
  * resolves to that entire cap, which is the only value leaving room for an answer
17107
17469
  * after a long trace.
17108
17470
  *
17471
+ * Bedrock DeepSeek R1 is the same shape: its monologue is inlined into `content`
17472
+ * (see bedrockBackend/deepseek.ts), it matches no shape check, and its 32K cap
17473
+ * becomes the floor for the same reason.
17474
+ *
17109
17475
  * DeepSeek Flash and V4 Pro miss every clause for their own set of reasons: no
17110
17476
  * `thinkingStyle` (that field is Anthropic's), absent from the OpenAI-only
17111
17477
  * REASONING_SUPPORTED_MODELS, and DEEPSEEK_PROFILE declares plain `max_tokens`
@@ -17118,6 +17484,7 @@ const THINKING_ANSWER_HEADROOM_TOKENS = 1e3;
17118
17484
  const REASONS_WITHIN_OUTPUT_BUDGET_IDS = /* @__PURE__ */ new Set([
17119
17485
  ChatModels.KIMI_K2_THINKING_BEDROCK,
17120
17486
  ChatModels.KIMI_K2_5_BEDROCK,
17487
+ ChatModels.DEEPSEEK_R1_BEDROCK,
17121
17488
  ChatModels.DEEPSEEK_FLASH,
17122
17489
  ChatModels.DEEPSEEK_V4_PRO
17123
17490
  ]);
@@ -17155,12 +17522,36 @@ function reasonsWithinOutputBudget(modelInfo) {
17155
17522
  *
17156
17523
  * Models that reason inside the output budget default to
17157
17524
  * ADAPTIVE_THINKING_MAX_TOKENS_FLOOR, clamped to their own cap: a small default can
17158
- * be consumed entirely by reasoning, leaving an empty visible reply.
17525
+ * be consumed entirely by reasoning, leaving an empty visible reply. "Their own cap"
17526
+ * means a DECLARED one - a cap toModelInfo derived is only a default, and clamping
17527
+ * such a model to it reproduces that same starvation, so derivedOutputCeiling stands
17528
+ * in for it. Every path that has a usable cap ends in a clamp against it; a model with
17529
+ * no usable cap at all (line 138) is a different, deliberate exception - see its comment.
17159
17530
  */
17160
17531
  function resolveOutputMaxTokens({ requested, fallback, modelInfo, modelMaxOutputTokens }) {
17161
- const preferred = usableTokenCount(requested) ?? (reasonsWithinOutputBudget(modelInfo) ? 64e3 : fallback);
17532
+ const reasonsWithinBudget = reasonsWithinOutputBudget(modelInfo);
17533
+ const preferred = usableTokenCount(requested) ?? (reasonsWithinBudget ? 64e3 : fallback);
17162
17534
  const cap = usableTokenCount(modelMaxOutputTokens);
17163
- return cap === void 0 ? preferred : Math.min(preferred, cap);
17535
+ if (cap === void 0) return preferred;
17536
+ if (modelInfo.maxOutputTokensDerived === true && reasonsWithinBudget) return Math.min(preferred, derivedOutputCeiling(modelInfo, cap));
17537
+ return Math.min(preferred, cap);
17538
+ }
17539
+ /**
17540
+ * The ceiling to use in place of a derived cap. Two bounds, whichever is larger:
17541
+ *
17542
+ * - the derived cap itself, so this can only ever raise a budget, never shrink one; and
17543
+ * - the adaptive floor, bounded by half the context window after the safety buffer - the same
17544
+ * split catalogWrite applies to a window that cannot fund the default output reserve.
17545
+ *
17546
+ * The window share is what keeps this honest: contextWindow is the INPUT+output budget, so handing
17547
+ * the whole of it to output would make every non-empty prompt exceed the window and 400 the turn.
17548
+ * Half of it leaves the prompt at least as much room as the answer.
17549
+ */
17550
+ function derivedOutputCeiling(modelInfo, derivedCap) {
17551
+ const window = usableTokenCount(modelInfo.contextWindow);
17552
+ if (window === void 0) return derivedCap;
17553
+ const windowShare = Math.floor((window - CONTEXT_WINDOW_SAFETY_BUFFER_TOKENS) / 2);
17554
+ return Math.max(derivedCap, Math.min(ADAPTIVE_THINKING_MAX_TOKENS_FLOOR, windowShare));
17164
17555
  }
17165
17556
  /**
17166
17557
  * Token counts reaching this module come from catalog rows and external callers, so they are
@@ -17230,27 +17621,130 @@ var DispatchModel = class {
17230
17621
  return this.for(model)?.dispatchProfile;
17231
17622
  }
17232
17623
  };
17233
- let _activeAnthropicCalls = 0;
17234
- const _anthropicWaitQueue = [];
17624
+ const DEFAULT_ACQUIRE_TIMEOUT_MS = 3e5;
17625
+ const ANON_TENANT = "";
17235
17626
  const _semaphoreLogger = new Logger();
17236
- function acquireSlot() {
17237
- if (_activeAnthropicCalls < 15) {
17238
- _activeAnthropicCalls++;
17239
- return Promise.resolve();
17240
- }
17241
- _semaphoreLogger.warn("[AnthropicBackend] Semaphore at capacity, queuing request", {
17242
- active: _activeAnthropicCalls,
17243
- queued: _anthropicWaitQueue.length + 1
17627
+ /**
17628
+ * Thrown when a slot cannot be obtained: the tenant's queue is full, or the wait timed out.
17629
+ * Both are transient backpressure on a shared pool rather than a fault in the request, so it
17630
+ * carries a 429 that the shared `shouldTriggerFallback` classifier reads (via `getHttpStatus`)
17631
+ * and hops the completion onto another model instead of surfacing a hard failure.
17632
+ */
17633
+ var SemaphoreBusyError = class extends Error {
17634
+ /** Read by the shared HTTP-status error classifiers; see the class doc. */
17635
+ status = 429;
17636
+ constructor(message) {
17637
+ super(message);
17638
+ this.name = "SemaphoreBusyError";
17639
+ }
17640
+ };
17641
+ let _totalActive = 0;
17642
+ const _activeByTenant = /* @__PURE__ */ new Map();
17643
+ const _waiters = [];
17644
+ function incActive(key) {
17645
+ _activeByTenant.set(key, (_activeByTenant.get(key) ?? 0) + 1);
17646
+ _totalActive++;
17647
+ }
17648
+ function decActive(key) {
17649
+ const remaining = (_activeByTenant.get(key) ?? 0) - 1;
17650
+ if (remaining <= 0) _activeByTenant.delete(key);
17651
+ else _activeByTenant.set(key, remaining);
17652
+ _totalActive--;
17653
+ }
17654
+ function makeRelease(key) {
17655
+ let released = false;
17656
+ return () => {
17657
+ if (released) return;
17658
+ released = true;
17659
+ decActive(key);
17660
+ admitNext();
17661
+ };
17662
+ }
17663
+ /**
17664
+ * Index of the waiter whose tenant holds the fewest active slots; arrival order breaks ties.
17665
+ * `_waiters` is only ever appended to and spliced from, so it is already in arrival order and
17666
+ * the strict `<` keeps the first-encountered (earliest) waiter on a tie - which is what makes
17667
+ * this degrade to FIFO within a single tenant.
17668
+ */
17669
+ function pickFairWaiterIndex() {
17670
+ let best = -1;
17671
+ let bestActive = Infinity;
17672
+ for (let i = 0; i < _waiters.length; i++) {
17673
+ const active = _activeByTenant.get(_waiters[i].key) ?? 0;
17674
+ if (active < bestActive) {
17675
+ best = i;
17676
+ bestActive = active;
17677
+ }
17678
+ }
17679
+ return best;
17680
+ }
17681
+ function admitNext() {
17682
+ if (_totalActive >= 15) return;
17683
+ const idx = pickFairWaiterIndex();
17684
+ if (idx === -1) return;
17685
+ const [waiter] = _waiters.splice(idx, 1);
17686
+ waiter.settle();
17687
+ incActive(waiter.key);
17688
+ waiter.resolve(makeRelease(waiter.key));
17689
+ }
17690
+ function abortError(signal) {
17691
+ const reason = signal.reason;
17692
+ if (reason instanceof Error) return reason;
17693
+ const err = /* @__PURE__ */ new Error("The operation was aborted");
17694
+ err.name = "AbortError";
17695
+ return err;
17696
+ }
17697
+ /**
17698
+ * Acquire a slot for an Anthropic API call, resolving with a release handle once one is
17699
+ * available. Rejects with an AbortError if `signal` fires while waiting, or a
17700
+ * SemaphoreBusyError if the tenant's queue is full or the wait times out.
17701
+ */
17702
+ function acquireSlot(opts = {}) {
17703
+ const key = opts.tenantKey ?? ANON_TENANT;
17704
+ const { signal } = opts;
17705
+ if (signal?.aborted) return Promise.reject(abortError(signal));
17706
+ if (_totalActive < 15 && _waiters.length === 0) {
17707
+ incActive(key);
17708
+ return Promise.resolve(makeRelease(key));
17709
+ }
17710
+ const queuedForTenant = _waiters.reduce((count, w) => w.key === key ? count + 1 : count, 0);
17711
+ if (queuedForTenant >= 100) {
17712
+ _semaphoreLogger.warn("[AnthropicSemaphore] Per-tenant queue cap reached, rejecting request", {
17713
+ active: _totalActive,
17714
+ queued: _waiters.length,
17715
+ tenantQueued: queuedForTenant
17716
+ });
17717
+ return Promise.reject(new SemaphoreBusyError(`Anthropic request queue is full for this tenant (${queuedForTenant} already waiting); try again shortly`));
17718
+ }
17719
+ _semaphoreLogger.warn("[AnthropicSemaphore] At capacity, queuing request", {
17720
+ active: _totalActive,
17721
+ queued: _waiters.length + 1
17244
17722
  });
17245
- return new Promise((resolve) => {
17246
- _anthropicWaitQueue.push(resolve);
17723
+ return new Promise((resolve, reject) => {
17724
+ const timeoutMs = opts.timeoutMs ?? 3e5;
17725
+ const settle = () => {
17726
+ clearTimeout(timer);
17727
+ if (signal) signal.removeEventListener("abort", onAbort);
17728
+ };
17729
+ const removeAndReject = (err) => {
17730
+ const idx = _waiters.indexOf(waiter);
17731
+ if (idx !== -1) _waiters.splice(idx, 1);
17732
+ settle();
17733
+ reject(err);
17734
+ };
17735
+ function onAbort() {
17736
+ removeAndReject(abortError(signal));
17737
+ }
17738
+ const timer = setTimeout(() => removeAndReject(new SemaphoreBusyError(`Timed out after ${timeoutMs}ms waiting for an Anthropic slot`)), timeoutMs);
17739
+ const waiter = {
17740
+ key,
17741
+ resolve,
17742
+ settle
17743
+ };
17744
+ _waiters.push(waiter);
17745
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
17247
17746
  });
17248
17747
  }
17249
- function releaseSlot() {
17250
- const next = _anthropicWaitQueue.shift();
17251
- if (next) next();
17252
- else _activeAnthropicCalls--;
17253
- }
17254
17748
  /**
17255
17749
  * Defaults are deliberately conservative: tripping requires a run of >= 2048
17256
17750
  * chars that is periodic on a unit of <= 512 chars repeated >= 25 times. Prose,
@@ -18038,7 +18532,7 @@ var AnthropicBackend = class {
18038
18532
  }) : options.maxTokens ?? DEFAULT_ANTHROPIC_MAX_TOKENS,
18039
18533
  messages: filteredMessages.map((m) => ({
18040
18534
  role: m.role === "user" ? "user" : "assistant",
18041
- content: m.content
18535
+ content: toAnthropicContent(m.content, this.logger)
18042
18536
  })),
18043
18537
  ...this.omitsSamplingParams(model) ? {} : { temperature: options.temperature },
18044
18538
  ...TEMPERATURE_ONLY_MODELS$1.includes(model) || this.omitsSamplingParams(model) ? {} : { top_p: options.topP },
@@ -18135,8 +18629,12 @@ var AnthropicBackend = class {
18135
18629
  let idleTimeoutMsForError = 0;
18136
18630
  let degenerateVerdict;
18137
18631
  (async () => {
18138
- await acquireSlot();
18632
+ let release;
18139
18633
  try {
18634
+ release = await acquireSlot({
18635
+ tenantKey: this._endUserId,
18636
+ signal: combinedSignal
18637
+ });
18140
18638
  const payloadForSize = {
18141
18639
  ...apiParams,
18142
18640
  stream: true
@@ -18407,7 +18905,7 @@ var AnthropicBackend = class {
18407
18905
  }
18408
18906
  else reject(error);
18409
18907
  } finally {
18410
- releaseSlot();
18908
+ release?.();
18411
18909
  }
18412
18910
  })();
18413
18911
  });
@@ -18590,9 +19088,13 @@ var AnthropicBackend = class {
18590
19088
  return;
18591
19089
  }
18592
19090
  } else {
18593
- await acquireSlot();
18594
19091
  let response;
19092
+ let release;
18595
19093
  try {
19094
+ release = await acquireSlot({
19095
+ tenantKey: this._endUserId,
19096
+ signal: options.abortSignal
19097
+ });
18596
19098
  response = await withRetry(() => this._api.messages.create(apiParams, {
18597
19099
  signal: options.abortSignal,
18598
19100
  ...requestExtraHeaders ? { headers: requestExtraHeaders } : {}
@@ -18606,7 +19108,7 @@ var AnthropicBackend = class {
18606
19108
  abortSignal: options.abortSignal
18607
19109
  }).then((r) => r.result);
18608
19110
  } finally {
18609
- releaseSlot();
19111
+ release?.();
18610
19112
  }
18611
19113
  const streamedText = [];
18612
19114
  if ("content" in response && Array.isArray(response.content)) {
@@ -20094,19 +20596,17 @@ var AnthropicBedrockBackend = class extends BaseBedrockBackend {
20094
20596
  return m;
20095
20597
  }
20096
20598
  if (Array.isArray(m.content)) {
20097
- const sanitizedContent = m.content.map((block) => {
20098
- if (isRecord(block) && block.type === "text") {
20099
- if (!(typeof block.text === "string" ? block.text : "").trim()) return null;
20100
- }
20101
- return block;
20102
- }).filter((block) => block !== null);
20103
- if (sanitizedContent.length === 0) return {
20599
+ const translatedContent = toAnthropicContent(m.content.filter((block) => {
20600
+ if (isRecord(block) && block.type === "text") return !!(typeof block.text === "string" ? block.text : "").trim();
20601
+ return true;
20602
+ }), Logger.globalInstance);
20603
+ if (translatedContent.length === 0) return {
20104
20604
  ...m,
20105
20605
  content: ""
20106
20606
  };
20107
20607
  return {
20108
20608
  ...m,
20109
- content: sanitizedContent
20609
+ content: translatedContent
20110
20610
  };
20111
20611
  }
20112
20612
  return {
@@ -20938,11 +21438,49 @@ var LlamaBedrockBackend = class extends BaseBedrockBackend {
20938
21438
  return messages;
20939
21439
  }
20940
21440
  };
21441
+ /**
21442
+ * Kimi (Moonshot) on Bedrock non-deterministically returns tool calls two ways:
21443
+ * as structured `tool_calls` deltas (handled directly in the backend), OR as its
21444
+ * NATIVE special-token format emitted inline in the content/reasoning stream:
21445
+ *
21446
+ * <|tool_calls_section_begin|>
21447
+ * <|tool_call_begin|> functions.<name>:<index> <|tool_call_argument_begin|> {json} <|tool_call_end|>
21448
+ * ...more calls...
21449
+ * <|tool_calls_section_end|>
21450
+ *
21451
+ * Nothing downstream parses that, so the tokens would leak into the answer as text
21452
+ * and the tool would never run. This module extracts the native section and yields
21453
+ * structured tool calls, so both provider shapes converge on the same execution path.
21454
+ *
21455
+ * Verified against live Bedrock captures (moonshot.kimi-k2-thinking, us-east-2):
21456
+ * the section is emitted WITHIN the model's reasoning, its markers span content
21457
+ * deltas, and a section can carry several parallel calls.
21458
+ */
21459
+ const NATIVE_TOOL_SECTION_PARSE_CAP = 32e3;
20941
21460
  const SECTION_BEGIN = "<|tool_calls_section_begin|>";
20942
21461
  const SECTION_END = "<|tool_calls_section_end|>";
21462
+ /**
21463
+ * Per-call markers. The section wrapper is not always present - a call can arrive bare -
21464
+ * so anything that scopes input to the calls has to fall back to these.
21465
+ */
21466
+ const CALL_BEGIN = "<|tool_call_begin|>";
21467
+ const CALL_END = "<|tool_call_end|>";
20943
21468
  /** Cheap gate: is there any native tool-call marker in this text at all? */
20944
21469
  function hasNativeToolMarker(text) {
20945
- return text.includes(SECTION_BEGIN) || text.includes("<|tool_call_begin|>");
21470
+ return text.includes("<|tool_calls_section_begin|>") || text.includes("<|tool_call_begin|>");
21471
+ }
21472
+ /**
21473
+ * Offset of the first tool call in `text`, preferring the section wrapper when present,
21474
+ * or -1 when there is none.
21475
+ *
21476
+ * This is how a caller meets parseNativeToolSection's section-scoping contract. It is a
21477
+ * function rather than an inline indexOf because the wrapper is optional: scoping only on
21478
+ * SECTION_BEGIN leaves the bare shape falling through to the whole message, which is the
21479
+ * exact input the cap then truncates.
21480
+ */
21481
+ function nativeToolCallsBegin(text) {
21482
+ const section = text.indexOf(SECTION_BEGIN);
21483
+ return section >= 0 ? section : text.indexOf(CALL_BEGIN);
20946
21484
  }
20947
21485
  /** `functions.math_evaluate:0` -> { name: 'math_evaluate', index: 0 }. */
20948
21486
  function splitNativeToolId(rawId, fallbackIndex) {
@@ -20961,10 +21499,18 @@ function splitNativeToolId(rawId, fallbackIndex) {
20961
21499
  };
20962
21500
  }
20963
21501
  /**
20964
- * Parse the calls out of one section's inner text (between the section markers, or a
20965
- * whole string that contains them - the per-call regex ignores the section markers).
21502
+ * Parse the calls out of ONE section's text.
21503
+ *
21504
+ * Callers must pass text already scoped to the section - `inner.slice(sectionBegin)` at
21505
+ * minimum, not a whole message. The cap below is applied to whatever arrives, and this
21506
+ * parser's output drives execution: handed a whole message, a long monologue ahead of
21507
+ * the section pushes it past the cap and every call silently disappears, or a call
21508
+ * straddling the cut leaves a parallel set partly executed. Both callers in this repo
21509
+ * (the stream below, and the non-streaming branch in moonshot.ts) slice first.
20966
21510
  */
20967
21511
  function parseNativeToolSection(section) {
21512
+ if (section.length > NATIVE_TOOL_SECTION_PARSE_CAP) console.warn(`[KimiNativeTools] tool-call section is ${section.length} chars, over the ${NATIVE_TOOL_SECTION_PARSE_CAP} parse cap; calls past the cut will not run`);
21513
+ section = capForParse(section, NATIVE_TOOL_SECTION_PARSE_CAP);
20968
21514
  const calls = [];
20969
21515
  const re = /<\|tool_call_begin\|>\s*([\s\S]+?)\s*<\|tool_call_argument_begin\|>\s*([\s\S]*?)\s*<\|tool_call_end\|>/g;
20970
21516
  let match;
@@ -20999,20 +21545,38 @@ function partialMarkerTail(buf, marker) {
20999
21545
  var KimiNativeToolStream = class {
21000
21546
  buffer = "";
21001
21547
  inSection = false;
21548
+ /** Inside a bare call (no section wrapper), which ends at CALL_END rather than SECTION_END. */
21549
+ inBareCall = false;
21002
21550
  push(chunk) {
21003
21551
  this.buffer += chunk;
21004
21552
  let text = "";
21005
21553
  const toolCalls = [];
21006
21554
  for (;;) {
21555
+ if (this.inBareCall) {
21556
+ const end = this.buffer.indexOf(CALL_END);
21557
+ if (end === -1) break;
21558
+ const through = end + 17;
21559
+ toolCalls.push(...parseNativeToolSection(this.buffer.slice(0, through)));
21560
+ this.buffer = this.buffer.slice(through);
21561
+ this.inBareCall = false;
21562
+ continue;
21563
+ }
21007
21564
  if (!this.inSection) {
21008
21565
  const start = this.buffer.indexOf(SECTION_BEGIN);
21009
- if (start >= 0) {
21566
+ const bare = this.buffer.indexOf(CALL_BEGIN);
21567
+ if (start >= 0 && (bare === -1 || start <= bare)) {
21010
21568
  text += this.buffer.slice(0, start);
21011
21569
  this.buffer = this.buffer.slice(start + 28);
21012
21570
  this.inSection = true;
21013
21571
  continue;
21014
21572
  }
21015
- const hold = partialMarkerTail(this.buffer, SECTION_BEGIN);
21573
+ if (bare >= 0) {
21574
+ text += this.buffer.slice(0, bare);
21575
+ this.buffer = this.buffer.slice(bare);
21576
+ this.inBareCall = true;
21577
+ continue;
21578
+ }
21579
+ const hold = Math.max(partialMarkerTail(this.buffer, SECTION_BEGIN), partialMarkerTail(this.buffer, CALL_BEGIN));
21016
21580
  text += this.buffer.slice(0, this.buffer.length - hold);
21017
21581
  this.buffer = hold > 0 ? this.buffer.slice(this.buffer.length - hold) : "";
21018
21582
  break;
@@ -21032,13 +21596,13 @@ var KimiNativeToolStream = class {
21032
21596
  };
21033
21597
  }
21034
21598
  /**
21035
- * Surface any held-back tail at end of stream. Non-empty only when a section-begin
21599
+ * Surface any held-back tail at end of stream. Non-empty only when a begin-marker
21036
21600
  * prefix was held but never completed (i.e. it was ordinary text ending in `<|...`),
21037
- * so it is safe to emit. A genuinely unterminated section is dropped rather than
21038
- * leaked.
21601
+ * so it is safe to emit. A genuinely unterminated section or call is dropped rather
21602
+ * than leaked.
21039
21603
  */
21040
21604
  flush() {
21041
- if (this.inSection) return "";
21605
+ if (this.inSection || this.inBareCall) return "";
21042
21606
  const remaining = this.buffer;
21043
21607
  this.buffer = "";
21044
21608
  return remaining;
@@ -21336,9 +21900,9 @@ var MoonshotBedrockBackend = class extends BaseBedrockBackend {
21336
21900
  }
21337
21901
  if (hasNativeToolMarker(content)) {
21338
21902
  const inner = content.replace(/<\/?reasoning>/g, "");
21339
- const begin = inner.indexOf("<|tool_calls_section_begin|>");
21903
+ const begin = nativeToolCallsBegin(inner);
21340
21904
  const before = (begin >= 0 ? inner.slice(0, begin) : inner).trim();
21341
- const nativeCalls = parseNativeToolSection(inner);
21905
+ const nativeCalls = begin >= 0 ? parseNativeToolSection(inner.slice(begin)) : [];
21342
21906
  const think = [reasoning, before].filter(Boolean).join(" ").trim();
21343
21907
  let usageAttached = false;
21344
21908
  if (think) {
@@ -22439,6 +23003,22 @@ var GeminiBackend = class {
22439
23003
  data: (message.content?.[0]).source.data
22440
23004
  } }]
22441
23005
  };
23006
+ if (!hasToolUse && message.content?.[0].type === "image_url") {
23007
+ const imageUrl = message.content[0].image_url.url;
23008
+ const dataUrlMatch = /^data:([^;,]+)(?:;[^,]*)?;base64,(.+)$/s.exec(imageUrl);
23009
+ if (dataUrlMatch) return {
23010
+ role: mapRole(message.role),
23011
+ parts: [{ inlineData: {
23012
+ mimeType: dataUrlMatch[1],
23013
+ data: dataUrlMatch[2]
23014
+ } }]
23015
+ };
23016
+ if (/^https?:\/\//i.test(imageUrl)) return {
23017
+ role: mapRole(message.role),
23018
+ parts: [{ fileData: { fileUri: imageUrl } }]
23019
+ };
23020
+ return null;
23021
+ }
22442
23022
  if (hasToolUse) {
22443
23023
  const toolUseBlocks = message.content.filter((item) => item.type === "tool_use");
22444
23024
  const textParts = message.content.filter((item) => item.type === "text").map((item) => ({ text: item.text }));
@@ -22889,6 +23469,7 @@ var DeepSeekBackend = class {
22889
23469
  let outputTokens = 0;
22890
23470
  if (!(response instanceof Stream)) {
22891
23471
  const streamedText = [];
23472
+ let sawProse = false;
22892
23473
  if (!response.choices || response.choices.length === 0) throw new Error("No choices returned from the DeepSeek API");
22893
23474
  const turnCacheReadTokens = cachedTokensFromUsage(response.usage);
22894
23475
  for (const c of response.choices) {
@@ -23003,11 +23584,12 @@ var DeepSeekBackend = class {
23003
23584
  return;
23004
23585
  }
23005
23586
  } else {
23006
- const content = c.message.content || "";
23007
- streamedText[c.index] = reasoningContent ? `<think>${reasoningContent}</think>${content}` : content;
23587
+ const prose = c.message.content || "";
23588
+ if (prose) sawProse = true;
23589
+ streamedText[c.index] = reasoningContent ? `<think>${reasoningContent}</think>${prose}` : prose;
23008
23590
  }
23009
23591
  }
23010
- if (streamedText.every((text) => !text) && toolsUsed.length === 0) {
23592
+ if (!sawProse && toolsUsed.length === 0) {
23011
23593
  const finish = response.choices[0]?.finish_reason;
23012
23594
  throw new Error(finish === "length" ? `DeepSeek returned no content for ${model}: the output budget was exhausted before any answer was produced (finish_reason: length). Raise maxTokens or lower the reasoning effort.` : `DeepSeek returned no content for ${model} (finish_reason: ${finish ?? "unknown"}).`);
23013
23595
  }
@@ -23033,7 +23615,7 @@ var DeepSeekBackend = class {
23033
23615
  let streamedReasoning = "";
23034
23616
  let cachedTokensFromStream = 0;
23035
23617
  let streamFinishReason;
23036
- let sawAnyText = false;
23618
+ let sawProse = false;
23037
23619
  for await (const chunk of response) {
23038
23620
  const streamedText = [];
23039
23621
  if (chunk.usage) {
@@ -23055,6 +23637,7 @@ var DeepSeekBackend = class {
23055
23637
  }
23056
23638
  if (isInThinkingBlock && c.delta.content) {
23057
23639
  isInThinkingBlock = false;
23640
+ sawProse = true;
23058
23641
  streamedText[c.index] = (streamedText[c.index] ?? "") + "</think>" + c.delta.content;
23059
23642
  return;
23060
23643
  }
@@ -23066,9 +23649,9 @@ var DeepSeekBackend = class {
23066
23649
  func[tool.index].parameters += tool.function?.arguments || "";
23067
23650
  });
23068
23651
  if (func.length > 0) return;
23652
+ if (c.delta.content) sawProse = true;
23069
23653
  streamedText[c.index] = c.delta.content || "";
23070
23654
  });
23071
- if (streamedText.some((t) => t)) sawAnyText = true;
23072
23655
  const normalizedFinishReason = normalizeOpenAIFinishReason(streamFinishReason);
23073
23656
  await callback(streamedText, {
23074
23657
  ...splitCacheInclusiveInput(accumInputTokens + inputTokens, accumCacheReadTokens + cachedTokensFromStream),
@@ -23085,7 +23668,7 @@ var DeepSeekBackend = class {
23085
23668
  });
23086
23669
  isInThinkingBlock = false;
23087
23670
  }
23088
- if (!sawAnyText && func.length === 0 && toolsUsed.length === 0) throw new Error(streamFinishReason === "length" ? `DeepSeek returned no content for ${model}: the output budget was exhausted before any answer was produced (finish_reason: length). Raise maxTokens or lower the reasoning effort.` : `DeepSeek returned no content for ${model} (finish_reason: ${streamFinishReason ?? "unknown"}).`);
23671
+ if (!sawProse && func.length === 0 && toolsUsed.length === 0) throw new Error(streamFinishReason === "length" ? `DeepSeek returned no content for ${model}: the output budget was exhausted before any answer was produced (finish_reason: length). Raise maxTokens or lower the reasoning effort.` : `DeepSeek returned no content for ${model} (finish_reason: ${streamFinishReason ?? "unknown"}).`);
23089
23672
  let cacheStats;
23090
23673
  if (cacheStrategy?.enableCaching && inputTokens > 0) {
23091
23674
  cacheStats = getCachingAdapter(ModelBackend.DeepSeek).extractCacheStats({ usage: {
@@ -23585,6 +24168,7 @@ var KimiBackend = class {
23585
24168
  let outputTokens = 0;
23586
24169
  if (!(response instanceof Stream)) {
23587
24170
  const streamedText = [];
24171
+ let sawProse = false;
23588
24172
  if (!response.choices || response.choices.length === 0) throw new Error("No choices returned from the Moonshot API");
23589
24173
  const turnCacheReadTokens = cachedTokensFromUsage(response.usage);
23590
24174
  for (const c of response.choices) {
@@ -23695,11 +24279,12 @@ var KimiBackend = class {
23695
24279
  return;
23696
24280
  }
23697
24281
  } else {
23698
- const content = c.message.content || "";
23699
- streamedText[c.index] = reasoningContent ? `<think>${reasoningContent}</think>${content}` : content;
24282
+ const prose = c.message.content || "";
24283
+ if (prose) sawProse = true;
24284
+ streamedText[c.index] = reasoningContent ? `<think>${reasoningContent}</think>${prose}` : prose;
23700
24285
  }
23701
24286
  }
23702
- if (streamedText.every((text) => !text) && toolsUsed.length === 0) {
24287
+ if (!sawProse && toolsUsed.length === 0) {
23703
24288
  const finish = response.choices[0]?.finish_reason;
23704
24289
  throw new Error(finish === "length" ? `Moonshot returned no content for ${model}: the output budget was exhausted before any answer was produced (finish_reason: length). Raise maxTokens or lower the reasoning effort.` : `Moonshot returned no content for ${model} (finish_reason: ${finish ?? "unknown"}).`);
23705
24290
  }
@@ -23724,7 +24309,7 @@ var KimiBackend = class {
23724
24309
  let isInThinkingBlock = false;
23725
24310
  let cachedTokensFromStream = 0;
23726
24311
  let streamFinishReason;
23727
- let sawAnyText = false;
24312
+ let sawProse = false;
23728
24313
  for await (const chunk of response) {
23729
24314
  const streamedText = [];
23730
24315
  if (chunk.usage) {
@@ -23745,6 +24330,7 @@ var KimiBackend = class {
23745
24330
  }
23746
24331
  if (isInThinkingBlock && c.delta.content) {
23747
24332
  isInThinkingBlock = false;
24333
+ sawProse = true;
23748
24334
  streamedText[c.index] = (streamedText[c.index] ?? "") + "</think>" + c.delta.content;
23749
24335
  return;
23750
24336
  }
@@ -23756,9 +24342,9 @@ var KimiBackend = class {
23756
24342
  func[tool.index].parameters += tool.function?.arguments || "";
23757
24343
  });
23758
24344
  if (func.length > 0) return;
24345
+ if (c.delta.content) sawProse = true;
23759
24346
  streamedText[c.index] = c.delta.content || "";
23760
24347
  });
23761
- if (streamedText.some((t) => t)) sawAnyText = true;
23762
24348
  const normalizedFinishReason = normalizeOpenAIFinishReason(streamFinishReason);
23763
24349
  await callback(streamedText, {
23764
24350
  ...splitCacheInclusiveInput(accumInputTokens + inputTokens, accumCacheReadTokens + cachedTokensFromStream),
@@ -23775,7 +24361,7 @@ var KimiBackend = class {
23775
24361
  });
23776
24362
  isInThinkingBlock = false;
23777
24363
  }
23778
- if (!sawAnyText && func.length === 0 && toolsUsed.length === 0) throw new Error(streamFinishReason === "length" ? `Moonshot returned no content for ${model}: the output budget was exhausted before any answer was produced (finish_reason: length). Raise maxTokens or lower the reasoning effort.` : `Moonshot returned no content for ${model} (finish_reason: ${streamFinishReason ?? "unknown"}).`);
24364
+ if (!sawProse && func.length === 0 && toolsUsed.length === 0) throw new Error(streamFinishReason === "length" ? `Moonshot returned no content for ${model}: the output budget was exhausted before any answer was produced (finish_reason: length). Raise maxTokens or lower the reasoning effort.` : `Moonshot returned no content for ${model} (finish_reason: ${streamFinishReason ?? "unknown"}).`);
23779
24365
  let cacheStats;
23780
24366
  if (cacheStrategy?.enableCaching && inputTokens > 0) {
23781
24367
  cacheStats = getCachingAdapter(ModelBackend.Kimi).extractCacheStats({ usage: {
@@ -25988,7 +26574,7 @@ var OpenAIBackend = class {
25988
26574
  });
25989
26575
  else if (m.role === "user") items.push({
25990
26576
  role: "user",
25991
- content: chatContentToString(m.content)
26577
+ content: chatContentToResponsesInput(m.content)
25992
26578
  });
25993
26579
  else if (m.role === "assistant") {
25994
26580
  const text = chatContentToString(m.content);
@@ -26189,8 +26775,9 @@ var OpenAIBackend = class {
26189
26775
  };
26190
26776
  /**
26191
26777
  * Coerce a Chat Completions message `content` (string | content-part array | null)
26192
- * to a plain string for Responses input items. Text parts are concatenated; other
26193
- * parts (images/files) are dropped - the Responses path is used for text+tools turns.
26778
+ * to a plain string for Responses input items that accept text only (system,
26779
+ * assistant, tool output). Text parts are concatenated; other parts are dropped.
26780
+ * User messages go through chatContentToResponsesInput instead, which keeps images.
26194
26781
  */
26195
26782
  function chatContentToString(content) {
26196
26783
  if (typeof content === "string") return content;
@@ -26202,6 +26789,47 @@ function chatContentToString(content) {
26202
26789
  return "";
26203
26790
  }
26204
26791
  /**
26792
+ * Build a Responses user-message `content` from Chat-Completions-shaped content,
26793
+ * keeping image parts as `input_image` items instead of stringifying them away.
26794
+ * Returns a plain string when there is nothing but text, so text-only turns keep
26795
+ * producing exactly the input they did before.
26796
+ */
26797
+ const RESPONSES_IMAGE_DETAIL = /* @__PURE__ */ new Set([
26798
+ "low",
26799
+ "high",
26800
+ "auto",
26801
+ "original"
26802
+ ]);
26803
+ function chatContentToResponsesInput(content) {
26804
+ if (!Array.isArray(content)) return chatContentToString(content);
26805
+ const parts = [];
26806
+ let sawImage = false;
26807
+ for (const part of content) {
26808
+ if (typeof part === "string") {
26809
+ parts.push({
26810
+ type: "input_text",
26811
+ text: part
26812
+ });
26813
+ continue;
26814
+ }
26815
+ if (!part || typeof part !== "object") continue;
26816
+ const typed = part;
26817
+ if (typed.type === "image_url" && typed.image_url?.url) {
26818
+ sawImage = true;
26819
+ const detail = typed.image_url.detail;
26820
+ parts.push({
26821
+ type: "input_image",
26822
+ image_url: typed.image_url.url,
26823
+ detail: RESPONSES_IMAGE_DETAIL.has(detail) ? detail : "auto"
26824
+ });
26825
+ } else if (typeof typed.text === "string") parts.push({
26826
+ type: "input_text",
26827
+ text: typed.text
26828
+ });
26829
+ }
26830
+ return sawImage ? parts : chatContentToString(content);
26831
+ }
26832
+ /**
26205
26833
  * Map an ICompletionOptions `tool_choice` to the Responses API shape. Strings
26206
26834
  * ('auto' | 'required' | 'none') pass through; the Chat Completions object form
26207
26835
  * `{ type:'function', function:{ name } }` becomes the Responses form
@@ -27625,32 +28253,41 @@ var UndifferentiatedBedrockBackend = class extends BaseBedrockBackend {
27625
28253
  }
27626
28254
  };
27627
28255
  /**
27628
- * The prices this build ships in code, keyed by model id.
27629
- *
27630
- * Same provenance as packages/database's modelPrices.seed.json - the adapter
27631
- * `getModelInfo()` literals - reachable without a database, which is what the
27632
- * price planner needs: a model's FIRST discovery-written row has no row in force
27633
- * to carry the rates no feed publishes from, and a tier that reaches
27634
- * getTextModelCost without `cache_read` settles cached reads at
27635
- * input * CACHE_READ_MULTIPLIER. On DeepSeek Flash that default is 0.03/1M
27636
- * against a real 0.006/1M. MUST STAY IN SYNC with collectStaticTextModels in
27637
- * packages/database/src/seeds/generateModelPriceSeed.ts: both lists are "every
27638
- * backend whose getModelInfo() is a static table", and Ollama is absent from
27639
- * both because its listing is a live server call.
27640
- */
27641
- const STATIC_PRICE_BACKENDS = () => [
27642
- new OpenAIBackend("price-literal"),
27643
- new AnthropicBackend("price-literal"),
28256
+ * Every backend whose `getModelInfo()` is a static table - no network, no real key.
28257
+ *
28258
+ * The one list both in-code price paths draw from: `adapterPriceTiers` below, and
28259
+ * collectStaticTextModels in packages/database/src/seeds/generateModelPriceSeed.ts,
28260
+ * which generates modelPrices.seed.json. They were two hand-synced copies, and a
28261
+ * backend reaching one but not the other is a silent billing defect on that
28262
+ * provider - a model with no carried `cache_read` settles cached reads at
28263
+ * input * CACHE_READ_MULTIPLIER (see `adapterPriceTiers`).
28264
+ *
28265
+ * Ollama is absent because its listing is a live server call; BFL and the image
28266
+ * backends publish no text models. The key is a placeholder - a static table needs
28267
+ * none, but the constructors take the argument. Both consumers filter to text
28268
+ * models, so AWSBackend (speech-to-text only) contributes nothing today.
28269
+ */
28270
+ const staticPriceBackends = () => [
28271
+ new OpenAIBackend("static-price-table"),
28272
+ new AnthropicBackend("static-price-table"),
27644
28273
  new UndifferentiatedBedrockBackend(),
27645
- new GeminiBackend("price-literal"),
27646
- new XAIBackend("price-literal"),
27647
- new KimiBackend("price-literal"),
27648
- new DeepSeekBackend("price-literal"),
28274
+ new GeminiBackend("static-price-table"),
28275
+ new XAIBackend("static-price-table"),
28276
+ new KimiBackend("static-price-table"),
28277
+ new DeepSeekBackend("static-price-table"),
27649
28278
  new AWSBackend()
27650
28279
  ];
27651
28280
  let cached;
27652
28281
  /**
27653
- * The lowest-threshold tier of each priced text model's adapter literal.
28282
+ * The prices this build ships in code: the lowest-threshold tier of each priced
28283
+ * text model's adapter literal, keyed by model id.
28284
+ *
28285
+ * Same provenance as packages/database's modelPrices.seed.json, but reachable
28286
+ * without a database, which is what the price planner needs: a model's FIRST
28287
+ * discovery-written row has no row in force to carry the rates no feed publishes
28288
+ * from, and a tier that reaches getTextModelCost without `cache_read` settles
28289
+ * cached reads at input * CACHE_READ_MULTIPLIER. On DeepSeek Flash that default
28290
+ * is 0.03/1M against a real 0.006/1M.
27654
28291
  *
27655
28292
  * Lowest tier on purpose: this is a last-resort carry for rates no feed
27656
28293
  * publishes (cache and audio), and those do not vary by context bracket in any
@@ -27663,7 +28300,7 @@ async function adapterPriceTiers() {
27663
28300
  return cached;
27664
28301
  }
27665
28302
  async function collect() {
27666
- const tables = await Promise.all(STATIC_PRICE_BACKENDS().map((backend) => backend.getModelInfo()));
28303
+ const tables = await Promise.all(staticPriceBackends().map((backend) => backend.getModelInfo()));
27667
28304
  const tiers = /* @__PURE__ */ new Map();
27668
28305
  for (const model of tables.flat()) {
27669
28306
  if (model.type !== "text" || model.freeToRun) continue;
@@ -29823,6 +30460,7 @@ var AgentStore = class {
29823
30460
  */
29824
30461
  constructor(builtinDir, projectRoot) {
29825
30462
  this.agents = /* @__PURE__ */ new Map();
30463
+ this.projectTrusted = false;
29826
30464
  const root = projectRoot || process.cwd();
29827
30465
  const home = os.homedir();
29828
30466
  this.builtinAgentsDir = builtinDir;
@@ -29845,8 +30483,17 @@ var AgentStore = class {
29845
30483
  await this.loadAgentsFromDirectory(this.builtinAgentsDir, "builtin");
29846
30484
  await this.loadAgentsFromDirectory(this.globalB4MAgentsDir, "global");
29847
30485
  await this.loadAgentsFromDirectory(this.globalClaudeAgentsDir, "global");
29848
- await this.loadAgentsFromDirectory(this.projectB4MAgentsDir, "project");
29849
- await this.loadAgentsFromDirectory(this.projectClaudeAgentsDir, "project");
30486
+ if (this.projectTrusted) {
30487
+ await this.loadAgentsFromDirectory(this.projectB4MAgentsDir, "project");
30488
+ await this.loadAgentsFromDirectory(this.projectClaudeAgentsDir, "project");
30489
+ }
30490
+ }
30491
+ /**
30492
+ * Set whether the project root is trusted. When false, `loadAgents()` skips
30493
+ * the project agent directories. Call before `loadAgents()`.
30494
+ */
30495
+ setProjectTrusted(trusted) {
30496
+ this.projectTrusted = trusted;
29850
30497
  }
29851
30498
  /**
29852
30499
  * Recursively load agents from a directory
@@ -30027,6 +30674,31 @@ Describe the expected output format here.
30027
30674
  }
30028
30675
  };
30029
30676
  //#endregion
30677
+ //#region src/bootstrap/projectStores.ts
30678
+ /**
30679
+ * Build the project AgentStore with the folder-trust gate wired from the
30680
+ * ConfigStore, but NOT yet loaded (the caller runs `loadAgents()`).
30681
+ *
30682
+ * Centralizes the trust wiring so the headless (`b4m -p`) and interactive
30683
+ * bootstrap paths can't drift: an untrusted project must contribute no agents
30684
+ * in EITHER path. Pairs with `loadProjectContext` below - must stay in sync.
30685
+ */
30686
+ function buildProjectAgentStore(builtinAgentsDir, configStore) {
30687
+ const store = new AgentStore(builtinAgentsDir, configStore.getProjectConfigDir() ?? process.cwd());
30688
+ store.setProjectTrusted(configStore.isProjectTrusted());
30689
+ return store;
30690
+ }
30691
+ /**
30692
+ * Load the project + global context files with the folder-trust gate applied:
30693
+ * an untrusted project passes `null` so its CLAUDE.md/AGENTS.md is never
30694
+ * injected. Same invariant as `buildProjectAgentStore`, shared by the headless
30695
+ * and interactive paths.
30696
+ */
30697
+ function loadProjectContext(configStore) {
30698
+ const projectDir = configStore.getProjectConfigDir();
30699
+ return loadContextFiles(configStore.isProjectTrusted() ? projectDir : null);
30700
+ }
30701
+ //#endregion
30030
30702
  //#region ../../b4m-core/utils/dist/rolldown-runtime-BBjsoOtd.mjs
30031
30703
  var __defProp = Object.defineProperty;
30032
30704
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -30306,12 +30978,53 @@ function parseArtifacts(content) {
30306
30978
  cleanedContent: cleanedContent.trim()
30307
30979
  };
30308
30980
  }
30981
+ function hasReactComponentLine(code) {
30982
+ const DECLARATIONS = [
30983
+ "function",
30984
+ "const",
30985
+ "class"
30986
+ ];
30987
+ const COMPONENT_TOKENS = [
30988
+ "component",
30989
+ "app",
30990
+ "export default"
30991
+ ];
30992
+ for (const rawLine of code.split("\n")) {
30993
+ const line = rawLine.toLowerCase();
30994
+ let declStart = Infinity;
30995
+ let declEnd = -1;
30996
+ for (const decl of DECLARATIONS) {
30997
+ const at = line.indexOf(decl);
30998
+ if (at >= 0 && at < declStart) {
30999
+ declStart = at;
31000
+ declEnd = at + decl.length;
31001
+ }
31002
+ }
31003
+ if (declEnd < 0) continue;
31004
+ const afterDecl = line.slice(declEnd);
31005
+ if (COMPONENT_TOKENS.some((token) => afterDecl.includes(token))) return true;
31006
+ }
31007
+ return false;
31008
+ }
31009
+ function hasFullHtmlDocument(code) {
31010
+ const lower = code.toLowerCase();
31011
+ const doctype = lower.indexOf("<!doctype");
31012
+ if (doctype < 0) return false;
31013
+ return lower.indexOf("</html>", doctype + 9) >= 0;
31014
+ }
31015
+ function hasCompleteSvg(code) {
31016
+ const lower = code.toLowerCase();
31017
+ const open = lower.indexOf("<svg");
31018
+ if (open < 0) return false;
31019
+ return lower.indexOf("</svg>", open + 4) >= 0;
31020
+ }
30309
31021
  /**
30310
31022
  * Post-processes AI responses to detect code blocks that should be artifacts
30311
31023
  * and converts them to proper artifact syntax as a fallback
30312
31024
  */
30313
31025
  function convertCodeBlocksToArtifacts(content) {
30314
- content = content.replace(/```(?:tsx?|javascript|jsx)\s*((?:.*\n)*?.*(?:function|const|class).*(?:Component|App|export default).*(?:\n.*)*?)```/gi, (match, codeContent) => {
31026
+ content = content.replace(/```(?:tsx?|javascript|jsx)\s*([\s\S]*?)```/gi, (match, codeContent) => {
31027
+ if (!hasReactComponentLine(codeContent)) return match;
30315
31028
  if (codeContent.includes("useState") || codeContent.includes("useEffect") || codeContent.includes("export default") || codeContent.includes("function") && codeContent.includes("return")) {
30316
31029
  const componentName = extractComponentName(codeContent) || "component";
30317
31030
  return `<artifact identifier="${componentName.toLowerCase().replace(/[^a-z0-9]/g, "-")}" type="application/vnd.ant.react" title="${componentName}">
@@ -30320,7 +31033,8 @@ ${codeContent.trim()}
30320
31033
  }
30321
31034
  return match;
30322
31035
  });
30323
- content = content.replace(/```html\s*((?:.*\n)*?.*<!DOCTYPE.*(?:\n.*)*?.*<\/html>.*(?:\n.*)*?)```/gi, (match, codeContent) => {
31036
+ content = content.replace(/```html\s*([\s\S]*?)```/gi, (match, codeContent) => {
31037
+ if (!hasFullHtmlDocument(codeContent)) return match;
30324
31038
  const title = extractHTMLTitle(codeContent) || "HTML Page";
30325
31039
  return `<artifact identifier="${title.toLowerCase().replace(/[^a-z0-9]/g, "-")}" type="text/html" title="${title}">
30326
31040
  ${codeContent.trim()}
@@ -30333,7 +31047,8 @@ ${codeContent.trim()}
30333
31047
  ${codeContent.trim()}
30334
31048
  </artifact>`;
30335
31049
  });
30336
- content = content.replace(/```svg\s*((?:.*\n)*?.*<svg.*(?:\n.*)*?.*<\/svg>.*(?:\n.*)*?)```/gi, (match, codeContent) => {
31050
+ content = content.replace(/```svg\s*([\s\S]*?)```/gi, (match, codeContent) => {
31051
+ if (!hasCompleteSvg(codeContent)) return match;
30337
31052
  return `<artifact identifier="svg-graphic" type="image/svg+xml" title="SVG Graphic">
30338
31053
  ${codeContent.trim()}
30339
31054
  </artifact>`;
@@ -30668,8 +31383,9 @@ function effectiveContextWindow(modelInfo) {
30668
31383
  function safeInputWindow(modelInfo, requestedMaxTokens, safetyBuffer = CONTEXT_WINDOW_SAFETY_BUFFER_TOKENS) {
30669
31384
  const returnsMedia = isMediaModelType(modelInfo.type);
30670
31385
  const contextLimit = effectiveContextWindow(modelInfo);
30671
- const modelMaxOutput = modelInfo.max_tokens ?? 16384;
30672
- return contextLimit - (returnsMedia ? 0 : Math.min(requestedMaxTokens, modelMaxOutput)) - safetyBuffer;
31386
+ const cap = usableTokenCount(modelInfo.max_tokens);
31387
+ const capClamps = modelInfo.maxOutputTokensDerived !== true;
31388
+ return contextLimit - (returnsMedia ? 0 : capClamps && cap !== void 0 ? Math.min(requestedMaxTokens, cap) : requestedMaxTokens) - safetyBuffer;
30673
31389
  }
30674
31390
  /**
30675
31391
  * Floor for the context-overflow buffer, used when 5% of the context window is under 1000 tokens.
@@ -30761,7 +31477,7 @@ const DEFAULT_OUTPUT_MAX_TOKENS = 4096;
30761
31477
  * turn instead of disabling the trim.
30762
31478
  */
30763
31479
  function computeVerbatimTokenBudget(modelInfo, requestedMaxTokens, opts) {
30764
- const modelMaxOutput = modelInfo.max_tokens ?? 16384;
31480
+ const modelMaxOutput = modelInfo.max_tokens;
30765
31481
  const safeMaxTokens = resolveOutputMaxTokens({
30766
31482
  requested: requestedMaxTokens,
30767
31483
  fallback: DEFAULT_OUTPUT_MAX_TOKENS,
@@ -31246,7 +31962,7 @@ async function getSettingsByNames(settingNames, db, options) {
31246
31962
  result[name] = null;
31247
31963
  });
31248
31964
  settings.forEach((setting) => {
31249
- result[setting.settingName] = setting.settingValue;
31965
+ result[setting.settingName] = setting.settingValue ?? null;
31250
31966
  });
31251
31967
  return result;
31252
31968
  }
@@ -31472,12 +32188,9 @@ async function getFileType(buffer, fileName, currentMimeType = "") {
31472
32188
  ext: fileType.ext,
31473
32189
  mime: fileType.mime
31474
32190
  };
31475
- let ext = getFileExtension(fileName);
31476
- const mime = currentMimeType || (isPlainText(buffer) ? SupportedFabFileMimeTypes.TXT_PLAIN : "application/octet-stream");
31477
- ext = ext ?? MIME_TO_EXT[mime] ?? "";
31478
32191
  return {
31479
- ext,
31480
- mime
32192
+ ext: getFileExtension(fileName),
32193
+ mime: currentMimeType || (isPlainText(buffer) ? SupportedFabFileMimeTypes.TXT_PLAIN : "application/octet-stream")
31481
32194
  };
31482
32195
  }
31483
32196
  /**
@@ -31521,91 +32234,123 @@ function decodeBase64DataUrl(dataUrl) {
31521
32234
  const getFileExtension = (fileName) => {
31522
32235
  return path.extname(fileName).toLowerCase().slice(1);
31523
32236
  };
32237
+ const hasFileExtension = (fileName) => getFileExtension(fileName) !== "";
32238
+ const isExtensionlessFileName = (fileName) => !hasFileExtension(fileName) && !fileName.endsWith(".");
31524
32239
  /**
31525
- * Returns the MIME type corresponding to a given file extension.
31526
- * Special handling for configuration files and TypeScript files.
32240
+ * Returns the MIME type corresponding to a given file extension, or '' if unresolved.
31527
32241
  */
31528
32242
  const getMimeTypeByExtension = (ext) => {
31529
- const EXT_TO_MIME = invert(MIME_TO_EXT);
31530
32243
  const lowerExt = ext.toLowerCase();
31531
- if ([
31532
- "ini",
31533
- "env",
31534
- "conf"
31535
- ].includes(lowerExt)) return SupportedFabFileMimeTypes.TXT_PLAIN;
31536
- if (lowerExt === "tsx" || lowerExt === "ts") return SupportedFabFileMimeTypes.TS;
31537
- if (lowerExt === "mdx") return SupportedFabFileMimeTypes.TXT_MARKDOWN;
31538
- if (lowerExt === "xls") return SupportedFabFileMimeTypes.XLS;
31539
- if (lowerExt === "xlsx") return SupportedFabFileMimeTypes.XLSX;
31540
- if (lowerExt === "docx") return SupportedFabFileMimeTypes.DOCX;
31541
- if (lowerExt === "pptx") return SupportedFabFileMimeTypes.PPTX;
31542
- if (lowerExt === "jpeg") return SupportedFabFileMimeTypes.JPG;
31543
32244
  return EXT_TO_MIME[lowerExt] ?? "";
31544
32245
  };
31545
32246
  /**
31546
- * Resolve the effective, supported MIME type for an uploaded file.
31547
- *
31548
- * Browsers frequently report an empty or generic (`application/octet-stream`)
31549
- * MIME type - even for supported code/text files (e.g. `.py`, `.ts`). We trust
31550
- * a claimed type only if it's already supported; otherwise we derive the type
31551
- * from the file extension. The returned `mimeType` is what should be persisted
31552
- * (so the chunker keys on a type it can actually process), and `supported`
31553
- * gates ingest so unsupported/binary files (e.g. `.exe`) are rejected.
32247
+ * Resolve the effective MIME type for an uploaded file: `mimeType` is what to persist (so the
32248
+ * chunker keys on a type it can process) and `supported` gates ingest. Under the default
32249
+ * `'extension-first'` a name whose extension does not resolve is refused outright, claim or no
32250
+ * claim. `'claim-first'` exists for a claim that comes from a trusted server-side source rather
32251
+ * than a client (Google Drive's stored metadata), where the user-renamable filename is worth less.
31554
32252
  *
31555
32253
  * @param fileName - Original file name (used to derive the extension).
31556
- * @param claimedMimeType - The browser/client-provided MIME type, if any.
31557
- */
31558
- function resolveSupportedMimeType(fileName, claimedMimeType) {
31559
- if (isSupportedFabFileMimeType(claimedMimeType)) return {
32254
+ * @param claimedMimeType - The claimed MIME type, if any.
32255
+ * @param opts.isAcceptable - Predicate gating both extension- and claim-derived types; defaults
32256
+ * to `isSupportedFabFileMimeType`.
32257
+ * @param opts.precedence - Which of extension/claim is consulted first; defaults to
32258
+ * `'extension-first'`.
32259
+ * @param opts.extensionlessFallback - Type for a name carrying no extension at all (`LICENSE`,
32260
+ * `.env`) that also carried no claim. Omit it to keep a door strict.
32261
+ */
32262
+ function resolveSupportedMimeType(fileName, claimedMimeType, opts = {}) {
32263
+ const { isAcceptable = isSupportedFabFileMimeType, precedence = "extension-first", extensionlessFallback } = opts;
32264
+ const ext = getFileExtension(fileName);
32265
+ const byExtension = getMimeTypeByExtension(ext);
32266
+ const extensionResult = byExtension ? {
32267
+ mimeType: byExtension,
32268
+ supported: isAcceptable(byExtension)
32269
+ } : null;
32270
+ if (!extensionResult && ext !== "" && precedence !== "claim-first") return {
32271
+ mimeType: "",
32272
+ supported: false
32273
+ };
32274
+ const claimResult = claimedMimeType && isAcceptable(claimedMimeType) ? {
31560
32275
  mimeType: claimedMimeType,
31561
32276
  supported: true
32277
+ } : null;
32278
+ const resolved = precedence === "claim-first" ? claimResult ?? extensionResult : extensionResult ?? claimResult;
32279
+ if (resolved) return resolved;
32280
+ if (extensionlessFallback && !claimedMimeType && isExtensionlessFileName(fileName)) return {
32281
+ mimeType: extensionlessFallback,
32282
+ supported: isAcceptable(extensionlessFallback)
31562
32283
  };
31563
- const byExtension = getMimeTypeByExtension(getFileExtension(fileName));
31564
32284
  return {
31565
- mimeType: byExtension,
31566
- supported: isSupportedFabFileMimeType(byExtension)
32285
+ mimeType: "",
32286
+ supported: false
31567
32287
  };
31568
32288
  }
31569
- const MIME_TO_EXT = {
31570
- [SupportedFabFileMimeTypes.TXT_PLAIN]: "txt",
31571
- [SupportedFabFileMimeTypes.TXT_MARKDOWN]: "md",
31572
- [SupportedFabFileMimeTypes.TXT_MD_LEGACY]: "md",
31573
- [SupportedFabFileMimeTypes.HTML]: "html",
31574
- [SupportedFabFileMimeTypes.CSV]: "csv",
31575
- [SupportedFabFileMimeTypes.JPG]: "jpg",
31576
- [SupportedFabFileMimeTypes.PNG]: "png",
31577
- [SupportedFabFileMimeTypes.GIF]: "gif",
31578
- [SupportedFabFileMimeTypes.SVG]: "svg",
31579
- [SupportedFabFileMimeTypes.WEBP]: "webp",
31580
- [SupportedFabFileMimeTypes.PDF]: "pdf",
31581
- [SupportedFabFileMimeTypes.JSON]: "json",
31582
- [SupportedFabFileMimeTypes.XML]: "xml",
31583
- [SupportedFabFileMimeTypes.DOCX]: "docx",
31584
- [SupportedFabFileMimeTypes.PPTX]: "pptx",
31585
- [SupportedFabFileMimeTypes.XLSX]: "xlsx",
31586
- [SupportedFabFileMimeTypes.XLS]: "xls",
31587
- [SupportedFabFileMimeTypes.JS]: "js",
31588
- [SupportedFabFileMimeTypes.JSX]: "jsx",
31589
- [SupportedFabFileMimeTypes.TS]: "ts",
31590
- [SupportedFabFileMimeTypes.PY]: "py",
31591
- [SupportedFabFileMimeTypes.JAVA]: "java",
31592
- [SupportedFabFileMimeTypes.CPP]: "cpp",
31593
- [SupportedFabFileMimeTypes.CS]: "cs",
31594
- [SupportedFabFileMimeTypes.PHP]: "php",
31595
- [SupportedFabFileMimeTypes.RUBY]: "rb",
31596
- [SupportedFabFileMimeTypes.GO]: "go",
31597
- [SupportedFabFileMimeTypes.SWIFT]: "swift",
31598
- [SupportedFabFileMimeTypes.KOTLIN]: "kt",
31599
- [SupportedFabFileMimeTypes.RUST]: "rs",
31600
- [SupportedFabFileMimeTypes.CSS]: "css",
31601
- [SupportedFabFileMimeTypes.LESS]: "less",
31602
- [SupportedFabFileMimeTypes.SASS]: "sass",
31603
- [SupportedFabFileMimeTypes.SCSS]: "scss",
31604
- [SupportedFabFileMimeTypes.YAML]: "yaml",
31605
- [SupportedFabFileMimeTypes.TOML]: "toml",
31606
- [SupportedFabFileMimeTypes.SH]: "sh",
31607
- [SupportedFabFileMimeTypes.BASH]: "bash"
31608
- };
32289
+ const EXT_TO_MIME = Object.assign(Object.create(null), {
32290
+ txt: SupportedFabFileMimeTypes.TXT_PLAIN,
32291
+ ini: SupportedFabFileMimeTypes.TXT_PLAIN,
32292
+ env: SupportedFabFileMimeTypes.TXT_PLAIN,
32293
+ conf: SupportedFabFileMimeTypes.TXT_PLAIN,
32294
+ log: SupportedFabFileMimeTypes.TXT_PLAIN,
32295
+ sql: SupportedFabFileMimeTypes.TXT_PLAIN,
32296
+ text: SupportedFabFileMimeTypes.TXT_PLAIN,
32297
+ md: SupportedFabFileMimeTypes.TXT_MARKDOWN,
32298
+ mdx: SupportedFabFileMimeTypes.TXT_MARKDOWN,
32299
+ html: SupportedFabFileMimeTypes.HTML,
32300
+ htm: SupportedFabFileMimeTypes.HTML,
32301
+ shtml: SupportedFabFileMimeTypes.HTML,
32302
+ csv: SupportedFabFileMimeTypes.CSV,
32303
+ jpg: SupportedFabFileMimeTypes.JPG,
32304
+ jpeg: SupportedFabFileMimeTypes.JPG,
32305
+ jfif: SupportedFabFileMimeTypes.JPG,
32306
+ jpe: SupportedFabFileMimeTypes.JPG,
32307
+ png: SupportedFabFileMimeTypes.PNG,
32308
+ gif: SupportedFabFileMimeTypes.GIF,
32309
+ svg: SupportedFabFileMimeTypes.SVG,
32310
+ webp: SupportedFabFileMimeTypes.WEBP,
32311
+ pdf: SupportedFabFileMimeTypes.PDF,
32312
+ json: SupportedFabFileMimeTypes.JSON,
32313
+ xml: SupportedFabFileMimeTypes.XML,
32314
+ docx: SupportedFabFileMimeTypes.DOCX,
32315
+ pptx: SupportedFabFileMimeTypes.PPTX,
32316
+ xlsx: SupportedFabFileMimeTypes.XLSX,
32317
+ xls: SupportedFabFileMimeTypes.XLS,
32318
+ js: SupportedFabFileMimeTypes.JS,
32319
+ mjs: SupportedFabFileMimeTypes.JS,
32320
+ cjs: SupportedFabFileMimeTypes.JS,
32321
+ jsx: SupportedFabFileMimeTypes.JSX,
32322
+ ts: SupportedFabFileMimeTypes.TS,
32323
+ tsx: SupportedFabFileMimeTypes.TS,
32324
+ py: SupportedFabFileMimeTypes.PY,
32325
+ java: SupportedFabFileMimeTypes.JAVA,
32326
+ cpp: SupportedFabFileMimeTypes.CPP,
32327
+ c: SupportedFabFileMimeTypes.CPP,
32328
+ h: SupportedFabFileMimeTypes.CPP,
32329
+ cs: SupportedFabFileMimeTypes.CS,
32330
+ php: SupportedFabFileMimeTypes.PHP,
32331
+ rb: SupportedFabFileMimeTypes.RUBY,
32332
+ go: SupportedFabFileMimeTypes.GO,
32333
+ swift: SupportedFabFileMimeTypes.SWIFT,
32334
+ kt: SupportedFabFileMimeTypes.KOTLIN,
32335
+ rs: SupportedFabFileMimeTypes.RUST,
32336
+ css: SupportedFabFileMimeTypes.CSS,
32337
+ less: SupportedFabFileMimeTypes.LESS,
32338
+ sass: SupportedFabFileMimeTypes.SASS,
32339
+ scss: SupportedFabFileMimeTypes.SCSS,
32340
+ yaml: SupportedFabFileMimeTypes.YAML,
32341
+ yml: SupportedFabFileMimeTypes.YAML,
32342
+ toml: SupportedFabFileMimeTypes.TOML,
32343
+ sh: SupportedFabFileMimeTypes.SH,
32344
+ bash: SupportedFabFileMimeTypes.BASH,
32345
+ mp3: AudioMimeType.MP3,
32346
+ wav: AudioMimeType.WAV,
32347
+ opus: AudioMimeType.OPUS,
32348
+ aac: AudioMimeType.AAC,
32349
+ flac: AudioMimeType.FLAC,
32350
+ pcm: AudioMimeType.PCM,
32351
+ ogg: AudioMimeType.OGG,
32352
+ webm: AudioMimeType.WEBM
32353
+ });
31609
32354
  const MAX_FILE_SIZE = 6e3;
31610
32355
  /** Cap on generated images surfaced to the model for editing (keeps the context note small). */
31611
32356
  const MAX_RECENT_GENERATED_IMAGES = 6;
@@ -32292,6 +33037,24 @@ async function cosineSearch(file, userPromptVector, { db, logger }) {
32292
33037
  }
32293
33038
  /** Passthrough default: no resize when a caller doesn't inject one. */
32294
33039
  const noopResize = async (imageBuffer) => imageBuffer;
33040
+ /**
33041
+ * Skip an image whose declared canvas is over the decode budget (resizeImageForModel returned
33042
+ * null). It is never decoded, so it cannot be downscaled here - only a smaller upload fixes it.
33043
+ * Every vision path that drops a file has to push a notice, or the file reaches neither the prompt
33044
+ * nor the user (#2228).
33045
+ */
33046
+ async function noticeOversizedCanvas(file, fileNotices, logger, sendStatusUpdate) {
33047
+ const message = `\u26a0\ufe0f Image "${file.fileName}" declares too large a canvas to process and was not sent. Please delete this file and re-upload a smaller image.`;
33048
+ logger.warn(message);
33049
+ await sendStatusUpdate(message);
33050
+ fileNotices.push({
33051
+ fabFileId: file.id,
33052
+ fileName: file.fileName,
33053
+ band: "image_too_large",
33054
+ message,
33055
+ delivered: false
33056
+ });
33057
+ }
32295
33058
  async function processFabFilesServer(embeddingFactory, fabFiles, userPrompt, attachedContentTokenBudget, modelInfo, sendStatusUpdate, { logger, storage, db, resizeImageForModel = noopResize }, progressCallback) {
32296
33059
  if (!fabFiles || fabFiles.length === 0) return {
32297
33060
  userMessages: [],
@@ -32398,6 +33161,10 @@ async function processFabFilesServer(embeddingFactory, fabFiles, userPrompt, att
32398
33161
  return;
32399
33162
  }
32400
33163
  const imageBuffer = await resizeImageForModel(await storage.download(file.filePath), void 0, logger);
33164
+ if (imageBuffer === null) {
33165
+ await noticeOversizedCanvas(file, fileNotices, logger, sendStatusUpdate);
33166
+ return;
33167
+ }
32401
33168
  const imageData = imageBuffer.toString("base64");
32402
33169
  const { mime: actualMimeType } = await getFileType(imageBuffer, file.fileName, file.mimeType);
32403
33170
  imageContent.push({
@@ -32416,6 +33183,10 @@ async function processFabFilesServer(embeddingFactory, fabFiles, userPrompt, att
32416
33183
  fullyDelivered = true;
32417
33184
  } else if (modelInfo.id.startsWith("moonshot")) {
32418
33185
  const moonshotBuffer = await resizeImageForModel(await storage.download(file.filePath), void 0, logger);
33186
+ if (moonshotBuffer === null) {
33187
+ await noticeOversizedCanvas(file, fileNotices, logger, sendStatusUpdate);
33188
+ return;
33189
+ }
32419
33190
  const { mime: moonshotMimeType } = await getFileType(moonshotBuffer, file.fileName, file.mimeType);
32420
33191
  const moonshotBase64 = moonshotBuffer.toString("base64");
32421
33192
  if (moonshotBase64.length > 3e6) {
@@ -32455,6 +33226,10 @@ async function processFabFilesServer(embeddingFactory, fabFiles, userPrompt, att
32455
33226
  break;
32456
33227
  case ModelBackend.Ollama: {
32457
33228
  const imageBuffer = await resizeImageForModel(await storage.download(file.filePath), void 0, logger);
33229
+ if (imageBuffer === null) {
33230
+ await noticeOversizedCanvas(file, fileNotices, logger, sendStatusUpdate);
33231
+ return;
33232
+ }
32458
33233
  const { mime: ollamaMimeType } = await getFileType(imageBuffer, file.fileName, file.mimeType);
32459
33234
  const ollamaBase64 = imageBuffer.toString("base64");
32460
33235
  const OLLAMA_IMAGE_WARN_MB = 3.5;
@@ -34831,6 +35606,38 @@ const OPENAI_IMAGE_CLIENT_OPTS = {
34831
35606
  maxRetries: 0
34832
35607
  };
34833
35608
  const ALTERNATIVE_IMAGE_MODELS = "Flux Pro, Flux Dev, or Grok";
35609
+ const GPT_IMAGE_QUALITY_VALUES = [
35610
+ "low",
35611
+ "medium",
35612
+ "high",
35613
+ "auto"
35614
+ ];
35615
+ function isGptImageQuality(value) {
35616
+ return typeof value === "string" && GPT_IMAGE_QUALITY_VALUES.includes(value);
35617
+ }
35618
+ /**
35619
+ * Normalizes a requested quality to the tier a GPT-Image model actually accepts, or
35620
+ * undefined when it maps to nothing usable. The 'standard'/'hd' translation must stay
35621
+ * in step with OpenAIImageCostCalculator.normalizeInput (services) and
35622
+ * ImageGeneration's mapQualityForModel, which bill against the mapped tier - if they
35623
+ * diverge, the user is charged one tier and rendered another.
35624
+ *
35625
+ * 'auto' is the one value deliberately forwarded unresolved: OpenAI picks the effort per
35626
+ * request, so the services-side calculator prices it at the highest tier it could render
35627
+ * rather than pretending to know the tier. Do not "fix" that by pinning 'auto' here without
35628
+ * repricing it there. (Named symbols are left out on purpose - services depends on utils, not
35629
+ * the reverse, so nothing in this package can import or rename-track them.)
35630
+ *
35631
+ * An absent quality still maps to undefined here, which drops the parameter and lets OpenAI
35632
+ * apply its own 'auto'. On the generation path that state is no longer reachable: both
35633
+ * dispatch sites in services pin an omitted tier to the tier they bill before calling in, so
35634
+ * the render matches the charge. The edit path does not pin, and is priced separately.
35635
+ * Keep this a pure mapper - the pin belongs with the code that also holds the credits.
35636
+ */
35637
+ function toGptImageQuality(quality) {
35638
+ const mapped = quality === "standard" ? "medium" : quality === "hd" ? "high" : quality;
35639
+ return isGptImageQuality(mapped) ? mapped : void 0;
35640
+ }
34834
35641
  const truncatePromptForLog = (prompt) => prompt.length > 100 ? `${prompt.slice(0, 100)}...` : prompt;
34835
35642
  /**
34836
35643
  * Builds a user-friendly error when OpenAI's safety system blocks an image
@@ -34860,53 +35667,51 @@ function buildModerationBlockedError(error) {
34860
35667
  Tip: Switch to an alternative model with different content policies — e.g. ${ALTERNATIVE_IMAGE_MODELS} — which may accept this prompt.\n\nIf you believe this is an error, you can report it to OpenAI with request ID: ${requestId}`);
34861
35668
  }
34862
35669
  /**
34863
- * Splits a WIDTHxHEIGHT size into its two edges, or null when the value is not a
34864
- * pair of non-zero numbers (e.g. 'auto', '', 'wide'). Null means "not a custom
34865
- * resolution" rather than "invalid": generate() has always left such values
34866
- * untouched, and that behaviour is preserved.
34867
- */
34868
- function parseSizeEdges(size) {
34869
- if (typeof size !== "string") return null;
34870
- const [width, height] = size.split("x").map(Number);
34871
- if (!width || !height) return null;
34872
- return {
34873
- width,
34874
- height
34875
- };
34876
- }
34877
- /**
34878
- * True when a custom gpt-image-2 resolution meets OpenAI's documented limits.
34879
- * gpt-image-2 accepts any resolution satisfying these, not only the presets in
34880
- * OPENAI_GPT_IMAGE_2_IMAGE_SIZES, so a flat preset check would reject valid
34881
- * custom sizes. Must stay the single source of this rule for generate() and edit().
34882
- */
34883
- function satisfiesGptImage2Constraints({ width, height }) {
34884
- const { maxEdge, minTotalPixels, maxTotalPixels, edgeMultiple, maxAspectRatio } = IMAGE_SIZE_CONSTRAINTS.GPT_IMAGE_2.constraints;
34885
- const longEdge = Math.max(width, height);
34886
- const shortEdge = Math.min(width, height);
34887
- const totalPixels = width * height;
34888
- return longEdge <= maxEdge && width % edgeMultiple === 0 && height % edgeMultiple === 0 && longEdge / shortEdge <= maxAspectRatio && totalPixels >= minTotalPixels && totalPixels <= maxTotalPixels;
34889
- }
34890
- /**
34891
- * True when `size` may be forwarded to images.edit for `model`. gpt-image-2 takes
34892
- * its presets (including 'auto') or any custom WIDTHxHEIGHT meeting the same
34893
- * constraints generate() enforces; the gpt-image-1 family is limited to its three
34894
- * fixed sizes. An unsupported size is dropped by the caller so OpenAI applies its
34895
- * own default instead of rejecting the whole request with a 400.
34896
- *
34897
- * GPT-Image tiers only: dall-e-2 has its own size list and passes size through
34898
- * untouched, so do not route that model here.
34899
- */
34900
- function isSupportedEditSize(model, size) {
34901
- if (typeof size !== "string") return false;
34902
- if (isGPTImage2Model(model)) {
34903
- if (OPENAI_GPT_IMAGE_2_IMAGE_SIZES.includes(size)) return true;
34904
- const edges = parseSizeEdges(size);
34905
- return edges !== null && satisfiesGptImage2Constraints(edges);
35670
+ * Resolve the alpha/container pair gpt-image accepts. OpenAI rejects
35671
+ * `background: 'transparent'` together with jpeg (no alpha channel), so a transparent
35672
+ * request promotes the container to png rather than failing the whole render.
35673
+ * gpt-image-2 rejects `background: 'transparent'` outright, so it is dropped there
35674
+ * (falling back to OpenAI's own default) with a warning instead of 400-ing the whole
35675
+ * request - this is the single backstop for every call site (generate/edit, tool call
35676
+ * or queue handler, explicit model selection or default), so `model` must be the
35677
+ * fully-resolved model actually sent to OpenAI, not a pre-fallback value.
35678
+ * Returns the fields to spread onto the request; absent keys mean "let OpenAI default".
35679
+ */
35680
+ function resolveGptImageOutputOptions(background, outputFormat, warnings, model) {
35681
+ const resolved = {};
35682
+ if (background) resolved.background = background;
35683
+ if (outputFormat) resolved.output_format = outputFormat;
35684
+ if (background === "transparent" && isGPTImage2Model(model)) {
35685
+ delete resolved.background;
35686
+ warnings.push("gpt-image-2 does not support background: 'transparent'; background parameter removed");
35687
+ }
35688
+ if (resolved.background === "transparent" && outputFormat === "jpeg") {
35689
+ resolved.output_format = "png";
35690
+ warnings.push("Transparent background requires an alpha-capable format; output_format changed from 'jpeg' to 'png'");
34906
35691
  }
34907
- return OPENAI_GPT_IMAGE_1_IMAGE_SIZES.includes(size);
35692
+ return resolved;
34908
35693
  }
34909
35694
  var OpenAIImageService = class extends AIImageService {
35695
+ /**
35696
+ * Fetches an image (URL or data URL) and normalizes it to the PNG-under-4MB form every
35697
+ * OpenAI image endpoint accepts. The 4MB/PNG coercion is dall-e-2's constraint, not
35698
+ * gpt-image's (which takes png/webp/jpg up to 50MB) - kept as-is so this refactor does
35699
+ * not change what reaches the provider.
35700
+ */
35701
+ async toImageFile(source, fileName) {
35702
+ if (!this.imageProcessorLambdaName) throw new Error("ImageProcessor Lambda name is required for image processing. Please provide it when creating the image service.");
35703
+ const pngBuffer = await invokeImageProcessor(await downloadImageAsBuffer(source), this.imageProcessorLambdaName, 4);
35704
+ return new File([pngBuffer], fileName, { type: "image/png" });
35705
+ }
35706
+ /**
35707
+ * Converts style-anchor sources into files, in the order given. Parallel on purpose: each
35708
+ * source costs a download plus an ImageProcessor Lambda round trip, and serialized those
35709
+ * would eat a meaningful share of the 8-minute client budget (OPENAI_IMAGE_CLIENT_OPTS).
35710
+ */
35711
+ async toReferenceImageFiles(sources) {
35712
+ if (!sources?.length) return [];
35713
+ return Promise.all(sources.map((source, i) => this.toImageFile(source, `reference-${i + 1}.png`)));
35714
+ }
34910
35715
  async generate(prompt, options) {
34911
35716
  const openai = new OpenAI({
34912
35717
  apiKey: this.apiKey,
@@ -34914,42 +35719,30 @@ var OpenAIImageService = class extends AIImageService {
34914
35719
  });
34915
35720
  Logger.log("Generating image... with these params: ", options);
34916
35721
  try {
34917
- const { safety_tolerance, prompt_upsampling, seed: bflSeed, output_format, imagePrompt, stream, ...openaiOptions } = options;
35722
+ const { safety_tolerance, prompt_upsampling, seed: bflSeed, output_format, background, imagePrompt, referenceImages, stream, ...openaiOptions } = options;
34918
35723
  const parameterWarnings = [];
35724
+ let gptImageOutputOptions = {};
35725
+ const modelName = options.model || ImageModels.GPT_IMAGE_1_5;
34919
35726
  if (isGPTImageModel(options.model)) {
34920
- const modelName = options.model || ImageModels.GPT_IMAGE_1_5;
34921
35727
  openaiOptions.model = modelName;
35728
+ gptImageOutputOptions = resolveGptImageOutputOptions(background, output_format, parameterWarnings, modelName);
34922
35729
  if (openaiOptions.style) {
34923
35730
  parameterWarnings.push(`Style parameter ('${openaiOptions.style}') is not supported by ${modelName} and was removed`);
34924
35731
  delete openaiOptions.style;
34925
35732
  }
34926
35733
  if (openaiOptions.response_format) delete openaiOptions.response_format;
34927
35734
  if (openaiOptions.quality) {
34928
- parameterWarnings.push(`Quality parameter ('${openaiOptions.quality}') is not supported by ${modelName} text-to-image generation and was removed`);
34929
- delete openaiOptions.quality;
35735
+ const mappedQuality = toGptImageQuality(openaiOptions.quality);
35736
+ if (mappedQuality) openaiOptions.quality = mappedQuality;
35737
+ else {
35738
+ parameterWarnings.push(`Quality parameter ('${openaiOptions.quality}') is not supported by ${modelName} and was removed`);
35739
+ delete openaiOptions.quality;
35740
+ }
34930
35741
  }
34931
- if (isGPTImage2Model(options.model)) {
34932
- if (openaiOptions.size && openaiOptions.size !== "auto") {
34933
- const edges = parseSizeEdges(openaiOptions.size);
34934
- if (edges && !satisfiesGptImage2Constraints(edges)) {
34935
- const originalSize = openaiOptions.size;
34936
- openaiOptions.size = "1024x1024";
34937
- parameterWarnings.push(`Size '${originalSize}' violates gpt-image-2 constraints, changed to '1024x1024'`);
34938
- }
34939
- } else if (!openaiOptions.size) openaiOptions.size = "auto";
34940
- } else {
34941
- const validGPTSizes = [
34942
- "1024x1024",
34943
- "1536x1024",
34944
- "1024x1536"
34945
- ];
34946
- if (openaiOptions.size) {
34947
- if (!validGPTSizes.includes(openaiOptions.size)) {
34948
- const originalSize = openaiOptions.size;
34949
- openaiOptions.size = "1024x1024";
34950
- parameterWarnings.push(`Size '${originalSize}' is not supported by ${modelName}, changed to '1024x1024'`);
34951
- }
34952
- } else openaiOptions.size = "1024x1024";
35742
+ const resolvedSize = resolveGptImageGenerateSize(modelName, openaiOptions.size);
35743
+ if (resolvedSize !== openaiOptions.size) {
35744
+ if (openaiOptions.size) parameterWarnings.push(`Size '${openaiOptions.size}' is not supported by ${modelName}, changed to '${resolvedSize}'`);
35745
+ openaiOptions.size = resolvedSize;
34953
35746
  }
34954
35747
  if ("width" in openaiOptions || "height" in openaiOptions) {
34955
35748
  const dims = openaiOptions;
@@ -34957,52 +35750,54 @@ var OpenAIImageService = class extends AIImageService {
34957
35750
  delete dims.height;
34958
35751
  parameterWarnings.push(`Custom width/height not supported by ${modelName}, using standard sizes`);
34959
35752
  }
34960
- if (parameterWarnings.length > 0) Logger.globalInstance.debug(`[DEBUG] ⚠️ ${modelName} parameter adjustments:`, parameterWarnings);
34961
35753
  } else {
34962
35754
  openaiOptions.response_format = "url";
35755
+ if (background) parameterWarnings.push(`Background parameter ('${background}') is only supported by gpt-image models and was removed`);
35756
+ if (output_format) parameterWarnings.push(`Output format parameter ('${output_format}') is only supported by gpt-image models and was removed`);
34963
35757
  if (openaiOptions.quality && !["standard", "hd"].includes(openaiOptions.quality)) {
34964
35758
  const originalQuality = openaiOptions.quality;
34965
35759
  openaiOptions.quality = "standard";
34966
35760
  parameterWarnings.push(`Quality '${originalQuality}' is not supported by legacy models, changed to 'standard'`);
34967
35761
  }
34968
- if (openaiOptions.size && ![
34969
- "256x256",
34970
- "512x512",
34971
- "1024x1024",
34972
- "1792x1024",
34973
- "1024x1792"
34974
- ].includes(openaiOptions.size)) {
35762
+ if (openaiOptions.size && !isSupportedImageSize(openaiOptions.model, openaiOptions.size)) {
34975
35763
  const originalSize = openaiOptions.size;
34976
- openaiOptions.size = "1024x1024";
34977
- parameterWarnings.push(`Size '${originalSize}' is not supported by legacy models, changed to '1024x1024'`);
35764
+ openaiOptions.size = fallbackImageSize(openaiOptions.model);
35765
+ parameterWarnings.push(`Size '${originalSize}' is not supported by legacy models, changed to '${openaiOptions.size}'`);
34978
35766
  }
34979
35767
  }
35768
+ if (parameterWarnings.length > 0) Logger.globalInstance.debug(`[DEBUG] ⚠️ ${modelName} parameter adjustments:`, parameterWarnings);
34980
35769
  if (bflSeed !== null && bflSeed !== void 0) openaiOptions.seed = bflSeed;
34981
35770
  let images = [];
34982
35771
  let result;
34983
35772
  if (imagePrompt) {
34984
- const imageBuffer = await downloadImageAsBuffer(imagePrompt);
34985
- if (!this.imageProcessorLambdaName) throw new Error("ImageProcessor Lambda name is required for image processing. Please provide it when creating the image service.");
34986
- const pngBuffer = await invokeImageProcessor(imageBuffer, this.imageProcessorLambdaName, 4);
34987
- const imageFile = new File([pngBuffer], "image.png", { type: "image/png" });
35773
+ const imageFile = await this.toImageFile(imagePrompt, "image.png");
34988
35774
  if (isGPTImageModel(options.model)) {
34989
35775
  const editModel = options.model || ImageModels.GPT_IMAGE_2;
35776
+ const editQuality = toGptImageQuality(openaiOptions.quality);
35777
+ const editSize = isSupportedImageSize(editModel, openaiOptions.size) ? openaiOptions.size : void 0;
35778
+ const imageFiles = [imageFile, ...await this.toReferenceImageFiles(referenceImages)];
34990
35779
  this.logger.log("OpenAI image generation request (edit endpoint, image-to-image):", {
34991
35780
  model: editModel,
34992
- prompt: truncatePromptForLog(prompt)
35781
+ prompt: truncatePromptForLog(prompt),
35782
+ quality: editQuality,
35783
+ size: editSize,
35784
+ n: openaiOptions.n,
35785
+ referenceImageCount: imageFiles.length - 1,
35786
+ ...gptImageOutputOptions
34993
35787
  });
34994
35788
  result = await openai.images.edit({
34995
35789
  model: editModel,
34996
- image: [imageFile],
34997
- prompt
35790
+ image: imageFiles,
35791
+ prompt,
35792
+ ...editQuality ? { quality: editQuality } : {},
35793
+ ...editSize ? { size: editSize } : {},
35794
+ ...openaiOptions.n ? { n: openaiOptions.n } : {},
35795
+ ...gptImageOutputOptions
34998
35796
  });
34999
35797
  } else {
35798
+ if (referenceImages?.length) Logger.globalInstance.debug(`[DEBUG] Reference images are not supported by ${modelName} and were removed`);
35000
35799
  const { style, quality, model, ...opts } = openaiOptions;
35001
- const variationSize = [
35002
- "256x256",
35003
- "512x512",
35004
- "1024x1024"
35005
- ].find((s) => s === openaiOptions.size);
35800
+ const variationSize = IMAGE_SIZE_CONSTRAINTS.DALL_E_2.sizes.find((s) => s === openaiOptions.size);
35006
35801
  this.logger.log("OpenAI image generation request (variation endpoint):", {
35007
35802
  ...opts,
35008
35803
  size: variationSize
@@ -35020,7 +35815,8 @@ var OpenAIImageService = class extends AIImageService {
35020
35815
  });
35021
35816
  result = await openai.images.generate({
35022
35817
  prompt,
35023
- ...openaiOptions
35818
+ ...openaiOptions,
35819
+ ...gptImageOutputOptions
35024
35820
  });
35025
35821
  }
35026
35822
  images = this.imageResponseToUrl(result);
@@ -35049,13 +35845,14 @@ var OpenAIImageService = class extends AIImageService {
35049
35845
  }
35050
35846
  }
35051
35847
  imageResponseToUrl(response) {
35848
+ const mimeType = `image/${response?.output_format ?? "png"}`;
35052
35849
  return (response?.data ?? []).map((imageData) => {
35053
- if (imageData.b64_json) return `data:image/png;base64,${imageData.b64_json}`;
35850
+ if (imageData.b64_json) return `data:${mimeType};base64,${imageData.b64_json}`;
35054
35851
  if (imageData.url) return imageData.url;
35055
35852
  throw new Error(`Image response contains neither url nor b64_json: ${JSON.stringify(Object.keys(imageData))}`);
35056
35853
  });
35057
35854
  }
35058
- async edit(image, prompt, { mask = null, model = ImageModels.GPT_IMAGE_2, n = 1, size, response_format = "url", user }) {
35855
+ async edit(image, prompt, { mask = null, model = ImageModels.GPT_IMAGE_2, n = 1, quality, size, response_format = "url", user, background, output_format, referenceImages }) {
35059
35856
  try {
35060
35857
  const openai = new OpenAI({
35061
35858
  apiKey: this.apiKey,
@@ -35079,34 +35876,45 @@ var OpenAIImageService = class extends AIImageService {
35079
35876
  Logger.globalInstance.debug(`[DEBUG] ⚠️ Edit endpoint doesn't support ${model}, defaulting to gpt-image-2`);
35080
35877
  editModel = ImageModels.GPT_IMAGE_2;
35081
35878
  }
35082
- const forwardSize = isSupportedEditSize(editModel, size);
35879
+ const editModelCarriesReferences = isGPTImageModel(editModel);
35880
+ if (referenceImages?.length && !editModelCarriesReferences) Logger.globalInstance.debug(`[DEBUG] Reference images are not supported by ${editModel} and were removed`);
35881
+ const referenceImageFiles = editModelCarriesReferences ? await this.toReferenceImageFiles(referenceImages) : [];
35882
+ const editWarnings = [];
35883
+ const gptImageOutputOptions = resolveGptImageOutputOptions(background, output_format, editWarnings, editModel);
35884
+ if (editWarnings.length > 0) Logger.globalInstance.debug(`[DEBUG] ⚠️ ${editModel} parameter adjustments:`, editWarnings);
35885
+ const forwardSize = isSupportedImageSize(editModel, size);
35886
+ const editQuality = toGptImageQuality(quality);
35083
35887
  this.logger.log("OpenAI image edit request:", {
35084
35888
  model: editModel,
35085
35889
  prompt: truncatePromptForLog(prompt),
35086
35890
  hasMask: !!maskFile,
35087
- n,
35891
+ requestedN: n,
35088
35892
  size,
35893
+ quality: editQuality,
35894
+ referenceImageCount: referenceImageFiles.length,
35089
35895
  response_format
35090
35896
  });
35091
35897
  const response = await openai.images.edit(isGPTImageModel(editModel) ? {
35092
35898
  model: editModel,
35093
- image: [imageFile],
35899
+ image: [imageFile, ...referenceImageFiles],
35094
35900
  prompt,
35095
35901
  ...forwardSize ? { size } : {},
35096
- ...maskFile ? { mask: maskFile } : {}
35902
+ ...maskFile ? { mask: maskFile } : {},
35903
+ ...editQuality ? { quality: editQuality } : {},
35904
+ ...gptImageOutputOptions
35097
35905
  } : {
35098
35906
  model: editModel,
35099
35907
  image: imageFile,
35100
35908
  prompt,
35101
35909
  mask: maskFile,
35102
- n,
35910
+ n: 1,
35103
35911
  size,
35104
35912
  response_format,
35105
35913
  user
35106
35914
  });
35107
35915
  if (response.data && response.data.length > 0) {
35108
35916
  const result = response.data[0];
35109
- const dataUrl = result.b64_json ? `data:image/png;base64,${result.b64_json}` : result.url;
35917
+ const dataUrl = result.b64_json ? `data:image/${response.output_format ?? "png"};base64,${result.b64_json}` : result.url;
35110
35918
  if (!dataUrl) throw new Error(`Image response contains neither url nor b64_json: ${JSON.stringify(Object.keys(result))}`);
35111
35919
  return {
35112
35920
  type: "success",
@@ -35194,7 +36002,7 @@ var BFLImageService = class extends AIImageService {
35194
36002
  ...modelSpecificOptions,
35195
36003
  user
35196
36004
  };
35197
- if (model.includes("ultra")) {
36005
+ if (isBflUltraImageModel(model)) {
35198
36006
  if (aspect_ratio) {
35199
36007
  requestBody.aspect_ratio = aspect_ratio;
35200
36008
  Logger.globalInstance.debug(`[DEBUG] Using aspect_ratio: ${aspect_ratio} for Ultra model`);
@@ -35258,7 +36066,7 @@ var BFLImageService = class extends AIImageService {
35258
36066
  image,
35259
36067
  mask,
35260
36068
  guidance: guidance ?? void 0,
35261
- output_format: output_format || "jpeg"
36069
+ output_format: toNonWebpOutputFormat(output_format) || "jpeg"
35262
36070
  };
35263
36071
  const cleanedBody = this.stripNullFields(requestBody);
35264
36072
  Logger.globalInstance.debug("[DEBUG] BFL Image edit request body:", cleanedBody);
@@ -36417,6 +37225,49 @@ function isAiEditableOfficeMime(mime) {
36417
37225
  * force an unbounded read (the client UI also gates, but that is bypassable). 10 MB.
36418
37226
  */
36419
37227
  const MAX_OFFICE_EDIT_BYTES = 10485760;
37228
+ /**
37229
+ * Cap on the number of cells a workbook's declared `!ref` ranges may span IN AGGREGATE. A .xlsx
37230
+ * can declare a range far larger than its populated cells (the full grid, `A1:XFD1048576`, is
37231
+ * ~17e9 cells); extractXlsxText iterates the DECLARED range, so an unbounded `!ref` turns a tiny
37232
+ * upload into minutes of event-loop work. The cap has to be a running total rather than per sheet:
37233
+ * a worksheet part that declares a huge range and populates nothing is a couple of hundred bytes,
37234
+ * so within MAX_OFFICE_EDIT_BYTES an attacker multiplies sheets instead of enlarging one. Well
37235
+ * above any human-scale AI-editable workbook.
37236
+ */
37237
+ const MAX_XLSX_CELLS = 1e6;
37238
+ /**
37239
+ * Max decompressed size of a single OOXML zip entry we read into a string (the docx
37240
+ * `word/document.xml`). The 10 MB binary cap bounds the compressed upload, not what an entry
37241
+ * decompresses to - a small zip can inflate an entry by ~1000x (zip-bomb shape), so the inflate
37242
+ * itself is bounded as it runs (see readZipEntryBounded).
37243
+ */
37244
+ const MAX_OFFICE_ENTRY_BYTES = 33554432;
37245
+ /**
37246
+ * Decode a worksheet's declared `!ref`, rejecting it once the workbook's cells cross
37247
+ * MAX_XLSX_CELLS in total. Both the read (extractXlsxText, which iterates the range) and write
37248
+ * (applyXlsxText) paths take their ranges through here, each threading its own running total, so
37249
+ * a crafted `!ref` is bounded before it drives any work.
37250
+ */
37251
+ function decodeBoundedRange(XLSX, ref, sheetName, cellsSoFar) {
37252
+ const range = XLSX.utils.decode_range(ref);
37253
+ const total = cellsSoFar + (range.e.r - range.s.r + 1) * (range.e.c - range.s.c + 1);
37254
+ if (total > 1e6) throw new BadRequestError(`Spreadsheet declares ${total.toLocaleString()} cells through sheet "${sheetName}", over the ${MAX_XLSX_CELLS.toLocaleString()}-cell limit`);
37255
+ return {
37256
+ range,
37257
+ cellsSoFar: total
37258
+ };
37259
+ }
37260
+ /**
37261
+ * Read a named zip entry to a string, bounded at `maxBytes` of DECOMPRESSED output. The bound is
37262
+ * enforced during the inflate rather than against the entry's self-declared uncompressed size,
37263
+ * which is attacker-controlled and which jszip only validates after allocating in full - see
37264
+ * readZipEntryBounded in @bike4mind/common.
37265
+ */
37266
+ async function readOfficeEntryBounded(entry, maxBytes, label) {
37267
+ const result = await readZipEntryBounded(entry, maxBytes);
37268
+ if (!result.ok) throw new BadRequestError(`${label} is over the ${maxBytes.toLocaleString()}-byte decompressed limit`);
37269
+ return result.text;
37270
+ }
36420
37271
  async function extractEditableText(buffer, mime) {
36421
37272
  if (mime === SupportedFabFileMimeTypes.DOCX) return extractDocxText(buffer);
36422
37273
  if (mime === SupportedFabFileMimeTypes.XLSX) return extractXlsxText(buffer);
@@ -36469,7 +37320,7 @@ async function loadDocumentXml(buffer) {
36469
37320
  }
36470
37321
  const entry = zip.file("word/document.xml");
36471
37322
  if (!entry) throw new BadRequestError("File is not a valid .docx document (missing word/document.xml)");
36472
- const xml = await entry.async("string");
37323
+ const xml = await readOfficeEntryBounded(entry, MAX_OFFICE_ENTRY_BYTES, "word/document.xml");
36473
37324
  return {
36474
37325
  zip,
36475
37326
  xml
@@ -36530,12 +37381,15 @@ async function extractXlsxText(buffer) {
36530
37381
  throw new BadRequestError("File is not a valid .xlsx spreadsheet");
36531
37382
  }
36532
37383
  const blocks = [];
37384
+ let cellsSoFar = 0;
36533
37385
  for (const sheetName of workbook.SheetNames) {
36534
37386
  const sheet = workbook.Sheets[sheetName];
36535
37387
  const lines = [`${XLSX_SHEET_HEADER}${sheetName}`];
36536
37388
  const ref = sheet["!ref"];
36537
37389
  if (ref) {
36538
- const range = XLSX.utils.decode_range(ref);
37390
+ const decoded = decodeBoundedRange(XLSX, ref, sheetName, cellsSoFar);
37391
+ cellsSoFar = decoded.cellsSoFar;
37392
+ const range = decoded.range;
36539
37393
  for (let r = range.s.r; r <= range.e.r; r++) {
36540
37394
  const row = [];
36541
37395
  for (let c = range.s.c; c <= range.e.c; c++) {
@@ -36601,6 +37455,7 @@ async function applyXlsxText(originalBuffer, editedText) {
36601
37455
  } catch {
36602
37456
  throw new BadRequestError("File is not a valid .xlsx spreadsheet");
36603
37457
  }
37458
+ let cellsSoFar = 0;
36604
37459
  for (const { name, csv } of splitXlsxSheets(editedText)) {
36605
37460
  const rows = parse(csv.replace(/\s+$/, ""), {
36606
37461
  relax_column_count: true,
@@ -36635,7 +37490,7 @@ async function applyXlsxText(originalBuffer, editedText) {
36635
37490
  XLSX.utils.book_append_sheet(workbook, created, name);
36636
37491
  continue;
36637
37492
  }
36638
- const existingRef = sheet["!ref"] ? XLSX.utils.decode_range(sheet["!ref"]) : {
37493
+ let existingRef = {
36639
37494
  s: {
36640
37495
  r: 0,
36641
37496
  c: 0
@@ -36645,6 +37500,11 @@ async function applyXlsxText(originalBuffer, editedText) {
36645
37500
  c: 0
36646
37501
  }
36647
37502
  };
37503
+ if (sheet["!ref"]) {
37504
+ const decoded = decodeBoundedRange(XLSX, sheet["!ref"], name, cellsSoFar);
37505
+ cellsSoFar = decoded.cellsSoFar;
37506
+ existingRef = decoded.range;
37507
+ }
36648
37508
  let maxR = existingRef.e.r;
36649
37509
  let maxC = existingRef.e.c;
36650
37510
  for (let r = 0; r < rows.length; r++) {
@@ -36688,7 +37548,10 @@ async function applyXlsxText(originalBuffer, editedText) {
36688
37548
  */
36689
37549
  function getHttpStatus(error) {
36690
37550
  if (isAxiosError(error)) return error.response?.status;
36691
- return error.$metadata?.httpStatusCode;
37551
+ const metadata = error.$metadata;
37552
+ if (metadata?.httpStatusCode !== void 0) return metadata.httpStatusCode;
37553
+ const { status } = error;
37554
+ return typeof status === "number" ? status : void 0;
36692
37555
  }
36693
37556
  /**
36694
37557
  * Transient AWS SDK v3 exception names worth retrying/falling back on. These carry the
@@ -36984,7 +37847,8 @@ async function getLlmWithFallback(originalModel, fallbackModelId, availableModel
36984
37847
  if (!options.forceSwitch && !excludeModelIds?.has(originalModel.id)) {
36985
37848
  const originalBackend = (0, llm_exports.getLlmByModel)(apiKeyTable, {
36986
37849
  modelInfo: originalModel,
36987
- logger
37850
+ logger,
37851
+ endUserId: options.endUserId
36988
37852
  });
36989
37853
  if (originalBackend) return {
36990
37854
  model: originalModel,
@@ -37002,7 +37866,8 @@ async function getLlmWithFallback(originalModel, fallbackModelId, availableModel
37002
37866
  }
37003
37867
  const backend = (0, llm_exports.getLlmByModel)(apiKeyTable, {
37004
37868
  modelInfo: automaticFallback,
37005
- logger
37869
+ logger,
37870
+ endUserId: options.endUserId
37006
37871
  });
37007
37872
  if (backend) {
37008
37873
  logger.info(`✅ Using automatic fallback: ${automaticFallback.id}`);
@@ -37022,7 +37887,8 @@ async function getLlmWithFallback(originalModel, fallbackModelId, availableModel
37022
37887
  }
37023
37888
  const backend = (0, llm_exports.getLlmByModel)(apiKeyTable, {
37024
37889
  modelInfo: fallbackModel,
37025
- logger
37890
+ logger,
37891
+ endUserId: options.endUserId
37026
37892
  });
37027
37893
  if (backend) {
37028
37894
  logger.info(`✅ Fallback successful: Using ${fallbackModel.id}`, {
@@ -37639,11 +38505,13 @@ __reExport(/* @__PURE__ */ __exportAll({
37639
38505
  MAX_DESCRIPTION_LENGTH: () => MAX_DESCRIPTION_LENGTH,
37640
38506
  MAX_GOAL_LENGTH: () => MAX_GOAL_LENGTH,
37641
38507
  MAX_OFFICE_EDIT_BYTES: () => MAX_OFFICE_EDIT_BYTES,
38508
+ MAX_OFFICE_ENTRY_BYTES: () => MAX_OFFICE_ENTRY_BYTES,
37642
38509
  MAX_QUESTS: () => 20,
37643
38510
  MAX_SUBQUESTS_PER_QUEST: () => 20,
37644
38511
  MAX_TAGS: () => 10,
37645
38512
  MAX_TAG_LENGTH: () => 50,
37646
38513
  MAX_TITLE_LENGTH: () => 200,
38514
+ MAX_XLSX_CELLS: () => MAX_XLSX_CELLS,
37647
38515
  MIN_ATTACHED_CONTENT_EXTRACTION_SHARE: () => MIN_ATTACHED_CONTENT_EXTRACTION_SHARE,
37648
38516
  MIN_ATTACHED_CONTENT_TOKEN_ALLOCATION: () => MIN_ATTACHED_CONTENT_TOKEN_ALLOCATION,
37649
38517
  MIN_PASSAGE_TOKEN_TARGET: () => 64,
@@ -37901,24 +38769,37 @@ var SubagentOrchestrator = class {
37901
38769
  allowedTools: allowedTools || agentDef.allowedTools,
37902
38770
  deniedTools: [...agentDef.deniedTools || [], ...ALWAYS_DENIED_FOR_AGENTS]
37903
38771
  };
37904
- const { tools: allTools, agentContext: updatedContext } = await generateCliTools(this.deps.userId, this.deps.llm, effectiveModel, this.deps.permissionManager, this.deps.showPermissionPrompt, {
38772
+ const agentContext = {
37905
38773
  currentAgent: null,
37906
38774
  observationQueue: []
37907
- }, this.deps.configStore, this.deps.apiClient, void 0, this.deps.showUserQuestion, this.deps.checkpointStore, void 0, void 0, effectiveInteractionMode);
38775
+ };
38776
+ const { tools: allTools, agentContext: updatedContext } = await generateCliTools(this.deps.userId, this.deps.llm, effectiveModel, this.deps.permissionManager, this.deps.showPermissionPrompt, agentContext, this.deps.configStore, this.deps.apiClient, void 0, this.deps.showUserQuestion, this.deps.checkpointStore, this.deps.sandboxOrchestrator, this.deps.additionalDirectories, effectiveInteractionMode);
37908
38777
  const filteredTools = filterToolsByPatterns(allTools, toolFilter.allowedTools, toolFilter.deniedTools);
37909
38778
  if (options.additionalTools) {
37910
38779
  const safe = options.additionalTools.filter((t) => !ALWAYS_DENIED_FOR_AGENTS.includes(t.toolSchema.name));
37911
38780
  filteredTools.push(...safe);
37912
38781
  }
37913
38782
  if (this.deps.customCommandStore) {
37914
- const skillTool = createSkillTool({
38783
+ const [skillTool] = wrapTools([createSkillTool({
37915
38784
  customCommandStore: this.deps.customCommandStore,
37916
38785
  subagentOrchestrator: this,
37917
38786
  sessionId: parentSessionId,
37918
38787
  allowedSkills: agentDef.skills,
37919
38788
  parentDepth: depth,
37920
38789
  parentInteractionMode: effectiveInteractionMode,
37921
- parentModel: effectiveModel
38790
+ parentModel: effectiveModel,
38791
+ permissionManager: this.deps.permissionManager,
38792
+ promptFn: this.deps.showPermissionPrompt,
38793
+ allowedDirectories: this.deps.additionalDirectories
38794
+ })], {
38795
+ permissionManager: this.deps.permissionManager,
38796
+ showPermissionPrompt: this.deps.showPermissionPrompt,
38797
+ agentContext,
38798
+ configStore: this.deps.configStore,
38799
+ apiClient: this.deps.apiClient,
38800
+ sandboxOrchestrator: this.deps.sandboxOrchestrator,
38801
+ allowedDirectories: this.deps.additionalDirectories,
38802
+ interactionModeOverride: effectiveInteractionMode
37922
38803
  });
37923
38804
  filteredTools.push(skillTool);
37924
38805
  const skillsSection = buildSkillsPromptSection(this.deps.customCommandStore.getAllCommands(), agentDef.skills);
@@ -37931,7 +38812,11 @@ var SubagentOrchestrator = class {
37931
38812
  const hookWrapperContext = {
37932
38813
  sessionId: parentSessionId,
37933
38814
  agentName,
37934
- cwd: process.cwd()
38815
+ cwd: process.cwd(),
38816
+ permission: {
38817
+ permissionManager: this.deps.permissionManager,
38818
+ promptFn: this.deps.showPermissionPrompt
38819
+ }
37935
38820
  };
37936
38821
  const hookedTools = filteredTools.map((tool) => wrapToolWithHooks(tool, agentDef.hooks, hookWrapperContext));
37937
38822
  this.deps.logger.debug(`Spawning "${agentName}" agent with ${hookedTools.length} tools, thoroughness: ${effectiveThoroughness}, max iterations: ${maxIterations}`);
@@ -38006,7 +38891,7 @@ var SubagentOrchestrator = class {
38006
38891
  const stopResult = await executeHooks(agentDef.hooks.Stop, buildHookContext({
38007
38892
  ...hookWrapperContext,
38008
38893
  hookEventName: "Stop"
38009
- }));
38894
+ }), hookWrapperContext.permission);
38010
38895
  if (stopResult.decision === "block") this.deps.logger.debug(`Stop hook blocked: ${stopResult.reason}`);
38011
38896
  }
38012
38897
  this.deps.logger.debug(`Agent "${agentName}" completed in ${duration}ms, ${result.completionInfo.iterations} iterations, ${result.completionInfo.totalTokens} tokens`);
@@ -38583,4 +39468,4 @@ var AgentHistoryStore = class {
38583
39468
  }
38584
39469
  };
38585
39470
  //#endregion
38586
- export { RemoteSkillSource as $, FallbackLlmBackend as A, classifyCommandRisk as B, createWriteTodosTool as C, createResumeAgentTool as D, createCoordinateTaskTool as E, loadContextFiles as F, DEFAULT_THOROUGHNESS as G, DEFAULT_AGENT_MODEL as H, PermissionManager as I, setWebSocketToolExecutor as J, clearFeatureModuleTools as K, generateCliTools as L, ReActAgent as M, findIterationBoundary as N, createBackgroundAgentTools as O, extractCompactInstructions as P, isReadOnlyTool as Q, getProcessHooks as R, createTodoStore as S, parseAgentConfig as T, DEFAULT_MAX_ITERATIONS as U, ALWAYS_DENIED_FOR_AGENTS as V, DEFAULT_RETRY_CONFIG as W, buildSystemPrompt as X, getPlanModeFilePath as Y, buildSkillsPromptSection as Z, createDecisionStore as _, McpManager as a, searchCommands as at, createWorkItemTools as b, isTransientNetworkError as c, searchFiles as ct, createReviewGateTool as d, DEFAULT_SUBAGENT_HISTORY_TTL_MS as dt, CustomCommandStore as et, formatReviewGatesOutput as f, MAX_PASTE_SIZE as ft, createDecisionLogTool as g, formatBlockersOutput as h, AgentStore as i, processFileReferences as it, substituteArguments as j, createAgentDelegateTool as k, OllamaBackend as l, warmFileCache as lt, createBlockerTools as m, BackgroundAgentManager as n, SessionStore as nt, createSseBackend as o, mergeCommands as ot, createBlockerStore as p, USAGE_CACHE_TTL as pt, registerFeatureModuleTools as q, SubagentOrchestrator as r, hasFileReferences as rt, ServerLlmBackend as s, formatFileSize$1 as st, AgentHistoryStore as t, CheckpointStore as tt, createReviewGateStore as u, COMPACTION_SUMMARY_MARKER as ut, formatDecisionsOutput as v, createSkillTool as w, createFindDefinitionTool as x, createGetFileStructureTool as y, SHELL_LIKE_TOOL_COMMAND_FIELDS as z };
39471
+ export { CustomCommandStore as $, createAgentDelegateTool as A, ALWAYS_DENIED_FOR_AGENTS as B, createTodoStore as C, createCoordinateTaskTool as D, parseAgentConfig as E, PermissionManager as F, classifyCommandRisk as G, DEFAULT_MAX_ITERATIONS as H, generateCliTools as I, setWebSocketToolExecutor as J, clearFeatureModuleTools as K, wrapTools as L, substituteArguments as M, ReActAgent as N, createResumeAgentTool as O, findIterationBoundary as P, RemoteSkillSource as Q, getProcessHooks as R, createFindDefinitionTool as S, createSkillTool as T, DEFAULT_RETRY_CONFIG as U, DEFAULT_AGENT_MODEL as V, DEFAULT_THOROUGHNESS as W, buildSystemPrompt as X, getPlanModeFilePath as Y, buildSkillsPromptSection as Z, createDecisionLogTool as _, loadProjectContext as a, mergeCommands as at, createGetFileStructureTool as b, ServerLlmBackend as c, warmFileCache as ct, createReviewGateStore as d, MAX_PASTE_SIZE as dt, CheckpointStore as et, createReviewGateTool as f, USAGE_CACHE_TTL as ft, formatBlockersOutput as g, createBlockerTools as h, buildProjectAgentStore as i, searchCommands as it, FallbackLlmBackend as j, createBackgroundAgentTools as k, isTransientNetworkError as l, COMPACTION_SUMMARY_MARKER as lt, createBlockerStore as m, BackgroundAgentManager as n, hasFileReferences as nt, McpManager as o, formatFileSize as ot, formatReviewGatesOutput as p, registerFeatureModuleTools as q, SubagentOrchestrator as r, processFileReferences as rt, createSseBackend as s, searchFiles as st, AgentHistoryStore as t, SessionStore as tt, OllamaBackend as u, DEFAULT_SUBAGENT_HISTORY_TTL_MS as ut, createDecisionStore as v, createWriteTodosTool as w, createWorkItemTools as x, formatDecisionsOutput as y, SHELL_LIKE_TOOL_COMMAND_FIELDS as z };