@promptev/context-engine 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +93 -5
  2. package/dist/cli.js +1761 -501
  3. package/dist/cli.js.map +1 -1
  4. package/dist/{config-Bt9bUQqU.d.ts → config-CNnASw5X.d.cts} +42 -5
  5. package/dist/{config-Bl9U789m.d.cts → config-CdlSkKgV.d.ts} +42 -5
  6. package/dist/express.cjs +925 -151
  7. package/dist/express.cjs.map +1 -1
  8. package/dist/express.d.cts +11 -4
  9. package/dist/express.d.ts +11 -4
  10. package/dist/express.js +926 -152
  11. package/dist/express.js.map +1 -1
  12. package/dist/fastify.cjs +923 -151
  13. package/dist/fastify.cjs.map +1 -1
  14. package/dist/fastify.d.cts +8 -4
  15. package/dist/fastify.d.ts +8 -4
  16. package/dist/fastify.js +924 -152
  17. package/dist/fastify.js.map +1 -1
  18. package/dist/{governance-BDkcv4qZ.d.cts → governance-D8g6Wyvb.d.cts} +8 -2
  19. package/dist/{governance-XIScatRO.d.ts → governance-XFVgtEdV.d.ts} +8 -2
  20. package/dist/graph/index.cjs +122 -43
  21. package/dist/graph/index.cjs.map +1 -1
  22. package/dist/graph/index.d.cts +5 -3
  23. package/dist/graph/index.d.ts +5 -3
  24. package/dist/graph/index.js +122 -43
  25. package/dist/graph/index.js.map +1 -1
  26. package/dist/hono.cjs +923 -151
  27. package/dist/hono.cjs.map +1 -1
  28. package/dist/hono.d.cts +8 -4
  29. package/dist/hono.d.ts +8 -4
  30. package/dist/hono.js +924 -152
  31. package/dist/hono.js.map +1 -1
  32. package/dist/index.cjs +2186 -910
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +70 -107
  35. package/dist/index.d.ts +70 -107
  36. package/dist/index.js +2186 -908
  37. package/dist/index.js.map +1 -1
  38. package/dist/mcp.cjs +100 -14
  39. package/dist/mcp.cjs.map +1 -1
  40. package/dist/mcp.d.cts +5 -0
  41. package/dist/mcp.d.ts +5 -0
  42. package/dist/mcp.js +100 -14
  43. package/dist/mcp.js.map +1 -1
  44. package/dist/migrations/sql/0003_tools.sql +2 -0
  45. package/dist/migrations/sql/0004_acl_indexes.sql +23 -2
  46. package/dist/{redaction-BmDSWJ7h.d.cts → redaction-BqD_DEUQ.d.cts} +22 -1
  47. package/dist/{redaction-BmDSWJ7h.d.ts → redaction-BqD_DEUQ.d.ts} +22 -1
  48. package/dist/redaction-presidio.d.cts +1 -1
  49. package/dist/redaction-presidio.d.ts +1 -1
  50. package/dist/{router-CrxZ2y_Z.d.ts → router-B_DTkQgU.d.ts} +17 -2
  51. package/dist/{router-OPgSoYAB.d.cts → router-Dsv3fv0R.d.cts} +17 -2
  52. package/dist/storage-DU1JRno5.d.cts +164 -0
  53. package/dist/storage-Dvt2ZxsV.d.ts +164 -0
  54. package/package.json +61 -23
  55. package/src/migrations/sql/0003_tools.sql +2 -0
  56. package/src/migrations/sql/0004_acl_indexes.sql +23 -2
  57. package/dist/embeddings-B-jZ42mk.d.cts +0 -67
  58. package/dist/embeddings-DaSdAZN3.d.ts +0 -67
package/dist/index.cjs CHANGED
@@ -2,20 +2,22 @@
2
2
 
3
3
  var franc = require('franc');
4
4
  var OpenAI = require('openai');
5
+ var crypto = require('crypto');
5
6
  var jsonrepair = require('jsonrepair');
6
7
  var cheerio = require('cheerio');
7
8
  var ExcelJS = require('exceljs');
8
9
  var JSZip = require('jszip');
9
10
  var mammoth = require('mammoth');
10
11
  var PostalMime = require('postal-mime');
12
+ var zod = require('zod');
11
13
  var unpdf = require('unpdf');
12
14
  var module$1 = require('module');
13
- var crypto = require('crypto');
14
15
  var fs = require('fs');
15
16
  var path = require('path');
16
- var zod = require('zod');
17
17
  var url = require('url');
18
18
  var jsTiktoken = require('js-tiktoken');
19
+ var dns = require('dns');
20
+ var net = require('net');
19
21
 
20
22
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
21
23
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -786,6 +788,14 @@ function emitError(hooks, exc, ctx) {
786
788
  log2.warn("onError callback raised; swallowing");
787
789
  }
788
790
  }
791
+ function emitProgress(hooks, event) {
792
+ if (!hooks?.onProgress) return;
793
+ try {
794
+ hooks.onProgress(event);
795
+ } catch {
796
+ log2.warn("onProgress callback raised; swallowing");
797
+ }
798
+ }
789
799
  function emitToolCall(hooks, event) {
790
800
  if (!hooks?.onToolCall) return;
791
801
  try {
@@ -807,6 +817,8 @@ var init_hooks = __esm({
807
817
  // src/providers/llm.ts
808
818
  var llm_exports = {};
809
819
  __export(llm_exports, {
820
+ CALL_TIMEOUT_MS: () => CALL_TIMEOUT_MS,
821
+ GEMINI_CALL_TIMEOUT_MS: () => GEMINI_CALL_TIMEOUT_MS,
810
822
  LLMClient: () => exports.LLMClient,
811
823
  TIMEOUT_MS: () => TIMEOUT_MS,
812
824
  buildLlmClient: () => buildLlmClient,
@@ -849,7 +861,7 @@ async function loadGeminiChatClient(apiKey) {
849
861
  if (!Ctor) {
850
862
  throw new exports.ExtraMissingError("gemini", specifier, "gemini llm");
851
863
  }
852
- return new Ctor({ apiKey: apiKey ?? null });
864
+ return new Ctor({ apiKey: apiKey ?? null, httpOptions: { timeout: GEMINI_CALL_TIMEOUT_MS } });
853
865
  }
854
866
  async function loadBedrockSdk() {
855
867
  const specifier = "@aws-sdk/client-bedrock-runtime";
@@ -886,11 +898,13 @@ async function callLlm(cfg2, opts) {
886
898
  await owned.aclose();
887
899
  }
888
900
  }
889
- var TIMEOUT_MS, ANTHROPIC_VERSION, ANTHROPIC_MAX_TOKENS, OPENAI_FAMILY; exports.LLMClient = void 0;
901
+ var CALL_TIMEOUT_MS, TIMEOUT_MS, GEMINI_CALL_TIMEOUT_MS, ANTHROPIC_VERSION, ANTHROPIC_MAX_TOKENS, OPENAI_FAMILY; exports.LLMClient = void 0;
890
902
  var init_llm = __esm({
891
903
  "src/providers/llm.ts"() {
892
904
  init_errors();
893
- TIMEOUT_MS = 3e4;
905
+ CALL_TIMEOUT_MS = 24e4;
906
+ TIMEOUT_MS = CALL_TIMEOUT_MS;
907
+ GEMINI_CALL_TIMEOUT_MS = CALL_TIMEOUT_MS;
894
908
  ANTHROPIC_VERSION = "2023-06-01";
895
909
  ANTHROPIC_MAX_TOKENS = 4096;
896
910
  OPENAI_FAMILY = /* @__PURE__ */ new Set(["openai", "azure_openai", "custom"]);
@@ -918,22 +932,30 @@ var init_llm = __esm({
918
932
  await this.aclose();
919
933
  }
920
934
  async call(opts) {
921
- const { system, user, jsonMode = false, images = null } = opts;
935
+ const {
936
+ system,
937
+ user,
938
+ jsonMode = false,
939
+ images = null,
940
+ maxTokens = null,
941
+ thinkingBudget = null,
942
+ temperature = null
943
+ } = opts;
922
944
  if (this.provider === "anthropic") {
923
- return this.callAnthropic(system, user, jsonMode, images);
945
+ return this.callAnthropic(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
924
946
  }
925
947
  if (OPENAI_FAMILY.has(this.provider)) {
926
- return this.callOpenAI(system, user, jsonMode, images);
948
+ return this.callOpenAI(system, user, jsonMode, images, maxTokens, temperature);
927
949
  }
928
950
  if (this.provider === "gemini") {
929
- return this.callGemini(system, user, jsonMode, images);
951
+ return this.callGemini(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
930
952
  }
931
953
  if (this.provider === "bedrock") {
932
- return this.callBedrock(system, user, jsonMode, images);
954
+ return this.callBedrock(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
933
955
  }
934
956
  throw new Error(`unknown llm provider: ${JSON.stringify(this.provider)}`);
935
957
  }
936
- async callAnthropic(system, user, jsonMode, images) {
958
+ async callAnthropic(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
937
959
  if (!this.fetchImpl) {
938
960
  throw new Error("anthropic llm client has no fetch implementation");
939
961
  }
@@ -954,26 +976,27 @@ Respond with valid JSON only.`;
954
976
  });
955
977
  }
956
978
  content.push({ type: "text", text: user });
957
- const data = await postJson(
958
- this.fetchImpl,
959
- "https://api.anthropic.com/v1/messages",
960
- {
961
- model: this.model,
962
- max_tokens: ANTHROPIC_MAX_TOKENS,
963
- system,
964
- messages: [{ role: "user", content }]
965
- },
966
- {
967
- "x-api-key": this.cfg.apiKey ?? "",
968
- "anthropic-version": ANTHROPIC_VERSION,
969
- "content-type": "application/json"
970
- }
971
- );
979
+ const body = {
980
+ model: this.model,
981
+ max_tokens: maxTokens ?? ANTHROPIC_MAX_TOKENS,
982
+ system,
983
+ messages: [{ role: "user", content }]
984
+ };
985
+ if (thinkingBudget) {
986
+ body.thinking = { type: "enabled", budget_tokens: thinkingBudget };
987
+ } else if (temperature !== null) {
988
+ body.temperature = temperature;
989
+ }
990
+ const data = await postJson(this.fetchImpl, "https://api.anthropic.com/v1/messages", body, {
991
+ "x-api-key": this.cfg.apiKey ?? "",
992
+ "anthropic-version": ANTHROPIC_VERSION,
993
+ "content-type": "application/json"
994
+ });
972
995
  const text = data.content[0].text;
973
996
  const usage = data.usage ?? {};
974
997
  return [text, { input: usage.input_tokens ?? 0, output: usage.output_tokens ?? 0 }];
975
998
  }
976
- async callOpenAI(system, user, jsonMode, images) {
999
+ async callOpenAI(system, user, jsonMode, images, maxTokens, temperature = null) {
977
1000
  if (!this.client) {
978
1001
  throw new Error("openai-family llm client has no client");
979
1002
  }
@@ -994,12 +1017,18 @@ Respond with valid JSON only.`;
994
1017
  if (jsonMode) {
995
1018
  body.response_format = { type: "json_object" };
996
1019
  }
1020
+ if (maxTokens) {
1021
+ body.max_completion_tokens = maxTokens;
1022
+ }
1023
+ if (temperature !== null) {
1024
+ body.temperature = temperature;
1025
+ }
997
1026
  const resp = await this.client.chat.completions.create(body);
998
1027
  const text = resp.choices[0]?.message?.content ?? "";
999
1028
  const usage = resp.usage;
1000
1029
  return [text, { input: usage?.prompt_tokens ?? 0, output: usage?.completion_tokens ?? 0 }];
1001
1030
  }
1002
- async callGemini(system, user, jsonMode, images) {
1031
+ async callGemini(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
1003
1032
  if (!this.genaiClient) {
1004
1033
  this.genaiClient = await loadGeminiChatClient(this.cfg.apiKey);
1005
1034
  }
@@ -1008,10 +1037,30 @@ Respond with valid JSON only.`;
1008
1037
  parts.push({ inlineData: { mimeType: "image/png", data: toBase64(img) } });
1009
1038
  }
1010
1039
  parts.push({ text: user });
1011
- const config = { systemInstruction: system };
1040
+ const config = {
1041
+ systemInstruction: system,
1042
+ // ALWAYS off, and not as a preference. Automatic function calling means
1043
+ // the SDK itself EXECUTES a callable it was handed as a tool and loops
1044
+ // on the result — up to ten round trips — before returning anything.
1045
+ // This package passes declarations only, so today nothing is executable;
1046
+ // but that depends on every future caller continuing to do the same, and
1047
+ // an application that gates tool execution behind human approval would
1048
+ // have that gate bypassed silently, by a library, with the loop already
1049
+ // run before it could object.
1050
+ automaticFunctionCalling: { disable: true }
1051
+ };
1012
1052
  if (jsonMode) {
1013
1053
  config.responseMimeType = "application/json";
1014
1054
  }
1055
+ if (maxTokens) {
1056
+ config.maxOutputTokens = maxTokens;
1057
+ }
1058
+ if (temperature !== null) {
1059
+ config.temperature = temperature;
1060
+ }
1061
+ if (thinkingBudget !== null) {
1062
+ config.thinkingConfig = { thinkingBudget };
1063
+ }
1015
1064
  const resp = await this.genaiClient.models.generateContent({
1016
1065
  model: this.model,
1017
1066
  contents: parts,
@@ -1023,7 +1072,7 @@ Respond with valid JSON only.`;
1023
1072
  const outputTokens = usageMeta?.candidatesTokenCount ?? usageMeta?.candidates_token_count ?? 0;
1024
1073
  return [text, { input: inputTokens || 0, output: outputTokens || 0 }];
1025
1074
  }
1026
- async callBedrock(system, user, jsonMode, images) {
1075
+ async callBedrock(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
1027
1076
  const { BedrockRuntimeClient, ConverseCommand } = await loadBedrockSdk();
1028
1077
  if (jsonMode) {
1029
1078
  system = `${system}
@@ -1047,7 +1096,21 @@ Respond with valid JSON only.`;
1047
1096
  new ConverseCommand({
1048
1097
  modelId: this.model,
1049
1098
  system: [{ text: system }],
1050
- messages: [{ role: "user", content }]
1099
+ messages: [{ role: "user", content }],
1100
+ ...maxTokens || temperature !== null ? {
1101
+ inferenceConfig: {
1102
+ ...maxTokens ? { maxTokens } : {},
1103
+ ...temperature !== null ? { temperature } : {}
1104
+ }
1105
+ } : {},
1106
+ // Anthropic-style passthrough — Converse forwards it to the model.
1107
+ // Zero (the vision transcription contract) sends nothing: thinking
1108
+ // is opt-in for the anthropic models bedrock hosts.
1109
+ ...thinkingBudget ? {
1110
+ additionalModelRequestFields: {
1111
+ thinking: { type: "enabled", budget_tokens: thinkingBudget }
1112
+ }
1113
+ } : {}
1051
1114
  })
1052
1115
  );
1053
1116
  const text = result.output?.message?.content?.[0]?.text ?? "";
@@ -1060,6 +1123,302 @@ Respond with valid JSON only.`;
1060
1123
  };
1061
1124
  }
1062
1125
  });
1126
+ function encryptDict(data, key) {
1127
+ const nonce = crypto.randomBytes(12);
1128
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, nonce);
1129
+ const plaintext = Buffer.from(JSON.stringify(data), "utf8");
1130
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
1131
+ const tag = cipher.getAuthTag();
1132
+ return Buffer.concat([nonce, ciphertext, tag]).toString("base64");
1133
+ }
1134
+ function decryptDict(token, key) {
1135
+ const raw = Buffer.from(token, "base64");
1136
+ const nonce = raw.subarray(0, 12);
1137
+ const tag = raw.subarray(raw.length - 16);
1138
+ const ciphertext = raw.subarray(12, raw.length - 16);
1139
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
1140
+ decipher.setAuthTag(tag);
1141
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
1142
+ return JSON.parse(plaintext.toString("utf8"));
1143
+ }
1144
+ function getSecretKey(config) {
1145
+ const secretKey = config.secretKey;
1146
+ if (!secretKey) {
1147
+ throw new Error(
1148
+ "ContextEngineConfig.secretKey (env CE_SECRET_KEY) is not configured \u2014 a base64url-encoded 32-byte AES key is required to encrypt/decrypt tool configs that hold secrets."
1149
+ );
1150
+ }
1151
+ return Buffer.from(secretKey, "base64url");
1152
+ }
1153
+ function hmacSha256Hex(key, value) {
1154
+ const k = typeof key === "string" ? Buffer.from(key) : key;
1155
+ return crypto.createHmac("sha256", k).update(value, "utf8").digest("hex");
1156
+ }
1157
+ var init_crypto = __esm({
1158
+ "src/crypto.ts"() {
1159
+ }
1160
+ });
1161
+
1162
+ // src/redaction.ts
1163
+ function spansFrom(re, text) {
1164
+ const out = [];
1165
+ re.lastIndex = 0;
1166
+ let m = re.exec(text);
1167
+ while (m !== null) {
1168
+ out.push([m.index, m.index + m[0].length]);
1169
+ if (m[0].length === 0) re.lastIndex++;
1170
+ m = re.exec(text);
1171
+ }
1172
+ return out;
1173
+ }
1174
+ function luhnOk(digits) {
1175
+ let total = 0;
1176
+ const rev = [...digits].reverse();
1177
+ for (let i = 0; i < rev.length; i++) {
1178
+ let d = Number(rev[i]);
1179
+ if (i % 2 === 1) {
1180
+ d *= 2;
1181
+ if (d > 9) d -= 9;
1182
+ }
1183
+ total += d;
1184
+ }
1185
+ return total % 10 === 0;
1186
+ }
1187
+ function detectCreditCard(text) {
1188
+ const spans = [];
1189
+ for (const [start, end] of spansFrom(CARD_RE, text)) {
1190
+ const digits = text.slice(start, end).replace(/[ -]/g, "");
1191
+ if (digits.length >= 13 && digits.length <= 19 && luhnOk(digits)) {
1192
+ spans.push([start, end]);
1193
+ }
1194
+ }
1195
+ return spans;
1196
+ }
1197
+ function looksLikeGenericSecret(token) {
1198
+ if (UUID_RE.test(token)) return false;
1199
+ let hasUpper = false;
1200
+ let hasLower = false;
1201
+ let hasDigit = false;
1202
+ for (const c of token) {
1203
+ if (c >= "A" && c <= "Z") hasUpper = true;
1204
+ else if (c >= "a" && c <= "z") hasLower = true;
1205
+ else if (c >= "0" && c <= "9") hasDigit = true;
1206
+ }
1207
+ return hasUpper && hasLower && hasDigit;
1208
+ }
1209
+ function detectApiKey(text) {
1210
+ const spans = spansFrom(API_KEY_PREFIX_RE, text);
1211
+ for (const [start, end] of spansFrom(API_KEY_GENERIC_RE, text)) {
1212
+ if (spans.some(([s, e]) => start < e && s < end)) continue;
1213
+ if (looksLikeGenericSecret(text.slice(start, end))) spans.push([start, end]);
1214
+ }
1215
+ return spans;
1216
+ }
1217
+ function detectBuiltin(name, text) {
1218
+ const detector = BUILTIN[name];
1219
+ if (!detector) throw new Error(`unknown built-in detector: ${name}`);
1220
+ if (!text) return [];
1221
+ return detector(text).sort((a, b) => a[0] - b[0]);
1222
+ }
1223
+ function ruleApplies(rule, opts) {
1224
+ if (rule.applyAt !== "both" && rule.applyAt !== opts.phase) return false;
1225
+ if (opts.phase === "ingest") return true;
1226
+ if (!rule.unless.length) return true;
1227
+ if (opts.principals === null) return false;
1228
+ const held = new Set(opts.principals);
1229
+ return !rule.unless.some((p) => held.has(p));
1230
+ }
1231
+ function spansForRule(rule, text, policy) {
1232
+ if (rule.pattern !== null) {
1233
+ const re = rule.patternRe() ?? new RegExp(rule.pattern, "g");
1234
+ return spansFrom(re, text);
1235
+ }
1236
+ if (rule.detector !== null) {
1237
+ const custom = policy.customDetectors[rule.detector];
1238
+ if (custom) return [...custom(text)];
1239
+ return detectBuiltin(rule.detector, text);
1240
+ }
1241
+ return [];
1242
+ }
1243
+ function hashToken(value, secretKey) {
1244
+ const key = Buffer.isBuffer(secretKey) ? secretKey : Buffer.from(String(secretKey ?? ""));
1245
+ return hmacSha256Hex(key, value).slice(0, HASH_TOKEN_CHARS);
1246
+ }
1247
+ function applyRedaction(text, policy, opts) {
1248
+ if (!text || policy.isEmpty()) return [text, {}];
1249
+ const principals = opts.principals ?? null;
1250
+ const collected = [];
1251
+ const fired = [];
1252
+ const failed = [];
1253
+ for (const rule of policy.rules) {
1254
+ if (!ruleApplies(rule, { phase: opts.phase, principals })) continue;
1255
+ if (rule.action === "hash" && !opts.secretKey) {
1256
+ throw new Error(
1257
+ `rule '${rule.name}': action='hash' requires a non-empty secretKey (an unkeyed HMAC is a reversible pseudonym, not a redaction)`
1258
+ );
1259
+ }
1260
+ try {
1261
+ const raw = spansForRule(rule, text, policy);
1262
+ const ruleSpans = [];
1263
+ let invalid = false;
1264
+ for (const item of raw) {
1265
+ const start = item?.[0];
1266
+ const end = item?.[1];
1267
+ if (typeof start !== "number" || typeof end !== "number") {
1268
+ invalid = true;
1269
+ continue;
1270
+ }
1271
+ if (!(start >= 0 && start < end && end <= text.length)) {
1272
+ invalid = true;
1273
+ continue;
1274
+ }
1275
+ ruleSpans.push([start, end]);
1276
+ }
1277
+ if (invalid) failed.push(rule.name);
1278
+ for (const [s, e] of ruleSpans) collected.push([s, e, rule]);
1279
+ } catch (exc) {
1280
+ failed.push(rule.name);
1281
+ if (opts.hooks) emitError(opts.hooks, exc, { stage: "redaction", rule: rule.name });
1282
+ }
1283
+ }
1284
+ if (!collected.length) {
1285
+ if (failed.length) return [text, { rules_fired: [], spans: 0, rules_failed: failed }];
1286
+ return [text, {}];
1287
+ }
1288
+ collected.sort((a, b) => a[0] - b[0] || b[1] - b[0] - (a[1] - a[0]));
1289
+ const merged = [];
1290
+ for (const [start, end, rule] of collected) {
1291
+ const last = merged[merged.length - 1];
1292
+ if (last && start < last[1]) {
1293
+ if (end > last[1]) last[1] = end;
1294
+ continue;
1295
+ }
1296
+ merged.push([start, end, rule]);
1297
+ }
1298
+ const out = [];
1299
+ let cursor = 0;
1300
+ for (const [start, end, rule] of merged) {
1301
+ out.push(text.slice(cursor, start));
1302
+ const original = text.slice(start, end);
1303
+ if (rule.action === "mask") out.push(rule.effectivePlaceholder());
1304
+ else if (rule.action === "hash") {
1305
+ out.push(`[${rule.name.toUpperCase()}:${hashToken(original, opts.secretKey)}]`);
1306
+ }
1307
+ if (!fired.includes(rule.name)) fired.push(rule.name);
1308
+ cursor = end;
1309
+ }
1310
+ out.push(text.slice(cursor));
1311
+ const note = { rules_fired: fired, spans: merged.length };
1312
+ if (failed.length) note.rules_failed = failed;
1313
+ return [out.join(""), note];
1314
+ }
1315
+ var BUILTIN_DETECTOR_NAMES; exports.RedactionRule = void 0; exports.RedactionPolicy = void 0; var EMAIL_RE, PHONE_RE, SSN_RE, CARD_RE, IBAN_RE, API_KEY_ALNUM, API_KEY_PREFIX_RE, API_KEY_GENERIC_RE, UUID_RE, BUILTIN, HASH_TOKEN_CHARS;
1316
+ var init_redaction = __esm({
1317
+ "src/redaction.ts"() {
1318
+ init_crypto();
1319
+ init_hooks();
1320
+ BUILTIN_DETECTOR_NAMES = /* @__PURE__ */ new Set(["email", "phone", "ssn", "credit_card", "iban", "api_key"]);
1321
+ exports.RedactionRule = class {
1322
+ name;
1323
+ detector;
1324
+ pattern;
1325
+ field;
1326
+ action;
1327
+ placeholder;
1328
+ applyAt;
1329
+ unless;
1330
+ compiled = null;
1331
+ constructor(init) {
1332
+ this.name = init.name;
1333
+ this.detector = init.detector ?? null;
1334
+ this.pattern = init.pattern ?? null;
1335
+ this.field = init.field ?? null;
1336
+ this.action = init.action ?? "mask";
1337
+ this.placeholder = init.placeholder ?? null;
1338
+ this.applyAt = init.applyAt ?? "output";
1339
+ this.unless = init.unless ?? [];
1340
+ this.validate();
1341
+ }
1342
+ validate() {
1343
+ if (this.field !== null) {
1344
+ throw new Error(
1345
+ `rule '${this.name}': field-targeted rules are not implemented yet; use detector or pattern instead`
1346
+ );
1347
+ }
1348
+ const targets = [this.detector, this.pattern].filter((t) => t !== null);
1349
+ if (targets.length !== 1) {
1350
+ throw new Error(`rule '${this.name}': exactly one of detector/pattern must be set`);
1351
+ }
1352
+ if (this.pattern !== null) {
1353
+ try {
1354
+ this.compiled = new RegExp(this.pattern, "g");
1355
+ } catch (exc) {
1356
+ throw new Error(`rule '${this.name}': invalid regex: ${exc}`);
1357
+ }
1358
+ }
1359
+ if (this.unless.length && this.applyAt === "ingest") {
1360
+ throw new Error(
1361
+ `rule '${this.name}': unless is output-time only and cannot be set on an applyAt='ingest' rule`
1362
+ );
1363
+ }
1364
+ }
1365
+ patternRe() {
1366
+ return this.compiled;
1367
+ }
1368
+ effectivePlaceholder() {
1369
+ return this.placeholder || `[${this.name.toUpperCase()}]`;
1370
+ }
1371
+ };
1372
+ exports.RedactionPolicy = class {
1373
+ rules;
1374
+ customDetectors;
1375
+ constructor(init = {}) {
1376
+ this.rules = (init.rules ?? []).map((r) => r instanceof exports.RedactionRule ? r : new exports.RedactionRule(r));
1377
+ this.customDetectors = init.customDetectors ?? {};
1378
+ const seen = /* @__PURE__ */ new Set();
1379
+ for (const rule of this.rules) {
1380
+ if (seen.has(rule.name)) throw new Error(`duplicate rule name: '${rule.name}'`);
1381
+ seen.add(rule.name);
1382
+ if (rule.detector !== null) {
1383
+ const known = BUILTIN_DETECTOR_NAMES.has(rule.detector) || rule.detector in this.customDetectors;
1384
+ if (!known) {
1385
+ throw new Error(
1386
+ `rule '${rule.name}': unknown detector '${rule.detector}' (not built-in and not in customDetectors)`
1387
+ );
1388
+ }
1389
+ }
1390
+ }
1391
+ }
1392
+ isEmpty() {
1393
+ return this.rules.length === 0;
1394
+ }
1395
+ };
1396
+ EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
1397
+ PHONE_RE = /\+\d[\d\s-]{7,17}\d/g;
1398
+ SSN_RE = /(?<!\d)\d{3}-\d{2}-\d{4}(?!\d)/g;
1399
+ CARD_RE = /(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/g;
1400
+ IBAN_RE = /(?<![A-Za-z0-9])[A-Z]{2}\d{2}[A-Z0-9]{10,30}(?![A-Za-z0-9])/g;
1401
+ API_KEY_ALNUM = "A-Za-z0-9_\\-+/=";
1402
+ API_KEY_PREFIX_RE = new RegExp(
1403
+ `(?<![${API_KEY_ALNUM}])(?:(?:AKIA|ASIA)[0-9A-Z]{16}|sk-[A-Za-z0-9]{20,}|gh[opsu]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9\\-]{10,}|AIza[0-9A-Za-z_\\-]{35})(?![${API_KEY_ALNUM}])`,
1404
+ "g"
1405
+ );
1406
+ API_KEY_GENERIC_RE = new RegExp(
1407
+ `(?<![${API_KEY_ALNUM}])[${API_KEY_ALNUM}]{24,}(?![${API_KEY_ALNUM}])`,
1408
+ "g"
1409
+ );
1410
+ UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
1411
+ BUILTIN = {
1412
+ email: (t) => spansFrom(EMAIL_RE, t),
1413
+ phone: (t) => spansFrom(PHONE_RE, t),
1414
+ ssn: (t) => spansFrom(SSN_RE, t),
1415
+ credit_card: detectCreditCard,
1416
+ iban: (t) => spansFrom(IBAN_RE, t),
1417
+ api_key: detectApiKey
1418
+ };
1419
+ HASH_TOKEN_CHARS = 16;
1420
+ }
1421
+ });
1063
1422
  function stripFences(text) {
1064
1423
  let t = text.trim();
1065
1424
  const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(t);
@@ -1095,17 +1454,38 @@ var init_json = __esm({
1095
1454
  "src/extraction/json.ts"() {
1096
1455
  }
1097
1456
  });
1098
- function getFileExtension(filename) {
1099
- if (!filename?.includes(".")) return "";
1100
- return filename.slice(filename.lastIndexOf(".")).toLowerCase();
1101
- }
1102
- function isNonIngestibleMedia(filename, mime) {
1103
- const m = (mime || "").toLowerCase();
1104
- if (m.startsWith("audio/") || m.startsWith("video/")) return true;
1105
- return NON_INGESTIBLE_MEDIA_EXTS.has(getFileExtension(filename));
1106
- }
1107
- function isZip(content) {
1108
- return content.length >= 4 && content.subarray(0, 4).equals(ZIP_MAGIC);
1457
+
1458
+ // src/extras.ts
1459
+ async function tryImport(specifier) {
1460
+ try {
1461
+ return await import(specifier);
1462
+ } catch {
1463
+ return null;
1464
+ }
1465
+ }
1466
+ async function requireExtra(specifier, extra, what) {
1467
+ try {
1468
+ return await import(specifier);
1469
+ } catch (_err) {
1470
+ throw new exports.ExtraMissingError(extra, specifier, what);
1471
+ }
1472
+ }
1473
+ var init_extras = __esm({
1474
+ "src/extras.ts"() {
1475
+ init_errors();
1476
+ }
1477
+ });
1478
+ function getFileExtension(filename) {
1479
+ if (!filename?.includes(".")) return "";
1480
+ return filename.slice(filename.lastIndexOf(".")).toLowerCase();
1481
+ }
1482
+ function isNonIngestibleMedia(filename, mime) {
1483
+ const m = (mime || "").toLowerCase();
1484
+ if (m.startsWith("audio/") || m.startsWith("video/")) return true;
1485
+ return NON_INGESTIBLE_MEDIA_EXTS.has(getFileExtension(filename));
1486
+ }
1487
+ function isZip(content) {
1488
+ return content.length >= 4 && content.subarray(0, 4).equals(ZIP_MAGIC);
1109
1489
  }
1110
1490
  function decodeXmlEntities(s) {
1111
1491
  return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
@@ -1485,25 +1865,182 @@ var init_files = __esm({
1485
1865
  XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
1486
1866
  }
1487
1867
  });
1488
-
1489
- // src/extras.ts
1490
- async function tryImport(specifier) {
1491
- try {
1492
- return await import(specifier);
1493
- } catch {
1494
- return null;
1868
+ function camelize(key) {
1869
+ return key.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
1870
+ }
1871
+ function loadCeEnv() {
1872
+ const root = {};
1873
+ for (const [raw, value] of Object.entries(process.env)) {
1874
+ if (!raw.startsWith("CE_") || value === void 0) continue;
1875
+ const path = raw.slice(3).split("__").map(camelize);
1876
+ let cur = root;
1877
+ for (let i = 0; i < path.length - 1; i++) {
1878
+ const k = path[i];
1879
+ const next = cur[k];
1880
+ if (typeof next !== "object" || next === null) cur[k] = {};
1881
+ cur = cur[k];
1882
+ }
1883
+ cur[path[path.length - 1]] = coerceEnv(value);
1495
1884
  }
1885
+ return root;
1496
1886
  }
1497
- async function requireExtra(specifier, extra, what) {
1498
- try {
1499
- return await import(specifier);
1500
- } catch (_err) {
1501
- throw new exports.ExtraMissingError(extra, specifier, what);
1887
+ function coerceEnv(value) {
1888
+ if (value === "true") return true;
1889
+ if (value === "false") return false;
1890
+ if (/^-?\d+$/.test(value)) return Number(value);
1891
+ if (/^-?\d+\.\d+$/.test(value)) return Number(value);
1892
+ return value;
1893
+ }
1894
+ function deepMerge(a, b) {
1895
+ const out = { ...a };
1896
+ for (const [k, v] of Object.entries(b)) {
1897
+ if (v === void 0) continue;
1898
+ const existing = out[k];
1899
+ if (v && typeof v === "object" && !Array.isArray(v) && existing && typeof existing === "object" && !Array.isArray(existing)) {
1900
+ out[k] = deepMerge(existing, v);
1901
+ } else {
1902
+ out[k] = v;
1903
+ }
1502
1904
  }
1905
+ return out;
1503
1906
  }
1504
- var init_extras = __esm({
1505
- "src/extras.ts"() {
1506
- init_errors();
1907
+ var embeddingSchema, llmSchema, graphSchema, rerankerSchema, fusionSchema, storageSchema, extractionSchema; exports.ContextEngineConfig = void 0;
1908
+ var init_config = __esm({
1909
+ "src/config.ts"() {
1910
+ init_redaction();
1911
+ embeddingSchema = zod.z.object({
1912
+ provider: zod.z.enum(["openai", "azure_openai", "gemini", "voyage", "cohere", "custom"]),
1913
+ model: zod.z.string(),
1914
+ dim: zod.z.number().int().positive().nullable().optional().default(null),
1915
+ apiKey: zod.z.string().nullable().optional().default(null),
1916
+ baseUrl: zod.z.string().nullable().optional().default(null)
1917
+ });
1918
+ llmSchema = zod.z.object({
1919
+ provider: zod.z.enum(["anthropic", "openai", "azure_openai", "gemini", "bedrock", "custom"]),
1920
+ model: zod.z.string(),
1921
+ apiKey: zod.z.string().nullable().optional().default(null),
1922
+ baseUrl: zod.z.string().nullable().optional().default(null)
1923
+ });
1924
+ graphSchema = zod.z.object({
1925
+ enabled: zod.z.boolean().default(false),
1926
+ neo4jUri: zod.z.string().nullable().optional().default(null),
1927
+ neo4jUser: zod.z.string().default("neo4j"),
1928
+ neo4jPassword: zod.z.string().nullable().optional().default(null),
1929
+ neo4jDatabase: zod.z.string().default("neo4j"),
1930
+ extractionLlm: llmSchema.nullable().optional().default(null)
1931
+ });
1932
+ rerankerSchema = zod.z.object({
1933
+ enabled: zod.z.boolean().default(false),
1934
+ provider: zod.z.enum(["cohere", "voyage", "jina", "custom"]).nullable().optional().default(null),
1935
+ model: zod.z.string().nullable().optional().default(null),
1936
+ apiKey: zod.z.string().nullable().optional().default(null),
1937
+ baseUrl: zod.z.string().nullable().optional().default(null),
1938
+ candidates: zod.z.number().int().positive().default(50)
1939
+ });
1940
+ fusionSchema = zod.z.object({
1941
+ method: zod.z.literal("rrf").default("rrf"),
1942
+ k: zod.z.number().int().positive().default(60),
1943
+ weights: zod.z.record(zod.z.string(), zod.z.number()).default({ fts: 1, trgm: 0.8, ann: 1, graph: 1 })
1944
+ });
1945
+ storageSchema = zod.z.object({
1946
+ backend: zod.z.literal("postgres").default("postgres"),
1947
+ annExactThreshold: zod.z.number().int().positive().default(5e4),
1948
+ // Connection pool, passed straight through to `pg.Pool`. Sizing is a
1949
+ // deployment decision (how many workers times how many concurrent searches
1950
+ // each runs, against what the database allows), so these are pg's own
1951
+ // defaults rather than a guess at your topology. (`pg` has no pre-ping.)
1952
+ //
1953
+ // Connections kept open in the pool.
1954
+ poolMax: zod.z.number().int().positive().default(10),
1955
+ // Milliseconds an idle connection is kept before it is closed.
1956
+ poolIdleTimeoutMs: zod.z.number().int().nonnegative().default(1e4),
1957
+ // Milliseconds a caller waits for a connection before the attempt fails.
1958
+ poolConnectionTimeoutMs: zod.z.number().int().nonnegative().default(3e4)
1959
+ });
1960
+ extractionSchema = zod.z.object({
1961
+ // Ceiling on an image's LONG EDGE in pixels, for rendered PDF pages and
1962
+ // for images sent to the vision LLM.
1963
+ maxRenderPx: zod.z.number().int().positive().default(2e3),
1964
+ // Ceiling on ONE vision batch's transcription, in output tokens — a reply
1965
+ // far past a batch's honest bound is a repetition loop. Raising it is the
1966
+ // fix if very dense pages come back truncated.
1967
+ visionMaxOutputTokens: zod.z.number().int().positive().default(16e3),
1968
+ // Pages per vision-LLM call. Smaller batches mean more calls but more
1969
+ // concurrency and less risk of hitting the output ceiling or a provider
1970
+ // deadline; larger batches the reverse. Measure on your own corpus.
1971
+ pagesPerVisionBatch: zod.z.number().int().positive().default(5),
1972
+ // Language hint for the Tesseract OCR fallback ('en', 'ur', 'mixed', any
1973
+ // Tesseract code, or 'auto' for script detection). null (the default)
1974
+ // detects the document's language from its usable text-layer pages and
1975
+ // falls back to 'auto' when there is nothing to sample.
1976
+ ocrLanguage: zod.z.string().nullable().default(null)
1977
+ });
1978
+ exports.ContextEngineConfig = class _ContextEngineConfig {
1979
+ databaseUrl;
1980
+ storage;
1981
+ defaultMode;
1982
+ embedding;
1983
+ llm;
1984
+ visionLlm;
1985
+ graph;
1986
+ reranker;
1987
+ fusion;
1988
+ extraction;
1989
+ enableCodeExecution;
1990
+ secretKey;
1991
+ /**
1992
+ * Lets http tools and probes reach loopback/private/link-local/metadata
1993
+ * addresses. Off by default — an http tool is registered by a caller, and a
1994
+ * private destination is server-side request forgery. See `tools/egress.ts`.
1995
+ */
1996
+ allowPrivateEgress;
1997
+ redaction;
1998
+ constructor(init) {
1999
+ this.databaseUrl = init.databaseUrl;
2000
+ this.storage = storageSchema.parse(init.storage ?? {});
2001
+ this.defaultMode = init.defaultMode ?? "hybrid";
2002
+ this.embedding = embeddingSchema.parse(init.embedding);
2003
+ this.llm = init.llm ? llmSchema.parse(init.llm) : null;
2004
+ this.visionLlm = init.visionLlm ? llmSchema.parse(init.visionLlm) : null;
2005
+ this.graph = graphSchema.parse(init.graph ?? {});
2006
+ this.reranker = rerankerSchema.parse(init.reranker ?? {});
2007
+ this.fusion = fusionSchema.parse(init.fusion ?? {});
2008
+ this.extraction = extractionSchema.parse(init.extraction ?? {});
2009
+ this.enableCodeExecution = init.enableCodeExecution ?? false;
2010
+ this.secretKey = init.secretKey ?? null;
2011
+ this.allowPrivateEgress = init.allowPrivateEgress ?? false;
2012
+ this.redaction = init.redaction instanceof exports.RedactionPolicy ? init.redaction : new exports.RedactionPolicy(init.redaction ?? {});
2013
+ this.validate();
2014
+ }
2015
+ validate() {
2016
+ if (this.graph.enabled && !(this.graph.neo4jUri && this.graph.neo4jPassword && this.graph.extractionLlm)) {
2017
+ throw new Error("graph enabled but neo4jUri/neo4jPassword/extractionLlm missing");
2018
+ }
2019
+ if (this.reranker.enabled && !(this.reranker.provider && this.reranker.apiKey)) {
2020
+ throw new Error("reranker enabled but provider/apiKey missing");
2021
+ }
2022
+ if (this.defaultMode === "graph" && !this.graph.enabled) {
2023
+ throw new Error("defaultMode is 'graph' but graph enabled is false");
2024
+ }
2025
+ for (const rule of this.redaction.rules) {
2026
+ if (rule.action === "hash" && !this.secretKey) {
2027
+ throw new Error(
2028
+ `redaction rule '${rule.name}': action='hash' requires ContextEngineConfig.secretKey to be set`
2029
+ );
2030
+ }
2031
+ }
2032
+ }
2033
+ static fromEnv(overrides = {}) {
2034
+ const env = loadCeEnv();
2035
+ const merged = deepMerge(env, overrides);
2036
+ if (!merged.databaseUrl || !merged.embedding) {
2037
+ throw new Error(
2038
+ "ContextEngineConfig.fromEnv requires CE_DATABASE_URL and CE_EMBEDDING__PROVIDER/MODEL (or explicit overrides)"
2039
+ );
2040
+ }
2041
+ return new _ContextEngineConfig(merged);
2042
+ }
2043
+ };
1507
2044
  }
1508
2045
  });
1509
2046
 
@@ -1932,8 +2469,26 @@ __export(pdf_exports, {
1932
2469
  embeddedTextPerPage: () => embeddedTextPerPage,
1933
2470
  extract: () => extract,
1934
2471
  extractPdf: () => extract,
2472
+ ocrLanguageHint: () => ocrLanguageHint,
1935
2473
  pageCount: () => pageCount
1936
2474
  });
2475
+ function ocrLanguageHint(texts, override) {
2476
+ if (override) return override;
2477
+ let sample = "";
2478
+ const indices = [...texts.keys()].sort((a, b) => a - b).slice(0, 3);
2479
+ for (const idx of indices) {
2480
+ sample += texts.get(idx) ?? "";
2481
+ if (sample.length >= OCR_LANG_SAMPLE_MIN) break;
2482
+ }
2483
+ if (sample.length >= OCR_LANG_SAMPLE_MIN) {
2484
+ try {
2485
+ const detected = detectLanguage(sample, 30);
2486
+ if (detected) return detected;
2487
+ } catch {
2488
+ }
2489
+ }
2490
+ return "auto";
2491
+ }
1937
2492
  function isCcControl(ch) {
1938
2493
  if (ch === "\n" || ch === "\r" || ch === " ") return false;
1939
2494
  const cp = ch.codePointAt(0);
@@ -1986,16 +2541,27 @@ async function extract(content, opts) {
1986
2541
  const { pages, structured: structuredMarkdown } = await classifyPages(content);
1987
2542
  const providerTokens = {};
1988
2543
  let visionMarkdown = false;
2544
+ let benignBlank = /* @__PURE__ */ new Set();
1989
2545
  if (pages.needsOcr.length) {
1990
2546
  if (opts.visionLlm) {
1991
2547
  try {
1992
2548
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
1993
- const [visionTexts, tokens] = await vision.extractPdfPages(content, pages.needsOcr, {
1994
- visionLlm: opts.visionLlm
2549
+ const [visionTexts, tokens, outcomes] = await vision.extractPdfPages(content, pages.needsOcr, {
2550
+ visionLlm: opts.visionLlm,
2551
+ extraction: opts.extraction
1995
2552
  });
1996
2553
  for (const [idx, text] of Object.entries(visionTexts)) {
1997
2554
  pages.texts.set(Number(idx), text);
1998
2555
  }
2556
+ benignBlank = new Set(
2557
+ Object.entries(outcomes).filter(([, o]) => o.status === "blank").map(([idx]) => Number(idx))
2558
+ );
2559
+ const failed = Object.entries(outcomes).filter(([, o]) => o.status === "failed");
2560
+ if (failed.length) {
2561
+ console.warn(
2562
+ `[pdf] vision failed on ${failed.length} of ${pages.needsOcr.length} pages: ` + failed.map(([idx, o]) => `page ${Number(idx) + 1}: ${o.error}`).join("; ")
2563
+ );
2564
+ }
1999
2565
  visionMarkdown = Object.keys(visionTexts).length > 0;
2000
2566
  for (const [key, val] of Object.entries(tokens)) {
2001
2567
  providerTokens[key] = (providerTokens[key] ?? 0) + val;
@@ -2007,9 +2573,17 @@ async function extract(content, opts) {
2007
2573
  try {
2008
2574
  const ocr = await Promise.resolve().then(() => (init_ocr(), ocr_exports));
2009
2575
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
2010
- const rendered = await vision.renderPdfPagesAsImages(content, pages.needsOcr);
2576
+ const rendered = await vision.renderPdfPagesAsImages(
2577
+ content,
2578
+ pages.needsOcr,
2579
+ 150,
2580
+ opts.extraction?.maxRenderPx ?? vision.MAX_RENDER_PX
2581
+ );
2582
+ const ocrConfig = {
2583
+ language: ocrLanguageHint(pages.texts, opts.extraction?.ocrLanguage)
2584
+ };
2011
2585
  for (const [idx, pngBytes] of rendered) {
2012
- const result = await ocr.extractImageTextOcr(pngBytes);
2586
+ const result = await ocr.extractImageTextOcr(pngBytes, ocrConfig);
2013
2587
  if (result.text.trim()) pages.texts.set(idx, result.text.trim());
2014
2588
  }
2015
2589
  } catch (exc) {
@@ -2027,31 +2601,38 @@ async function extract(content, opts) {
2027
2601
  if (t) textParts.push(`--- Page ${i + 1} ---
2028
2602
  ${t}`);
2029
2603
  }
2604
+ const unread = pages.needsOcr.filter((i) => !pages.texts.get(i) && !benignBlank.has(i));
2030
2605
  return new Extracted2({
2031
2606
  text: textParts.join("\n\n"),
2032
2607
  pages: pages.total,
2033
2608
  slides: null,
2034
2609
  mediaOnly: false,
2035
2610
  providerTokens,
2036
- isMarkdown: visionMarkdown || structuredMarkdown
2611
+ isMarkdown: visionMarkdown || structuredMarkdown,
2612
+ unreadableReason: unread.length ? opts.visionLlm ? "vision_failed" : "needs_vision" : null,
2613
+ unreadablePages: unread.length
2037
2614
  });
2038
2615
  }
2039
- var GARBAGE_SCAN_LIMIT, MIN_USABLE_TEXT_LEN;
2616
+ var GARBAGE_SCAN_LIMIT, MIN_USABLE_TEXT_LEN, OCR_LANG_SAMPLE_MIN;
2040
2617
  var init_pdf = __esm({
2041
2618
  "src/extraction/pdf.ts"() {
2042
2619
  init_errors();
2043
2620
  init_hooks();
2621
+ init_text();
2044
2622
  init_pdf_structure();
2045
2623
  GARBAGE_SCAN_LIMIT = 2e4;
2046
2624
  MIN_USABLE_TEXT_LEN = 50;
2625
+ OCR_LANG_SAMPLE_MIN = 100;
2047
2626
  }
2048
2627
  });
2049
2628
 
2050
2629
  // src/extraction/vision.ts
2051
2630
  var vision_exports = {};
2052
2631
  __export(vision_exports, {
2632
+ MAX_RENDER_PX: () => MAX_RENDER_PX,
2053
2633
  PAGES_PER_VISION_BATCH: () => PAGES_PER_VISION_BATCH,
2054
2634
  VISION_BATCH_ATTEMPTS: () => VISION_BATCH_ATTEMPTS,
2635
+ VISION_MAX_OUTPUT_TOKENS: () => VISION_MAX_OUTPUT_TOKENS,
2055
2636
  assignBatchPages: () => assignBatchPages,
2056
2637
  coercePagesResult: () => coercePagesResult,
2057
2638
  detectPdfVisionPages: () => detectPdfVisionPages,
@@ -2068,6 +2649,17 @@ function pagesPrompt(nImages) {
2068
2649
  Return JSON: {"pages": [{"page": 1, "text": "<markdown for this page>"}]}
2069
2650
  Return exactly ${nImages} page entries numbered 1 to ${nImages} in the order the images are given \u2014 IGNORE any page numbers printed on the pages.`;
2070
2651
  }
2652
+ function asText(value) {
2653
+ if (typeof value === "string") return value;
2654
+ if (value == null || typeof value === "boolean") return "";
2655
+ if (Array.isArray(value)) {
2656
+ return value.filter((v) => v != null && v !== "").map(asText).join("\n");
2657
+ }
2658
+ if (typeof value === "object") {
2659
+ return Object.values(value).filter((v) => v != null && v !== "").map(asText).join("\n");
2660
+ }
2661
+ return String(value);
2662
+ }
2071
2663
  function coercePagesResult(parsed) {
2072
2664
  let base;
2073
2665
  let rawPages;
@@ -2087,7 +2679,7 @@ function coercePagesResult(parsed) {
2087
2679
  const rec = entry;
2088
2680
  pages.push({
2089
2681
  page: typeof rec.page === "number" ? rec.page : i + 1,
2090
- text: typeof rec.text === "string" ? rec.text : ""
2682
+ text: asText(rec.text)
2091
2683
  });
2092
2684
  } else if (typeof entry === "string") {
2093
2685
  pages.push({ page: i + 1, text: entry });
@@ -2118,16 +2710,28 @@ function parsePagesResponse(rawText) {
2118
2710
  function sleep(ms) {
2119
2711
  return new Promise((r) => setTimeout(r, ms));
2120
2712
  }
2121
- async function extractTextFromImages(images, opts) {
2122
- if (!images.length) return [[], {}];
2123
- const maxConcurrent = opts.maxConcurrent ?? 8;
2124
- const batches = [];
2125
- for (let i = 0; i < images.length; i += PAGES_PER_VISION_BATCH) {
2126
- batches.push(images.slice(i, i + PAGES_PER_VISION_BATCH));
2713
+ async function capped(image, canvas, maxPx) {
2714
+ if (!canvas) return image;
2715
+ try {
2716
+ const img = await canvas.loadImage(image);
2717
+ const longEdge = Math.max(img.width, img.height);
2718
+ if (longEdge <= maxPx) return image;
2719
+ const ratio = maxPx / longEdge;
2720
+ const w = Math.max(Math.round(img.width * ratio), 1);
2721
+ const h = Math.max(Math.round(img.height * ratio), 1);
2722
+ const out = canvas.createCanvas(w, h);
2723
+ out.getContext("2d").drawImage(img, 0, 0, w, h);
2724
+ return out.toBuffer("image/png");
2725
+ } catch {
2726
+ return image;
2127
2727
  }
2128
- const results = Array.from({ length: images.length }, () => "");
2728
+ }
2729
+ async function runBatches(batchSource, opts) {
2730
+ const started = Date.now();
2731
+ const results = /* @__PURE__ */ new Map();
2732
+ const outcomes = /* @__PURE__ */ new Map();
2129
2733
  const tokensTotal = { input: 0, output: 0 };
2130
- const semaphore = new Semaphore(maxConcurrent);
2734
+ const maxConcurrent = opts.maxConcurrent ?? 8;
2131
2735
  let lock = Promise.resolve();
2132
2736
  const withLock = async (fn) => {
2133
2737
  const prev = lock;
@@ -2143,66 +2747,183 @@ async function extractTextFromImages(images, opts) {
2143
2747
  }
2144
2748
  };
2145
2749
  const client = buildLlmClient(opts.visionLlm);
2146
- const processBatch = async (batchIdx, batchImages) => {
2147
- await semaphore.acquire();
2148
- try {
2149
- const nImages = batchImages.length;
2150
- const prompt = pagesPrompt(nImages);
2151
- for (let attempt = 1; attempt <= VISION_BATCH_ATTEMPTS; attempt++) {
2152
- try {
2153
- const [rawText, usage] = await callLlm(opts.visionLlm, {
2154
- system: VISION_SYSTEM_PROMPT,
2155
- user: prompt,
2156
- jsonMode: true,
2157
- images: batchImages,
2158
- client
2750
+ let total = 0;
2751
+ let nBatches = 0;
2752
+ const queue = new BoundedQueue(maxConcurrent);
2753
+ const processBatch = async (batchNum, baseIdx, batchImages) => {
2754
+ const batchStarted = Date.now();
2755
+ let pendingLocal = batchImages.map((_, i) => i);
2756
+ for (let attempt = 1; attempt <= VISION_BATCH_ATTEMPTS; attempt++) {
2757
+ const ask = pendingLocal.map((i) => batchImages[i]);
2758
+ try {
2759
+ const [rawText, usage] = await callLlm(opts.visionLlm, {
2760
+ system: VISION_SYSTEM_PROMPT,
2761
+ user: pagesPrompt(ask.length),
2762
+ jsonMode: true,
2763
+ images: ask,
2764
+ maxTokens: opts.extCfg.visionMaxOutputTokens,
2765
+ // Transcription is not reasoning, and on a thinking model the two
2766
+ // compete for the SAME budget — see `LlmCallOpts.thinkingBudget`.
2767
+ // Zero, not small: there is nothing here to reason ABOUT, the model
2768
+ // is being asked what is on the page. `temperature: 0` for the same
2769
+ // reason — one scan must transcribe to the same text twice.
2770
+ thinkingBudget: 0,
2771
+ temperature: 0,
2772
+ client
2773
+ });
2774
+ if (!rawText?.trim()) throw new Error("empty vision response");
2775
+ const [, pages] = parsePagesResponse(rawText);
2776
+ const assigned = assignBatchPages(pages, ask.length);
2777
+ const batchSeconds = (Date.now() - batchStarted) / 1e3;
2778
+ const stillMissing = [];
2779
+ await withLock(() => {
2780
+ tokensTotal.input += usage.input ?? 0;
2781
+ tokensTotal.output += usage.output ?? 0;
2782
+ pendingLocal.forEach((localIdx, position) => {
2783
+ const pageText = assigned[position];
2784
+ if (pageText === void 0) {
2785
+ stillMissing.push(localIdx);
2786
+ return;
2787
+ }
2788
+ results.set(baseIdx + localIdx, pageText);
2789
+ outcomes.set(baseIdx + localIdx, {
2790
+ status: pageText.trim() ? "read" : "blank",
2791
+ attempts: attempt,
2792
+ error: null,
2793
+ seconds: batchSeconds
2794
+ });
2159
2795
  });
2160
- if (!rawText?.trim()) throw new Error("empty vision response");
2161
- const [, pages] = parsePagesResponse(rawText);
2162
- const assigned = assignBatchPages(pages, nImages);
2796
+ });
2797
+ pendingLocal = stillMissing;
2798
+ if (pendingLocal.length === 0) return;
2799
+ if (attempt < VISION_BATCH_ATTEMPTS) {
2800
+ console.warn(
2801
+ `[vision] batch ${batchNum} attempt ${attempt} answered ${ask.length - pendingLocal.length} of ${ask.length} pages \u2014 asking again for ${pendingLocal.length}`
2802
+ );
2803
+ continue;
2804
+ }
2805
+ console.error(
2806
+ `[vision] batch ${batchNum}: ${pendingLocal.length} page(s) never came back after ${attempt} attempts`
2807
+ );
2808
+ await withLock(() => {
2809
+ for (const localIdx of pendingLocal) {
2810
+ outcomes.set(baseIdx + localIdx, {
2811
+ status: "failed",
2812
+ attempts: attempt,
2813
+ error: "page missing from batch response",
2814
+ seconds: batchSeconds
2815
+ });
2816
+ }
2817
+ });
2818
+ return;
2819
+ } catch (exc) {
2820
+ if (attempt < VISION_BATCH_ATTEMPTS) {
2821
+ console.warn(`[vision] batch ${batchNum} attempt ${attempt} failed: ${exc} \u2014 retrying`);
2822
+ await sleep(2e3 * attempt);
2823
+ } else {
2824
+ console.error(`[vision] batch ${batchNum} failed after ${VISION_BATCH_ATTEMPTS} attempts: ${exc}`);
2825
+ const batchSeconds = (Date.now() - batchStarted) / 1e3;
2163
2826
  await withLock(() => {
2164
- for (const [localIdx, pageText] of Object.entries(assigned)) {
2165
- const globalIdx = batchIdx * PAGES_PER_VISION_BATCH + Number(localIdx);
2166
- if (globalIdx >= 0 && globalIdx < results.length) results[globalIdx] = pageText;
2827
+ for (const localIdx of pendingLocal) {
2828
+ outcomes.set(baseIdx + localIdx, {
2829
+ status: "failed",
2830
+ attempts: attempt,
2831
+ error: String(exc),
2832
+ seconds: batchSeconds
2833
+ });
2167
2834
  }
2168
- tokensTotal.input += usage.input ?? 0;
2169
- tokensTotal.output += usage.output ?? 0;
2170
2835
  });
2171
- return;
2172
- } catch (exc) {
2173
- if (attempt < VISION_BATCH_ATTEMPTS) {
2174
- console.warn(`[vision] batch ${batchIdx} attempt ${attempt} failed: ${exc} \u2014 retrying`);
2175
- await sleep(2e3 * attempt);
2176
- } else {
2177
- console.error(
2178
- `[vision] batch ${batchIdx} failed after ${VISION_BATCH_ATTEMPTS} attempts: ${exc}`
2179
- );
2180
- }
2181
2836
  }
2182
2837
  }
2183
- } finally {
2184
- semaphore.release();
2185
2838
  }
2186
2839
  };
2840
+ const worker = async () => {
2841
+ for (; ; ) {
2842
+ const item = await queue.get();
2843
+ if (item === null) return;
2844
+ await processBatch(...item);
2845
+ }
2846
+ };
2847
+ const workers = Array.from({ length: maxConcurrent }, () => worker());
2187
2848
  try {
2188
- await Promise.all(batches.map((b, i) => processBatch(i, b)));
2849
+ for await (const [baseIdx, batchImages] of batchSource) {
2850
+ if (!batchImages.length) continue;
2851
+ for (let j = 0; j < batchImages.length; j++) {
2852
+ outcomes.set(baseIdx + j, { status: "failed", attempts: 0, error: "never attempted", seconds: 0 });
2853
+ }
2854
+ total = Math.max(total, baseIdx + batchImages.length);
2855
+ await queue.put([nBatches, baseIdx, batchImages]);
2856
+ nBatches += 1;
2857
+ }
2858
+ queue.close();
2859
+ await Promise.all(workers);
2860
+ } catch (exc) {
2861
+ queue.abort();
2862
+ await Promise.allSettled(workers);
2863
+ throw exc;
2189
2864
  } finally {
2190
2865
  await client.aclose();
2191
2866
  }
2192
- return [results, tokensTotal];
2867
+ const texts = Array.from({ length: total }, (_, i) => results.get(i) ?? "");
2868
+ const outcomeList = Array.from(
2869
+ { length: total },
2870
+ (_, i) => outcomes.get(i) ?? { status: "failed", attempts: 0, error: "never attempted", seconds: 0 }
2871
+ );
2872
+ const counts = { read: 0, blank: 0, failed: 0 };
2873
+ for (const o of outcomeList) counts[o.status] += 1;
2874
+ console.info(
2875
+ `[vision] ${total} pages, ${nBatches} batches, ${((Date.now() - started) / 1e3).toFixed(1)}s, ${tokensTotal.input} in / ${tokensTotal.output} out tokens, ${counts.read} read / ${counts.blank} blank / ${counts.failed} failed`
2876
+ );
2877
+ return [texts, tokensTotal, outcomeList];
2878
+ }
2879
+ async function extractTextFromImages(images, opts) {
2880
+ if (!images.length) return [[], {}, []];
2881
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
2882
+ const canvasModule = await tryImport("@napi-rs/canvas");
2883
+ const sendable = await Promise.all(images.map((img) => capped(img, canvasModule, extCfg.maxRenderPx)));
2884
+ const batchSize = extCfg.pagesPerVisionBatch ?? PAGES_PER_VISION_BATCH;
2885
+ async function* batches() {
2886
+ for (let i = 0; i < sendable.length; i += batchSize) {
2887
+ yield [i, sendable.slice(i, i + batchSize)];
2888
+ }
2889
+ }
2890
+ return runBatches(batches(), {
2891
+ visionLlm: opts.visionLlm,
2892
+ extCfg,
2893
+ maxConcurrent: opts.maxConcurrent
2894
+ });
2193
2895
  }
2194
2896
  async function extractPdfPages(content, pageIndices, opts) {
2195
- const rendered = await renderPdfPagesAsImages(content, pageIndices);
2196
- const images = rendered.map(([, png]) => png);
2197
- const [texts, tokens] = await extractTextFromImages(images, { visionLlm: opts.visionLlm });
2897
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
2898
+ const batchSize = extCfg.pagesPerVisionBatch ?? PAGES_PER_VISION_BATCH;
2899
+ const pageOrder = [];
2900
+ async function* renderedBatches() {
2901
+ let pos = 0;
2902
+ for (let start = 0; start < pageIndices.length; start += batchSize) {
2903
+ const group = pageIndices.slice(start, start + batchSize);
2904
+ const rendered = await renderPdfPagesAsImages(content, group, 150, extCfg.maxRenderPx);
2905
+ if (!rendered.length) continue;
2906
+ pageOrder.push(...rendered.map(([idx]) => idx));
2907
+ yield [pos, rendered.map(([, png]) => png)];
2908
+ pos += rendered.length;
2909
+ }
2910
+ }
2911
+ const [texts, tokens, outcomes] = await runBatches(renderedBatches(), {
2912
+ visionLlm: opts.visionLlm,
2913
+ extCfg
2914
+ });
2198
2915
  const pageTexts = {};
2199
- for (let i = 0; i < rendered.length; i++) {
2916
+ const pageOutcomes = {};
2917
+ for (let i = 0; i < pageOrder.length; i++) {
2200
2918
  const text = texts[i];
2201
- if (text?.trim()) pageTexts[rendered[i][0]] = text;
2919
+ if (text?.trim()) pageTexts[pageOrder[i]] = text;
2920
+ const outcome = outcomes[i];
2921
+ if (outcome) pageOutcomes[pageOrder[i]] = outcome;
2202
2922
  }
2203
- return [pageTexts, tokens];
2923
+ return [pageTexts, tokens, pageOutcomes];
2204
2924
  }
2205
- async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
2925
+ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150, maxPx = MAX_RENDER_PX) {
2926
+ const started = Date.now();
2206
2927
  const scale = dpi / 72;
2207
2928
  const data = new Uint8Array(content);
2208
2929
  const pdf = await unpdf.getDocumentProxy(data);
@@ -2211,9 +2932,12 @@ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
2211
2932
  for (const idx of pageIndices) {
2212
2933
  if (idx < 0 || idx >= pdf.numPages) continue;
2213
2934
  try {
2935
+ const page = await pdf.getPage(idx + 1);
2936
+ const viewport = page.getViewport({ scale: 1 });
2937
+ const pageScale = Math.min(scale, maxPx / Math.max(viewport.width, viewport.height, 1));
2214
2938
  const buf = await unpdf.renderPageAsImage(pdf, idx + 1, {
2215
2939
  canvasImport: canvasImport ? async () => canvasImport : void 0,
2216
- scale
2940
+ scale: pageScale
2217
2941
  });
2218
2942
  const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
2219
2943
  results.push([idx, Buffer.from(bytes)]);
@@ -2221,6 +2945,7 @@ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
2221
2945
  console.warn(`[vision] failed to render PDF page ${idx}:`, exc);
2222
2946
  }
2223
2947
  }
2948
+ console.info(`[pdf] rastered ${results.length} pages in ${((Date.now() - started) / 1e3).toFixed(1)}s`);
2224
2949
  return results;
2225
2950
  }
2226
2951
  async function detectPdfVisionPages(content) {
@@ -2242,7 +2967,13 @@ async function detectPdfVisionPages(content) {
2242
2967
  return visionPages;
2243
2968
  }
2244
2969
  async function extractDocxImages(content) {
2245
- const zip = await JSZip__default.default.loadAsync(content);
2970
+ let zip;
2971
+ try {
2972
+ zip = await JSZip__default.default.loadAsync(content);
2973
+ } catch (exc) {
2974
+ console.warn(`[vision] cannot open DOCX for images: ${exc}`);
2975
+ return [];
2976
+ }
2246
2977
  const rels = zip.file("word/_rels/document.xml.rels");
2247
2978
  if (!rels) return [];
2248
2979
  const xml = await rels.async("string");
@@ -2268,7 +2999,13 @@ async function extractDocxImages(content) {
2268
2999
  return images;
2269
3000
  }
2270
3001
  async function extractPptxImages(content) {
2271
- const zip = await JSZip__default.default.loadAsync(content);
3002
+ let zip;
3003
+ try {
3004
+ zip = await JSZip__default.default.loadAsync(content);
3005
+ } catch (exc) {
3006
+ console.warn(`[vision] cannot open PPTX for images: ${exc}`);
3007
+ return [];
3008
+ }
2272
3009
  const slideNames = Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n)).sort((a, b) => {
2273
3010
  const na = Number(/slide(\d+)/.exec(a)?.[1] ?? 0);
2274
3011
  const nb = Number(/slide(\d+)/.exec(b)?.[1] ?? 0);
@@ -2301,12 +3038,22 @@ async function extractPptxImages(content) {
2301
3038
  }
2302
3039
  async function extractImage(content, opts) {
2303
3040
  const { Extracted: Extracted2 } = await Promise.resolve().then(() => (init_extraction(), extraction_exports));
3041
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
3042
+ const canvasModule = await tryImport("@napi-rs/canvas");
3043
+ const image = await capped(content, canvasModule, extCfg.maxRenderPx);
2304
3044
  const [rawText, usage] = await callLlm(opts.visionLlm, {
2305
3045
  system: VISION_SYSTEM_PROMPT,
2306
3046
  user: `${MARKDOWN_RULES}
2307
3047
 
2308
3048
  Transcribe this single image. Return the Markdown only.`,
2309
- images: [content]
3049
+ images: [image],
3050
+ maxTokens: extCfg.visionMaxOutputTokens,
3051
+ // Same contract as the batched path (see processBatch): with the output
3052
+ // cap in place, default-on thinking bills against it — an all-thinking
3053
+ // truncated-empty reply lands as vision_failed. Transcription is not
3054
+ // reasoning, and it must be deterministic.
3055
+ thinkingBudget: 0,
3056
+ temperature: 0
2310
3057
  });
2311
3058
  const text = (rawText || "").trim();
2312
3059
  return new Extracted2({
@@ -2315,37 +3062,75 @@ Transcribe this single image. Return the Markdown only.`,
2315
3062
  slides: null,
2316
3063
  mediaOnly: !text,
2317
3064
  providerTokens: usage ?? {},
2318
- isMarkdown: Boolean(text)
3065
+ isMarkdown: Boolean(text),
3066
+ // A configured reader that returns nothing (refusal, filter, truncation)
3067
+ // is the same "reader read nothing" state the PDF path labels
3068
+ // vision_failed — not a blank image with no explanation.
3069
+ unreadableReason: text ? null : "vision_failed",
3070
+ unreadablePages: text ? 0 : 1
2319
3071
  });
2320
3072
  }
2321
- var PAGES_PER_VISION_BATCH, VISION_BATCH_ATTEMPTS, VISION_SYSTEM_PROMPT, MARKDOWN_RULES, Semaphore;
3073
+ var VISION_BATCH_ATTEMPTS, EXTRACTION_DEFAULTS, MAX_RENDER_PX, VISION_MAX_OUTPUT_TOKENS, PAGES_PER_VISION_BATCH, VISION_SYSTEM_PROMPT, MARKDOWN_RULES, BoundedQueue;
2322
3074
  var init_vision = __esm({
2323
3075
  "src/extraction/vision.ts"() {
3076
+ init_config();
2324
3077
  init_extras();
2325
3078
  init_llm();
2326
3079
  init_json();
2327
3080
  init_pdf();
2328
- PAGES_PER_VISION_BATCH = 5;
2329
3081
  VISION_BATCH_ATTEMPTS = 3;
3082
+ EXTRACTION_DEFAULTS = extractionSchema.parse({});
3083
+ MAX_RENDER_PX = EXTRACTION_DEFAULTS.maxRenderPx;
3084
+ VISION_MAX_OUTPUT_TOKENS = EXTRACTION_DEFAULTS.visionMaxOutputTokens;
3085
+ PAGES_PER_VISION_BATCH = EXTRACTION_DEFAULTS.pagesPerVisionBatch;
2330
3086
  VISION_SYSTEM_PROMPT = "You are a precise document transcription engine. You transcribe page images into clean GitHub-flavored Markdown that preserves tables, headings, and reading order \u2014 verbatim, never summarizing, translating, or inventing content.";
2331
3087
  MARKDOWN_RULES = "Transcribe into clean Markdown that PRESERVES structure:\n- TABLES: reproduce as GitHub-flavored Markdown tables \u2014 one row per line, real column separators, keep EVERY cell (empty cell for blanks; put a spanning cell's value in its top-left position). NEVER flatten a table into a sentence.\n- HEADINGS/TITLES: mark with #/##/### by visual hierarchy.\n- LISTS: use - or 1. as printed.\n- FIGURES/CHARTS: add a short italic caption line, e.g. *Figure: bar chart of ...*.\n- Follow natural reading order (multi-column pages: finish the left column, then the right).\n- Transcribe VERBATIM \u2014 every word, number, label, caption. Do not summarize, translate, or invent.";
2332
- Semaphore = class {
2333
- n;
2334
- waiters = [];
2335
- constructor(max) {
2336
- this.n = max;
2337
- }
2338
- async acquire() {
2339
- if (this.n > 0) {
2340
- this.n -= 1;
2341
- return;
3088
+ BoundedQueue = class {
3089
+ constructor(maxsize) {
3090
+ this.maxsize = maxsize;
3091
+ }
3092
+ maxsize;
3093
+ items = [];
3094
+ putters = [];
3095
+ getters = [];
3096
+ closed = false;
3097
+ async put(item) {
3098
+ while (!this.closed && this.items.length >= this.maxsize) {
3099
+ await new Promise((resolve) => this.putters.push(resolve));
3100
+ }
3101
+ if (this.closed) return;
3102
+ this.items.push(item);
3103
+ this.getters.shift()?.();
3104
+ }
3105
+ /** `null` once the queue is closed AND drained — the worker's exit signal. */
3106
+ async get() {
3107
+ for (; ; ) {
3108
+ if (this.items.length) {
3109
+ const item = this.items.shift();
3110
+ this.putters.shift()?.();
3111
+ return item;
3112
+ }
3113
+ if (this.closed) return null;
3114
+ await new Promise((resolve) => this.getters.push(resolve));
2342
3115
  }
2343
- await new Promise((resolve) => this.waiters.push(resolve));
2344
3116
  }
2345
- release() {
2346
- const next = this.waiters.shift();
2347
- if (next) next();
2348
- else this.n += 1;
3117
+ close() {
3118
+ this.closed = true;
3119
+ for (const wake of this.getters.splice(0)) wake();
3120
+ for (const wake of this.putters.splice(0)) wake();
3121
+ }
3122
+ /**
3123
+ * Like `close()`, but for the error path: DISCARDS whatever is still
3124
+ * queued rather than letting it drain. A batch already in a worker's hands
3125
+ * keeps running (there is no cancelling that), but one that only ever sat
3126
+ * in the queue must never start — the source has already failed, so
3127
+ * spending a full vision-LLM retry budget on it buys nothing.
3128
+ */
3129
+ abort() {
3130
+ this.closed = true;
3131
+ this.items.length = 0;
3132
+ for (const wake of this.getters.splice(0)) wake();
3133
+ for (const wake of this.putters.splice(0)) wake();
2349
3134
  }
2350
3135
  };
2351
3136
  }
@@ -2355,7 +3140,8 @@ var init_vision = __esm({
2355
3140
  var extraction_exports = {};
2356
3141
  __export(extraction_exports, {
2357
3142
  Extracted: () => exports.Extracted,
2358
- extract: () => extract2
3143
+ extract: () => extract2,
3144
+ extractEmbeddedImagesText: () => extractEmbeddedImagesText
2359
3145
  });
2360
3146
  function isPdf(ext, mime) {
2361
3147
  return ext === ".pdf" || mime === "application/pdf";
@@ -2363,14 +3149,50 @@ function isPdf(ext, mime) {
2363
3149
  function isImage(ext, mime) {
2364
3150
  return mime.startsWith("image/") || IMAGE_EXTS.has(ext);
2365
3151
  }
2366
- async function extractEmbeddedImagesText(images, visionLlm, hooks) {
3152
+ async function officeUnreadable(text, images, visionLlm) {
3153
+ if (text.trim()) return { reason: null, unread: 0 };
3154
+ let readable = 0;
3155
+ for (const img of images) {
3156
+ if (!await embeddedImageIsNegligible(img.imageBytes)) readable += 1;
3157
+ }
3158
+ if (!readable) return { reason: null, unread: 0 };
3159
+ return { reason: visionLlm ? "vision_failed" : "needs_vision", unread: readable };
3160
+ }
3161
+ async function embeddedImageIsNegligible(data) {
3162
+ if (data.length < MIN_EMBEDDED_IMAGE_BYTES) return true;
3163
+ const canvas = await tryImport("@napi-rs/canvas");
3164
+ if (!canvas) return false;
3165
+ try {
3166
+ const img = await canvas.loadImage(data);
3167
+ return Math.max(img.width, img.height) < MIN_EMBEDDED_IMAGE_PX;
3168
+ } catch {
3169
+ return false;
3170
+ }
3171
+ }
3172
+ async function extractEmbeddedImagesText(images, visionLlm, hooks, extraction) {
2367
3173
  if (!images.length) return [[], {}];
2368
3174
  try {
2369
3175
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
2370
- return await vision.extractTextFromImages(
2371
- images.map((img) => img.imageBytes),
2372
- { visionLlm }
2373
- );
3176
+ const { createHash: createHash3 } = await import('crypto');
3177
+ const keys = [];
3178
+ const firstAt = /* @__PURE__ */ new Map();
3179
+ const send = [];
3180
+ for (const img of images) {
3181
+ const blob = img.imageBytes;
3182
+ if (await embeddedImageIsNegligible(blob)) {
3183
+ keys.push(null);
3184
+ continue;
3185
+ }
3186
+ const digest = createHash3("sha256").update(blob).digest("hex");
3187
+ if (!firstAt.has(digest)) {
3188
+ firstAt.set(digest, send.length);
3189
+ send.push(blob);
3190
+ }
3191
+ keys.push(digest);
3192
+ }
3193
+ if (!send.length) return [images.map(() => ""), {}];
3194
+ const [texts, tokens] = await vision.extractTextFromImages(send, { visionLlm, extraction });
3195
+ return [keys.map((k) => k == null ? "" : texts[firstAt.get(k)] ?? ""), tokens];
2374
3196
  } catch (exc) {
2375
3197
  emitError(hooks, exc, { stage: "embedded_image_vision" });
2376
3198
  return [[], {}];
@@ -2381,22 +3203,35 @@ async function extract2(content, filename, mime, opts) {
2381
3203
  const ext = getFileExtension(filename);
2382
3204
  const visionLlm = opts.visionLlm ?? null;
2383
3205
  const hooks = opts.hooks;
3206
+ const extraction = opts.extraction ?? null;
2384
3207
  try {
2385
3208
  if (isNonIngestibleMedia(filename, m)) {
2386
3209
  return new exports.Extracted({ text: "", mediaOnly: true });
2387
3210
  }
2388
3211
  if (isPdf(ext, m)) {
2389
3212
  const pdf = await Promise.resolve().then(() => (init_pdf(), pdf_exports));
2390
- return await pdf.extract(content, { visionLlm, hooks });
3213
+ return await pdf.extract(content, { visionLlm, hooks, extraction });
2391
3214
  }
2392
3215
  if (isImage(ext, m)) {
2393
- if (!visionLlm) return new exports.Extracted({ text: "", mediaOnly: true });
3216
+ if (!visionLlm) {
3217
+ return new exports.Extracted({
3218
+ text: "",
3219
+ mediaOnly: true,
3220
+ unreadableReason: "needs_vision",
3221
+ unreadablePages: 1
3222
+ });
3223
+ }
2394
3224
  try {
2395
3225
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
2396
- return await vision.extractImage(content, { visionLlm });
3226
+ return await vision.extractImage(content, { visionLlm, extraction });
2397
3227
  } catch (exc) {
2398
3228
  emitError(hooks, exc, { stage: "image_vision", filename });
2399
- return new exports.Extracted({ text: "", mediaOnly: true });
3229
+ return new exports.Extracted({
3230
+ text: "",
3231
+ mediaOnly: true,
3232
+ unreadableReason: "vision_failed",
3233
+ unreadablePages: 1
3234
+ });
2400
3235
  }
2401
3236
  }
2402
3237
  if (ext === ".docx" || m === DOCX_MIME) {
@@ -2404,10 +3239,11 @@ async function extract2(content, filename, mime, opts) {
2404
3239
  let providerTokens = {};
2405
3240
  let llmPictures = 0;
2406
3241
  let combined = text;
3242
+ let images = [];
2407
3243
  if (visionLlm) {
2408
3244
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
2409
- const images = await vision.extractDocxImages(content);
2410
- const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks);
3245
+ images = await vision.extractDocxImages(content);
3246
+ const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks, extraction);
2411
3247
  for (let i = 0; i < texts.length; i++) {
2412
3248
  const imgText = texts[i];
2413
3249
  if (imgText) {
@@ -2418,12 +3254,18 @@ ${imgText}`;
2418
3254
  }
2419
3255
  }
2420
3256
  providerTokens = tokens;
3257
+ } else if (!text.trim()) {
3258
+ const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
3259
+ images = await vision.extractDocxImages(content);
2421
3260
  }
3261
+ const { reason, unread } = await officeUnreadable(combined, images, visionLlm);
2422
3262
  return new exports.Extracted({
2423
3263
  text: combined,
2424
3264
  pages: await countDocxPages(content),
2425
3265
  providerTokens,
2426
- llmPictures
3266
+ llmPictures,
3267
+ unreadableReason: reason,
3268
+ unreadablePages: unread
2427
3269
  });
2428
3270
  }
2429
3271
  if (ext === ".pptx" || m === PPTX_MIME) {
@@ -2431,10 +3273,11 @@ ${imgText}`;
2431
3273
  let providerTokens = {};
2432
3274
  let llmPictures = 0;
2433
3275
  let combined = text;
3276
+ let images = [];
2434
3277
  if (visionLlm) {
2435
3278
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
2436
- const images = await vision.extractPptxImages(content);
2437
- const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks);
3279
+ images = await vision.extractPptxImages(content);
3280
+ const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks, extraction);
2438
3281
  for (let i = 0; i < texts.length; i++) {
2439
3282
  const imgText = texts[i];
2440
3283
  if (imgText) {
@@ -2446,12 +3289,18 @@ ${imgText}`;
2446
3289
  }
2447
3290
  }
2448
3291
  providerTokens = tokens;
3292
+ } else if (!text.trim()) {
3293
+ const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
3294
+ images = await vision.extractPptxImages(content);
2449
3295
  }
3296
+ const { reason, unread } = await officeUnreadable(combined, images, visionLlm);
2450
3297
  return new exports.Extracted({
2451
3298
  text: combined,
2452
3299
  slides: await countPptxSlides(content),
2453
3300
  providerTokens,
2454
- llmPictures
3301
+ llmPictures,
3302
+ unreadableReason: reason,
3303
+ unreadablePages: unread
2455
3304
  });
2456
3305
  }
2457
3306
  if (ext === ".xlsx" || m === XLSX_MIME) {
@@ -2475,9 +3324,10 @@ ${imgText}`;
2475
3324
  return new exports.Extracted({ text: content.toString("utf8") });
2476
3325
  }
2477
3326
  }
2478
- var IMAGE_EXTS; exports.Extracted = void 0;
3327
+ var IMAGE_EXTS; exports.Extracted = void 0; var MIN_EMBEDDED_IMAGE_BYTES, MIN_EMBEDDED_IMAGE_PX;
2479
3328
  var init_extraction = __esm({
2480
3329
  "src/extraction/index.ts"() {
3330
+ init_extras();
2481
3331
  init_hooks();
2482
3332
  init_files();
2483
3333
  IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp"]);
@@ -2489,6 +3339,10 @@ var init_extraction = __esm({
2489
3339
  providerTokens;
2490
3340
  llmPictures;
2491
3341
  isMarkdown;
3342
+ /** See `UnreadableReason`. `mediaOnly` is the same idea for whole files and
3343
+ * stays set alongside this, so existing callers keep working. */
3344
+ unreadableReason;
3345
+ unreadablePages;
2492
3346
  constructor(init) {
2493
3347
  this.text = init.text;
2494
3348
  this.pages = init.pages ?? null;
@@ -2497,8 +3351,12 @@ var init_extraction = __esm({
2497
3351
  this.providerTokens = init.providerTokens ?? {};
2498
3352
  this.llmPictures = init.llmPictures ?? 0;
2499
3353
  this.isMarkdown = init.isMarkdown ?? false;
3354
+ this.unreadableReason = init.unreadableReason ?? null;
3355
+ this.unreadablePages = init.unreadablePages ?? 0;
2500
3356
  }
2501
3357
  };
3358
+ MIN_EMBEDDED_IMAGE_BYTES = 3 * 1024;
3359
+ MIN_EMBEDDED_IMAGE_PX = 100;
2502
3360
  }
2503
3361
  });
2504
3362
 
@@ -2543,6 +3401,9 @@ __export(db_exports, {
2543
3401
  function stripSqlNoise(sql) {
2544
3402
  return sql.replace(SQL_NOISE_RE, (m) => " ".repeat(m.length));
2545
3403
  }
3404
+ function stripSqlNoiseBackslash(sql) {
3405
+ return sql.replace(SQL_NOISE_BACKSLASH_RE, (m) => " ".repeat(m.length));
3406
+ }
2546
3407
  function hasKeyword(sqlUpper, keyword) {
2547
3408
  return new RegExp(`\\b${keyword}\\b`).test(sqlUpper);
2548
3409
  }
@@ -2671,24 +3532,28 @@ async function executeQueryAsync(config, sql, maxRows, accessModeRaw) {
2671
3532
  `Invalid access_mode ${JSON.stringify(accessModeRaw)}; must be one of ${ALLOWED_ACCESS_MODES}`
2672
3533
  );
2673
3534
  }
2674
- const sqlUpper = stripSqlNoise(sql).trim().toUpperCase();
3535
+ const sqlVariants = [
3536
+ stripSqlNoise(sql).trim().toUpperCase(),
3537
+ stripSqlNoiseBackslash(sql).trim().toUpperCase()
3538
+ ];
3539
+ const sqlUpper = sqlVariants[0];
2675
3540
  for (const keyword of ALWAYS_BLOCKED) {
2676
- if (hasKeyword(sqlUpper, keyword)) {
3541
+ if ([...sqlVariants, sql.toUpperCase()].some((v) => hasKeyword(v, keyword))) {
2677
3542
  return { success: false, error: `${keyword} queries are not allowed` };
2678
3543
  }
2679
3544
  }
2680
3545
  if (accessMode === "readonly") {
2681
- if (!sqlUpper.startsWith("SELECT") && !sqlUpper.startsWith("WITH")) {
3546
+ if (!sqlVariants.every((v) => v.startsWith("SELECT") || v.startsWith("WITH"))) {
2682
3547
  return { success: false, error: "Only SELECT queries are allowed in read-only mode" };
2683
3548
  }
2684
3549
  for (const keyword of READONLY_BLOCKED) {
2685
- if (hasKeyword(sqlUpper, keyword)) {
3550
+ if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
2686
3551
  return { success: false, error: `${keyword} queries are not allowed in read-only mode` };
2687
3552
  }
2688
3553
  }
2689
3554
  } else if (accessMode === "readwrite") {
2690
3555
  for (const keyword of READWRITE_BLOCKED) {
2691
- if (hasKeyword(sqlUpper, keyword)) {
3556
+ if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
2692
3557
  return { success: false, error: `${keyword} queries are not allowed in read-write mode` };
2693
3558
  }
2694
3559
  }
@@ -2822,7 +3687,7 @@ async function getSchemaText(config, selectedTables) {
2822
3687
  }
2823
3688
  return rowsToText(schemaRows);
2824
3689
  }
2825
- var require2, MAX_ROWS, CONNECT_TIMEOUT, QUERY_TIMEOUT_MS, ALWAYS_BLOCKED, READONLY_BLOCKED, READWRITE_BLOCKED, ALLOWED_ACCESS_MODES, SQL_NOISE_RE;
3690
+ var require2, MAX_ROWS, CONNECT_TIMEOUT, QUERY_TIMEOUT_MS, ALWAYS_BLOCKED, READONLY_BLOCKED, READWRITE_BLOCKED, ALLOWED_ACCESS_MODES, SQL_NOISE_RE, SQL_NOISE_BACKSLASH_RE;
2826
3691
  var init_db = __esm({
2827
3692
  "src/tools/executors/db.ts"() {
2828
3693
  init_errors();
@@ -2831,10 +3696,22 @@ var init_db = __esm({
2831
3696
  CONNECT_TIMEOUT = 10;
2832
3697
  QUERY_TIMEOUT_MS = 3e4;
2833
3698
  ALWAYS_BLOCKED = ["GRANT", "REVOKE"];
2834
- READONLY_BLOCKED = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE"];
2835
- READWRITE_BLOCKED = ["DELETE", "DROP", "TRUNCATE"];
3699
+ READONLY_BLOCKED = [
3700
+ "INSERT",
3701
+ "UPDATE",
3702
+ "DELETE",
3703
+ "MERGE",
3704
+ "DROP",
3705
+ "ALTER",
3706
+ "CREATE",
3707
+ "TRUNCATE",
3708
+ "DO",
3709
+ "CALL"
3710
+ ];
3711
+ READWRITE_BLOCKED = ["DELETE", "DROP", "TRUNCATE", "DO", "CALL"];
2836
3712
  ALLOWED_ACCESS_MODES = ["readonly", "readwrite", "full"];
2837
- SQL_NOISE_RE = /'(?:[^']|'')*'|\$([A-Za-z_]\w*)?\$.*?\$\1?\$|--[^\n]*|\/\*[\s\S]*?\*\//g;
3713
+ SQL_NOISE_RE = /\b[eE]'(?:[^'\\]|\\[\s\S]|'')*'|'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
3714
+ SQL_NOISE_BACKSLASH_RE = /'(?:[^'\\]|\\[\s\S]|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
2838
3715
  }
2839
3716
  });
2840
3717
  function mulberry322(seed) {
@@ -3666,7 +4543,8 @@ var retrieval_exports = {};
3666
4543
  __export(retrieval_exports, {
3667
4544
  buildGraphRanked: () => buildGraphRanked,
3668
4545
  corpusIsAclUniform: () => corpusIsAclUniform,
3669
- shouldUseCommunitySummaries: () => shouldUseCommunitySummaries
4546
+ shouldUseCommunitySummaries: () => shouldUseCommunitySummaries,
4547
+ vectorSeedIds: () => vectorSeedIds
3670
4548
  });
3671
4549
  function vecLiteral(vector) {
3672
4550
  return `[${vector.map((x) => Number(x)).join(",")}]`;
@@ -3692,8 +4570,8 @@ async function deriveQueryEntities(pool, chunkIds, opts) {
3692
4570
  JOIN context_engine_chunks c ON c.id = ce.chunk_id
3693
4571
  WHERE ce.chunk_id = ANY($1::uuid[]) ${SCOPE2}
3694
4572
  GROUP BY e.normalized_name, e.name, e.type
3695
- ORDER BY freq DESC LIMIT $4`,
3696
- [chunkIds, opts.sourceIds, opts.principals, ENTITY_LIMIT]
4573
+ ORDER BY freq DESC LIMIT $5`,
4574
+ [chunkIds, opts.sourceIds, opts.documentIds ?? null, opts.principals, ENTITY_LIMIT]
3697
4575
  );
3698
4576
  return result.rows.map((r) => ({
3699
4577
  normalized_name: r.normalized_name,
@@ -3758,8 +4636,8 @@ async function computeCommunityScores(pool, chunkIds, vector, opts) {
3758
4636
  `SELECT ce.chunk_id::text, ce.entity_id::text
3759
4637
  FROM context_engine_chunk_entities ce
3760
4638
  JOIN context_engine_chunks c ON c.id = ce.chunk_id
3761
- WHERE ce.chunk_id = ANY($1::uuid[]) AND ce.entity_id = ANY($4::uuid[]) ${SCOPE2}`,
3762
- [chunkIds, opts.sourceIds, opts.principals, Object.keys(entityScore)]
4639
+ WHERE ce.chunk_id = ANY($1::uuid[]) AND ce.entity_id = ANY($5::uuid[]) ${SCOPE2}`,
4640
+ [chunkIds, opts.sourceIds, opts.documentIds ?? null, opts.principals, Object.keys(entityScore)]
3763
4641
  );
3764
4642
  const chunkScores = {};
3765
4643
  for (const row of result.rows) {
@@ -3768,13 +4646,29 @@ async function computeCommunityScores(pool, chunkIds, vector, opts) {
3768
4646
  return chunkScores;
3769
4647
  }
3770
4648
  async function vectorSeedIds(pool, vector, opts) {
3771
- const result = await pool.query(
3772
- `SELECT c.id::text FROM context_engine_chunks c
3773
- WHERE c.embedding IS NOT NULL ${SCOPE2}
3774
- ORDER BY c.embedding <=> CAST($1 AS vector) LIMIT $4`,
3775
- [vecLiteral(vector), opts.sourceIds, opts.principals, SEED_LIMIT]
3776
- );
3777
- return result.rows.map((r) => String(r.id));
4649
+ const binds = [opts.sourceIds, opts.documentIds ?? null, opts.principals];
4650
+ const scoped = binds.some((v) => v !== null);
4651
+ const client = await pool.connect();
4652
+ try {
4653
+ await client.query("BEGIN");
4654
+ await opts.backend?.tuneAnnScan?.(client, SEED_LIMIT, scoped);
4655
+ const result = await client.query(
4656
+ `SELECT c.id::text FROM context_engine_chunks c
4657
+ WHERE c.embedding IS NOT NULL ${SCOPE2}
4658
+ ORDER BY c.embedding <=> CAST($1 AS vector) LIMIT $5`,
4659
+ [vecLiteral(vector), ...binds, SEED_LIMIT]
4660
+ );
4661
+ await client.query("COMMIT");
4662
+ return result.rows.map((r) => String(r.id));
4663
+ } catch (err) {
4664
+ try {
4665
+ await client.query("ROLLBACK");
4666
+ } catch {
4667
+ }
4668
+ throw err;
4669
+ } finally {
4670
+ client.release();
4671
+ }
3778
4672
  }
3779
4673
  async function buildGraphRanked(query, opts) {
3780
4674
  try {
@@ -3783,11 +4677,18 @@ async function buildGraphRanked(query, opts) {
3783
4677
  const vector = vectors[0] ? [...vectors[0]] : null;
3784
4678
  if (!vector) return [];
3785
4679
  const sourceIds = opts.sourceIds ?? null;
4680
+ const documentIds = opts.documentIds ?? null;
3786
4681
  const principals = opts.principals ?? null;
3787
- const seeds = await vectorSeedIds(opts.pool, vector, { sourceIds, principals });
4682
+ const seeds = await vectorSeedIds(opts.pool, vector, {
4683
+ sourceIds,
4684
+ documentIds,
4685
+ principals,
4686
+ backend: opts.backend
4687
+ });
3788
4688
  if (!seeds.length) return [];
3789
4689
  const queryEntities = await deriveQueryEntities(opts.pool, seeds.slice(0, TOP_SEEDS_FOR_ENTITIES), {
3790
4690
  sourceIds,
4691
+ documentIds,
3791
4692
  principals
3792
4693
  });
3793
4694
  const entityNorms = queryEntities.map((e) => String(e.normalized_name));
@@ -3809,6 +4710,7 @@ async function buildGraphRanked(query, opts) {
3809
4710
  const relScores = await computeRelationshipScores(opts.pool, allIds, entityNorms);
3810
4711
  const commScores = await computeCommunityScores(opts.pool, allIds, vector, {
3811
4712
  sourceIds,
4713
+ documentIds,
3812
4714
  principals,
3813
4715
  useSummaries
3814
4716
  });
@@ -3860,7 +4762,8 @@ var init_retrieval = __esm({
3860
4762
  TOP_SEEDS_FOR_ENTITIES = 20;
3861
4763
  SCOPE2 = `
3862
4764
  AND ($2::text[] IS NULL OR c.source_id = ANY($2::text[]))
3863
- AND ($3::text[] IS NULL OR c.acl IS NULL OR c.acl && $3::text[])
4765
+ AND ($3::uuid[] IS NULL OR c.document_id = ANY($3::uuid[]))
4766
+ AND ($4::text[] IS NULL OR c.acl IS NULL OR c.acl && $4::text[])
3864
4767
  `;
3865
4768
  }
3866
4769
  });
@@ -4445,381 +5348,96 @@ function splitIntoChunks(text, maxWords = 180, overlap = 18) {
4445
5348
  for (const ln of (text || "").split(/\r\n|\n|\r/)) {
4446
5349
  const stripped = ln.trim();
4447
5350
  if (!stripped) {
4448
- pendingBlank = units.length > 0;
4449
- continue;
4450
- }
4451
- const lineSep = pendingBlank ? "\n\n" : "\n";
4452
- pendingBlank = false;
4453
- if (HEADING_OR_BULLET_RE.test(ln) || stripped.length <= 120) {
4454
- units.push([lineSep, stripped, tokenize(stripped).length]);
4455
- } else {
4456
- let first = true;
4457
- for (const sent of splitSentences(ln)) {
4458
- const s = sent.trim();
4459
- if (s) {
4460
- units.push([first ? lineSep : " ", s, tokenize(s).length]);
4461
- first = false;
4462
- }
4463
- }
4464
- }
4465
- }
4466
- const normalizedUnits = [];
4467
- for (const [uSep, uText, uTokens] of units) {
4468
- if (uTokens <= maxWords) {
4469
- normalizedUnits.push([uSep, uText, uTokens]);
4470
- continue;
4471
- }
4472
- const windowChars = maxWords * 4;
4473
- const uChars = [...uText];
4474
- let pos = 0;
4475
- let pieceSep = uSep;
4476
- while (pos < uChars.length) {
4477
- let pieceChars = uChars.slice(pos, pos + windowChars);
4478
- let piece = pieceChars.join("");
4479
- if (pos + windowChars < uChars.length) {
4480
- const ws = piece.lastIndexOf(" ");
4481
- if (ws > Math.floor(windowChars / 2)) {
4482
- piece = piece.slice(0, ws);
4483
- pieceChars = [...piece];
4484
- }
4485
- }
4486
- let pieceTokens = tokenize(piece);
4487
- if (pieceTokens.length > maxWords) {
4488
- piece = pieceChars.slice(0, maxWords).join("");
4489
- pieceTokens = tokenize(piece);
4490
- }
4491
- normalizedUnits.push([pieceSep, piece, pieceTokens.length]);
4492
- pos += codePointLength(piece);
4493
- pieceSep = "";
4494
- }
4495
- }
4496
- if (!normalizedUnits.some(([, , t]) => t)) return [];
4497
- const chunks = [];
4498
- let lastKept = null;
4499
- let cur = [];
4500
- let curTokens = 0;
4501
- const flush = () => {
4502
- const chunk = assembleUnits(cur);
4503
- if (chunk && (lastKept === null || jaccardSim(chunk, lastKept) < 0.92)) {
4504
- chunks.push(chunk);
4505
- lastKept = chunk;
4506
- }
4507
- const carried = [];
4508
- let carriedTokens = 0;
4509
- for (let i = cur.length - 1; i >= 0; i--) {
4510
- const prev = cur[i];
4511
- if (prev[2] === 0 || carriedTokens + prev[2] > overlap) break;
4512
- carried.unshift(prev);
4513
- carriedTokens += prev[2];
4514
- }
4515
- cur = carried;
4516
- curTokens = carriedTokens;
4517
- };
4518
- for (const unit of normalizedUnits) {
4519
- if (cur.length && curTokens + unit[2] > maxWords) flush();
4520
- cur.push(unit);
4521
- curTokens += unit[2];
4522
- }
4523
- if (cur.length) {
4524
- const chunk = assembleUnits(cur);
4525
- if (chunk && (lastKept === null || jaccardSim(chunk, lastKept) < 0.92)) {
4526
- chunks.push(chunk);
4527
- }
4528
- }
4529
- return chunks;
4530
- }
4531
-
4532
- // src/actions.ts
4533
- init_errors();
4534
- init_hooks();
4535
- init_llm();
4536
- function encryptDict(data, key) {
4537
- const nonce = crypto.randomBytes(12);
4538
- const cipher = crypto.createCipheriv("aes-256-gcm", key, nonce);
4539
- const plaintext = Buffer.from(JSON.stringify(data), "utf8");
4540
- const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
4541
- const tag = cipher.getAuthTag();
4542
- return Buffer.concat([nonce, ciphertext, tag]).toString("base64");
4543
- }
4544
- function decryptDict(token, key) {
4545
- const raw = Buffer.from(token, "base64");
4546
- const nonce = raw.subarray(0, 12);
4547
- const tag = raw.subarray(raw.length - 16);
4548
- const ciphertext = raw.subarray(12, raw.length - 16);
4549
- const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
4550
- decipher.setAuthTag(tag);
4551
- const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
4552
- return JSON.parse(plaintext.toString("utf8"));
4553
- }
4554
- function getSecretKey(config) {
4555
- const secretKey = config.secretKey;
4556
- if (!secretKey) {
4557
- throw new Error(
4558
- "ContextEngineConfig.secretKey (env CE_SECRET_KEY) is not configured \u2014 a base64url-encoded 32-byte AES key is required to encrypt/decrypt tool configs that hold secrets."
4559
- );
4560
- }
4561
- return Buffer.from(secretKey, "base64url");
4562
- }
4563
- function hmacSha256Hex(key, value) {
4564
- const k = typeof key === "string" ? Buffer.from(key) : key;
4565
- return crypto.createHmac("sha256", k).update(value, "utf8").digest("hex");
4566
- }
4567
-
4568
- // src/redaction.ts
4569
- init_hooks();
4570
- var BUILTIN_DETECTOR_NAMES = /* @__PURE__ */ new Set(["email", "phone", "ssn", "credit_card", "iban", "api_key"]);
4571
- var RedactionRule = class {
4572
- name;
4573
- detector;
4574
- pattern;
4575
- field;
4576
- action;
4577
- placeholder;
4578
- applyAt;
4579
- unless;
4580
- compiled = null;
4581
- constructor(init) {
4582
- this.name = init.name;
4583
- this.detector = init.detector ?? null;
4584
- this.pattern = init.pattern ?? null;
4585
- this.field = init.field ?? null;
4586
- this.action = init.action ?? "mask";
4587
- this.placeholder = init.placeholder ?? null;
4588
- this.applyAt = init.applyAt ?? "output";
4589
- this.unless = init.unless ?? [];
4590
- this.validate();
4591
- }
4592
- validate() {
4593
- if (this.field !== null) {
4594
- throw new Error(
4595
- `rule '${this.name}': field-targeted rules are not implemented yet; use detector or pattern instead`
4596
- );
4597
- }
4598
- const targets = [this.detector, this.pattern].filter((t) => t !== null);
4599
- if (targets.length !== 1) {
4600
- throw new Error(`rule '${this.name}': exactly one of detector/pattern must be set`);
4601
- }
4602
- if (this.pattern !== null) {
4603
- try {
4604
- this.compiled = new RegExp(this.pattern, "g");
4605
- } catch (exc) {
4606
- throw new Error(`rule '${this.name}': invalid regex: ${exc}`);
4607
- }
4608
- }
4609
- if (this.unless.length && this.applyAt === "ingest") {
4610
- throw new Error(
4611
- `rule '${this.name}': unless is output-time only and cannot be set on an applyAt='ingest' rule`
4612
- );
4613
- }
4614
- }
4615
- patternRe() {
4616
- return this.compiled;
4617
- }
4618
- effectivePlaceholder() {
4619
- return this.placeholder || `[${this.name.toUpperCase()}]`;
4620
- }
4621
- };
4622
- var RedactionPolicy = class {
4623
- rules;
4624
- customDetectors;
4625
- constructor(init = {}) {
4626
- this.rules = (init.rules ?? []).map((r) => r instanceof RedactionRule ? r : new RedactionRule(r));
4627
- this.customDetectors = init.customDetectors ?? {};
4628
- const seen = /* @__PURE__ */ new Set();
4629
- for (const rule of this.rules) {
4630
- if (seen.has(rule.name)) throw new Error(`duplicate rule name: '${rule.name}'`);
4631
- seen.add(rule.name);
4632
- if (rule.detector !== null) {
4633
- const known = BUILTIN_DETECTOR_NAMES.has(rule.detector) || rule.detector in this.customDetectors;
4634
- if (!known) {
4635
- throw new Error(
4636
- `rule '${rule.name}': unknown detector '${rule.detector}' (not built-in and not in customDetectors)`
4637
- );
4638
- }
4639
- }
4640
- }
4641
- }
4642
- isEmpty() {
4643
- return this.rules.length === 0;
4644
- }
4645
- };
4646
- var EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
4647
- var PHONE_RE = /\+\d[\d\s-]{7,17}\d/g;
4648
- var SSN_RE = /(?<!\d)\d{3}-\d{2}-\d{4}(?!\d)/g;
4649
- var CARD_RE = /(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/g;
4650
- var IBAN_RE = /(?<![A-Za-z0-9])[A-Z]{2}\d{2}[A-Z0-9]{10,30}(?![A-Za-z0-9])/g;
4651
- var API_KEY_ALNUM = "A-Za-z0-9_\\-+/=";
4652
- var API_KEY_PREFIX_RE = new RegExp(
4653
- `(?<![${API_KEY_ALNUM}])(?:(?:AKIA|ASIA)[0-9A-Z]{16}|sk-[A-Za-z0-9]{20,}|gh[opsu]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9\\-]{10,}|AIza[0-9A-Za-z_\\-]{35})(?![${API_KEY_ALNUM}])`,
4654
- "g"
4655
- );
4656
- var API_KEY_GENERIC_RE = new RegExp(
4657
- `(?<![${API_KEY_ALNUM}])[${API_KEY_ALNUM}]{24,}(?![${API_KEY_ALNUM}])`,
4658
- "g"
4659
- );
4660
- var UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
4661
- function spansFrom(re, text) {
4662
- const out = [];
4663
- re.lastIndex = 0;
4664
- let m = re.exec(text);
4665
- while (m !== null) {
4666
- out.push([m.index, m.index + m[0].length]);
4667
- if (m[0].length === 0) re.lastIndex++;
4668
- m = re.exec(text);
4669
- }
4670
- return out;
4671
- }
4672
- function luhnOk(digits) {
4673
- let total = 0;
4674
- const rev = [...digits].reverse();
4675
- for (let i = 0; i < rev.length; i++) {
4676
- let d = Number(rev[i]);
4677
- if (i % 2 === 1) {
4678
- d *= 2;
4679
- if (d > 9) d -= 9;
4680
- }
4681
- total += d;
4682
- }
4683
- return total % 10 === 0;
4684
- }
4685
- function detectCreditCard(text) {
4686
- const spans = [];
4687
- for (const [start, end] of spansFrom(CARD_RE, text)) {
4688
- const digits = text.slice(start, end).replace(/[ -]/g, "");
4689
- if (digits.length >= 13 && digits.length <= 19 && luhnOk(digits)) {
4690
- spans.push([start, end]);
4691
- }
4692
- }
4693
- return spans;
4694
- }
4695
- function looksLikeGenericSecret(token) {
4696
- if (UUID_RE.test(token)) return false;
4697
- let hasUpper = false;
4698
- let hasLower = false;
4699
- let hasDigit = false;
4700
- for (const c of token) {
4701
- if (c >= "A" && c <= "Z") hasUpper = true;
4702
- else if (c >= "a" && c <= "z") hasLower = true;
4703
- else if (c >= "0" && c <= "9") hasDigit = true;
4704
- }
4705
- return hasUpper && hasLower && hasDigit;
4706
- }
4707
- function detectApiKey(text) {
4708
- const spans = spansFrom(API_KEY_PREFIX_RE, text);
4709
- for (const [start, end] of spansFrom(API_KEY_GENERIC_RE, text)) {
4710
- if (spans.some(([s, e]) => start < e && s < end)) continue;
4711
- if (looksLikeGenericSecret(text.slice(start, end))) spans.push([start, end]);
4712
- }
4713
- return spans;
4714
- }
4715
- var BUILTIN = {
4716
- email: (t) => spansFrom(EMAIL_RE, t),
4717
- phone: (t) => spansFrom(PHONE_RE, t),
4718
- ssn: (t) => spansFrom(SSN_RE, t),
4719
- credit_card: detectCreditCard,
4720
- iban: (t) => spansFrom(IBAN_RE, t),
4721
- api_key: detectApiKey
4722
- };
4723
- function detectBuiltin(name, text) {
4724
- const detector = BUILTIN[name];
4725
- if (!detector) throw new Error(`unknown built-in detector: ${name}`);
4726
- if (!text) return [];
4727
- return detector(text).sort((a, b) => a[0] - b[0]);
4728
- }
4729
- function ruleApplies(rule, opts) {
4730
- if (rule.applyAt !== "both" && rule.applyAt !== opts.phase) return false;
4731
- if (opts.phase === "ingest") return true;
4732
- if (!rule.unless.length) return true;
4733
- if (opts.principals === null) return false;
4734
- const held = new Set(opts.principals);
4735
- return !rule.unless.some((p) => held.has(p));
4736
- }
4737
- function spansForRule(rule, text, policy) {
4738
- if (rule.pattern !== null) {
4739
- const re = rule.patternRe() ?? new RegExp(rule.pattern, "g");
4740
- return spansFrom(re, text);
4741
- }
4742
- if (rule.detector !== null) {
4743
- const custom = policy.customDetectors[rule.detector];
4744
- if (custom) return [...custom(text)];
4745
- return detectBuiltin(rule.detector, text);
4746
- }
4747
- return [];
4748
- }
4749
- var HASH_TOKEN_CHARS = 16;
4750
- function hashToken(value, secretKey) {
4751
- const key = Buffer.isBuffer(secretKey) ? secretKey : Buffer.from(String(secretKey ?? ""));
4752
- return hmacSha256Hex(key, value).slice(0, HASH_TOKEN_CHARS);
4753
- }
4754
- function applyRedaction(text, policy, opts) {
4755
- if (!text || policy.isEmpty()) return [text, {}];
4756
- const principals = opts.principals ?? null;
4757
- const collected = [];
4758
- const fired = [];
4759
- const failed = [];
4760
- for (const rule of policy.rules) {
4761
- if (!ruleApplies(rule, { phase: opts.phase, principals })) continue;
4762
- if (rule.action === "hash" && !opts.secretKey) {
4763
- throw new Error(
4764
- `rule '${rule.name}': action='hash' requires a non-empty secretKey (an unkeyed HMAC is a reversible pseudonym, not a redaction)`
4765
- );
5351
+ pendingBlank = units.length > 0;
5352
+ continue;
4766
5353
  }
4767
- try {
4768
- const raw = spansForRule(rule, text, policy);
4769
- const ruleSpans = [];
4770
- let invalid = false;
4771
- for (const item of raw) {
4772
- const start = item?.[0];
4773
- const end = item?.[1];
4774
- if (typeof start !== "number" || typeof end !== "number") {
4775
- invalid = true;
4776
- continue;
4777
- }
4778
- if (!(start >= 0 && start < end && end <= text.length)) {
4779
- invalid = true;
4780
- continue;
5354
+ const lineSep = pendingBlank ? "\n\n" : "\n";
5355
+ pendingBlank = false;
5356
+ if (HEADING_OR_BULLET_RE.test(ln) || stripped.length <= 120) {
5357
+ units.push([lineSep, stripped, tokenize(stripped).length]);
5358
+ } else {
5359
+ let first = true;
5360
+ for (const sent of splitSentences(ln)) {
5361
+ const s = sent.trim();
5362
+ if (s) {
5363
+ units.push([first ? lineSep : " ", s, tokenize(s).length]);
5364
+ first = false;
4781
5365
  }
4782
- ruleSpans.push([start, end]);
4783
5366
  }
4784
- if (invalid) failed.push(rule.name);
4785
- for (const [s, e] of ruleSpans) collected.push([s, e, rule]);
4786
- } catch (exc) {
4787
- failed.push(rule.name);
4788
- if (opts.hooks) emitError(opts.hooks, exc, { stage: "redaction", rule: rule.name });
4789
5367
  }
4790
5368
  }
4791
- if (!collected.length) {
4792
- if (failed.length) return [text, { rules_fired: [], spans: 0, rules_failed: failed }];
4793
- return [text, {}];
4794
- }
4795
- collected.sort((a, b) => a[0] - b[0] || b[1] - b[0] - (a[1] - a[0]));
4796
- const merged = [];
4797
- for (const [start, end, rule] of collected) {
4798
- const last = merged[merged.length - 1];
4799
- if (last && start < last[1]) {
4800
- if (end > last[1]) last[1] = end;
5369
+ const normalizedUnits = [];
5370
+ for (const [uSep, uText, uTokens] of units) {
5371
+ if (uTokens <= maxWords) {
5372
+ normalizedUnits.push([uSep, uText, uTokens]);
4801
5373
  continue;
4802
5374
  }
4803
- merged.push([start, end, rule]);
5375
+ const windowChars = maxWords * 4;
5376
+ const uChars = [...uText];
5377
+ let pos = 0;
5378
+ let pieceSep = uSep;
5379
+ while (pos < uChars.length) {
5380
+ let pieceChars = uChars.slice(pos, pos + windowChars);
5381
+ let piece = pieceChars.join("");
5382
+ if (pos + windowChars < uChars.length) {
5383
+ const ws = piece.lastIndexOf(" ");
5384
+ if (ws > Math.floor(windowChars / 2)) {
5385
+ piece = piece.slice(0, ws);
5386
+ pieceChars = [...piece];
5387
+ }
5388
+ }
5389
+ let pieceTokens = tokenize(piece);
5390
+ if (pieceTokens.length > maxWords) {
5391
+ piece = pieceChars.slice(0, maxWords).join("");
5392
+ pieceTokens = tokenize(piece);
5393
+ }
5394
+ normalizedUnits.push([pieceSep, piece, pieceTokens.length]);
5395
+ pos += codePointLength(piece);
5396
+ pieceSep = "";
5397
+ }
4804
5398
  }
4805
- const out = [];
4806
- let cursor = 0;
4807
- for (const [start, end, rule] of merged) {
4808
- out.push(text.slice(cursor, start));
4809
- const original = text.slice(start, end);
4810
- if (rule.action === "mask") out.push(rule.effectivePlaceholder());
4811
- else if (rule.action === "hash") {
4812
- out.push(`[${rule.name.toUpperCase()}:${hashToken(original, opts.secretKey)}]`);
5399
+ if (!normalizedUnits.some(([, , t]) => t)) return [];
5400
+ const chunks = [];
5401
+ let lastKept = null;
5402
+ let cur = [];
5403
+ let curTokens = 0;
5404
+ const flush = () => {
5405
+ const chunk = assembleUnits(cur);
5406
+ if (chunk && (lastKept === null || jaccardSim(chunk, lastKept) < 0.92)) {
5407
+ chunks.push(chunk);
5408
+ lastKept = chunk;
4813
5409
  }
4814
- if (!fired.includes(rule.name)) fired.push(rule.name);
4815
- cursor = end;
5410
+ const carried = [];
5411
+ let carriedTokens = 0;
5412
+ for (let i = cur.length - 1; i >= 0; i--) {
5413
+ const prev = cur[i];
5414
+ if (prev[2] === 0 || carriedTokens + prev[2] > overlap) break;
5415
+ carried.unshift(prev);
5416
+ carriedTokens += prev[2];
5417
+ }
5418
+ cur = carried;
5419
+ curTokens = carriedTokens;
5420
+ };
5421
+ for (const unit of normalizedUnits) {
5422
+ if (cur.length && curTokens + unit[2] > maxWords) flush();
5423
+ cur.push(unit);
5424
+ curTokens += unit[2];
4816
5425
  }
4817
- out.push(text.slice(cursor));
4818
- const note = { rules_fired: fired, spans: merged.length };
4819
- if (failed.length) note.rules_failed = failed;
4820
- return [out.join(""), note];
5426
+ if (cur.length) {
5427
+ const chunk = assembleUnits(cur);
5428
+ if (chunk && (lastKept === null || jaccardSim(chunk, lastKept) < 0.92)) {
5429
+ chunks.push(chunk);
5430
+ }
5431
+ }
5432
+ return chunks;
4821
5433
  }
4822
5434
 
5435
+ // src/actions.ts
5436
+ init_errors();
5437
+ init_hooks();
5438
+ init_llm();
5439
+ init_redaction();
5440
+
4823
5441
  // src/sandbox.ts
4824
5442
  init_errors();
4825
5443
  var DEFAULT_TIMEOUT = 30;
@@ -5358,6 +5976,39 @@ async function nearestField(pool, vector, sourceIds, docType) {
5358
5976
  if (similarity < FIELD_RESOLUTION_SIMILARITY_FLOOR) return null;
5359
5977
  return String(row.key_norm);
5360
5978
  }
5979
+
5980
+ // src/sentinels.ts
5981
+ var UNSET = /* @__PURE__ */ Symbol.for("context_engine.UNSET");
5982
+ var TRUSTED = /* @__PURE__ */ Symbol.for("context_engine.TRUSTED");
5983
+ var warnedMethods = /* @__PURE__ */ new Set();
5984
+ function resolvePrincipals(value, method) {
5985
+ if (value === TRUSTED) return null;
5986
+ if (value === void 0 || value === null) {
5987
+ if (!warnedMethods.has(method)) {
5988
+ warnedMethods.add(method);
5989
+ process.emitWarning(
5990
+ `${method}(principals=${value === null ? "null" : "undefined"}) means TRUSTED CALLER \u2014 access control is disabled and every document is returned. If that is what you want, pass principals=TRUSTED (from @promptev/context-engine) to say so explicitly. If you meant 'no authenticated user', pass principals=[] instead. Passing null/omitting will raise in 1.0.`,
5991
+ { type: "DeprecationWarning", code: "CE_PRINCIPALS_NULL" }
5992
+ );
5993
+ }
5994
+ return null;
5995
+ }
5996
+ if (!Array.isArray(value)) {
5997
+ throw new TypeError(
5998
+ `${method}(principals=...) must be an array of principal strings, [] for an anonymous caller, or TRUSTED \u2014 got ${typeof value}. A non-array value must never silently disable ACL filtering.`
5999
+ );
6000
+ }
6001
+ return value;
6002
+ }
6003
+
6004
+ // src/tools/acl.ts
6005
+ function aclVisible(acl, principals) {
6006
+ if (principals === null || principals === TRUSTED) return true;
6007
+ if (acl == null) return true;
6008
+ if (!acl.length) return false;
6009
+ const held = new Set(principals ?? []);
6010
+ return acl.some((p) => held.has(p));
6011
+ }
5361
6012
  init_extraction();
5362
6013
  init_hooks();
5363
6014
  var TIMEOUT_MS2 = 3e4;
@@ -5445,6 +6096,7 @@ async function pollEmbeddingBatch(cfg2, batchId, opts = {}) {
5445
6096
  }
5446
6097
 
5447
6098
  // src/ingest.ts
6099
+ init_redaction();
5448
6100
  init_usage();
5449
6101
  var MODE_RANK = { hybrid: 0, graph: 1 };
5450
6102
  var EMBED_BATCH = 256;
@@ -5531,7 +6183,9 @@ async function prepare(opts) {
5531
6183
  mediaOnly: false,
5532
6184
  providerTokens: {},
5533
6185
  contentHash: calculateContentHash({ fullText: clean }),
5534
- isMarkdown: false
6186
+ isMarkdown: false,
6187
+ unreadableReason: null,
6188
+ unreadablePages: 0
5535
6189
  };
5536
6190
  }
5537
6191
  const content = opts.content;
@@ -5539,11 +6193,12 @@ async function prepare(opts) {
5539
6193
  const mime = mimeForFilename(fname) || "";
5540
6194
  const extracted = await extract2(content, fname, mime || null, {
5541
6195
  visionLlm: opts.config.visionLlm,
6196
+ extraction: opts.config.extraction,
5542
6197
  hooks: opts.hooks
5543
6198
  });
5544
6199
  let body = sanitizeText(extracted.text || "") || "";
5545
6200
  const effectiveMime = mime || inferMime(body, fname) || DEFAULT_MIME;
5546
- if (effectiveMime === "application/pdf" && body) body = dedupLines(body);
6201
+ if (effectiveMime === "application/pdf" && body && !extracted.isMarkdown) body = dedupLines(body);
5547
6202
  return {
5548
6203
  text: body,
5549
6204
  mime: effectiveMime,
@@ -5554,7 +6209,9 @@ async function prepare(opts) {
5554
6209
  mediaOnly: Boolean(extracted.mediaOnly),
5555
6210
  providerTokens: { ...extracted.providerTokens ?? {} },
5556
6211
  contentHash: calculateContentHash({ docBytes: content }),
5557
- isMarkdown: Boolean(extracted.isMarkdown)
6212
+ isMarkdown: Boolean(extracted.isMarkdown),
6213
+ unreadableReason: extracted.unreadableReason ?? null,
6214
+ unreadablePages: extracted.unreadablePages ?? 0
5558
6215
  };
5559
6216
  }
5560
6217
  function computeUnits(prepared) {
@@ -5732,7 +6389,11 @@ async function decide(pool, opts) {
5732
6389
  }
5733
6390
  const same = stored != null && incoming != null && (Buffer.isBuffer(stored) && Buffer.isBuffer(incoming) ? Buffer.compare(stored, incoming) === 0 : stored === incoming);
5734
6391
  if (!same) return { action: "process", documentId: existingId };
5735
- if (existing.status === "skipped") return { action: "skip", documentId: existingId };
6392
+ if (existing.status === "skipped") {
6393
+ const metaData = existing.meta_data;
6394
+ if (metaData?.unreadable_reason) return { action: "process", documentId: existingId };
6395
+ return { action: "skip", documentId: existingId };
6396
+ }
5736
6397
  const reqMode = opts.request.mode ?? "hybrid";
5737
6398
  if ((MODE_RANK[reqMode] ?? 0) <= (MODE_RANK[existing.mode || "hybrid"] ?? 0)) {
5738
6399
  return { action: "skip", documentId: existingId };
@@ -6023,6 +6684,22 @@ function singleReport(doc) {
6023
6684
  async function maybeAwait(value) {
6024
6685
  return value;
6025
6686
  }
6687
+ function progress(hooks, request, displayName, documentId, stage, state, detail = {}) {
6688
+ emitProgress(hooks, {
6689
+ sourceId: request.sourceId ?? null,
6690
+ externalId: request.externalId ?? null,
6691
+ documentId: documentId == null ? null : String(documentId),
6692
+ name: displayName,
6693
+ stage,
6694
+ state,
6695
+ detail: { ...detail }
6696
+ });
6697
+ }
6698
+ function extractMethod(prepared, text) {
6699
+ if (text != null) return "text";
6700
+ if (Object.keys(prepared.providerTokens).length || prepared.isMarkdown) return "vision";
6701
+ return "parser";
6702
+ }
6026
6703
  async function runModeUpgrade(request, opts) {
6027
6704
  await refreshOnSkip(request, {
6028
6705
  pool: opts.pool,
@@ -6036,6 +6713,7 @@ async function runModeUpgrade(request, opts) {
6036
6713
  let chunkCount = 0;
6037
6714
  try {
6038
6715
  if (request.mode === "graph" && opts.graphStage) {
6716
+ progress(opts.hooks, request, opts.displayName, opts.documentId, "graph", "started");
6039
6717
  graphUnits2 = Number(
6040
6718
  await maybeAwait(
6041
6719
  opts.graphStage({
@@ -6046,6 +6724,9 @@ async function runModeUpgrade(request, opts) {
6046
6724
  })
6047
6725
  ) || 0
6048
6726
  );
6727
+ progress(opts.hooks, request, opts.displayName, opts.documentId, "graph", "done", {
6728
+ graph_units: graphUnits2
6729
+ });
6049
6730
  }
6050
6731
  chunkCount = await upgradeMode(opts.pool, opts.documentId, request.mode ?? "hybrid");
6051
6732
  } catch (exc) {
@@ -6135,6 +6816,7 @@ async function runIngest(request, opts) {
6135
6816
  );
6136
6817
  }
6137
6818
  }
6819
+ progress(opts.hooks, request, displayName, null, "extract", "started");
6138
6820
  const prepared = await prepare({
6139
6821
  content,
6140
6822
  filename,
@@ -6143,6 +6825,11 @@ async function runIngest(request, opts) {
6143
6825
  config: opts.config,
6144
6826
  hooks: opts.hooks
6145
6827
  });
6828
+ progress(opts.hooks, request, displayName, null, "extract", "done", {
6829
+ pages: prepared.pages,
6830
+ method: extractMethod(prepared, text),
6831
+ mime: prepared.mime
6832
+ });
6146
6833
  const ingestHash = sha256Bytes(prepared.text || "");
6147
6834
  const decision = await decide(opts.pool, {
6148
6835
  request: { ...request, mode },
@@ -6229,8 +6916,11 @@ async function runIngest(request, opts) {
6229
6916
  const metaUpdates = { ...request.metaData ?? {} };
6230
6917
  metaUpdates.mime_type = prepared.mime;
6231
6918
  if (prepared.contentHash) metaUpdates.content_hash = prepared.contentHash;
6919
+ metaUpdates.unreadable_reason = prepared.unreadableReason;
6920
+ metaUpdates.unreadable_pages = prepared.unreadablePages;
6232
6921
  const redactionFailed = [];
6233
6922
  let documentText;
6923
+ progress(opts.hooks, request, displayName, documentId, "redact", "started");
6234
6924
  try {
6235
6925
  documentText = redactDocumentText(
6236
6926
  prepared.text,
@@ -6243,6 +6933,9 @@ async function runIngest(request, opts) {
6243
6933
  await finalizeDocument(opts.pool, documentId, { status: "failed", error: String(exc) });
6244
6934
  throw exc;
6245
6935
  }
6936
+ progress(opts.hooks, request, displayName, documentId, "redact", "done", {
6937
+ rules_failed: redactionFailed.length
6938
+ });
6246
6939
  if (!prepared.text.trim() || prepared.mediaOnly || looksMostlyBoilerplate(prepared.text)) {
6247
6940
  const reason = prepared.mediaOnly ? "media-only (no extractable text)" : "no extractable text";
6248
6941
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, []);
@@ -6268,7 +6961,13 @@ async function runIngest(request, opts) {
6268
6961
  graphUnits: 0,
6269
6962
  providerTokens: {},
6270
6963
  error: reason,
6271
- redactionFailed
6964
+ redactionFailed,
6965
+ // Extraction RAN here — an unreadable scan is exactly what lands in
6966
+ // this branch, so this is the report that has to say why. The
6967
+ // dedup-skip/upgrade sites stay at the defaults: they never extracted
6968
+ // and would be claiming blind.
6969
+ unreadableReason: prepared.unreadableReason,
6970
+ unreadablePages: prepared.unreadablePages
6272
6971
  });
6273
6972
  }
6274
6973
  const units = computeUnits(prepared);
@@ -6278,18 +6977,26 @@ async function runIngest(request, opts) {
6278
6977
  let rows = [];
6279
6978
  let effectiveMime = prepared.mime;
6280
6979
  try {
6980
+ progress(opts.hooks, request, displayName, documentId, "chunk", "started");
6281
6981
  [rows, effectiveMime] = buildChunks(prepared, request.name, {
6282
6982
  policy: opts.config.redaction,
6283
6983
  secretKey: opts.config.secretKey,
6284
6984
  hooks: opts.hooks,
6285
6985
  failed: redactionFailed
6286
6986
  });
6987
+ progress(opts.hooks, request, displayName, documentId, "chunk", "done", { chunks: rows.length });
6988
+ progress(opts.hooks, request, displayName, documentId, "embed", "started");
6287
6989
  if (request.batch) {
6288
6990
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, rows);
6289
6991
  const batchId = await submitEmbeddingBatch(
6290
6992
  opts.config.embedding,
6291
6993
  rows.map((r) => r.text)
6292
6994
  );
6995
+ progress(opts.hooks, request, displayName, documentId, "embed", "done", {
6996
+ batch: true,
6997
+ batch_id: batchId,
6998
+ chunks: rows.length
6999
+ });
6293
7000
  metaUpdates.mime_type = effectiveMime;
6294
7001
  metaUpdates.batch = {
6295
7002
  id: batchId,
@@ -6319,15 +7026,26 @@ async function runIngest(request, opts) {
6319
7026
  graphUnits: 0,
6320
7027
  providerTokens,
6321
7028
  error: null,
6322
- redactionFailed
7029
+ redactionFailed,
7030
+ // Extraction RAN here just like the skipped/failed/completed sites —
7031
+ // this report has to say why pages were unreadable too, not leave
7032
+ // the caller to re-derive it later from listDocuments.
7033
+ unreadableReason: prepared.unreadableReason,
7034
+ unreadablePages: prepared.unreadablePages
6323
7035
  });
6324
7036
  }
6325
7037
  const embeddingTokens = await embedChunks(opts.embedder, rows);
6326
7038
  if (embeddingTokens) providerTokens.embedding_tokens = embeddingTokens;
6327
7039
  await recordEmbeddingDim(opts.pool, opts.embedder.dim);
6328
7040
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, rows);
7041
+ progress(opts.hooks, request, displayName, documentId, "embed", "done", {
7042
+ batch: false,
7043
+ chunks: rows.length,
7044
+ tokens: embeddingTokens
7045
+ });
6329
7046
  const structuredKw = {};
6330
7047
  if (request.extractStructured && opts.config.llm) {
7048
+ progress(opts.hooks, request, displayName, documentId, "structured", "started");
6331
7049
  const extraction = await extractStructuredData(documentText, {
6332
7050
  llmCfg: opts.config.llm,
6333
7051
  fieldHints: request.fieldHints
@@ -6368,9 +7086,15 @@ async function runIngest(request, opts) {
6368
7086
  document: displayName
6369
7087
  });
6370
7088
  }
7089
+ progress(opts.hooks, request, displayName, documentId, "structured", "done", {
7090
+ document_type: extraction.documentType,
7091
+ keys: extraction.keysNormalized.length,
7092
+ quality: extraction.quality
7093
+ });
6371
7094
  }
6372
7095
  let graphUnits2 = 0;
6373
7096
  if (mode === "graph" && opts.graphStage) {
7097
+ progress(opts.hooks, request, displayName, documentId, "graph", "started");
6374
7098
  graphUnits2 = Number(
6375
7099
  await maybeAwait(
6376
7100
  opts.graphStage({
@@ -6381,6 +7105,7 @@ async function runIngest(request, opts) {
6381
7105
  })
6382
7106
  ) || 0
6383
7107
  );
7108
+ progress(opts.hooks, request, displayName, documentId, "graph", "done", { graph_units: graphUnits2 });
6384
7109
  }
6385
7110
  metaUpdates.mime_type = effectiveMime;
6386
7111
  await finalizeDocument(opts.pool, documentId, {
@@ -6420,7 +7145,9 @@ async function runIngest(request, opts) {
6420
7145
  graphUnits: graphUnits2,
6421
7146
  providerTokens,
6422
7147
  error: null,
6423
- redactionFailed
7148
+ redactionFailed,
7149
+ unreadableReason: prepared.unreadableReason,
7150
+ unreadablePages: prepared.unreadablePages
6424
7151
  });
6425
7152
  } catch (exc) {
6426
7153
  emitError(opts.hooks, exc, { stage: "ingest", document: displayName });
@@ -6435,7 +7162,9 @@ async function runIngest(request, opts) {
6435
7162
  graphUnits: 0,
6436
7163
  providerTokens,
6437
7164
  error: String(exc),
6438
- redactionFailed
7165
+ redactionFailed,
7166
+ unreadableReason: prepared.unreadableReason,
7167
+ unreadablePages: prepared.unreadablePages
6439
7168
  });
6440
7169
  }
6441
7170
  }
@@ -6532,12 +7261,7 @@ function parseDocumentId(documentId) {
6532
7261
  }
6533
7262
  return raw;
6534
7263
  }
6535
- function visible(acl, principals) {
6536
- if (principals === null) return true;
6537
- if (acl == null) return true;
6538
- const held = new Set(principals);
6539
- return acl.some((p) => held.has(p));
6540
- }
7264
+ var visible = aclVisible;
6541
7265
  function scopeSql(sourceIds, principals, params) {
6542
7266
  const where = [];
6543
7267
  if (sourceIds != null) {
@@ -6626,7 +7350,9 @@ async function getDocumentRow(pool, documentId, principals) {
6626
7350
  createdAt: doc.created_at,
6627
7351
  updatedAt: doc.updated_at,
6628
7352
  startedAt: doc.started_at,
6629
- completedAt: doc.completed_at
7353
+ completedAt: doc.completed_at,
7354
+ unreadableReason: doc.meta_data?.unreadable_reason ?? null,
7355
+ unreadablePages: doc.meta_data?.unreadable_pages ?? 0
6630
7356
  };
6631
7357
  }
6632
7358
  async function stats(pool, sourceId) {
@@ -6683,7 +7409,7 @@ async function listDocuments(opts) {
6683
7409
  params.push(limit + 1);
6684
7410
  const { rows } = await opts.pool.query(
6685
7411
  `SELECT id, source_id, external_id, name, description, mode, mime_type, lang, status,
6686
- document_type, created_at, updated_at, acl
7412
+ document_type, created_at, updated_at, acl, meta_data
6687
7413
  FROM context_engine_documents
6688
7414
  ${where}
6689
7415
  ORDER BY created_at DESC, id DESC
@@ -6708,7 +7434,15 @@ async function listDocuments(opts) {
6708
7434
  hooks: opts.hooks
6709
7435
  }),
6710
7436
  createdAt: doc.created_at instanceof Date ? doc.created_at.toISOString() : doc.created_at,
6711
- updatedAt: doc.updated_at instanceof Date ? doc.updated_at.toISOString() : doc.updated_at
7437
+ updatedAt: doc.updated_at instanceof Date ? doc.updated_at.toISOString() : doc.updated_at,
7438
+ unreadableReason: doc.meta_data?.unreadable_reason ?? null,
7439
+ unreadablePages: doc.meta_data?.unreadable_pages ?? 0,
7440
+ // WHO can see this. Stored, enforced on every query and editable through
7441
+ // updateDocument — and, until this line, invisible to every caller,
7442
+ // because this serializer builds a FIXED object. `null` is UNRESTRICTED
7443
+ // and must stay null; an empty array would read as "nobody", which is the
7444
+ // opposite claim.
7445
+ acl: doc.acl ?? null
6712
7446
  }));
6713
7447
  const result = {
6714
7448
  documents,
@@ -6866,9 +7600,9 @@ async function compute(instruction, opts) {
6866
7600
  return `mime_type ILIKE $${params.length}`;
6867
7601
  }).join(" OR ");
6868
7602
  where = where ? `${where} AND (${mimeClause})` : `WHERE (${mimeClause})`;
6869
- if (opts.docIds?.length) {
7603
+ if (opts.documentIds?.length) {
6870
7604
  const parsedIds = [];
6871
- for (const did of opts.docIds) {
7605
+ for (const did of opts.documentIds) {
6872
7606
  try {
6873
7607
  parsedIds.push(parseDocumentId(did));
6874
7608
  } catch {
@@ -6896,13 +7630,13 @@ async function compute(instruction, opts) {
6896
7630
  }
6897
7631
  if (tabular.length > MAX_COMPUTE_DOCUMENTS) {
6898
7632
  throw new exports.EngineActionError(
6899
- `more than ${MAX_COMPUTE_DOCUMENTS} tabular documents are in scope for compute() \u2014 narrow the request with docIds or sourceIds`
7633
+ `more than ${MAX_COMPUTE_DOCUMENTS} tabular documents are in scope for compute() \u2014 narrow the request with documentIds or sourceIds`
6900
7634
  );
6901
7635
  }
6902
7636
  const totalChars = tabular.reduce((n, d) => n + String(d.text ?? "").length, 0);
6903
7637
  if (totalChars > MAX_COMPUTE_TEXT_CHARS) {
6904
7638
  throw new exports.EngineActionError(
6905
- `in-scope spreadsheet text too large to load (${totalChars} chars > ${MAX_COMPUTE_TEXT_CHARS} cap) \u2014 narrow the request with docIds or sourceIds`
7639
+ `in-scope spreadsheet text too large to load (${totalChars} chars > ${MAX_COMPUTE_TEXT_CHARS} cap) \u2014 narrow the request with documentIds or sourceIds`
6906
7640
  );
6907
7641
  }
6908
7642
  const dfs = {};
@@ -6942,182 +7676,52 @@ ${schemaLines.join("\n")}`;
6942
7676
  user: userPrompt,
6943
7677
  jsonMode: false
6944
7678
  });
6945
- } catch (exc) {
6946
- emitError(opts.hooks, exc, { stage: "compute_codegen", instruction: instruction.slice(0, 200) });
6947
- throw exc;
6948
- }
6949
- const code = stripCodeFences(rawCode);
6950
- const execResult = await executeSafeCode(code, { dfs, documents }, { timeout });
6951
- if (!execResult.success) {
6952
- let maskedCode;
6953
- try {
6954
- maskedCode = redactValueRecursive(code.slice(0, 500), opts.config.redaction, {
6955
- principals,
6956
- secretKey: opts.config.secretKey,
6957
- hooks: opts.hooks
6958
- });
6959
- } catch {
6960
- maskedCode = "<redaction failed: code omitted>";
6961
- }
6962
- emitError(opts.hooks, new Error(execResult.error || "compute execution failed"), {
6963
- stage: "compute_exec",
6964
- code: maskedCode
6965
- });
6966
- }
6967
- const result = {
6968
- success: execResult.success,
6969
- result: execResult.result,
6970
- code,
6971
- stdout: execResult.stdout ?? "",
6972
- error: execResult.error,
6973
- executionTime: execResult.executionTime,
6974
- documentsUsed: documents.map((d) => d.id),
6975
- providerTokens: {
6976
- llm_input: tokens?.input ?? 0,
6977
- llm_output: tokens?.output ?? 0
6978
- }
6979
- };
6980
- return redactValueRecursive(result, opts.config.redaction, {
6981
- principals,
6982
- secretKey: opts.config.secretKey,
6983
- hooks: opts.hooks
6984
- });
6985
- }
6986
- var embeddingSchema = zod.z.object({
6987
- provider: zod.z.enum(["openai", "azure_openai", "gemini", "voyage", "cohere", "custom"]),
6988
- model: zod.z.string(),
6989
- dim: zod.z.number().int().positive().nullable().optional().default(null),
6990
- apiKey: zod.z.string().nullable().optional().default(null),
6991
- baseUrl: zod.z.string().nullable().optional().default(null)
6992
- });
6993
- var llmSchema = zod.z.object({
6994
- provider: zod.z.enum(["anthropic", "openai", "azure_openai", "gemini", "bedrock", "custom"]),
6995
- model: zod.z.string(),
6996
- apiKey: zod.z.string().nullable().optional().default(null),
6997
- baseUrl: zod.z.string().nullable().optional().default(null)
6998
- });
6999
- var graphSchema = zod.z.object({
7000
- enabled: zod.z.boolean().default(false),
7001
- neo4jUri: zod.z.string().nullable().optional().default(null),
7002
- neo4jUser: zod.z.string().default("neo4j"),
7003
- neo4jPassword: zod.z.string().nullable().optional().default(null),
7004
- neo4jDatabase: zod.z.string().default("neo4j"),
7005
- extractionLlm: llmSchema.nullable().optional().default(null)
7006
- });
7007
- var rerankerSchema = zod.z.object({
7008
- enabled: zod.z.boolean().default(false),
7009
- provider: zod.z.enum(["cohere", "voyage", "jina", "custom"]).nullable().optional().default(null),
7010
- model: zod.z.string().nullable().optional().default(null),
7011
- apiKey: zod.z.string().nullable().optional().default(null),
7012
- baseUrl: zod.z.string().nullable().optional().default(null),
7013
- candidates: zod.z.number().int().positive().default(50)
7014
- });
7015
- var fusionSchema = zod.z.object({
7016
- method: zod.z.literal("rrf").default("rrf"),
7017
- k: zod.z.number().int().positive().default(60),
7018
- weights: zod.z.record(zod.z.string(), zod.z.number()).default({ fts: 1, trgm: 0.8, ann: 1, graph: 1 })
7019
- });
7020
- var storageSchema = zod.z.object({
7021
- backend: zod.z.literal("postgres").default("postgres"),
7022
- annExactThreshold: zod.z.number().int().positive().default(5e4)
7023
- });
7024
- var ContextEngineConfig = class _ContextEngineConfig {
7025
- databaseUrl;
7026
- storage;
7027
- defaultMode;
7028
- embedding;
7029
- llm;
7030
- visionLlm;
7031
- graph;
7032
- reranker;
7033
- fusion;
7034
- enableCodeExecution;
7035
- secretKey;
7036
- redaction;
7037
- constructor(init) {
7038
- this.databaseUrl = init.databaseUrl;
7039
- this.storage = storageSchema.parse(init.storage ?? {});
7040
- this.defaultMode = init.defaultMode ?? "hybrid";
7041
- this.embedding = embeddingSchema.parse(init.embedding);
7042
- this.llm = init.llm ? llmSchema.parse(init.llm) : null;
7043
- this.visionLlm = init.visionLlm ? llmSchema.parse(init.visionLlm) : null;
7044
- this.graph = graphSchema.parse(init.graph ?? {});
7045
- this.reranker = rerankerSchema.parse(init.reranker ?? {});
7046
- this.fusion = fusionSchema.parse(init.fusion ?? {});
7047
- this.enableCodeExecution = init.enableCodeExecution ?? false;
7048
- this.secretKey = init.secretKey ?? null;
7049
- this.redaction = init.redaction instanceof RedactionPolicy ? init.redaction : new RedactionPolicy(init.redaction ?? {});
7050
- this.validate();
7051
- }
7052
- validate() {
7053
- if (this.graph.enabled && !(this.graph.neo4jUri && this.graph.neo4jPassword && this.graph.extractionLlm)) {
7054
- throw new Error("graph enabled but neo4jUri/neo4jPassword/extractionLlm missing");
7055
- }
7056
- if (this.reranker.enabled && !(this.reranker.provider && this.reranker.apiKey)) {
7057
- throw new Error("reranker enabled but provider/apiKey missing");
7058
- }
7059
- if (this.defaultMode === "graph" && !this.graph.enabled) {
7060
- throw new Error("defaultMode is 'graph' but graph enabled is false");
7061
- }
7062
- for (const rule of this.redaction.rules) {
7063
- if (rule.action === "hash" && !this.secretKey) {
7064
- throw new Error(
7065
- `redaction rule '${rule.name}': action='hash' requires ContextEngineConfig.secretKey to be set`
7066
- );
7067
- }
7068
- }
7069
- }
7070
- static fromEnv(overrides = {}) {
7071
- const env = loadCeEnv();
7072
- const merged = deepMerge(env, overrides);
7073
- if (!merged.databaseUrl || !merged.embedding) {
7074
- throw new Error(
7075
- "ContextEngineConfig.fromEnv requires CE_DATABASE_URL and CE_EMBEDDING__PROVIDER/MODEL (or explicit overrides)"
7076
- );
7077
- }
7078
- return new _ContextEngineConfig(merged);
7079
- }
7080
- };
7081
- function camelize(key) {
7082
- return key.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
7083
- }
7084
- function loadCeEnv() {
7085
- const root = {};
7086
- for (const [raw, value] of Object.entries(process.env)) {
7087
- if (!raw.startsWith("CE_") || value === void 0) continue;
7088
- const path = raw.slice(3).split("__").map(camelize);
7089
- let cur = root;
7090
- for (let i = 0; i < path.length - 1; i++) {
7091
- const k = path[i];
7092
- const next = cur[k];
7093
- if (typeof next !== "object" || next === null) cur[k] = {};
7094
- cur = cur[k];
7095
- }
7096
- cur[path[path.length - 1]] = coerceEnv(value);
7097
- }
7098
- return root;
7099
- }
7100
- function coerceEnv(value) {
7101
- if (value === "true") return true;
7102
- if (value === "false") return false;
7103
- if (/^-?\d+$/.test(value)) return Number(value);
7104
- if (/^-?\d+\.\d+$/.test(value)) return Number(value);
7105
- return value;
7106
- }
7107
- function deepMerge(a, b) {
7108
- const out = { ...a };
7109
- for (const [k, v] of Object.entries(b)) {
7110
- if (v === void 0) continue;
7111
- const existing = out[k];
7112
- if (v && typeof v === "object" && !Array.isArray(v) && existing && typeof existing === "object" && !Array.isArray(existing)) {
7113
- out[k] = deepMerge(existing, v);
7114
- } else {
7115
- out[k] = v;
7679
+ } catch (exc) {
7680
+ emitError(opts.hooks, exc, { stage: "compute_codegen", instruction: instruction.slice(0, 200) });
7681
+ throw exc;
7682
+ }
7683
+ const code = stripCodeFences(rawCode);
7684
+ const execResult = await executeSafeCode(code, { dfs, documents }, { timeout });
7685
+ if (!execResult.success) {
7686
+ let maskedCode;
7687
+ try {
7688
+ maskedCode = redactValueRecursive(code.slice(0, 500), opts.config.redaction, {
7689
+ principals,
7690
+ secretKey: opts.config.secretKey,
7691
+ hooks: opts.hooks
7692
+ });
7693
+ } catch {
7694
+ maskedCode = "<redaction failed: code omitted>";
7116
7695
  }
7696
+ emitError(opts.hooks, new Error(execResult.error || "compute execution failed"), {
7697
+ stage: "compute_exec",
7698
+ code: maskedCode
7699
+ });
7117
7700
  }
7118
- return out;
7701
+ const result = {
7702
+ success: execResult.success,
7703
+ result: execResult.result,
7704
+ code,
7705
+ stdout: execResult.stdout ?? "",
7706
+ error: execResult.error,
7707
+ executionTime: execResult.executionTime,
7708
+ documentsUsed: documents.map((d) => d.id),
7709
+ providerTokens: {
7710
+ llm_input: tokens?.input ?? 0,
7711
+ llm_output: tokens?.output ?? 0
7712
+ }
7713
+ };
7714
+ return redactValueRecursive(result, opts.config.redaction, {
7715
+ principals,
7716
+ secretKey: opts.config.secretKey,
7717
+ hooks: opts.hooks
7718
+ });
7119
7719
  }
7120
7720
 
7721
+ // src/index.ts
7722
+ init_config();
7723
+ init_crypto();
7724
+
7121
7725
  // src/db.ts
7122
7726
  init_errors();
7123
7727
  var VERSION_TABLE = "context_engine_alembic_version";
@@ -7200,6 +7804,7 @@ async function runMigrate(databaseUrl, opts = {}) {
7200
7804
  recorded = rev;
7201
7805
  done.add(rev);
7202
7806
  }
7807
+ await client.query(loadSql("0004_acl_indexes", dim));
7203
7808
  const meta = await client.query("SELECT embedding_dim FROM context_engine_meta WHERE id = 1");
7204
7809
  if (!meta.rows.length) {
7205
7810
  await client.query("INSERT INTO context_engine_meta (id, embedding_dim) VALUES (1, $1)", [dim]);
@@ -7376,7 +7981,7 @@ async function loadGeminiEmbedClient(apiKey) {
7376
7981
  if (!Ctor) {
7377
7982
  throw new exports.ExtraMissingError("gemini", specifier, "gemini embeddings");
7378
7983
  }
7379
- return new Ctor({ apiKey: apiKey ?? null });
7984
+ return new Ctor({ apiKey: apiKey ?? null, httpOptions: { timeout: TIMEOUT_MS3 } });
7380
7985
  }
7381
7986
  function buildEmbedder(cfg2, opts) {
7382
7987
  if (opts?.client || opts?.fetch || opts?.fetchImpl) {
@@ -7394,6 +7999,9 @@ function buildEmbedder(cfg2, opts) {
7394
7999
  throw new Error(`unknown embedding provider: ${JSON.stringify(cfg2.provider)}`);
7395
8000
  }
7396
8001
 
8002
+ // src/engine.ts
8003
+ init_redaction();
8004
+
7397
8005
  // src/compression.ts
7398
8006
  init_text();
7399
8007
  var TOKENS_PER_CHAR = 0.25;
@@ -8697,6 +9305,7 @@ async function rerank(cfg2, query, docs, opts = {}) {
8697
9305
  }
8698
9306
 
8699
9307
  // src/search.ts
9308
+ init_redaction();
8700
9309
  var MIN_LEG_CANDIDATES = 50;
8701
9310
  var MAX_LEG_CANDIDATES = 500;
8702
9311
  var UNITS_PER_SCOPE_HYBRID = 1;
@@ -8906,6 +9515,7 @@ async function runSearch(query, opts) {
8906
9515
  const topK = Math.max(1, Math.trunc(opts.topK ?? 10));
8907
9516
  const scope = {
8908
9517
  sourceIds: opts.sourceIds != null ? [...opts.sourceIds] : null,
9518
+ documentIds: opts.documentIds != null ? [...opts.documentIds] : null,
8909
9519
  principals: opts.principals != null ? [...opts.principals] : null,
8910
9520
  limit: legLimit(topK, opts.config)
8911
9521
  };
@@ -8926,13 +9536,8 @@ async function runSearch(query, opts) {
8926
9536
  let degraded = null;
8927
9537
  if (mode === "graph" && !graphLeg) {
8928
9538
  degraded = "graph_leg_unavailable";
8929
- emitError(
8930
- opts.hooks,
8931
- new exports.GraphLegUnavailable(
8932
- "search(mode='graph') ran the hybrid legs only: no graph ranked list was supplied (the graph retrieval leg is not implemented yet). Results are hybrid and billed as hybrid."
8933
- ),
8934
- { stage: "graph_leg", mode, degraded }
8935
- );
9539
+ const reason = opts.graphRanked?.length ? "search(mode='graph') ran the hybrid legs only: the supplied graph ranked list was filtered to nothing by the search scope (source/document/ACL) \u2014 every candidate lies outside it. Results are hybrid and billed as hybrid." : "search(mode='graph') ran the hybrid legs only: graph retrieval supplied no candidates. Results are hybrid and billed as hybrid.";
9540
+ emitError(opts.hooks, new exports.GraphLegUnavailable(reason), { stage: "graph_leg", mode, degraded });
8936
9541
  }
8937
9542
  const fused = rrfFuse(ranked, { k: opts.config.fusion.k, weights: opts.config.fusion.weights });
8938
9543
  const window = opts.config.reranker.enabled ? Math.max(topK, opts.config.reranker.candidates) : topK;
@@ -9007,31 +9612,6 @@ async function runSearch(query, opts) {
9007
9612
  return { hits, usage };
9008
9613
  }
9009
9614
 
9010
- // src/sentinels.ts
9011
- var UNSET = /* @__PURE__ */ Symbol.for("context_engine.UNSET");
9012
- var TrustedSentinel = class {
9013
- [Symbol.toStringTag] = "TRUSTED";
9014
- toString() {
9015
- return "TRUSTED";
9016
- }
9017
- valueOf() {
9018
- return true;
9019
- }
9020
- };
9021
- var TRUSTED = Object.freeze(new TrustedSentinel());
9022
- function resolvePrincipals(value, method) {
9023
- if (value === TRUSTED) return null;
9024
- if (value === void 0) return null;
9025
- if (value === null) {
9026
- process.emitWarning(
9027
- `${method}(principals=null) means TRUSTED CALLER \u2014 access control is disabled and every document is returned. If that is what you want, pass principals=TRUSTED (from @promptev/context-engine) to say so explicitly. If you meant 'no authenticated user', pass principals=[] instead; null returns the entire corpus. Passing null will raise in 1.0.`,
9028
- "DeprecationWarning"
9029
- );
9030
- return null;
9031
- }
9032
- return Array.isArray(value) ? value : null;
9033
- }
9034
-
9035
9615
  // src/storage.ts
9036
9616
  init_text();
9037
9617
  var MIN_ITERATIVE_SCAN_VERSION = [0, 8];
@@ -9070,25 +9650,26 @@ function trigramThreshold(query) {
9070
9650
  }
9071
9651
  var SCOPE = `
9072
9652
  AND ($1::text[] IS NULL OR c.source_id = ANY($1))
9073
- AND ($2::text[] IS NULL OR c.acl IS NULL OR c.acl && $2)
9653
+ AND ($2::uuid[] IS NULL OR c.document_id = ANY($2::uuid[]))
9654
+ AND ($3::text[] IS NULL OR c.acl IS NULL OR c.acl && $3)
9074
9655
  `;
9075
9656
  var SQL_FTS = `
9076
9657
  SELECT c.id::text
9077
9658
  FROM context_engine_chunks c
9078
9659
  WHERE c.text IS NOT NULL
9079
- AND c.text_search @@ websearch_to_tsquery('simple'::regconfig, $3)
9660
+ AND c.text_search @@ websearch_to_tsquery('simple'::regconfig, $4)
9080
9661
  ${SCOPE}
9081
- ORDER BY ts_rank_cd(c.text_search, websearch_to_tsquery('simple'::regconfig, $3)) DESC, c.id
9082
- LIMIT $4
9662
+ ORDER BY ts_rank_cd(c.text_search, websearch_to_tsquery('simple'::regconfig, $4)) DESC, c.id
9663
+ LIMIT $5
9083
9664
  `;
9084
9665
  var SQL_TRGM = `
9085
9666
  SELECT c.id::text
9086
9667
  FROM context_engine_chunks c
9087
9668
  WHERE c.text IS NOT NULL
9088
- AND c.text_trgm_norm % $3
9669
+ AND c.text_trgm_norm % $4
9089
9670
  ${SCOPE}
9090
- ORDER BY similarity(c.text_trgm_norm, $3) DESC, c.id
9091
- LIMIT $4
9671
+ ORDER BY similarity(c.text_trgm_norm, $4) DESC, c.id
9672
+ LIMIT $5
9092
9673
  `;
9093
9674
  var SQL_COUNT_ELIGIBLE = `
9094
9675
  SELECT count(*)::int AS count FROM (
@@ -9096,13 +9677,13 @@ SELECT count(*)::int AS count FROM (
9096
9677
  FROM context_engine_chunks c
9097
9678
  WHERE c.embedding IS NOT NULL
9098
9679
  ${SCOPE}
9099
- LIMIT $3
9680
+ LIMIT $4
9100
9681
  ) probe
9101
9682
  `;
9102
9683
  var SQL_FILTER_IDS = `
9103
9684
  SELECT c.id::text
9104
9685
  FROM context_engine_chunks c
9105
- WHERE c.id = ANY($3::uuid[])
9686
+ WHERE c.id = ANY($4::uuid[])
9106
9687
  ${SCOPE}
9107
9688
  `;
9108
9689
  var UUID_RE4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -9121,7 +9702,7 @@ FROM context_engine_chunks c
9121
9702
  WHERE c.embedding IS NOT NULL
9122
9703
  ${SCOPE}
9123
9704
  ORDER BY c.embedding <=> ${vec}
9124
- LIMIT $3
9705
+ LIMIT $4
9125
9706
  `;
9126
9707
  }
9127
9708
  function sqlAnnExact(vec) {
@@ -9134,7 +9715,7 @@ WITH eligible AS MATERIALIZED (
9134
9715
  )
9135
9716
  SELECT id::text FROM eligible
9136
9717
  ORDER BY embedding <=> ${vec}
9137
- LIMIT $3
9718
+ LIMIT $4
9138
9719
  `;
9139
9720
  }
9140
9721
  function idsOf(rows) {
@@ -9161,24 +9742,26 @@ var PostgresBackend = class {
9161
9742
  * mean different things.
9162
9743
  */
9163
9744
  scopeParams(scope) {
9164
- return [
9165
- scope.sourceIds != null ? [...scope.sourceIds] : null,
9166
- scope.principals != null ? [...scope.principals] : null,
9167
- Math.max(1, Math.trunc(scope.limit ?? 50))
9168
- ];
9745
+ return {
9746
+ binds: [
9747
+ scope.sourceIds != null ? [...scope.sourceIds] : null,
9748
+ scope.documentIds != null ? [...scope.documentIds] : null,
9749
+ scope.principals != null ? [...scope.principals] : null
9750
+ ],
9751
+ lim: Math.max(1, Math.trunc(scope.limit ?? 50))
9752
+ };
9169
9753
  }
9170
9754
  async upsertChunks(documentId, sourceId, acl, chunks) {
9171
9755
  const client = await this.pool.connect();
9172
9756
  try {
9173
9757
  await client.query("BEGIN");
9174
9758
  await client.query("DELETE FROM context_engine_chunks WHERE document_id = $1", [documentId]);
9175
- for (const c of chunks) {
9176
- const emb = c.embedding != null ? vectorLiteral2(c.embedding) : "NULL";
9177
- await client.query(
9178
- `INSERT INTO context_engine_chunks
9179
- (id, document_id, source_id, acl, idx, text, lang, embedding, meta_data)
9180
- VALUES ($1, $2, $3, $4, $5, $6, $7, ${emb}, $8::jsonb)`,
9181
- [
9759
+ const BATCH = 500;
9760
+ for (let start = 0; start < chunks.length; start += BATCH) {
9761
+ const batch = chunks.slice(start, start + BATCH);
9762
+ const params = [];
9763
+ const rows = batch.map((c) => {
9764
+ params.push(
9182
9765
  crypto.randomUUID(),
9183
9766
  documentId,
9184
9767
  sourceId,
@@ -9187,7 +9770,16 @@ var PostgresBackend = class {
9187
9770
  c.text,
9188
9771
  c.lang ?? null,
9189
9772
  JSON.stringify(c.meta ?? {})
9190
- ]
9773
+ );
9774
+ const p = params.length;
9775
+ const emb = c.embedding != null ? vectorLiteral2(c.embedding) : "NULL";
9776
+ return `($${p - 7}, $${p - 6}, $${p - 5}, $${p - 4}, $${p - 3}, $${p - 2}, $${p - 1}, ${emb}, $${p}::jsonb)`;
9777
+ });
9778
+ await client.query(
9779
+ `INSERT INTO context_engine_chunks
9780
+ (id, document_id, source_id, acl, idx, text, lang, embedding, meta_data)
9781
+ VALUES ${rows.join(", ")}`,
9782
+ params
9191
9783
  );
9192
9784
  }
9193
9785
  await client.query("COMMIT");
@@ -9252,19 +9844,19 @@ var PostgresBackend = class {
9252
9844
  }
9253
9845
  }
9254
9846
  if (!parsed.length) return [];
9255
- const [src, principals] = this.scopeParams(scope);
9256
- const { rows } = await this.pool.query(SQL_FILTER_IDS, [src, principals, parsed]);
9847
+ const { binds } = this.scopeParams(scope);
9848
+ const { rows } = await this.pool.query(SQL_FILTER_IDS, [...binds, parsed]);
9257
9849
  const visible2 = new Set(idsOf(rows));
9258
9850
  return chunkIds.filter((cid) => visible2.has(String(cid))).map(String);
9259
9851
  }
9260
9852
  async ftsSearch(query, scope) {
9261
- const [src, principals, lim] = this.scopeParams(scope);
9262
- const { rows } = await this.pool.query(SQL_FTS, [src, principals, query, lim]);
9853
+ const { binds, lim } = this.scopeParams(scope);
9854
+ const { rows } = await this.pool.query(SQL_FTS, [...binds, query, lim]);
9263
9855
  return idsOf(rows);
9264
9856
  }
9265
9857
  async trgmSearch(query, scope) {
9266
9858
  const threshold = trigramThreshold(query);
9267
- const [src, principals, lim] = this.scopeParams(scope);
9859
+ const { binds, lim } = this.scopeParams(scope);
9268
9860
  const client = await this.pool.connect();
9269
9861
  let previous = null;
9270
9862
  let usedSetLimit = false;
@@ -9273,7 +9865,7 @@ var PostgresBackend = class {
9273
9865
  const applied = await this.applyTrgmThreshold(client, threshold);
9274
9866
  previous = applied.previous;
9275
9867
  usedSetLimit = applied.usedSetLimit;
9276
- const { rows } = await client.query(SQL_TRGM, [src, principals, query, lim]);
9868
+ const { rows } = await client.query(SQL_TRGM, [...binds, query, lim]);
9277
9869
  if (usedSetLimit) await this.restoreTrgmLimit(client, previous);
9278
9870
  await client.query("COMMIT");
9279
9871
  return idsOf(rows);
@@ -9319,18 +9911,18 @@ var PostgresBackend = class {
9319
9911
  }
9320
9912
  async annSearch(vector, scope) {
9321
9913
  const literal = vectorLiteral2(vector);
9322
- const [src, principals, lim] = this.scopeParams(scope);
9323
- const scoped = src !== null || principals !== null;
9914
+ const { binds, lim } = this.scopeParams(scope);
9915
+ const scoped = binds.some((v) => v !== null);
9324
9916
  const client = await this.pool.connect();
9325
9917
  try {
9326
9918
  await client.query("BEGIN");
9327
- if (scoped && await this.eligibleIsSmall(client, src, principals)) {
9328
- const { rows: rows2 } = await client.query(sqlAnnExact(literal), [src, principals, lim]);
9919
+ if (scoped && await this.eligibleIsSmall(client, binds)) {
9920
+ const { rows: rows2 } = await client.query(sqlAnnExact(literal), [...binds, lim]);
9329
9921
  await client.query("COMMIT");
9330
9922
  return idsOf(rows2);
9331
9923
  }
9332
9924
  await this.tuneAnnScan(client, lim, scoped);
9333
- const { rows } = await client.query(sqlAnn(literal), [src, principals, lim]);
9925
+ const { rows } = await client.query(sqlAnn(literal), [...binds, lim]);
9334
9926
  await client.query("COMMIT");
9335
9927
  return idsOf(rows);
9336
9928
  } catch (err) {
@@ -9343,11 +9935,17 @@ var PostgresBackend = class {
9343
9935
  client.release();
9344
9936
  }
9345
9937
  }
9346
- async eligibleIsSmall(client, src, principals) {
9938
+ async eligibleIsSmall(client, binds) {
9347
9939
  const cap = this.exactThreshold + 1;
9348
- const { rows } = await client.query(SQL_COUNT_ELIGIBLE, [src, principals, cap]);
9940
+ const { rows } = await client.query(SQL_COUNT_ELIGIBLE, [...binds, cap]);
9349
9941
  return Number(rows[0]?.count ?? 0) <= this.exactThreshold;
9350
9942
  }
9943
+ /** Size the HNSW candidate budget, and make it iterative when filtered.
9944
+ *
9945
+ * Public because the graph leg's seed query (`graph/retrieval.ts`) is the
9946
+ * same shape — an ANN walk with the scope predicate as a POST-filter — and
9947
+ * must not carry a second copy of this. Call it inside a transaction on the
9948
+ * client the query itself will run on: every setting here is `SET LOCAL`. */
9351
9949
  async tuneAnnScan(client, limit, scoped) {
9352
9950
  const efSearch = Math.min(Math.max(Math.trunc(limit) * 4, ANN_MIN_EF_SEARCH), ANN_MAX_EF_SEARCH);
9353
9951
  await client.query(`SET LOCAL hnsw.ef_search = ${efSearch}`);
@@ -9440,15 +10038,7 @@ function functionTool(fn) {
9440
10038
  // src/tools/governance.ts
9441
10039
  init_errors();
9442
10040
  init_hooks();
9443
-
9444
- // src/tools/acl.ts
9445
- function aclVisible(acl, principals) {
9446
- if (principals === null) return true;
9447
- if (acl == null) return true;
9448
- if (!acl.length) return false;
9449
- const held = new Set(principals ?? []);
9450
- return acl.some((p) => held.has(p));
9451
- }
10041
+ init_redaction();
9452
10042
 
9453
10043
  // src/tools/approval.ts
9454
10044
  init_errors();
@@ -9506,17 +10096,24 @@ function rowToRecord(row) {
9506
10096
  toolName: String(row.tool_name),
9507
10097
  toolArgsFrozen: row.tool_args_frozen ?? {},
9508
10098
  sourceId: row.source_id ?? null,
10099
+ approvalScope: row.approval_scope ?? null,
9509
10100
  principals: row.principals,
9510
10101
  status: String(row.status),
9511
10102
  approver: row.approver ?? null,
9512
10103
  approverMeta: row.approver_meta ?? null,
9513
- expiresAt: row.expires_at ? new Date(String(row.expires_at)) : null,
9514
- resolvedAt: row.resolved_at ? new Date(String(row.resolved_at)) : null,
9515
- createdAt: row.created_at ? new Date(String(row.created_at)) : null
10104
+ expiresAt: toDate(row.expires_at),
10105
+ resolvedAt: toDate(row.resolved_at),
10106
+ createdAt: toDate(row.created_at)
9516
10107
  };
9517
10108
  }
10109
+ function toDate(value) {
10110
+ if (value == null) return null;
10111
+ if (value instanceof Date) return value;
10112
+ return new Date(String(value));
10113
+ }
9518
10114
  function visibilitySql(principals, paramIndex) {
9519
- if (principals === null) return { sql: "TRUE", params: [] };
10115
+ if (principals === null || principals === TRUSTED) return { sql: "TRUE", params: [] };
10116
+ principals = principals;
9520
10117
  if (!principals.length) {
9521
10118
  return { sql: `(principals IS NULL OR principals = 'null'::jsonb)`, params: [] };
9522
10119
  }
@@ -9525,41 +10122,95 @@ function visibilitySql(principals, paramIndex) {
9525
10122
  params: [principals]
9526
10123
  };
9527
10124
  }
9528
- async function createPending(engine, opts) {
10125
+ function claimVisibilitySql(principals, paramIndex) {
10126
+ if (principals === null || principals === TRUSTED) return { sql: "TRUE", params: [] };
10127
+ const noWall = `principals IS NULL OR principals = 'null'::jsonb OR principals = '[]'::jsonb`;
10128
+ principals = principals;
10129
+ if (!principals.length) return { sql: `(${noWall})`, params: [] };
10130
+ return { sql: `(${noWall} OR principals ?| $${paramIndex}::text[])`, params: [principals] };
10131
+ }
10132
+ function validateApprovalScope(approvalScope) {
10133
+ if (approvalScope === null || approvalScope === void 0) return null;
10134
+ if (typeof approvalScope !== "string") {
10135
+ throw new TypeError(`approvalScope must be a non-empty string or null, got ${typeof approvalScope}`);
10136
+ }
10137
+ if (!approvalScope) throw new Error("approvalScope must be a non-empty string or null, got ''");
10138
+ return approvalScope;
10139
+ }
10140
+ function pendingRow(opts, approvalScope) {
9529
10141
  const policy = opts.policy ?? {};
9530
10142
  const frozen = structuredClone(opts.args);
9531
10143
  const createdAt = opts.now ?? /* @__PURE__ */ new Date();
9532
10144
  const timeout = Number(policy.timeout_minutes ?? DEFAULT_TIMEOUT_MINUTES);
9533
10145
  const expiresAt = new Date(createdAt.getTime() + timeout * 6e4);
10146
+ const principals = opts.principals === void 0 || opts.principals === TRUSTED ? null : opts.principals;
10147
+ return { frozen, createdAt, expiresAt, principals, approvalScope };
10148
+ }
10149
+ async function insertPending(engine, opts, row) {
9534
10150
  const id = crypto.randomUUID();
9535
10151
  await engine.pool.query(
9536
10152
  `INSERT INTO context_engine_tool_approvals
9537
- (id, tool_name, tool_args_frozen, source_id, principals, status, expires_at, created_at)
9538
- VALUES ($1,$2,$3::jsonb,$4,$5::jsonb,'pending',$6,$7)`,
10153
+ (id, tool_name, tool_args_frozen, source_id, approval_scope, principals, status, expires_at, created_at)
10154
+ VALUES ($1,$2,$3::jsonb,$4,$5,$6::jsonb,'pending',$7,$8)`,
9539
10155
  [
9540
10156
  id,
9541
10157
  opts.toolName,
9542
- JSON.stringify(frozen),
10158
+ JSON.stringify(row.frozen),
9543
10159
  opts.sourceId ?? null,
9544
- opts.principals === void 0 ? null : JSON.stringify(opts.principals),
9545
- expiresAt,
9546
- createdAt
10160
+ row.approvalScope,
10161
+ row.principals === null ? null : JSON.stringify(row.principals),
10162
+ row.expiresAt,
10163
+ row.createdAt
9547
10164
  ]
9548
10165
  );
9549
10166
  return {
9550
10167
  id,
9551
10168
  toolName: opts.toolName,
9552
- toolArgsFrozen: frozen,
10169
+ toolArgsFrozen: row.frozen,
9553
10170
  sourceId: opts.sourceId ?? null,
9554
- principals: opts.principals ?? null,
10171
+ approvalScope: row.approvalScope,
10172
+ principals: row.principals,
9555
10173
  status: "pending",
9556
10174
  approver: null,
9557
10175
  approverMeta: null,
9558
- expiresAt,
10176
+ expiresAt: row.expiresAt,
9559
10177
  resolvedAt: null,
9560
- createdAt
10178
+ createdAt: row.createdAt
9561
10179
  };
9562
10180
  }
10181
+ async function createPending(engine, opts) {
10182
+ const approvalScope = validateApprovalScope(opts.approvalScope);
10183
+ return insertPending(engine, opts, pendingRow(opts, approvalScope));
10184
+ }
10185
+ async function findOrCreatePending(engine, opts) {
10186
+ const approvalScope = validateApprovalScope(opts.approvalScope);
10187
+ if (approvalScope === null)
10188
+ throw new Error("findOrCreatePending requires an approvalScope; use createPending");
10189
+ const row = pendingRow(opts, approvalScope);
10190
+ const frozenJson = JSON.stringify(row.frozen);
10191
+ const sameCall = `tool_name = $1 AND approval_scope = $2 AND tool_args_frozen = $3::jsonb`;
10192
+ const params = [opts.toolName, approvalScope, frozenJson, row.createdAt];
10193
+ await engine.pool.query(
10194
+ `UPDATE context_engine_tool_approvals SET status = 'expired'
10195
+ WHERE ${sameCall} AND status = 'pending' AND expires_at <= $4`,
10196
+ params
10197
+ );
10198
+ for (let attempt = 0; attempt < 2; attempt++) {
10199
+ const found = await engine.pool.query(
10200
+ `SELECT * FROM context_engine_tool_approvals
10201
+ WHERE ${sameCall} AND status = 'pending' AND (expires_at IS NULL OR expires_at > $4)
10202
+ ORDER BY created_at ASC LIMIT 1`,
10203
+ params
10204
+ );
10205
+ if (found.rows[0]) return rowToRecord(found.rows[0]);
10206
+ try {
10207
+ return await insertPending(engine, opts, row);
10208
+ } catch (exc) {
10209
+ if (exc?.code !== "23505") throw exc;
10210
+ }
10211
+ }
10212
+ throw new Error("findOrCreatePending: lost the insert race twice and found no live pending row");
10213
+ }
9563
10214
  async function resolveApproval(engine, approvalId, decision, approver, meta = null, opts = {}) {
9564
10215
  if (decision !== "approved" && decision !== "rejected") {
9565
10216
  throw new Error(`invalid decision: ${JSON.stringify(decision)} (expected 'approved' or 'rejected')`);
@@ -9707,6 +10358,10 @@ var ToolConfig = class _ToolConfig {
9707
10358
  requiresApproval;
9708
10359
  approvalPolicy;
9709
10360
  enabled;
10361
+ /** Engine-opaque user metadata (documents have the same column). Stored
10362
+ * and returned in CLEAR on the admin surface only — secrets go in
10363
+ * `config`, which is encrypted. */
10364
+ metaData;
9710
10365
  constructor(init) {
9711
10366
  this.id = init.id ?? null;
9712
10367
  this.name = init.name;
@@ -9718,6 +10373,7 @@ var ToolConfig = class _ToolConfig {
9718
10373
  this.requiresApproval = init.requiresApproval ?? init.requires_approval ?? false;
9719
10374
  this.approvalPolicy = init.approvalPolicy ?? init.approval_policy ?? {};
9720
10375
  this.enabled = init.enabled ?? true;
10376
+ this.metaData = init.metaData ?? init.meta_data ?? {};
9721
10377
  }
9722
10378
  static fromUnknown(body) {
9723
10379
  if (!body || typeof body !== "object" || Array.isArray(body)) {
@@ -9740,7 +10396,8 @@ var ToolConfig = class _ToolConfig {
9740
10396
  acl: b.acl ?? null,
9741
10397
  requiresApproval: Boolean(b.requiresApproval ?? b.requires_approval ?? false),
9742
10398
  approvalPolicy: b.approvalPolicy ?? b.approval_policy ?? {},
9743
- enabled: b.enabled === void 0 ? true : Boolean(b.enabled)
10399
+ enabled: b.enabled === void 0 ? true : Boolean(b.enabled),
10400
+ metaData: b.metaData ?? b.meta_data ?? {}
9744
10401
  });
9745
10402
  }
9746
10403
  };
@@ -9759,9 +10416,26 @@ var SCHEMAS = {
9759
10416
  additionalProperties: { type: "string" },
9760
10417
  writeOnly: true
9761
10418
  },
10419
+ // The request body TEMPLATE (the http executor reads it). Whether it
10420
+ // round-trips is the USER's call — see `body_secret` and the
10421
+ // conditional in `redactConfig`; listing it documents the shape, the
10422
+ // redactor decides.
10423
+ body: {
10424
+ description: "Request body template; top-level keys merge with LLM parameters"
10425
+ },
10426
+ // Salman (2026-08-18): "give checkbox to user" — the author declares
10427
+ // whether their body carries secrets. True (or ABSENT — every pre-flag
10428
+ // row, fail closed) masks body values like headers; an explicit false
10429
+ // round-trips the body in clear like `url`. The flag round-trips.
10430
+ body_secret: {
10431
+ type: "boolean",
10432
+ description: "Body values are write-only (mask like headers)"
10433
+ },
9762
10434
  parameters: {
9763
10435
  type: "object",
9764
- description: "Static/user-fixed parameters sent on every call"
10436
+ description: "Static/user-fixed parameters sent on every call",
10437
+ // The canonical home of a static api_key — secret by position.
10438
+ writeOnly: true
9765
10439
  },
9766
10440
  llmParameters: {
9767
10441
  type: "object",
@@ -9792,7 +10466,13 @@ var SCHEMAS = {
9792
10466
  default: "readonly"
9793
10467
  },
9794
10468
  max_rows: { type: "integer", default: 1e3 },
9795
- selected_tables: { type: "array", items: { type: "string" } }
10469
+ selected_tables: { type: "array", items: { type: "string" } },
10470
+ // Dialect connection identifiers the db executor reads — same
10471
+ // sensitivity class as `database`, listed so they round-trip instead
10472
+ // of dying to redactConfig's default-deny.
10473
+ service_name: { type: "string" },
10474
+ schema_name: { type: "string" },
10475
+ warehouse: { type: "string" }
9796
10476
  },
9797
10477
  required: ["engine", "database"]
9798
10478
  },
@@ -9824,11 +10504,245 @@ function configSchema(kind) {
9824
10504
  if (!schema) throw new Error(`unknown tool kind: ${JSON.stringify(kind)}`);
9825
10505
  return schema;
9826
10506
  }
10507
+ var REDACTED_SENTINEL = "__redacted__";
10508
+ var ConfigTemplateError = class extends Error {
10509
+ };
10510
+ function isPlainObject(value) {
10511
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10512
+ }
10513
+ function containsSentinel(value) {
10514
+ if (typeof value === "string") return value === REDACTED_SENTINEL;
10515
+ if (Array.isArray(value)) return value.some(containsSentinel);
10516
+ if (isPlainObject(value)) return Object.values(value).some(containsSentinel);
10517
+ return false;
10518
+ }
10519
+ function mergeRedacted(incoming, stored) {
10520
+ const resolve = (value, kept, keptPresent, path) => {
10521
+ if (isPlainObject(value)) {
10522
+ const keptDict = isPlainObject(kept) ? kept : {};
10523
+ const out2 = {};
10524
+ for (const [k, v] of Object.entries(value)) {
10525
+ out2[k] = resolve(v, keptDict[k], k in keptDict, path ? `${path}.${k}` : k);
10526
+ }
10527
+ return out2;
10528
+ }
10529
+ if (Array.isArray(value)) {
10530
+ const keptList = Array.isArray(kept) ? kept : [];
10531
+ return value.map((v, i) => resolve(v, keptList[i], i < keptList.length, `${path}[${i}]`));
10532
+ }
10533
+ if (value === REDACTED_SENTINEL) {
10534
+ if (!keptPresent) {
10535
+ throw new ConfigTemplateError(
10536
+ `config.${path} is ${JSON.stringify(REDACTED_SENTINEL)} but there is no stored value to keep \u2014 re-enter the secret or omit the field`
10537
+ );
10538
+ }
10539
+ return kept;
10540
+ }
10541
+ return value;
10542
+ };
10543
+ const keptRoot = stored ?? {};
10544
+ const out = {};
10545
+ for (const [k, v] of Object.entries(incoming)) {
10546
+ out[k] = resolve(v, keptRoot[k], k in keptRoot, k);
10547
+ }
10548
+ return out;
10549
+ }
10550
+
10551
+ // src/tools/crypto.ts
10552
+ init_crypto();
10553
+ var MAX_RESPONSE_BYTES = 1e6;
10554
+ var MAX_REDIRECTS = 5;
10555
+ function isRedirect(resp) {
10556
+ return resp.status >= 300 && resp.status < 400 || resp.type === "opaqueredirect";
10557
+ }
10558
+ var EgressDenied = class extends Error {
10559
+ constructor(message) {
10560
+ super(message);
10561
+ this.name = "EgressDenied";
10562
+ }
10563
+ };
10564
+ var resolver = {
10565
+ async lookup(host) {
10566
+ const answers = await dns.promises.lookup(host, { all: true, verbatim: true });
10567
+ return answers.map((a) => a.address);
10568
+ }
10569
+ };
10570
+ var V4_PRIVATE = [
10571
+ ["0.0.0.0", 8],
10572
+ // "this network" / unspecified
10573
+ ["10.0.0.0", 8],
10574
+ // RFC 1918
10575
+ ["100.64.0.0", 10],
10576
+ // RFC 6598 carrier-grade NAT — overlay VPNs, pod CIDRs
10577
+ ["127.0.0.0", 8],
10578
+ // loopback
10579
+ ["169.254.0.0", 16],
10580
+ // link-local, incl. the cloud metadata address
10581
+ ["172.16.0.0", 12],
10582
+ // RFC 1918
10583
+ ["192.168.0.0", 16],
10584
+ // RFC 1918
10585
+ ["224.0.0.0", 4],
10586
+ // multicast
10587
+ ["240.0.0.0", 4],
10588
+ // reserved
10589
+ ["255.255.255.255", 32]
10590
+ // broadcast (inside 240/4; spelled out anyway)
10591
+ ];
10592
+ var V6_PRIVATE = [
10593
+ ["::", 128],
10594
+ // unspecified
10595
+ ["::1", 128],
10596
+ // loopback
10597
+ ["::ffff:0:0", 96],
10598
+ // IPv4-mapped — the wrapped v4 is checked too
10599
+ ["64:ff9b::", 96],
10600
+ // NAT64 — the wrapped v4 is checked too
10601
+ ["2002::", 16],
10602
+ // 6to4 — the wrapped v4 is checked too, and this is denied
10603
+ ["fc00::", 7],
10604
+ // unique local
10605
+ ["fe80::", 10],
10606
+ // link-local
10607
+ ["fec0::", 10],
10608
+ // site-local (deprecated, still configured)
10609
+ ["ff00::", 8],
10610
+ // multicast
10611
+ ["3fff::", 20]
10612
+ // documentation
10613
+ ];
10614
+ function parseV4(text) {
10615
+ const parts = text.split(".");
10616
+ if (parts.length !== 4) return null;
10617
+ const out = new Uint8Array(4);
10618
+ for (let i = 0; i < 4; i++) {
10619
+ const part = parts[i];
10620
+ if (!/^\d{1,3}$/.test(part)) return null;
10621
+ const value = Number(part);
10622
+ if (value > 255) return null;
10623
+ out[i] = value;
10624
+ }
10625
+ return out;
10626
+ }
10627
+ function parseV6(text) {
10628
+ let body = text.split("%")[0] ?? "";
10629
+ const lastColon = body.lastIndexOf(":");
10630
+ if (lastColon < 0) return null;
10631
+ const tail = body.slice(lastColon + 1);
10632
+ if (tail.includes(".")) {
10633
+ const v4 = parseV4(tail);
10634
+ if (!v4) return null;
10635
+ const hi = (v4[0] << 8 | v4[1]).toString(16);
10636
+ const lo = (v4[2] << 8 | v4[3]).toString(16);
10637
+ body = `${body.slice(0, lastColon + 1)}${hi}:${lo}`;
10638
+ }
10639
+ const halves = body.split("::");
10640
+ if (halves.length > 2) return null;
10641
+ const head = halves[0] ? halves[0].split(":") : [];
10642
+ const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
10643
+ let groups;
10644
+ if (halves.length === 1) {
10645
+ if (head.length !== 8) return null;
10646
+ groups = head;
10647
+ } else {
10648
+ const missing = 8 - head.length - rest.length;
10649
+ if (missing < 0) return null;
10650
+ groups = [...head, ...Array(missing).fill("0"), ...rest];
10651
+ }
10652
+ const out = new Uint8Array(16);
10653
+ for (let i = 0; i < 8; i++) {
10654
+ const group = groups[i];
10655
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return null;
10656
+ const value = Number.parseInt(group, 16);
10657
+ out[2 * i] = value >> 8;
10658
+ out[2 * i + 1] = value & 255;
10659
+ }
10660
+ return out;
10661
+ }
10662
+ function parseAddress(text) {
10663
+ const family = net.isIP(text);
10664
+ if (family === 4) return parseV4(text);
10665
+ if (family === 6) return parseV6(text);
10666
+ return null;
10667
+ }
10668
+ function inNet(addr, net, prefix) {
10669
+ if (addr.length !== net.length) return false;
10670
+ const whole = prefix >> 3;
10671
+ for (let i = 0; i < whole; i++) if (addr[i] !== net[i]) return false;
10672
+ const bits = prefix & 7;
10673
+ if (bits === 0) return true;
10674
+ const mask = 255 << 8 - bits;
10675
+ return (addr[whole] & mask) === (net[whole] & mask);
10676
+ }
10677
+ function compile(table) {
10678
+ return table.map(([cidr, prefix]) => {
10679
+ const bytes = parseAddress(cidr);
10680
+ if (!bytes) throw new Error(`egress: unparseable network ${cidr}`);
10681
+ return [bytes, prefix];
10682
+ });
10683
+ }
10684
+ var V4_NETS = compile(V4_PRIVATE);
10685
+ var V6_NETS = compile(V6_PRIVATE);
10686
+ var MAPPED_V4 = compile([["::ffff:0:0", 96]])[0];
10687
+ var NAT64 = compile([["64:ff9b::", 96]])[0];
10688
+ var SIXTOFOUR = compile([["2002::", 16]])[0];
10689
+ function embeddedV4(bytes) {
10690
+ if (inNet(bytes, MAPPED_V4[0], MAPPED_V4[1]) || inNet(bytes, NAT64[0], NAT64[1])) {
10691
+ return bytes.subarray(12, 16);
10692
+ }
10693
+ if (inNet(bytes, SIXTOFOUR[0], SIXTOFOUR[1])) return bytes.subarray(2, 6);
10694
+ return null;
10695
+ }
10696
+ function addressIsPrivate(address) {
10697
+ const bytes = parseAddress(address);
10698
+ if (!bytes) return true;
10699
+ if (bytes.length === 4) return V4_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
10700
+ const embedded = embeddedV4(bytes);
10701
+ if (embedded && V4_NETS.some(([net, prefix]) => inNet(embedded, net, prefix))) return true;
10702
+ return V6_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
10703
+ }
10704
+ async function isPrivateAddress(host) {
10705
+ let name = (host ?? "").trim().toLowerCase().replace(/^\.+|\.+$/g, "");
10706
+ if (!name) return true;
10707
+ if (name === "localhost") return true;
10708
+ if (name.startsWith("[") && name.endsWith("]")) name = name.slice(1, -1);
10709
+ if (net.isIP(name)) return addressIsPrivate(name);
10710
+ let addresses;
10711
+ try {
10712
+ addresses = await resolver.lookup(name);
10713
+ } catch {
10714
+ return true;
10715
+ }
10716
+ if (!addresses.length) return true;
10717
+ return addresses.some((address) => addressIsPrivate(address.split("%")[0] ?? address));
10718
+ }
10719
+ async function assertEgressAllowed(url, opts) {
10720
+ let parsed;
10721
+ try {
10722
+ parsed = new URL(url ?? "");
10723
+ } catch {
10724
+ throw new EgressDenied(`egress denied: ${JSON.stringify(url)} is not a valid URL`);
10725
+ }
10726
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
10727
+ if (scheme !== "http" && scheme !== "https") {
10728
+ throw new EgressDenied(
10729
+ `egress denied: unsupported URL scheme ${JSON.stringify(scheme)} \u2014 http tools may only reach http/https`
10730
+ );
10731
+ }
10732
+ if (opts.allowPrivate) return;
10733
+ const host = parsed.hostname;
10734
+ if (await isPrivateAddress(host)) {
10735
+ throw new EgressDenied(
10736
+ `egress denied: ${host || "(no host)"} is a private, loopback, link-local or metadata address; set allowPrivateEgress to permit it`
10737
+ );
10738
+ }
10739
+ }
9827
10740
 
9828
10741
  // src/tools/governance.ts
9829
10742
  init_db();
9830
10743
 
9831
10744
  // src/tools/executors/http.ts
10745
+ init_errors();
9832
10746
  var TIMEOUT_MS5 = 3e4;
9833
10747
  var SENSITIVE_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
9834
10748
  "set-cookie",
@@ -9837,6 +10751,8 @@ var SENSITIVE_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
9837
10751
  "proxy-authenticate",
9838
10752
  "www-authenticate"
9839
10753
  ]);
10754
+ var REDIRECT_STATUS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
10755
+ var CROSS_ORIGIN_HEADERS = /* @__PURE__ */ new Set(["authorization", "proxy-authorization", "cookie"]);
9840
10756
  function percentEncode(value) {
9841
10757
  return encodeURIComponent(value).replace(
9842
10758
  /[!'()*]/g,
@@ -9867,10 +10783,38 @@ function withQuery(url, params) {
9867
10783
  if (!q) return url;
9868
10784
  return url.includes("?") ? `${url}&${q}` : `${url}?${q}`;
9869
10785
  }
10786
+ async function readCapped(resp) {
10787
+ if (!resp.body) return { text: await resp.text(), truncated: false };
10788
+ const reader = resp.body.getReader();
10789
+ const chunks = [];
10790
+ let size = 0;
10791
+ let truncated = false;
10792
+ for (; ; ) {
10793
+ const { done, value } = await reader.read();
10794
+ if (done) break;
10795
+ if (value) {
10796
+ chunks.push(Buffer.from(value));
10797
+ size += value.byteLength;
10798
+ }
10799
+ if (size > MAX_RESPONSE_BYTES) {
10800
+ truncated = true;
10801
+ await reader.cancel();
10802
+ break;
10803
+ }
10804
+ }
10805
+ const bytes = Buffer.concat(chunks);
10806
+ return {
10807
+ text: (truncated ? bytes.subarray(0, MAX_RESPONSE_BYTES) : bytes).toString("utf8"),
10808
+ truncated
10809
+ };
10810
+ }
9870
10811
  async function defaultRequest(init) {
9871
10812
  let url = init.url;
9872
10813
  if (init.params) url = withQuery(url, init.params);
9873
10814
  const headers = { ...init.headers ?? {} };
10815
+ if (!Object.keys(headers).some((k) => k.toLowerCase() === "accept-encoding")) {
10816
+ headers["Accept-Encoding"] = "identity";
10817
+ }
9874
10818
  let body;
9875
10819
  if (init.json !== void 0) {
9876
10820
  headers["Content-Type"] = headers["Content-Type"] ?? headers["content-type"] ?? "application/json";
@@ -9882,25 +10826,47 @@ async function defaultRequest(init) {
9882
10826
  method: init.method,
9883
10827
  headers,
9884
10828
  body,
9885
- redirect: "follow",
10829
+ // Redirects are followed by hand in `executeHttp` — an automatic follow
10830
+ // would jump to a destination no egress check ever saw.
10831
+ redirect: "manual",
9886
10832
  signal: AbortSignal.timeout(TIMEOUT_MS5)
9887
10833
  });
9888
10834
  const respHeaders = {};
9889
10835
  resp.headers.forEach((v, k) => {
9890
10836
  respHeaders[k] = v;
9891
10837
  });
9892
- const text = await resp.text();
10838
+ const { text, truncated } = await readCapped(resp);
9893
10839
  return {
9894
10840
  status: resp.status,
9895
10841
  headers: respHeaders,
10842
+ truncated,
9896
10843
  async json() {
9897
- return JSON.parse(text);
10844
+ return JSON.parse(text.replace(/^/, ""));
9898
10845
  },
9899
10846
  async text() {
9900
10847
  return text;
9901
10848
  }
9902
10849
  };
9903
10850
  }
10851
+ function nextHop(init, status, location) {
10852
+ const current = new URL(init.url);
10853
+ const target = new URL(location, current);
10854
+ let method = init.method;
10855
+ if ((status === 302 || status === 303) && method !== "HEAD") method = "GET";
10856
+ else if (status === 301 && method === "POST") method = "GET";
10857
+ let headers = { ...init.headers ?? {} };
10858
+ if (current.origin !== target.origin) {
10859
+ headers = Object.fromEntries(
10860
+ Object.entries(headers).filter(([k]) => !CROSS_ORIGIN_HEADERS.has(k.toLowerCase()))
10861
+ );
10862
+ }
10863
+ const hop = { method, url: target.toString(), headers };
10864
+ if (method === init.method) {
10865
+ if (init.json !== void 0) hop.json = init.json;
10866
+ if (init.content !== void 0) hop.content = init.content;
10867
+ }
10868
+ return hop;
10869
+ }
9904
10870
  async function executeHttp(config, args, opts = {}) {
9905
10871
  const method = String(config.method ?? "GET").toUpperCase();
9906
10872
  let url = config.url;
@@ -9921,7 +10887,7 @@ async function executeHttp(config, args, opts = {}) {
9921
10887
  }
9922
10888
  }
9923
10889
  }
9924
- const requestInit = {
10890
+ let requestInit = {
9925
10891
  method,
9926
10892
  url: url ?? "",
9927
10893
  headers
@@ -9957,29 +10923,64 @@ async function executeHttp(config, args, opts = {}) {
9957
10923
  }
9958
10924
  }
9959
10925
  const client = opts.client;
9960
- const resp = client ? await client.request(requestInit) : await defaultRequest(requestInit);
10926
+ const allowPrivate = opts.allowPrivate ?? false;
10927
+ let resp;
10928
+ let hops = 0;
10929
+ for (; ; ) {
10930
+ await assertEgressAllowed(requestInit.url, { allowPrivate });
10931
+ resp = client ? await client.request(requestInit) : await defaultRequest(requestInit);
10932
+ const location = resp.headers.location ?? resp.headers.Location;
10933
+ if (!REDIRECT_STATUS.has(resp.status) || !location) break;
10934
+ if (hops >= MAX_REDIRECTS) {
10935
+ throw new exports.EngineActionError(`too many redirects (more than ${MAX_REDIRECTS}) starting at ${url}`);
10936
+ }
10937
+ hops += 1;
10938
+ requestInit = nextHop(requestInit, resp.status, location);
10939
+ }
9961
10940
  const contentType = resp.headers["content-type"] ?? resp.headers["Content-Type"] ?? "";
9962
- let data;
10941
+ let text = "";
9963
10942
  try {
9964
- data = contentType.includes("application/json") ? await resp.json() : await resp.text();
10943
+ text = await resp.text();
9965
10944
  } catch {
9966
- data = await resp.text();
10945
+ text = "";
10946
+ }
10947
+ const bytes = Buffer.from(text, "utf8");
10948
+ const truncated = resp.truncated === true || bytes.byteLength > MAX_RESPONSE_BYTES;
10949
+ let data;
10950
+ if (truncated) {
10951
+ data = bytes.subarray(0, MAX_RESPONSE_BYTES).toString("utf8");
10952
+ } else {
10953
+ try {
10954
+ data = contentType.includes("application/json") ? await resp.json() : text;
10955
+ } catch {
10956
+ data = text;
10957
+ }
9967
10958
  }
9968
10959
  const safeHeaders = {};
9969
10960
  for (const [k, v] of Object.entries(resp.headers)) {
9970
10961
  if (!SENSITIVE_RESPONSE_HEADERS.has(k.toLowerCase())) safeHeaders[k] = v;
9971
10962
  }
9972
- return {
10963
+ const result = {
9973
10964
  status_code: resp.status,
9974
10965
  headers: safeHeaders,
9975
10966
  data
9976
10967
  };
10968
+ if (truncated) result.truncated = true;
10969
+ return result;
9977
10970
  }
9978
10971
 
9979
10972
  // src/version.ts
9980
10973
  var __version__ = "0.0.0";
9981
10974
 
9982
10975
  // src/tools/executors/mcp-client.ts
10976
+ async function checkEgress(url, allowPrivate) {
10977
+ let checked = url;
10978
+ const scheme = (/^([a-z0-9+.-]+):/i.exec(url ?? "")?.[1] ?? "").toLowerCase();
10979
+ if (scheme === "ws") checked = `http:${url.slice(scheme.length + 1)}`;
10980
+ else if (scheme === "wss") checked = `https:${url.slice(scheme.length + 1)}`;
10981
+ await assertEgressAllowed(checked, { allowPrivate });
10982
+ }
10983
+ var REDIRECT_MESSAGE = "MCP server redirected; register the final URL";
9983
10984
  var MCPError = class _MCPError extends Error {
9984
10985
  code;
9985
10986
  data;
@@ -10008,10 +11009,12 @@ function parseSseBuffer(raw) {
10008
11009
  var HttpTransport = class {
10009
11010
  kind = "http";
10010
11011
  url;
11012
+ allowPrivate;
10011
11013
  baseHeaders;
10012
11014
  sessionId = null;
10013
- constructor(url, headers) {
11015
+ constructor(url, headers, allowPrivate = false) {
10014
11016
  this.url = url;
11017
+ this.allowPrivate = allowPrivate;
10015
11018
  this.baseHeaders = {
10016
11019
  ...headers,
10017
11020
  "Content-Type": "application/json",
@@ -10019,6 +11022,7 @@ var HttpTransport = class {
10019
11022
  };
10020
11023
  }
10021
11024
  async connect() {
11025
+ await checkEgress(this.url, this.allowPrivate);
10022
11026
  }
10023
11027
  async close() {
10024
11028
  this.sessionId = null;
@@ -10032,10 +11036,17 @@ var HttpTransport = class {
10032
11036
  const resp = await fetch(this.url, {
10033
11037
  method: "POST",
10034
11038
  headers: this.buildHeaders(),
10035
- body: JSON.stringify(msg)
11039
+ body: JSON.stringify(msg),
11040
+ // `redirect: "manual"`: a followed redirect would carry this POST —
11041
+ // headers, Authorization and all — to a destination `connect()`'s
11042
+ // check never saw. A 3xx is reported, never chased.
11043
+ redirect: "manual"
10036
11044
  });
10037
11045
  const sid = resp.headers.get("Mcp-Session-Id");
10038
11046
  if (sid) this.sessionId = sid;
11047
+ if (isRedirect(resp)) {
11048
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
11049
+ }
10039
11050
  if (resp.status >= 400) {
10040
11051
  const body = await resp.text();
10041
11052
  console.error(`[HTTP Transport] ${resp.status} from ${this.url}: ${body.slice(0, 300)}`);
@@ -10068,11 +11079,20 @@ var SseTransport = class {
10068
11079
  eventsUrl;
10069
11080
  baseUrl;
10070
11081
  headers;
11082
+ allowPrivate;
11083
+ /** Where the server told us to POST — it arrives in an `endpoint` event on
11084
+ * the stream, so it is server-chosen and hence checked. Private: the only
11085
+ * way in is the parser, which is the path a real server takes. */
10071
11086
  postUrl = null;
11087
+ /** The last POST endpoint cleared by the egress check. The check costs a
11088
+ * DNS resolution and the endpoint changes at most once per connection, so
11089
+ * it is not repeated per message. */
11090
+ checkedPostUrl = null;
10072
11091
  queue = [];
10073
11092
  waiters = [];
10074
11093
  abort = null;
10075
- constructor(url, headers) {
11094
+ constructor(url, headers, allowPrivate = false) {
11095
+ this.allowPrivate = allowPrivate;
10076
11096
  this.eventsUrl = url.replace(/\/$/, "");
10077
11097
  if (url.endsWith("/events")) this.baseUrl = url.slice(0, -7);
10078
11098
  else if (url.endsWith("/sse")) this.baseUrl = url.slice(0, -4);
@@ -10085,14 +11105,19 @@ var SseTransport = class {
10085
11105
  else this.queue.push(msg);
10086
11106
  }
10087
11107
  async connect() {
11108
+ await checkEgress(this.eventsUrl, this.allowPrivate);
10088
11109
  this.abort = new AbortController();
10089
11110
  void this.listen();
10090
11111
  }
10091
11112
  async listen() {
10092
11113
  const resp = await fetch(this.eventsUrl, {
10093
11114
  headers: { ...this.headers, Accept: "text/event-stream" },
10094
- signal: this.abort?.signal
11115
+ signal: this.abort?.signal,
11116
+ redirect: "manual"
10095
11117
  });
11118
+ if (isRedirect(resp)) {
11119
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
11120
+ }
10096
11121
  if (!resp.ok) throw new Error(`SSE ${resp.status}`);
10097
11122
  if (!resp.body) throw new Error("SSE response has no body");
10098
11123
  const reader = resp.body.getReader();
@@ -10144,11 +11169,19 @@ var SseTransport = class {
10144
11169
  }
10145
11170
  }
10146
11171
  const postUrl = this.postUrl || this.baseUrl;
11172
+ if (postUrl !== this.checkedPostUrl) {
11173
+ await checkEgress(postUrl, this.allowPrivate);
11174
+ this.checkedPostUrl = postUrl;
11175
+ }
10147
11176
  const resp = await fetch(postUrl, {
10148
11177
  method: "POST",
10149
11178
  headers: { ...this.headers, "Content-Type": "application/json" },
10150
- body: JSON.stringify(msg)
11179
+ body: JSON.stringify(msg),
11180
+ redirect: "manual"
10151
11181
  });
11182
+ if (isRedirect(resp)) {
11183
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
11184
+ }
10152
11185
  if (!resp.ok) throw new Error(`SSE POST ${resp.status}`);
10153
11186
  try {
10154
11187
  const data = await resp.json();
@@ -10167,14 +11200,17 @@ var WsTransport = class {
10167
11200
  kind = "websocket";
10168
11201
  url;
10169
11202
  headers;
11203
+ allowPrivate;
10170
11204
  ws = null;
10171
11205
  queue = [];
10172
11206
  waiters = [];
10173
- constructor(url, headers) {
11207
+ constructor(url, headers, allowPrivate = false) {
10174
11208
  this.url = url;
11209
+ this.allowPrivate = allowPrivate;
10175
11210
  this.headers = headers;
10176
11211
  }
10177
11212
  async connect() {
11213
+ await checkEgress(this.url, this.allowPrivate);
10178
11214
  const WS = globalThis.WebSocket;
10179
11215
  if (!WS) throw new Error("WebSocket is not available in this runtime");
10180
11216
  this.ws = new WS(this.url);
@@ -10215,21 +11251,27 @@ var MCPClient = class _MCPClient {
10215
11251
  tools = [];
10216
11252
  resources = [];
10217
11253
  headers;
10218
- constructor(url, headers = null) {
11254
+ allowPrivate;
11255
+ constructor(url, headers = null, opts = {}) {
10219
11256
  this.url = url;
10220
11257
  this.headers = headers ?? {};
10221
- this.transport = _MCPClient.createTransport(url, this.headers);
11258
+ this.allowPrivate = opts.allowPrivate ?? false;
11259
+ this.transport = _MCPClient.createTransport(url, this.headers, this.allowPrivate);
10222
11260
  }
10223
- static createTransport(url, headers) {
11261
+ static createTransport(url, headers, allowPrivate = false) {
10224
11262
  if (url.startsWith("stdio:") || url === "stdio") {
10225
11263
  throw new Error("stdio MCP transport is not supported (cloud deployments use SSE/HTTP/WebSocket)");
10226
11264
  }
10227
- if (url.startsWith("ws://") || url.startsWith("wss://")) return new WsTransport(url, headers);
11265
+ if (url.startsWith("ws://") || url.startsWith("wss://")) {
11266
+ return new WsTransport(url, headers, allowPrivate);
11267
+ }
10228
11268
  if (url.startsWith("sse+http://") || url.startsWith("sse+https://") || url.endsWith("/events") || url.endsWith("/sse")) {
10229
11269
  const clean = url.replace("sse+http://", "http://").replace("sse+https://", "https://");
10230
- return new SseTransport(clean, headers);
11270
+ return new SseTransport(clean, headers, allowPrivate);
11271
+ }
11272
+ if (url.startsWith("http://") || url.startsWith("https://")) {
11273
+ return new HttpTransport(url, headers, allowPrivate);
10231
11274
  }
10232
- if (url.startsWith("http://") || url.startsWith("https://")) return new HttpTransport(url, headers);
10233
11275
  throw new Error(`Unsupported URL: ${url} (use http(s)://, ws(s)://, or .../sse)`);
10234
11276
  }
10235
11277
  nextId() {
@@ -10281,7 +11323,15 @@ var MCPClient = class _MCPClient {
10281
11323
  await this.transport.send({ jsonrpc: "2.0", method, params: params ?? {} });
10282
11324
  }
10283
11325
  async connect() {
10284
- await this.transport.connect();
11326
+ try {
11327
+ await this.transport.connect();
11328
+ } catch (e) {
11329
+ try {
11330
+ await this.transport.close();
11331
+ } catch {
11332
+ }
11333
+ throw e;
11334
+ }
10285
11335
  if (!(this.transport instanceof HttpTransport)) {
10286
11336
  this.connected = true;
10287
11337
  void this.recvLoop();
@@ -10364,7 +11414,7 @@ var MCPClient = class _MCPClient {
10364
11414
  }
10365
11415
  this.id = 0;
10366
11416
  this.pending.clear();
10367
- this.transport = _MCPClient.createTransport(url, headers);
11417
+ this.transport = _MCPClient.createTransport(url, headers, this.allowPrivate);
10368
11418
  await this.connect();
10369
11419
  await this.listTools();
10370
11420
  }
@@ -10372,11 +11422,11 @@ var MCPClient = class _MCPClient {
10372
11422
  var MCPRegistry = class {
10373
11423
  clients = /* @__PURE__ */ new Map();
10374
11424
  healthTask = null;
10375
- async connect(name, url, headers = null, token = null) {
11425
+ async connect(name, url, headers = null, token = null, allowPrivate = false) {
10376
11426
  this.clients.delete(name);
10377
11427
  const hdrs = { ...headers ?? {} };
10378
11428
  if (token) hdrs.Authorization = hdrs.Authorization ?? `Bearer ${token}`;
10379
- const client = new MCPClient(url, hdrs);
11429
+ const client = new MCPClient(url, hdrs, { allowPrivate });
10380
11430
  await client.connect();
10381
11431
  await client.listTools();
10382
11432
  this.clients.set(name, client);
@@ -10428,8 +11478,18 @@ var MCPRegistry = class {
10428
11478
  };
10429
11479
  var PromptevMCP = class {
10430
11480
  clients = new MCPRegistry();
11481
+ allowPrivate;
11482
+ /**
11483
+ * `allowPrivate` is the operator knob `allowPrivateEgress`, carried down to
11484
+ * every transport this facade builds. It defaults to `false`, so a
11485
+ * construction site that forgets to pass it denies private destinations
11486
+ * rather than permitting them.
11487
+ */
11488
+ constructor(opts = {}) {
11489
+ this.allowPrivate = opts.allowPrivate ?? false;
11490
+ }
10431
11491
  async addServer(name, url, opts = {}) {
10432
- await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null);
11492
+ await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null, this.allowPrivate);
10433
11493
  }
10434
11494
  async shutdown() {
10435
11495
  await this.clients.shutdown();
@@ -10563,8 +11623,13 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
10563
11623
  "requires_approval",
10564
11624
  "approvalPolicy",
10565
11625
  "approval_policy",
10566
- "enabled"
11626
+ "enabled",
11627
+ "metaData",
11628
+ "meta_data"
10567
11629
  ]);
11630
+ function allowPrivateEgress(engine) {
11631
+ return Boolean(engine?.config?.allowPrivateEgress);
11632
+ }
10568
11633
  function toolVisible(ct, principals) {
10569
11634
  return aclVisible(ct.acl, principals);
10570
11635
  }
@@ -10646,11 +11711,19 @@ function redactToolResult(result, policy, opts) {
10646
11711
  if (failed.length) note.rules_failed = failed;
10647
11712
  return [redacted, note];
10648
11713
  }
10649
- function rowToToolConfig(row, engine) {
10650
- let config = {};
10651
- if (row.config_encrypted) {
10652
- config = decryptDict(String(row.config_encrypted), getSecretKey(engine.config));
11714
+ function cryptoKey(engine) {
11715
+ const key = getSecretKey(engine.config);
11716
+ if (key.length !== 32) {
11717
+ throw new Error(`CE_SECRET_KEY is malformed: decoded to ${key.length} bytes, need 32`);
10653
11718
  }
11719
+ return key;
11720
+ }
11721
+ function decryptRowConfig(row, engine) {
11722
+ if (!row.config_encrypted) return {};
11723
+ return decryptDict(String(row.config_encrypted), cryptoKey(engine));
11724
+ }
11725
+ function rowToToolConfig(row, engine, config) {
11726
+ config ??= decryptRowConfig(row, engine);
10654
11727
  return new ToolConfig({
10655
11728
  id: String(row.id),
10656
11729
  name: String(row.name),
@@ -10661,9 +11734,24 @@ function rowToToolConfig(row, engine) {
10661
11734
  acl: row.acl != null ? [...row.acl] : null,
10662
11735
  requiresApproval: Boolean(row.requires_approval),
10663
11736
  approvalPolicy: row.approval_policy ?? {},
10664
- enabled: Boolean(row.enabled)
11737
+ enabled: Boolean(row.enabled),
11738
+ metaData: row.meta_data ?? {}
10665
11739
  });
10666
11740
  }
11741
+ function rowToToolConfigLenient(row, engine) {
11742
+ if (!row.config_encrypted) return rowToToolConfig(row, engine, {});
11743
+ const key = cryptoKey(engine);
11744
+ let config;
11745
+ try {
11746
+ config = decryptDict(String(row.config_encrypted), key);
11747
+ } catch {
11748
+ console.warn(
11749
+ `tool ${String(row.id)} (${String(row.name)}): config_error \u2014 its stored config could not be decrypted (was the secret key rotated?); leaving it out of the tool set`
11750
+ );
11751
+ return null;
11752
+ }
11753
+ return rowToToolConfig(row, engine, config);
11754
+ }
10667
11755
  async function loadPersistedCanonicals(engine, sourceId) {
10668
11756
  const params = [];
10669
11757
  let sql = `SELECT * FROM context_engine_tools WHERE enabled IS TRUE`;
@@ -10674,7 +11762,9 @@ async function loadPersistedCanonicals(engine, sourceId) {
10674
11762
  const result = await engine.pool.query(sql, params);
10675
11763
  const canonicals = [];
10676
11764
  for (const row of result.rows) {
10677
- canonicals.push(...canonicalFromConfig(rowToToolConfig(row, engine)));
11765
+ const tc = rowToToolConfigLenient(row, engine);
11766
+ if (tc === null) continue;
11767
+ canonicals.push(...canonicalFromConfig(tc));
10678
11768
  }
10679
11769
  return canonicals;
10680
11770
  }
@@ -10694,13 +11784,18 @@ function canonicalToPublic(ct) {
10694
11784
  async function registerTool(engine, tc) {
10695
11785
  canonicalFromConfig(tc);
10696
11786
  const config = tc.config ?? {};
10697
- const configEncrypted = Object.keys(config).length ? encryptDict(config, getSecretKey(engine.config)) : null;
11787
+ if (containsSentinel(config)) {
11788
+ throw new ConfigTemplateError(
11789
+ `config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a redacted template cannot be registered as a new tool; re-enter the secret values`
11790
+ );
11791
+ }
11792
+ const configEncrypted = Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null;
10698
11793
  const id = crypto.randomUUID();
10699
11794
  await engine.pool.query(
10700
11795
  `INSERT INTO context_engine_tools
10701
11796
  (id, name, kind, description, source_id, acl, config_encrypted,
10702
- requires_approval, approval_policy, enabled)
10703
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
11797
+ requires_approval, approval_policy, enabled, meta_data)
11798
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11::jsonb)`,
10704
11799
  [
10705
11800
  id,
10706
11801
  tc.name,
@@ -10711,7 +11806,8 @@ async function registerTool(engine, tc) {
10711
11806
  configEncrypted,
10712
11807
  tc.requiresApproval,
10713
11808
  JSON.stringify(tc.approvalPolicy ?? {}),
10714
- tc.enabled
11809
+ tc.enabled,
11810
+ JSON.stringify(tc.metaData ?? {})
10715
11811
  ]
10716
11812
  );
10717
11813
  return id;
@@ -10728,12 +11824,41 @@ async function updateTool(engine, id, opts) {
10728
11824
  let i = 1;
10729
11825
  for (const [key, value] of Object.entries(fields)) {
10730
11826
  if (key === "config") {
11827
+ let config = value;
11828
+ if (config && containsSentinel(config)) {
11829
+ if (Object.hasOwn(fields, "kind") && fields.kind !== row.kind) {
11830
+ throw new ConfigTemplateError(
11831
+ "cannot change kind and keep redacted secrets in one PATCH \u2014 re-enter the config in full"
11832
+ );
11833
+ }
11834
+ if (config.body_secret === false && containsSentinel(config.body)) {
11835
+ throw new ConfigTemplateError(
11836
+ `body_secret cannot be turned off while the body still contains ${JSON.stringify(REDACTED_SENTINEL)} \u2014 re-enter the body to declassify it`
11837
+ );
11838
+ }
11839
+ if (!row.config_encrypted) {
11840
+ throw new ConfigTemplateError(
11841
+ `config contains ${JSON.stringify(REDACTED_SENTINEL)} but this tool has no stored config to keep \u2014 re-enter the secret values`
11842
+ );
11843
+ }
11844
+ const key2 = cryptoKey(engine);
11845
+ let stored;
11846
+ try {
11847
+ stored = decryptDict(String(row.config_encrypted), key2);
11848
+ } catch (exc) {
11849
+ throw new ConfigTemplateError(
11850
+ `the stored config cannot be decrypted (was the secret key rotated?) \u2014 re-enter the config in full, without ${JSON.stringify(REDACTED_SENTINEL)} values`,
11851
+ { cause: exc }
11852
+ );
11853
+ }
11854
+ config = mergeRedacted(config, stored);
11855
+ }
10731
11856
  sets.push(`config_encrypted = $${i++}`);
10732
- params.push(value ? encryptDict(value, getSecretKey(engine.config)) : null);
11857
+ params.push(config && Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null);
10733
11858
  } else if (UPDATABLE_COLUMNS.has(key)) {
10734
- const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key;
11859
+ const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key === "metaData" ? "meta_data" : key;
10735
11860
  sets.push(`${col} = $${i++}`);
10736
- params.push(col === "approval_policy" ? JSON.stringify(value ?? {}) : value);
11861
+ params.push(col === "approval_policy" || col === "meta_data" ? JSON.stringify(value ?? {}) : value);
10737
11862
  }
10738
11863
  }
10739
11864
  sets.push(`updated_at = now()`);
@@ -10742,7 +11867,8 @@ async function updateTool(engine, id, opts) {
10742
11867
  `UPDATE context_engine_tools SET ${sets.join(", ")} WHERE id = $${i} RETURNING *`,
10743
11868
  params
10744
11869
  );
10745
- return rowToToolConfig(updated.rows[0], engine);
11870
+ const row_ = updated.rows[0];
11871
+ return rowToToolConfigLenient(row_, engine) ?? rowToToolConfig(row_, engine, {});
10746
11872
  }
10747
11873
  async function deleteTool(engine, id, opts = {}) {
10748
11874
  const existing = await engine.pool.query(`SELECT * FROM context_engine_tools WHERE id = $1`, [id]);
@@ -10828,17 +11954,38 @@ function probeFailure(exc, kind) {
10828
11954
  console.warn(`test_tool(kind=${kind}) failed:`, exc, "->", category);
10829
11955
  return { ok: false, error: category };
10830
11956
  }
10831
- async function testTool(_engine, tc) {
11957
+ async function testTool(engine, tc) {
10832
11958
  const kind = tc.kind;
10833
11959
  const config = tc.config ?? {};
11960
+ if (containsSentinel(config)) {
11961
+ throw new ConfigTemplateError(
11962
+ `config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a probe would send it verbatim as the credential; re-enter the secret values`
11963
+ );
11964
+ }
10834
11965
  if (kind === "http") {
10835
11966
  const url = config.url;
10836
11967
  if (!url) return { ok: false, error: "http config missing 'url'" };
10837
11968
  try {
10838
- const resp = await fetch(url, {
11969
+ await assertEgressAllowed(url, { allowPrivate: allowPrivateEgress(engine) });
11970
+ } catch (exc) {
11971
+ if (exc instanceof EgressDenied) {
11972
+ console.warn(`testTool(kind=http) refused: ${exc.message}`);
11973
+ return { ok: false, error: "egress_denied" };
11974
+ }
11975
+ throw exc;
11976
+ }
11977
+ const client = engine?._toolHttpClient ?? null;
11978
+ try {
11979
+ const resp = client ? await client.request({
11980
+ method: "HEAD",
11981
+ url,
11982
+ headers: config.headers ?? {}
11983
+ }) : await fetch(url, {
10839
11984
  method: "HEAD",
10840
11985
  headers: config.headers ?? {},
10841
- redirect: "follow",
11986
+ // No automatic follow: a redirect would reach a destination the
11987
+ // check above never saw.
11988
+ redirect: "manual",
10842
11989
  signal: AbortSignal.timeout(1e4)
10843
11990
  });
10844
11991
  return { ok: resp.status < 500, status_code: resp.status };
@@ -10859,12 +12006,16 @@ async function testTool(_engine, tc) {
10859
12006
  const url = config.url;
10860
12007
  if (!url) return { ok: false, error: "mcp config missing 'url'" };
10861
12008
  const token = config.oauth_token ?? config.bearer ?? config.access_token;
10862
- const mcp = new PromptevMCP();
12009
+ const mcp = new PromptevMCP({ allowPrivate: allowPrivateEgress(engine) });
10863
12010
  try {
10864
12011
  await mcp.addServer(tc.name, url, { token: token ?? null });
10865
12012
  const client = mcp.clients.get(tc.name);
10866
12013
  return { ok: true, tools: client.tools.map((t) => t.name) };
10867
12014
  } catch (exc) {
12015
+ if (exc instanceof EgressDenied) {
12016
+ console.warn(`testTool(kind=mcp) refused: ${exc.message}`);
12017
+ return { ok: false, error: "egress_denied" };
12018
+ }
10868
12019
  return probeFailure(exc, "mcp");
10869
12020
  } finally {
10870
12021
  await mcp.shutdown();
@@ -10872,11 +12023,20 @@ async function testTool(_engine, tc) {
10872
12023
  }
10873
12024
  return { ok: false, error: `test_tool does not support kind=${JSON.stringify(kind)}` };
10874
12025
  }
10875
- async function findAndClaimApproved(engine, toolName, args, sourceId) {
12026
+ async function findAndClaimApproved(engine, toolName, args, sourceId, principals, approvalScope) {
10876
12027
  const claimedAt = /* @__PURE__ */ new Date();
12028
+ const params = [toolName];
12029
+ let scopeSql2 = "approval_scope IS NULL";
12030
+ if (approvalScope !== null) {
12031
+ params.push(approvalScope);
12032
+ scopeSql2 = `approval_scope = $${params.length}`;
12033
+ }
12034
+ const wall = claimVisibilitySql(principals, params.length + 1);
12035
+ params.push(...wall.params);
10877
12036
  const candidates = await engine.pool.query(
10878
- `SELECT * FROM context_engine_tool_approvals WHERE tool_name = $1 AND status = 'approved'`,
10879
- [toolName]
12037
+ `SELECT * FROM context_engine_tool_approvals
12038
+ WHERE tool_name = $1 AND status = 'approved' AND ${scopeSql2} AND ${wall.sql}`,
12039
+ params
10880
12040
  );
10881
12041
  for (const row of candidates.rows) {
10882
12042
  if ((row.source_id ?? null) !== (sourceId ?? null)) continue;
@@ -10893,7 +12053,7 @@ async function findAndClaimApproved(engine, toolName, args, sourceId) {
10893
12053
  }
10894
12054
  return null;
10895
12055
  }
10896
- async function dispatchMcp(ct, config, args) {
12056
+ async function dispatchMcp(ct, config, args, opts = {}) {
10897
12057
  const url = config.url;
10898
12058
  if (!url) throw new exports.EngineActionError(`mcp tool ${ct.callName} config missing 'url'`);
10899
12059
  const headers = { ...config.headers ?? {} };
@@ -10902,7 +12062,7 @@ async function dispatchMcp(ct, config, args) {
10902
12062
  if (token) headers.Authorization = `Bearer ${token}`;
10903
12063
  else if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
10904
12064
  const toolName = String(ct.raw?.tool_name ?? ct.displayName);
10905
- const mcp = new PromptevMCP();
12065
+ const mcp = new PromptevMCP({ allowPrivate: opts.allowPrivate ?? false });
10906
12066
  try {
10907
12067
  await mcp.addServer(ct.callName, url, { headers: Object.keys(headers).length ? headers : null });
10908
12068
  const client = mcp.clients.get(ct.callName);
@@ -10913,7 +12073,10 @@ async function dispatchMcp(ct, config, args) {
10913
12073
  }
10914
12074
  async function dispatch(engine, ct, config, args) {
10915
12075
  if (ct.kind === "http") {
10916
- return executeHttp(config, args, { client: engine._toolHttpClient ?? null });
12076
+ return executeHttp(config, args, {
12077
+ client: engine._toolHttpClient ?? null,
12078
+ allowPrivate: allowPrivateEgress(engine)
12079
+ });
10917
12080
  }
10918
12081
  if (ct.kind === "db") {
10919
12082
  const query = String(args.query ?? "");
@@ -10928,7 +12091,9 @@ async function dispatch(engine, ct, config, args) {
10928
12091
  }
10929
12092
  return result;
10930
12093
  }
10931
- if (ct.kind === "mcp") return dispatchMcp(ct, config, args);
12094
+ if (ct.kind === "mcp") {
12095
+ return dispatchMcp(ct, config, args, { allowPrivate: allowPrivateEgress(engine) });
12096
+ }
10932
12097
  if (ct.kind === "function") {
10933
12098
  const fn = ct.raw?.callable;
10934
12099
  if (!fn) throw new exports.EngineActionError(`function tool ${ct.callName} has no callable`);
@@ -10949,10 +12114,20 @@ async function decryptCtConfig(engine, toolId) {
10949
12114
  ]);
10950
12115
  const row = result.rows[0];
10951
12116
  if (!row?.config_encrypted) return {};
10952
- return decryptDict(String(row.config_encrypted), getSecretKey(engine.config));
12117
+ return decryptDict(String(row.config_encrypted), cryptoKey(engine));
12118
+ }
12119
+ var warnedUnscoped = /* @__PURE__ */ new Set();
12120
+ function warnUnscopedApproval(callName) {
12121
+ if (warnedUnscoped.has(callName)) return;
12122
+ warnedUnscoped.add(callName);
12123
+ process.emitWarning(
12124
+ `executeTool(${JSON.stringify(callName)}) needs an approval but was called without approvalScope. The record is opened and matched UNSCOPED (tool + args + sourceId, behind the principals wall). Pass approvalScope: <opaque string> (e.g. a run id) so approvals are claimable only within that scope; a gated call without one will be refused in the next release.`,
12125
+ { type: "DeprecationWarning", code: "CE_APPROVAL_SCOPE_MISSING" }
12126
+ );
10953
12127
  }
10954
12128
  async function executeTool(engine, callName, args, opts = {}) {
10955
12129
  const runtimeArgs = args ?? {};
12130
+ const approvalScope = validateApprovalScope(opts.approvalScope);
10956
12131
  const ct = findTool(await mergedTools(engine, opts.sourceId ?? null), callName);
10957
12132
  if (!ct) throw new exports.EngineActionError(`tool not found: ${callName}`);
10958
12133
  if (!toolVisible(ct, opts.principals ?? null)) {
@@ -10961,22 +12136,32 @@ async function executeTool(engine, callName, args, opts = {}) {
10961
12136
  const publicArgs = stripUnderscoreArgs(runtimeArgs);
10962
12137
  let approvalId = null;
10963
12138
  if (shouldRequireApproval(ct, publicArgs)) {
10964
- approvalId = await findAndClaimApproved(engine, ct.callName, publicArgs, opts.sourceId ?? null);
12139
+ if (approvalScope === null) warnUnscopedApproval(callName);
12140
+ approvalId = await findAndClaimApproved(
12141
+ engine,
12142
+ ct.callName,
12143
+ publicArgs,
12144
+ opts.sourceId ?? null,
12145
+ opts.principals ?? null,
12146
+ approvalScope
12147
+ );
10965
12148
  if (approvalId == null) {
10966
- const record = await createPending(engine, {
12149
+ const pendingOpts = {
10967
12150
  toolName: ct.callName,
10968
12151
  args: publicArgs,
10969
12152
  sourceId: opts.sourceId ?? null,
10970
12153
  principals: opts.principals ?? null,
10971
12154
  policy: ct.approvalPolicy ?? {}
10972
- });
12155
+ };
12156
+ const record = approvalScope === null ? await createPending(engine, pendingOpts) : await findOrCreatePending(engine, { ...pendingOpts, approvalScope });
10973
12157
  const reason = ct.requiresApproval ? "tool requires approval" : `approval policy condition met: ${ct.approvalPolicy?.condition}`;
10974
12158
  return {
10975
12159
  approval_required: {
10976
12160
  approval_id: String(record.id),
10977
12161
  tool_name: ct.callName,
10978
12162
  args: publicArgs,
10979
- reason
12163
+ reason,
12164
+ expires_at: record.expiresAt ? record.expiresAt.toISOString() : null
10980
12165
  }
10981
12166
  };
10982
12167
  }
@@ -11001,8 +12186,9 @@ async function executeTool(engine, callName, args, opts = {}) {
11001
12186
  let truncated = false;
11002
12187
  if (success) {
11003
12188
  try {
12189
+ const redactPrincipals = opts.principals === TRUSTED ? null : opts.principals ?? null;
11004
12190
  const [redacted] = redactToolResult(rawResult, engine.config.redaction, {
11005
- principals: opts.principals ?? null,
12191
+ principals: redactPrincipals,
11006
12192
  secretKey: engine.config.secretKey,
11007
12193
  hooks: engine.hooks
11008
12194
  });
@@ -11101,11 +12287,19 @@ var ContextEngine = class {
11101
12287
  _graphStore = null;
11102
12288
  constructor(config, opts = {}) {
11103
12289
  this.config = config;
11104
- this.hooks = { onUsage: opts.onUsage ?? null, onError: opts.onError ?? null };
12290
+ this.hooks = {
12291
+ onUsage: opts.onUsage ?? null,
12292
+ onError: opts.onError ?? null,
12293
+ onProgress: opts.onProgress ?? null
12294
+ };
11105
12295
  }
11106
12296
  async ensurePool() {
11107
12297
  if (!this._pool) {
11108
- this._pool = await createPool(this.config.databaseUrl);
12298
+ this._pool = await createPool(this.config.databaseUrl, {
12299
+ max: this.config.storage.poolMax,
12300
+ idleTimeoutMillis: this.config.storage.poolIdleTimeoutMs,
12301
+ connectionTimeoutMillis: this.config.storage.poolConnectionTimeoutMs
12302
+ });
11109
12303
  this.pool = this._pool;
11110
12304
  }
11111
12305
  if (!this.backend) {
@@ -11291,7 +12485,9 @@ var ContextEngine = class {
11291
12485
  graphStore: await this.getGraphStore(),
11292
12486
  hooks: this.hooks,
11293
12487
  sourceIds: opts.sourceIds ?? null,
11294
- principals
12488
+ documentIds: opts.documentIds ?? null,
12489
+ principals,
12490
+ backend: this.backend
11295
12491
  });
11296
12492
  }
11297
12493
  return runSearch(query, {
@@ -11301,6 +12497,7 @@ var ContextEngine = class {
11301
12497
  embedder: this.embedder,
11302
12498
  pool,
11303
12499
  sourceIds: opts.sourceIds,
12500
+ documentIds: opts.documentIds,
11304
12501
  principals,
11305
12502
  topK: opts.topK,
11306
12503
  mode: opts.mode,
@@ -11406,7 +12603,7 @@ var ContextEngine = class {
11406
12603
  hooks: this.hooks,
11407
12604
  sourceIds: opts.sourceIds,
11408
12605
  principals: resolvePrincipals(opts.principals, "compute"),
11409
- docIds: opts.docIds,
12606
+ documentIds: opts.documentIds,
11410
12607
  modelCfg: opts.modelCfg,
11411
12608
  timeout: opts.timeout
11412
12609
  });
@@ -11459,7 +12656,8 @@ var ContextEngine = class {
11459
12656
  sourceId: opts.sourceId,
11460
12657
  principals: resolvePrincipals(opts.principals, "executeTool"),
11461
12658
  actor: opts.actor,
11462
- source: opts.source ?? "api"
12659
+ source: opts.source ?? "api",
12660
+ approvalScope: opts.approvalScope
11463
12661
  });
11464
12662
  }
11465
12663
  };
@@ -11472,18 +12670,83 @@ init_hooks();
11472
12670
  // src/mcp.ts
11473
12671
  init_errors();
11474
12672
  init_extras();
11475
-
11476
- // src/tools/mcp-tools.ts
12673
+ var HandlerError = class extends Error {
12674
+ status;
12675
+ detail;
12676
+ constructor(status, detail) {
12677
+ super(typeof detail === "string" ? detail : JSON.stringify(detail));
12678
+ this.name = "HandlerError";
12679
+ this.status = status;
12680
+ this.detail = detail;
12681
+ }
12682
+ };
12683
+ zod.z.object({
12684
+ text: zod.z.string(),
12685
+ name: zod.z.string(),
12686
+ source_id: zod.z.string().nullable().optional(),
12687
+ external_id: zod.z.string().nullable().optional(),
12688
+ description: zod.z.string().nullable().optional(),
12689
+ meta_data: zod.z.record(zod.z.unknown()).nullable().optional(),
12690
+ acl: zod.z.array(zod.z.string()).nullable().optional(),
12691
+ mode: zod.z.enum(["hybrid", "graph"]).nullable().optional(),
12692
+ extract_structured: zod.z.boolean().optional().default(false),
12693
+ batch: zod.z.boolean().optional().default(false)
12694
+ }).strip();
12695
+ zod.z.object({
12696
+ acl: zod.z.array(zod.z.string()).nullable().optional(),
12697
+ name: zod.z.string().nullable().optional(),
12698
+ description: zod.z.string().nullable().optional(),
12699
+ meta_data: zod.z.record(zod.z.unknown()).nullable().optional()
12700
+ }).strict();
12701
+ zod.z.object({
12702
+ query: zod.z.string(),
12703
+ source_ids: zod.z.array(zod.z.string()).nullable().optional(),
12704
+ // Narrows WITHIN a source and INTERSECTS with source_ids — it can only
12705
+ // shrink the result set (the ACL predicate still applies in the same SQL
12706
+ // conjunction), so exposing it needs no authorizeAcl-style grant check.
12707
+ document_ids: zod.z.array(zod.z.string()).nullable().optional(),
12708
+ top_k: zod.z.number().int().optional().default(10),
12709
+ mode: zod.z.enum(["hybrid", "graph"]).optional().default("hybrid"),
12710
+ compress_to_tokens: zod.z.number().int().nullable().optional()
12711
+ }).strip();
12712
+ var UUID_RE5 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12713
+ function requireValidUuid(documentId) {
12714
+ if (!UUID_RE5.test(String(documentId))) {
12715
+ throw new HandlerError(400, `invalid document id: ${JSON.stringify(documentId)}`);
12716
+ }
12717
+ }
12718
+ function resolveRequestPrincipals(value, opts) {
12719
+ if (value === TRUSTED) return TRUSTED;
12720
+ if (value == null) {
12721
+ throw new HandlerError(
12722
+ 500,
12723
+ `${opts.surface}: the \`principals\` dependency returned null, which means TRUSTED CALLER \u2014 it disables ACL filtering and skips the check that stops a caller filing documents under groups it does not hold. This mount is misconfigured: return [] for an unauthenticated caller, or pass TRUSTED explicitly (from @promptev/context-engine) if the surface really is trusted.`
12724
+ );
12725
+ }
12726
+ if (!Array.isArray(value) || value.some((p) => typeof p !== "string")) {
12727
+ throw new HandlerError(
12728
+ 500,
12729
+ `${opts.surface}: the \`principals\` dependency must return a list of principal strings, [] for an anonymous caller, or TRUSTED \u2014 got ${typeof value}.`
12730
+ );
12731
+ }
12732
+ return value;
12733
+ }
11477
12734
  var SCOPE_NOTE = " Results are scoped to the caller's permissions (resolved by the server's injected principals dependency \u2014 never a caller-supplied argument) and to the given source_ids, if any.";
11478
12735
  async function resolvePrincipalsFn(principals) {
11479
12736
  const result = await Promise.resolve(principals());
11480
- return result;
12737
+ try {
12738
+ return resolveRequestPrincipals(result, { surface: "the MCP `principals` dependency" });
12739
+ } catch (exc) {
12740
+ if (exc instanceof HandlerError) throw new Error(exc.message, { cause: exc });
12741
+ throw exc;
12742
+ }
11481
12743
  }
11482
- function registerToolGateway(mcp, engine, principals) {
12744
+ function registerToolGateway(mcp, engine, principals, approvalScope = null) {
11483
12745
  mcp.tool(
11484
12746
  "search_tools",
11485
12747
  "Keyword search over the tools this deployment has registered (http/db/mcp/function) \u2014 the ACL-visible name, kind, description, and JSON Schema params of each match, so a caller can discover what it can then invoke with `execute_tool`." + SCOPE_NOTE,
11486
- { query: { type: "string" }, limit: { type: "number" } },
12748
+ // Zod raw shape see the note on the search tool in mcp.ts.
12749
+ { query: zod.z.string(), limit: zod.z.number().int().optional() },
11487
12750
  async (...raw) => {
11488
12751
  const args = raw[0] ?? {};
11489
12752
  const callerPrincipals = await resolvePrincipalsFn(principals);
@@ -11497,13 +12760,15 @@ function registerToolGateway(mcp, engine, principals) {
11497
12760
  mcp.tool(
11498
12761
  "execute_tool",
11499
12762
  "Governed execution of a tool previously discovered via `search_tools` (ACL check, approval gate, audit). Returns `{result, usage}` on success, or an `approval_required` payload when the call needs a human approval that isn't already granted \u2014 the caller must not retry until that approval resolves." + SCOPE_NOTE,
11500
- { name: { type: "string" }, args: { type: "object" } },
12763
+ { name: zod.z.string(), args: zod.z.record(zod.z.unknown()).optional() },
11501
12764
  async (...raw) => {
11502
12765
  const body = raw[0] ?? {};
11503
12766
  const callerPrincipals = await resolvePrincipalsFn(principals);
12767
+ const scope = approvalScope ? await Promise.resolve(approvalScope()) : null;
11504
12768
  return engine.executeTool(String(body.name), body.args ?? null, {
11505
12769
  principals: callerPrincipals,
11506
- source: "mcp"
12770
+ source: "mcp",
12771
+ approvalScope: scope
11507
12772
  });
11508
12773
  }
11509
12774
  );
@@ -11545,15 +12810,24 @@ async function createMcpApp(engine, opts) {
11545
12810
  "search_knowledge_base",
11546
12811
  `Hybrid search (full-text + trigram + vector, RRF-fused) over the ingested corpus. Returns the top-matching chunks with their source document.${SCOPE_NOTE}`,
11547
12812
  {
11548
- query: { type: "string" },
11549
- source_ids: { type: "array", items: { type: "string" } },
11550
- top_k: { type: "number" },
11551
- mode: { type: "string", enum: ["hybrid", "graph"] }
12813
+ // Zod RAW SHAPES, not JSON schema: the SDK's isZodRawShape test
12814
+ // rejects a plain schema object on current SDK versions that made
12815
+ // registration THROW at startup, and on 1.12.0 the object was consumed
12816
+ // as annotations and every handler ran with NO arguments.
12817
+ query: zod.z.string(),
12818
+ source_ids: zod.z.array(zod.z.string()).optional(),
12819
+ document_ids: zod.z.array(zod.z.string()).optional(),
12820
+ top_k: zod.z.number().int().optional(),
12821
+ mode: zod.z.enum(["hybrid", "graph"]).optional()
11552
12822
  },
11553
12823
  async (args) => {
12824
+ for (const did of args.document_ids ?? []) {
12825
+ requireValidUuid(did);
12826
+ }
11554
12827
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
11555
12828
  const result = await engine.search(String(args.query ?? ""), {
11556
12829
  sourceIds: args.source_ids,
12830
+ documentIds: args.document_ids,
11557
12831
  principals: callerPrincipals,
11558
12832
  topK: args.top_k ?? 10,
11559
12833
  mode: args.mode ?? "hybrid"
@@ -11575,7 +12849,7 @@ async function createMcpApp(engine, opts) {
11575
12849
  tool(
11576
12850
  "get_document",
11577
12851
  `Fetch one document's full text and metadata by id.${SCOPE_NOTE}`,
11578
- { document_id: { type: "string" } },
12852
+ { document_id: zod.z.string() },
11579
12853
  async (args) => {
11580
12854
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
11581
12855
  return engine.getDocument(String(args.document_id), { principals: callerPrincipals });
@@ -11585,7 +12859,7 @@ async function createMcpApp(engine, opts) {
11585
12859
  tool(
11586
12860
  "query_structured",
11587
12861
  `Answer a question against documents' extracted structured data (only documents with structured data are candidates).${SCOPE_NOTE}`,
11588
- { question: { type: "string" }, source_ids: { type: "array", items: { type: "string" } } },
12862
+ { question: zod.z.string(), source_ids: zod.z.array(zod.z.string()).optional() },
11589
12863
  async (args) => {
11590
12864
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
11591
12865
  if (!engine.queryStructured) throw new Error("queryStructured is not available on this engine");
@@ -11599,7 +12873,7 @@ async function createMcpApp(engine, opts) {
11599
12873
  tool(
11600
12874
  "compute",
11601
12875
  `Run LLM-authored JavaScript over in-scope spreadsheet documents and return the computed result.${SCOPE_NOTE}`,
11602
- { instruction: { type: "string" }, source_ids: { type: "array", items: { type: "string" } } },
12876
+ { instruction: zod.z.string(), source_ids: zod.z.array(zod.z.string()).optional() },
11603
12877
  async (args) => {
11604
12878
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
11605
12879
  if (!engine.compute) throw new Error("compute is not available on this engine");
@@ -11618,7 +12892,8 @@ async function createMcpApp(engine, opts) {
11618
12892
  }
11619
12893
  },
11620
12894
  engine,
11621
- opts.principals
12895
+ opts.principals,
12896
+ opts.approvalScope ?? null
11622
12897
  );
11623
12898
  const handler = (async (req, res) => {
11624
12899
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
@@ -11631,6 +12906,9 @@ async function createMcpApp(engine, opts) {
11631
12906
 
11632
12907
  // src/providers/index.ts
11633
12908
  init_llm();
12909
+
12910
+ // src/index.ts
12911
+ init_redaction();
11634
12912
  var InProcessRunner = class {
11635
12913
  registry = /* @__PURE__ */ new Map();
11636
12914
  /** Kept so the Promise isn't garbage-collected mid-flight. */
@@ -11684,14 +12962,11 @@ init_usage();
11684
12962
 
11685
12963
  exports.CeleryRunner = CeleryRunner;
11686
12964
  exports.ContextEngine = ContextEngine;
11687
- exports.ContextEngineConfig = ContextEngineConfig;
11688
12965
  exports.DEFAULT_LEG_WEIGHT = DEFAULT_LEG_WEIGHT;
11689
12966
  exports.EXTRACTION_VERSION = EXTRACTION_VERSION;
11690
12967
  exports.Embedder = Embedder;
11691
12968
  exports.InProcessRunner = InProcessRunner;
11692
12969
  exports.PostgresBackend = PostgresBackend;
11693
- exports.RedactionPolicy = RedactionPolicy;
11694
- exports.RedactionRule = RedactionRule;
11695
12970
  exports.TRUSTED = TRUSTED;
11696
12971
  exports.ToolConfig = ToolConfig;
11697
12972
  exports.UNSET = UNSET;
@@ -11705,6 +12980,7 @@ exports.configSchema = configSchema;
11705
12980
  exports.createMcpApp = createMcpApp;
11706
12981
  exports.decryptDict = decryptDict;
11707
12982
  exports.emitError = emitError;
12983
+ exports.emitProgress = emitProgress;
11708
12984
  exports.emitToolCall = emitToolCall;
11709
12985
  exports.emitUsage = emitUsage;
11710
12986
  exports.encryptDict = encryptDict;