@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.js CHANGED
@@ -1,19 +1,21 @@
1
1
  import { francAll } from 'franc';
2
2
  import OpenAI from 'openai';
3
+ import { randomBytes, createCipheriv, createDecipheriv, randomUUID, createHash, createHmac } from 'crypto';
3
4
  import { jsonrepair } from 'jsonrepair';
4
5
  import { load } from 'cheerio';
5
6
  import ExcelJS from 'exceljs';
6
7
  import JSZip from 'jszip';
7
8
  import mammoth from 'mammoth';
8
9
  import PostalMime from 'postal-mime';
10
+ import { z } from 'zod';
9
11
  import { getDocumentProxy, extractText, renderPageAsImage, extractImages } from 'unpdf';
10
12
  import { createRequire } from 'module';
11
- import { randomBytes, createCipheriv, createDecipheriv, randomUUID, createHash, createHmac } from 'crypto';
12
13
  import { readFileSync, openSync, readSync, closeSync } from 'fs';
13
14
  import { join, basename, dirname } from 'path';
14
- import { z } from 'zod';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { getEncoding } from 'js-tiktoken';
17
+ import { promises } from 'dns';
18
+ import { isIP } from 'net';
17
19
 
18
20
  var __defProp = Object.defineProperty;
19
21
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -775,6 +777,14 @@ function emitError(hooks, exc, ctx) {
775
777
  log2.warn("onError callback raised; swallowing");
776
778
  }
777
779
  }
780
+ function emitProgress(hooks, event) {
781
+ if (!hooks?.onProgress) return;
782
+ try {
783
+ hooks.onProgress(event);
784
+ } catch {
785
+ log2.warn("onProgress callback raised; swallowing");
786
+ }
787
+ }
778
788
  function emitToolCall(hooks, event) {
779
789
  if (!hooks?.onToolCall) return;
780
790
  try {
@@ -796,6 +806,8 @@ var init_hooks = __esm({
796
806
  // src/providers/llm.ts
797
807
  var llm_exports = {};
798
808
  __export(llm_exports, {
809
+ CALL_TIMEOUT_MS: () => CALL_TIMEOUT_MS,
810
+ GEMINI_CALL_TIMEOUT_MS: () => GEMINI_CALL_TIMEOUT_MS,
799
811
  LLMClient: () => LLMClient,
800
812
  TIMEOUT_MS: () => TIMEOUT_MS,
801
813
  buildLlmClient: () => buildLlmClient,
@@ -838,7 +850,7 @@ async function loadGeminiChatClient(apiKey) {
838
850
  if (!Ctor) {
839
851
  throw new ExtraMissingError("gemini", specifier, "gemini llm");
840
852
  }
841
- return new Ctor({ apiKey: apiKey ?? null });
853
+ return new Ctor({ apiKey: apiKey ?? null, httpOptions: { timeout: GEMINI_CALL_TIMEOUT_MS } });
842
854
  }
843
855
  async function loadBedrockSdk() {
844
856
  const specifier = "@aws-sdk/client-bedrock-runtime";
@@ -875,11 +887,13 @@ async function callLlm(cfg2, opts) {
875
887
  await owned.aclose();
876
888
  }
877
889
  }
878
- var TIMEOUT_MS, ANTHROPIC_VERSION, ANTHROPIC_MAX_TOKENS, OPENAI_FAMILY, LLMClient;
890
+ var CALL_TIMEOUT_MS, TIMEOUT_MS, GEMINI_CALL_TIMEOUT_MS, ANTHROPIC_VERSION, ANTHROPIC_MAX_TOKENS, OPENAI_FAMILY, LLMClient;
879
891
  var init_llm = __esm({
880
892
  "src/providers/llm.ts"() {
881
893
  init_errors();
882
- TIMEOUT_MS = 3e4;
894
+ CALL_TIMEOUT_MS = 24e4;
895
+ TIMEOUT_MS = CALL_TIMEOUT_MS;
896
+ GEMINI_CALL_TIMEOUT_MS = CALL_TIMEOUT_MS;
883
897
  ANTHROPIC_VERSION = "2023-06-01";
884
898
  ANTHROPIC_MAX_TOKENS = 4096;
885
899
  OPENAI_FAMILY = /* @__PURE__ */ new Set(["openai", "azure_openai", "custom"]);
@@ -907,22 +921,30 @@ var init_llm = __esm({
907
921
  await this.aclose();
908
922
  }
909
923
  async call(opts) {
910
- const { system, user, jsonMode = false, images = null } = opts;
924
+ const {
925
+ system,
926
+ user,
927
+ jsonMode = false,
928
+ images = null,
929
+ maxTokens = null,
930
+ thinkingBudget = null,
931
+ temperature = null
932
+ } = opts;
911
933
  if (this.provider === "anthropic") {
912
- return this.callAnthropic(system, user, jsonMode, images);
934
+ return this.callAnthropic(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
913
935
  }
914
936
  if (OPENAI_FAMILY.has(this.provider)) {
915
- return this.callOpenAI(system, user, jsonMode, images);
937
+ return this.callOpenAI(system, user, jsonMode, images, maxTokens, temperature);
916
938
  }
917
939
  if (this.provider === "gemini") {
918
- return this.callGemini(system, user, jsonMode, images);
940
+ return this.callGemini(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
919
941
  }
920
942
  if (this.provider === "bedrock") {
921
- return this.callBedrock(system, user, jsonMode, images);
943
+ return this.callBedrock(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
922
944
  }
923
945
  throw new Error(`unknown llm provider: ${JSON.stringify(this.provider)}`);
924
946
  }
925
- async callAnthropic(system, user, jsonMode, images) {
947
+ async callAnthropic(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
926
948
  if (!this.fetchImpl) {
927
949
  throw new Error("anthropic llm client has no fetch implementation");
928
950
  }
@@ -943,26 +965,27 @@ Respond with valid JSON only.`;
943
965
  });
944
966
  }
945
967
  content.push({ type: "text", text: user });
946
- const data = await postJson(
947
- this.fetchImpl,
948
- "https://api.anthropic.com/v1/messages",
949
- {
950
- model: this.model,
951
- max_tokens: ANTHROPIC_MAX_TOKENS,
952
- system,
953
- messages: [{ role: "user", content }]
954
- },
955
- {
956
- "x-api-key": this.cfg.apiKey ?? "",
957
- "anthropic-version": ANTHROPIC_VERSION,
958
- "content-type": "application/json"
959
- }
960
- );
968
+ const body = {
969
+ model: this.model,
970
+ max_tokens: maxTokens ?? ANTHROPIC_MAX_TOKENS,
971
+ system,
972
+ messages: [{ role: "user", content }]
973
+ };
974
+ if (thinkingBudget) {
975
+ body.thinking = { type: "enabled", budget_tokens: thinkingBudget };
976
+ } else if (temperature !== null) {
977
+ body.temperature = temperature;
978
+ }
979
+ const data = await postJson(this.fetchImpl, "https://api.anthropic.com/v1/messages", body, {
980
+ "x-api-key": this.cfg.apiKey ?? "",
981
+ "anthropic-version": ANTHROPIC_VERSION,
982
+ "content-type": "application/json"
983
+ });
961
984
  const text = data.content[0].text;
962
985
  const usage = data.usage ?? {};
963
986
  return [text, { input: usage.input_tokens ?? 0, output: usage.output_tokens ?? 0 }];
964
987
  }
965
- async callOpenAI(system, user, jsonMode, images) {
988
+ async callOpenAI(system, user, jsonMode, images, maxTokens, temperature = null) {
966
989
  if (!this.client) {
967
990
  throw new Error("openai-family llm client has no client");
968
991
  }
@@ -983,12 +1006,18 @@ Respond with valid JSON only.`;
983
1006
  if (jsonMode) {
984
1007
  body.response_format = { type: "json_object" };
985
1008
  }
1009
+ if (maxTokens) {
1010
+ body.max_completion_tokens = maxTokens;
1011
+ }
1012
+ if (temperature !== null) {
1013
+ body.temperature = temperature;
1014
+ }
986
1015
  const resp = await this.client.chat.completions.create(body);
987
1016
  const text = resp.choices[0]?.message?.content ?? "";
988
1017
  const usage = resp.usage;
989
1018
  return [text, { input: usage?.prompt_tokens ?? 0, output: usage?.completion_tokens ?? 0 }];
990
1019
  }
991
- async callGemini(system, user, jsonMode, images) {
1020
+ async callGemini(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
992
1021
  if (!this.genaiClient) {
993
1022
  this.genaiClient = await loadGeminiChatClient(this.cfg.apiKey);
994
1023
  }
@@ -997,10 +1026,30 @@ Respond with valid JSON only.`;
997
1026
  parts.push({ inlineData: { mimeType: "image/png", data: toBase64(img) } });
998
1027
  }
999
1028
  parts.push({ text: user });
1000
- const config = { systemInstruction: system };
1029
+ const config = {
1030
+ systemInstruction: system,
1031
+ // ALWAYS off, and not as a preference. Automatic function calling means
1032
+ // the SDK itself EXECUTES a callable it was handed as a tool and loops
1033
+ // on the result — up to ten round trips — before returning anything.
1034
+ // This package passes declarations only, so today nothing is executable;
1035
+ // but that depends on every future caller continuing to do the same, and
1036
+ // an application that gates tool execution behind human approval would
1037
+ // have that gate bypassed silently, by a library, with the loop already
1038
+ // run before it could object.
1039
+ automaticFunctionCalling: { disable: true }
1040
+ };
1001
1041
  if (jsonMode) {
1002
1042
  config.responseMimeType = "application/json";
1003
1043
  }
1044
+ if (maxTokens) {
1045
+ config.maxOutputTokens = maxTokens;
1046
+ }
1047
+ if (temperature !== null) {
1048
+ config.temperature = temperature;
1049
+ }
1050
+ if (thinkingBudget !== null) {
1051
+ config.thinkingConfig = { thinkingBudget };
1052
+ }
1004
1053
  const resp = await this.genaiClient.models.generateContent({
1005
1054
  model: this.model,
1006
1055
  contents: parts,
@@ -1012,7 +1061,7 @@ Respond with valid JSON only.`;
1012
1061
  const outputTokens = usageMeta?.candidatesTokenCount ?? usageMeta?.candidates_token_count ?? 0;
1013
1062
  return [text, { input: inputTokens || 0, output: outputTokens || 0 }];
1014
1063
  }
1015
- async callBedrock(system, user, jsonMode, images) {
1064
+ async callBedrock(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
1016
1065
  const { BedrockRuntimeClient, ConverseCommand } = await loadBedrockSdk();
1017
1066
  if (jsonMode) {
1018
1067
  system = `${system}
@@ -1036,7 +1085,21 @@ Respond with valid JSON only.`;
1036
1085
  new ConverseCommand({
1037
1086
  modelId: this.model,
1038
1087
  system: [{ text: system }],
1039
- messages: [{ role: "user", content }]
1088
+ messages: [{ role: "user", content }],
1089
+ ...maxTokens || temperature !== null ? {
1090
+ inferenceConfig: {
1091
+ ...maxTokens ? { maxTokens } : {},
1092
+ ...temperature !== null ? { temperature } : {}
1093
+ }
1094
+ } : {},
1095
+ // Anthropic-style passthrough — Converse forwards it to the model.
1096
+ // Zero (the vision transcription contract) sends nothing: thinking
1097
+ // is opt-in for the anthropic models bedrock hosts.
1098
+ ...thinkingBudget ? {
1099
+ additionalModelRequestFields: {
1100
+ thinking: { type: "enabled", budget_tokens: thinkingBudget }
1101
+ }
1102
+ } : {}
1040
1103
  })
1041
1104
  );
1042
1105
  const text = result.output?.message?.content?.[0]?.text ?? "";
@@ -1049,6 +1112,302 @@ Respond with valid JSON only.`;
1049
1112
  };
1050
1113
  }
1051
1114
  });
1115
+ function encryptDict(data, key) {
1116
+ const nonce = randomBytes(12);
1117
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
1118
+ const plaintext = Buffer.from(JSON.stringify(data), "utf8");
1119
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
1120
+ const tag = cipher.getAuthTag();
1121
+ return Buffer.concat([nonce, ciphertext, tag]).toString("base64");
1122
+ }
1123
+ function decryptDict(token, key) {
1124
+ const raw = Buffer.from(token, "base64");
1125
+ const nonce = raw.subarray(0, 12);
1126
+ const tag = raw.subarray(raw.length - 16);
1127
+ const ciphertext = raw.subarray(12, raw.length - 16);
1128
+ const decipher = createDecipheriv("aes-256-gcm", key, nonce);
1129
+ decipher.setAuthTag(tag);
1130
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
1131
+ return JSON.parse(plaintext.toString("utf8"));
1132
+ }
1133
+ function getSecretKey(config) {
1134
+ const secretKey = config.secretKey;
1135
+ if (!secretKey) {
1136
+ throw new Error(
1137
+ "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."
1138
+ );
1139
+ }
1140
+ return Buffer.from(secretKey, "base64url");
1141
+ }
1142
+ function hmacSha256Hex(key, value) {
1143
+ const k = typeof key === "string" ? Buffer.from(key) : key;
1144
+ return createHmac("sha256", k).update(value, "utf8").digest("hex");
1145
+ }
1146
+ var init_crypto = __esm({
1147
+ "src/crypto.ts"() {
1148
+ }
1149
+ });
1150
+
1151
+ // src/redaction.ts
1152
+ function spansFrom(re, text) {
1153
+ const out = [];
1154
+ re.lastIndex = 0;
1155
+ let m = re.exec(text);
1156
+ while (m !== null) {
1157
+ out.push([m.index, m.index + m[0].length]);
1158
+ if (m[0].length === 0) re.lastIndex++;
1159
+ m = re.exec(text);
1160
+ }
1161
+ return out;
1162
+ }
1163
+ function luhnOk(digits) {
1164
+ let total = 0;
1165
+ const rev = [...digits].reverse();
1166
+ for (let i = 0; i < rev.length; i++) {
1167
+ let d = Number(rev[i]);
1168
+ if (i % 2 === 1) {
1169
+ d *= 2;
1170
+ if (d > 9) d -= 9;
1171
+ }
1172
+ total += d;
1173
+ }
1174
+ return total % 10 === 0;
1175
+ }
1176
+ function detectCreditCard(text) {
1177
+ const spans = [];
1178
+ for (const [start, end] of spansFrom(CARD_RE, text)) {
1179
+ const digits = text.slice(start, end).replace(/[ -]/g, "");
1180
+ if (digits.length >= 13 && digits.length <= 19 && luhnOk(digits)) {
1181
+ spans.push([start, end]);
1182
+ }
1183
+ }
1184
+ return spans;
1185
+ }
1186
+ function looksLikeGenericSecret(token) {
1187
+ if (UUID_RE.test(token)) return false;
1188
+ let hasUpper = false;
1189
+ let hasLower = false;
1190
+ let hasDigit = false;
1191
+ for (const c of token) {
1192
+ if (c >= "A" && c <= "Z") hasUpper = true;
1193
+ else if (c >= "a" && c <= "z") hasLower = true;
1194
+ else if (c >= "0" && c <= "9") hasDigit = true;
1195
+ }
1196
+ return hasUpper && hasLower && hasDigit;
1197
+ }
1198
+ function detectApiKey(text) {
1199
+ const spans = spansFrom(API_KEY_PREFIX_RE, text);
1200
+ for (const [start, end] of spansFrom(API_KEY_GENERIC_RE, text)) {
1201
+ if (spans.some(([s, e]) => start < e && s < end)) continue;
1202
+ if (looksLikeGenericSecret(text.slice(start, end))) spans.push([start, end]);
1203
+ }
1204
+ return spans;
1205
+ }
1206
+ function detectBuiltin(name, text) {
1207
+ const detector = BUILTIN[name];
1208
+ if (!detector) throw new Error(`unknown built-in detector: ${name}`);
1209
+ if (!text) return [];
1210
+ return detector(text).sort((a, b) => a[0] - b[0]);
1211
+ }
1212
+ function ruleApplies(rule, opts) {
1213
+ if (rule.applyAt !== "both" && rule.applyAt !== opts.phase) return false;
1214
+ if (opts.phase === "ingest") return true;
1215
+ if (!rule.unless.length) return true;
1216
+ if (opts.principals === null) return false;
1217
+ const held = new Set(opts.principals);
1218
+ return !rule.unless.some((p) => held.has(p));
1219
+ }
1220
+ function spansForRule(rule, text, policy) {
1221
+ if (rule.pattern !== null) {
1222
+ const re = rule.patternRe() ?? new RegExp(rule.pattern, "g");
1223
+ return spansFrom(re, text);
1224
+ }
1225
+ if (rule.detector !== null) {
1226
+ const custom = policy.customDetectors[rule.detector];
1227
+ if (custom) return [...custom(text)];
1228
+ return detectBuiltin(rule.detector, text);
1229
+ }
1230
+ return [];
1231
+ }
1232
+ function hashToken(value, secretKey) {
1233
+ const key = Buffer.isBuffer(secretKey) ? secretKey : Buffer.from(String(secretKey ?? ""));
1234
+ return hmacSha256Hex(key, value).slice(0, HASH_TOKEN_CHARS);
1235
+ }
1236
+ function applyRedaction(text, policy, opts) {
1237
+ if (!text || policy.isEmpty()) return [text, {}];
1238
+ const principals = opts.principals ?? null;
1239
+ const collected = [];
1240
+ const fired = [];
1241
+ const failed = [];
1242
+ for (const rule of policy.rules) {
1243
+ if (!ruleApplies(rule, { phase: opts.phase, principals })) continue;
1244
+ if (rule.action === "hash" && !opts.secretKey) {
1245
+ throw new Error(
1246
+ `rule '${rule.name}': action='hash' requires a non-empty secretKey (an unkeyed HMAC is a reversible pseudonym, not a redaction)`
1247
+ );
1248
+ }
1249
+ try {
1250
+ const raw = spansForRule(rule, text, policy);
1251
+ const ruleSpans = [];
1252
+ let invalid = false;
1253
+ for (const item of raw) {
1254
+ const start = item?.[0];
1255
+ const end = item?.[1];
1256
+ if (typeof start !== "number" || typeof end !== "number") {
1257
+ invalid = true;
1258
+ continue;
1259
+ }
1260
+ if (!(start >= 0 && start < end && end <= text.length)) {
1261
+ invalid = true;
1262
+ continue;
1263
+ }
1264
+ ruleSpans.push([start, end]);
1265
+ }
1266
+ if (invalid) failed.push(rule.name);
1267
+ for (const [s, e] of ruleSpans) collected.push([s, e, rule]);
1268
+ } catch (exc) {
1269
+ failed.push(rule.name);
1270
+ if (opts.hooks) emitError(opts.hooks, exc, { stage: "redaction", rule: rule.name });
1271
+ }
1272
+ }
1273
+ if (!collected.length) {
1274
+ if (failed.length) return [text, { rules_fired: [], spans: 0, rules_failed: failed }];
1275
+ return [text, {}];
1276
+ }
1277
+ collected.sort((a, b) => a[0] - b[0] || b[1] - b[0] - (a[1] - a[0]));
1278
+ const merged = [];
1279
+ for (const [start, end, rule] of collected) {
1280
+ const last = merged[merged.length - 1];
1281
+ if (last && start < last[1]) {
1282
+ if (end > last[1]) last[1] = end;
1283
+ continue;
1284
+ }
1285
+ merged.push([start, end, rule]);
1286
+ }
1287
+ const out = [];
1288
+ let cursor = 0;
1289
+ for (const [start, end, rule] of merged) {
1290
+ out.push(text.slice(cursor, start));
1291
+ const original = text.slice(start, end);
1292
+ if (rule.action === "mask") out.push(rule.effectivePlaceholder());
1293
+ else if (rule.action === "hash") {
1294
+ out.push(`[${rule.name.toUpperCase()}:${hashToken(original, opts.secretKey)}]`);
1295
+ }
1296
+ if (!fired.includes(rule.name)) fired.push(rule.name);
1297
+ cursor = end;
1298
+ }
1299
+ out.push(text.slice(cursor));
1300
+ const note = { rules_fired: fired, spans: merged.length };
1301
+ if (failed.length) note.rules_failed = failed;
1302
+ return [out.join(""), note];
1303
+ }
1304
+ var BUILTIN_DETECTOR_NAMES, RedactionRule, RedactionPolicy, 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;
1305
+ var init_redaction = __esm({
1306
+ "src/redaction.ts"() {
1307
+ init_crypto();
1308
+ init_hooks();
1309
+ BUILTIN_DETECTOR_NAMES = /* @__PURE__ */ new Set(["email", "phone", "ssn", "credit_card", "iban", "api_key"]);
1310
+ RedactionRule = class {
1311
+ name;
1312
+ detector;
1313
+ pattern;
1314
+ field;
1315
+ action;
1316
+ placeholder;
1317
+ applyAt;
1318
+ unless;
1319
+ compiled = null;
1320
+ constructor(init) {
1321
+ this.name = init.name;
1322
+ this.detector = init.detector ?? null;
1323
+ this.pattern = init.pattern ?? null;
1324
+ this.field = init.field ?? null;
1325
+ this.action = init.action ?? "mask";
1326
+ this.placeholder = init.placeholder ?? null;
1327
+ this.applyAt = init.applyAt ?? "output";
1328
+ this.unless = init.unless ?? [];
1329
+ this.validate();
1330
+ }
1331
+ validate() {
1332
+ if (this.field !== null) {
1333
+ throw new Error(
1334
+ `rule '${this.name}': field-targeted rules are not implemented yet; use detector or pattern instead`
1335
+ );
1336
+ }
1337
+ const targets = [this.detector, this.pattern].filter((t) => t !== null);
1338
+ if (targets.length !== 1) {
1339
+ throw new Error(`rule '${this.name}': exactly one of detector/pattern must be set`);
1340
+ }
1341
+ if (this.pattern !== null) {
1342
+ try {
1343
+ this.compiled = new RegExp(this.pattern, "g");
1344
+ } catch (exc) {
1345
+ throw new Error(`rule '${this.name}': invalid regex: ${exc}`);
1346
+ }
1347
+ }
1348
+ if (this.unless.length && this.applyAt === "ingest") {
1349
+ throw new Error(
1350
+ `rule '${this.name}': unless is output-time only and cannot be set on an applyAt='ingest' rule`
1351
+ );
1352
+ }
1353
+ }
1354
+ patternRe() {
1355
+ return this.compiled;
1356
+ }
1357
+ effectivePlaceholder() {
1358
+ return this.placeholder || `[${this.name.toUpperCase()}]`;
1359
+ }
1360
+ };
1361
+ RedactionPolicy = class {
1362
+ rules;
1363
+ customDetectors;
1364
+ constructor(init = {}) {
1365
+ this.rules = (init.rules ?? []).map((r) => r instanceof RedactionRule ? r : new RedactionRule(r));
1366
+ this.customDetectors = init.customDetectors ?? {};
1367
+ const seen = /* @__PURE__ */ new Set();
1368
+ for (const rule of this.rules) {
1369
+ if (seen.has(rule.name)) throw new Error(`duplicate rule name: '${rule.name}'`);
1370
+ seen.add(rule.name);
1371
+ if (rule.detector !== null) {
1372
+ const known = BUILTIN_DETECTOR_NAMES.has(rule.detector) || rule.detector in this.customDetectors;
1373
+ if (!known) {
1374
+ throw new Error(
1375
+ `rule '${rule.name}': unknown detector '${rule.detector}' (not built-in and not in customDetectors)`
1376
+ );
1377
+ }
1378
+ }
1379
+ }
1380
+ }
1381
+ isEmpty() {
1382
+ return this.rules.length === 0;
1383
+ }
1384
+ };
1385
+ EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
1386
+ PHONE_RE = /\+\d[\d\s-]{7,17}\d/g;
1387
+ SSN_RE = /(?<!\d)\d{3}-\d{2}-\d{4}(?!\d)/g;
1388
+ CARD_RE = /(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/g;
1389
+ IBAN_RE = /(?<![A-Za-z0-9])[A-Z]{2}\d{2}[A-Z0-9]{10,30}(?![A-Za-z0-9])/g;
1390
+ API_KEY_ALNUM = "A-Za-z0-9_\\-+/=";
1391
+ API_KEY_PREFIX_RE = new RegExp(
1392
+ `(?<![${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}])`,
1393
+ "g"
1394
+ );
1395
+ API_KEY_GENERIC_RE = new RegExp(
1396
+ `(?<![${API_KEY_ALNUM}])[${API_KEY_ALNUM}]{24,}(?![${API_KEY_ALNUM}])`,
1397
+ "g"
1398
+ );
1399
+ 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}$/;
1400
+ BUILTIN = {
1401
+ email: (t) => spansFrom(EMAIL_RE, t),
1402
+ phone: (t) => spansFrom(PHONE_RE, t),
1403
+ ssn: (t) => spansFrom(SSN_RE, t),
1404
+ credit_card: detectCreditCard,
1405
+ iban: (t) => spansFrom(IBAN_RE, t),
1406
+ api_key: detectApiKey
1407
+ };
1408
+ HASH_TOKEN_CHARS = 16;
1409
+ }
1410
+ });
1052
1411
  function stripFences(text) {
1053
1412
  let t = text.trim();
1054
1413
  const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(t);
@@ -1084,17 +1443,38 @@ var init_json = __esm({
1084
1443
  "src/extraction/json.ts"() {
1085
1444
  }
1086
1445
  });
1087
- function getFileExtension(filename) {
1088
- if (!filename?.includes(".")) return "";
1089
- return filename.slice(filename.lastIndexOf(".")).toLowerCase();
1090
- }
1091
- function isNonIngestibleMedia(filename, mime) {
1092
- const m = (mime || "").toLowerCase();
1093
- if (m.startsWith("audio/") || m.startsWith("video/")) return true;
1094
- return NON_INGESTIBLE_MEDIA_EXTS.has(getFileExtension(filename));
1095
- }
1096
- function isZip(content) {
1097
- return content.length >= 4 && content.subarray(0, 4).equals(ZIP_MAGIC);
1446
+
1447
+ // src/extras.ts
1448
+ async function tryImport(specifier) {
1449
+ try {
1450
+ return await import(specifier);
1451
+ } catch {
1452
+ return null;
1453
+ }
1454
+ }
1455
+ async function requireExtra(specifier, extra, what) {
1456
+ try {
1457
+ return await import(specifier);
1458
+ } catch (_err) {
1459
+ throw new ExtraMissingError(extra, specifier, what);
1460
+ }
1461
+ }
1462
+ var init_extras = __esm({
1463
+ "src/extras.ts"() {
1464
+ init_errors();
1465
+ }
1466
+ });
1467
+ function getFileExtension(filename) {
1468
+ if (!filename?.includes(".")) return "";
1469
+ return filename.slice(filename.lastIndexOf(".")).toLowerCase();
1470
+ }
1471
+ function isNonIngestibleMedia(filename, mime) {
1472
+ const m = (mime || "").toLowerCase();
1473
+ if (m.startsWith("audio/") || m.startsWith("video/")) return true;
1474
+ return NON_INGESTIBLE_MEDIA_EXTS.has(getFileExtension(filename));
1475
+ }
1476
+ function isZip(content) {
1477
+ return content.length >= 4 && content.subarray(0, 4).equals(ZIP_MAGIC);
1098
1478
  }
1099
1479
  function decodeXmlEntities(s) {
1100
1480
  return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
@@ -1474,25 +1854,182 @@ var init_files = __esm({
1474
1854
  XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
1475
1855
  }
1476
1856
  });
1477
-
1478
- // src/extras.ts
1479
- async function tryImport(specifier) {
1480
- try {
1481
- return await import(specifier);
1482
- } catch {
1483
- return null;
1857
+ function camelize(key) {
1858
+ return key.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
1859
+ }
1860
+ function loadCeEnv() {
1861
+ const root = {};
1862
+ for (const [raw, value] of Object.entries(process.env)) {
1863
+ if (!raw.startsWith("CE_") || value === void 0) continue;
1864
+ const path = raw.slice(3).split("__").map(camelize);
1865
+ let cur = root;
1866
+ for (let i = 0; i < path.length - 1; i++) {
1867
+ const k = path[i];
1868
+ const next = cur[k];
1869
+ if (typeof next !== "object" || next === null) cur[k] = {};
1870
+ cur = cur[k];
1871
+ }
1872
+ cur[path[path.length - 1]] = coerceEnv(value);
1484
1873
  }
1874
+ return root;
1485
1875
  }
1486
- async function requireExtra(specifier, extra, what) {
1487
- try {
1488
- return await import(specifier);
1489
- } catch (_err) {
1490
- throw new ExtraMissingError(extra, specifier, what);
1876
+ function coerceEnv(value) {
1877
+ if (value === "true") return true;
1878
+ if (value === "false") return false;
1879
+ if (/^-?\d+$/.test(value)) return Number(value);
1880
+ if (/^-?\d+\.\d+$/.test(value)) return Number(value);
1881
+ return value;
1882
+ }
1883
+ function deepMerge(a, b) {
1884
+ const out = { ...a };
1885
+ for (const [k, v] of Object.entries(b)) {
1886
+ if (v === void 0) continue;
1887
+ const existing = out[k];
1888
+ if (v && typeof v === "object" && !Array.isArray(v) && existing && typeof existing === "object" && !Array.isArray(existing)) {
1889
+ out[k] = deepMerge(existing, v);
1890
+ } else {
1891
+ out[k] = v;
1892
+ }
1491
1893
  }
1894
+ return out;
1492
1895
  }
1493
- var init_extras = __esm({
1494
- "src/extras.ts"() {
1495
- init_errors();
1896
+ var embeddingSchema, llmSchema, graphSchema, rerankerSchema, fusionSchema, storageSchema, extractionSchema, ContextEngineConfig;
1897
+ var init_config = __esm({
1898
+ "src/config.ts"() {
1899
+ init_redaction();
1900
+ embeddingSchema = z.object({
1901
+ provider: z.enum(["openai", "azure_openai", "gemini", "voyage", "cohere", "custom"]),
1902
+ model: z.string(),
1903
+ dim: z.number().int().positive().nullable().optional().default(null),
1904
+ apiKey: z.string().nullable().optional().default(null),
1905
+ baseUrl: z.string().nullable().optional().default(null)
1906
+ });
1907
+ llmSchema = z.object({
1908
+ provider: z.enum(["anthropic", "openai", "azure_openai", "gemini", "bedrock", "custom"]),
1909
+ model: z.string(),
1910
+ apiKey: z.string().nullable().optional().default(null),
1911
+ baseUrl: z.string().nullable().optional().default(null)
1912
+ });
1913
+ graphSchema = z.object({
1914
+ enabled: z.boolean().default(false),
1915
+ neo4jUri: z.string().nullable().optional().default(null),
1916
+ neo4jUser: z.string().default("neo4j"),
1917
+ neo4jPassword: z.string().nullable().optional().default(null),
1918
+ neo4jDatabase: z.string().default("neo4j"),
1919
+ extractionLlm: llmSchema.nullable().optional().default(null)
1920
+ });
1921
+ rerankerSchema = z.object({
1922
+ enabled: z.boolean().default(false),
1923
+ provider: z.enum(["cohere", "voyage", "jina", "custom"]).nullable().optional().default(null),
1924
+ model: z.string().nullable().optional().default(null),
1925
+ apiKey: z.string().nullable().optional().default(null),
1926
+ baseUrl: z.string().nullable().optional().default(null),
1927
+ candidates: z.number().int().positive().default(50)
1928
+ });
1929
+ fusionSchema = z.object({
1930
+ method: z.literal("rrf").default("rrf"),
1931
+ k: z.number().int().positive().default(60),
1932
+ weights: z.record(z.string(), z.number()).default({ fts: 1, trgm: 0.8, ann: 1, graph: 1 })
1933
+ });
1934
+ storageSchema = z.object({
1935
+ backend: z.literal("postgres").default("postgres"),
1936
+ annExactThreshold: z.number().int().positive().default(5e4),
1937
+ // Connection pool, passed straight through to `pg.Pool`. Sizing is a
1938
+ // deployment decision (how many workers times how many concurrent searches
1939
+ // each runs, against what the database allows), so these are pg's own
1940
+ // defaults rather than a guess at your topology. (`pg` has no pre-ping.)
1941
+ //
1942
+ // Connections kept open in the pool.
1943
+ poolMax: z.number().int().positive().default(10),
1944
+ // Milliseconds an idle connection is kept before it is closed.
1945
+ poolIdleTimeoutMs: z.number().int().nonnegative().default(1e4),
1946
+ // Milliseconds a caller waits for a connection before the attempt fails.
1947
+ poolConnectionTimeoutMs: z.number().int().nonnegative().default(3e4)
1948
+ });
1949
+ extractionSchema = z.object({
1950
+ // Ceiling on an image's LONG EDGE in pixels, for rendered PDF pages and
1951
+ // for images sent to the vision LLM.
1952
+ maxRenderPx: z.number().int().positive().default(2e3),
1953
+ // Ceiling on ONE vision batch's transcription, in output tokens — a reply
1954
+ // far past a batch's honest bound is a repetition loop. Raising it is the
1955
+ // fix if very dense pages come back truncated.
1956
+ visionMaxOutputTokens: z.number().int().positive().default(16e3),
1957
+ // Pages per vision-LLM call. Smaller batches mean more calls but more
1958
+ // concurrency and less risk of hitting the output ceiling or a provider
1959
+ // deadline; larger batches the reverse. Measure on your own corpus.
1960
+ pagesPerVisionBatch: z.number().int().positive().default(5),
1961
+ // Language hint for the Tesseract OCR fallback ('en', 'ur', 'mixed', any
1962
+ // Tesseract code, or 'auto' for script detection). null (the default)
1963
+ // detects the document's language from its usable text-layer pages and
1964
+ // falls back to 'auto' when there is nothing to sample.
1965
+ ocrLanguage: z.string().nullable().default(null)
1966
+ });
1967
+ ContextEngineConfig = class _ContextEngineConfig {
1968
+ databaseUrl;
1969
+ storage;
1970
+ defaultMode;
1971
+ embedding;
1972
+ llm;
1973
+ visionLlm;
1974
+ graph;
1975
+ reranker;
1976
+ fusion;
1977
+ extraction;
1978
+ enableCodeExecution;
1979
+ secretKey;
1980
+ /**
1981
+ * Lets http tools and probes reach loopback/private/link-local/metadata
1982
+ * addresses. Off by default — an http tool is registered by a caller, and a
1983
+ * private destination is server-side request forgery. See `tools/egress.ts`.
1984
+ */
1985
+ allowPrivateEgress;
1986
+ redaction;
1987
+ constructor(init) {
1988
+ this.databaseUrl = init.databaseUrl;
1989
+ this.storage = storageSchema.parse(init.storage ?? {});
1990
+ this.defaultMode = init.defaultMode ?? "hybrid";
1991
+ this.embedding = embeddingSchema.parse(init.embedding);
1992
+ this.llm = init.llm ? llmSchema.parse(init.llm) : null;
1993
+ this.visionLlm = init.visionLlm ? llmSchema.parse(init.visionLlm) : null;
1994
+ this.graph = graphSchema.parse(init.graph ?? {});
1995
+ this.reranker = rerankerSchema.parse(init.reranker ?? {});
1996
+ this.fusion = fusionSchema.parse(init.fusion ?? {});
1997
+ this.extraction = extractionSchema.parse(init.extraction ?? {});
1998
+ this.enableCodeExecution = init.enableCodeExecution ?? false;
1999
+ this.secretKey = init.secretKey ?? null;
2000
+ this.allowPrivateEgress = init.allowPrivateEgress ?? false;
2001
+ this.redaction = init.redaction instanceof RedactionPolicy ? init.redaction : new RedactionPolicy(init.redaction ?? {});
2002
+ this.validate();
2003
+ }
2004
+ validate() {
2005
+ if (this.graph.enabled && !(this.graph.neo4jUri && this.graph.neo4jPassword && this.graph.extractionLlm)) {
2006
+ throw new Error("graph enabled but neo4jUri/neo4jPassword/extractionLlm missing");
2007
+ }
2008
+ if (this.reranker.enabled && !(this.reranker.provider && this.reranker.apiKey)) {
2009
+ throw new Error("reranker enabled but provider/apiKey missing");
2010
+ }
2011
+ if (this.defaultMode === "graph" && !this.graph.enabled) {
2012
+ throw new Error("defaultMode is 'graph' but graph enabled is false");
2013
+ }
2014
+ for (const rule of this.redaction.rules) {
2015
+ if (rule.action === "hash" && !this.secretKey) {
2016
+ throw new Error(
2017
+ `redaction rule '${rule.name}': action='hash' requires ContextEngineConfig.secretKey to be set`
2018
+ );
2019
+ }
2020
+ }
2021
+ }
2022
+ static fromEnv(overrides = {}) {
2023
+ const env = loadCeEnv();
2024
+ const merged = deepMerge(env, overrides);
2025
+ if (!merged.databaseUrl || !merged.embedding) {
2026
+ throw new Error(
2027
+ "ContextEngineConfig.fromEnv requires CE_DATABASE_URL and CE_EMBEDDING__PROVIDER/MODEL (or explicit overrides)"
2028
+ );
2029
+ }
2030
+ return new _ContextEngineConfig(merged);
2031
+ }
2032
+ };
1496
2033
  }
1497
2034
  });
1498
2035
 
@@ -1921,8 +2458,26 @@ __export(pdf_exports, {
1921
2458
  embeddedTextPerPage: () => embeddedTextPerPage,
1922
2459
  extract: () => extract,
1923
2460
  extractPdf: () => extract,
2461
+ ocrLanguageHint: () => ocrLanguageHint,
1924
2462
  pageCount: () => pageCount
1925
2463
  });
2464
+ function ocrLanguageHint(texts, override) {
2465
+ if (override) return override;
2466
+ let sample = "";
2467
+ const indices = [...texts.keys()].sort((a, b) => a - b).slice(0, 3);
2468
+ for (const idx of indices) {
2469
+ sample += texts.get(idx) ?? "";
2470
+ if (sample.length >= OCR_LANG_SAMPLE_MIN) break;
2471
+ }
2472
+ if (sample.length >= OCR_LANG_SAMPLE_MIN) {
2473
+ try {
2474
+ const detected = detectLanguage(sample, 30);
2475
+ if (detected) return detected;
2476
+ } catch {
2477
+ }
2478
+ }
2479
+ return "auto";
2480
+ }
1926
2481
  function isCcControl(ch) {
1927
2482
  if (ch === "\n" || ch === "\r" || ch === " ") return false;
1928
2483
  const cp = ch.codePointAt(0);
@@ -1975,16 +2530,27 @@ async function extract(content, opts) {
1975
2530
  const { pages, structured: structuredMarkdown } = await classifyPages(content);
1976
2531
  const providerTokens = {};
1977
2532
  let visionMarkdown = false;
2533
+ let benignBlank = /* @__PURE__ */ new Set();
1978
2534
  if (pages.needsOcr.length) {
1979
2535
  if (opts.visionLlm) {
1980
2536
  try {
1981
2537
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
1982
- const [visionTexts, tokens] = await vision.extractPdfPages(content, pages.needsOcr, {
1983
- visionLlm: opts.visionLlm
2538
+ const [visionTexts, tokens, outcomes] = await vision.extractPdfPages(content, pages.needsOcr, {
2539
+ visionLlm: opts.visionLlm,
2540
+ extraction: opts.extraction
1984
2541
  });
1985
2542
  for (const [idx, text] of Object.entries(visionTexts)) {
1986
2543
  pages.texts.set(Number(idx), text);
1987
2544
  }
2545
+ benignBlank = new Set(
2546
+ Object.entries(outcomes).filter(([, o]) => o.status === "blank").map(([idx]) => Number(idx))
2547
+ );
2548
+ const failed = Object.entries(outcomes).filter(([, o]) => o.status === "failed");
2549
+ if (failed.length) {
2550
+ console.warn(
2551
+ `[pdf] vision failed on ${failed.length} of ${pages.needsOcr.length} pages: ` + failed.map(([idx, o]) => `page ${Number(idx) + 1}: ${o.error}`).join("; ")
2552
+ );
2553
+ }
1988
2554
  visionMarkdown = Object.keys(visionTexts).length > 0;
1989
2555
  for (const [key, val] of Object.entries(tokens)) {
1990
2556
  providerTokens[key] = (providerTokens[key] ?? 0) + val;
@@ -1996,9 +2562,17 @@ async function extract(content, opts) {
1996
2562
  try {
1997
2563
  const ocr = await Promise.resolve().then(() => (init_ocr(), ocr_exports));
1998
2564
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
1999
- const rendered = await vision.renderPdfPagesAsImages(content, pages.needsOcr);
2565
+ const rendered = await vision.renderPdfPagesAsImages(
2566
+ content,
2567
+ pages.needsOcr,
2568
+ 150,
2569
+ opts.extraction?.maxRenderPx ?? vision.MAX_RENDER_PX
2570
+ );
2571
+ const ocrConfig = {
2572
+ language: ocrLanguageHint(pages.texts, opts.extraction?.ocrLanguage)
2573
+ };
2000
2574
  for (const [idx, pngBytes] of rendered) {
2001
- const result = await ocr.extractImageTextOcr(pngBytes);
2575
+ const result = await ocr.extractImageTextOcr(pngBytes, ocrConfig);
2002
2576
  if (result.text.trim()) pages.texts.set(idx, result.text.trim());
2003
2577
  }
2004
2578
  } catch (exc) {
@@ -2016,31 +2590,38 @@ async function extract(content, opts) {
2016
2590
  if (t) textParts.push(`--- Page ${i + 1} ---
2017
2591
  ${t}`);
2018
2592
  }
2593
+ const unread = pages.needsOcr.filter((i) => !pages.texts.get(i) && !benignBlank.has(i));
2019
2594
  return new Extracted2({
2020
2595
  text: textParts.join("\n\n"),
2021
2596
  pages: pages.total,
2022
2597
  slides: null,
2023
2598
  mediaOnly: false,
2024
2599
  providerTokens,
2025
- isMarkdown: visionMarkdown || structuredMarkdown
2600
+ isMarkdown: visionMarkdown || structuredMarkdown,
2601
+ unreadableReason: unread.length ? opts.visionLlm ? "vision_failed" : "needs_vision" : null,
2602
+ unreadablePages: unread.length
2026
2603
  });
2027
2604
  }
2028
- var GARBAGE_SCAN_LIMIT, MIN_USABLE_TEXT_LEN;
2605
+ var GARBAGE_SCAN_LIMIT, MIN_USABLE_TEXT_LEN, OCR_LANG_SAMPLE_MIN;
2029
2606
  var init_pdf = __esm({
2030
2607
  "src/extraction/pdf.ts"() {
2031
2608
  init_errors();
2032
2609
  init_hooks();
2610
+ init_text();
2033
2611
  init_pdf_structure();
2034
2612
  GARBAGE_SCAN_LIMIT = 2e4;
2035
2613
  MIN_USABLE_TEXT_LEN = 50;
2614
+ OCR_LANG_SAMPLE_MIN = 100;
2036
2615
  }
2037
2616
  });
2038
2617
 
2039
2618
  // src/extraction/vision.ts
2040
2619
  var vision_exports = {};
2041
2620
  __export(vision_exports, {
2621
+ MAX_RENDER_PX: () => MAX_RENDER_PX,
2042
2622
  PAGES_PER_VISION_BATCH: () => PAGES_PER_VISION_BATCH,
2043
2623
  VISION_BATCH_ATTEMPTS: () => VISION_BATCH_ATTEMPTS,
2624
+ VISION_MAX_OUTPUT_TOKENS: () => VISION_MAX_OUTPUT_TOKENS,
2044
2625
  assignBatchPages: () => assignBatchPages,
2045
2626
  coercePagesResult: () => coercePagesResult,
2046
2627
  detectPdfVisionPages: () => detectPdfVisionPages,
@@ -2057,6 +2638,17 @@ function pagesPrompt(nImages) {
2057
2638
  Return JSON: {"pages": [{"page": 1, "text": "<markdown for this page>"}]}
2058
2639
  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.`;
2059
2640
  }
2641
+ function asText(value) {
2642
+ if (typeof value === "string") return value;
2643
+ if (value == null || typeof value === "boolean") return "";
2644
+ if (Array.isArray(value)) {
2645
+ return value.filter((v) => v != null && v !== "").map(asText).join("\n");
2646
+ }
2647
+ if (typeof value === "object") {
2648
+ return Object.values(value).filter((v) => v != null && v !== "").map(asText).join("\n");
2649
+ }
2650
+ return String(value);
2651
+ }
2060
2652
  function coercePagesResult(parsed) {
2061
2653
  let base;
2062
2654
  let rawPages;
@@ -2076,7 +2668,7 @@ function coercePagesResult(parsed) {
2076
2668
  const rec = entry;
2077
2669
  pages.push({
2078
2670
  page: typeof rec.page === "number" ? rec.page : i + 1,
2079
- text: typeof rec.text === "string" ? rec.text : ""
2671
+ text: asText(rec.text)
2080
2672
  });
2081
2673
  } else if (typeof entry === "string") {
2082
2674
  pages.push({ page: i + 1, text: entry });
@@ -2107,16 +2699,28 @@ function parsePagesResponse(rawText) {
2107
2699
  function sleep(ms) {
2108
2700
  return new Promise((r) => setTimeout(r, ms));
2109
2701
  }
2110
- async function extractTextFromImages(images, opts) {
2111
- if (!images.length) return [[], {}];
2112
- const maxConcurrent = opts.maxConcurrent ?? 8;
2113
- const batches = [];
2114
- for (let i = 0; i < images.length; i += PAGES_PER_VISION_BATCH) {
2115
- batches.push(images.slice(i, i + PAGES_PER_VISION_BATCH));
2702
+ async function capped(image, canvas, maxPx) {
2703
+ if (!canvas) return image;
2704
+ try {
2705
+ const img = await canvas.loadImage(image);
2706
+ const longEdge = Math.max(img.width, img.height);
2707
+ if (longEdge <= maxPx) return image;
2708
+ const ratio = maxPx / longEdge;
2709
+ const w = Math.max(Math.round(img.width * ratio), 1);
2710
+ const h = Math.max(Math.round(img.height * ratio), 1);
2711
+ const out = canvas.createCanvas(w, h);
2712
+ out.getContext("2d").drawImage(img, 0, 0, w, h);
2713
+ return out.toBuffer("image/png");
2714
+ } catch {
2715
+ return image;
2116
2716
  }
2117
- const results = Array.from({ length: images.length }, () => "");
2717
+ }
2718
+ async function runBatches(batchSource, opts) {
2719
+ const started = Date.now();
2720
+ const results = /* @__PURE__ */ new Map();
2721
+ const outcomes = /* @__PURE__ */ new Map();
2118
2722
  const tokensTotal = { input: 0, output: 0 };
2119
- const semaphore = new Semaphore(maxConcurrent);
2723
+ const maxConcurrent = opts.maxConcurrent ?? 8;
2120
2724
  let lock = Promise.resolve();
2121
2725
  const withLock = async (fn) => {
2122
2726
  const prev = lock;
@@ -2132,66 +2736,183 @@ async function extractTextFromImages(images, opts) {
2132
2736
  }
2133
2737
  };
2134
2738
  const client = buildLlmClient(opts.visionLlm);
2135
- const processBatch = async (batchIdx, batchImages) => {
2136
- await semaphore.acquire();
2137
- try {
2138
- const nImages = batchImages.length;
2139
- const prompt = pagesPrompt(nImages);
2140
- for (let attempt = 1; attempt <= VISION_BATCH_ATTEMPTS; attempt++) {
2141
- try {
2142
- const [rawText, usage] = await callLlm(opts.visionLlm, {
2143
- system: VISION_SYSTEM_PROMPT,
2144
- user: prompt,
2145
- jsonMode: true,
2146
- images: batchImages,
2147
- client
2739
+ let total = 0;
2740
+ let nBatches = 0;
2741
+ const queue = new BoundedQueue(maxConcurrent);
2742
+ const processBatch = async (batchNum, baseIdx, batchImages) => {
2743
+ const batchStarted = Date.now();
2744
+ let pendingLocal = batchImages.map((_, i) => i);
2745
+ for (let attempt = 1; attempt <= VISION_BATCH_ATTEMPTS; attempt++) {
2746
+ const ask = pendingLocal.map((i) => batchImages[i]);
2747
+ try {
2748
+ const [rawText, usage] = await callLlm(opts.visionLlm, {
2749
+ system: VISION_SYSTEM_PROMPT,
2750
+ user: pagesPrompt(ask.length),
2751
+ jsonMode: true,
2752
+ images: ask,
2753
+ maxTokens: opts.extCfg.visionMaxOutputTokens,
2754
+ // Transcription is not reasoning, and on a thinking model the two
2755
+ // compete for the SAME budget — see `LlmCallOpts.thinkingBudget`.
2756
+ // Zero, not small: there is nothing here to reason ABOUT, the model
2757
+ // is being asked what is on the page. `temperature: 0` for the same
2758
+ // reason — one scan must transcribe to the same text twice.
2759
+ thinkingBudget: 0,
2760
+ temperature: 0,
2761
+ client
2762
+ });
2763
+ if (!rawText?.trim()) throw new Error("empty vision response");
2764
+ const [, pages] = parsePagesResponse(rawText);
2765
+ const assigned = assignBatchPages(pages, ask.length);
2766
+ const batchSeconds = (Date.now() - batchStarted) / 1e3;
2767
+ const stillMissing = [];
2768
+ await withLock(() => {
2769
+ tokensTotal.input += usage.input ?? 0;
2770
+ tokensTotal.output += usage.output ?? 0;
2771
+ pendingLocal.forEach((localIdx, position) => {
2772
+ const pageText = assigned[position];
2773
+ if (pageText === void 0) {
2774
+ stillMissing.push(localIdx);
2775
+ return;
2776
+ }
2777
+ results.set(baseIdx + localIdx, pageText);
2778
+ outcomes.set(baseIdx + localIdx, {
2779
+ status: pageText.trim() ? "read" : "blank",
2780
+ attempts: attempt,
2781
+ error: null,
2782
+ seconds: batchSeconds
2783
+ });
2148
2784
  });
2149
- if (!rawText?.trim()) throw new Error("empty vision response");
2150
- const [, pages] = parsePagesResponse(rawText);
2151
- const assigned = assignBatchPages(pages, nImages);
2785
+ });
2786
+ pendingLocal = stillMissing;
2787
+ if (pendingLocal.length === 0) return;
2788
+ if (attempt < VISION_BATCH_ATTEMPTS) {
2789
+ console.warn(
2790
+ `[vision] batch ${batchNum} attempt ${attempt} answered ${ask.length - pendingLocal.length} of ${ask.length} pages \u2014 asking again for ${pendingLocal.length}`
2791
+ );
2792
+ continue;
2793
+ }
2794
+ console.error(
2795
+ `[vision] batch ${batchNum}: ${pendingLocal.length} page(s) never came back after ${attempt} attempts`
2796
+ );
2797
+ await withLock(() => {
2798
+ for (const localIdx of pendingLocal) {
2799
+ outcomes.set(baseIdx + localIdx, {
2800
+ status: "failed",
2801
+ attempts: attempt,
2802
+ error: "page missing from batch response",
2803
+ seconds: batchSeconds
2804
+ });
2805
+ }
2806
+ });
2807
+ return;
2808
+ } catch (exc) {
2809
+ if (attempt < VISION_BATCH_ATTEMPTS) {
2810
+ console.warn(`[vision] batch ${batchNum} attempt ${attempt} failed: ${exc} \u2014 retrying`);
2811
+ await sleep(2e3 * attempt);
2812
+ } else {
2813
+ console.error(`[vision] batch ${batchNum} failed after ${VISION_BATCH_ATTEMPTS} attempts: ${exc}`);
2814
+ const batchSeconds = (Date.now() - batchStarted) / 1e3;
2152
2815
  await withLock(() => {
2153
- for (const [localIdx, pageText] of Object.entries(assigned)) {
2154
- const globalIdx = batchIdx * PAGES_PER_VISION_BATCH + Number(localIdx);
2155
- if (globalIdx >= 0 && globalIdx < results.length) results[globalIdx] = pageText;
2816
+ for (const localIdx of pendingLocal) {
2817
+ outcomes.set(baseIdx + localIdx, {
2818
+ status: "failed",
2819
+ attempts: attempt,
2820
+ error: String(exc),
2821
+ seconds: batchSeconds
2822
+ });
2156
2823
  }
2157
- tokensTotal.input += usage.input ?? 0;
2158
- tokensTotal.output += usage.output ?? 0;
2159
2824
  });
2160
- return;
2161
- } catch (exc) {
2162
- if (attempt < VISION_BATCH_ATTEMPTS) {
2163
- console.warn(`[vision] batch ${batchIdx} attempt ${attempt} failed: ${exc} \u2014 retrying`);
2164
- await sleep(2e3 * attempt);
2165
- } else {
2166
- console.error(
2167
- `[vision] batch ${batchIdx} failed after ${VISION_BATCH_ATTEMPTS} attempts: ${exc}`
2168
- );
2169
- }
2170
2825
  }
2171
2826
  }
2172
- } finally {
2173
- semaphore.release();
2174
2827
  }
2175
2828
  };
2829
+ const worker = async () => {
2830
+ for (; ; ) {
2831
+ const item = await queue.get();
2832
+ if (item === null) return;
2833
+ await processBatch(...item);
2834
+ }
2835
+ };
2836
+ const workers = Array.from({ length: maxConcurrent }, () => worker());
2176
2837
  try {
2177
- await Promise.all(batches.map((b, i) => processBatch(i, b)));
2838
+ for await (const [baseIdx, batchImages] of batchSource) {
2839
+ if (!batchImages.length) continue;
2840
+ for (let j = 0; j < batchImages.length; j++) {
2841
+ outcomes.set(baseIdx + j, { status: "failed", attempts: 0, error: "never attempted", seconds: 0 });
2842
+ }
2843
+ total = Math.max(total, baseIdx + batchImages.length);
2844
+ await queue.put([nBatches, baseIdx, batchImages]);
2845
+ nBatches += 1;
2846
+ }
2847
+ queue.close();
2848
+ await Promise.all(workers);
2849
+ } catch (exc) {
2850
+ queue.abort();
2851
+ await Promise.allSettled(workers);
2852
+ throw exc;
2178
2853
  } finally {
2179
2854
  await client.aclose();
2180
2855
  }
2181
- return [results, tokensTotal];
2856
+ const texts = Array.from({ length: total }, (_, i) => results.get(i) ?? "");
2857
+ const outcomeList = Array.from(
2858
+ { length: total },
2859
+ (_, i) => outcomes.get(i) ?? { status: "failed", attempts: 0, error: "never attempted", seconds: 0 }
2860
+ );
2861
+ const counts = { read: 0, blank: 0, failed: 0 };
2862
+ for (const o of outcomeList) counts[o.status] += 1;
2863
+ console.info(
2864
+ `[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`
2865
+ );
2866
+ return [texts, tokensTotal, outcomeList];
2867
+ }
2868
+ async function extractTextFromImages(images, opts) {
2869
+ if (!images.length) return [[], {}, []];
2870
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
2871
+ const canvasModule = await tryImport("@napi-rs/canvas");
2872
+ const sendable = await Promise.all(images.map((img) => capped(img, canvasModule, extCfg.maxRenderPx)));
2873
+ const batchSize = extCfg.pagesPerVisionBatch ?? PAGES_PER_VISION_BATCH;
2874
+ async function* batches() {
2875
+ for (let i = 0; i < sendable.length; i += batchSize) {
2876
+ yield [i, sendable.slice(i, i + batchSize)];
2877
+ }
2878
+ }
2879
+ return runBatches(batches(), {
2880
+ visionLlm: opts.visionLlm,
2881
+ extCfg,
2882
+ maxConcurrent: opts.maxConcurrent
2883
+ });
2182
2884
  }
2183
2885
  async function extractPdfPages(content, pageIndices, opts) {
2184
- const rendered = await renderPdfPagesAsImages(content, pageIndices);
2185
- const images = rendered.map(([, png]) => png);
2186
- const [texts, tokens] = await extractTextFromImages(images, { visionLlm: opts.visionLlm });
2886
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
2887
+ const batchSize = extCfg.pagesPerVisionBatch ?? PAGES_PER_VISION_BATCH;
2888
+ const pageOrder = [];
2889
+ async function* renderedBatches() {
2890
+ let pos = 0;
2891
+ for (let start = 0; start < pageIndices.length; start += batchSize) {
2892
+ const group = pageIndices.slice(start, start + batchSize);
2893
+ const rendered = await renderPdfPagesAsImages(content, group, 150, extCfg.maxRenderPx);
2894
+ if (!rendered.length) continue;
2895
+ pageOrder.push(...rendered.map(([idx]) => idx));
2896
+ yield [pos, rendered.map(([, png]) => png)];
2897
+ pos += rendered.length;
2898
+ }
2899
+ }
2900
+ const [texts, tokens, outcomes] = await runBatches(renderedBatches(), {
2901
+ visionLlm: opts.visionLlm,
2902
+ extCfg
2903
+ });
2187
2904
  const pageTexts = {};
2188
- for (let i = 0; i < rendered.length; i++) {
2905
+ const pageOutcomes = {};
2906
+ for (let i = 0; i < pageOrder.length; i++) {
2189
2907
  const text = texts[i];
2190
- if (text?.trim()) pageTexts[rendered[i][0]] = text;
2908
+ if (text?.trim()) pageTexts[pageOrder[i]] = text;
2909
+ const outcome = outcomes[i];
2910
+ if (outcome) pageOutcomes[pageOrder[i]] = outcome;
2191
2911
  }
2192
- return [pageTexts, tokens];
2912
+ return [pageTexts, tokens, pageOutcomes];
2193
2913
  }
2194
- async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
2914
+ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150, maxPx = MAX_RENDER_PX) {
2915
+ const started = Date.now();
2195
2916
  const scale = dpi / 72;
2196
2917
  const data = new Uint8Array(content);
2197
2918
  const pdf = await getDocumentProxy(data);
@@ -2200,9 +2921,12 @@ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
2200
2921
  for (const idx of pageIndices) {
2201
2922
  if (idx < 0 || idx >= pdf.numPages) continue;
2202
2923
  try {
2924
+ const page = await pdf.getPage(idx + 1);
2925
+ const viewport = page.getViewport({ scale: 1 });
2926
+ const pageScale = Math.min(scale, maxPx / Math.max(viewport.width, viewport.height, 1));
2203
2927
  const buf = await renderPageAsImage(pdf, idx + 1, {
2204
2928
  canvasImport: canvasImport ? async () => canvasImport : void 0,
2205
- scale
2929
+ scale: pageScale
2206
2930
  });
2207
2931
  const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
2208
2932
  results.push([idx, Buffer.from(bytes)]);
@@ -2210,6 +2934,7 @@ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
2210
2934
  console.warn(`[vision] failed to render PDF page ${idx}:`, exc);
2211
2935
  }
2212
2936
  }
2937
+ console.info(`[pdf] rastered ${results.length} pages in ${((Date.now() - started) / 1e3).toFixed(1)}s`);
2213
2938
  return results;
2214
2939
  }
2215
2940
  async function detectPdfVisionPages(content) {
@@ -2231,7 +2956,13 @@ async function detectPdfVisionPages(content) {
2231
2956
  return visionPages;
2232
2957
  }
2233
2958
  async function extractDocxImages(content) {
2234
- const zip = await JSZip.loadAsync(content);
2959
+ let zip;
2960
+ try {
2961
+ zip = await JSZip.loadAsync(content);
2962
+ } catch (exc) {
2963
+ console.warn(`[vision] cannot open DOCX for images: ${exc}`);
2964
+ return [];
2965
+ }
2235
2966
  const rels = zip.file("word/_rels/document.xml.rels");
2236
2967
  if (!rels) return [];
2237
2968
  const xml = await rels.async("string");
@@ -2257,7 +2988,13 @@ async function extractDocxImages(content) {
2257
2988
  return images;
2258
2989
  }
2259
2990
  async function extractPptxImages(content) {
2260
- const zip = await JSZip.loadAsync(content);
2991
+ let zip;
2992
+ try {
2993
+ zip = await JSZip.loadAsync(content);
2994
+ } catch (exc) {
2995
+ console.warn(`[vision] cannot open PPTX for images: ${exc}`);
2996
+ return [];
2997
+ }
2261
2998
  const slideNames = Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n)).sort((a, b) => {
2262
2999
  const na = Number(/slide(\d+)/.exec(a)?.[1] ?? 0);
2263
3000
  const nb = Number(/slide(\d+)/.exec(b)?.[1] ?? 0);
@@ -2290,12 +3027,22 @@ async function extractPptxImages(content) {
2290
3027
  }
2291
3028
  async function extractImage(content, opts) {
2292
3029
  const { Extracted: Extracted2 } = await Promise.resolve().then(() => (init_extraction(), extraction_exports));
3030
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
3031
+ const canvasModule = await tryImport("@napi-rs/canvas");
3032
+ const image = await capped(content, canvasModule, extCfg.maxRenderPx);
2293
3033
  const [rawText, usage] = await callLlm(opts.visionLlm, {
2294
3034
  system: VISION_SYSTEM_PROMPT,
2295
3035
  user: `${MARKDOWN_RULES}
2296
3036
 
2297
3037
  Transcribe this single image. Return the Markdown only.`,
2298
- images: [content]
3038
+ images: [image],
3039
+ maxTokens: extCfg.visionMaxOutputTokens,
3040
+ // Same contract as the batched path (see processBatch): with the output
3041
+ // cap in place, default-on thinking bills against it — an all-thinking
3042
+ // truncated-empty reply lands as vision_failed. Transcription is not
3043
+ // reasoning, and it must be deterministic.
3044
+ thinkingBudget: 0,
3045
+ temperature: 0
2299
3046
  });
2300
3047
  const text = (rawText || "").trim();
2301
3048
  return new Extracted2({
@@ -2304,37 +3051,75 @@ Transcribe this single image. Return the Markdown only.`,
2304
3051
  slides: null,
2305
3052
  mediaOnly: !text,
2306
3053
  providerTokens: usage ?? {},
2307
- isMarkdown: Boolean(text)
3054
+ isMarkdown: Boolean(text),
3055
+ // A configured reader that returns nothing (refusal, filter, truncation)
3056
+ // is the same "reader read nothing" state the PDF path labels
3057
+ // vision_failed — not a blank image with no explanation.
3058
+ unreadableReason: text ? null : "vision_failed",
3059
+ unreadablePages: text ? 0 : 1
2308
3060
  });
2309
3061
  }
2310
- var PAGES_PER_VISION_BATCH, VISION_BATCH_ATTEMPTS, VISION_SYSTEM_PROMPT, MARKDOWN_RULES, Semaphore;
3062
+ var VISION_BATCH_ATTEMPTS, EXTRACTION_DEFAULTS, MAX_RENDER_PX, VISION_MAX_OUTPUT_TOKENS, PAGES_PER_VISION_BATCH, VISION_SYSTEM_PROMPT, MARKDOWN_RULES, BoundedQueue;
2311
3063
  var init_vision = __esm({
2312
3064
  "src/extraction/vision.ts"() {
3065
+ init_config();
2313
3066
  init_extras();
2314
3067
  init_llm();
2315
3068
  init_json();
2316
3069
  init_pdf();
2317
- PAGES_PER_VISION_BATCH = 5;
2318
3070
  VISION_BATCH_ATTEMPTS = 3;
3071
+ EXTRACTION_DEFAULTS = extractionSchema.parse({});
3072
+ MAX_RENDER_PX = EXTRACTION_DEFAULTS.maxRenderPx;
3073
+ VISION_MAX_OUTPUT_TOKENS = EXTRACTION_DEFAULTS.visionMaxOutputTokens;
3074
+ PAGES_PER_VISION_BATCH = EXTRACTION_DEFAULTS.pagesPerVisionBatch;
2319
3075
  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.";
2320
3076
  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.";
2321
- Semaphore = class {
2322
- n;
2323
- waiters = [];
2324
- constructor(max) {
2325
- this.n = max;
2326
- }
2327
- async acquire() {
2328
- if (this.n > 0) {
2329
- this.n -= 1;
2330
- return;
3077
+ BoundedQueue = class {
3078
+ constructor(maxsize) {
3079
+ this.maxsize = maxsize;
3080
+ }
3081
+ maxsize;
3082
+ items = [];
3083
+ putters = [];
3084
+ getters = [];
3085
+ closed = false;
3086
+ async put(item) {
3087
+ while (!this.closed && this.items.length >= this.maxsize) {
3088
+ await new Promise((resolve) => this.putters.push(resolve));
3089
+ }
3090
+ if (this.closed) return;
3091
+ this.items.push(item);
3092
+ this.getters.shift()?.();
3093
+ }
3094
+ /** `null` once the queue is closed AND drained — the worker's exit signal. */
3095
+ async get() {
3096
+ for (; ; ) {
3097
+ if (this.items.length) {
3098
+ const item = this.items.shift();
3099
+ this.putters.shift()?.();
3100
+ return item;
3101
+ }
3102
+ if (this.closed) return null;
3103
+ await new Promise((resolve) => this.getters.push(resolve));
2331
3104
  }
2332
- await new Promise((resolve) => this.waiters.push(resolve));
2333
3105
  }
2334
- release() {
2335
- const next = this.waiters.shift();
2336
- if (next) next();
2337
- else this.n += 1;
3106
+ close() {
3107
+ this.closed = true;
3108
+ for (const wake of this.getters.splice(0)) wake();
3109
+ for (const wake of this.putters.splice(0)) wake();
3110
+ }
3111
+ /**
3112
+ * Like `close()`, but for the error path: DISCARDS whatever is still
3113
+ * queued rather than letting it drain. A batch already in a worker's hands
3114
+ * keeps running (there is no cancelling that), but one that only ever sat
3115
+ * in the queue must never start — the source has already failed, so
3116
+ * spending a full vision-LLM retry budget on it buys nothing.
3117
+ */
3118
+ abort() {
3119
+ this.closed = true;
3120
+ this.items.length = 0;
3121
+ for (const wake of this.getters.splice(0)) wake();
3122
+ for (const wake of this.putters.splice(0)) wake();
2338
3123
  }
2339
3124
  };
2340
3125
  }
@@ -2344,7 +3129,8 @@ var init_vision = __esm({
2344
3129
  var extraction_exports = {};
2345
3130
  __export(extraction_exports, {
2346
3131
  Extracted: () => Extracted,
2347
- extract: () => extract2
3132
+ extract: () => extract2,
3133
+ extractEmbeddedImagesText: () => extractEmbeddedImagesText
2348
3134
  });
2349
3135
  function isPdf(ext, mime) {
2350
3136
  return ext === ".pdf" || mime === "application/pdf";
@@ -2352,14 +3138,50 @@ function isPdf(ext, mime) {
2352
3138
  function isImage(ext, mime) {
2353
3139
  return mime.startsWith("image/") || IMAGE_EXTS.has(ext);
2354
3140
  }
2355
- async function extractEmbeddedImagesText(images, visionLlm, hooks) {
3141
+ async function officeUnreadable(text, images, visionLlm) {
3142
+ if (text.trim()) return { reason: null, unread: 0 };
3143
+ let readable = 0;
3144
+ for (const img of images) {
3145
+ if (!await embeddedImageIsNegligible(img.imageBytes)) readable += 1;
3146
+ }
3147
+ if (!readable) return { reason: null, unread: 0 };
3148
+ return { reason: visionLlm ? "vision_failed" : "needs_vision", unread: readable };
3149
+ }
3150
+ async function embeddedImageIsNegligible(data) {
3151
+ if (data.length < MIN_EMBEDDED_IMAGE_BYTES) return true;
3152
+ const canvas = await tryImport("@napi-rs/canvas");
3153
+ if (!canvas) return false;
3154
+ try {
3155
+ const img = await canvas.loadImage(data);
3156
+ return Math.max(img.width, img.height) < MIN_EMBEDDED_IMAGE_PX;
3157
+ } catch {
3158
+ return false;
3159
+ }
3160
+ }
3161
+ async function extractEmbeddedImagesText(images, visionLlm, hooks, extraction) {
2356
3162
  if (!images.length) return [[], {}];
2357
3163
  try {
2358
3164
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
2359
- return await vision.extractTextFromImages(
2360
- images.map((img) => img.imageBytes),
2361
- { visionLlm }
2362
- );
3165
+ const { createHash: createHash3 } = await import('crypto');
3166
+ const keys = [];
3167
+ const firstAt = /* @__PURE__ */ new Map();
3168
+ const send = [];
3169
+ for (const img of images) {
3170
+ const blob = img.imageBytes;
3171
+ if (await embeddedImageIsNegligible(blob)) {
3172
+ keys.push(null);
3173
+ continue;
3174
+ }
3175
+ const digest = createHash3("sha256").update(blob).digest("hex");
3176
+ if (!firstAt.has(digest)) {
3177
+ firstAt.set(digest, send.length);
3178
+ send.push(blob);
3179
+ }
3180
+ keys.push(digest);
3181
+ }
3182
+ if (!send.length) return [images.map(() => ""), {}];
3183
+ const [texts, tokens] = await vision.extractTextFromImages(send, { visionLlm, extraction });
3184
+ return [keys.map((k) => k == null ? "" : texts[firstAt.get(k)] ?? ""), tokens];
2363
3185
  } catch (exc) {
2364
3186
  emitError(hooks, exc, { stage: "embedded_image_vision" });
2365
3187
  return [[], {}];
@@ -2370,22 +3192,35 @@ async function extract2(content, filename, mime, opts) {
2370
3192
  const ext = getFileExtension(filename);
2371
3193
  const visionLlm = opts.visionLlm ?? null;
2372
3194
  const hooks = opts.hooks;
3195
+ const extraction = opts.extraction ?? null;
2373
3196
  try {
2374
3197
  if (isNonIngestibleMedia(filename, m)) {
2375
3198
  return new Extracted({ text: "", mediaOnly: true });
2376
3199
  }
2377
3200
  if (isPdf(ext, m)) {
2378
3201
  const pdf = await Promise.resolve().then(() => (init_pdf(), pdf_exports));
2379
- return await pdf.extract(content, { visionLlm, hooks });
3202
+ return await pdf.extract(content, { visionLlm, hooks, extraction });
2380
3203
  }
2381
3204
  if (isImage(ext, m)) {
2382
- if (!visionLlm) return new Extracted({ text: "", mediaOnly: true });
3205
+ if (!visionLlm) {
3206
+ return new Extracted({
3207
+ text: "",
3208
+ mediaOnly: true,
3209
+ unreadableReason: "needs_vision",
3210
+ unreadablePages: 1
3211
+ });
3212
+ }
2383
3213
  try {
2384
3214
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
2385
- return await vision.extractImage(content, { visionLlm });
3215
+ return await vision.extractImage(content, { visionLlm, extraction });
2386
3216
  } catch (exc) {
2387
3217
  emitError(hooks, exc, { stage: "image_vision", filename });
2388
- return new Extracted({ text: "", mediaOnly: true });
3218
+ return new Extracted({
3219
+ text: "",
3220
+ mediaOnly: true,
3221
+ unreadableReason: "vision_failed",
3222
+ unreadablePages: 1
3223
+ });
2389
3224
  }
2390
3225
  }
2391
3226
  if (ext === ".docx" || m === DOCX_MIME) {
@@ -2393,10 +3228,11 @@ async function extract2(content, filename, mime, opts) {
2393
3228
  let providerTokens = {};
2394
3229
  let llmPictures = 0;
2395
3230
  let combined = text;
3231
+ let images = [];
2396
3232
  if (visionLlm) {
2397
3233
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
2398
- const images = await vision.extractDocxImages(content);
2399
- const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks);
3234
+ images = await vision.extractDocxImages(content);
3235
+ const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks, extraction);
2400
3236
  for (let i = 0; i < texts.length; i++) {
2401
3237
  const imgText = texts[i];
2402
3238
  if (imgText) {
@@ -2407,12 +3243,18 @@ ${imgText}`;
2407
3243
  }
2408
3244
  }
2409
3245
  providerTokens = tokens;
3246
+ } else if (!text.trim()) {
3247
+ const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
3248
+ images = await vision.extractDocxImages(content);
2410
3249
  }
3250
+ const { reason, unread } = await officeUnreadable(combined, images, visionLlm);
2411
3251
  return new Extracted({
2412
3252
  text: combined,
2413
3253
  pages: await countDocxPages(content),
2414
3254
  providerTokens,
2415
- llmPictures
3255
+ llmPictures,
3256
+ unreadableReason: reason,
3257
+ unreadablePages: unread
2416
3258
  });
2417
3259
  }
2418
3260
  if (ext === ".pptx" || m === PPTX_MIME) {
@@ -2420,10 +3262,11 @@ ${imgText}`;
2420
3262
  let providerTokens = {};
2421
3263
  let llmPictures = 0;
2422
3264
  let combined = text;
3265
+ let images = [];
2423
3266
  if (visionLlm) {
2424
3267
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
2425
- const images = await vision.extractPptxImages(content);
2426
- const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks);
3268
+ images = await vision.extractPptxImages(content);
3269
+ const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks, extraction);
2427
3270
  for (let i = 0; i < texts.length; i++) {
2428
3271
  const imgText = texts[i];
2429
3272
  if (imgText) {
@@ -2435,12 +3278,18 @@ ${imgText}`;
2435
3278
  }
2436
3279
  }
2437
3280
  providerTokens = tokens;
3281
+ } else if (!text.trim()) {
3282
+ const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
3283
+ images = await vision.extractPptxImages(content);
2438
3284
  }
3285
+ const { reason, unread } = await officeUnreadable(combined, images, visionLlm);
2439
3286
  return new Extracted({
2440
3287
  text: combined,
2441
3288
  slides: await countPptxSlides(content),
2442
3289
  providerTokens,
2443
- llmPictures
3290
+ llmPictures,
3291
+ unreadableReason: reason,
3292
+ unreadablePages: unread
2444
3293
  });
2445
3294
  }
2446
3295
  if (ext === ".xlsx" || m === XLSX_MIME) {
@@ -2464,9 +3313,10 @@ ${imgText}`;
2464
3313
  return new Extracted({ text: content.toString("utf8") });
2465
3314
  }
2466
3315
  }
2467
- var IMAGE_EXTS, Extracted;
3316
+ var IMAGE_EXTS, Extracted, MIN_EMBEDDED_IMAGE_BYTES, MIN_EMBEDDED_IMAGE_PX;
2468
3317
  var init_extraction = __esm({
2469
3318
  "src/extraction/index.ts"() {
3319
+ init_extras();
2470
3320
  init_hooks();
2471
3321
  init_files();
2472
3322
  IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp"]);
@@ -2478,6 +3328,10 @@ var init_extraction = __esm({
2478
3328
  providerTokens;
2479
3329
  llmPictures;
2480
3330
  isMarkdown;
3331
+ /** See `UnreadableReason`. `mediaOnly` is the same idea for whole files and
3332
+ * stays set alongside this, so existing callers keep working. */
3333
+ unreadableReason;
3334
+ unreadablePages;
2481
3335
  constructor(init) {
2482
3336
  this.text = init.text;
2483
3337
  this.pages = init.pages ?? null;
@@ -2486,8 +3340,12 @@ var init_extraction = __esm({
2486
3340
  this.providerTokens = init.providerTokens ?? {};
2487
3341
  this.llmPictures = init.llmPictures ?? 0;
2488
3342
  this.isMarkdown = init.isMarkdown ?? false;
3343
+ this.unreadableReason = init.unreadableReason ?? null;
3344
+ this.unreadablePages = init.unreadablePages ?? 0;
2489
3345
  }
2490
3346
  };
3347
+ MIN_EMBEDDED_IMAGE_BYTES = 3 * 1024;
3348
+ MIN_EMBEDDED_IMAGE_PX = 100;
2491
3349
  }
2492
3350
  });
2493
3351
 
@@ -2532,6 +3390,9 @@ __export(db_exports, {
2532
3390
  function stripSqlNoise(sql) {
2533
3391
  return sql.replace(SQL_NOISE_RE, (m) => " ".repeat(m.length));
2534
3392
  }
3393
+ function stripSqlNoiseBackslash(sql) {
3394
+ return sql.replace(SQL_NOISE_BACKSLASH_RE, (m) => " ".repeat(m.length));
3395
+ }
2535
3396
  function hasKeyword(sqlUpper, keyword) {
2536
3397
  return new RegExp(`\\b${keyword}\\b`).test(sqlUpper);
2537
3398
  }
@@ -2660,24 +3521,28 @@ async function executeQueryAsync(config, sql, maxRows, accessModeRaw) {
2660
3521
  `Invalid access_mode ${JSON.stringify(accessModeRaw)}; must be one of ${ALLOWED_ACCESS_MODES}`
2661
3522
  );
2662
3523
  }
2663
- const sqlUpper = stripSqlNoise(sql).trim().toUpperCase();
3524
+ const sqlVariants = [
3525
+ stripSqlNoise(sql).trim().toUpperCase(),
3526
+ stripSqlNoiseBackslash(sql).trim().toUpperCase()
3527
+ ];
3528
+ const sqlUpper = sqlVariants[0];
2664
3529
  for (const keyword of ALWAYS_BLOCKED) {
2665
- if (hasKeyword(sqlUpper, keyword)) {
3530
+ if ([...sqlVariants, sql.toUpperCase()].some((v) => hasKeyword(v, keyword))) {
2666
3531
  return { success: false, error: `${keyword} queries are not allowed` };
2667
3532
  }
2668
3533
  }
2669
3534
  if (accessMode === "readonly") {
2670
- if (!sqlUpper.startsWith("SELECT") && !sqlUpper.startsWith("WITH")) {
3535
+ if (!sqlVariants.every((v) => v.startsWith("SELECT") || v.startsWith("WITH"))) {
2671
3536
  return { success: false, error: "Only SELECT queries are allowed in read-only mode" };
2672
3537
  }
2673
3538
  for (const keyword of READONLY_BLOCKED) {
2674
- if (hasKeyword(sqlUpper, keyword)) {
3539
+ if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
2675
3540
  return { success: false, error: `${keyword} queries are not allowed in read-only mode` };
2676
3541
  }
2677
3542
  }
2678
3543
  } else if (accessMode === "readwrite") {
2679
3544
  for (const keyword of READWRITE_BLOCKED) {
2680
- if (hasKeyword(sqlUpper, keyword)) {
3545
+ if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
2681
3546
  return { success: false, error: `${keyword} queries are not allowed in read-write mode` };
2682
3547
  }
2683
3548
  }
@@ -2811,7 +3676,7 @@ async function getSchemaText(config, selectedTables) {
2811
3676
  }
2812
3677
  return rowsToText(schemaRows);
2813
3678
  }
2814
- var require2, MAX_ROWS, CONNECT_TIMEOUT, QUERY_TIMEOUT_MS, ALWAYS_BLOCKED, READONLY_BLOCKED, READWRITE_BLOCKED, ALLOWED_ACCESS_MODES, SQL_NOISE_RE;
3679
+ 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;
2815
3680
  var init_db = __esm({
2816
3681
  "src/tools/executors/db.ts"() {
2817
3682
  init_errors();
@@ -2820,10 +3685,22 @@ var init_db = __esm({
2820
3685
  CONNECT_TIMEOUT = 10;
2821
3686
  QUERY_TIMEOUT_MS = 3e4;
2822
3687
  ALWAYS_BLOCKED = ["GRANT", "REVOKE"];
2823
- READONLY_BLOCKED = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE"];
2824
- READWRITE_BLOCKED = ["DELETE", "DROP", "TRUNCATE"];
3688
+ READONLY_BLOCKED = [
3689
+ "INSERT",
3690
+ "UPDATE",
3691
+ "DELETE",
3692
+ "MERGE",
3693
+ "DROP",
3694
+ "ALTER",
3695
+ "CREATE",
3696
+ "TRUNCATE",
3697
+ "DO",
3698
+ "CALL"
3699
+ ];
3700
+ READWRITE_BLOCKED = ["DELETE", "DROP", "TRUNCATE", "DO", "CALL"];
2825
3701
  ALLOWED_ACCESS_MODES = ["readonly", "readwrite", "full"];
2826
- SQL_NOISE_RE = /'(?:[^']|'')*'|\$([A-Za-z_]\w*)?\$.*?\$\1?\$|--[^\n]*|\/\*[\s\S]*?\*\//g;
3702
+ SQL_NOISE_RE = /\b[eE]'(?:[^'\\]|\\[\s\S]|'')*'|'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
3703
+ SQL_NOISE_BACKSLASH_RE = /'(?:[^'\\]|\\[\s\S]|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
2827
3704
  }
2828
3705
  });
2829
3706
  function mulberry322(seed) {
@@ -3655,7 +4532,8 @@ var retrieval_exports = {};
3655
4532
  __export(retrieval_exports, {
3656
4533
  buildGraphRanked: () => buildGraphRanked,
3657
4534
  corpusIsAclUniform: () => corpusIsAclUniform,
3658
- shouldUseCommunitySummaries: () => shouldUseCommunitySummaries
4535
+ shouldUseCommunitySummaries: () => shouldUseCommunitySummaries,
4536
+ vectorSeedIds: () => vectorSeedIds
3659
4537
  });
3660
4538
  function vecLiteral(vector) {
3661
4539
  return `[${vector.map((x) => Number(x)).join(",")}]`;
@@ -3681,8 +4559,8 @@ async function deriveQueryEntities(pool, chunkIds, opts) {
3681
4559
  JOIN context_engine_chunks c ON c.id = ce.chunk_id
3682
4560
  WHERE ce.chunk_id = ANY($1::uuid[]) ${SCOPE2}
3683
4561
  GROUP BY e.normalized_name, e.name, e.type
3684
- ORDER BY freq DESC LIMIT $4`,
3685
- [chunkIds, opts.sourceIds, opts.principals, ENTITY_LIMIT]
4562
+ ORDER BY freq DESC LIMIT $5`,
4563
+ [chunkIds, opts.sourceIds, opts.documentIds ?? null, opts.principals, ENTITY_LIMIT]
3686
4564
  );
3687
4565
  return result.rows.map((r) => ({
3688
4566
  normalized_name: r.normalized_name,
@@ -3747,8 +4625,8 @@ async function computeCommunityScores(pool, chunkIds, vector, opts) {
3747
4625
  `SELECT ce.chunk_id::text, ce.entity_id::text
3748
4626
  FROM context_engine_chunk_entities ce
3749
4627
  JOIN context_engine_chunks c ON c.id = ce.chunk_id
3750
- WHERE ce.chunk_id = ANY($1::uuid[]) AND ce.entity_id = ANY($4::uuid[]) ${SCOPE2}`,
3751
- [chunkIds, opts.sourceIds, opts.principals, Object.keys(entityScore)]
4628
+ WHERE ce.chunk_id = ANY($1::uuid[]) AND ce.entity_id = ANY($5::uuid[]) ${SCOPE2}`,
4629
+ [chunkIds, opts.sourceIds, opts.documentIds ?? null, opts.principals, Object.keys(entityScore)]
3752
4630
  );
3753
4631
  const chunkScores = {};
3754
4632
  for (const row of result.rows) {
@@ -3757,13 +4635,29 @@ async function computeCommunityScores(pool, chunkIds, vector, opts) {
3757
4635
  return chunkScores;
3758
4636
  }
3759
4637
  async function vectorSeedIds(pool, vector, opts) {
3760
- const result = await pool.query(
3761
- `SELECT c.id::text FROM context_engine_chunks c
3762
- WHERE c.embedding IS NOT NULL ${SCOPE2}
3763
- ORDER BY c.embedding <=> CAST($1 AS vector) LIMIT $4`,
3764
- [vecLiteral(vector), opts.sourceIds, opts.principals, SEED_LIMIT]
3765
- );
3766
- return result.rows.map((r) => String(r.id));
4638
+ const binds = [opts.sourceIds, opts.documentIds ?? null, opts.principals];
4639
+ const scoped = binds.some((v) => v !== null);
4640
+ const client = await pool.connect();
4641
+ try {
4642
+ await client.query("BEGIN");
4643
+ await opts.backend?.tuneAnnScan?.(client, SEED_LIMIT, scoped);
4644
+ const result = await client.query(
4645
+ `SELECT c.id::text FROM context_engine_chunks c
4646
+ WHERE c.embedding IS NOT NULL ${SCOPE2}
4647
+ ORDER BY c.embedding <=> CAST($1 AS vector) LIMIT $5`,
4648
+ [vecLiteral(vector), ...binds, SEED_LIMIT]
4649
+ );
4650
+ await client.query("COMMIT");
4651
+ return result.rows.map((r) => String(r.id));
4652
+ } catch (err) {
4653
+ try {
4654
+ await client.query("ROLLBACK");
4655
+ } catch {
4656
+ }
4657
+ throw err;
4658
+ } finally {
4659
+ client.release();
4660
+ }
3767
4661
  }
3768
4662
  async function buildGraphRanked(query, opts) {
3769
4663
  try {
@@ -3772,11 +4666,18 @@ async function buildGraphRanked(query, opts) {
3772
4666
  const vector = vectors[0] ? [...vectors[0]] : null;
3773
4667
  if (!vector) return [];
3774
4668
  const sourceIds = opts.sourceIds ?? null;
4669
+ const documentIds = opts.documentIds ?? null;
3775
4670
  const principals = opts.principals ?? null;
3776
- const seeds = await vectorSeedIds(opts.pool, vector, { sourceIds, principals });
4671
+ const seeds = await vectorSeedIds(opts.pool, vector, {
4672
+ sourceIds,
4673
+ documentIds,
4674
+ principals,
4675
+ backend: opts.backend
4676
+ });
3777
4677
  if (!seeds.length) return [];
3778
4678
  const queryEntities = await deriveQueryEntities(opts.pool, seeds.slice(0, TOP_SEEDS_FOR_ENTITIES), {
3779
4679
  sourceIds,
4680
+ documentIds,
3780
4681
  principals
3781
4682
  });
3782
4683
  const entityNorms = queryEntities.map((e) => String(e.normalized_name));
@@ -3798,6 +4699,7 @@ async function buildGraphRanked(query, opts) {
3798
4699
  const relScores = await computeRelationshipScores(opts.pool, allIds, entityNorms);
3799
4700
  const commScores = await computeCommunityScores(opts.pool, allIds, vector, {
3800
4701
  sourceIds,
4702
+ documentIds,
3801
4703
  principals,
3802
4704
  useSummaries
3803
4705
  });
@@ -3849,7 +4751,8 @@ var init_retrieval = __esm({
3849
4751
  TOP_SEEDS_FOR_ENTITIES = 20;
3850
4752
  SCOPE2 = `
3851
4753
  AND ($2::text[] IS NULL OR c.source_id = ANY($2::text[]))
3852
- AND ($3::text[] IS NULL OR c.acl IS NULL OR c.acl && $3::text[])
4754
+ AND ($3::uuid[] IS NULL OR c.document_id = ANY($3::uuid[]))
4755
+ AND ($4::text[] IS NULL OR c.acl IS NULL OR c.acl && $4::text[])
3853
4756
  `;
3854
4757
  }
3855
4758
  });
@@ -4434,381 +5337,96 @@ function splitIntoChunks(text, maxWords = 180, overlap = 18) {
4434
5337
  for (const ln of (text || "").split(/\r\n|\n|\r/)) {
4435
5338
  const stripped = ln.trim();
4436
5339
  if (!stripped) {
4437
- pendingBlank = units.length > 0;
4438
- continue;
4439
- }
4440
- const lineSep = pendingBlank ? "\n\n" : "\n";
4441
- pendingBlank = false;
4442
- if (HEADING_OR_BULLET_RE.test(ln) || stripped.length <= 120) {
4443
- units.push([lineSep, stripped, tokenize(stripped).length]);
4444
- } else {
4445
- let first = true;
4446
- for (const sent of splitSentences(ln)) {
4447
- const s = sent.trim();
4448
- if (s) {
4449
- units.push([first ? lineSep : " ", s, tokenize(s).length]);
4450
- first = false;
4451
- }
4452
- }
4453
- }
4454
- }
4455
- const normalizedUnits = [];
4456
- for (const [uSep, uText, uTokens] of units) {
4457
- if (uTokens <= maxWords) {
4458
- normalizedUnits.push([uSep, uText, uTokens]);
4459
- continue;
4460
- }
4461
- const windowChars = maxWords * 4;
4462
- const uChars = [...uText];
4463
- let pos = 0;
4464
- let pieceSep = uSep;
4465
- while (pos < uChars.length) {
4466
- let pieceChars = uChars.slice(pos, pos + windowChars);
4467
- let piece = pieceChars.join("");
4468
- if (pos + windowChars < uChars.length) {
4469
- const ws = piece.lastIndexOf(" ");
4470
- if (ws > Math.floor(windowChars / 2)) {
4471
- piece = piece.slice(0, ws);
4472
- pieceChars = [...piece];
4473
- }
4474
- }
4475
- let pieceTokens = tokenize(piece);
4476
- if (pieceTokens.length > maxWords) {
4477
- piece = pieceChars.slice(0, maxWords).join("");
4478
- pieceTokens = tokenize(piece);
4479
- }
4480
- normalizedUnits.push([pieceSep, piece, pieceTokens.length]);
4481
- pos += codePointLength(piece);
4482
- pieceSep = "";
4483
- }
4484
- }
4485
- if (!normalizedUnits.some(([, , t]) => t)) return [];
4486
- const chunks = [];
4487
- let lastKept = null;
4488
- let cur = [];
4489
- let curTokens = 0;
4490
- const flush = () => {
4491
- const chunk = assembleUnits(cur);
4492
- if (chunk && (lastKept === null || jaccardSim(chunk, lastKept) < 0.92)) {
4493
- chunks.push(chunk);
4494
- lastKept = chunk;
4495
- }
4496
- const carried = [];
4497
- let carriedTokens = 0;
4498
- for (let i = cur.length - 1; i >= 0; i--) {
4499
- const prev = cur[i];
4500
- if (prev[2] === 0 || carriedTokens + prev[2] > overlap) break;
4501
- carried.unshift(prev);
4502
- carriedTokens += prev[2];
4503
- }
4504
- cur = carried;
4505
- curTokens = carriedTokens;
4506
- };
4507
- for (const unit of normalizedUnits) {
4508
- if (cur.length && curTokens + unit[2] > maxWords) flush();
4509
- cur.push(unit);
4510
- curTokens += unit[2];
4511
- }
4512
- if (cur.length) {
4513
- const chunk = assembleUnits(cur);
4514
- if (chunk && (lastKept === null || jaccardSim(chunk, lastKept) < 0.92)) {
4515
- chunks.push(chunk);
4516
- }
4517
- }
4518
- return chunks;
4519
- }
4520
-
4521
- // src/actions.ts
4522
- init_errors();
4523
- init_hooks();
4524
- init_llm();
4525
- function encryptDict(data, key) {
4526
- const nonce = randomBytes(12);
4527
- const cipher = createCipheriv("aes-256-gcm", key, nonce);
4528
- const plaintext = Buffer.from(JSON.stringify(data), "utf8");
4529
- const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
4530
- const tag = cipher.getAuthTag();
4531
- return Buffer.concat([nonce, ciphertext, tag]).toString("base64");
4532
- }
4533
- function decryptDict(token, key) {
4534
- const raw = Buffer.from(token, "base64");
4535
- const nonce = raw.subarray(0, 12);
4536
- const tag = raw.subarray(raw.length - 16);
4537
- const ciphertext = raw.subarray(12, raw.length - 16);
4538
- const decipher = createDecipheriv("aes-256-gcm", key, nonce);
4539
- decipher.setAuthTag(tag);
4540
- const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
4541
- return JSON.parse(plaintext.toString("utf8"));
4542
- }
4543
- function getSecretKey(config) {
4544
- const secretKey = config.secretKey;
4545
- if (!secretKey) {
4546
- throw new Error(
4547
- "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."
4548
- );
4549
- }
4550
- return Buffer.from(secretKey, "base64url");
4551
- }
4552
- function hmacSha256Hex(key, value) {
4553
- const k = typeof key === "string" ? Buffer.from(key) : key;
4554
- return createHmac("sha256", k).update(value, "utf8").digest("hex");
4555
- }
4556
-
4557
- // src/redaction.ts
4558
- init_hooks();
4559
- var BUILTIN_DETECTOR_NAMES = /* @__PURE__ */ new Set(["email", "phone", "ssn", "credit_card", "iban", "api_key"]);
4560
- var RedactionRule = class {
4561
- name;
4562
- detector;
4563
- pattern;
4564
- field;
4565
- action;
4566
- placeholder;
4567
- applyAt;
4568
- unless;
4569
- compiled = null;
4570
- constructor(init) {
4571
- this.name = init.name;
4572
- this.detector = init.detector ?? null;
4573
- this.pattern = init.pattern ?? null;
4574
- this.field = init.field ?? null;
4575
- this.action = init.action ?? "mask";
4576
- this.placeholder = init.placeholder ?? null;
4577
- this.applyAt = init.applyAt ?? "output";
4578
- this.unless = init.unless ?? [];
4579
- this.validate();
4580
- }
4581
- validate() {
4582
- if (this.field !== null) {
4583
- throw new Error(
4584
- `rule '${this.name}': field-targeted rules are not implemented yet; use detector or pattern instead`
4585
- );
4586
- }
4587
- const targets = [this.detector, this.pattern].filter((t) => t !== null);
4588
- if (targets.length !== 1) {
4589
- throw new Error(`rule '${this.name}': exactly one of detector/pattern must be set`);
4590
- }
4591
- if (this.pattern !== null) {
4592
- try {
4593
- this.compiled = new RegExp(this.pattern, "g");
4594
- } catch (exc) {
4595
- throw new Error(`rule '${this.name}': invalid regex: ${exc}`);
4596
- }
4597
- }
4598
- if (this.unless.length && this.applyAt === "ingest") {
4599
- throw new Error(
4600
- `rule '${this.name}': unless is output-time only and cannot be set on an applyAt='ingest' rule`
4601
- );
4602
- }
4603
- }
4604
- patternRe() {
4605
- return this.compiled;
4606
- }
4607
- effectivePlaceholder() {
4608
- return this.placeholder || `[${this.name.toUpperCase()}]`;
4609
- }
4610
- };
4611
- var RedactionPolicy = class {
4612
- rules;
4613
- customDetectors;
4614
- constructor(init = {}) {
4615
- this.rules = (init.rules ?? []).map((r) => r instanceof RedactionRule ? r : new RedactionRule(r));
4616
- this.customDetectors = init.customDetectors ?? {};
4617
- const seen = /* @__PURE__ */ new Set();
4618
- for (const rule of this.rules) {
4619
- if (seen.has(rule.name)) throw new Error(`duplicate rule name: '${rule.name}'`);
4620
- seen.add(rule.name);
4621
- if (rule.detector !== null) {
4622
- const known = BUILTIN_DETECTOR_NAMES.has(rule.detector) || rule.detector in this.customDetectors;
4623
- if (!known) {
4624
- throw new Error(
4625
- `rule '${rule.name}': unknown detector '${rule.detector}' (not built-in and not in customDetectors)`
4626
- );
4627
- }
4628
- }
4629
- }
4630
- }
4631
- isEmpty() {
4632
- return this.rules.length === 0;
4633
- }
4634
- };
4635
- var EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
4636
- var PHONE_RE = /\+\d[\d\s-]{7,17}\d/g;
4637
- var SSN_RE = /(?<!\d)\d{3}-\d{2}-\d{4}(?!\d)/g;
4638
- var CARD_RE = /(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/g;
4639
- var IBAN_RE = /(?<![A-Za-z0-9])[A-Z]{2}\d{2}[A-Z0-9]{10,30}(?![A-Za-z0-9])/g;
4640
- var API_KEY_ALNUM = "A-Za-z0-9_\\-+/=";
4641
- var API_KEY_PREFIX_RE = new RegExp(
4642
- `(?<![${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}])`,
4643
- "g"
4644
- );
4645
- var API_KEY_GENERIC_RE = new RegExp(
4646
- `(?<![${API_KEY_ALNUM}])[${API_KEY_ALNUM}]{24,}(?![${API_KEY_ALNUM}])`,
4647
- "g"
4648
- );
4649
- 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}$/;
4650
- function spansFrom(re, text) {
4651
- const out = [];
4652
- re.lastIndex = 0;
4653
- let m = re.exec(text);
4654
- while (m !== null) {
4655
- out.push([m.index, m.index + m[0].length]);
4656
- if (m[0].length === 0) re.lastIndex++;
4657
- m = re.exec(text);
4658
- }
4659
- return out;
4660
- }
4661
- function luhnOk(digits) {
4662
- let total = 0;
4663
- const rev = [...digits].reverse();
4664
- for (let i = 0; i < rev.length; i++) {
4665
- let d = Number(rev[i]);
4666
- if (i % 2 === 1) {
4667
- d *= 2;
4668
- if (d > 9) d -= 9;
4669
- }
4670
- total += d;
4671
- }
4672
- return total % 10 === 0;
4673
- }
4674
- function detectCreditCard(text) {
4675
- const spans = [];
4676
- for (const [start, end] of spansFrom(CARD_RE, text)) {
4677
- const digits = text.slice(start, end).replace(/[ -]/g, "");
4678
- if (digits.length >= 13 && digits.length <= 19 && luhnOk(digits)) {
4679
- spans.push([start, end]);
4680
- }
4681
- }
4682
- return spans;
4683
- }
4684
- function looksLikeGenericSecret(token) {
4685
- if (UUID_RE.test(token)) return false;
4686
- let hasUpper = false;
4687
- let hasLower = false;
4688
- let hasDigit = false;
4689
- for (const c of token) {
4690
- if (c >= "A" && c <= "Z") hasUpper = true;
4691
- else if (c >= "a" && c <= "z") hasLower = true;
4692
- else if (c >= "0" && c <= "9") hasDigit = true;
4693
- }
4694
- return hasUpper && hasLower && hasDigit;
4695
- }
4696
- function detectApiKey(text) {
4697
- const spans = spansFrom(API_KEY_PREFIX_RE, text);
4698
- for (const [start, end] of spansFrom(API_KEY_GENERIC_RE, text)) {
4699
- if (spans.some(([s, e]) => start < e && s < end)) continue;
4700
- if (looksLikeGenericSecret(text.slice(start, end))) spans.push([start, end]);
4701
- }
4702
- return spans;
4703
- }
4704
- var BUILTIN = {
4705
- email: (t) => spansFrom(EMAIL_RE, t),
4706
- phone: (t) => spansFrom(PHONE_RE, t),
4707
- ssn: (t) => spansFrom(SSN_RE, t),
4708
- credit_card: detectCreditCard,
4709
- iban: (t) => spansFrom(IBAN_RE, t),
4710
- api_key: detectApiKey
4711
- };
4712
- function detectBuiltin(name, text) {
4713
- const detector = BUILTIN[name];
4714
- if (!detector) throw new Error(`unknown built-in detector: ${name}`);
4715
- if (!text) return [];
4716
- return detector(text).sort((a, b) => a[0] - b[0]);
4717
- }
4718
- function ruleApplies(rule, opts) {
4719
- if (rule.applyAt !== "both" && rule.applyAt !== opts.phase) return false;
4720
- if (opts.phase === "ingest") return true;
4721
- if (!rule.unless.length) return true;
4722
- if (opts.principals === null) return false;
4723
- const held = new Set(opts.principals);
4724
- return !rule.unless.some((p) => held.has(p));
4725
- }
4726
- function spansForRule(rule, text, policy) {
4727
- if (rule.pattern !== null) {
4728
- const re = rule.patternRe() ?? new RegExp(rule.pattern, "g");
4729
- return spansFrom(re, text);
4730
- }
4731
- if (rule.detector !== null) {
4732
- const custom = policy.customDetectors[rule.detector];
4733
- if (custom) return [...custom(text)];
4734
- return detectBuiltin(rule.detector, text);
4735
- }
4736
- return [];
4737
- }
4738
- var HASH_TOKEN_CHARS = 16;
4739
- function hashToken(value, secretKey) {
4740
- const key = Buffer.isBuffer(secretKey) ? secretKey : Buffer.from(String(secretKey ?? ""));
4741
- return hmacSha256Hex(key, value).slice(0, HASH_TOKEN_CHARS);
4742
- }
4743
- function applyRedaction(text, policy, opts) {
4744
- if (!text || policy.isEmpty()) return [text, {}];
4745
- const principals = opts.principals ?? null;
4746
- const collected = [];
4747
- const fired = [];
4748
- const failed = [];
4749
- for (const rule of policy.rules) {
4750
- if (!ruleApplies(rule, { phase: opts.phase, principals })) continue;
4751
- if (rule.action === "hash" && !opts.secretKey) {
4752
- throw new Error(
4753
- `rule '${rule.name}': action='hash' requires a non-empty secretKey (an unkeyed HMAC is a reversible pseudonym, not a redaction)`
4754
- );
5340
+ pendingBlank = units.length > 0;
5341
+ continue;
4755
5342
  }
4756
- try {
4757
- const raw = spansForRule(rule, text, policy);
4758
- const ruleSpans = [];
4759
- let invalid = false;
4760
- for (const item of raw) {
4761
- const start = item?.[0];
4762
- const end = item?.[1];
4763
- if (typeof start !== "number" || typeof end !== "number") {
4764
- invalid = true;
4765
- continue;
4766
- }
4767
- if (!(start >= 0 && start < end && end <= text.length)) {
4768
- invalid = true;
4769
- continue;
5343
+ const lineSep = pendingBlank ? "\n\n" : "\n";
5344
+ pendingBlank = false;
5345
+ if (HEADING_OR_BULLET_RE.test(ln) || stripped.length <= 120) {
5346
+ units.push([lineSep, stripped, tokenize(stripped).length]);
5347
+ } else {
5348
+ let first = true;
5349
+ for (const sent of splitSentences(ln)) {
5350
+ const s = sent.trim();
5351
+ if (s) {
5352
+ units.push([first ? lineSep : " ", s, tokenize(s).length]);
5353
+ first = false;
4770
5354
  }
4771
- ruleSpans.push([start, end]);
4772
5355
  }
4773
- if (invalid) failed.push(rule.name);
4774
- for (const [s, e] of ruleSpans) collected.push([s, e, rule]);
4775
- } catch (exc) {
4776
- failed.push(rule.name);
4777
- if (opts.hooks) emitError(opts.hooks, exc, { stage: "redaction", rule: rule.name });
4778
5356
  }
4779
5357
  }
4780
- if (!collected.length) {
4781
- if (failed.length) return [text, { rules_fired: [], spans: 0, rules_failed: failed }];
4782
- return [text, {}];
4783
- }
4784
- collected.sort((a, b) => a[0] - b[0] || b[1] - b[0] - (a[1] - a[0]));
4785
- const merged = [];
4786
- for (const [start, end, rule] of collected) {
4787
- const last = merged[merged.length - 1];
4788
- if (last && start < last[1]) {
4789
- if (end > last[1]) last[1] = end;
5358
+ const normalizedUnits = [];
5359
+ for (const [uSep, uText, uTokens] of units) {
5360
+ if (uTokens <= maxWords) {
5361
+ normalizedUnits.push([uSep, uText, uTokens]);
4790
5362
  continue;
4791
5363
  }
4792
- merged.push([start, end, rule]);
5364
+ const windowChars = maxWords * 4;
5365
+ const uChars = [...uText];
5366
+ let pos = 0;
5367
+ let pieceSep = uSep;
5368
+ while (pos < uChars.length) {
5369
+ let pieceChars = uChars.slice(pos, pos + windowChars);
5370
+ let piece = pieceChars.join("");
5371
+ if (pos + windowChars < uChars.length) {
5372
+ const ws = piece.lastIndexOf(" ");
5373
+ if (ws > Math.floor(windowChars / 2)) {
5374
+ piece = piece.slice(0, ws);
5375
+ pieceChars = [...piece];
5376
+ }
5377
+ }
5378
+ let pieceTokens = tokenize(piece);
5379
+ if (pieceTokens.length > maxWords) {
5380
+ piece = pieceChars.slice(0, maxWords).join("");
5381
+ pieceTokens = tokenize(piece);
5382
+ }
5383
+ normalizedUnits.push([pieceSep, piece, pieceTokens.length]);
5384
+ pos += codePointLength(piece);
5385
+ pieceSep = "";
5386
+ }
4793
5387
  }
4794
- const out = [];
4795
- let cursor = 0;
4796
- for (const [start, end, rule] of merged) {
4797
- out.push(text.slice(cursor, start));
4798
- const original = text.slice(start, end);
4799
- if (rule.action === "mask") out.push(rule.effectivePlaceholder());
4800
- else if (rule.action === "hash") {
4801
- out.push(`[${rule.name.toUpperCase()}:${hashToken(original, opts.secretKey)}]`);
5388
+ if (!normalizedUnits.some(([, , t]) => t)) return [];
5389
+ const chunks = [];
5390
+ let lastKept = null;
5391
+ let cur = [];
5392
+ let curTokens = 0;
5393
+ const flush = () => {
5394
+ const chunk = assembleUnits(cur);
5395
+ if (chunk && (lastKept === null || jaccardSim(chunk, lastKept) < 0.92)) {
5396
+ chunks.push(chunk);
5397
+ lastKept = chunk;
4802
5398
  }
4803
- if (!fired.includes(rule.name)) fired.push(rule.name);
4804
- cursor = end;
5399
+ const carried = [];
5400
+ let carriedTokens = 0;
5401
+ for (let i = cur.length - 1; i >= 0; i--) {
5402
+ const prev = cur[i];
5403
+ if (prev[2] === 0 || carriedTokens + prev[2] > overlap) break;
5404
+ carried.unshift(prev);
5405
+ carriedTokens += prev[2];
5406
+ }
5407
+ cur = carried;
5408
+ curTokens = carriedTokens;
5409
+ };
5410
+ for (const unit of normalizedUnits) {
5411
+ if (cur.length && curTokens + unit[2] > maxWords) flush();
5412
+ cur.push(unit);
5413
+ curTokens += unit[2];
4805
5414
  }
4806
- out.push(text.slice(cursor));
4807
- const note = { rules_fired: fired, spans: merged.length };
4808
- if (failed.length) note.rules_failed = failed;
4809
- return [out.join(""), note];
5415
+ if (cur.length) {
5416
+ const chunk = assembleUnits(cur);
5417
+ if (chunk && (lastKept === null || jaccardSim(chunk, lastKept) < 0.92)) {
5418
+ chunks.push(chunk);
5419
+ }
5420
+ }
5421
+ return chunks;
4810
5422
  }
4811
5423
 
5424
+ // src/actions.ts
5425
+ init_errors();
5426
+ init_hooks();
5427
+ init_llm();
5428
+ init_redaction();
5429
+
4812
5430
  // src/sandbox.ts
4813
5431
  init_errors();
4814
5432
  var DEFAULT_TIMEOUT = 30;
@@ -5347,6 +5965,39 @@ async function nearestField(pool, vector, sourceIds, docType) {
5347
5965
  if (similarity < FIELD_RESOLUTION_SIMILARITY_FLOOR) return null;
5348
5966
  return String(row.key_norm);
5349
5967
  }
5968
+
5969
+ // src/sentinels.ts
5970
+ var UNSET = /* @__PURE__ */ Symbol.for("context_engine.UNSET");
5971
+ var TRUSTED = /* @__PURE__ */ Symbol.for("context_engine.TRUSTED");
5972
+ var warnedMethods = /* @__PURE__ */ new Set();
5973
+ function resolvePrincipals(value, method) {
5974
+ if (value === TRUSTED) return null;
5975
+ if (value === void 0 || value === null) {
5976
+ if (!warnedMethods.has(method)) {
5977
+ warnedMethods.add(method);
5978
+ process.emitWarning(
5979
+ `${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.`,
5980
+ { type: "DeprecationWarning", code: "CE_PRINCIPALS_NULL" }
5981
+ );
5982
+ }
5983
+ return null;
5984
+ }
5985
+ if (!Array.isArray(value)) {
5986
+ throw new TypeError(
5987
+ `${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.`
5988
+ );
5989
+ }
5990
+ return value;
5991
+ }
5992
+
5993
+ // src/tools/acl.ts
5994
+ function aclVisible(acl, principals) {
5995
+ if (principals === null || principals === TRUSTED) return true;
5996
+ if (acl == null) return true;
5997
+ if (!acl.length) return false;
5998
+ const held = new Set(principals ?? []);
5999
+ return acl.some((p) => held.has(p));
6000
+ }
5350
6001
  init_extraction();
5351
6002
  init_hooks();
5352
6003
  var TIMEOUT_MS2 = 3e4;
@@ -5434,6 +6085,7 @@ async function pollEmbeddingBatch(cfg2, batchId, opts = {}) {
5434
6085
  }
5435
6086
 
5436
6087
  // src/ingest.ts
6088
+ init_redaction();
5437
6089
  init_usage();
5438
6090
  var MODE_RANK = { hybrid: 0, graph: 1 };
5439
6091
  var EMBED_BATCH = 256;
@@ -5520,7 +6172,9 @@ async function prepare(opts) {
5520
6172
  mediaOnly: false,
5521
6173
  providerTokens: {},
5522
6174
  contentHash: calculateContentHash({ fullText: clean }),
5523
- isMarkdown: false
6175
+ isMarkdown: false,
6176
+ unreadableReason: null,
6177
+ unreadablePages: 0
5524
6178
  };
5525
6179
  }
5526
6180
  const content = opts.content;
@@ -5528,11 +6182,12 @@ async function prepare(opts) {
5528
6182
  const mime = mimeForFilename(fname) || "";
5529
6183
  const extracted = await extract2(content, fname, mime || null, {
5530
6184
  visionLlm: opts.config.visionLlm,
6185
+ extraction: opts.config.extraction,
5531
6186
  hooks: opts.hooks
5532
6187
  });
5533
6188
  let body = sanitizeText(extracted.text || "") || "";
5534
6189
  const effectiveMime = mime || inferMime(body, fname) || DEFAULT_MIME;
5535
- if (effectiveMime === "application/pdf" && body) body = dedupLines(body);
6190
+ if (effectiveMime === "application/pdf" && body && !extracted.isMarkdown) body = dedupLines(body);
5536
6191
  return {
5537
6192
  text: body,
5538
6193
  mime: effectiveMime,
@@ -5543,7 +6198,9 @@ async function prepare(opts) {
5543
6198
  mediaOnly: Boolean(extracted.mediaOnly),
5544
6199
  providerTokens: { ...extracted.providerTokens ?? {} },
5545
6200
  contentHash: calculateContentHash({ docBytes: content }),
5546
- isMarkdown: Boolean(extracted.isMarkdown)
6201
+ isMarkdown: Boolean(extracted.isMarkdown),
6202
+ unreadableReason: extracted.unreadableReason ?? null,
6203
+ unreadablePages: extracted.unreadablePages ?? 0
5547
6204
  };
5548
6205
  }
5549
6206
  function computeUnits(prepared) {
@@ -5721,7 +6378,11 @@ async function decide(pool, opts) {
5721
6378
  }
5722
6379
  const same = stored != null && incoming != null && (Buffer.isBuffer(stored) && Buffer.isBuffer(incoming) ? Buffer.compare(stored, incoming) === 0 : stored === incoming);
5723
6380
  if (!same) return { action: "process", documentId: existingId };
5724
- if (existing.status === "skipped") return { action: "skip", documentId: existingId };
6381
+ if (existing.status === "skipped") {
6382
+ const metaData = existing.meta_data;
6383
+ if (metaData?.unreadable_reason) return { action: "process", documentId: existingId };
6384
+ return { action: "skip", documentId: existingId };
6385
+ }
5725
6386
  const reqMode = opts.request.mode ?? "hybrid";
5726
6387
  if ((MODE_RANK[reqMode] ?? 0) <= (MODE_RANK[existing.mode || "hybrid"] ?? 0)) {
5727
6388
  return { action: "skip", documentId: existingId };
@@ -6012,6 +6673,22 @@ function singleReport(doc) {
6012
6673
  async function maybeAwait(value) {
6013
6674
  return value;
6014
6675
  }
6676
+ function progress(hooks, request, displayName, documentId, stage, state, detail = {}) {
6677
+ emitProgress(hooks, {
6678
+ sourceId: request.sourceId ?? null,
6679
+ externalId: request.externalId ?? null,
6680
+ documentId: documentId == null ? null : String(documentId),
6681
+ name: displayName,
6682
+ stage,
6683
+ state,
6684
+ detail: { ...detail }
6685
+ });
6686
+ }
6687
+ function extractMethod(prepared, text) {
6688
+ if (text != null) return "text";
6689
+ if (Object.keys(prepared.providerTokens).length || prepared.isMarkdown) return "vision";
6690
+ return "parser";
6691
+ }
6015
6692
  async function runModeUpgrade(request, opts) {
6016
6693
  await refreshOnSkip(request, {
6017
6694
  pool: opts.pool,
@@ -6025,6 +6702,7 @@ async function runModeUpgrade(request, opts) {
6025
6702
  let chunkCount = 0;
6026
6703
  try {
6027
6704
  if (request.mode === "graph" && opts.graphStage) {
6705
+ progress(opts.hooks, request, opts.displayName, opts.documentId, "graph", "started");
6028
6706
  graphUnits2 = Number(
6029
6707
  await maybeAwait(
6030
6708
  opts.graphStage({
@@ -6035,6 +6713,9 @@ async function runModeUpgrade(request, opts) {
6035
6713
  })
6036
6714
  ) || 0
6037
6715
  );
6716
+ progress(opts.hooks, request, opts.displayName, opts.documentId, "graph", "done", {
6717
+ graph_units: graphUnits2
6718
+ });
6038
6719
  }
6039
6720
  chunkCount = await upgradeMode(opts.pool, opts.documentId, request.mode ?? "hybrid");
6040
6721
  } catch (exc) {
@@ -6124,6 +6805,7 @@ async function runIngest(request, opts) {
6124
6805
  );
6125
6806
  }
6126
6807
  }
6808
+ progress(opts.hooks, request, displayName, null, "extract", "started");
6127
6809
  const prepared = await prepare({
6128
6810
  content,
6129
6811
  filename,
@@ -6132,6 +6814,11 @@ async function runIngest(request, opts) {
6132
6814
  config: opts.config,
6133
6815
  hooks: opts.hooks
6134
6816
  });
6817
+ progress(opts.hooks, request, displayName, null, "extract", "done", {
6818
+ pages: prepared.pages,
6819
+ method: extractMethod(prepared, text),
6820
+ mime: prepared.mime
6821
+ });
6135
6822
  const ingestHash = sha256Bytes(prepared.text || "");
6136
6823
  const decision = await decide(opts.pool, {
6137
6824
  request: { ...request, mode },
@@ -6218,8 +6905,11 @@ async function runIngest(request, opts) {
6218
6905
  const metaUpdates = { ...request.metaData ?? {} };
6219
6906
  metaUpdates.mime_type = prepared.mime;
6220
6907
  if (prepared.contentHash) metaUpdates.content_hash = prepared.contentHash;
6908
+ metaUpdates.unreadable_reason = prepared.unreadableReason;
6909
+ metaUpdates.unreadable_pages = prepared.unreadablePages;
6221
6910
  const redactionFailed = [];
6222
6911
  let documentText;
6912
+ progress(opts.hooks, request, displayName, documentId, "redact", "started");
6223
6913
  try {
6224
6914
  documentText = redactDocumentText(
6225
6915
  prepared.text,
@@ -6232,6 +6922,9 @@ async function runIngest(request, opts) {
6232
6922
  await finalizeDocument(opts.pool, documentId, { status: "failed", error: String(exc) });
6233
6923
  throw exc;
6234
6924
  }
6925
+ progress(opts.hooks, request, displayName, documentId, "redact", "done", {
6926
+ rules_failed: redactionFailed.length
6927
+ });
6235
6928
  if (!prepared.text.trim() || prepared.mediaOnly || looksMostlyBoilerplate(prepared.text)) {
6236
6929
  const reason = prepared.mediaOnly ? "media-only (no extractable text)" : "no extractable text";
6237
6930
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, []);
@@ -6257,7 +6950,13 @@ async function runIngest(request, opts) {
6257
6950
  graphUnits: 0,
6258
6951
  providerTokens: {},
6259
6952
  error: reason,
6260
- redactionFailed
6953
+ redactionFailed,
6954
+ // Extraction RAN here — an unreadable scan is exactly what lands in
6955
+ // this branch, so this is the report that has to say why. The
6956
+ // dedup-skip/upgrade sites stay at the defaults: they never extracted
6957
+ // and would be claiming blind.
6958
+ unreadableReason: prepared.unreadableReason,
6959
+ unreadablePages: prepared.unreadablePages
6261
6960
  });
6262
6961
  }
6263
6962
  const units = computeUnits(prepared);
@@ -6267,18 +6966,26 @@ async function runIngest(request, opts) {
6267
6966
  let rows = [];
6268
6967
  let effectiveMime = prepared.mime;
6269
6968
  try {
6969
+ progress(opts.hooks, request, displayName, documentId, "chunk", "started");
6270
6970
  [rows, effectiveMime] = buildChunks(prepared, request.name, {
6271
6971
  policy: opts.config.redaction,
6272
6972
  secretKey: opts.config.secretKey,
6273
6973
  hooks: opts.hooks,
6274
6974
  failed: redactionFailed
6275
6975
  });
6976
+ progress(opts.hooks, request, displayName, documentId, "chunk", "done", { chunks: rows.length });
6977
+ progress(opts.hooks, request, displayName, documentId, "embed", "started");
6276
6978
  if (request.batch) {
6277
6979
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, rows);
6278
6980
  const batchId = await submitEmbeddingBatch(
6279
6981
  opts.config.embedding,
6280
6982
  rows.map((r) => r.text)
6281
6983
  );
6984
+ progress(opts.hooks, request, displayName, documentId, "embed", "done", {
6985
+ batch: true,
6986
+ batch_id: batchId,
6987
+ chunks: rows.length
6988
+ });
6282
6989
  metaUpdates.mime_type = effectiveMime;
6283
6990
  metaUpdates.batch = {
6284
6991
  id: batchId,
@@ -6308,15 +7015,26 @@ async function runIngest(request, opts) {
6308
7015
  graphUnits: 0,
6309
7016
  providerTokens,
6310
7017
  error: null,
6311
- redactionFailed
7018
+ redactionFailed,
7019
+ // Extraction RAN here just like the skipped/failed/completed sites —
7020
+ // this report has to say why pages were unreadable too, not leave
7021
+ // the caller to re-derive it later from listDocuments.
7022
+ unreadableReason: prepared.unreadableReason,
7023
+ unreadablePages: prepared.unreadablePages
6312
7024
  });
6313
7025
  }
6314
7026
  const embeddingTokens = await embedChunks(opts.embedder, rows);
6315
7027
  if (embeddingTokens) providerTokens.embedding_tokens = embeddingTokens;
6316
7028
  await recordEmbeddingDim(opts.pool, opts.embedder.dim);
6317
7029
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, rows);
7030
+ progress(opts.hooks, request, displayName, documentId, "embed", "done", {
7031
+ batch: false,
7032
+ chunks: rows.length,
7033
+ tokens: embeddingTokens
7034
+ });
6318
7035
  const structuredKw = {};
6319
7036
  if (request.extractStructured && opts.config.llm) {
7037
+ progress(opts.hooks, request, displayName, documentId, "structured", "started");
6320
7038
  const extraction = await extractStructuredData(documentText, {
6321
7039
  llmCfg: opts.config.llm,
6322
7040
  fieldHints: request.fieldHints
@@ -6357,9 +7075,15 @@ async function runIngest(request, opts) {
6357
7075
  document: displayName
6358
7076
  });
6359
7077
  }
7078
+ progress(opts.hooks, request, displayName, documentId, "structured", "done", {
7079
+ document_type: extraction.documentType,
7080
+ keys: extraction.keysNormalized.length,
7081
+ quality: extraction.quality
7082
+ });
6360
7083
  }
6361
7084
  let graphUnits2 = 0;
6362
7085
  if (mode === "graph" && opts.graphStage) {
7086
+ progress(opts.hooks, request, displayName, documentId, "graph", "started");
6363
7087
  graphUnits2 = Number(
6364
7088
  await maybeAwait(
6365
7089
  opts.graphStage({
@@ -6370,6 +7094,7 @@ async function runIngest(request, opts) {
6370
7094
  })
6371
7095
  ) || 0
6372
7096
  );
7097
+ progress(opts.hooks, request, displayName, documentId, "graph", "done", { graph_units: graphUnits2 });
6373
7098
  }
6374
7099
  metaUpdates.mime_type = effectiveMime;
6375
7100
  await finalizeDocument(opts.pool, documentId, {
@@ -6409,7 +7134,9 @@ async function runIngest(request, opts) {
6409
7134
  graphUnits: graphUnits2,
6410
7135
  providerTokens,
6411
7136
  error: null,
6412
- redactionFailed
7137
+ redactionFailed,
7138
+ unreadableReason: prepared.unreadableReason,
7139
+ unreadablePages: prepared.unreadablePages
6413
7140
  });
6414
7141
  } catch (exc) {
6415
7142
  emitError(opts.hooks, exc, { stage: "ingest", document: displayName });
@@ -6424,7 +7151,9 @@ async function runIngest(request, opts) {
6424
7151
  graphUnits: 0,
6425
7152
  providerTokens,
6426
7153
  error: String(exc),
6427
- redactionFailed
7154
+ redactionFailed,
7155
+ unreadableReason: prepared.unreadableReason,
7156
+ unreadablePages: prepared.unreadablePages
6428
7157
  });
6429
7158
  }
6430
7159
  }
@@ -6521,12 +7250,7 @@ function parseDocumentId(documentId) {
6521
7250
  }
6522
7251
  return raw;
6523
7252
  }
6524
- function visible(acl, principals) {
6525
- if (principals === null) return true;
6526
- if (acl == null) return true;
6527
- const held = new Set(principals);
6528
- return acl.some((p) => held.has(p));
6529
- }
7253
+ var visible = aclVisible;
6530
7254
  function scopeSql(sourceIds, principals, params) {
6531
7255
  const where = [];
6532
7256
  if (sourceIds != null) {
@@ -6615,7 +7339,9 @@ async function getDocumentRow(pool, documentId, principals) {
6615
7339
  createdAt: doc.created_at,
6616
7340
  updatedAt: doc.updated_at,
6617
7341
  startedAt: doc.started_at,
6618
- completedAt: doc.completed_at
7342
+ completedAt: doc.completed_at,
7343
+ unreadableReason: doc.meta_data?.unreadable_reason ?? null,
7344
+ unreadablePages: doc.meta_data?.unreadable_pages ?? 0
6619
7345
  };
6620
7346
  }
6621
7347
  async function stats(pool, sourceId) {
@@ -6672,7 +7398,7 @@ async function listDocuments(opts) {
6672
7398
  params.push(limit + 1);
6673
7399
  const { rows } = await opts.pool.query(
6674
7400
  `SELECT id, source_id, external_id, name, description, mode, mime_type, lang, status,
6675
- document_type, created_at, updated_at, acl
7401
+ document_type, created_at, updated_at, acl, meta_data
6676
7402
  FROM context_engine_documents
6677
7403
  ${where}
6678
7404
  ORDER BY created_at DESC, id DESC
@@ -6697,7 +7423,15 @@ async function listDocuments(opts) {
6697
7423
  hooks: opts.hooks
6698
7424
  }),
6699
7425
  createdAt: doc.created_at instanceof Date ? doc.created_at.toISOString() : doc.created_at,
6700
- updatedAt: doc.updated_at instanceof Date ? doc.updated_at.toISOString() : doc.updated_at
7426
+ updatedAt: doc.updated_at instanceof Date ? doc.updated_at.toISOString() : doc.updated_at,
7427
+ unreadableReason: doc.meta_data?.unreadable_reason ?? null,
7428
+ unreadablePages: doc.meta_data?.unreadable_pages ?? 0,
7429
+ // WHO can see this. Stored, enforced on every query and editable through
7430
+ // updateDocument — and, until this line, invisible to every caller,
7431
+ // because this serializer builds a FIXED object. `null` is UNRESTRICTED
7432
+ // and must stay null; an empty array would read as "nobody", which is the
7433
+ // opposite claim.
7434
+ acl: doc.acl ?? null
6701
7435
  }));
6702
7436
  const result = {
6703
7437
  documents,
@@ -6855,9 +7589,9 @@ async function compute(instruction, opts) {
6855
7589
  return `mime_type ILIKE $${params.length}`;
6856
7590
  }).join(" OR ");
6857
7591
  where = where ? `${where} AND (${mimeClause})` : `WHERE (${mimeClause})`;
6858
- if (opts.docIds?.length) {
7592
+ if (opts.documentIds?.length) {
6859
7593
  const parsedIds = [];
6860
- for (const did of opts.docIds) {
7594
+ for (const did of opts.documentIds) {
6861
7595
  try {
6862
7596
  parsedIds.push(parseDocumentId(did));
6863
7597
  } catch {
@@ -6885,13 +7619,13 @@ async function compute(instruction, opts) {
6885
7619
  }
6886
7620
  if (tabular.length > MAX_COMPUTE_DOCUMENTS) {
6887
7621
  throw new EngineActionError(
6888
- `more than ${MAX_COMPUTE_DOCUMENTS} tabular documents are in scope for compute() \u2014 narrow the request with docIds or sourceIds`
7622
+ `more than ${MAX_COMPUTE_DOCUMENTS} tabular documents are in scope for compute() \u2014 narrow the request with documentIds or sourceIds`
6889
7623
  );
6890
7624
  }
6891
7625
  const totalChars = tabular.reduce((n, d) => n + String(d.text ?? "").length, 0);
6892
7626
  if (totalChars > MAX_COMPUTE_TEXT_CHARS) {
6893
7627
  throw new EngineActionError(
6894
- `in-scope spreadsheet text too large to load (${totalChars} chars > ${MAX_COMPUTE_TEXT_CHARS} cap) \u2014 narrow the request with docIds or sourceIds`
7628
+ `in-scope spreadsheet text too large to load (${totalChars} chars > ${MAX_COMPUTE_TEXT_CHARS} cap) \u2014 narrow the request with documentIds or sourceIds`
6895
7629
  );
6896
7630
  }
6897
7631
  const dfs = {};
@@ -6931,182 +7665,52 @@ ${schemaLines.join("\n")}`;
6931
7665
  user: userPrompt,
6932
7666
  jsonMode: false
6933
7667
  });
6934
- } catch (exc) {
6935
- emitError(opts.hooks, exc, { stage: "compute_codegen", instruction: instruction.slice(0, 200) });
6936
- throw exc;
6937
- }
6938
- const code = stripCodeFences(rawCode);
6939
- const execResult = await executeSafeCode(code, { dfs, documents }, { timeout });
6940
- if (!execResult.success) {
6941
- let maskedCode;
6942
- try {
6943
- maskedCode = redactValueRecursive(code.slice(0, 500), opts.config.redaction, {
6944
- principals,
6945
- secretKey: opts.config.secretKey,
6946
- hooks: opts.hooks
6947
- });
6948
- } catch {
6949
- maskedCode = "<redaction failed: code omitted>";
6950
- }
6951
- emitError(opts.hooks, new Error(execResult.error || "compute execution failed"), {
6952
- stage: "compute_exec",
6953
- code: maskedCode
6954
- });
6955
- }
6956
- const result = {
6957
- success: execResult.success,
6958
- result: execResult.result,
6959
- code,
6960
- stdout: execResult.stdout ?? "",
6961
- error: execResult.error,
6962
- executionTime: execResult.executionTime,
6963
- documentsUsed: documents.map((d) => d.id),
6964
- providerTokens: {
6965
- llm_input: tokens?.input ?? 0,
6966
- llm_output: tokens?.output ?? 0
6967
- }
6968
- };
6969
- return redactValueRecursive(result, opts.config.redaction, {
6970
- principals,
6971
- secretKey: opts.config.secretKey,
6972
- hooks: opts.hooks
6973
- });
6974
- }
6975
- var embeddingSchema = z.object({
6976
- provider: z.enum(["openai", "azure_openai", "gemini", "voyage", "cohere", "custom"]),
6977
- model: z.string(),
6978
- dim: z.number().int().positive().nullable().optional().default(null),
6979
- apiKey: z.string().nullable().optional().default(null),
6980
- baseUrl: z.string().nullable().optional().default(null)
6981
- });
6982
- var llmSchema = z.object({
6983
- provider: z.enum(["anthropic", "openai", "azure_openai", "gemini", "bedrock", "custom"]),
6984
- model: z.string(),
6985
- apiKey: z.string().nullable().optional().default(null),
6986
- baseUrl: z.string().nullable().optional().default(null)
6987
- });
6988
- var graphSchema = z.object({
6989
- enabled: z.boolean().default(false),
6990
- neo4jUri: z.string().nullable().optional().default(null),
6991
- neo4jUser: z.string().default("neo4j"),
6992
- neo4jPassword: z.string().nullable().optional().default(null),
6993
- neo4jDatabase: z.string().default("neo4j"),
6994
- extractionLlm: llmSchema.nullable().optional().default(null)
6995
- });
6996
- var rerankerSchema = z.object({
6997
- enabled: z.boolean().default(false),
6998
- provider: z.enum(["cohere", "voyage", "jina", "custom"]).nullable().optional().default(null),
6999
- model: z.string().nullable().optional().default(null),
7000
- apiKey: z.string().nullable().optional().default(null),
7001
- baseUrl: z.string().nullable().optional().default(null),
7002
- candidates: z.number().int().positive().default(50)
7003
- });
7004
- var fusionSchema = z.object({
7005
- method: z.literal("rrf").default("rrf"),
7006
- k: z.number().int().positive().default(60),
7007
- weights: z.record(z.string(), z.number()).default({ fts: 1, trgm: 0.8, ann: 1, graph: 1 })
7008
- });
7009
- var storageSchema = z.object({
7010
- backend: z.literal("postgres").default("postgres"),
7011
- annExactThreshold: z.number().int().positive().default(5e4)
7012
- });
7013
- var ContextEngineConfig = class _ContextEngineConfig {
7014
- databaseUrl;
7015
- storage;
7016
- defaultMode;
7017
- embedding;
7018
- llm;
7019
- visionLlm;
7020
- graph;
7021
- reranker;
7022
- fusion;
7023
- enableCodeExecution;
7024
- secretKey;
7025
- redaction;
7026
- constructor(init) {
7027
- this.databaseUrl = init.databaseUrl;
7028
- this.storage = storageSchema.parse(init.storage ?? {});
7029
- this.defaultMode = init.defaultMode ?? "hybrid";
7030
- this.embedding = embeddingSchema.parse(init.embedding);
7031
- this.llm = init.llm ? llmSchema.parse(init.llm) : null;
7032
- this.visionLlm = init.visionLlm ? llmSchema.parse(init.visionLlm) : null;
7033
- this.graph = graphSchema.parse(init.graph ?? {});
7034
- this.reranker = rerankerSchema.parse(init.reranker ?? {});
7035
- this.fusion = fusionSchema.parse(init.fusion ?? {});
7036
- this.enableCodeExecution = init.enableCodeExecution ?? false;
7037
- this.secretKey = init.secretKey ?? null;
7038
- this.redaction = init.redaction instanceof RedactionPolicy ? init.redaction : new RedactionPolicy(init.redaction ?? {});
7039
- this.validate();
7040
- }
7041
- validate() {
7042
- if (this.graph.enabled && !(this.graph.neo4jUri && this.graph.neo4jPassword && this.graph.extractionLlm)) {
7043
- throw new Error("graph enabled but neo4jUri/neo4jPassword/extractionLlm missing");
7044
- }
7045
- if (this.reranker.enabled && !(this.reranker.provider && this.reranker.apiKey)) {
7046
- throw new Error("reranker enabled but provider/apiKey missing");
7047
- }
7048
- if (this.defaultMode === "graph" && !this.graph.enabled) {
7049
- throw new Error("defaultMode is 'graph' but graph enabled is false");
7050
- }
7051
- for (const rule of this.redaction.rules) {
7052
- if (rule.action === "hash" && !this.secretKey) {
7053
- throw new Error(
7054
- `redaction rule '${rule.name}': action='hash' requires ContextEngineConfig.secretKey to be set`
7055
- );
7056
- }
7057
- }
7058
- }
7059
- static fromEnv(overrides = {}) {
7060
- const env = loadCeEnv();
7061
- const merged = deepMerge(env, overrides);
7062
- if (!merged.databaseUrl || !merged.embedding) {
7063
- throw new Error(
7064
- "ContextEngineConfig.fromEnv requires CE_DATABASE_URL and CE_EMBEDDING__PROVIDER/MODEL (or explicit overrides)"
7065
- );
7066
- }
7067
- return new _ContextEngineConfig(merged);
7068
- }
7069
- };
7070
- function camelize(key) {
7071
- return key.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
7072
- }
7073
- function loadCeEnv() {
7074
- const root = {};
7075
- for (const [raw, value] of Object.entries(process.env)) {
7076
- if (!raw.startsWith("CE_") || value === void 0) continue;
7077
- const path = raw.slice(3).split("__").map(camelize);
7078
- let cur = root;
7079
- for (let i = 0; i < path.length - 1; i++) {
7080
- const k = path[i];
7081
- const next = cur[k];
7082
- if (typeof next !== "object" || next === null) cur[k] = {};
7083
- cur = cur[k];
7084
- }
7085
- cur[path[path.length - 1]] = coerceEnv(value);
7086
- }
7087
- return root;
7088
- }
7089
- function coerceEnv(value) {
7090
- if (value === "true") return true;
7091
- if (value === "false") return false;
7092
- if (/^-?\d+$/.test(value)) return Number(value);
7093
- if (/^-?\d+\.\d+$/.test(value)) return Number(value);
7094
- return value;
7095
- }
7096
- function deepMerge(a, b) {
7097
- const out = { ...a };
7098
- for (const [k, v] of Object.entries(b)) {
7099
- if (v === void 0) continue;
7100
- const existing = out[k];
7101
- if (v && typeof v === "object" && !Array.isArray(v) && existing && typeof existing === "object" && !Array.isArray(existing)) {
7102
- out[k] = deepMerge(existing, v);
7103
- } else {
7104
- out[k] = v;
7668
+ } catch (exc) {
7669
+ emitError(opts.hooks, exc, { stage: "compute_codegen", instruction: instruction.slice(0, 200) });
7670
+ throw exc;
7671
+ }
7672
+ const code = stripCodeFences(rawCode);
7673
+ const execResult = await executeSafeCode(code, { dfs, documents }, { timeout });
7674
+ if (!execResult.success) {
7675
+ let maskedCode;
7676
+ try {
7677
+ maskedCode = redactValueRecursive(code.slice(0, 500), opts.config.redaction, {
7678
+ principals,
7679
+ secretKey: opts.config.secretKey,
7680
+ hooks: opts.hooks
7681
+ });
7682
+ } catch {
7683
+ maskedCode = "<redaction failed: code omitted>";
7105
7684
  }
7685
+ emitError(opts.hooks, new Error(execResult.error || "compute execution failed"), {
7686
+ stage: "compute_exec",
7687
+ code: maskedCode
7688
+ });
7106
7689
  }
7107
- return out;
7690
+ const result = {
7691
+ success: execResult.success,
7692
+ result: execResult.result,
7693
+ code,
7694
+ stdout: execResult.stdout ?? "",
7695
+ error: execResult.error,
7696
+ executionTime: execResult.executionTime,
7697
+ documentsUsed: documents.map((d) => d.id),
7698
+ providerTokens: {
7699
+ llm_input: tokens?.input ?? 0,
7700
+ llm_output: tokens?.output ?? 0
7701
+ }
7702
+ };
7703
+ return redactValueRecursive(result, opts.config.redaction, {
7704
+ principals,
7705
+ secretKey: opts.config.secretKey,
7706
+ hooks: opts.hooks
7707
+ });
7108
7708
  }
7109
7709
 
7710
+ // src/index.ts
7711
+ init_config();
7712
+ init_crypto();
7713
+
7110
7714
  // src/db.ts
7111
7715
  init_errors();
7112
7716
  var VERSION_TABLE = "context_engine_alembic_version";
@@ -7189,6 +7793,7 @@ async function runMigrate(databaseUrl, opts = {}) {
7189
7793
  recorded = rev;
7190
7794
  done.add(rev);
7191
7795
  }
7796
+ await client.query(loadSql("0004_acl_indexes", dim));
7192
7797
  const meta = await client.query("SELECT embedding_dim FROM context_engine_meta WHERE id = 1");
7193
7798
  if (!meta.rows.length) {
7194
7799
  await client.query("INSERT INTO context_engine_meta (id, embedding_dim) VALUES (1, $1)", [dim]);
@@ -7365,7 +7970,7 @@ async function loadGeminiEmbedClient(apiKey) {
7365
7970
  if (!Ctor) {
7366
7971
  throw new ExtraMissingError("gemini", specifier, "gemini embeddings");
7367
7972
  }
7368
- return new Ctor({ apiKey: apiKey ?? null });
7973
+ return new Ctor({ apiKey: apiKey ?? null, httpOptions: { timeout: TIMEOUT_MS3 } });
7369
7974
  }
7370
7975
  function buildEmbedder(cfg2, opts) {
7371
7976
  if (opts?.client || opts?.fetch || opts?.fetchImpl) {
@@ -7383,6 +7988,9 @@ function buildEmbedder(cfg2, opts) {
7383
7988
  throw new Error(`unknown embedding provider: ${JSON.stringify(cfg2.provider)}`);
7384
7989
  }
7385
7990
 
7991
+ // src/engine.ts
7992
+ init_redaction();
7993
+
7386
7994
  // src/compression.ts
7387
7995
  init_text();
7388
7996
  var TOKENS_PER_CHAR = 0.25;
@@ -8686,6 +9294,7 @@ async function rerank(cfg2, query, docs, opts = {}) {
8686
9294
  }
8687
9295
 
8688
9296
  // src/search.ts
9297
+ init_redaction();
8689
9298
  var MIN_LEG_CANDIDATES = 50;
8690
9299
  var MAX_LEG_CANDIDATES = 500;
8691
9300
  var UNITS_PER_SCOPE_HYBRID = 1;
@@ -8895,6 +9504,7 @@ async function runSearch(query, opts) {
8895
9504
  const topK = Math.max(1, Math.trunc(opts.topK ?? 10));
8896
9505
  const scope = {
8897
9506
  sourceIds: opts.sourceIds != null ? [...opts.sourceIds] : null,
9507
+ documentIds: opts.documentIds != null ? [...opts.documentIds] : null,
8898
9508
  principals: opts.principals != null ? [...opts.principals] : null,
8899
9509
  limit: legLimit(topK, opts.config)
8900
9510
  };
@@ -8915,13 +9525,8 @@ async function runSearch(query, opts) {
8915
9525
  let degraded = null;
8916
9526
  if (mode === "graph" && !graphLeg) {
8917
9527
  degraded = "graph_leg_unavailable";
8918
- emitError(
8919
- opts.hooks,
8920
- new GraphLegUnavailable(
8921
- "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."
8922
- ),
8923
- { stage: "graph_leg", mode, degraded }
8924
- );
9528
+ 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.";
9529
+ emitError(opts.hooks, new GraphLegUnavailable(reason), { stage: "graph_leg", mode, degraded });
8925
9530
  }
8926
9531
  const fused = rrfFuse(ranked, { k: opts.config.fusion.k, weights: opts.config.fusion.weights });
8927
9532
  const window = opts.config.reranker.enabled ? Math.max(topK, opts.config.reranker.candidates) : topK;
@@ -8996,31 +9601,6 @@ async function runSearch(query, opts) {
8996
9601
  return { hits, usage };
8997
9602
  }
8998
9603
 
8999
- // src/sentinels.ts
9000
- var UNSET = /* @__PURE__ */ Symbol.for("context_engine.UNSET");
9001
- var TrustedSentinel = class {
9002
- [Symbol.toStringTag] = "TRUSTED";
9003
- toString() {
9004
- return "TRUSTED";
9005
- }
9006
- valueOf() {
9007
- return true;
9008
- }
9009
- };
9010
- var TRUSTED = Object.freeze(new TrustedSentinel());
9011
- function resolvePrincipals(value, method) {
9012
- if (value === TRUSTED) return null;
9013
- if (value === void 0) return null;
9014
- if (value === null) {
9015
- process.emitWarning(
9016
- `${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.`,
9017
- "DeprecationWarning"
9018
- );
9019
- return null;
9020
- }
9021
- return Array.isArray(value) ? value : null;
9022
- }
9023
-
9024
9604
  // src/storage.ts
9025
9605
  init_text();
9026
9606
  var MIN_ITERATIVE_SCAN_VERSION = [0, 8];
@@ -9059,25 +9639,26 @@ function trigramThreshold(query) {
9059
9639
  }
9060
9640
  var SCOPE = `
9061
9641
  AND ($1::text[] IS NULL OR c.source_id = ANY($1))
9062
- AND ($2::text[] IS NULL OR c.acl IS NULL OR c.acl && $2)
9642
+ AND ($2::uuid[] IS NULL OR c.document_id = ANY($2::uuid[]))
9643
+ AND ($3::text[] IS NULL OR c.acl IS NULL OR c.acl && $3)
9063
9644
  `;
9064
9645
  var SQL_FTS = `
9065
9646
  SELECT c.id::text
9066
9647
  FROM context_engine_chunks c
9067
9648
  WHERE c.text IS NOT NULL
9068
- AND c.text_search @@ websearch_to_tsquery('simple'::regconfig, $3)
9649
+ AND c.text_search @@ websearch_to_tsquery('simple'::regconfig, $4)
9069
9650
  ${SCOPE}
9070
- ORDER BY ts_rank_cd(c.text_search, websearch_to_tsquery('simple'::regconfig, $3)) DESC, c.id
9071
- LIMIT $4
9651
+ ORDER BY ts_rank_cd(c.text_search, websearch_to_tsquery('simple'::regconfig, $4)) DESC, c.id
9652
+ LIMIT $5
9072
9653
  `;
9073
9654
  var SQL_TRGM = `
9074
9655
  SELECT c.id::text
9075
9656
  FROM context_engine_chunks c
9076
9657
  WHERE c.text IS NOT NULL
9077
- AND c.text_trgm_norm % $3
9658
+ AND c.text_trgm_norm % $4
9078
9659
  ${SCOPE}
9079
- ORDER BY similarity(c.text_trgm_norm, $3) DESC, c.id
9080
- LIMIT $4
9660
+ ORDER BY similarity(c.text_trgm_norm, $4) DESC, c.id
9661
+ LIMIT $5
9081
9662
  `;
9082
9663
  var SQL_COUNT_ELIGIBLE = `
9083
9664
  SELECT count(*)::int AS count FROM (
@@ -9085,13 +9666,13 @@ SELECT count(*)::int AS count FROM (
9085
9666
  FROM context_engine_chunks c
9086
9667
  WHERE c.embedding IS NOT NULL
9087
9668
  ${SCOPE}
9088
- LIMIT $3
9669
+ LIMIT $4
9089
9670
  ) probe
9090
9671
  `;
9091
9672
  var SQL_FILTER_IDS = `
9092
9673
  SELECT c.id::text
9093
9674
  FROM context_engine_chunks c
9094
- WHERE c.id = ANY($3::uuid[])
9675
+ WHERE c.id = ANY($4::uuid[])
9095
9676
  ${SCOPE}
9096
9677
  `;
9097
9678
  var UUID_RE4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -9110,7 +9691,7 @@ FROM context_engine_chunks c
9110
9691
  WHERE c.embedding IS NOT NULL
9111
9692
  ${SCOPE}
9112
9693
  ORDER BY c.embedding <=> ${vec}
9113
- LIMIT $3
9694
+ LIMIT $4
9114
9695
  `;
9115
9696
  }
9116
9697
  function sqlAnnExact(vec) {
@@ -9123,7 +9704,7 @@ WITH eligible AS MATERIALIZED (
9123
9704
  )
9124
9705
  SELECT id::text FROM eligible
9125
9706
  ORDER BY embedding <=> ${vec}
9126
- LIMIT $3
9707
+ LIMIT $4
9127
9708
  `;
9128
9709
  }
9129
9710
  function idsOf(rows) {
@@ -9150,24 +9731,26 @@ var PostgresBackend = class {
9150
9731
  * mean different things.
9151
9732
  */
9152
9733
  scopeParams(scope) {
9153
- return [
9154
- scope.sourceIds != null ? [...scope.sourceIds] : null,
9155
- scope.principals != null ? [...scope.principals] : null,
9156
- Math.max(1, Math.trunc(scope.limit ?? 50))
9157
- ];
9734
+ return {
9735
+ binds: [
9736
+ scope.sourceIds != null ? [...scope.sourceIds] : null,
9737
+ scope.documentIds != null ? [...scope.documentIds] : null,
9738
+ scope.principals != null ? [...scope.principals] : null
9739
+ ],
9740
+ lim: Math.max(1, Math.trunc(scope.limit ?? 50))
9741
+ };
9158
9742
  }
9159
9743
  async upsertChunks(documentId, sourceId, acl, chunks) {
9160
9744
  const client = await this.pool.connect();
9161
9745
  try {
9162
9746
  await client.query("BEGIN");
9163
9747
  await client.query("DELETE FROM context_engine_chunks WHERE document_id = $1", [documentId]);
9164
- for (const c of chunks) {
9165
- const emb = c.embedding != null ? vectorLiteral2(c.embedding) : "NULL";
9166
- await client.query(
9167
- `INSERT INTO context_engine_chunks
9168
- (id, document_id, source_id, acl, idx, text, lang, embedding, meta_data)
9169
- VALUES ($1, $2, $3, $4, $5, $6, $7, ${emb}, $8::jsonb)`,
9170
- [
9748
+ const BATCH = 500;
9749
+ for (let start = 0; start < chunks.length; start += BATCH) {
9750
+ const batch = chunks.slice(start, start + BATCH);
9751
+ const params = [];
9752
+ const rows = batch.map((c) => {
9753
+ params.push(
9171
9754
  randomUUID(),
9172
9755
  documentId,
9173
9756
  sourceId,
@@ -9176,7 +9759,16 @@ var PostgresBackend = class {
9176
9759
  c.text,
9177
9760
  c.lang ?? null,
9178
9761
  JSON.stringify(c.meta ?? {})
9179
- ]
9762
+ );
9763
+ const p = params.length;
9764
+ const emb = c.embedding != null ? vectorLiteral2(c.embedding) : "NULL";
9765
+ return `($${p - 7}, $${p - 6}, $${p - 5}, $${p - 4}, $${p - 3}, $${p - 2}, $${p - 1}, ${emb}, $${p}::jsonb)`;
9766
+ });
9767
+ await client.query(
9768
+ `INSERT INTO context_engine_chunks
9769
+ (id, document_id, source_id, acl, idx, text, lang, embedding, meta_data)
9770
+ VALUES ${rows.join(", ")}`,
9771
+ params
9180
9772
  );
9181
9773
  }
9182
9774
  await client.query("COMMIT");
@@ -9241,19 +9833,19 @@ var PostgresBackend = class {
9241
9833
  }
9242
9834
  }
9243
9835
  if (!parsed.length) return [];
9244
- const [src, principals] = this.scopeParams(scope);
9245
- const { rows } = await this.pool.query(SQL_FILTER_IDS, [src, principals, parsed]);
9836
+ const { binds } = this.scopeParams(scope);
9837
+ const { rows } = await this.pool.query(SQL_FILTER_IDS, [...binds, parsed]);
9246
9838
  const visible2 = new Set(idsOf(rows));
9247
9839
  return chunkIds.filter((cid) => visible2.has(String(cid))).map(String);
9248
9840
  }
9249
9841
  async ftsSearch(query, scope) {
9250
- const [src, principals, lim] = this.scopeParams(scope);
9251
- const { rows } = await this.pool.query(SQL_FTS, [src, principals, query, lim]);
9842
+ const { binds, lim } = this.scopeParams(scope);
9843
+ const { rows } = await this.pool.query(SQL_FTS, [...binds, query, lim]);
9252
9844
  return idsOf(rows);
9253
9845
  }
9254
9846
  async trgmSearch(query, scope) {
9255
9847
  const threshold = trigramThreshold(query);
9256
- const [src, principals, lim] = this.scopeParams(scope);
9848
+ const { binds, lim } = this.scopeParams(scope);
9257
9849
  const client = await this.pool.connect();
9258
9850
  let previous = null;
9259
9851
  let usedSetLimit = false;
@@ -9262,7 +9854,7 @@ var PostgresBackend = class {
9262
9854
  const applied = await this.applyTrgmThreshold(client, threshold);
9263
9855
  previous = applied.previous;
9264
9856
  usedSetLimit = applied.usedSetLimit;
9265
- const { rows } = await client.query(SQL_TRGM, [src, principals, query, lim]);
9857
+ const { rows } = await client.query(SQL_TRGM, [...binds, query, lim]);
9266
9858
  if (usedSetLimit) await this.restoreTrgmLimit(client, previous);
9267
9859
  await client.query("COMMIT");
9268
9860
  return idsOf(rows);
@@ -9308,18 +9900,18 @@ var PostgresBackend = class {
9308
9900
  }
9309
9901
  async annSearch(vector, scope) {
9310
9902
  const literal = vectorLiteral2(vector);
9311
- const [src, principals, lim] = this.scopeParams(scope);
9312
- const scoped = src !== null || principals !== null;
9903
+ const { binds, lim } = this.scopeParams(scope);
9904
+ const scoped = binds.some((v) => v !== null);
9313
9905
  const client = await this.pool.connect();
9314
9906
  try {
9315
9907
  await client.query("BEGIN");
9316
- if (scoped && await this.eligibleIsSmall(client, src, principals)) {
9317
- const { rows: rows2 } = await client.query(sqlAnnExact(literal), [src, principals, lim]);
9908
+ if (scoped && await this.eligibleIsSmall(client, binds)) {
9909
+ const { rows: rows2 } = await client.query(sqlAnnExact(literal), [...binds, lim]);
9318
9910
  await client.query("COMMIT");
9319
9911
  return idsOf(rows2);
9320
9912
  }
9321
9913
  await this.tuneAnnScan(client, lim, scoped);
9322
- const { rows } = await client.query(sqlAnn(literal), [src, principals, lim]);
9914
+ const { rows } = await client.query(sqlAnn(literal), [...binds, lim]);
9323
9915
  await client.query("COMMIT");
9324
9916
  return idsOf(rows);
9325
9917
  } catch (err) {
@@ -9332,11 +9924,17 @@ var PostgresBackend = class {
9332
9924
  client.release();
9333
9925
  }
9334
9926
  }
9335
- async eligibleIsSmall(client, src, principals) {
9927
+ async eligibleIsSmall(client, binds) {
9336
9928
  const cap = this.exactThreshold + 1;
9337
- const { rows } = await client.query(SQL_COUNT_ELIGIBLE, [src, principals, cap]);
9929
+ const { rows } = await client.query(SQL_COUNT_ELIGIBLE, [...binds, cap]);
9338
9930
  return Number(rows[0]?.count ?? 0) <= this.exactThreshold;
9339
9931
  }
9932
+ /** Size the HNSW candidate budget, and make it iterative when filtered.
9933
+ *
9934
+ * Public because the graph leg's seed query (`graph/retrieval.ts`) is the
9935
+ * same shape — an ANN walk with the scope predicate as a POST-filter — and
9936
+ * must not carry a second copy of this. Call it inside a transaction on the
9937
+ * client the query itself will run on: every setting here is `SET LOCAL`. */
9340
9938
  async tuneAnnScan(client, limit, scoped) {
9341
9939
  const efSearch = Math.min(Math.max(Math.trunc(limit) * 4, ANN_MIN_EF_SEARCH), ANN_MAX_EF_SEARCH);
9342
9940
  await client.query(`SET LOCAL hnsw.ef_search = ${efSearch}`);
@@ -9429,15 +10027,7 @@ function functionTool(fn) {
9429
10027
  // src/tools/governance.ts
9430
10028
  init_errors();
9431
10029
  init_hooks();
9432
-
9433
- // src/tools/acl.ts
9434
- function aclVisible(acl, principals) {
9435
- if (principals === null) return true;
9436
- if (acl == null) return true;
9437
- if (!acl.length) return false;
9438
- const held = new Set(principals ?? []);
9439
- return acl.some((p) => held.has(p));
9440
- }
10030
+ init_redaction();
9441
10031
 
9442
10032
  // src/tools/approval.ts
9443
10033
  init_errors();
@@ -9495,17 +10085,24 @@ function rowToRecord(row) {
9495
10085
  toolName: String(row.tool_name),
9496
10086
  toolArgsFrozen: row.tool_args_frozen ?? {},
9497
10087
  sourceId: row.source_id ?? null,
10088
+ approvalScope: row.approval_scope ?? null,
9498
10089
  principals: row.principals,
9499
10090
  status: String(row.status),
9500
10091
  approver: row.approver ?? null,
9501
10092
  approverMeta: row.approver_meta ?? null,
9502
- expiresAt: row.expires_at ? new Date(String(row.expires_at)) : null,
9503
- resolvedAt: row.resolved_at ? new Date(String(row.resolved_at)) : null,
9504
- createdAt: row.created_at ? new Date(String(row.created_at)) : null
10093
+ expiresAt: toDate(row.expires_at),
10094
+ resolvedAt: toDate(row.resolved_at),
10095
+ createdAt: toDate(row.created_at)
9505
10096
  };
9506
10097
  }
10098
+ function toDate(value) {
10099
+ if (value == null) return null;
10100
+ if (value instanceof Date) return value;
10101
+ return new Date(String(value));
10102
+ }
9507
10103
  function visibilitySql(principals, paramIndex) {
9508
- if (principals === null) return { sql: "TRUE", params: [] };
10104
+ if (principals === null || principals === TRUSTED) return { sql: "TRUE", params: [] };
10105
+ principals = principals;
9509
10106
  if (!principals.length) {
9510
10107
  return { sql: `(principals IS NULL OR principals = 'null'::jsonb)`, params: [] };
9511
10108
  }
@@ -9514,41 +10111,95 @@ function visibilitySql(principals, paramIndex) {
9514
10111
  params: [principals]
9515
10112
  };
9516
10113
  }
9517
- async function createPending(engine, opts) {
10114
+ function claimVisibilitySql(principals, paramIndex) {
10115
+ if (principals === null || principals === TRUSTED) return { sql: "TRUE", params: [] };
10116
+ const noWall = `principals IS NULL OR principals = 'null'::jsonb OR principals = '[]'::jsonb`;
10117
+ principals = principals;
10118
+ if (!principals.length) return { sql: `(${noWall})`, params: [] };
10119
+ return { sql: `(${noWall} OR principals ?| $${paramIndex}::text[])`, params: [principals] };
10120
+ }
10121
+ function validateApprovalScope(approvalScope) {
10122
+ if (approvalScope === null || approvalScope === void 0) return null;
10123
+ if (typeof approvalScope !== "string") {
10124
+ throw new TypeError(`approvalScope must be a non-empty string or null, got ${typeof approvalScope}`);
10125
+ }
10126
+ if (!approvalScope) throw new Error("approvalScope must be a non-empty string or null, got ''");
10127
+ return approvalScope;
10128
+ }
10129
+ function pendingRow(opts, approvalScope) {
9518
10130
  const policy = opts.policy ?? {};
9519
10131
  const frozen = structuredClone(opts.args);
9520
10132
  const createdAt = opts.now ?? /* @__PURE__ */ new Date();
9521
10133
  const timeout = Number(policy.timeout_minutes ?? DEFAULT_TIMEOUT_MINUTES);
9522
10134
  const expiresAt = new Date(createdAt.getTime() + timeout * 6e4);
10135
+ const principals = opts.principals === void 0 || opts.principals === TRUSTED ? null : opts.principals;
10136
+ return { frozen, createdAt, expiresAt, principals, approvalScope };
10137
+ }
10138
+ async function insertPending(engine, opts, row) {
9523
10139
  const id = randomUUID();
9524
10140
  await engine.pool.query(
9525
10141
  `INSERT INTO context_engine_tool_approvals
9526
- (id, tool_name, tool_args_frozen, source_id, principals, status, expires_at, created_at)
9527
- VALUES ($1,$2,$3::jsonb,$4,$5::jsonb,'pending',$6,$7)`,
10142
+ (id, tool_name, tool_args_frozen, source_id, approval_scope, principals, status, expires_at, created_at)
10143
+ VALUES ($1,$2,$3::jsonb,$4,$5,$6::jsonb,'pending',$7,$8)`,
9528
10144
  [
9529
10145
  id,
9530
10146
  opts.toolName,
9531
- JSON.stringify(frozen),
10147
+ JSON.stringify(row.frozen),
9532
10148
  opts.sourceId ?? null,
9533
- opts.principals === void 0 ? null : JSON.stringify(opts.principals),
9534
- expiresAt,
9535
- createdAt
10149
+ row.approvalScope,
10150
+ row.principals === null ? null : JSON.stringify(row.principals),
10151
+ row.expiresAt,
10152
+ row.createdAt
9536
10153
  ]
9537
10154
  );
9538
10155
  return {
9539
10156
  id,
9540
10157
  toolName: opts.toolName,
9541
- toolArgsFrozen: frozen,
10158
+ toolArgsFrozen: row.frozen,
9542
10159
  sourceId: opts.sourceId ?? null,
9543
- principals: opts.principals ?? null,
10160
+ approvalScope: row.approvalScope,
10161
+ principals: row.principals,
9544
10162
  status: "pending",
9545
10163
  approver: null,
9546
10164
  approverMeta: null,
9547
- expiresAt,
10165
+ expiresAt: row.expiresAt,
9548
10166
  resolvedAt: null,
9549
- createdAt
10167
+ createdAt: row.createdAt
9550
10168
  };
9551
10169
  }
10170
+ async function createPending(engine, opts) {
10171
+ const approvalScope = validateApprovalScope(opts.approvalScope);
10172
+ return insertPending(engine, opts, pendingRow(opts, approvalScope));
10173
+ }
10174
+ async function findOrCreatePending(engine, opts) {
10175
+ const approvalScope = validateApprovalScope(opts.approvalScope);
10176
+ if (approvalScope === null)
10177
+ throw new Error("findOrCreatePending requires an approvalScope; use createPending");
10178
+ const row = pendingRow(opts, approvalScope);
10179
+ const frozenJson = JSON.stringify(row.frozen);
10180
+ const sameCall = `tool_name = $1 AND approval_scope = $2 AND tool_args_frozen = $3::jsonb`;
10181
+ const params = [opts.toolName, approvalScope, frozenJson, row.createdAt];
10182
+ await engine.pool.query(
10183
+ `UPDATE context_engine_tool_approvals SET status = 'expired'
10184
+ WHERE ${sameCall} AND status = 'pending' AND expires_at <= $4`,
10185
+ params
10186
+ );
10187
+ for (let attempt = 0; attempt < 2; attempt++) {
10188
+ const found = await engine.pool.query(
10189
+ `SELECT * FROM context_engine_tool_approvals
10190
+ WHERE ${sameCall} AND status = 'pending' AND (expires_at IS NULL OR expires_at > $4)
10191
+ ORDER BY created_at ASC LIMIT 1`,
10192
+ params
10193
+ );
10194
+ if (found.rows[0]) return rowToRecord(found.rows[0]);
10195
+ try {
10196
+ return await insertPending(engine, opts, row);
10197
+ } catch (exc) {
10198
+ if (exc?.code !== "23505") throw exc;
10199
+ }
10200
+ }
10201
+ throw new Error("findOrCreatePending: lost the insert race twice and found no live pending row");
10202
+ }
9552
10203
  async function resolveApproval(engine, approvalId, decision, approver, meta = null, opts = {}) {
9553
10204
  if (decision !== "approved" && decision !== "rejected") {
9554
10205
  throw new Error(`invalid decision: ${JSON.stringify(decision)} (expected 'approved' or 'rejected')`);
@@ -9696,6 +10347,10 @@ var ToolConfig = class _ToolConfig {
9696
10347
  requiresApproval;
9697
10348
  approvalPolicy;
9698
10349
  enabled;
10350
+ /** Engine-opaque user metadata (documents have the same column). Stored
10351
+ * and returned in CLEAR on the admin surface only — secrets go in
10352
+ * `config`, which is encrypted. */
10353
+ metaData;
9699
10354
  constructor(init) {
9700
10355
  this.id = init.id ?? null;
9701
10356
  this.name = init.name;
@@ -9707,6 +10362,7 @@ var ToolConfig = class _ToolConfig {
9707
10362
  this.requiresApproval = init.requiresApproval ?? init.requires_approval ?? false;
9708
10363
  this.approvalPolicy = init.approvalPolicy ?? init.approval_policy ?? {};
9709
10364
  this.enabled = init.enabled ?? true;
10365
+ this.metaData = init.metaData ?? init.meta_data ?? {};
9710
10366
  }
9711
10367
  static fromUnknown(body) {
9712
10368
  if (!body || typeof body !== "object" || Array.isArray(body)) {
@@ -9729,7 +10385,8 @@ var ToolConfig = class _ToolConfig {
9729
10385
  acl: b.acl ?? null,
9730
10386
  requiresApproval: Boolean(b.requiresApproval ?? b.requires_approval ?? false),
9731
10387
  approvalPolicy: b.approvalPolicy ?? b.approval_policy ?? {},
9732
- enabled: b.enabled === void 0 ? true : Boolean(b.enabled)
10388
+ enabled: b.enabled === void 0 ? true : Boolean(b.enabled),
10389
+ metaData: b.metaData ?? b.meta_data ?? {}
9733
10390
  });
9734
10391
  }
9735
10392
  };
@@ -9748,9 +10405,26 @@ var SCHEMAS = {
9748
10405
  additionalProperties: { type: "string" },
9749
10406
  writeOnly: true
9750
10407
  },
10408
+ // The request body TEMPLATE (the http executor reads it). Whether it
10409
+ // round-trips is the USER's call — see `body_secret` and the
10410
+ // conditional in `redactConfig`; listing it documents the shape, the
10411
+ // redactor decides.
10412
+ body: {
10413
+ description: "Request body template; top-level keys merge with LLM parameters"
10414
+ },
10415
+ // Salman (2026-08-18): "give checkbox to user" — the author declares
10416
+ // whether their body carries secrets. True (or ABSENT — every pre-flag
10417
+ // row, fail closed) masks body values like headers; an explicit false
10418
+ // round-trips the body in clear like `url`. The flag round-trips.
10419
+ body_secret: {
10420
+ type: "boolean",
10421
+ description: "Body values are write-only (mask like headers)"
10422
+ },
9751
10423
  parameters: {
9752
10424
  type: "object",
9753
- description: "Static/user-fixed parameters sent on every call"
10425
+ description: "Static/user-fixed parameters sent on every call",
10426
+ // The canonical home of a static api_key — secret by position.
10427
+ writeOnly: true
9754
10428
  },
9755
10429
  llmParameters: {
9756
10430
  type: "object",
@@ -9781,7 +10455,13 @@ var SCHEMAS = {
9781
10455
  default: "readonly"
9782
10456
  },
9783
10457
  max_rows: { type: "integer", default: 1e3 },
9784
- selected_tables: { type: "array", items: { type: "string" } }
10458
+ selected_tables: { type: "array", items: { type: "string" } },
10459
+ // Dialect connection identifiers the db executor reads — same
10460
+ // sensitivity class as `database`, listed so they round-trip instead
10461
+ // of dying to redactConfig's default-deny.
10462
+ service_name: { type: "string" },
10463
+ schema_name: { type: "string" },
10464
+ warehouse: { type: "string" }
9785
10465
  },
9786
10466
  required: ["engine", "database"]
9787
10467
  },
@@ -9813,11 +10493,245 @@ function configSchema(kind) {
9813
10493
  if (!schema) throw new Error(`unknown tool kind: ${JSON.stringify(kind)}`);
9814
10494
  return schema;
9815
10495
  }
10496
+ var REDACTED_SENTINEL = "__redacted__";
10497
+ var ConfigTemplateError = class extends Error {
10498
+ };
10499
+ function isPlainObject(value) {
10500
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10501
+ }
10502
+ function containsSentinel(value) {
10503
+ if (typeof value === "string") return value === REDACTED_SENTINEL;
10504
+ if (Array.isArray(value)) return value.some(containsSentinel);
10505
+ if (isPlainObject(value)) return Object.values(value).some(containsSentinel);
10506
+ return false;
10507
+ }
10508
+ function mergeRedacted(incoming, stored) {
10509
+ const resolve = (value, kept, keptPresent, path) => {
10510
+ if (isPlainObject(value)) {
10511
+ const keptDict = isPlainObject(kept) ? kept : {};
10512
+ const out2 = {};
10513
+ for (const [k, v] of Object.entries(value)) {
10514
+ out2[k] = resolve(v, keptDict[k], k in keptDict, path ? `${path}.${k}` : k);
10515
+ }
10516
+ return out2;
10517
+ }
10518
+ if (Array.isArray(value)) {
10519
+ const keptList = Array.isArray(kept) ? kept : [];
10520
+ return value.map((v, i) => resolve(v, keptList[i], i < keptList.length, `${path}[${i}]`));
10521
+ }
10522
+ if (value === REDACTED_SENTINEL) {
10523
+ if (!keptPresent) {
10524
+ throw new ConfigTemplateError(
10525
+ `config.${path} is ${JSON.stringify(REDACTED_SENTINEL)} but there is no stored value to keep \u2014 re-enter the secret or omit the field`
10526
+ );
10527
+ }
10528
+ return kept;
10529
+ }
10530
+ return value;
10531
+ };
10532
+ const keptRoot = stored ?? {};
10533
+ const out = {};
10534
+ for (const [k, v] of Object.entries(incoming)) {
10535
+ out[k] = resolve(v, keptRoot[k], k in keptRoot, k);
10536
+ }
10537
+ return out;
10538
+ }
10539
+
10540
+ // src/tools/crypto.ts
10541
+ init_crypto();
10542
+ var MAX_RESPONSE_BYTES = 1e6;
10543
+ var MAX_REDIRECTS = 5;
10544
+ function isRedirect(resp) {
10545
+ return resp.status >= 300 && resp.status < 400 || resp.type === "opaqueredirect";
10546
+ }
10547
+ var EgressDenied = class extends Error {
10548
+ constructor(message) {
10549
+ super(message);
10550
+ this.name = "EgressDenied";
10551
+ }
10552
+ };
10553
+ var resolver = {
10554
+ async lookup(host) {
10555
+ const answers = await promises.lookup(host, { all: true, verbatim: true });
10556
+ return answers.map((a) => a.address);
10557
+ }
10558
+ };
10559
+ var V4_PRIVATE = [
10560
+ ["0.0.0.0", 8],
10561
+ // "this network" / unspecified
10562
+ ["10.0.0.0", 8],
10563
+ // RFC 1918
10564
+ ["100.64.0.0", 10],
10565
+ // RFC 6598 carrier-grade NAT — overlay VPNs, pod CIDRs
10566
+ ["127.0.0.0", 8],
10567
+ // loopback
10568
+ ["169.254.0.0", 16],
10569
+ // link-local, incl. the cloud metadata address
10570
+ ["172.16.0.0", 12],
10571
+ // RFC 1918
10572
+ ["192.168.0.0", 16],
10573
+ // RFC 1918
10574
+ ["224.0.0.0", 4],
10575
+ // multicast
10576
+ ["240.0.0.0", 4],
10577
+ // reserved
10578
+ ["255.255.255.255", 32]
10579
+ // broadcast (inside 240/4; spelled out anyway)
10580
+ ];
10581
+ var V6_PRIVATE = [
10582
+ ["::", 128],
10583
+ // unspecified
10584
+ ["::1", 128],
10585
+ // loopback
10586
+ ["::ffff:0:0", 96],
10587
+ // IPv4-mapped — the wrapped v4 is checked too
10588
+ ["64:ff9b::", 96],
10589
+ // NAT64 — the wrapped v4 is checked too
10590
+ ["2002::", 16],
10591
+ // 6to4 — the wrapped v4 is checked too, and this is denied
10592
+ ["fc00::", 7],
10593
+ // unique local
10594
+ ["fe80::", 10],
10595
+ // link-local
10596
+ ["fec0::", 10],
10597
+ // site-local (deprecated, still configured)
10598
+ ["ff00::", 8],
10599
+ // multicast
10600
+ ["3fff::", 20]
10601
+ // documentation
10602
+ ];
10603
+ function parseV4(text) {
10604
+ const parts = text.split(".");
10605
+ if (parts.length !== 4) return null;
10606
+ const out = new Uint8Array(4);
10607
+ for (let i = 0; i < 4; i++) {
10608
+ const part = parts[i];
10609
+ if (!/^\d{1,3}$/.test(part)) return null;
10610
+ const value = Number(part);
10611
+ if (value > 255) return null;
10612
+ out[i] = value;
10613
+ }
10614
+ return out;
10615
+ }
10616
+ function parseV6(text) {
10617
+ let body = text.split("%")[0] ?? "";
10618
+ const lastColon = body.lastIndexOf(":");
10619
+ if (lastColon < 0) return null;
10620
+ const tail = body.slice(lastColon + 1);
10621
+ if (tail.includes(".")) {
10622
+ const v4 = parseV4(tail);
10623
+ if (!v4) return null;
10624
+ const hi = (v4[0] << 8 | v4[1]).toString(16);
10625
+ const lo = (v4[2] << 8 | v4[3]).toString(16);
10626
+ body = `${body.slice(0, lastColon + 1)}${hi}:${lo}`;
10627
+ }
10628
+ const halves = body.split("::");
10629
+ if (halves.length > 2) return null;
10630
+ const head = halves[0] ? halves[0].split(":") : [];
10631
+ const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
10632
+ let groups;
10633
+ if (halves.length === 1) {
10634
+ if (head.length !== 8) return null;
10635
+ groups = head;
10636
+ } else {
10637
+ const missing = 8 - head.length - rest.length;
10638
+ if (missing < 0) return null;
10639
+ groups = [...head, ...Array(missing).fill("0"), ...rest];
10640
+ }
10641
+ const out = new Uint8Array(16);
10642
+ for (let i = 0; i < 8; i++) {
10643
+ const group = groups[i];
10644
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return null;
10645
+ const value = Number.parseInt(group, 16);
10646
+ out[2 * i] = value >> 8;
10647
+ out[2 * i + 1] = value & 255;
10648
+ }
10649
+ return out;
10650
+ }
10651
+ function parseAddress(text) {
10652
+ const family = isIP(text);
10653
+ if (family === 4) return parseV4(text);
10654
+ if (family === 6) return parseV6(text);
10655
+ return null;
10656
+ }
10657
+ function inNet(addr, net, prefix) {
10658
+ if (addr.length !== net.length) return false;
10659
+ const whole = prefix >> 3;
10660
+ for (let i = 0; i < whole; i++) if (addr[i] !== net[i]) return false;
10661
+ const bits = prefix & 7;
10662
+ if (bits === 0) return true;
10663
+ const mask = 255 << 8 - bits;
10664
+ return (addr[whole] & mask) === (net[whole] & mask);
10665
+ }
10666
+ function compile(table) {
10667
+ return table.map(([cidr, prefix]) => {
10668
+ const bytes = parseAddress(cidr);
10669
+ if (!bytes) throw new Error(`egress: unparseable network ${cidr}`);
10670
+ return [bytes, prefix];
10671
+ });
10672
+ }
10673
+ var V4_NETS = compile(V4_PRIVATE);
10674
+ var V6_NETS = compile(V6_PRIVATE);
10675
+ var MAPPED_V4 = compile([["::ffff:0:0", 96]])[0];
10676
+ var NAT64 = compile([["64:ff9b::", 96]])[0];
10677
+ var SIXTOFOUR = compile([["2002::", 16]])[0];
10678
+ function embeddedV4(bytes) {
10679
+ if (inNet(bytes, MAPPED_V4[0], MAPPED_V4[1]) || inNet(bytes, NAT64[0], NAT64[1])) {
10680
+ return bytes.subarray(12, 16);
10681
+ }
10682
+ if (inNet(bytes, SIXTOFOUR[0], SIXTOFOUR[1])) return bytes.subarray(2, 6);
10683
+ return null;
10684
+ }
10685
+ function addressIsPrivate(address) {
10686
+ const bytes = parseAddress(address);
10687
+ if (!bytes) return true;
10688
+ if (bytes.length === 4) return V4_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
10689
+ const embedded = embeddedV4(bytes);
10690
+ if (embedded && V4_NETS.some(([net, prefix]) => inNet(embedded, net, prefix))) return true;
10691
+ return V6_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
10692
+ }
10693
+ async function isPrivateAddress(host) {
10694
+ let name = (host ?? "").trim().toLowerCase().replace(/^\.+|\.+$/g, "");
10695
+ if (!name) return true;
10696
+ if (name === "localhost") return true;
10697
+ if (name.startsWith("[") && name.endsWith("]")) name = name.slice(1, -1);
10698
+ if (isIP(name)) return addressIsPrivate(name);
10699
+ let addresses;
10700
+ try {
10701
+ addresses = await resolver.lookup(name);
10702
+ } catch {
10703
+ return true;
10704
+ }
10705
+ if (!addresses.length) return true;
10706
+ return addresses.some((address) => addressIsPrivate(address.split("%")[0] ?? address));
10707
+ }
10708
+ async function assertEgressAllowed(url, opts) {
10709
+ let parsed;
10710
+ try {
10711
+ parsed = new URL(url ?? "");
10712
+ } catch {
10713
+ throw new EgressDenied(`egress denied: ${JSON.stringify(url)} is not a valid URL`);
10714
+ }
10715
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
10716
+ if (scheme !== "http" && scheme !== "https") {
10717
+ throw new EgressDenied(
10718
+ `egress denied: unsupported URL scheme ${JSON.stringify(scheme)} \u2014 http tools may only reach http/https`
10719
+ );
10720
+ }
10721
+ if (opts.allowPrivate) return;
10722
+ const host = parsed.hostname;
10723
+ if (await isPrivateAddress(host)) {
10724
+ throw new EgressDenied(
10725
+ `egress denied: ${host || "(no host)"} is a private, loopback, link-local or metadata address; set allowPrivateEgress to permit it`
10726
+ );
10727
+ }
10728
+ }
9816
10729
 
9817
10730
  // src/tools/governance.ts
9818
10731
  init_db();
9819
10732
 
9820
10733
  // src/tools/executors/http.ts
10734
+ init_errors();
9821
10735
  var TIMEOUT_MS5 = 3e4;
9822
10736
  var SENSITIVE_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
9823
10737
  "set-cookie",
@@ -9826,6 +10740,8 @@ var SENSITIVE_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
9826
10740
  "proxy-authenticate",
9827
10741
  "www-authenticate"
9828
10742
  ]);
10743
+ var REDIRECT_STATUS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
10744
+ var CROSS_ORIGIN_HEADERS = /* @__PURE__ */ new Set(["authorization", "proxy-authorization", "cookie"]);
9829
10745
  function percentEncode(value) {
9830
10746
  return encodeURIComponent(value).replace(
9831
10747
  /[!'()*]/g,
@@ -9856,10 +10772,38 @@ function withQuery(url, params) {
9856
10772
  if (!q) return url;
9857
10773
  return url.includes("?") ? `${url}&${q}` : `${url}?${q}`;
9858
10774
  }
10775
+ async function readCapped(resp) {
10776
+ if (!resp.body) return { text: await resp.text(), truncated: false };
10777
+ const reader = resp.body.getReader();
10778
+ const chunks = [];
10779
+ let size = 0;
10780
+ let truncated = false;
10781
+ for (; ; ) {
10782
+ const { done, value } = await reader.read();
10783
+ if (done) break;
10784
+ if (value) {
10785
+ chunks.push(Buffer.from(value));
10786
+ size += value.byteLength;
10787
+ }
10788
+ if (size > MAX_RESPONSE_BYTES) {
10789
+ truncated = true;
10790
+ await reader.cancel();
10791
+ break;
10792
+ }
10793
+ }
10794
+ const bytes = Buffer.concat(chunks);
10795
+ return {
10796
+ text: (truncated ? bytes.subarray(0, MAX_RESPONSE_BYTES) : bytes).toString("utf8"),
10797
+ truncated
10798
+ };
10799
+ }
9859
10800
  async function defaultRequest(init) {
9860
10801
  let url = init.url;
9861
10802
  if (init.params) url = withQuery(url, init.params);
9862
10803
  const headers = { ...init.headers ?? {} };
10804
+ if (!Object.keys(headers).some((k) => k.toLowerCase() === "accept-encoding")) {
10805
+ headers["Accept-Encoding"] = "identity";
10806
+ }
9863
10807
  let body;
9864
10808
  if (init.json !== void 0) {
9865
10809
  headers["Content-Type"] = headers["Content-Type"] ?? headers["content-type"] ?? "application/json";
@@ -9871,25 +10815,47 @@ async function defaultRequest(init) {
9871
10815
  method: init.method,
9872
10816
  headers,
9873
10817
  body,
9874
- redirect: "follow",
10818
+ // Redirects are followed by hand in `executeHttp` — an automatic follow
10819
+ // would jump to a destination no egress check ever saw.
10820
+ redirect: "manual",
9875
10821
  signal: AbortSignal.timeout(TIMEOUT_MS5)
9876
10822
  });
9877
10823
  const respHeaders = {};
9878
10824
  resp.headers.forEach((v, k) => {
9879
10825
  respHeaders[k] = v;
9880
10826
  });
9881
- const text = await resp.text();
10827
+ const { text, truncated } = await readCapped(resp);
9882
10828
  return {
9883
10829
  status: resp.status,
9884
10830
  headers: respHeaders,
10831
+ truncated,
9885
10832
  async json() {
9886
- return JSON.parse(text);
10833
+ return JSON.parse(text.replace(/^/, ""));
9887
10834
  },
9888
10835
  async text() {
9889
10836
  return text;
9890
10837
  }
9891
10838
  };
9892
10839
  }
10840
+ function nextHop(init, status, location) {
10841
+ const current = new URL(init.url);
10842
+ const target = new URL(location, current);
10843
+ let method = init.method;
10844
+ if ((status === 302 || status === 303) && method !== "HEAD") method = "GET";
10845
+ else if (status === 301 && method === "POST") method = "GET";
10846
+ let headers = { ...init.headers ?? {} };
10847
+ if (current.origin !== target.origin) {
10848
+ headers = Object.fromEntries(
10849
+ Object.entries(headers).filter(([k]) => !CROSS_ORIGIN_HEADERS.has(k.toLowerCase()))
10850
+ );
10851
+ }
10852
+ const hop = { method, url: target.toString(), headers };
10853
+ if (method === init.method) {
10854
+ if (init.json !== void 0) hop.json = init.json;
10855
+ if (init.content !== void 0) hop.content = init.content;
10856
+ }
10857
+ return hop;
10858
+ }
9893
10859
  async function executeHttp(config, args, opts = {}) {
9894
10860
  const method = String(config.method ?? "GET").toUpperCase();
9895
10861
  let url = config.url;
@@ -9910,7 +10876,7 @@ async function executeHttp(config, args, opts = {}) {
9910
10876
  }
9911
10877
  }
9912
10878
  }
9913
- const requestInit = {
10879
+ let requestInit = {
9914
10880
  method,
9915
10881
  url: url ?? "",
9916
10882
  headers
@@ -9946,29 +10912,64 @@ async function executeHttp(config, args, opts = {}) {
9946
10912
  }
9947
10913
  }
9948
10914
  const client = opts.client;
9949
- const resp = client ? await client.request(requestInit) : await defaultRequest(requestInit);
10915
+ const allowPrivate = opts.allowPrivate ?? false;
10916
+ let resp;
10917
+ let hops = 0;
10918
+ for (; ; ) {
10919
+ await assertEgressAllowed(requestInit.url, { allowPrivate });
10920
+ resp = client ? await client.request(requestInit) : await defaultRequest(requestInit);
10921
+ const location = resp.headers.location ?? resp.headers.Location;
10922
+ if (!REDIRECT_STATUS.has(resp.status) || !location) break;
10923
+ if (hops >= MAX_REDIRECTS) {
10924
+ throw new EngineActionError(`too many redirects (more than ${MAX_REDIRECTS}) starting at ${url}`);
10925
+ }
10926
+ hops += 1;
10927
+ requestInit = nextHop(requestInit, resp.status, location);
10928
+ }
9950
10929
  const contentType = resp.headers["content-type"] ?? resp.headers["Content-Type"] ?? "";
9951
- let data;
10930
+ let text = "";
9952
10931
  try {
9953
- data = contentType.includes("application/json") ? await resp.json() : await resp.text();
10932
+ text = await resp.text();
9954
10933
  } catch {
9955
- data = await resp.text();
10934
+ text = "";
10935
+ }
10936
+ const bytes = Buffer.from(text, "utf8");
10937
+ const truncated = resp.truncated === true || bytes.byteLength > MAX_RESPONSE_BYTES;
10938
+ let data;
10939
+ if (truncated) {
10940
+ data = bytes.subarray(0, MAX_RESPONSE_BYTES).toString("utf8");
10941
+ } else {
10942
+ try {
10943
+ data = contentType.includes("application/json") ? await resp.json() : text;
10944
+ } catch {
10945
+ data = text;
10946
+ }
9956
10947
  }
9957
10948
  const safeHeaders = {};
9958
10949
  for (const [k, v] of Object.entries(resp.headers)) {
9959
10950
  if (!SENSITIVE_RESPONSE_HEADERS.has(k.toLowerCase())) safeHeaders[k] = v;
9960
10951
  }
9961
- return {
10952
+ const result = {
9962
10953
  status_code: resp.status,
9963
10954
  headers: safeHeaders,
9964
10955
  data
9965
10956
  };
10957
+ if (truncated) result.truncated = true;
10958
+ return result;
9966
10959
  }
9967
10960
 
9968
10961
  // src/version.ts
9969
10962
  var __version__ = "0.0.0";
9970
10963
 
9971
10964
  // src/tools/executors/mcp-client.ts
10965
+ async function checkEgress(url, allowPrivate) {
10966
+ let checked = url;
10967
+ const scheme = (/^([a-z0-9+.-]+):/i.exec(url ?? "")?.[1] ?? "").toLowerCase();
10968
+ if (scheme === "ws") checked = `http:${url.slice(scheme.length + 1)}`;
10969
+ else if (scheme === "wss") checked = `https:${url.slice(scheme.length + 1)}`;
10970
+ await assertEgressAllowed(checked, { allowPrivate });
10971
+ }
10972
+ var REDIRECT_MESSAGE = "MCP server redirected; register the final URL";
9972
10973
  var MCPError = class _MCPError extends Error {
9973
10974
  code;
9974
10975
  data;
@@ -9997,10 +10998,12 @@ function parseSseBuffer(raw) {
9997
10998
  var HttpTransport = class {
9998
10999
  kind = "http";
9999
11000
  url;
11001
+ allowPrivate;
10000
11002
  baseHeaders;
10001
11003
  sessionId = null;
10002
- constructor(url, headers) {
11004
+ constructor(url, headers, allowPrivate = false) {
10003
11005
  this.url = url;
11006
+ this.allowPrivate = allowPrivate;
10004
11007
  this.baseHeaders = {
10005
11008
  ...headers,
10006
11009
  "Content-Type": "application/json",
@@ -10008,6 +11011,7 @@ var HttpTransport = class {
10008
11011
  };
10009
11012
  }
10010
11013
  async connect() {
11014
+ await checkEgress(this.url, this.allowPrivate);
10011
11015
  }
10012
11016
  async close() {
10013
11017
  this.sessionId = null;
@@ -10021,10 +11025,17 @@ var HttpTransport = class {
10021
11025
  const resp = await fetch(this.url, {
10022
11026
  method: "POST",
10023
11027
  headers: this.buildHeaders(),
10024
- body: JSON.stringify(msg)
11028
+ body: JSON.stringify(msg),
11029
+ // `redirect: "manual"`: a followed redirect would carry this POST —
11030
+ // headers, Authorization and all — to a destination `connect()`'s
11031
+ // check never saw. A 3xx is reported, never chased.
11032
+ redirect: "manual"
10025
11033
  });
10026
11034
  const sid = resp.headers.get("Mcp-Session-Id");
10027
11035
  if (sid) this.sessionId = sid;
11036
+ if (isRedirect(resp)) {
11037
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
11038
+ }
10028
11039
  if (resp.status >= 400) {
10029
11040
  const body = await resp.text();
10030
11041
  console.error(`[HTTP Transport] ${resp.status} from ${this.url}: ${body.slice(0, 300)}`);
@@ -10057,11 +11068,20 @@ var SseTransport = class {
10057
11068
  eventsUrl;
10058
11069
  baseUrl;
10059
11070
  headers;
11071
+ allowPrivate;
11072
+ /** Where the server told us to POST — it arrives in an `endpoint` event on
11073
+ * the stream, so it is server-chosen and hence checked. Private: the only
11074
+ * way in is the parser, which is the path a real server takes. */
10060
11075
  postUrl = null;
11076
+ /** The last POST endpoint cleared by the egress check. The check costs a
11077
+ * DNS resolution and the endpoint changes at most once per connection, so
11078
+ * it is not repeated per message. */
11079
+ checkedPostUrl = null;
10061
11080
  queue = [];
10062
11081
  waiters = [];
10063
11082
  abort = null;
10064
- constructor(url, headers) {
11083
+ constructor(url, headers, allowPrivate = false) {
11084
+ this.allowPrivate = allowPrivate;
10065
11085
  this.eventsUrl = url.replace(/\/$/, "");
10066
11086
  if (url.endsWith("/events")) this.baseUrl = url.slice(0, -7);
10067
11087
  else if (url.endsWith("/sse")) this.baseUrl = url.slice(0, -4);
@@ -10074,14 +11094,19 @@ var SseTransport = class {
10074
11094
  else this.queue.push(msg);
10075
11095
  }
10076
11096
  async connect() {
11097
+ await checkEgress(this.eventsUrl, this.allowPrivate);
10077
11098
  this.abort = new AbortController();
10078
11099
  void this.listen();
10079
11100
  }
10080
11101
  async listen() {
10081
11102
  const resp = await fetch(this.eventsUrl, {
10082
11103
  headers: { ...this.headers, Accept: "text/event-stream" },
10083
- signal: this.abort?.signal
11104
+ signal: this.abort?.signal,
11105
+ redirect: "manual"
10084
11106
  });
11107
+ if (isRedirect(resp)) {
11108
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
11109
+ }
10085
11110
  if (!resp.ok) throw new Error(`SSE ${resp.status}`);
10086
11111
  if (!resp.body) throw new Error("SSE response has no body");
10087
11112
  const reader = resp.body.getReader();
@@ -10133,11 +11158,19 @@ var SseTransport = class {
10133
11158
  }
10134
11159
  }
10135
11160
  const postUrl = this.postUrl || this.baseUrl;
11161
+ if (postUrl !== this.checkedPostUrl) {
11162
+ await checkEgress(postUrl, this.allowPrivate);
11163
+ this.checkedPostUrl = postUrl;
11164
+ }
10136
11165
  const resp = await fetch(postUrl, {
10137
11166
  method: "POST",
10138
11167
  headers: { ...this.headers, "Content-Type": "application/json" },
10139
- body: JSON.stringify(msg)
11168
+ body: JSON.stringify(msg),
11169
+ redirect: "manual"
10140
11170
  });
11171
+ if (isRedirect(resp)) {
11172
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
11173
+ }
10141
11174
  if (!resp.ok) throw new Error(`SSE POST ${resp.status}`);
10142
11175
  try {
10143
11176
  const data = await resp.json();
@@ -10156,14 +11189,17 @@ var WsTransport = class {
10156
11189
  kind = "websocket";
10157
11190
  url;
10158
11191
  headers;
11192
+ allowPrivate;
10159
11193
  ws = null;
10160
11194
  queue = [];
10161
11195
  waiters = [];
10162
- constructor(url, headers) {
11196
+ constructor(url, headers, allowPrivate = false) {
10163
11197
  this.url = url;
11198
+ this.allowPrivate = allowPrivate;
10164
11199
  this.headers = headers;
10165
11200
  }
10166
11201
  async connect() {
11202
+ await checkEgress(this.url, this.allowPrivate);
10167
11203
  const WS = globalThis.WebSocket;
10168
11204
  if (!WS) throw new Error("WebSocket is not available in this runtime");
10169
11205
  this.ws = new WS(this.url);
@@ -10204,21 +11240,27 @@ var MCPClient = class _MCPClient {
10204
11240
  tools = [];
10205
11241
  resources = [];
10206
11242
  headers;
10207
- constructor(url, headers = null) {
11243
+ allowPrivate;
11244
+ constructor(url, headers = null, opts = {}) {
10208
11245
  this.url = url;
10209
11246
  this.headers = headers ?? {};
10210
- this.transport = _MCPClient.createTransport(url, this.headers);
11247
+ this.allowPrivate = opts.allowPrivate ?? false;
11248
+ this.transport = _MCPClient.createTransport(url, this.headers, this.allowPrivate);
10211
11249
  }
10212
- static createTransport(url, headers) {
11250
+ static createTransport(url, headers, allowPrivate = false) {
10213
11251
  if (url.startsWith("stdio:") || url === "stdio") {
10214
11252
  throw new Error("stdio MCP transport is not supported (cloud deployments use SSE/HTTP/WebSocket)");
10215
11253
  }
10216
- if (url.startsWith("ws://") || url.startsWith("wss://")) return new WsTransport(url, headers);
11254
+ if (url.startsWith("ws://") || url.startsWith("wss://")) {
11255
+ return new WsTransport(url, headers, allowPrivate);
11256
+ }
10217
11257
  if (url.startsWith("sse+http://") || url.startsWith("sse+https://") || url.endsWith("/events") || url.endsWith("/sse")) {
10218
11258
  const clean = url.replace("sse+http://", "http://").replace("sse+https://", "https://");
10219
- return new SseTransport(clean, headers);
11259
+ return new SseTransport(clean, headers, allowPrivate);
11260
+ }
11261
+ if (url.startsWith("http://") || url.startsWith("https://")) {
11262
+ return new HttpTransport(url, headers, allowPrivate);
10220
11263
  }
10221
- if (url.startsWith("http://") || url.startsWith("https://")) return new HttpTransport(url, headers);
10222
11264
  throw new Error(`Unsupported URL: ${url} (use http(s)://, ws(s)://, or .../sse)`);
10223
11265
  }
10224
11266
  nextId() {
@@ -10270,7 +11312,15 @@ var MCPClient = class _MCPClient {
10270
11312
  await this.transport.send({ jsonrpc: "2.0", method, params: params ?? {} });
10271
11313
  }
10272
11314
  async connect() {
10273
- await this.transport.connect();
11315
+ try {
11316
+ await this.transport.connect();
11317
+ } catch (e) {
11318
+ try {
11319
+ await this.transport.close();
11320
+ } catch {
11321
+ }
11322
+ throw e;
11323
+ }
10274
11324
  if (!(this.transport instanceof HttpTransport)) {
10275
11325
  this.connected = true;
10276
11326
  void this.recvLoop();
@@ -10353,7 +11403,7 @@ var MCPClient = class _MCPClient {
10353
11403
  }
10354
11404
  this.id = 0;
10355
11405
  this.pending.clear();
10356
- this.transport = _MCPClient.createTransport(url, headers);
11406
+ this.transport = _MCPClient.createTransport(url, headers, this.allowPrivate);
10357
11407
  await this.connect();
10358
11408
  await this.listTools();
10359
11409
  }
@@ -10361,11 +11411,11 @@ var MCPClient = class _MCPClient {
10361
11411
  var MCPRegistry = class {
10362
11412
  clients = /* @__PURE__ */ new Map();
10363
11413
  healthTask = null;
10364
- async connect(name, url, headers = null, token = null) {
11414
+ async connect(name, url, headers = null, token = null, allowPrivate = false) {
10365
11415
  this.clients.delete(name);
10366
11416
  const hdrs = { ...headers ?? {} };
10367
11417
  if (token) hdrs.Authorization = hdrs.Authorization ?? `Bearer ${token}`;
10368
- const client = new MCPClient(url, hdrs);
11418
+ const client = new MCPClient(url, hdrs, { allowPrivate });
10369
11419
  await client.connect();
10370
11420
  await client.listTools();
10371
11421
  this.clients.set(name, client);
@@ -10417,8 +11467,18 @@ var MCPRegistry = class {
10417
11467
  };
10418
11468
  var PromptevMCP = class {
10419
11469
  clients = new MCPRegistry();
11470
+ allowPrivate;
11471
+ /**
11472
+ * `allowPrivate` is the operator knob `allowPrivateEgress`, carried down to
11473
+ * every transport this facade builds. It defaults to `false`, so a
11474
+ * construction site that forgets to pass it denies private destinations
11475
+ * rather than permitting them.
11476
+ */
11477
+ constructor(opts = {}) {
11478
+ this.allowPrivate = opts.allowPrivate ?? false;
11479
+ }
10420
11480
  async addServer(name, url, opts = {}) {
10421
- await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null);
11481
+ await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null, this.allowPrivate);
10422
11482
  }
10423
11483
  async shutdown() {
10424
11484
  await this.clients.shutdown();
@@ -10552,8 +11612,13 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
10552
11612
  "requires_approval",
10553
11613
  "approvalPolicy",
10554
11614
  "approval_policy",
10555
- "enabled"
11615
+ "enabled",
11616
+ "metaData",
11617
+ "meta_data"
10556
11618
  ]);
11619
+ function allowPrivateEgress(engine) {
11620
+ return Boolean(engine?.config?.allowPrivateEgress);
11621
+ }
10557
11622
  function toolVisible(ct, principals) {
10558
11623
  return aclVisible(ct.acl, principals);
10559
11624
  }
@@ -10635,11 +11700,19 @@ function redactToolResult(result, policy, opts) {
10635
11700
  if (failed.length) note.rules_failed = failed;
10636
11701
  return [redacted, note];
10637
11702
  }
10638
- function rowToToolConfig(row, engine) {
10639
- let config = {};
10640
- if (row.config_encrypted) {
10641
- config = decryptDict(String(row.config_encrypted), getSecretKey(engine.config));
11703
+ function cryptoKey(engine) {
11704
+ const key = getSecretKey(engine.config);
11705
+ if (key.length !== 32) {
11706
+ throw new Error(`CE_SECRET_KEY is malformed: decoded to ${key.length} bytes, need 32`);
10642
11707
  }
11708
+ return key;
11709
+ }
11710
+ function decryptRowConfig(row, engine) {
11711
+ if (!row.config_encrypted) return {};
11712
+ return decryptDict(String(row.config_encrypted), cryptoKey(engine));
11713
+ }
11714
+ function rowToToolConfig(row, engine, config) {
11715
+ config ??= decryptRowConfig(row, engine);
10643
11716
  return new ToolConfig({
10644
11717
  id: String(row.id),
10645
11718
  name: String(row.name),
@@ -10650,9 +11723,24 @@ function rowToToolConfig(row, engine) {
10650
11723
  acl: row.acl != null ? [...row.acl] : null,
10651
11724
  requiresApproval: Boolean(row.requires_approval),
10652
11725
  approvalPolicy: row.approval_policy ?? {},
10653
- enabled: Boolean(row.enabled)
11726
+ enabled: Boolean(row.enabled),
11727
+ metaData: row.meta_data ?? {}
10654
11728
  });
10655
11729
  }
11730
+ function rowToToolConfigLenient(row, engine) {
11731
+ if (!row.config_encrypted) return rowToToolConfig(row, engine, {});
11732
+ const key = cryptoKey(engine);
11733
+ let config;
11734
+ try {
11735
+ config = decryptDict(String(row.config_encrypted), key);
11736
+ } catch {
11737
+ console.warn(
11738
+ `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`
11739
+ );
11740
+ return null;
11741
+ }
11742
+ return rowToToolConfig(row, engine, config);
11743
+ }
10656
11744
  async function loadPersistedCanonicals(engine, sourceId) {
10657
11745
  const params = [];
10658
11746
  let sql = `SELECT * FROM context_engine_tools WHERE enabled IS TRUE`;
@@ -10663,7 +11751,9 @@ async function loadPersistedCanonicals(engine, sourceId) {
10663
11751
  const result = await engine.pool.query(sql, params);
10664
11752
  const canonicals = [];
10665
11753
  for (const row of result.rows) {
10666
- canonicals.push(...canonicalFromConfig(rowToToolConfig(row, engine)));
11754
+ const tc = rowToToolConfigLenient(row, engine);
11755
+ if (tc === null) continue;
11756
+ canonicals.push(...canonicalFromConfig(tc));
10667
11757
  }
10668
11758
  return canonicals;
10669
11759
  }
@@ -10683,13 +11773,18 @@ function canonicalToPublic(ct) {
10683
11773
  async function registerTool(engine, tc) {
10684
11774
  canonicalFromConfig(tc);
10685
11775
  const config = tc.config ?? {};
10686
- const configEncrypted = Object.keys(config).length ? encryptDict(config, getSecretKey(engine.config)) : null;
11776
+ if (containsSentinel(config)) {
11777
+ throw new ConfigTemplateError(
11778
+ `config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a redacted template cannot be registered as a new tool; re-enter the secret values`
11779
+ );
11780
+ }
11781
+ const configEncrypted = Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null;
10687
11782
  const id = randomUUID();
10688
11783
  await engine.pool.query(
10689
11784
  `INSERT INTO context_engine_tools
10690
11785
  (id, name, kind, description, source_id, acl, config_encrypted,
10691
- requires_approval, approval_policy, enabled)
10692
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
11786
+ requires_approval, approval_policy, enabled, meta_data)
11787
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11::jsonb)`,
10693
11788
  [
10694
11789
  id,
10695
11790
  tc.name,
@@ -10700,7 +11795,8 @@ async function registerTool(engine, tc) {
10700
11795
  configEncrypted,
10701
11796
  tc.requiresApproval,
10702
11797
  JSON.stringify(tc.approvalPolicy ?? {}),
10703
- tc.enabled
11798
+ tc.enabled,
11799
+ JSON.stringify(tc.metaData ?? {})
10704
11800
  ]
10705
11801
  );
10706
11802
  return id;
@@ -10717,12 +11813,41 @@ async function updateTool(engine, id, opts) {
10717
11813
  let i = 1;
10718
11814
  for (const [key, value] of Object.entries(fields)) {
10719
11815
  if (key === "config") {
11816
+ let config = value;
11817
+ if (config && containsSentinel(config)) {
11818
+ if (Object.hasOwn(fields, "kind") && fields.kind !== row.kind) {
11819
+ throw new ConfigTemplateError(
11820
+ "cannot change kind and keep redacted secrets in one PATCH \u2014 re-enter the config in full"
11821
+ );
11822
+ }
11823
+ if (config.body_secret === false && containsSentinel(config.body)) {
11824
+ throw new ConfigTemplateError(
11825
+ `body_secret cannot be turned off while the body still contains ${JSON.stringify(REDACTED_SENTINEL)} \u2014 re-enter the body to declassify it`
11826
+ );
11827
+ }
11828
+ if (!row.config_encrypted) {
11829
+ throw new ConfigTemplateError(
11830
+ `config contains ${JSON.stringify(REDACTED_SENTINEL)} but this tool has no stored config to keep \u2014 re-enter the secret values`
11831
+ );
11832
+ }
11833
+ const key2 = cryptoKey(engine);
11834
+ let stored;
11835
+ try {
11836
+ stored = decryptDict(String(row.config_encrypted), key2);
11837
+ } catch (exc) {
11838
+ throw new ConfigTemplateError(
11839
+ `the stored config cannot be decrypted (was the secret key rotated?) \u2014 re-enter the config in full, without ${JSON.stringify(REDACTED_SENTINEL)} values`,
11840
+ { cause: exc }
11841
+ );
11842
+ }
11843
+ config = mergeRedacted(config, stored);
11844
+ }
10720
11845
  sets.push(`config_encrypted = $${i++}`);
10721
- params.push(value ? encryptDict(value, getSecretKey(engine.config)) : null);
11846
+ params.push(config && Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null);
10722
11847
  } else if (UPDATABLE_COLUMNS.has(key)) {
10723
- const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key;
11848
+ const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key === "metaData" ? "meta_data" : key;
10724
11849
  sets.push(`${col} = $${i++}`);
10725
- params.push(col === "approval_policy" ? JSON.stringify(value ?? {}) : value);
11850
+ params.push(col === "approval_policy" || col === "meta_data" ? JSON.stringify(value ?? {}) : value);
10726
11851
  }
10727
11852
  }
10728
11853
  sets.push(`updated_at = now()`);
@@ -10731,7 +11856,8 @@ async function updateTool(engine, id, opts) {
10731
11856
  `UPDATE context_engine_tools SET ${sets.join(", ")} WHERE id = $${i} RETURNING *`,
10732
11857
  params
10733
11858
  );
10734
- return rowToToolConfig(updated.rows[0], engine);
11859
+ const row_ = updated.rows[0];
11860
+ return rowToToolConfigLenient(row_, engine) ?? rowToToolConfig(row_, engine, {});
10735
11861
  }
10736
11862
  async function deleteTool(engine, id, opts = {}) {
10737
11863
  const existing = await engine.pool.query(`SELECT * FROM context_engine_tools WHERE id = $1`, [id]);
@@ -10817,17 +11943,38 @@ function probeFailure(exc, kind) {
10817
11943
  console.warn(`test_tool(kind=${kind}) failed:`, exc, "->", category);
10818
11944
  return { ok: false, error: category };
10819
11945
  }
10820
- async function testTool(_engine, tc) {
11946
+ async function testTool(engine, tc) {
10821
11947
  const kind = tc.kind;
10822
11948
  const config = tc.config ?? {};
11949
+ if (containsSentinel(config)) {
11950
+ throw new ConfigTemplateError(
11951
+ `config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a probe would send it verbatim as the credential; re-enter the secret values`
11952
+ );
11953
+ }
10823
11954
  if (kind === "http") {
10824
11955
  const url = config.url;
10825
11956
  if (!url) return { ok: false, error: "http config missing 'url'" };
10826
11957
  try {
10827
- const resp = await fetch(url, {
11958
+ await assertEgressAllowed(url, { allowPrivate: allowPrivateEgress(engine) });
11959
+ } catch (exc) {
11960
+ if (exc instanceof EgressDenied) {
11961
+ console.warn(`testTool(kind=http) refused: ${exc.message}`);
11962
+ return { ok: false, error: "egress_denied" };
11963
+ }
11964
+ throw exc;
11965
+ }
11966
+ const client = engine?._toolHttpClient ?? null;
11967
+ try {
11968
+ const resp = client ? await client.request({
11969
+ method: "HEAD",
11970
+ url,
11971
+ headers: config.headers ?? {}
11972
+ }) : await fetch(url, {
10828
11973
  method: "HEAD",
10829
11974
  headers: config.headers ?? {},
10830
- redirect: "follow",
11975
+ // No automatic follow: a redirect would reach a destination the
11976
+ // check above never saw.
11977
+ redirect: "manual",
10831
11978
  signal: AbortSignal.timeout(1e4)
10832
11979
  });
10833
11980
  return { ok: resp.status < 500, status_code: resp.status };
@@ -10848,12 +11995,16 @@ async function testTool(_engine, tc) {
10848
11995
  const url = config.url;
10849
11996
  if (!url) return { ok: false, error: "mcp config missing 'url'" };
10850
11997
  const token = config.oauth_token ?? config.bearer ?? config.access_token;
10851
- const mcp = new PromptevMCP();
11998
+ const mcp = new PromptevMCP({ allowPrivate: allowPrivateEgress(engine) });
10852
11999
  try {
10853
12000
  await mcp.addServer(tc.name, url, { token: token ?? null });
10854
12001
  const client = mcp.clients.get(tc.name);
10855
12002
  return { ok: true, tools: client.tools.map((t) => t.name) };
10856
12003
  } catch (exc) {
12004
+ if (exc instanceof EgressDenied) {
12005
+ console.warn(`testTool(kind=mcp) refused: ${exc.message}`);
12006
+ return { ok: false, error: "egress_denied" };
12007
+ }
10857
12008
  return probeFailure(exc, "mcp");
10858
12009
  } finally {
10859
12010
  await mcp.shutdown();
@@ -10861,11 +12012,20 @@ async function testTool(_engine, tc) {
10861
12012
  }
10862
12013
  return { ok: false, error: `test_tool does not support kind=${JSON.stringify(kind)}` };
10863
12014
  }
10864
- async function findAndClaimApproved(engine, toolName, args, sourceId) {
12015
+ async function findAndClaimApproved(engine, toolName, args, sourceId, principals, approvalScope) {
10865
12016
  const claimedAt = /* @__PURE__ */ new Date();
12017
+ const params = [toolName];
12018
+ let scopeSql2 = "approval_scope IS NULL";
12019
+ if (approvalScope !== null) {
12020
+ params.push(approvalScope);
12021
+ scopeSql2 = `approval_scope = $${params.length}`;
12022
+ }
12023
+ const wall = claimVisibilitySql(principals, params.length + 1);
12024
+ params.push(...wall.params);
10866
12025
  const candidates = await engine.pool.query(
10867
- `SELECT * FROM context_engine_tool_approvals WHERE tool_name = $1 AND status = 'approved'`,
10868
- [toolName]
12026
+ `SELECT * FROM context_engine_tool_approvals
12027
+ WHERE tool_name = $1 AND status = 'approved' AND ${scopeSql2} AND ${wall.sql}`,
12028
+ params
10869
12029
  );
10870
12030
  for (const row of candidates.rows) {
10871
12031
  if ((row.source_id ?? null) !== (sourceId ?? null)) continue;
@@ -10882,7 +12042,7 @@ async function findAndClaimApproved(engine, toolName, args, sourceId) {
10882
12042
  }
10883
12043
  return null;
10884
12044
  }
10885
- async function dispatchMcp(ct, config, args) {
12045
+ async function dispatchMcp(ct, config, args, opts = {}) {
10886
12046
  const url = config.url;
10887
12047
  if (!url) throw new EngineActionError(`mcp tool ${ct.callName} config missing 'url'`);
10888
12048
  const headers = { ...config.headers ?? {} };
@@ -10891,7 +12051,7 @@ async function dispatchMcp(ct, config, args) {
10891
12051
  if (token) headers.Authorization = `Bearer ${token}`;
10892
12052
  else if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
10893
12053
  const toolName = String(ct.raw?.tool_name ?? ct.displayName);
10894
- const mcp = new PromptevMCP();
12054
+ const mcp = new PromptevMCP({ allowPrivate: opts.allowPrivate ?? false });
10895
12055
  try {
10896
12056
  await mcp.addServer(ct.callName, url, { headers: Object.keys(headers).length ? headers : null });
10897
12057
  const client = mcp.clients.get(ct.callName);
@@ -10902,7 +12062,10 @@ async function dispatchMcp(ct, config, args) {
10902
12062
  }
10903
12063
  async function dispatch(engine, ct, config, args) {
10904
12064
  if (ct.kind === "http") {
10905
- return executeHttp(config, args, { client: engine._toolHttpClient ?? null });
12065
+ return executeHttp(config, args, {
12066
+ client: engine._toolHttpClient ?? null,
12067
+ allowPrivate: allowPrivateEgress(engine)
12068
+ });
10906
12069
  }
10907
12070
  if (ct.kind === "db") {
10908
12071
  const query = String(args.query ?? "");
@@ -10917,7 +12080,9 @@ async function dispatch(engine, ct, config, args) {
10917
12080
  }
10918
12081
  return result;
10919
12082
  }
10920
- if (ct.kind === "mcp") return dispatchMcp(ct, config, args);
12083
+ if (ct.kind === "mcp") {
12084
+ return dispatchMcp(ct, config, args, { allowPrivate: allowPrivateEgress(engine) });
12085
+ }
10921
12086
  if (ct.kind === "function") {
10922
12087
  const fn = ct.raw?.callable;
10923
12088
  if (!fn) throw new EngineActionError(`function tool ${ct.callName} has no callable`);
@@ -10938,10 +12103,20 @@ async function decryptCtConfig(engine, toolId) {
10938
12103
  ]);
10939
12104
  const row = result.rows[0];
10940
12105
  if (!row?.config_encrypted) return {};
10941
- return decryptDict(String(row.config_encrypted), getSecretKey(engine.config));
12106
+ return decryptDict(String(row.config_encrypted), cryptoKey(engine));
12107
+ }
12108
+ var warnedUnscoped = /* @__PURE__ */ new Set();
12109
+ function warnUnscopedApproval(callName) {
12110
+ if (warnedUnscoped.has(callName)) return;
12111
+ warnedUnscoped.add(callName);
12112
+ process.emitWarning(
12113
+ `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.`,
12114
+ { type: "DeprecationWarning", code: "CE_APPROVAL_SCOPE_MISSING" }
12115
+ );
10942
12116
  }
10943
12117
  async function executeTool(engine, callName, args, opts = {}) {
10944
12118
  const runtimeArgs = args ?? {};
12119
+ const approvalScope = validateApprovalScope(opts.approvalScope);
10945
12120
  const ct = findTool(await mergedTools(engine, opts.sourceId ?? null), callName);
10946
12121
  if (!ct) throw new EngineActionError(`tool not found: ${callName}`);
10947
12122
  if (!toolVisible(ct, opts.principals ?? null)) {
@@ -10950,22 +12125,32 @@ async function executeTool(engine, callName, args, opts = {}) {
10950
12125
  const publicArgs = stripUnderscoreArgs(runtimeArgs);
10951
12126
  let approvalId = null;
10952
12127
  if (shouldRequireApproval(ct, publicArgs)) {
10953
- approvalId = await findAndClaimApproved(engine, ct.callName, publicArgs, opts.sourceId ?? null);
12128
+ if (approvalScope === null) warnUnscopedApproval(callName);
12129
+ approvalId = await findAndClaimApproved(
12130
+ engine,
12131
+ ct.callName,
12132
+ publicArgs,
12133
+ opts.sourceId ?? null,
12134
+ opts.principals ?? null,
12135
+ approvalScope
12136
+ );
10954
12137
  if (approvalId == null) {
10955
- const record = await createPending(engine, {
12138
+ const pendingOpts = {
10956
12139
  toolName: ct.callName,
10957
12140
  args: publicArgs,
10958
12141
  sourceId: opts.sourceId ?? null,
10959
12142
  principals: opts.principals ?? null,
10960
12143
  policy: ct.approvalPolicy ?? {}
10961
- });
12144
+ };
12145
+ const record = approvalScope === null ? await createPending(engine, pendingOpts) : await findOrCreatePending(engine, { ...pendingOpts, approvalScope });
10962
12146
  const reason = ct.requiresApproval ? "tool requires approval" : `approval policy condition met: ${ct.approvalPolicy?.condition}`;
10963
12147
  return {
10964
12148
  approval_required: {
10965
12149
  approval_id: String(record.id),
10966
12150
  tool_name: ct.callName,
10967
12151
  args: publicArgs,
10968
- reason
12152
+ reason,
12153
+ expires_at: record.expiresAt ? record.expiresAt.toISOString() : null
10969
12154
  }
10970
12155
  };
10971
12156
  }
@@ -10990,8 +12175,9 @@ async function executeTool(engine, callName, args, opts = {}) {
10990
12175
  let truncated = false;
10991
12176
  if (success) {
10992
12177
  try {
12178
+ const redactPrincipals = opts.principals === TRUSTED ? null : opts.principals ?? null;
10993
12179
  const [redacted] = redactToolResult(rawResult, engine.config.redaction, {
10994
- principals: opts.principals ?? null,
12180
+ principals: redactPrincipals,
10995
12181
  secretKey: engine.config.secretKey,
10996
12182
  hooks: engine.hooks
10997
12183
  });
@@ -11090,11 +12276,19 @@ var ContextEngine = class {
11090
12276
  _graphStore = null;
11091
12277
  constructor(config, opts = {}) {
11092
12278
  this.config = config;
11093
- this.hooks = { onUsage: opts.onUsage ?? null, onError: opts.onError ?? null };
12279
+ this.hooks = {
12280
+ onUsage: opts.onUsage ?? null,
12281
+ onError: opts.onError ?? null,
12282
+ onProgress: opts.onProgress ?? null
12283
+ };
11094
12284
  }
11095
12285
  async ensurePool() {
11096
12286
  if (!this._pool) {
11097
- this._pool = await createPool(this.config.databaseUrl);
12287
+ this._pool = await createPool(this.config.databaseUrl, {
12288
+ max: this.config.storage.poolMax,
12289
+ idleTimeoutMillis: this.config.storage.poolIdleTimeoutMs,
12290
+ connectionTimeoutMillis: this.config.storage.poolConnectionTimeoutMs
12291
+ });
11098
12292
  this.pool = this._pool;
11099
12293
  }
11100
12294
  if (!this.backend) {
@@ -11280,7 +12474,9 @@ var ContextEngine = class {
11280
12474
  graphStore: await this.getGraphStore(),
11281
12475
  hooks: this.hooks,
11282
12476
  sourceIds: opts.sourceIds ?? null,
11283
- principals
12477
+ documentIds: opts.documentIds ?? null,
12478
+ principals,
12479
+ backend: this.backend
11284
12480
  });
11285
12481
  }
11286
12482
  return runSearch(query, {
@@ -11290,6 +12486,7 @@ var ContextEngine = class {
11290
12486
  embedder: this.embedder,
11291
12487
  pool,
11292
12488
  sourceIds: opts.sourceIds,
12489
+ documentIds: opts.documentIds,
11293
12490
  principals,
11294
12491
  topK: opts.topK,
11295
12492
  mode: opts.mode,
@@ -11395,7 +12592,7 @@ var ContextEngine = class {
11395
12592
  hooks: this.hooks,
11396
12593
  sourceIds: opts.sourceIds,
11397
12594
  principals: resolvePrincipals(opts.principals, "compute"),
11398
- docIds: opts.docIds,
12595
+ documentIds: opts.documentIds,
11399
12596
  modelCfg: opts.modelCfg,
11400
12597
  timeout: opts.timeout
11401
12598
  });
@@ -11448,7 +12645,8 @@ var ContextEngine = class {
11448
12645
  sourceId: opts.sourceId,
11449
12646
  principals: resolvePrincipals(opts.principals, "executeTool"),
11450
12647
  actor: opts.actor,
11451
- source: opts.source ?? "api"
12648
+ source: opts.source ?? "api",
12649
+ approvalScope: opts.approvalScope
11452
12650
  });
11453
12651
  }
11454
12652
  };
@@ -11461,18 +12659,83 @@ init_hooks();
11461
12659
  // src/mcp.ts
11462
12660
  init_errors();
11463
12661
  init_extras();
11464
-
11465
- // src/tools/mcp-tools.ts
12662
+ var HandlerError = class extends Error {
12663
+ status;
12664
+ detail;
12665
+ constructor(status, detail) {
12666
+ super(typeof detail === "string" ? detail : JSON.stringify(detail));
12667
+ this.name = "HandlerError";
12668
+ this.status = status;
12669
+ this.detail = detail;
12670
+ }
12671
+ };
12672
+ z.object({
12673
+ text: z.string(),
12674
+ name: z.string(),
12675
+ source_id: z.string().nullable().optional(),
12676
+ external_id: z.string().nullable().optional(),
12677
+ description: z.string().nullable().optional(),
12678
+ meta_data: z.record(z.unknown()).nullable().optional(),
12679
+ acl: z.array(z.string()).nullable().optional(),
12680
+ mode: z.enum(["hybrid", "graph"]).nullable().optional(),
12681
+ extract_structured: z.boolean().optional().default(false),
12682
+ batch: z.boolean().optional().default(false)
12683
+ }).strip();
12684
+ z.object({
12685
+ acl: z.array(z.string()).nullable().optional(),
12686
+ name: z.string().nullable().optional(),
12687
+ description: z.string().nullable().optional(),
12688
+ meta_data: z.record(z.unknown()).nullable().optional()
12689
+ }).strict();
12690
+ z.object({
12691
+ query: z.string(),
12692
+ source_ids: z.array(z.string()).nullable().optional(),
12693
+ // Narrows WITHIN a source and INTERSECTS with source_ids — it can only
12694
+ // shrink the result set (the ACL predicate still applies in the same SQL
12695
+ // conjunction), so exposing it needs no authorizeAcl-style grant check.
12696
+ document_ids: z.array(z.string()).nullable().optional(),
12697
+ top_k: z.number().int().optional().default(10),
12698
+ mode: z.enum(["hybrid", "graph"]).optional().default("hybrid"),
12699
+ compress_to_tokens: z.number().int().nullable().optional()
12700
+ }).strip();
12701
+ var UUID_RE5 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12702
+ function requireValidUuid(documentId) {
12703
+ if (!UUID_RE5.test(String(documentId))) {
12704
+ throw new HandlerError(400, `invalid document id: ${JSON.stringify(documentId)}`);
12705
+ }
12706
+ }
12707
+ function resolveRequestPrincipals(value, opts) {
12708
+ if (value === TRUSTED) return TRUSTED;
12709
+ if (value == null) {
12710
+ throw new HandlerError(
12711
+ 500,
12712
+ `${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.`
12713
+ );
12714
+ }
12715
+ if (!Array.isArray(value) || value.some((p) => typeof p !== "string")) {
12716
+ throw new HandlerError(
12717
+ 500,
12718
+ `${opts.surface}: the \`principals\` dependency must return a list of principal strings, [] for an anonymous caller, or TRUSTED \u2014 got ${typeof value}.`
12719
+ );
12720
+ }
12721
+ return value;
12722
+ }
11466
12723
  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.";
11467
12724
  async function resolvePrincipalsFn(principals) {
11468
12725
  const result = await Promise.resolve(principals());
11469
- return result;
12726
+ try {
12727
+ return resolveRequestPrincipals(result, { surface: "the MCP `principals` dependency" });
12728
+ } catch (exc) {
12729
+ if (exc instanceof HandlerError) throw new Error(exc.message, { cause: exc });
12730
+ throw exc;
12731
+ }
11470
12732
  }
11471
- function registerToolGateway(mcp, engine, principals) {
12733
+ function registerToolGateway(mcp, engine, principals, approvalScope = null) {
11472
12734
  mcp.tool(
11473
12735
  "search_tools",
11474
12736
  "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,
11475
- { query: { type: "string" }, limit: { type: "number" } },
12737
+ // Zod raw shape see the note on the search tool in mcp.ts.
12738
+ { query: z.string(), limit: z.number().int().optional() },
11476
12739
  async (...raw) => {
11477
12740
  const args = raw[0] ?? {};
11478
12741
  const callerPrincipals = await resolvePrincipalsFn(principals);
@@ -11486,13 +12749,15 @@ function registerToolGateway(mcp, engine, principals) {
11486
12749
  mcp.tool(
11487
12750
  "execute_tool",
11488
12751
  "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,
11489
- { name: { type: "string" }, args: { type: "object" } },
12752
+ { name: z.string(), args: z.record(z.unknown()).optional() },
11490
12753
  async (...raw) => {
11491
12754
  const body = raw[0] ?? {};
11492
12755
  const callerPrincipals = await resolvePrincipalsFn(principals);
12756
+ const scope = approvalScope ? await Promise.resolve(approvalScope()) : null;
11493
12757
  return engine.executeTool(String(body.name), body.args ?? null, {
11494
12758
  principals: callerPrincipals,
11495
- source: "mcp"
12759
+ source: "mcp",
12760
+ approvalScope: scope
11496
12761
  });
11497
12762
  }
11498
12763
  );
@@ -11534,15 +12799,24 @@ async function createMcpApp(engine, opts) {
11534
12799
  "search_knowledge_base",
11535
12800
  `Hybrid search (full-text + trigram + vector, RRF-fused) over the ingested corpus. Returns the top-matching chunks with their source document.${SCOPE_NOTE}`,
11536
12801
  {
11537
- query: { type: "string" },
11538
- source_ids: { type: "array", items: { type: "string" } },
11539
- top_k: { type: "number" },
11540
- mode: { type: "string", enum: ["hybrid", "graph"] }
12802
+ // Zod RAW SHAPES, not JSON schema: the SDK's isZodRawShape test
12803
+ // rejects a plain schema object on current SDK versions that made
12804
+ // registration THROW at startup, and on 1.12.0 the object was consumed
12805
+ // as annotations and every handler ran with NO arguments.
12806
+ query: z.string(),
12807
+ source_ids: z.array(z.string()).optional(),
12808
+ document_ids: z.array(z.string()).optional(),
12809
+ top_k: z.number().int().optional(),
12810
+ mode: z.enum(["hybrid", "graph"]).optional()
11541
12811
  },
11542
12812
  async (args) => {
12813
+ for (const did of args.document_ids ?? []) {
12814
+ requireValidUuid(did);
12815
+ }
11543
12816
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
11544
12817
  const result = await engine.search(String(args.query ?? ""), {
11545
12818
  sourceIds: args.source_ids,
12819
+ documentIds: args.document_ids,
11546
12820
  principals: callerPrincipals,
11547
12821
  topK: args.top_k ?? 10,
11548
12822
  mode: args.mode ?? "hybrid"
@@ -11564,7 +12838,7 @@ async function createMcpApp(engine, opts) {
11564
12838
  tool(
11565
12839
  "get_document",
11566
12840
  `Fetch one document's full text and metadata by id.${SCOPE_NOTE}`,
11567
- { document_id: { type: "string" } },
12841
+ { document_id: z.string() },
11568
12842
  async (args) => {
11569
12843
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
11570
12844
  return engine.getDocument(String(args.document_id), { principals: callerPrincipals });
@@ -11574,7 +12848,7 @@ async function createMcpApp(engine, opts) {
11574
12848
  tool(
11575
12849
  "query_structured",
11576
12850
  `Answer a question against documents' extracted structured data (only documents with structured data are candidates).${SCOPE_NOTE}`,
11577
- { question: { type: "string" }, source_ids: { type: "array", items: { type: "string" } } },
12851
+ { question: z.string(), source_ids: z.array(z.string()).optional() },
11578
12852
  async (args) => {
11579
12853
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
11580
12854
  if (!engine.queryStructured) throw new Error("queryStructured is not available on this engine");
@@ -11588,7 +12862,7 @@ async function createMcpApp(engine, opts) {
11588
12862
  tool(
11589
12863
  "compute",
11590
12864
  `Run LLM-authored JavaScript over in-scope spreadsheet documents and return the computed result.${SCOPE_NOTE}`,
11591
- { instruction: { type: "string" }, source_ids: { type: "array", items: { type: "string" } } },
12865
+ { instruction: z.string(), source_ids: z.array(z.string()).optional() },
11592
12866
  async (args) => {
11593
12867
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
11594
12868
  if (!engine.compute) throw new Error("compute is not available on this engine");
@@ -11607,7 +12881,8 @@ async function createMcpApp(engine, opts) {
11607
12881
  }
11608
12882
  },
11609
12883
  engine,
11610
- opts.principals
12884
+ opts.principals,
12885
+ opts.approvalScope ?? null
11611
12886
  );
11612
12887
  const handler = (async (req, res) => {
11613
12888
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
@@ -11620,6 +12895,9 @@ async function createMcpApp(engine, opts) {
11620
12895
 
11621
12896
  // src/providers/index.ts
11622
12897
  init_llm();
12898
+
12899
+ // src/index.ts
12900
+ init_redaction();
11623
12901
  var InProcessRunner = class {
11624
12902
  registry = /* @__PURE__ */ new Map();
11625
12903
  /** Kept so the Promise isn't garbage-collected mid-flight. */
@@ -11671,6 +12949,6 @@ var CeleryRunner = class {
11671
12949
  // src/index.ts
11672
12950
  init_usage();
11673
12951
 
11674
- export { ApprovalExpired, ApprovalNotPending, CeleryRunner, CodeExecutionError, CodeExecutionTimeout, ContextEngine, ContextEngineConfig, DEFAULT_LEG_WEIGHT, DocumentNotFoundError, EXTRACTION_VERSION, Embedder, EngineActionError, ExtraMissingError, Extracted, GraphLegUnavailable, InProcessRunner, LLMClient, PostgresBackend, RedactionPolicy, RedactionRule, TRUSTED, ToolConfig, UNSET, __version__, applyRedaction, buildEmbedder, buildLlmClient, callLlm, compute, configSchema, createMcpApp, decryptDict, emitError, emitToolCall, emitUsage, encryptDict, extract2 as extract, extractStructuredData, functionTool, getDocumentText, getSecretKey, graphUnits, listDocuments, queryStructured, redactHits, rerank, resolveApproval, resolveFields, resolvePrincipals, rrfFuse, runMigrate, runSearch, shouldRequireApproval, unitsForFile, upsertRegistry };
12952
+ export { ApprovalExpired, ApprovalNotPending, CeleryRunner, CodeExecutionError, CodeExecutionTimeout, ContextEngine, ContextEngineConfig, DEFAULT_LEG_WEIGHT, DocumentNotFoundError, EXTRACTION_VERSION, Embedder, EngineActionError, ExtraMissingError, Extracted, GraphLegUnavailable, InProcessRunner, LLMClient, PostgresBackend, RedactionPolicy, RedactionRule, TRUSTED, ToolConfig, UNSET, __version__, applyRedaction, buildEmbedder, buildLlmClient, callLlm, compute, configSchema, createMcpApp, decryptDict, emitError, emitProgress, emitToolCall, emitUsage, encryptDict, extract2 as extract, extractStructuredData, functionTool, getDocumentText, getSecretKey, graphUnits, listDocuments, queryStructured, redactHits, rerank, resolveApproval, resolveFields, resolvePrincipals, rrfFuse, runMigrate, runSearch, shouldRequireApproval, unitsForFile, upsertRegistry };
11675
12953
  //# sourceMappingURL=index.js.map
11676
12954
  //# sourceMappingURL=index.js.map