@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.
- package/dist/{AgentHistoryStore-CrRb8cMt.mjs → AgentHistoryStore-kT9eNMRO.mjs} +2007 -1122
- package/dist/{ApiClient-EUTyn5yu.mjs → ApiClient-BOWpVvTq.mjs} +2 -2
- package/dist/{ConfigStore-DHNgFdOu.mjs → ConfigStore-DdHHCH2t.mjs} +1435 -248
- package/dist/{SandboxOrchestrator-BFPVpmB5.mjs → SandboxOrchestrator-BbMDgjzr.mjs} +1 -1
- package/dist/{SandboxOrchestrator-C8uleDn2.mjs → SandboxOrchestrator-CIegJrCj.mjs} +1 -1
- package/dist/{buildAgent-CJrkEG0M.mjs → buildAgent-B_kArQ_Y.mjs} +18 -9
- package/dist/commands/acpCommand.mjs +34 -13
- package/dist/commands/apiCommand.mjs +1 -1
- package/dist/commands/doctorCommand.mjs +1 -1
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +73 -33
- package/dist/commands/mcpCommand.mjs +8 -13
- package/dist/commands/pluginCommand.mjs +9 -15
- package/dist/commands/updateCommand.mjs +1 -1
- package/dist/{createFile-DPv180yF-BnWFIxey.mjs → createFile-B8bur5Rb-CVzCarEA.mjs} +2 -2
- package/dist/{deleteFile-BdjUwUQF-B3XOJmg3.mjs → deleteFile-9B3gW_Nb-DG2sovIl.mjs} +2 -2
- package/dist/{globFiles-DjfDGaUK-CNR8pMRC.mjs → globFiles-CwJ8qmYo-BR5b2KvO.mjs} +3 -2
- package/dist/{grepSearch-BaYUfIYs-n0XKoGnL.mjs → grepSearch-BgoOOwGe-DtlV8Gn-.mjs} +3 -3
- package/dist/index.mjs +212 -47
- package/dist/{package-7a45-Svr.mjs → package-Bqg2LSnH.mjs} +1 -1
- package/dist/{pathValidation-D8tjkQXE-1HwvsuYT.mjs → pathValidation-BRqf4HFX-CHwtwp3O.mjs} +7 -3
- package/dist/{serve-CavAHPdQ.mjs → serve-DO9Edl5S.mjs} +2 -2
- package/dist/types-CdIKgWWe.mjs +3 -0
- package/dist/{types-LyRNHOiS.mjs → types-F61_hxmG.mjs} +2 -0
- package/package.json +28 -28
- package/dist/types-CqscS34o.mjs +0 -3
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as
|
|
3
|
-
import { n as isPathAllowed, t as assertPathAllowed } from "./pathValidation-
|
|
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
|
|
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
|
|
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: "
|
|
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 }, {
|
|
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,
|
|
1010
|
+
return JSON.stringify(value, Logger.errorReplacer, indent);
|
|
1137
1011
|
} catch {
|
|
1138
1012
|
return "[Circular]";
|
|
1139
1013
|
}
|
|
1140
1014
|
}
|
|
1141
1015
|
/**
|
|
1142
|
-
*
|
|
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
|
|
1148
|
-
|
|
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:
|
|
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
|
|
1544
|
-
*
|
|
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
|
|
2227
|
-
*
|
|
2228
|
-
*
|
|
2229
|
-
*
|
|
2230
|
-
* guard and no Atlas index, so
|
|
2231
|
-
*
|
|
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
|
|
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(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/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
|
|
2610
|
-
|
|
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
|
-
$
|
|
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
|
-
|
|
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
|
-
|
|
3699
|
+
scope.find("td, th").each((_index, cell) => {
|
|
3414
3700
|
$(cell).after(" ");
|
|
3415
3701
|
});
|
|
3416
|
-
|
|
3702
|
+
scope.find(BLOCK_LEVEL_SELECTOR).each((_index, element) => {
|
|
3417
3703
|
$(element).after("\n");
|
|
3418
3704
|
});
|
|
3419
|
-
return
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
7268
|
-
import("./globFiles-
|
|
7269
|
-
import("./grepSearch-
|
|
7270
|
-
import("./deleteFile-
|
|
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
|
-
|
|
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
|
-
|
|
9375
|
-
|
|
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 = "
|
|
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 = {
|
|
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(
|
|
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
|
-
|
|
10545
|
-
|
|
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(
|
|
10957
|
+
allowedDirectories.push(resolvedGrantDir);
|
|
10549
10958
|
if (response.action === "allow-always") try {
|
|
10550
|
-
await configStore.addDirectory(
|
|
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(
|
|
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
|
-
|
|
10637
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
15137
|
-
|
|
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
|
|
15634
|
-
|
|
15635
|
-
|
|
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
|
|
17532
|
+
const reasonsWithinBudget = reasonsWithinOutputBudget(modelInfo);
|
|
17533
|
+
const preferred = usableTokenCount(requested) ?? (reasonsWithinBudget ? 64e3 : fallback);
|
|
17162
17534
|
const cap = usableTokenCount(modelMaxOutputTokens);
|
|
17163
|
-
|
|
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
|
-
|
|
17234
|
-
const
|
|
17624
|
+
const DEFAULT_ACQUIRE_TIMEOUT_MS = 3e5;
|
|
17625
|
+
const ANON_TENANT = "";
|
|
17235
17626
|
const _semaphoreLogger = new Logger();
|
|
17236
|
-
|
|
17237
|
-
|
|
17238
|
-
|
|
17239
|
-
|
|
17240
|
-
|
|
17241
|
-
|
|
17242
|
-
|
|
17243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
20098
|
-
if (isRecord(block) && block.type === "text")
|
|
20099
|
-
|
|
20100
|
-
|
|
20101
|
-
|
|
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:
|
|
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(
|
|
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
|
|
20965
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
23007
|
-
|
|
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 (
|
|
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
|
|
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 (!
|
|
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
|
|
23699
|
-
|
|
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 (
|
|
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
|
|
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 (!
|
|
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:
|
|
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
|
|
26193
|
-
*
|
|
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
|
-
*
|
|
27629
|
-
*
|
|
27630
|
-
*
|
|
27631
|
-
*
|
|
27632
|
-
*
|
|
27633
|
-
*
|
|
27634
|
-
*
|
|
27635
|
-
* input * CACHE_READ_MULTIPLIER
|
|
27636
|
-
*
|
|
27637
|
-
*
|
|
27638
|
-
*
|
|
27639
|
-
*
|
|
27640
|
-
|
|
27641
|
-
|
|
27642
|
-
|
|
27643
|
-
new
|
|
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-
|
|
27646
|
-
new XAIBackend("price-
|
|
27647
|
-
new KimiBackend("price-
|
|
27648
|
-
new DeepSeekBackend("price-
|
|
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
|
|
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(
|
|
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
|
-
|
|
29849
|
-
|
|
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*(
|
|
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*(
|
|
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*(
|
|
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
|
|
30672
|
-
|
|
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
|
|
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
|
|
31547
|
-
*
|
|
31548
|
-
*
|
|
31549
|
-
*
|
|
31550
|
-
* a
|
|
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
|
|
31557
|
-
|
|
31558
|
-
|
|
31559
|
-
|
|
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:
|
|
31566
|
-
supported:
|
|
32285
|
+
mimeType: "",
|
|
32286
|
+
supported: false
|
|
31567
32287
|
};
|
|
31568
32288
|
}
|
|
31569
|
-
const
|
|
31570
|
-
|
|
31571
|
-
|
|
31572
|
-
|
|
31573
|
-
|
|
31574
|
-
|
|
31575
|
-
|
|
31576
|
-
|
|
31577
|
-
|
|
31578
|
-
|
|
31579
|
-
|
|
31580
|
-
|
|
31581
|
-
|
|
31582
|
-
|
|
31583
|
-
|
|
31584
|
-
|
|
31585
|
-
|
|
31586
|
-
|
|
31587
|
-
|
|
31588
|
-
|
|
31589
|
-
|
|
31590
|
-
|
|
31591
|
-
|
|
31592
|
-
|
|
31593
|
-
|
|
31594
|
-
|
|
31595
|
-
|
|
31596
|
-
|
|
31597
|
-
|
|
31598
|
-
|
|
31599
|
-
|
|
31600
|
-
|
|
31601
|
-
|
|
31602
|
-
|
|
31603
|
-
|
|
31604
|
-
|
|
31605
|
-
|
|
31606
|
-
|
|
31607
|
-
|
|
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
|
-
*
|
|
34864
|
-
*
|
|
34865
|
-
*
|
|
34866
|
-
*
|
|
34867
|
-
|
|
34868
|
-
|
|
34869
|
-
|
|
34870
|
-
|
|
34871
|
-
|
|
34872
|
-
|
|
34873
|
-
|
|
34874
|
-
|
|
34875
|
-
|
|
34876
|
-
|
|
34877
|
-
|
|
34878
|
-
|
|
34879
|
-
|
|
34880
|
-
|
|
34881
|
-
|
|
34882
|
-
|
|
34883
|
-
|
|
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
|
|
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
|
-
|
|
34929
|
-
|
|
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
|
-
|
|
34932
|
-
|
|
34933
|
-
|
|
34934
|
-
|
|
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 =
|
|
34977
|
-
parameterWarnings.push(`Size '${originalSize}' is not supported by legacy models, changed to '
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38772
|
+
const agentContext = {
|
|
37905
38773
|
currentAgent: null,
|
|
37906
38774
|
observationQueue: []
|
|
37907
|
-
}
|
|
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 {
|
|
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 };
|