@promptev/context-engine 0.0.0 → 0.0.2

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 (60) hide show
  1. package/README.md +116 -5
  2. package/dist/cli.js +1896 -552
  3. package/dist/cli.js.map +1 -1
  4. package/dist/{config-Bl9U789m.d.cts → config-BODDdXJ7.d.ts} +75 -16
  5. package/dist/{config-Bt9bUQqU.d.ts → config-C5RZ00W6.d.cts} +75 -16
  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-XIScatRO.d.ts → governance-BLPK7NMe.d.ts} +8 -2
  19. package/dist/{governance-BDkcv4qZ.d.cts → governance-P9pRb4Ol.d.cts} +8 -2
  20. package/dist/graph/index.cjs +171 -59
  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 +171 -59
  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 +2413 -1052
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +125 -107
  35. package/dist/index.d.ts +125 -107
  36. package/dist/index.js +2412 -1050
  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-CiFwC-EN.d.cts} +17 -2
  51. package/dist/{router-OPgSoYAB.d.cts → router-D8gBzwLd.d.ts} +17 -2
  52. package/dist/skills/context-engine/SKILL.md +5 -1
  53. package/dist/storage-CJrKgJeJ.d.ts +167 -0
  54. package/dist/storage-Dvpq2xAC.d.cts +167 -0
  55. package/package.json +61 -23
  56. package/src/migrations/sql/0003_tools.sql +2 -0
  57. package/src/migrations/sql/0004_acl_indexes.sql +23 -2
  58. package/src/skills/context-engine/SKILL.md +5 -1
  59. package/dist/embeddings-B-jZ42mk.d.cts +0 -67
  60. package/dist/embeddings-DaSdAZN3.d.ts +0 -67
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { randomUUID, createHash, randomBytes, createCipheriv, createDecipheriv, createHmac } from 'crypto';
3
+ import { z } from 'zod';
3
4
  import { readFileSync, existsSync, mkdirSync, writeFileSync, openSync, readSync, closeSync } from 'fs';
4
5
  import { join, dirname, basename } from 'path';
5
6
  import { fileURLToPath } from 'url';
@@ -13,11 +14,12 @@ import mammoth from 'mammoth';
13
14
  import PostalMime from 'postal-mime';
14
15
  import { getDocumentProxy, extractText, renderPageAsImage, extractImages } from 'unpdf';
15
16
  import { getEncoding } from 'js-tiktoken';
17
+ import { promises } from 'dns';
18
+ import { isIP } from 'net';
16
19
  import { createRequire } from 'module';
17
20
  import { createServer } from 'http';
18
21
  import { homedir } from 'os';
19
22
  import { Command } from 'commander';
20
- import { z } from 'zod';
21
23
 
22
24
  var __defProp = Object.defineProperty;
23
25
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -82,6 +84,14 @@ function emitError(hooks, exc, ctx) {
82
84
  log.warn("onError callback raised; swallowing");
83
85
  }
84
86
  }
87
+ function emitProgress(hooks, event) {
88
+ if (!hooks?.onProgress) return;
89
+ try {
90
+ hooks.onProgress(event);
91
+ } catch {
92
+ log.warn("onProgress callback raised; swallowing");
93
+ }
94
+ }
85
95
  function emitToolCall(hooks, event) {
86
96
  if (!hooks?.onToolCall) return;
87
97
  try {
@@ -360,6 +370,197 @@ var init_redaction = __esm({
360
370
  HASH_TOKEN_CHARS = 16;
361
371
  }
362
372
  });
373
+ function camelize(key) {
374
+ return key.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
375
+ }
376
+ function loadCeEnv() {
377
+ const root = {};
378
+ for (const [raw, value] of Object.entries(process.env)) {
379
+ if (!raw.startsWith("CE_") || value === void 0) continue;
380
+ const path = raw.slice(3).split("__").map(camelize);
381
+ let cur = root;
382
+ for (let i = 0; i < path.length - 1; i++) {
383
+ const k = path[i];
384
+ const next = cur[k];
385
+ if (typeof next !== "object" || next === null) cur[k] = {};
386
+ cur = cur[k];
387
+ }
388
+ cur[path[path.length - 1]] = coerceEnv(value);
389
+ }
390
+ return root;
391
+ }
392
+ function coerceEnv(value) {
393
+ if (value === "true") return true;
394
+ if (value === "false") return false;
395
+ if (/^-?\d+$/.test(value)) return Number(value);
396
+ if (/^-?\d+\.\d+$/.test(value)) return Number(value);
397
+ return value;
398
+ }
399
+ function deepMerge(a, b) {
400
+ const out = { ...a };
401
+ for (const [k, v] of Object.entries(b)) {
402
+ if (v === void 0) continue;
403
+ const existing = out[k];
404
+ if (v && typeof v === "object" && !Array.isArray(v) && existing && typeof existing === "object" && !Array.isArray(existing)) {
405
+ out[k] = deepMerge(existing, v);
406
+ } else {
407
+ out[k] = v;
408
+ }
409
+ }
410
+ return out;
411
+ }
412
+ var embeddingSchema, llmSchema, graphSchema, rerankerSchema, fusionSchema, storageSchema, extractionSchema, ContextEngineConfig;
413
+ var init_config = __esm({
414
+ "src/config.ts"() {
415
+ init_redaction();
416
+ embeddingSchema = z.object({
417
+ provider: z.enum(["openai", "azure_openai", "gemini", "vertex_ai", "voyage", "cohere", "custom"]),
418
+ model: z.string(),
419
+ dim: z.number().int().positive().nullable().optional().default(null),
420
+ apiKey: z.string().nullable().optional().default(null),
421
+ baseUrl: z.string().nullable().optional().default(null),
422
+ // `vertex_ai` only. Left optional on purpose: the Google SDK resolves both
423
+ // from GOOGLE_CLOUD_PROJECT / GOOGLE_CLOUD_LOCATION, which is how a GCP
424
+ // deployment is already wired, and requiring them here would break it.
425
+ //
426
+ // No `.default(null)`, unlike the fields above — these types come from
427
+ // `z.infer`, so a default would make them REQUIRED on the output type and
428
+ // break every hand-written `EmbeddingConfig` literal already compiled
429
+ // against 0.0.1. Optional keeps the addition additive.
430
+ project: z.string().nullable().optional(),
431
+ location: z.string().nullable().optional()
432
+ });
433
+ llmSchema = z.object({
434
+ provider: z.enum(["anthropic", "openai", "azure_openai", "gemini", "vertex_ai", "bedrock", "custom"]),
435
+ model: z.string(),
436
+ apiKey: z.string().nullable().optional().default(null),
437
+ baseUrl: z.string().nullable().optional().default(null),
438
+ // `vertex_ai` only — see the note on embeddingSchema.
439
+ project: z.string().nullable().optional(),
440
+ location: z.string().nullable().optional()
441
+ });
442
+ graphSchema = z.object({
443
+ enabled: z.boolean().default(false),
444
+ neo4jUri: z.string().nullable().optional().default(null),
445
+ neo4jUser: z.string().default("neo4j"),
446
+ neo4jPassword: z.string().nullable().optional().default(null),
447
+ neo4jDatabase: z.string().default("neo4j"),
448
+ extractionLlm: llmSchema.nullable().optional().default(null)
449
+ });
450
+ rerankerSchema = z.object({
451
+ enabled: z.boolean().default(false),
452
+ provider: z.enum(["cohere", "voyage", "jina", "custom"]).nullable().optional().default(null),
453
+ model: z.string().nullable().optional().default(null),
454
+ apiKey: z.string().nullable().optional().default(null),
455
+ baseUrl: z.string().nullable().optional().default(null),
456
+ candidates: z.number().int().positive().default(50)
457
+ });
458
+ fusionSchema = z.object({
459
+ method: z.literal("rrf").default("rrf"),
460
+ k: z.number().int().positive().default(60),
461
+ weights: z.record(z.string(), z.number()).default({ fts: 1, trgm: 0.8, ann: 1, graph: 1 })
462
+ });
463
+ storageSchema = z.object({
464
+ backend: z.literal("postgres").default("postgres"),
465
+ annExactThreshold: z.number().int().positive().default(5e4),
466
+ // Connection pool, passed straight through to `pg.Pool`. Sizing is a
467
+ // deployment decision (how many workers times how many concurrent searches
468
+ // each runs, against what the database allows), so these are pg's own
469
+ // defaults rather than a guess at your topology. (`pg` has no pre-ping.)
470
+ //
471
+ // Connections kept open in the pool.
472
+ poolMax: z.number().int().positive().default(10),
473
+ // Milliseconds an idle connection is kept before it is closed.
474
+ poolIdleTimeoutMs: z.number().int().nonnegative().default(1e4),
475
+ // Milliseconds a caller waits for a connection before the attempt fails.
476
+ poolConnectionTimeoutMs: z.number().int().nonnegative().default(3e4)
477
+ });
478
+ extractionSchema = z.object({
479
+ // Ceiling on an image's LONG EDGE in pixels, for rendered PDF pages and
480
+ // for images sent to the vision LLM.
481
+ maxRenderPx: z.number().int().positive().default(2e3),
482
+ // Ceiling on ONE vision batch's transcription, in output tokens — a reply
483
+ // far past a batch's honest bound is a repetition loop. Raising it is the
484
+ // fix if very dense pages come back truncated.
485
+ visionMaxOutputTokens: z.number().int().positive().default(16e3),
486
+ // Pages per vision-LLM call. Smaller batches mean more calls but more
487
+ // concurrency and less risk of hitting the output ceiling or a provider
488
+ // deadline; larger batches the reverse. Measure on your own corpus.
489
+ pagesPerVisionBatch: z.number().int().positive().default(5),
490
+ // Language hint for the Tesseract OCR fallback ('en', 'ur', 'mixed', any
491
+ // Tesseract code, or 'auto' for script detection). null (the default)
492
+ // detects the document's language from its usable text-layer pages and
493
+ // falls back to 'auto' when there is nothing to sample.
494
+ ocrLanguage: z.string().nullable().default(null)
495
+ });
496
+ ContextEngineConfig = class _ContextEngineConfig {
497
+ databaseUrl;
498
+ storage;
499
+ defaultMode;
500
+ embedding;
501
+ llm;
502
+ visionLlm;
503
+ graph;
504
+ reranker;
505
+ fusion;
506
+ extraction;
507
+ enableCodeExecution;
508
+ secretKey;
509
+ /**
510
+ * Lets http tools and probes reach loopback/private/link-local/metadata
511
+ * addresses. Off by default — an http tool is registered by a caller, and a
512
+ * private destination is server-side request forgery. See `tools/egress.ts`.
513
+ */
514
+ allowPrivateEgress;
515
+ redaction;
516
+ constructor(init) {
517
+ this.databaseUrl = init.databaseUrl;
518
+ this.storage = storageSchema.parse(init.storage ?? {});
519
+ this.defaultMode = init.defaultMode ?? "hybrid";
520
+ this.embedding = embeddingSchema.parse(init.embedding);
521
+ this.llm = init.llm ? llmSchema.parse(init.llm) : null;
522
+ this.visionLlm = init.visionLlm ? llmSchema.parse(init.visionLlm) : null;
523
+ this.graph = graphSchema.parse(init.graph ?? {});
524
+ this.reranker = rerankerSchema.parse(init.reranker ?? {});
525
+ this.fusion = fusionSchema.parse(init.fusion ?? {});
526
+ this.extraction = extractionSchema.parse(init.extraction ?? {});
527
+ this.enableCodeExecution = init.enableCodeExecution ?? false;
528
+ this.secretKey = init.secretKey ?? null;
529
+ this.allowPrivateEgress = init.allowPrivateEgress ?? false;
530
+ this.redaction = init.redaction instanceof RedactionPolicy ? init.redaction : new RedactionPolicy(init.redaction ?? {});
531
+ this.validate();
532
+ }
533
+ validate() {
534
+ if (this.graph.enabled && !(this.graph.neo4jUri && this.graph.neo4jPassword && this.graph.extractionLlm)) {
535
+ throw new Error("graph enabled but neo4jUri/neo4jPassword/extractionLlm missing");
536
+ }
537
+ if (this.reranker.enabled && !(this.reranker.provider && this.reranker.apiKey)) {
538
+ throw new Error("reranker enabled but provider/apiKey missing");
539
+ }
540
+ if (this.defaultMode === "graph" && !this.graph.enabled) {
541
+ throw new Error("defaultMode is 'graph' but graph enabled is false");
542
+ }
543
+ for (const rule of this.redaction.rules) {
544
+ if (rule.action === "hash" && !this.secretKey) {
545
+ throw new Error(
546
+ `redaction rule '${rule.name}': action='hash' requires ContextEngineConfig.secretKey to be set`
547
+ );
548
+ }
549
+ }
550
+ }
551
+ static fromEnv(overrides = {}) {
552
+ const env = loadCeEnv();
553
+ const merged = deepMerge(env, overrides);
554
+ if (!merged.databaseUrl || !merged.embedding) {
555
+ throw new Error(
556
+ "ContextEngineConfig.fromEnv requires CE_DATABASE_URL and CE_EMBEDDING__PROVIDER/MODEL (or explicit overrides)"
557
+ );
558
+ }
559
+ return new _ContextEngineConfig(merged);
560
+ }
561
+ };
562
+ }
563
+ });
363
564
 
364
565
  // src/errors.ts
365
566
  var EngineActionError, DocumentNotFoundError, GraphLegUnavailable, ExtraMissingError;
@@ -462,6 +663,7 @@ async function runMigrate(databaseUrl, opts = {}) {
462
663
  recorded = rev;
463
664
  done.add(rev);
464
665
  }
666
+ await client.query(loadSql("0004_acl_indexes", dim));
465
667
  const meta = await client.query("SELECT embedding_dim FROM context_engine_meta WHERE id = 1");
466
668
  if (!meta.rows.length) {
467
669
  await client.query("INSERT INTO context_engine_meta (id, embedding_dim) VALUES (1, $1)", [dim]);
@@ -517,16 +719,116 @@ var init_extras = __esm({
517
719
  }
518
720
  });
519
721
 
520
- // src/tools/mcp-tools.ts
722
+ // src/sentinels.ts
723
+ function resolvePrincipals(value, method) {
724
+ if (value === TRUSTED) return null;
725
+ if (value === void 0 || value === null) {
726
+ if (!warnedMethods.has(method)) {
727
+ warnedMethods.add(method);
728
+ process.emitWarning(
729
+ `${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.`,
730
+ { type: "DeprecationWarning", code: "CE_PRINCIPALS_NULL" }
731
+ );
732
+ }
733
+ return null;
734
+ }
735
+ if (!Array.isArray(value)) {
736
+ throw new TypeError(
737
+ `${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.`
738
+ );
739
+ }
740
+ return value;
741
+ }
742
+ var UNSET, TRUSTED, warnedMethods;
743
+ var init_sentinels = __esm({
744
+ "src/sentinels.ts"() {
745
+ UNSET = /* @__PURE__ */ Symbol.for("context_engine.UNSET");
746
+ TRUSTED = /* @__PURE__ */ Symbol.for("context_engine.TRUSTED");
747
+ warnedMethods = /* @__PURE__ */ new Set();
748
+ }
749
+ });
750
+ function requireValidUuid(documentId) {
751
+ if (!UUID_RE2.test(String(documentId))) {
752
+ throw new HandlerError(400, `invalid document id: ${JSON.stringify(documentId)}`);
753
+ }
754
+ }
755
+ function resolveRequestPrincipals(value, opts) {
756
+ if (value === TRUSTED) return TRUSTED;
757
+ if (value == null) {
758
+ throw new HandlerError(
759
+ 500,
760
+ `${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.`
761
+ );
762
+ }
763
+ if (!Array.isArray(value) || value.some((p) => typeof p !== "string")) {
764
+ throw new HandlerError(
765
+ 500,
766
+ `${opts.surface}: the \`principals\` dependency must return a list of principal strings, [] for an anonymous caller, or TRUSTED \u2014 got ${typeof value}.`
767
+ );
768
+ }
769
+ return value;
770
+ }
771
+ var HandlerError, UUID_RE2;
772
+ var init_routing_core = __esm({
773
+ "src/routing-core.ts"() {
774
+ init_sentinels();
775
+ HandlerError = class extends Error {
776
+ status;
777
+ detail;
778
+ constructor(status, detail) {
779
+ super(typeof detail === "string" ? detail : JSON.stringify(detail));
780
+ this.name = "HandlerError";
781
+ this.status = status;
782
+ this.detail = detail;
783
+ }
784
+ };
785
+ z.object({
786
+ text: z.string(),
787
+ name: z.string(),
788
+ source_id: z.string().nullable().optional(),
789
+ external_id: z.string().nullable().optional(),
790
+ description: z.string().nullable().optional(),
791
+ meta_data: z.record(z.unknown()).nullable().optional(),
792
+ acl: z.array(z.string()).nullable().optional(),
793
+ mode: z.enum(["hybrid", "graph"]).nullable().optional(),
794
+ extract_structured: z.boolean().optional().default(false),
795
+ batch: z.boolean().optional().default(false)
796
+ }).strip();
797
+ z.object({
798
+ acl: z.array(z.string()).nullable().optional(),
799
+ name: z.string().nullable().optional(),
800
+ description: z.string().nullable().optional(),
801
+ meta_data: z.record(z.unknown()).nullable().optional()
802
+ }).strict();
803
+ z.object({
804
+ query: z.string(),
805
+ source_ids: z.array(z.string()).nullable().optional(),
806
+ // Narrows WITHIN a source and INTERSECTS with source_ids — it can only
807
+ // shrink the result set (the ACL predicate still applies in the same SQL
808
+ // conjunction), so exposing it needs no authorizeAcl-style grant check.
809
+ document_ids: z.array(z.string()).nullable().optional(),
810
+ top_k: z.number().int().optional().default(10),
811
+ mode: z.enum(["hybrid", "graph"]).optional().default("hybrid"),
812
+ compress_to_tokens: z.number().int().nullable().optional()
813
+ }).strip();
814
+ UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
815
+ }
816
+ });
521
817
  async function resolvePrincipalsFn(principals) {
522
818
  const result = await Promise.resolve(principals());
523
- return result;
819
+ try {
820
+ return resolveRequestPrincipals(result, { surface: "the MCP `principals` dependency" });
821
+ } catch (exc) {
822
+ if (exc instanceof HandlerError) throw new Error(exc.message, { cause: exc });
823
+ throw exc;
824
+ }
524
825
  }
525
- function registerToolGateway(mcp, engine, principals) {
826
+ function registerToolGateway(mcp, engine, principals, approvalScope = null) {
526
827
  mcp.tool(
527
828
  "search_tools",
528
829
  "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,
529
- { query: { type: "string" }, limit: { type: "number" } },
830
+ // Zod raw shape see the note on the search tool in mcp.ts.
831
+ { query: z.string(), limit: z.number().int().optional() },
530
832
  async (...raw) => {
531
833
  const args = raw[0] ?? {};
532
834
  const callerPrincipals = await resolvePrincipalsFn(principals);
@@ -540,13 +842,15 @@ function registerToolGateway(mcp, engine, principals) {
540
842
  mcp.tool(
541
843
  "execute_tool",
542
844
  "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,
543
- { name: { type: "string" }, args: { type: "object" } },
845
+ { name: z.string(), args: z.record(z.unknown()).optional() },
544
846
  async (...raw) => {
545
847
  const body = raw[0] ?? {};
546
848
  const callerPrincipals = await resolvePrincipalsFn(principals);
849
+ const scope = approvalScope ? await Promise.resolve(approvalScope()) : null;
547
850
  return engine.executeTool(String(body.name), body.args ?? null, {
548
851
  principals: callerPrincipals,
549
- source: "mcp"
852
+ source: "mcp",
853
+ approvalScope: scope
550
854
  });
551
855
  }
552
856
  );
@@ -554,6 +858,7 @@ function registerToolGateway(mcp, engine, principals) {
554
858
  var SCOPE_NOTE;
555
859
  var init_mcp_tools = __esm({
556
860
  "src/tools/mcp-tools.ts"() {
861
+ init_routing_core();
557
862
  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.";
558
863
  }
559
864
  });
@@ -606,15 +911,24 @@ async function createMcpApp(engine, opts) {
606
911
  "search_knowledge_base",
607
912
  `Hybrid search (full-text + trigram + vector, RRF-fused) over the ingested corpus. Returns the top-matching chunks with their source document.${SCOPE_NOTE}`,
608
913
  {
609
- query: { type: "string" },
610
- source_ids: { type: "array", items: { type: "string" } },
611
- top_k: { type: "number" },
612
- mode: { type: "string", enum: ["hybrid", "graph"] }
914
+ // Zod RAW SHAPES, not JSON schema: the SDK's isZodRawShape test
915
+ // rejects a plain schema object on current SDK versions that made
916
+ // registration THROW at startup, and on 1.12.0 the object was consumed
917
+ // as annotations and every handler ran with NO arguments.
918
+ query: z.string(),
919
+ source_ids: z.array(z.string()).optional(),
920
+ document_ids: z.array(z.string()).optional(),
921
+ top_k: z.number().int().optional(),
922
+ mode: z.enum(["hybrid", "graph"]).optional()
613
923
  },
614
924
  async (args) => {
925
+ for (const did of args.document_ids ?? []) {
926
+ requireValidUuid(did);
927
+ }
615
928
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
616
929
  const result = await engine.search(String(args.query ?? ""), {
617
930
  sourceIds: args.source_ids,
931
+ documentIds: args.document_ids,
618
932
  principals: callerPrincipals,
619
933
  topK: args.top_k ?? 10,
620
934
  mode: args.mode ?? "hybrid"
@@ -636,7 +950,7 @@ async function createMcpApp(engine, opts) {
636
950
  tool(
637
951
  "get_document",
638
952
  `Fetch one document's full text and metadata by id.${SCOPE_NOTE}`,
639
- { document_id: { type: "string" } },
953
+ { document_id: z.string() },
640
954
  async (args) => {
641
955
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
642
956
  return engine.getDocument(String(args.document_id), { principals: callerPrincipals });
@@ -646,7 +960,7 @@ async function createMcpApp(engine, opts) {
646
960
  tool(
647
961
  "query_structured",
648
962
  `Answer a question against documents' extracted structured data (only documents with structured data are candidates).${SCOPE_NOTE}`,
649
- { question: { type: "string" }, source_ids: { type: "array", items: { type: "string" } } },
963
+ { question: z.string(), source_ids: z.array(z.string()).optional() },
650
964
  async (args) => {
651
965
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
652
966
  if (!engine.queryStructured) throw new Error("queryStructured is not available on this engine");
@@ -660,7 +974,7 @@ async function createMcpApp(engine, opts) {
660
974
  tool(
661
975
  "compute",
662
976
  `Run LLM-authored JavaScript over in-scope spreadsheet documents and return the computed result.${SCOPE_NOTE}`,
663
- { instruction: { type: "string" }, source_ids: { type: "array", items: { type: "string" } } },
977
+ { instruction: z.string(), source_ids: z.array(z.string()).optional() },
664
978
  async (args) => {
665
979
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
666
980
  if (!engine.compute) throw new Error("compute is not available on this engine");
@@ -679,7 +993,8 @@ async function createMcpApp(engine, opts) {
679
993
  }
680
994
  },
681
995
  engine,
682
- opts.principals
996
+ opts.principals,
997
+ opts.approvalScope ?? null
683
998
  );
684
999
  const handler = (async (req, res) => {
685
1000
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
@@ -693,6 +1008,7 @@ var init_mcp = __esm({
693
1008
  "src/mcp.ts"() {
694
1009
  init_errors();
695
1010
  init_extras();
1011
+ init_routing_core();
696
1012
  init_mcp_tools();
697
1013
  init_version();
698
1014
  }
@@ -1964,9 +2280,52 @@ var init_chunkers = __esm({
1964
2280
  }
1965
2281
  });
1966
2282
 
2283
+ // src/providers/google.ts
2284
+ async function buildGenaiClient(cfg2, timeoutMs, purpose) {
2285
+ const specifier = "@google/genai";
2286
+ let mod;
2287
+ try {
2288
+ mod = await import(specifier);
2289
+ } catch {
2290
+ throw new ExtraMissingError("gemini", specifier, purpose);
2291
+ }
2292
+ const Ctor = mod.GoogleGenAI ?? mod.Client;
2293
+ if (!Ctor) {
2294
+ throw new ExtraMissingError("gemini", specifier, purpose);
2295
+ }
2296
+ const opts = { httpOptions: { timeout: timeoutMs } };
2297
+ if (cfg2.provider === "vertex_ai") {
2298
+ opts.vertexai = true;
2299
+ if (cfg2.project || cfg2.location) {
2300
+ if (cfg2.project) opts.project = cfg2.project;
2301
+ if (cfg2.location) opts.location = cfg2.location;
2302
+ } else if (cfg2.apiKey) {
2303
+ opts.apiKey = cfg2.apiKey;
2304
+ }
2305
+ } else {
2306
+ opts.apiKey = cfg2.apiKey ?? null;
2307
+ }
2308
+ try {
2309
+ return new Ctor(opts);
2310
+ } catch (err) {
2311
+ const message = err instanceof Error ? err.message : String(err);
2312
+ if (!message.includes("Authentication is not set up")) throw err;
2313
+ throw new Error(
2314
+ `${message} Set \`project\` on the provider config, or export GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION. Credentials themselves come from Application Default Credentials.`
2315
+ );
2316
+ }
2317
+ }
2318
+ var init_google = __esm({
2319
+ "src/providers/google.ts"() {
2320
+ init_errors();
2321
+ }
2322
+ });
2323
+
1967
2324
  // src/providers/llm.ts
1968
2325
  var llm_exports = {};
1969
2326
  __export(llm_exports, {
2327
+ CALL_TIMEOUT_MS: () => CALL_TIMEOUT_MS,
2328
+ GEMINI_CALL_TIMEOUT_MS: () => GEMINI_CALL_TIMEOUT_MS,
1970
2329
  LLMClient: () => LLMClient,
1971
2330
  TIMEOUT_MS: () => TIMEOUT_MS,
1972
2331
  buildLlmClient: () => buildLlmClient,
@@ -1997,20 +2356,6 @@ function buildOpenAIChatClient(cfg2) {
1997
2356
  maxRetries: 0
1998
2357
  });
1999
2358
  }
2000
- async function loadGeminiChatClient(apiKey) {
2001
- const specifier = "@google/genai";
2002
- let mod;
2003
- try {
2004
- mod = await import(specifier);
2005
- } catch {
2006
- throw new ExtraMissingError("gemini", specifier, "gemini llm");
2007
- }
2008
- const Ctor = mod.GoogleGenAI ?? mod.Client;
2009
- if (!Ctor) {
2010
- throw new ExtraMissingError("gemini", specifier, "gemini llm");
2011
- }
2012
- return new Ctor({ apiKey: apiKey ?? null });
2013
- }
2014
2359
  async function loadBedrockSdk() {
2015
2360
  const specifier = "@aws-sdk/client-bedrock-runtime";
2016
2361
  try {
@@ -2029,7 +2374,7 @@ function buildLlmClient(cfg2, opts) {
2029
2374
  if (OPENAI_FAMILY.has(cfg2.provider)) {
2030
2375
  return new LLMClient(cfg2, { client: buildOpenAIChatClient(cfg2) });
2031
2376
  }
2032
- if (cfg2.provider === "gemini" || cfg2.provider === "bedrock") {
2377
+ if (GOOGLE_FAMILY.has(cfg2.provider) || cfg2.provider === "bedrock") {
2033
2378
  return new LLMClient(cfg2);
2034
2379
  }
2035
2380
  throw new Error(`unknown llm provider: ${JSON.stringify(cfg2.provider)}`);
@@ -2046,14 +2391,18 @@ async function callLlm(cfg2, opts) {
2046
2391
  await owned.aclose();
2047
2392
  }
2048
2393
  }
2049
- var TIMEOUT_MS, ANTHROPIC_VERSION, ANTHROPIC_MAX_TOKENS, OPENAI_FAMILY, LLMClient;
2394
+ var CALL_TIMEOUT_MS, TIMEOUT_MS, GEMINI_CALL_TIMEOUT_MS, ANTHROPIC_VERSION, ANTHROPIC_MAX_TOKENS, OPENAI_FAMILY, GOOGLE_FAMILY, LLMClient;
2050
2395
  var init_llm = __esm({
2051
2396
  "src/providers/llm.ts"() {
2052
2397
  init_errors();
2053
- TIMEOUT_MS = 3e4;
2398
+ init_google();
2399
+ CALL_TIMEOUT_MS = 24e4;
2400
+ TIMEOUT_MS = CALL_TIMEOUT_MS;
2401
+ GEMINI_CALL_TIMEOUT_MS = CALL_TIMEOUT_MS;
2054
2402
  ANTHROPIC_VERSION = "2023-06-01";
2055
2403
  ANTHROPIC_MAX_TOKENS = 4096;
2056
2404
  OPENAI_FAMILY = /* @__PURE__ */ new Set(["openai", "azure_openai", "custom"]);
2405
+ GOOGLE_FAMILY = /* @__PURE__ */ new Set(["gemini", "vertex_ai"]);
2057
2406
  LLMClient = class {
2058
2407
  cfg;
2059
2408
  provider;
@@ -2078,22 +2427,30 @@ var init_llm = __esm({
2078
2427
  await this.aclose();
2079
2428
  }
2080
2429
  async call(opts) {
2081
- const { system, user, jsonMode = false, images = null } = opts;
2430
+ const {
2431
+ system,
2432
+ user,
2433
+ jsonMode = false,
2434
+ images = null,
2435
+ maxTokens = null,
2436
+ thinkingBudget = null,
2437
+ temperature = null
2438
+ } = opts;
2082
2439
  if (this.provider === "anthropic") {
2083
- return this.callAnthropic(system, user, jsonMode, images);
2440
+ return this.callAnthropic(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
2084
2441
  }
2085
2442
  if (OPENAI_FAMILY.has(this.provider)) {
2086
- return this.callOpenAI(system, user, jsonMode, images);
2443
+ return this.callOpenAI(system, user, jsonMode, images, maxTokens, temperature);
2087
2444
  }
2088
- if (this.provider === "gemini") {
2089
- return this.callGemini(system, user, jsonMode, images);
2445
+ if (GOOGLE_FAMILY.has(this.provider)) {
2446
+ return this.callGemini(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
2090
2447
  }
2091
2448
  if (this.provider === "bedrock") {
2092
- return this.callBedrock(system, user, jsonMode, images);
2449
+ return this.callBedrock(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
2093
2450
  }
2094
2451
  throw new Error(`unknown llm provider: ${JSON.stringify(this.provider)}`);
2095
2452
  }
2096
- async callAnthropic(system, user, jsonMode, images) {
2453
+ async callAnthropic(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
2097
2454
  if (!this.fetchImpl) {
2098
2455
  throw new Error("anthropic llm client has no fetch implementation");
2099
2456
  }
@@ -2114,26 +2471,27 @@ Respond with valid JSON only.`;
2114
2471
  });
2115
2472
  }
2116
2473
  content.push({ type: "text", text: user });
2117
- const data = await postJson(
2118
- this.fetchImpl,
2119
- "https://api.anthropic.com/v1/messages",
2120
- {
2121
- model: this.model,
2122
- max_tokens: ANTHROPIC_MAX_TOKENS,
2123
- system,
2124
- messages: [{ role: "user", content }]
2125
- },
2126
- {
2127
- "x-api-key": this.cfg.apiKey ?? "",
2128
- "anthropic-version": ANTHROPIC_VERSION,
2129
- "content-type": "application/json"
2130
- }
2131
- );
2474
+ const body = {
2475
+ model: this.model,
2476
+ max_tokens: maxTokens ?? ANTHROPIC_MAX_TOKENS,
2477
+ system,
2478
+ messages: [{ role: "user", content }]
2479
+ };
2480
+ if (thinkingBudget) {
2481
+ body.thinking = { type: "enabled", budget_tokens: thinkingBudget };
2482
+ } else if (temperature !== null) {
2483
+ body.temperature = temperature;
2484
+ }
2485
+ const data = await postJson(this.fetchImpl, "https://api.anthropic.com/v1/messages", body, {
2486
+ "x-api-key": this.cfg.apiKey ?? "",
2487
+ "anthropic-version": ANTHROPIC_VERSION,
2488
+ "content-type": "application/json"
2489
+ });
2132
2490
  const text = data.content[0].text;
2133
2491
  const usage = data.usage ?? {};
2134
2492
  return [text, { input: usage.input_tokens ?? 0, output: usage.output_tokens ?? 0 }];
2135
2493
  }
2136
- async callOpenAI(system, user, jsonMode, images) {
2494
+ async callOpenAI(system, user, jsonMode, images, maxTokens, temperature = null) {
2137
2495
  if (!this.client) {
2138
2496
  throw new Error("openai-family llm client has no client");
2139
2497
  }
@@ -2154,24 +2512,54 @@ Respond with valid JSON only.`;
2154
2512
  if (jsonMode) {
2155
2513
  body.response_format = { type: "json_object" };
2156
2514
  }
2515
+ if (maxTokens) {
2516
+ body.max_completion_tokens = maxTokens;
2517
+ }
2518
+ if (temperature !== null) {
2519
+ body.temperature = temperature;
2520
+ }
2157
2521
  const resp = await this.client.chat.completions.create(body);
2158
2522
  const text = resp.choices[0]?.message?.content ?? "";
2159
2523
  const usage = resp.usage;
2160
2524
  return [text, { input: usage?.prompt_tokens ?? 0, output: usage?.completion_tokens ?? 0 }];
2161
2525
  }
2162
- async callGemini(system, user, jsonMode, images) {
2526
+ async callGemini(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
2163
2527
  if (!this.genaiClient) {
2164
- this.genaiClient = await loadGeminiChatClient(this.cfg.apiKey);
2528
+ this.genaiClient = await buildGenaiClient(
2529
+ this.cfg,
2530
+ GEMINI_CALL_TIMEOUT_MS,
2531
+ "gemini llm"
2532
+ );
2165
2533
  }
2166
2534
  const parts = [];
2167
2535
  for (const img of images ?? []) {
2168
2536
  parts.push({ inlineData: { mimeType: "image/png", data: toBase64(img) } });
2169
2537
  }
2170
2538
  parts.push({ text: user });
2171
- const config = { systemInstruction: system };
2539
+ const config = {
2540
+ systemInstruction: system,
2541
+ // ALWAYS off, and not as a preference. Automatic function calling means
2542
+ // the SDK itself EXECUTES a callable it was handed as a tool and loops
2543
+ // on the result — up to ten round trips — before returning anything.
2544
+ // This package passes declarations only, so today nothing is executable;
2545
+ // but that depends on every future caller continuing to do the same, and
2546
+ // an application that gates tool execution behind human approval would
2547
+ // have that gate bypassed silently, by a library, with the loop already
2548
+ // run before it could object.
2549
+ automaticFunctionCalling: { disable: true }
2550
+ };
2172
2551
  if (jsonMode) {
2173
2552
  config.responseMimeType = "application/json";
2174
2553
  }
2554
+ if (maxTokens) {
2555
+ config.maxOutputTokens = maxTokens;
2556
+ }
2557
+ if (temperature !== null) {
2558
+ config.temperature = temperature;
2559
+ }
2560
+ if (thinkingBudget !== null) {
2561
+ config.thinkingConfig = { thinkingBudget };
2562
+ }
2175
2563
  const resp = await this.genaiClient.models.generateContent({
2176
2564
  model: this.model,
2177
2565
  contents: parts,
@@ -2183,7 +2571,7 @@ Respond with valid JSON only.`;
2183
2571
  const outputTokens = usageMeta?.candidatesTokenCount ?? usageMeta?.candidates_token_count ?? 0;
2184
2572
  return [text, { input: inputTokens || 0, output: outputTokens || 0 }];
2185
2573
  }
2186
- async callBedrock(system, user, jsonMode, images) {
2574
+ async callBedrock(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
2187
2575
  const { BedrockRuntimeClient, ConverseCommand } = await loadBedrockSdk();
2188
2576
  if (jsonMode) {
2189
2577
  system = `${system}
@@ -2207,7 +2595,21 @@ Respond with valid JSON only.`;
2207
2595
  new ConverseCommand({
2208
2596
  modelId: this.model,
2209
2597
  system: [{ text: system }],
2210
- messages: [{ role: "user", content }]
2598
+ messages: [{ role: "user", content }],
2599
+ ...maxTokens || temperature !== null ? {
2600
+ inferenceConfig: {
2601
+ ...maxTokens ? { maxTokens } : {},
2602
+ ...temperature !== null ? { temperature } : {}
2603
+ }
2604
+ } : {},
2605
+ // Anthropic-style passthrough — Converse forwards it to the model.
2606
+ // Zero (the vision transcription contract) sends nothing: thinking
2607
+ // is opt-in for the anthropic models bedrock hosts.
2608
+ ...thinkingBudget ? {
2609
+ additionalModelRequestFields: {
2610
+ thinking: { type: "enabled", budget_tokens: thinkingBudget }
2611
+ }
2612
+ } : {}
2211
2613
  })
2212
2614
  );
2213
2615
  const text = result.output?.message?.content?.[0]?.text ?? "";
@@ -2802,6 +3204,20 @@ RETURN JSON SCHEMA (EXACT):
2802
3204
  }`;
2803
3205
  }
2804
3206
  });
3207
+
3208
+ // src/tools/acl.ts
3209
+ function aclVisible(acl, principals) {
3210
+ if (principals === null || principals === TRUSTED) return true;
3211
+ if (acl == null) return true;
3212
+ if (!acl.length) return false;
3213
+ const held = new Set(principals ?? []);
3214
+ return acl.some((p) => held.has(p));
3215
+ }
3216
+ var init_acl = __esm({
3217
+ "src/tools/acl.ts"() {
3218
+ init_sentinels();
3219
+ }
3220
+ });
2805
3221
  function getFileExtension(filename) {
2806
3222
  if (!filename?.includes(".")) return "";
2807
3223
  return filename.slice(filename.lastIndexOf(".")).toLowerCase();
@@ -3618,8 +4034,26 @@ __export(pdf_exports, {
3618
4034
  embeddedTextPerPage: () => embeddedTextPerPage,
3619
4035
  extract: () => extract,
3620
4036
  extractPdf: () => extract,
4037
+ ocrLanguageHint: () => ocrLanguageHint,
3621
4038
  pageCount: () => pageCount
3622
4039
  });
4040
+ function ocrLanguageHint(texts, override) {
4041
+ if (override) return override;
4042
+ let sample = "";
4043
+ const indices = [...texts.keys()].sort((a, b) => a - b).slice(0, 3);
4044
+ for (const idx of indices) {
4045
+ sample += texts.get(idx) ?? "";
4046
+ if (sample.length >= OCR_LANG_SAMPLE_MIN) break;
4047
+ }
4048
+ if (sample.length >= OCR_LANG_SAMPLE_MIN) {
4049
+ try {
4050
+ const detected = detectLanguage(sample, 30);
4051
+ if (detected) return detected;
4052
+ } catch {
4053
+ }
4054
+ }
4055
+ return "auto";
4056
+ }
3623
4057
  function isCcControl(ch) {
3624
4058
  if (ch === "\n" || ch === "\r" || ch === " ") return false;
3625
4059
  const cp = ch.codePointAt(0);
@@ -3672,16 +4106,27 @@ async function extract(content, opts) {
3672
4106
  const { pages, structured: structuredMarkdown } = await classifyPages(content);
3673
4107
  const providerTokens = {};
3674
4108
  let visionMarkdown = false;
4109
+ let benignBlank = /* @__PURE__ */ new Set();
3675
4110
  if (pages.needsOcr.length) {
3676
4111
  if (opts.visionLlm) {
3677
4112
  try {
3678
4113
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
3679
- const [visionTexts, tokens] = await vision.extractPdfPages(content, pages.needsOcr, {
3680
- visionLlm: opts.visionLlm
4114
+ const [visionTexts, tokens, outcomes] = await vision.extractPdfPages(content, pages.needsOcr, {
4115
+ visionLlm: opts.visionLlm,
4116
+ extraction: opts.extraction
3681
4117
  });
3682
4118
  for (const [idx, text] of Object.entries(visionTexts)) {
3683
4119
  pages.texts.set(Number(idx), text);
3684
4120
  }
4121
+ benignBlank = new Set(
4122
+ Object.entries(outcomes).filter(([, o]) => o.status === "blank").map(([idx]) => Number(idx))
4123
+ );
4124
+ const failed = Object.entries(outcomes).filter(([, o]) => o.status === "failed");
4125
+ if (failed.length) {
4126
+ console.warn(
4127
+ `[pdf] vision failed on ${failed.length} of ${pages.needsOcr.length} pages: ` + failed.map(([idx, o]) => `page ${Number(idx) + 1}: ${o.error}`).join("; ")
4128
+ );
4129
+ }
3685
4130
  visionMarkdown = Object.keys(visionTexts).length > 0;
3686
4131
  for (const [key, val] of Object.entries(tokens)) {
3687
4132
  providerTokens[key] = (providerTokens[key] ?? 0) + val;
@@ -3693,9 +4138,17 @@ async function extract(content, opts) {
3693
4138
  try {
3694
4139
  const ocr = await Promise.resolve().then(() => (init_ocr(), ocr_exports));
3695
4140
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
3696
- const rendered = await vision.renderPdfPagesAsImages(content, pages.needsOcr);
4141
+ const rendered = await vision.renderPdfPagesAsImages(
4142
+ content,
4143
+ pages.needsOcr,
4144
+ 150,
4145
+ opts.extraction?.maxRenderPx ?? vision.MAX_RENDER_PX
4146
+ );
4147
+ const ocrConfig = {
4148
+ language: ocrLanguageHint(pages.texts, opts.extraction?.ocrLanguage)
4149
+ };
3697
4150
  for (const [idx, pngBytes] of rendered) {
3698
- const result = await ocr.extractImageTextOcr(pngBytes);
4151
+ const result = await ocr.extractImageTextOcr(pngBytes, ocrConfig);
3699
4152
  if (result.text.trim()) pages.texts.set(idx, result.text.trim());
3700
4153
  }
3701
4154
  } catch (exc) {
@@ -3713,31 +4166,38 @@ async function extract(content, opts) {
3713
4166
  if (t) textParts.push(`--- Page ${i + 1} ---
3714
4167
  ${t}`);
3715
4168
  }
4169
+ const unread = pages.needsOcr.filter((i) => !pages.texts.get(i) && !benignBlank.has(i));
3716
4170
  return new Extracted2({
3717
4171
  text: textParts.join("\n\n"),
3718
4172
  pages: pages.total,
3719
4173
  slides: null,
3720
4174
  mediaOnly: false,
3721
4175
  providerTokens,
3722
- isMarkdown: visionMarkdown || structuredMarkdown
4176
+ isMarkdown: visionMarkdown || structuredMarkdown,
4177
+ unreadableReason: unread.length ? opts.visionLlm ? "vision_failed" : "needs_vision" : null,
4178
+ unreadablePages: unread.length
3723
4179
  });
3724
4180
  }
3725
- var GARBAGE_SCAN_LIMIT, MIN_USABLE_TEXT_LEN;
4181
+ var GARBAGE_SCAN_LIMIT, MIN_USABLE_TEXT_LEN, OCR_LANG_SAMPLE_MIN;
3726
4182
  var init_pdf = __esm({
3727
4183
  "src/extraction/pdf.ts"() {
3728
4184
  init_errors();
3729
4185
  init_hooks();
4186
+ init_text();
3730
4187
  init_pdf_structure();
3731
4188
  GARBAGE_SCAN_LIMIT = 2e4;
3732
4189
  MIN_USABLE_TEXT_LEN = 50;
4190
+ OCR_LANG_SAMPLE_MIN = 100;
3733
4191
  }
3734
4192
  });
3735
4193
 
3736
4194
  // src/extraction/vision.ts
3737
4195
  var vision_exports = {};
3738
4196
  __export(vision_exports, {
4197
+ MAX_RENDER_PX: () => MAX_RENDER_PX,
3739
4198
  PAGES_PER_VISION_BATCH: () => PAGES_PER_VISION_BATCH,
3740
4199
  VISION_BATCH_ATTEMPTS: () => VISION_BATCH_ATTEMPTS,
4200
+ VISION_MAX_OUTPUT_TOKENS: () => VISION_MAX_OUTPUT_TOKENS,
3741
4201
  assignBatchPages: () => assignBatchPages,
3742
4202
  coercePagesResult: () => coercePagesResult,
3743
4203
  detectPdfVisionPages: () => detectPdfVisionPages,
@@ -3754,6 +4214,17 @@ function pagesPrompt(nImages) {
3754
4214
  Return JSON: {"pages": [{"page": 1, "text": "<markdown for this page>"}]}
3755
4215
  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.`;
3756
4216
  }
4217
+ function asText(value) {
4218
+ if (typeof value === "string") return value;
4219
+ if (value == null || typeof value === "boolean") return "";
4220
+ if (Array.isArray(value)) {
4221
+ return value.filter((v) => v != null && v !== "").map(asText).join("\n");
4222
+ }
4223
+ if (typeof value === "object") {
4224
+ return Object.values(value).filter((v) => v != null && v !== "").map(asText).join("\n");
4225
+ }
4226
+ return String(value);
4227
+ }
3757
4228
  function coercePagesResult(parsed) {
3758
4229
  let base;
3759
4230
  let rawPages;
@@ -3773,7 +4244,7 @@ function coercePagesResult(parsed) {
3773
4244
  const rec = entry;
3774
4245
  pages.push({
3775
4246
  page: typeof rec.page === "number" ? rec.page : i + 1,
3776
- text: typeof rec.text === "string" ? rec.text : ""
4247
+ text: asText(rec.text)
3777
4248
  });
3778
4249
  } else if (typeof entry === "string") {
3779
4250
  pages.push({ page: i + 1, text: entry });
@@ -3804,16 +4275,28 @@ function parsePagesResponse(rawText) {
3804
4275
  function sleep(ms) {
3805
4276
  return new Promise((r) => setTimeout(r, ms));
3806
4277
  }
3807
- async function extractTextFromImages(images, opts) {
3808
- if (!images.length) return [[], {}];
3809
- const maxConcurrent = opts.maxConcurrent ?? 8;
3810
- const batches = [];
3811
- for (let i = 0; i < images.length; i += PAGES_PER_VISION_BATCH) {
3812
- batches.push(images.slice(i, i + PAGES_PER_VISION_BATCH));
4278
+ async function capped(image, canvas, maxPx) {
4279
+ if (!canvas) return image;
4280
+ try {
4281
+ const img = await canvas.loadImage(image);
4282
+ const longEdge = Math.max(img.width, img.height);
4283
+ if (longEdge <= maxPx) return image;
4284
+ const ratio = maxPx / longEdge;
4285
+ const w = Math.max(Math.round(img.width * ratio), 1);
4286
+ const h = Math.max(Math.round(img.height * ratio), 1);
4287
+ const out = canvas.createCanvas(w, h);
4288
+ out.getContext("2d").drawImage(img, 0, 0, w, h);
4289
+ return out.toBuffer("image/png");
4290
+ } catch {
4291
+ return image;
3813
4292
  }
3814
- const results = Array.from({ length: images.length }, () => "");
4293
+ }
4294
+ async function runBatches(batchSource, opts) {
4295
+ const started = Date.now();
4296
+ const results = /* @__PURE__ */ new Map();
4297
+ const outcomes = /* @__PURE__ */ new Map();
3815
4298
  const tokensTotal = { input: 0, output: 0 };
3816
- const semaphore = new Semaphore(maxConcurrent);
4299
+ const maxConcurrent = opts.maxConcurrent ?? 8;
3817
4300
  let lock = Promise.resolve();
3818
4301
  const withLock = async (fn) => {
3819
4302
  const prev = lock;
@@ -3829,66 +4312,183 @@ async function extractTextFromImages(images, opts) {
3829
4312
  }
3830
4313
  };
3831
4314
  const client = buildLlmClient(opts.visionLlm);
3832
- const processBatch = async (batchIdx, batchImages) => {
3833
- await semaphore.acquire();
3834
- try {
3835
- const nImages = batchImages.length;
3836
- const prompt = pagesPrompt(nImages);
3837
- for (let attempt = 1; attempt <= VISION_BATCH_ATTEMPTS; attempt++) {
3838
- try {
3839
- const [rawText, usage] = await callLlm(opts.visionLlm, {
3840
- system: VISION_SYSTEM_PROMPT,
3841
- user: prompt,
3842
- jsonMode: true,
3843
- images: batchImages,
3844
- client
4315
+ let total = 0;
4316
+ let nBatches = 0;
4317
+ const queue = new BoundedQueue(maxConcurrent);
4318
+ const processBatch = async (batchNum, baseIdx, batchImages) => {
4319
+ const batchStarted = Date.now();
4320
+ let pendingLocal = batchImages.map((_, i) => i);
4321
+ for (let attempt = 1; attempt <= VISION_BATCH_ATTEMPTS; attempt++) {
4322
+ const ask = pendingLocal.map((i) => batchImages[i]);
4323
+ try {
4324
+ const [rawText, usage] = await callLlm(opts.visionLlm, {
4325
+ system: VISION_SYSTEM_PROMPT,
4326
+ user: pagesPrompt(ask.length),
4327
+ jsonMode: true,
4328
+ images: ask,
4329
+ maxTokens: opts.extCfg.visionMaxOutputTokens,
4330
+ // Transcription is not reasoning, and on a thinking model the two
4331
+ // compete for the SAME budget — see `LlmCallOpts.thinkingBudget`.
4332
+ // Zero, not small: there is nothing here to reason ABOUT, the model
4333
+ // is being asked what is on the page. `temperature: 0` for the same
4334
+ // reason — one scan must transcribe to the same text twice.
4335
+ thinkingBudget: 0,
4336
+ temperature: 0,
4337
+ client
4338
+ });
4339
+ if (!rawText?.trim()) throw new Error("empty vision response");
4340
+ const [, pages] = parsePagesResponse(rawText);
4341
+ const assigned = assignBatchPages(pages, ask.length);
4342
+ const batchSeconds = (Date.now() - batchStarted) / 1e3;
4343
+ const stillMissing = [];
4344
+ await withLock(() => {
4345
+ tokensTotal.input += usage.input ?? 0;
4346
+ tokensTotal.output += usage.output ?? 0;
4347
+ pendingLocal.forEach((localIdx, position) => {
4348
+ const pageText = assigned[position];
4349
+ if (pageText === void 0) {
4350
+ stillMissing.push(localIdx);
4351
+ return;
4352
+ }
4353
+ results.set(baseIdx + localIdx, pageText);
4354
+ outcomes.set(baseIdx + localIdx, {
4355
+ status: pageText.trim() ? "read" : "blank",
4356
+ attempts: attempt,
4357
+ error: null,
4358
+ seconds: batchSeconds
4359
+ });
3845
4360
  });
3846
- if (!rawText?.trim()) throw new Error("empty vision response");
3847
- const [, pages] = parsePagesResponse(rawText);
3848
- const assigned = assignBatchPages(pages, nImages);
4361
+ });
4362
+ pendingLocal = stillMissing;
4363
+ if (pendingLocal.length === 0) return;
4364
+ if (attempt < VISION_BATCH_ATTEMPTS) {
4365
+ console.warn(
4366
+ `[vision] batch ${batchNum} attempt ${attempt} answered ${ask.length - pendingLocal.length} of ${ask.length} pages \u2014 asking again for ${pendingLocal.length}`
4367
+ );
4368
+ continue;
4369
+ }
4370
+ console.error(
4371
+ `[vision] batch ${batchNum}: ${pendingLocal.length} page(s) never came back after ${attempt} attempts`
4372
+ );
4373
+ await withLock(() => {
4374
+ for (const localIdx of pendingLocal) {
4375
+ outcomes.set(baseIdx + localIdx, {
4376
+ status: "failed",
4377
+ attempts: attempt,
4378
+ error: "page missing from batch response",
4379
+ seconds: batchSeconds
4380
+ });
4381
+ }
4382
+ });
4383
+ return;
4384
+ } catch (exc) {
4385
+ if (attempt < VISION_BATCH_ATTEMPTS) {
4386
+ console.warn(`[vision] batch ${batchNum} attempt ${attempt} failed: ${exc} \u2014 retrying`);
4387
+ await sleep(2e3 * attempt);
4388
+ } else {
4389
+ console.error(`[vision] batch ${batchNum} failed after ${VISION_BATCH_ATTEMPTS} attempts: ${exc}`);
4390
+ const batchSeconds = (Date.now() - batchStarted) / 1e3;
3849
4391
  await withLock(() => {
3850
- for (const [localIdx, pageText] of Object.entries(assigned)) {
3851
- const globalIdx = batchIdx * PAGES_PER_VISION_BATCH + Number(localIdx);
3852
- if (globalIdx >= 0 && globalIdx < results.length) results[globalIdx] = pageText;
4392
+ for (const localIdx of pendingLocal) {
4393
+ outcomes.set(baseIdx + localIdx, {
4394
+ status: "failed",
4395
+ attempts: attempt,
4396
+ error: String(exc),
4397
+ seconds: batchSeconds
4398
+ });
3853
4399
  }
3854
- tokensTotal.input += usage.input ?? 0;
3855
- tokensTotal.output += usage.output ?? 0;
3856
4400
  });
3857
- return;
3858
- } catch (exc) {
3859
- if (attempt < VISION_BATCH_ATTEMPTS) {
3860
- console.warn(`[vision] batch ${batchIdx} attempt ${attempt} failed: ${exc} \u2014 retrying`);
3861
- await sleep(2e3 * attempt);
3862
- } else {
3863
- console.error(
3864
- `[vision] batch ${batchIdx} failed after ${VISION_BATCH_ATTEMPTS} attempts: ${exc}`
3865
- );
3866
- }
3867
4401
  }
3868
4402
  }
3869
- } finally {
3870
- semaphore.release();
3871
4403
  }
3872
4404
  };
4405
+ const worker = async () => {
4406
+ for (; ; ) {
4407
+ const item = await queue.get();
4408
+ if (item === null) return;
4409
+ await processBatch(...item);
4410
+ }
4411
+ };
4412
+ const workers = Array.from({ length: maxConcurrent }, () => worker());
3873
4413
  try {
3874
- await Promise.all(batches.map((b, i) => processBatch(i, b)));
4414
+ for await (const [baseIdx, batchImages] of batchSource) {
4415
+ if (!batchImages.length) continue;
4416
+ for (let j = 0; j < batchImages.length; j++) {
4417
+ outcomes.set(baseIdx + j, { status: "failed", attempts: 0, error: "never attempted", seconds: 0 });
4418
+ }
4419
+ total = Math.max(total, baseIdx + batchImages.length);
4420
+ await queue.put([nBatches, baseIdx, batchImages]);
4421
+ nBatches += 1;
4422
+ }
4423
+ queue.close();
4424
+ await Promise.all(workers);
4425
+ } catch (exc) {
4426
+ queue.abort();
4427
+ await Promise.allSettled(workers);
4428
+ throw exc;
3875
4429
  } finally {
3876
4430
  await client.aclose();
3877
4431
  }
3878
- return [results, tokensTotal];
4432
+ const texts = Array.from({ length: total }, (_, i) => results.get(i) ?? "");
4433
+ const outcomeList = Array.from(
4434
+ { length: total },
4435
+ (_, i) => outcomes.get(i) ?? { status: "failed", attempts: 0, error: "never attempted", seconds: 0 }
4436
+ );
4437
+ const counts = { read: 0, blank: 0, failed: 0 };
4438
+ for (const o of outcomeList) counts[o.status] += 1;
4439
+ console.info(
4440
+ `[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`
4441
+ );
4442
+ return [texts, tokensTotal, outcomeList];
4443
+ }
4444
+ async function extractTextFromImages(images, opts) {
4445
+ if (!images.length) return [[], {}, []];
4446
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
4447
+ const canvasModule = await tryImport("@napi-rs/canvas");
4448
+ const sendable = await Promise.all(images.map((img) => capped(img, canvasModule, extCfg.maxRenderPx)));
4449
+ const batchSize = extCfg.pagesPerVisionBatch ?? PAGES_PER_VISION_BATCH;
4450
+ async function* batches() {
4451
+ for (let i = 0; i < sendable.length; i += batchSize) {
4452
+ yield [i, sendable.slice(i, i + batchSize)];
4453
+ }
4454
+ }
4455
+ return runBatches(batches(), {
4456
+ visionLlm: opts.visionLlm,
4457
+ extCfg,
4458
+ maxConcurrent: opts.maxConcurrent
4459
+ });
3879
4460
  }
3880
4461
  async function extractPdfPages(content, pageIndices, opts) {
3881
- const rendered = await renderPdfPagesAsImages(content, pageIndices);
3882
- const images = rendered.map(([, png]) => png);
3883
- const [texts, tokens] = await extractTextFromImages(images, { visionLlm: opts.visionLlm });
4462
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
4463
+ const batchSize = extCfg.pagesPerVisionBatch ?? PAGES_PER_VISION_BATCH;
4464
+ const pageOrder = [];
4465
+ async function* renderedBatches() {
4466
+ let pos = 0;
4467
+ for (let start = 0; start < pageIndices.length; start += batchSize) {
4468
+ const group = pageIndices.slice(start, start + batchSize);
4469
+ const rendered = await renderPdfPagesAsImages(content, group, 150, extCfg.maxRenderPx);
4470
+ if (!rendered.length) continue;
4471
+ pageOrder.push(...rendered.map(([idx]) => idx));
4472
+ yield [pos, rendered.map(([, png]) => png)];
4473
+ pos += rendered.length;
4474
+ }
4475
+ }
4476
+ const [texts, tokens, outcomes] = await runBatches(renderedBatches(), {
4477
+ visionLlm: opts.visionLlm,
4478
+ extCfg
4479
+ });
3884
4480
  const pageTexts = {};
3885
- for (let i = 0; i < rendered.length; i++) {
4481
+ const pageOutcomes = {};
4482
+ for (let i = 0; i < pageOrder.length; i++) {
3886
4483
  const text = texts[i];
3887
- if (text?.trim()) pageTexts[rendered[i][0]] = text;
4484
+ if (text?.trim()) pageTexts[pageOrder[i]] = text;
4485
+ const outcome = outcomes[i];
4486
+ if (outcome) pageOutcomes[pageOrder[i]] = outcome;
3888
4487
  }
3889
- return [pageTexts, tokens];
4488
+ return [pageTexts, tokens, pageOutcomes];
3890
4489
  }
3891
- async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
4490
+ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150, maxPx = MAX_RENDER_PX) {
4491
+ const started = Date.now();
3892
4492
  const scale = dpi / 72;
3893
4493
  const data = new Uint8Array(content);
3894
4494
  const pdf = await getDocumentProxy(data);
@@ -3897,9 +4497,12 @@ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
3897
4497
  for (const idx of pageIndices) {
3898
4498
  if (idx < 0 || idx >= pdf.numPages) continue;
3899
4499
  try {
4500
+ const page = await pdf.getPage(idx + 1);
4501
+ const viewport = page.getViewport({ scale: 1 });
4502
+ const pageScale = Math.min(scale, maxPx / Math.max(viewport.width, viewport.height, 1));
3900
4503
  const buf = await renderPageAsImage(pdf, idx + 1, {
3901
4504
  canvasImport: canvasImport ? async () => canvasImport : void 0,
3902
- scale
4505
+ scale: pageScale
3903
4506
  });
3904
4507
  const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
3905
4508
  results.push([idx, Buffer.from(bytes)]);
@@ -3907,6 +4510,7 @@ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
3907
4510
  console.warn(`[vision] failed to render PDF page ${idx}:`, exc);
3908
4511
  }
3909
4512
  }
4513
+ console.info(`[pdf] rastered ${results.length} pages in ${((Date.now() - started) / 1e3).toFixed(1)}s`);
3910
4514
  return results;
3911
4515
  }
3912
4516
  async function detectPdfVisionPages(content) {
@@ -3928,7 +4532,13 @@ async function detectPdfVisionPages(content) {
3928
4532
  return visionPages;
3929
4533
  }
3930
4534
  async function extractDocxImages(content) {
3931
- const zip = await JSZip.loadAsync(content);
4535
+ let zip;
4536
+ try {
4537
+ zip = await JSZip.loadAsync(content);
4538
+ } catch (exc) {
4539
+ console.warn(`[vision] cannot open DOCX for images: ${exc}`);
4540
+ return [];
4541
+ }
3932
4542
  const rels = zip.file("word/_rels/document.xml.rels");
3933
4543
  if (!rels) return [];
3934
4544
  const xml = await rels.async("string");
@@ -3954,7 +4564,13 @@ async function extractDocxImages(content) {
3954
4564
  return images;
3955
4565
  }
3956
4566
  async function extractPptxImages(content) {
3957
- const zip = await JSZip.loadAsync(content);
4567
+ let zip;
4568
+ try {
4569
+ zip = await JSZip.loadAsync(content);
4570
+ } catch (exc) {
4571
+ console.warn(`[vision] cannot open PPTX for images: ${exc}`);
4572
+ return [];
4573
+ }
3958
4574
  const slideNames = Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n)).sort((a, b) => {
3959
4575
  const na = Number(/slide(\d+)/.exec(a)?.[1] ?? 0);
3960
4576
  const nb = Number(/slide(\d+)/.exec(b)?.[1] ?? 0);
@@ -3987,12 +4603,22 @@ async function extractPptxImages(content) {
3987
4603
  }
3988
4604
  async function extractImage(content, opts) {
3989
4605
  const { Extracted: Extracted2 } = await Promise.resolve().then(() => (init_extraction(), extraction_exports));
4606
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
4607
+ const canvasModule = await tryImport("@napi-rs/canvas");
4608
+ const image = await capped(content, canvasModule, extCfg.maxRenderPx);
3990
4609
  const [rawText, usage] = await callLlm(opts.visionLlm, {
3991
4610
  system: VISION_SYSTEM_PROMPT,
3992
4611
  user: `${MARKDOWN_RULES}
3993
4612
 
3994
4613
  Transcribe this single image. Return the Markdown only.`,
3995
- images: [content]
4614
+ images: [image],
4615
+ maxTokens: extCfg.visionMaxOutputTokens,
4616
+ // Same contract as the batched path (see processBatch): with the output
4617
+ // cap in place, default-on thinking bills against it — an all-thinking
4618
+ // truncated-empty reply lands as vision_failed. Transcription is not
4619
+ // reasoning, and it must be deterministic.
4620
+ thinkingBudget: 0,
4621
+ temperature: 0
3996
4622
  });
3997
4623
  const text = (rawText || "").trim();
3998
4624
  return new Extracted2({
@@ -4001,37 +4627,75 @@ Transcribe this single image. Return the Markdown only.`,
4001
4627
  slides: null,
4002
4628
  mediaOnly: !text,
4003
4629
  providerTokens: usage ?? {},
4004
- isMarkdown: Boolean(text)
4630
+ isMarkdown: Boolean(text),
4631
+ // A configured reader that returns nothing (refusal, filter, truncation)
4632
+ // is the same "reader read nothing" state the PDF path labels
4633
+ // vision_failed — not a blank image with no explanation.
4634
+ unreadableReason: text ? null : "vision_failed",
4635
+ unreadablePages: text ? 0 : 1
4005
4636
  });
4006
4637
  }
4007
- var PAGES_PER_VISION_BATCH, VISION_BATCH_ATTEMPTS, VISION_SYSTEM_PROMPT, MARKDOWN_RULES, Semaphore;
4638
+ var VISION_BATCH_ATTEMPTS, EXTRACTION_DEFAULTS, MAX_RENDER_PX, VISION_MAX_OUTPUT_TOKENS, PAGES_PER_VISION_BATCH, VISION_SYSTEM_PROMPT, MARKDOWN_RULES, BoundedQueue;
4008
4639
  var init_vision = __esm({
4009
4640
  "src/extraction/vision.ts"() {
4641
+ init_config();
4010
4642
  init_extras();
4011
4643
  init_llm();
4012
4644
  init_json();
4013
4645
  init_pdf();
4014
- PAGES_PER_VISION_BATCH = 5;
4015
4646
  VISION_BATCH_ATTEMPTS = 3;
4647
+ EXTRACTION_DEFAULTS = extractionSchema.parse({});
4648
+ MAX_RENDER_PX = EXTRACTION_DEFAULTS.maxRenderPx;
4649
+ VISION_MAX_OUTPUT_TOKENS = EXTRACTION_DEFAULTS.visionMaxOutputTokens;
4650
+ PAGES_PER_VISION_BATCH = EXTRACTION_DEFAULTS.pagesPerVisionBatch;
4016
4651
  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.";
4017
4652
  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.";
4018
- Semaphore = class {
4019
- n;
4020
- waiters = [];
4021
- constructor(max) {
4022
- this.n = max;
4023
- }
4024
- async acquire() {
4025
- if (this.n > 0) {
4026
- this.n -= 1;
4027
- return;
4653
+ BoundedQueue = class {
4654
+ constructor(maxsize) {
4655
+ this.maxsize = maxsize;
4656
+ }
4657
+ maxsize;
4658
+ items = [];
4659
+ putters = [];
4660
+ getters = [];
4661
+ closed = false;
4662
+ async put(item) {
4663
+ while (!this.closed && this.items.length >= this.maxsize) {
4664
+ await new Promise((resolve) => this.putters.push(resolve));
4665
+ }
4666
+ if (this.closed) return;
4667
+ this.items.push(item);
4668
+ this.getters.shift()?.();
4669
+ }
4670
+ /** `null` once the queue is closed AND drained — the worker's exit signal. */
4671
+ async get() {
4672
+ for (; ; ) {
4673
+ if (this.items.length) {
4674
+ const item = this.items.shift();
4675
+ this.putters.shift()?.();
4676
+ return item;
4677
+ }
4678
+ if (this.closed) return null;
4679
+ await new Promise((resolve) => this.getters.push(resolve));
4028
4680
  }
4029
- await new Promise((resolve) => this.waiters.push(resolve));
4030
4681
  }
4031
- release() {
4032
- const next = this.waiters.shift();
4033
- if (next) next();
4034
- else this.n += 1;
4682
+ close() {
4683
+ this.closed = true;
4684
+ for (const wake of this.getters.splice(0)) wake();
4685
+ for (const wake of this.putters.splice(0)) wake();
4686
+ }
4687
+ /**
4688
+ * Like `close()`, but for the error path: DISCARDS whatever is still
4689
+ * queued rather than letting it drain. A batch already in a worker's hands
4690
+ * keeps running (there is no cancelling that), but one that only ever sat
4691
+ * in the queue must never start — the source has already failed, so
4692
+ * spending a full vision-LLM retry budget on it buys nothing.
4693
+ */
4694
+ abort() {
4695
+ this.closed = true;
4696
+ this.items.length = 0;
4697
+ for (const wake of this.getters.splice(0)) wake();
4698
+ for (const wake of this.putters.splice(0)) wake();
4035
4699
  }
4036
4700
  };
4037
4701
  }
@@ -4041,7 +4705,8 @@ var init_vision = __esm({
4041
4705
  var extraction_exports = {};
4042
4706
  __export(extraction_exports, {
4043
4707
  Extracted: () => Extracted,
4044
- extract: () => extract2
4708
+ extract: () => extract2,
4709
+ extractEmbeddedImagesText: () => extractEmbeddedImagesText
4045
4710
  });
4046
4711
  function isPdf(ext, mime) {
4047
4712
  return ext === ".pdf" || mime === "application/pdf";
@@ -4049,14 +4714,50 @@ function isPdf(ext, mime) {
4049
4714
  function isImage(ext, mime) {
4050
4715
  return mime.startsWith("image/") || IMAGE_EXTS.has(ext);
4051
4716
  }
4052
- async function extractEmbeddedImagesText(images, visionLlm, hooks) {
4717
+ async function officeUnreadable(text, images, visionLlm) {
4718
+ if (text.trim()) return { reason: null, unread: 0 };
4719
+ let readable = 0;
4720
+ for (const img of images) {
4721
+ if (!await embeddedImageIsNegligible(img.imageBytes)) readable += 1;
4722
+ }
4723
+ if (!readable) return { reason: null, unread: 0 };
4724
+ return { reason: visionLlm ? "vision_failed" : "needs_vision", unread: readable };
4725
+ }
4726
+ async function embeddedImageIsNegligible(data) {
4727
+ if (data.length < MIN_EMBEDDED_IMAGE_BYTES) return true;
4728
+ const canvas = await tryImport("@napi-rs/canvas");
4729
+ if (!canvas) return false;
4730
+ try {
4731
+ const img = await canvas.loadImage(data);
4732
+ return Math.max(img.width, img.height) < MIN_EMBEDDED_IMAGE_PX;
4733
+ } catch {
4734
+ return false;
4735
+ }
4736
+ }
4737
+ async function extractEmbeddedImagesText(images, visionLlm, hooks, extraction) {
4053
4738
  if (!images.length) return [[], {}];
4054
4739
  try {
4055
4740
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
4056
- return await vision.extractTextFromImages(
4057
- images.map((img) => img.imageBytes),
4058
- { visionLlm }
4059
- );
4741
+ const { createHash: createHash3 } = await import('crypto');
4742
+ const keys = [];
4743
+ const firstAt = /* @__PURE__ */ new Map();
4744
+ const send = [];
4745
+ for (const img of images) {
4746
+ const blob = img.imageBytes;
4747
+ if (await embeddedImageIsNegligible(blob)) {
4748
+ keys.push(null);
4749
+ continue;
4750
+ }
4751
+ const digest = createHash3("sha256").update(blob).digest("hex");
4752
+ if (!firstAt.has(digest)) {
4753
+ firstAt.set(digest, send.length);
4754
+ send.push(blob);
4755
+ }
4756
+ keys.push(digest);
4757
+ }
4758
+ if (!send.length) return [images.map(() => ""), {}];
4759
+ const [texts, tokens] = await vision.extractTextFromImages(send, { visionLlm, extraction });
4760
+ return [keys.map((k) => k == null ? "" : texts[firstAt.get(k)] ?? ""), tokens];
4060
4761
  } catch (exc) {
4061
4762
  emitError(hooks, exc, { stage: "embedded_image_vision" });
4062
4763
  return [[], {}];
@@ -4067,22 +4768,35 @@ async function extract2(content, filename, mime, opts) {
4067
4768
  const ext = getFileExtension(filename);
4068
4769
  const visionLlm = opts.visionLlm ?? null;
4069
4770
  const hooks = opts.hooks;
4771
+ const extraction = opts.extraction ?? null;
4070
4772
  try {
4071
4773
  if (isNonIngestibleMedia(filename, m)) {
4072
4774
  return new Extracted({ text: "", mediaOnly: true });
4073
4775
  }
4074
4776
  if (isPdf(ext, m)) {
4075
4777
  const pdf = await Promise.resolve().then(() => (init_pdf(), pdf_exports));
4076
- return await pdf.extract(content, { visionLlm, hooks });
4778
+ return await pdf.extract(content, { visionLlm, hooks, extraction });
4077
4779
  }
4078
4780
  if (isImage(ext, m)) {
4079
- if (!visionLlm) return new Extracted({ text: "", mediaOnly: true });
4781
+ if (!visionLlm) {
4782
+ return new Extracted({
4783
+ text: "",
4784
+ mediaOnly: true,
4785
+ unreadableReason: "needs_vision",
4786
+ unreadablePages: 1
4787
+ });
4788
+ }
4080
4789
  try {
4081
4790
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
4082
- return await vision.extractImage(content, { visionLlm });
4791
+ return await vision.extractImage(content, { visionLlm, extraction });
4083
4792
  } catch (exc) {
4084
4793
  emitError(hooks, exc, { stage: "image_vision", filename });
4085
- return new Extracted({ text: "", mediaOnly: true });
4794
+ return new Extracted({
4795
+ text: "",
4796
+ mediaOnly: true,
4797
+ unreadableReason: "vision_failed",
4798
+ unreadablePages: 1
4799
+ });
4086
4800
  }
4087
4801
  }
4088
4802
  if (ext === ".docx" || m === DOCX_MIME) {
@@ -4090,10 +4804,11 @@ async function extract2(content, filename, mime, opts) {
4090
4804
  let providerTokens = {};
4091
4805
  let llmPictures = 0;
4092
4806
  let combined = text;
4807
+ let images = [];
4093
4808
  if (visionLlm) {
4094
4809
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
4095
- const images = await vision.extractDocxImages(content);
4096
- const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks);
4810
+ images = await vision.extractDocxImages(content);
4811
+ const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks, extraction);
4097
4812
  for (let i = 0; i < texts.length; i++) {
4098
4813
  const imgText = texts[i];
4099
4814
  if (imgText) {
@@ -4104,12 +4819,18 @@ ${imgText}`;
4104
4819
  }
4105
4820
  }
4106
4821
  providerTokens = tokens;
4822
+ } else if (!text.trim()) {
4823
+ const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
4824
+ images = await vision.extractDocxImages(content);
4107
4825
  }
4826
+ const { reason, unread } = await officeUnreadable(combined, images, visionLlm);
4108
4827
  return new Extracted({
4109
4828
  text: combined,
4110
4829
  pages: await countDocxPages(content),
4111
4830
  providerTokens,
4112
- llmPictures
4831
+ llmPictures,
4832
+ unreadableReason: reason,
4833
+ unreadablePages: unread
4113
4834
  });
4114
4835
  }
4115
4836
  if (ext === ".pptx" || m === PPTX_MIME) {
@@ -4117,10 +4838,11 @@ ${imgText}`;
4117
4838
  let providerTokens = {};
4118
4839
  let llmPictures = 0;
4119
4840
  let combined = text;
4841
+ let images = [];
4120
4842
  if (visionLlm) {
4121
4843
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
4122
- const images = await vision.extractPptxImages(content);
4123
- const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks);
4844
+ images = await vision.extractPptxImages(content);
4845
+ const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks, extraction);
4124
4846
  for (let i = 0; i < texts.length; i++) {
4125
4847
  const imgText = texts[i];
4126
4848
  if (imgText) {
@@ -4132,12 +4854,18 @@ ${imgText}`;
4132
4854
  }
4133
4855
  }
4134
4856
  providerTokens = tokens;
4857
+ } else if (!text.trim()) {
4858
+ const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
4859
+ images = await vision.extractPptxImages(content);
4135
4860
  }
4861
+ const { reason, unread } = await officeUnreadable(combined, images, visionLlm);
4136
4862
  return new Extracted({
4137
4863
  text: combined,
4138
4864
  slides: await countPptxSlides(content),
4139
4865
  providerTokens,
4140
- llmPictures
4866
+ llmPictures,
4867
+ unreadableReason: reason,
4868
+ unreadablePages: unread
4141
4869
  });
4142
4870
  }
4143
4871
  if (ext === ".xlsx" || m === XLSX_MIME) {
@@ -4161,9 +4889,10 @@ ${imgText}`;
4161
4889
  return new Extracted({ text: content.toString("utf8") });
4162
4890
  }
4163
4891
  }
4164
- var IMAGE_EXTS, Extracted;
4892
+ var IMAGE_EXTS, Extracted, MIN_EMBEDDED_IMAGE_BYTES, MIN_EMBEDDED_IMAGE_PX;
4165
4893
  var init_extraction = __esm({
4166
4894
  "src/extraction/index.ts"() {
4895
+ init_extras();
4167
4896
  init_hooks();
4168
4897
  init_files();
4169
4898
  IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp"]);
@@ -4175,6 +4904,10 @@ var init_extraction = __esm({
4175
4904
  providerTokens;
4176
4905
  llmPictures;
4177
4906
  isMarkdown;
4907
+ /** See `UnreadableReason`. `mediaOnly` is the same idea for whole files and
4908
+ * stays set alongside this, so existing callers keep working. */
4909
+ unreadableReason;
4910
+ unreadablePages;
4178
4911
  constructor(init) {
4179
4912
  this.text = init.text;
4180
4913
  this.pages = init.pages ?? null;
@@ -4183,8 +4916,12 @@ var init_extraction = __esm({
4183
4916
  this.providerTokens = init.providerTokens ?? {};
4184
4917
  this.llmPictures = init.llmPictures ?? 0;
4185
4918
  this.isMarkdown = init.isMarkdown ?? false;
4919
+ this.unreadableReason = init.unreadableReason ?? null;
4920
+ this.unreadablePages = init.unreadablePages ?? 0;
4186
4921
  }
4187
4922
  };
4923
+ MIN_EMBEDDED_IMAGE_BYTES = 3 * 1024;
4924
+ MIN_EMBEDDED_IMAGE_PX = 100;
4188
4925
  }
4189
4926
  });
4190
4927
  function checkProvider(cfg2) {
@@ -4362,7 +5099,9 @@ async function prepare(opts) {
4362
5099
  mediaOnly: false,
4363
5100
  providerTokens: {},
4364
5101
  contentHash: calculateContentHash({ fullText: clean }),
4365
- isMarkdown: false
5102
+ isMarkdown: false,
5103
+ unreadableReason: null,
5104
+ unreadablePages: 0
4366
5105
  };
4367
5106
  }
4368
5107
  const content = opts.content;
@@ -4370,11 +5109,12 @@ async function prepare(opts) {
4370
5109
  const mime = mimeForFilename(fname) || "";
4371
5110
  const extracted = await extract2(content, fname, mime || null, {
4372
5111
  visionLlm: opts.config.visionLlm,
5112
+ extraction: opts.config.extraction,
4373
5113
  hooks: opts.hooks
4374
5114
  });
4375
5115
  let body = sanitizeText(extracted.text || "") || "";
4376
5116
  const effectiveMime = mime || inferMime(body, fname) || DEFAULT_MIME;
4377
- if (effectiveMime === "application/pdf" && body) body = dedupLines(body);
5117
+ if (effectiveMime === "application/pdf" && body && !extracted.isMarkdown) body = dedupLines(body);
4378
5118
  return {
4379
5119
  text: body,
4380
5120
  mime: effectiveMime,
@@ -4385,7 +5125,9 @@ async function prepare(opts) {
4385
5125
  mediaOnly: Boolean(extracted.mediaOnly),
4386
5126
  providerTokens: { ...extracted.providerTokens ?? {} },
4387
5127
  contentHash: calculateContentHash({ docBytes: content }),
4388
- isMarkdown: Boolean(extracted.isMarkdown)
5128
+ isMarkdown: Boolean(extracted.isMarkdown),
5129
+ unreadableReason: extracted.unreadableReason ?? null,
5130
+ unreadablePages: extracted.unreadablePages ?? 0
4389
5131
  };
4390
5132
  }
4391
5133
  function computeUnits(prepared) {
@@ -4563,7 +5305,11 @@ async function decide(pool, opts) {
4563
5305
  }
4564
5306
  const same = stored != null && incoming != null && (Buffer.isBuffer(stored) && Buffer.isBuffer(incoming) ? Buffer.compare(stored, incoming) === 0 : stored === incoming);
4565
5307
  if (!same) return { action: "process", documentId: existingId };
4566
- if (existing.status === "skipped") return { action: "skip", documentId: existingId };
5308
+ if (existing.status === "skipped") {
5309
+ const metaData = existing.meta_data;
5310
+ if (metaData?.unreadable_reason) return { action: "process", documentId: existingId };
5311
+ return { action: "skip", documentId: existingId };
5312
+ }
4567
5313
  const reqMode = opts.request.mode ?? "hybrid";
4568
5314
  if ((MODE_RANK[reqMode] ?? 0) <= (MODE_RANK[existing.mode || "hybrid"] ?? 0)) {
4569
5315
  return { action: "skip", documentId: existingId };
@@ -4854,6 +5600,22 @@ function singleReport(doc) {
4854
5600
  async function maybeAwait(value) {
4855
5601
  return value;
4856
5602
  }
5603
+ function progress(hooks, request, displayName, documentId, stage, state, detail = {}) {
5604
+ emitProgress(hooks, {
5605
+ sourceId: request.sourceId ?? null,
5606
+ externalId: request.externalId ?? null,
5607
+ documentId: documentId == null ? null : String(documentId),
5608
+ name: displayName,
5609
+ stage,
5610
+ state,
5611
+ detail: { ...detail }
5612
+ });
5613
+ }
5614
+ function extractMethod(prepared, text) {
5615
+ if (text != null) return "text";
5616
+ if (Object.keys(prepared.providerTokens).length || prepared.isMarkdown) return "vision";
5617
+ return "parser";
5618
+ }
4857
5619
  async function runModeUpgrade(request, opts) {
4858
5620
  await refreshOnSkip(request, {
4859
5621
  pool: opts.pool,
@@ -4867,6 +5629,7 @@ async function runModeUpgrade(request, opts) {
4867
5629
  let chunkCount = 0;
4868
5630
  try {
4869
5631
  if (request.mode === "graph" && opts.graphStage) {
5632
+ progress(opts.hooks, request, opts.displayName, opts.documentId, "graph", "started");
4870
5633
  graphUnits2 = Number(
4871
5634
  await maybeAwait(
4872
5635
  opts.graphStage({
@@ -4877,6 +5640,9 @@ async function runModeUpgrade(request, opts) {
4877
5640
  })
4878
5641
  ) || 0
4879
5642
  );
5643
+ progress(opts.hooks, request, opts.displayName, opts.documentId, "graph", "done", {
5644
+ graph_units: graphUnits2
5645
+ });
4880
5646
  }
4881
5647
  chunkCount = await upgradeMode(opts.pool, opts.documentId, request.mode ?? "hybrid");
4882
5648
  } catch (exc) {
@@ -4966,6 +5732,7 @@ async function runIngest(request, opts) {
4966
5732
  );
4967
5733
  }
4968
5734
  }
5735
+ progress(opts.hooks, request, displayName, null, "extract", "started");
4969
5736
  const prepared = await prepare({
4970
5737
  content,
4971
5738
  filename,
@@ -4974,6 +5741,11 @@ async function runIngest(request, opts) {
4974
5741
  config: opts.config,
4975
5742
  hooks: opts.hooks
4976
5743
  });
5744
+ progress(opts.hooks, request, displayName, null, "extract", "done", {
5745
+ pages: prepared.pages,
5746
+ method: extractMethod(prepared, text),
5747
+ mime: prepared.mime
5748
+ });
4977
5749
  const ingestHash = sha256Bytes(prepared.text || "");
4978
5750
  const decision = await decide(opts.pool, {
4979
5751
  request: { ...request, mode },
@@ -5060,8 +5832,11 @@ async function runIngest(request, opts) {
5060
5832
  const metaUpdates = { ...request.metaData ?? {} };
5061
5833
  metaUpdates.mime_type = prepared.mime;
5062
5834
  if (prepared.contentHash) metaUpdates.content_hash = prepared.contentHash;
5835
+ metaUpdates.unreadable_reason = prepared.unreadableReason;
5836
+ metaUpdates.unreadable_pages = prepared.unreadablePages;
5063
5837
  const redactionFailed = [];
5064
5838
  let documentText;
5839
+ progress(opts.hooks, request, displayName, documentId, "redact", "started");
5065
5840
  try {
5066
5841
  documentText = redactDocumentText(
5067
5842
  prepared.text,
@@ -5074,6 +5849,9 @@ async function runIngest(request, opts) {
5074
5849
  await finalizeDocument(opts.pool, documentId, { status: "failed", error: String(exc) });
5075
5850
  throw exc;
5076
5851
  }
5852
+ progress(opts.hooks, request, displayName, documentId, "redact", "done", {
5853
+ rules_failed: redactionFailed.length
5854
+ });
5077
5855
  if (!prepared.text.trim() || prepared.mediaOnly || looksMostlyBoilerplate(prepared.text)) {
5078
5856
  const reason = prepared.mediaOnly ? "media-only (no extractable text)" : "no extractable text";
5079
5857
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, []);
@@ -5099,7 +5877,13 @@ async function runIngest(request, opts) {
5099
5877
  graphUnits: 0,
5100
5878
  providerTokens: {},
5101
5879
  error: reason,
5102
- redactionFailed
5880
+ redactionFailed,
5881
+ // Extraction RAN here — an unreadable scan is exactly what lands in
5882
+ // this branch, so this is the report that has to say why. The
5883
+ // dedup-skip/upgrade sites stay at the defaults: they never extracted
5884
+ // and would be claiming blind.
5885
+ unreadableReason: prepared.unreadableReason,
5886
+ unreadablePages: prepared.unreadablePages
5103
5887
  });
5104
5888
  }
5105
5889
  const units = computeUnits(prepared);
@@ -5109,18 +5893,26 @@ async function runIngest(request, opts) {
5109
5893
  let rows = [];
5110
5894
  let effectiveMime = prepared.mime;
5111
5895
  try {
5896
+ progress(opts.hooks, request, displayName, documentId, "chunk", "started");
5112
5897
  [rows, effectiveMime] = buildChunks(prepared, request.name, {
5113
5898
  policy: opts.config.redaction,
5114
5899
  secretKey: opts.config.secretKey,
5115
5900
  hooks: opts.hooks,
5116
5901
  failed: redactionFailed
5117
5902
  });
5903
+ progress(opts.hooks, request, displayName, documentId, "chunk", "done", { chunks: rows.length });
5904
+ progress(opts.hooks, request, displayName, documentId, "embed", "started");
5118
5905
  if (request.batch) {
5119
5906
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, rows);
5120
5907
  const batchId = await submitEmbeddingBatch(
5121
5908
  opts.config.embedding,
5122
5909
  rows.map((r) => r.text)
5123
5910
  );
5911
+ progress(opts.hooks, request, displayName, documentId, "embed", "done", {
5912
+ batch: true,
5913
+ batch_id: batchId,
5914
+ chunks: rows.length
5915
+ });
5124
5916
  metaUpdates.mime_type = effectiveMime;
5125
5917
  metaUpdates.batch = {
5126
5918
  id: batchId,
@@ -5150,15 +5942,26 @@ async function runIngest(request, opts) {
5150
5942
  graphUnits: 0,
5151
5943
  providerTokens,
5152
5944
  error: null,
5153
- redactionFailed
5945
+ redactionFailed,
5946
+ // Extraction RAN here just like the skipped/failed/completed sites —
5947
+ // this report has to say why pages were unreadable too, not leave
5948
+ // the caller to re-derive it later from listDocuments.
5949
+ unreadableReason: prepared.unreadableReason,
5950
+ unreadablePages: prepared.unreadablePages
5154
5951
  });
5155
5952
  }
5156
5953
  const embeddingTokens = await embedChunks(opts.embedder, rows);
5157
5954
  if (embeddingTokens) providerTokens.embedding_tokens = embeddingTokens;
5158
5955
  await recordEmbeddingDim(opts.pool, opts.embedder.dim);
5159
5956
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, rows);
5957
+ progress(opts.hooks, request, displayName, documentId, "embed", "done", {
5958
+ batch: false,
5959
+ chunks: rows.length,
5960
+ tokens: embeddingTokens
5961
+ });
5160
5962
  const structuredKw = {};
5161
5963
  if (request.extractStructured && opts.config.llm) {
5964
+ progress(opts.hooks, request, displayName, documentId, "structured", "started");
5162
5965
  const extraction = await extractStructuredData(documentText, {
5163
5966
  llmCfg: opts.config.llm,
5164
5967
  fieldHints: request.fieldHints
@@ -5199,9 +6002,15 @@ async function runIngest(request, opts) {
5199
6002
  document: displayName
5200
6003
  });
5201
6004
  }
6005
+ progress(opts.hooks, request, displayName, documentId, "structured", "done", {
6006
+ document_type: extraction.documentType,
6007
+ keys: extraction.keysNormalized.length,
6008
+ quality: extraction.quality
6009
+ });
5202
6010
  }
5203
6011
  let graphUnits2 = 0;
5204
6012
  if (mode === "graph" && opts.graphStage) {
6013
+ progress(opts.hooks, request, displayName, documentId, "graph", "started");
5205
6014
  graphUnits2 = Number(
5206
6015
  await maybeAwait(
5207
6016
  opts.graphStage({
@@ -5212,6 +6021,7 @@ async function runIngest(request, opts) {
5212
6021
  })
5213
6022
  ) || 0
5214
6023
  );
6024
+ progress(opts.hooks, request, displayName, documentId, "graph", "done", { graph_units: graphUnits2 });
5215
6025
  }
5216
6026
  metaUpdates.mime_type = effectiveMime;
5217
6027
  await finalizeDocument(opts.pool, documentId, {
@@ -5251,7 +6061,9 @@ async function runIngest(request, opts) {
5251
6061
  graphUnits: graphUnits2,
5252
6062
  providerTokens,
5253
6063
  error: null,
5254
- redactionFailed
6064
+ redactionFailed,
6065
+ unreadableReason: prepared.unreadableReason,
6066
+ unreadablePages: prepared.unreadablePages
5255
6067
  });
5256
6068
  } catch (exc) {
5257
6069
  emitError(opts.hooks, exc, { stage: "ingest", document: displayName });
@@ -5266,7 +6078,9 @@ async function runIngest(request, opts) {
5266
6078
  graphUnits: 0,
5267
6079
  providerTokens,
5268
6080
  error: String(exc),
5269
- redactionFailed
6081
+ redactionFailed,
6082
+ unreadableReason: prepared.unreadableReason,
6083
+ unreadablePages: prepared.unreadablePages
5270
6084
  });
5271
6085
  }
5272
6086
  }
@@ -5310,17 +6124,11 @@ var init_ingest = __esm({
5310
6124
  // src/actions.ts
5311
6125
  function parseDocumentId(documentId) {
5312
6126
  const raw = String(documentId ?? "").replace(/[ \n\t]/g, "").trim();
5313
- if (!UUID_RE2.test(raw)) {
6127
+ if (!UUID_RE3.test(raw)) {
5314
6128
  throw new EngineActionError(`invalid document id: ${JSON.stringify(documentId)}`);
5315
6129
  }
5316
6130
  return raw;
5317
6131
  }
5318
- function visible(acl, principals) {
5319
- if (principals === null) return true;
5320
- if (acl == null) return true;
5321
- const held = new Set(principals);
5322
- return acl.some((p) => held.has(p));
5323
- }
5324
6132
  function scopeSql(sourceIds, principals, params) {
5325
6133
  const where = [];
5326
6134
  if (sourceIds != null) {
@@ -5409,7 +6217,9 @@ async function getDocumentRow(pool, documentId, principals) {
5409
6217
  createdAt: doc.created_at,
5410
6218
  updatedAt: doc.updated_at,
5411
6219
  startedAt: doc.started_at,
5412
- completedAt: doc.completed_at
6220
+ completedAt: doc.completed_at,
6221
+ unreadableReason: doc.meta_data?.unreadable_reason ?? null,
6222
+ unreadablePages: doc.meta_data?.unreadable_pages ?? 0
5413
6223
  };
5414
6224
  }
5415
6225
  async function stats(pool, sourceId) {
@@ -5458,7 +6268,7 @@ async function listDocuments(opts) {
5458
6268
  let where = scopeSql(sourceIds, principals, params);
5459
6269
  const cursorTime = parsedCursor?.time ?? parsedCursor?.created_at;
5460
6270
  const cursorId = parsedCursor?.id;
5461
- if (cursorTime && cursorId && UUID_RE2.test(String(cursorId))) {
6271
+ if (cursorTime && cursorId && UUID_RE3.test(String(cursorId))) {
5462
6272
  params.push(cursorTime, cursorId);
5463
6273
  const extra = `(created_at < $${params.length - 1} OR (created_at = $${params.length - 1} AND id < $${params.length}::uuid))`;
5464
6274
  where = where ? `${where} AND ${extra}` : `WHERE ${extra}`;
@@ -5466,7 +6276,7 @@ async function listDocuments(opts) {
5466
6276
  params.push(limit + 1);
5467
6277
  const { rows } = await opts.pool.query(
5468
6278
  `SELECT id, source_id, external_id, name, description, mode, mime_type, lang, status,
5469
- document_type, created_at, updated_at, acl
6279
+ document_type, created_at, updated_at, acl, meta_data
5470
6280
  FROM context_engine_documents
5471
6281
  ${where}
5472
6282
  ORDER BY created_at DESC, id DESC
@@ -5491,7 +6301,15 @@ async function listDocuments(opts) {
5491
6301
  hooks: opts.hooks
5492
6302
  }),
5493
6303
  createdAt: doc.created_at instanceof Date ? doc.created_at.toISOString() : doc.created_at,
5494
- updatedAt: doc.updated_at instanceof Date ? doc.updated_at.toISOString() : doc.updated_at
6304
+ updatedAt: doc.updated_at instanceof Date ? doc.updated_at.toISOString() : doc.updated_at,
6305
+ unreadableReason: doc.meta_data?.unreadable_reason ?? null,
6306
+ unreadablePages: doc.meta_data?.unreadable_pages ?? 0,
6307
+ // WHO can see this. Stored, enforced on every query and editable through
6308
+ // updateDocument — and, until this line, invisible to every caller,
6309
+ // because this serializer builds a FIXED object. `null` is UNRESTRICTED
6310
+ // and must stay null; an empty array would read as "nobody", which is the
6311
+ // opposite claim.
6312
+ acl: doc.acl ?? null
5495
6313
  }));
5496
6314
  const result = {
5497
6315
  documents,
@@ -5627,20 +6445,8 @@ function parseSpreadsheetText(dfd, text) {
5627
6445
  return out;
5628
6446
  }
5629
6447
  async function compute(instruction, opts) {
5630
- if (!opts.config.enableCodeExecution) {
5631
- throw new EngineActionError(
5632
- "compute() executes generated code and is disabled by default; set enableCodeExecution=true only in a deployment with out-of-process/container isolation."
5633
- );
5634
- }
5635
- const llmCfg = opts.modelCfg ?? opts.config.llm;
5636
- if (llmCfg == null) {
5637
- throw new Error("compute() requires an LLM: pass modelCfg= or configure ContextEngineConfig.llm");
5638
- }
5639
- if (!instruction?.trim()) {
5640
- throw new EngineActionError("instruction must not be empty");
5641
- }
6448
+ checkComputePreconditions(opts.config, opts.modelCfg, instruction);
5642
6449
  const dfd = await requireDanfo();
5643
- const timeout = Math.max(1, Math.min(Math.trunc(opts.timeout || DEFAULT_COMPUTE_TIMEOUT), 300));
5644
6450
  const principals = opts.principals ?? null;
5645
6451
  const params = [];
5646
6452
  let where = scopeSql(opts.sourceIds ?? null, principals, params);
@@ -5649,9 +6455,9 @@ async function compute(instruction, opts) {
5649
6455
  return `mime_type ILIKE $${params.length}`;
5650
6456
  }).join(" OR ");
5651
6457
  where = where ? `${where} AND (${mimeClause})` : `WHERE (${mimeClause})`;
5652
- if (opts.docIds?.length) {
6458
+ if (opts.documentIds?.length) {
5653
6459
  const parsedIds = [];
5654
- for (const did of opts.docIds) {
6460
+ for (const did of opts.documentIds) {
5655
6461
  try {
5656
6462
  parsedIds.push(parseDocumentId(did));
5657
6463
  } catch {
@@ -5679,13 +6485,13 @@ async function compute(instruction, opts) {
5679
6485
  }
5680
6486
  if (tabular.length > MAX_COMPUTE_DOCUMENTS) {
5681
6487
  throw new EngineActionError(
5682
- `more than ${MAX_COMPUTE_DOCUMENTS} tabular documents are in scope for compute() \u2014 narrow the request with docIds or sourceIds`
6488
+ `more than ${MAX_COMPUTE_DOCUMENTS} tabular documents are in scope for compute() \u2014 narrow the request with documentIds or sourceIds`
5683
6489
  );
5684
6490
  }
5685
6491
  const totalChars = tabular.reduce((n, d) => n + String(d.text ?? "").length, 0);
5686
6492
  if (totalChars > MAX_COMPUTE_TEXT_CHARS) {
5687
6493
  throw new EngineActionError(
5688
- `in-scope spreadsheet text too large to load (${totalChars} chars > ${MAX_COMPUTE_TEXT_CHARS} cap) \u2014 narrow the request with docIds or sourceIds`
6494
+ `in-scope spreadsheet text too large to load (${totalChars} chars > ${MAX_COMPUTE_TEXT_CHARS} cap) \u2014 narrow the request with documentIds or sourceIds`
5689
6495
  );
5690
6496
  }
5691
6497
  const dfs = {};
@@ -5710,6 +6516,69 @@ async function compute(instruction, opts) {
5710
6516
  if (!Object.keys(dfs).length) {
5711
6517
  throw new EngineActionError("in-scope documents did not parse into any usable dataframe");
5712
6518
  }
6519
+ return computeOverFrames(dfs, instruction, {
6520
+ config: opts.config,
6521
+ modelCfg: opts.modelCfg,
6522
+ timeout: opts.timeout,
6523
+ hooks: opts.hooks,
6524
+ principals,
6525
+ documents
6526
+ });
6527
+ }
6528
+ function checkComputePreconditions(config, modelCfg, instruction) {
6529
+ if (!config.enableCodeExecution) {
6530
+ throw new EngineActionError(
6531
+ "compute() executes generated code and is disabled by default; set enableCodeExecution=true only in a deployment with out-of-process/container isolation."
6532
+ );
6533
+ }
6534
+ const llmCfg = modelCfg ?? config.llm;
6535
+ if (llmCfg == null) {
6536
+ throw new Error("compute() requires an LLM: pass modelCfg= or configure ContextEngineConfig.llm");
6537
+ }
6538
+ if (!instruction?.trim()) {
6539
+ throw new EngineActionError("instruction must not be empty");
6540
+ }
6541
+ return llmCfg;
6542
+ }
6543
+ function maskFrames(frames, policy, opts) {
6544
+ if (policy == null || policy.isEmpty()) return frames;
6545
+ const mask = (text) => redactValueRecursive(text, policy, opts);
6546
+ const masked = {};
6547
+ for (const [name, rows] of Object.entries(frames)) {
6548
+ const labels = /* @__PURE__ */ new Map();
6549
+ const taken = /* @__PURE__ */ new Set();
6550
+ for (const row of rows) {
6551
+ for (const column of Object.keys(row)) {
6552
+ if (labels.has(column)) continue;
6553
+ const base = mask(column);
6554
+ let label = base;
6555
+ for (let n = 2; taken.has(label); n++) label = `${base}_${n}`;
6556
+ taken.add(label);
6557
+ labels.set(column, label);
6558
+ }
6559
+ }
6560
+ masked[name] = rows.map(
6561
+ (row) => Object.fromEntries(
6562
+ Object.entries(row).map(([column, value]) => [
6563
+ labels.get(column) ?? column,
6564
+ typeof value === "string" ? mask(value) : value
6565
+ ])
6566
+ )
6567
+ );
6568
+ }
6569
+ return masked;
6570
+ }
6571
+ async function computeOverFrames(frames, instruction, opts) {
6572
+ const llmCfg = checkComputePreconditions(opts.config, opts.modelCfg, instruction);
6573
+ if (!frames || !Object.keys(frames).length) {
6574
+ throw new EngineActionError("no tabular data to compute over");
6575
+ }
6576
+ const hooks = opts.hooks ?? {};
6577
+ const principals = opts.principals ?? null;
6578
+ const documents = [...opts.documents ?? []];
6579
+ const timeout = Math.max(1, Math.min(Math.trunc(opts.timeout || DEFAULT_COMPUTE_TIMEOUT), 300));
6580
+ const redactOpts = { principals, secretKey: opts.config.secretKey, hooks };
6581
+ const dfs = maskFrames(frames, opts.config.redaction, redactOpts);
5713
6582
  const schemaLines = Object.entries(dfs).map(
5714
6583
  ([name, table]) => `- ${name}: columns=${JSON.stringify(Object.keys(table[0] ?? {}))}, rows=${table.length}`
5715
6584
  );
@@ -5726,7 +6595,7 @@ ${schemaLines.join("\n")}`;
5726
6595
  jsonMode: false
5727
6596
  });
5728
6597
  } catch (exc) {
5729
- emitError(opts.hooks, exc, { stage: "compute_codegen", instruction: instruction.slice(0, 200) });
6598
+ emitError(hooks, exc, { stage: "compute_codegen", instruction: instruction.slice(0, 200) });
5730
6599
  throw exc;
5731
6600
  }
5732
6601
  const code = stripCodeFences(rawCode);
@@ -5737,12 +6606,12 @@ ${schemaLines.join("\n")}`;
5737
6606
  maskedCode = redactValueRecursive(code.slice(0, 500), opts.config.redaction, {
5738
6607
  principals,
5739
6608
  secretKey: opts.config.secretKey,
5740
- hooks: opts.hooks
6609
+ hooks
5741
6610
  });
5742
6611
  } catch {
5743
6612
  maskedCode = "<redaction failed: code omitted>";
5744
6613
  }
5745
- emitError(opts.hooks, new Error(execResult.error || "compute execution failed"), {
6614
+ emitError(hooks, new Error(execResult.error || "compute execution failed"), {
5746
6615
  stage: "compute_exec",
5747
6616
  code: maskedCode
5748
6617
  });
@@ -5763,10 +6632,10 @@ ${schemaLines.join("\n")}`;
5763
6632
  return redactValueRecursive(result, opts.config.redaction, {
5764
6633
  principals,
5765
6634
  secretKey: opts.config.secretKey,
5766
- hooks: opts.hooks
6635
+ hooks
5767
6636
  });
5768
6637
  }
5769
- var MAX_LIST_LIMIT, MAX_COMPUTE_TEXT_CHARS, DEFAULT_COMPUTE_TIMEOUT, MAX_COMPUTE_DOCUMENTS, TABULAR_MIME_PATTERNS, UUID_RE2, WORD_RE, STOPWORDS, SHEET_MARKER_RE2, CODE_FENCE_RE, COMPUTE_SYSTEM_PROMPT;
6638
+ var MAX_LIST_LIMIT, MAX_COMPUTE_TEXT_CHARS, DEFAULT_COMPUTE_TIMEOUT, MAX_COMPUTE_DOCUMENTS, TABULAR_MIME_PATTERNS, UUID_RE3, WORD_RE, STOPWORDS, SHEET_MARKER_RE2, CODE_FENCE_RE, COMPUTE_SYSTEM_PROMPT, visible;
5770
6639
  var init_actions = __esm({
5771
6640
  "src/actions.ts"() {
5772
6641
  init_chunkers();
@@ -5776,13 +6645,14 @@ var init_actions = __esm({
5776
6645
  init_redaction();
5777
6646
  init_sandbox();
5778
6647
  init_structured();
6648
+ init_acl();
5779
6649
  init_ingest();
5780
6650
  MAX_LIST_LIMIT = 200;
5781
6651
  MAX_COMPUTE_TEXT_CHARS = 2e6;
5782
6652
  DEFAULT_COMPUTE_TIMEOUT = 30;
5783
6653
  MAX_COMPUTE_DOCUMENTS = 50;
5784
6654
  TABULAR_MIME_PATTERNS = ["%csv%", "%sheet%", "%excel%", "%spreadsheetml%", "%tab-separated%"];
5785
- UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6655
+ UUID_RE3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5786
6656
  WORD_RE = /[^\W\d_]+/gu;
5787
6657
  STOPWORDS = /* @__PURE__ */ new Set([
5788
6658
  "the",
@@ -5861,6 +6731,7 @@ Rules:
5861
6731
  1. Set a variable named \`result\` to the final answer (a number, string, array, or object).
5862
6732
  2. No file I/O, no network calls, no imports, no require, no process.
5863
6733
  3. Return ONLY the JavaScript code \u2014 no markdown fences, no explanation, no commentary.`;
6734
+ visible = aclVisible;
5864
6735
  }
5865
6736
  });
5866
6737
  function estimatedTokens(texts) {
@@ -5887,20 +6758,6 @@ function buildOpenAIClient(cfg2) {
5887
6758
  maxRetries: 0
5888
6759
  });
5889
6760
  }
5890
- async function loadGeminiEmbedClient(apiKey) {
5891
- const specifier = "@google/genai";
5892
- let mod;
5893
- try {
5894
- mod = await import(specifier);
5895
- } catch {
5896
- throw new ExtraMissingError("gemini", specifier, "gemini embeddings");
5897
- }
5898
- const Ctor = mod.GoogleGenAI ?? mod.Client;
5899
- if (!Ctor) {
5900
- throw new ExtraMissingError("gemini", specifier, "gemini embeddings");
5901
- }
5902
- return new Ctor({ apiKey: apiKey ?? null });
5903
- }
5904
6761
  function buildEmbedder(cfg2, opts) {
5905
6762
  if (OPENAI_FAMILY2.has(cfg2.provider)) {
5906
6763
  return new Embedder(cfg2, { client: buildOpenAIClient(cfg2) });
@@ -5908,18 +6765,19 @@ function buildEmbedder(cfg2, opts) {
5908
6765
  if (cfg2.provider === "voyage" || cfg2.provider === "cohere") {
5909
6766
  return new Embedder(cfg2, { fetch: globalThis.fetch });
5910
6767
  }
5911
- if (cfg2.provider === "gemini") {
6768
+ if (GOOGLE_FAMILY2.has(cfg2.provider)) {
5912
6769
  return new Embedder(cfg2);
5913
6770
  }
5914
6771
  throw new Error(`unknown embedding provider: ${JSON.stringify(cfg2.provider)}`);
5915
6772
  }
5916
- var TIMEOUT_MS3, OPENAI_FAMILY2, Embedder;
6773
+ var TIMEOUT_MS3, OPENAI_FAMILY2, GOOGLE_FAMILY2, Embedder;
5917
6774
  var init_embeddings = __esm({
5918
6775
  "src/providers/embeddings.ts"() {
5919
- init_errors();
5920
6776
  init_text();
6777
+ init_google();
5921
6778
  TIMEOUT_MS3 = 3e4;
5922
6779
  OPENAI_FAMILY2 = /* @__PURE__ */ new Set(["openai", "azure_openai", "custom"]);
6780
+ GOOGLE_FAMILY2 = /* @__PURE__ */ new Set(["gemini", "vertex_ai"]);
5923
6781
  Embedder = class {
5924
6782
  cfg;
5925
6783
  provider;
@@ -5967,7 +6825,7 @@ var init_embeddings = __esm({
5967
6825
  /** Provider dispatch. Overridable per-instance (tests stub this). */
5968
6826
  async rawEmbed(texts, kind = "document") {
5969
6827
  if (OPENAI_FAMILY2.has(this.provider)) return this.embedOpenAI(texts);
5970
- if (this.provider === "gemini") return this.embedGemini(texts);
6828
+ if (GOOGLE_FAMILY2.has(this.provider)) return this.embedGemini(texts);
5971
6829
  if (this.provider === "voyage") return this.embedVoyage(texts, kind);
5972
6830
  if (this.provider === "cohere") return this.embedCohere(texts, kind);
5973
6831
  throw new Error(`unknown embedding provider: ${JSON.stringify(this.provider)}`);
@@ -5983,7 +6841,7 @@ var init_embeddings = __esm({
5983
6841
  }
5984
6842
  async embedGemini(texts) {
5985
6843
  if (!this.genaiClient) {
5986
- this.genaiClient = await loadGeminiEmbedClient(this.cfg.apiKey);
6844
+ this.genaiClient = await buildGenaiClient(this.cfg, TIMEOUT_MS3, "gemini embeddings");
5987
6845
  }
5988
6846
  const resp = await this.genaiClient.models.embedContent({
5989
6847
  model: this.model,
@@ -7447,10 +8305,10 @@ async function runLegs(query, opts) {
7447
8305
  return ranked;
7448
8306
  }
7449
8307
  async function hydrate(pool, chunkIds) {
7450
- const parsed = chunkIds.filter((id) => UUID_RE3.test(id));
8308
+ const parsed = chunkIds.filter((id) => UUID_RE4.test(id));
7451
8309
  if (parsed.length !== chunkIds.length) {
7452
8310
  for (const cid of chunkIds) {
7453
- if (!UUID_RE3.test(cid)) {
8311
+ if (!UUID_RE4.test(cid)) {
7454
8312
  console.warn("search: skipping unhydratable chunk id %s", cid);
7455
8313
  }
7456
8314
  }
@@ -7549,6 +8407,7 @@ async function runSearch(query, opts) {
7549
8407
  const topK = Math.max(1, Math.trunc(opts.topK ?? 10));
7550
8408
  const scope = {
7551
8409
  sourceIds: opts.sourceIds != null ? [...opts.sourceIds] : null,
8410
+ documentIds: opts.documentIds != null ? [...opts.documentIds] : null,
7552
8411
  principals: opts.principals != null ? [...opts.principals] : null,
7553
8412
  limit: legLimit(topK, opts.config)
7554
8413
  };
@@ -7569,13 +8428,8 @@ async function runSearch(query, opts) {
7569
8428
  let degraded = null;
7570
8429
  if (mode === "graph" && !graphLeg) {
7571
8430
  degraded = "graph_leg_unavailable";
7572
- emitError(
7573
- opts.hooks,
7574
- new GraphLegUnavailable(
7575
- "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."
7576
- ),
7577
- { stage: "graph_leg", mode, degraded }
7578
- );
8431
+ 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.";
8432
+ emitError(opts.hooks, new GraphLegUnavailable(reason), { stage: "graph_leg", mode, degraded });
7579
8433
  }
7580
8434
  const fused = rrfFuse(ranked, { k: opts.config.fusion.k, weights: opts.config.fusion.weights });
7581
8435
  const window = opts.config.reranker.enabled ? Math.max(topK, opts.config.reranker.candidates) : topK;
@@ -7649,7 +8503,7 @@ async function runSearch(query, opts) {
7649
8503
  });
7650
8504
  return { hits, usage };
7651
8505
  }
7652
- var MIN_LEG_CANDIDATES, MAX_LEG_CANDIDATES, UNITS_PER_SCOPE_HYBRID, UNITS_PER_SCOPE_GRAPH, UUID_RE3;
8506
+ var MIN_LEG_CANDIDATES, MAX_LEG_CANDIDATES, UNITS_PER_SCOPE_HYBRID, UNITS_PER_SCOPE_GRAPH, UUID_RE4;
7653
8507
  var init_search = __esm({
7654
8508
  "src/search.ts"() {
7655
8509
  init_compression();
@@ -7662,37 +8516,7 @@ var init_search = __esm({
7662
8516
  MAX_LEG_CANDIDATES = 500;
7663
8517
  UNITS_PER_SCOPE_HYBRID = 1;
7664
8518
  UNITS_PER_SCOPE_GRAPH = 5;
7665
- UUID_RE3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7666
- }
7667
- });
7668
-
7669
- // src/sentinels.ts
7670
- function resolvePrincipals(value, method) {
7671
- if (value === TRUSTED) return null;
7672
- if (value === void 0) return null;
7673
- if (value === null) {
7674
- process.emitWarning(
7675
- `${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.`,
7676
- "DeprecationWarning"
7677
- );
7678
- return null;
7679
- }
7680
- return Array.isArray(value) ? value : null;
7681
- }
7682
- var UNSET, TrustedSentinel, TRUSTED;
7683
- var init_sentinels = __esm({
7684
- "src/sentinels.ts"() {
7685
- UNSET = /* @__PURE__ */ Symbol.for("context_engine.UNSET");
7686
- TrustedSentinel = class {
7687
- [Symbol.toStringTag] = "TRUSTED";
7688
- toString() {
7689
- return "TRUSTED";
7690
- }
7691
- valueOf() {
7692
- return true;
7693
- }
7694
- };
7695
- TRUSTED = Object.freeze(new TrustedSentinel());
8519
+ UUID_RE4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7696
8520
  }
7697
8521
  });
7698
8522
  function isSpacelessQuery(query) {
@@ -7735,7 +8559,7 @@ FROM context_engine_chunks c
7735
8559
  WHERE c.embedding IS NOT NULL
7736
8560
  ${SCOPE}
7737
8561
  ORDER BY c.embedding <=> ${vec}
7738
- LIMIT $3
8562
+ LIMIT $4
7739
8563
  `;
7740
8564
  }
7741
8565
  function sqlAnnExact(vec) {
@@ -7748,13 +8572,13 @@ WITH eligible AS MATERIALIZED (
7748
8572
  )
7749
8573
  SELECT id::text FROM eligible
7750
8574
  ORDER BY embedding <=> ${vec}
7751
- LIMIT $3
8575
+ LIMIT $4
7752
8576
  `;
7753
8577
  }
7754
8578
  function idsOf(rows) {
7755
8579
  return rows.map((r) => String(r.id));
7756
8580
  }
7757
- var MIN_ITERATIVE_SCAN_VERSION, ANN_MIN_EF_SEARCH, ANN_MAX_EF_SEARCH, ANN_SCAN_FRACTION, ANN_SCAN_PER_ROW, ANN_MIN_SCAN_TUPLES, ANN_MAX_SCAN_TUPLES, ANN_SCAN_MEM_MULTIPLIER, ANN_EXACT_THRESHOLD, SCOPE, SQL_FTS, SQL_TRGM, SQL_COUNT_ELIGIBLE2, SQL_FILTER_IDS, UUID_RE4, PostgresBackend;
8581
+ var MIN_ITERATIVE_SCAN_VERSION, ANN_MIN_EF_SEARCH, ANN_MAX_EF_SEARCH, ANN_SCAN_FRACTION, ANN_SCAN_PER_ROW, ANN_MIN_SCAN_TUPLES, ANN_MAX_SCAN_TUPLES, ANN_SCAN_MEM_MULTIPLIER, ANN_EXACT_THRESHOLD, SCOPE, SQL_FTS, SQL_TRGM, SQL_COUNT_ELIGIBLE2, SQL_FILTER_IDS, UUID_RE5, PostgresBackend;
7758
8582
  var init_storage = __esm({
7759
8583
  "src/storage.ts"() {
7760
8584
  init_text();
@@ -7769,25 +8593,26 @@ var init_storage = __esm({
7769
8593
  ANN_EXACT_THRESHOLD = 5e4;
7770
8594
  SCOPE = `
7771
8595
  AND ($1::text[] IS NULL OR c.source_id = ANY($1))
7772
- AND ($2::text[] IS NULL OR c.acl IS NULL OR c.acl && $2)
8596
+ AND ($2::uuid[] IS NULL OR c.document_id = ANY($2::uuid[]))
8597
+ AND ($3::text[] IS NULL OR c.acl IS NULL OR c.acl && $3)
7773
8598
  `;
7774
8599
  SQL_FTS = `
7775
8600
  SELECT c.id::text
7776
8601
  FROM context_engine_chunks c
7777
8602
  WHERE c.text IS NOT NULL
7778
- AND c.text_search @@ websearch_to_tsquery('simple'::regconfig, $3)
8603
+ AND c.text_search @@ websearch_to_tsquery('simple'::regconfig, $4)
7779
8604
  ${SCOPE}
7780
- ORDER BY ts_rank_cd(c.text_search, websearch_to_tsquery('simple'::regconfig, $3)) DESC, c.id
7781
- LIMIT $4
8605
+ ORDER BY ts_rank_cd(c.text_search, websearch_to_tsquery('simple'::regconfig, $4)) DESC, c.id
8606
+ LIMIT $5
7782
8607
  `;
7783
8608
  SQL_TRGM = `
7784
8609
  SELECT c.id::text
7785
8610
  FROM context_engine_chunks c
7786
8611
  WHERE c.text IS NOT NULL
7787
- AND c.text_trgm_norm % $3
8612
+ AND c.text_trgm_norm % $4
7788
8613
  ${SCOPE}
7789
- ORDER BY similarity(c.text_trgm_norm, $3) DESC, c.id
7790
- LIMIT $4
8614
+ ORDER BY similarity(c.text_trgm_norm, $4) DESC, c.id
8615
+ LIMIT $5
7791
8616
  `;
7792
8617
  SQL_COUNT_ELIGIBLE2 = `
7793
8618
  SELECT count(*)::int AS count FROM (
@@ -7795,16 +8620,16 @@ SELECT count(*)::int AS count FROM (
7795
8620
  FROM context_engine_chunks c
7796
8621
  WHERE c.embedding IS NOT NULL
7797
8622
  ${SCOPE}
7798
- LIMIT $3
8623
+ LIMIT $4
7799
8624
  ) probe
7800
8625
  `;
7801
8626
  SQL_FILTER_IDS = `
7802
8627
  SELECT c.id::text
7803
8628
  FROM context_engine_chunks c
7804
- WHERE c.id = ANY($3::uuid[])
8629
+ WHERE c.id = ANY($4::uuid[])
7805
8630
  ${SCOPE}
7806
8631
  `;
7807
- UUID_RE4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8632
+ UUID_RE5 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7808
8633
  PostgresBackend = class {
7809
8634
  supportsFts = true;
7810
8635
  supportsTrgm = true;
@@ -7826,24 +8651,26 @@ WHERE c.id = ANY($3::uuid[])
7826
8651
  * mean different things.
7827
8652
  */
7828
8653
  scopeParams(scope) {
7829
- return [
7830
- scope.sourceIds != null ? [...scope.sourceIds] : null,
7831
- scope.principals != null ? [...scope.principals] : null,
7832
- Math.max(1, Math.trunc(scope.limit ?? 50))
7833
- ];
8654
+ return {
8655
+ binds: [
8656
+ scope.sourceIds != null ? [...scope.sourceIds] : null,
8657
+ scope.documentIds != null ? [...scope.documentIds] : null,
8658
+ scope.principals != null ? [...scope.principals] : null
8659
+ ],
8660
+ lim: Math.max(1, Math.trunc(scope.limit ?? 50))
8661
+ };
7834
8662
  }
7835
8663
  async upsertChunks(documentId, sourceId, acl, chunks) {
7836
8664
  const client = await this.pool.connect();
7837
8665
  try {
7838
8666
  await client.query("BEGIN");
7839
8667
  await client.query("DELETE FROM context_engine_chunks WHERE document_id = $1", [documentId]);
7840
- for (const c of chunks) {
7841
- const emb = c.embedding != null ? vectorLiteral2(c.embedding) : "NULL";
7842
- await client.query(
7843
- `INSERT INTO context_engine_chunks
7844
- (id, document_id, source_id, acl, idx, text, lang, embedding, meta_data)
7845
- VALUES ($1, $2, $3, $4, $5, $6, $7, ${emb}, $8::jsonb)`,
7846
- [
8668
+ const BATCH = 500;
8669
+ for (let start = 0; start < chunks.length; start += BATCH) {
8670
+ const batch = chunks.slice(start, start + BATCH);
8671
+ const params = [];
8672
+ const rows = batch.map((c) => {
8673
+ params.push(
7847
8674
  randomUUID(),
7848
8675
  documentId,
7849
8676
  sourceId,
@@ -7852,7 +8679,16 @@ WHERE c.id = ANY($3::uuid[])
7852
8679
  c.text,
7853
8680
  c.lang ?? null,
7854
8681
  JSON.stringify(c.meta ?? {})
7855
- ]
8682
+ );
8683
+ const p = params.length;
8684
+ const emb = c.embedding != null ? vectorLiteral2(c.embedding) : "NULL";
8685
+ return `($${p - 7}, $${p - 6}, $${p - 5}, $${p - 4}, $${p - 3}, $${p - 2}, $${p - 1}, ${emb}, $${p}::jsonb)`;
8686
+ });
8687
+ await client.query(
8688
+ `INSERT INTO context_engine_chunks
8689
+ (id, document_id, source_id, acl, idx, text, lang, embedding, meta_data)
8690
+ VALUES ${rows.join(", ")}`,
8691
+ params
7856
8692
  );
7857
8693
  }
7858
8694
  await client.query("COMMIT");
@@ -7912,24 +8748,24 @@ WHERE c.id = ANY($3::uuid[])
7912
8748
  const parsed = [];
7913
8749
  for (const cid of chunkIds) {
7914
8750
  try {
7915
- if (UUID_RE4.test(String(cid))) parsed.push(String(cid));
8751
+ if (UUID_RE5.test(String(cid))) parsed.push(String(cid));
7916
8752
  } catch {
7917
8753
  }
7918
8754
  }
7919
8755
  if (!parsed.length) return [];
7920
- const [src, principals] = this.scopeParams(scope);
7921
- const { rows } = await this.pool.query(SQL_FILTER_IDS, [src, principals, parsed]);
8756
+ const { binds } = this.scopeParams(scope);
8757
+ const { rows } = await this.pool.query(SQL_FILTER_IDS, [...binds, parsed]);
7922
8758
  const visible2 = new Set(idsOf(rows));
7923
8759
  return chunkIds.filter((cid) => visible2.has(String(cid))).map(String);
7924
8760
  }
7925
8761
  async ftsSearch(query, scope) {
7926
- const [src, principals, lim] = this.scopeParams(scope);
7927
- const { rows } = await this.pool.query(SQL_FTS, [src, principals, query, lim]);
8762
+ const { binds, lim } = this.scopeParams(scope);
8763
+ const { rows } = await this.pool.query(SQL_FTS, [...binds, query, lim]);
7928
8764
  return idsOf(rows);
7929
8765
  }
7930
8766
  async trgmSearch(query, scope) {
7931
8767
  const threshold = trigramThreshold(query);
7932
- const [src, principals, lim] = this.scopeParams(scope);
8768
+ const { binds, lim } = this.scopeParams(scope);
7933
8769
  const client = await this.pool.connect();
7934
8770
  let previous = null;
7935
8771
  let usedSetLimit = false;
@@ -7938,7 +8774,7 @@ WHERE c.id = ANY($3::uuid[])
7938
8774
  const applied = await this.applyTrgmThreshold(client, threshold);
7939
8775
  previous = applied.previous;
7940
8776
  usedSetLimit = applied.usedSetLimit;
7941
- const { rows } = await client.query(SQL_TRGM, [src, principals, query, lim]);
8777
+ const { rows } = await client.query(SQL_TRGM, [...binds, query, lim]);
7942
8778
  if (usedSetLimit) await this.restoreTrgmLimit(client, previous);
7943
8779
  await client.query("COMMIT");
7944
8780
  return idsOf(rows);
@@ -7984,18 +8820,18 @@ WHERE c.id = ANY($3::uuid[])
7984
8820
  }
7985
8821
  async annSearch(vector, scope) {
7986
8822
  const literal = vectorLiteral2(vector);
7987
- const [src, principals, lim] = this.scopeParams(scope);
7988
- const scoped = src !== null || principals !== null;
8823
+ const { binds, lim } = this.scopeParams(scope);
8824
+ const scoped = binds.some((v) => v !== null);
7989
8825
  const client = await this.pool.connect();
7990
8826
  try {
7991
8827
  await client.query("BEGIN");
7992
- if (scoped && await this.eligibleIsSmall(client, src, principals)) {
7993
- const { rows: rows2 } = await client.query(sqlAnnExact(literal), [src, principals, lim]);
8828
+ if (scoped && await this.eligibleIsSmall(client, binds)) {
8829
+ const { rows: rows2 } = await client.query(sqlAnnExact(literal), [...binds, lim]);
7994
8830
  await client.query("COMMIT");
7995
8831
  return idsOf(rows2);
7996
8832
  }
7997
8833
  await this.tuneAnnScan(client, lim, scoped);
7998
- const { rows } = await client.query(sqlAnn(literal), [src, principals, lim]);
8834
+ const { rows } = await client.query(sqlAnn(literal), [...binds, lim]);
7999
8835
  await client.query("COMMIT");
8000
8836
  return idsOf(rows);
8001
8837
  } catch (err) {
@@ -8008,11 +8844,17 @@ WHERE c.id = ANY($3::uuid[])
8008
8844
  client.release();
8009
8845
  }
8010
8846
  }
8011
- async eligibleIsSmall(client, src, principals) {
8847
+ async eligibleIsSmall(client, binds) {
8012
8848
  const cap = this.exactThreshold + 1;
8013
- const { rows } = await client.query(SQL_COUNT_ELIGIBLE2, [src, principals, cap]);
8849
+ const { rows } = await client.query(SQL_COUNT_ELIGIBLE2, [...binds, cap]);
8014
8850
  return Number(rows[0]?.count ?? 0) <= this.exactThreshold;
8015
8851
  }
8852
+ /** Size the HNSW candidate budget, and make it iterative when filtered.
8853
+ *
8854
+ * Public because the graph leg's seed query (`graph/retrieval.ts`) is the
8855
+ * same shape — an ANN walk with the scope predicate as a POST-filter — and
8856
+ * must not carry a second copy of this. Call it inside a transaction on the
8857
+ * client the query itself will run on: every setting here is `SET LOCAL`. */
8016
8858
  async tuneAnnScan(client, limit, scoped) {
8017
8859
  const efSearch = Math.min(Math.max(Math.trunc(limit) * 4, ANN_MIN_EF_SEARCH), ANN_MAX_EF_SEARCH);
8018
8860
  await client.query(`SET LOCAL hnsw.ef_search = ${efSearch}`);
@@ -8107,19 +8949,6 @@ var init_function = __esm({
8107
8949
  "src/tools/executors/function.ts"() {
8108
8950
  }
8109
8951
  });
8110
-
8111
- // src/tools/acl.ts
8112
- function aclVisible(acl, principals) {
8113
- if (principals === null) return true;
8114
- if (acl == null) return true;
8115
- if (!acl.length) return false;
8116
- const held = new Set(principals ?? []);
8117
- return acl.some((p) => held.has(p));
8118
- }
8119
- var init_acl = __esm({
8120
- "src/tools/acl.ts"() {
8121
- }
8122
- });
8123
8952
  function coerceValue(raw) {
8124
8953
  const trimmed = raw.trim();
8125
8954
  if (trimmed.length >= 2 && trimmed[0] === trimmed[trimmed.length - 1] && (trimmed[0] === "'" || trimmed[0] === '"')) {
@@ -8166,45 +8995,121 @@ function shouldRequireApproval(ct, args) {
8166
8995
  if (!condition) return false;
8167
8996
  return evaluateCondition(condition, args ?? {});
8168
8997
  }
8169
- async function createPending(engine, opts) {
8998
+ function rowToRecord(row) {
8999
+ return {
9000
+ id: String(row.id),
9001
+ toolName: String(row.tool_name),
9002
+ toolArgsFrozen: row.tool_args_frozen ?? {},
9003
+ sourceId: row.source_id ?? null,
9004
+ approvalScope: row.approval_scope ?? null,
9005
+ principals: row.principals,
9006
+ status: String(row.status),
9007
+ approver: row.approver ?? null,
9008
+ approverMeta: row.approver_meta ?? null,
9009
+ expiresAt: toDate(row.expires_at),
9010
+ resolvedAt: toDate(row.resolved_at),
9011
+ createdAt: toDate(row.created_at)
9012
+ };
9013
+ }
9014
+ function toDate(value) {
9015
+ if (value == null) return null;
9016
+ if (value instanceof Date) return value;
9017
+ return new Date(String(value));
9018
+ }
9019
+ function claimVisibilitySql(principals, paramIndex) {
9020
+ if (principals === null || principals === TRUSTED) return { sql: "TRUE", params: [] };
9021
+ const noWall = `principals IS NULL OR principals = 'null'::jsonb OR principals = '[]'::jsonb`;
9022
+ principals = principals;
9023
+ if (!principals.length) return { sql: `(${noWall})`, params: [] };
9024
+ return { sql: `(${noWall} OR principals ?| $${paramIndex}::text[])`, params: [principals] };
9025
+ }
9026
+ function validateApprovalScope(approvalScope) {
9027
+ if (approvalScope === null || approvalScope === void 0) return null;
9028
+ if (typeof approvalScope !== "string") {
9029
+ throw new TypeError(`approvalScope must be a non-empty string or null, got ${typeof approvalScope}`);
9030
+ }
9031
+ if (!approvalScope) throw new Error("approvalScope must be a non-empty string or null, got ''");
9032
+ return approvalScope;
9033
+ }
9034
+ function pendingRow(opts, approvalScope) {
8170
9035
  const policy = opts.policy ?? {};
8171
9036
  const frozen = structuredClone(opts.args);
8172
9037
  const createdAt = opts.now ?? /* @__PURE__ */ new Date();
8173
9038
  const timeout = Number(policy.timeout_minutes ?? DEFAULT_TIMEOUT_MINUTES);
8174
9039
  const expiresAt = new Date(createdAt.getTime() + timeout * 6e4);
9040
+ const principals = opts.principals === void 0 || opts.principals === TRUSTED ? null : opts.principals;
9041
+ return { frozen, createdAt, expiresAt, principals, approvalScope };
9042
+ }
9043
+ async function insertPending(engine, opts, row) {
8175
9044
  const id = randomUUID();
8176
9045
  await engine.pool.query(
8177
9046
  `INSERT INTO context_engine_tool_approvals
8178
- (id, tool_name, tool_args_frozen, source_id, principals, status, expires_at, created_at)
8179
- VALUES ($1,$2,$3::jsonb,$4,$5::jsonb,'pending',$6,$7)`,
9047
+ (id, tool_name, tool_args_frozen, source_id, approval_scope, principals, status, expires_at, created_at)
9048
+ VALUES ($1,$2,$3::jsonb,$4,$5,$6::jsonb,'pending',$7,$8)`,
8180
9049
  [
8181
9050
  id,
8182
9051
  opts.toolName,
8183
- JSON.stringify(frozen),
9052
+ JSON.stringify(row.frozen),
8184
9053
  opts.sourceId ?? null,
8185
- opts.principals === void 0 ? null : JSON.stringify(opts.principals),
8186
- expiresAt,
8187
- createdAt
9054
+ row.approvalScope,
9055
+ row.principals === null ? null : JSON.stringify(row.principals),
9056
+ row.expiresAt,
9057
+ row.createdAt
8188
9058
  ]
8189
9059
  );
8190
9060
  return {
8191
9061
  id,
8192
9062
  toolName: opts.toolName,
8193
- toolArgsFrozen: frozen,
9063
+ toolArgsFrozen: row.frozen,
8194
9064
  sourceId: opts.sourceId ?? null,
8195
- principals: opts.principals ?? null,
9065
+ approvalScope: row.approvalScope,
9066
+ principals: row.principals,
8196
9067
  status: "pending",
8197
9068
  approver: null,
8198
9069
  approverMeta: null,
8199
- expiresAt,
9070
+ expiresAt: row.expiresAt,
8200
9071
  resolvedAt: null,
8201
- createdAt
9072
+ createdAt: row.createdAt
8202
9073
  };
8203
9074
  }
9075
+ async function createPending(engine, opts) {
9076
+ const approvalScope = validateApprovalScope(opts.approvalScope);
9077
+ return insertPending(engine, opts, pendingRow(opts, approvalScope));
9078
+ }
9079
+ async function findOrCreatePending(engine, opts) {
9080
+ const approvalScope = validateApprovalScope(opts.approvalScope);
9081
+ if (approvalScope === null)
9082
+ throw new Error("findOrCreatePending requires an approvalScope; use createPending");
9083
+ const row = pendingRow(opts, approvalScope);
9084
+ const frozenJson = JSON.stringify(row.frozen);
9085
+ const sameCall = `tool_name = $1 AND approval_scope = $2 AND tool_args_frozen = $3::jsonb`;
9086
+ const params = [opts.toolName, approvalScope, frozenJson, row.createdAt];
9087
+ await engine.pool.query(
9088
+ `UPDATE context_engine_tool_approvals SET status = 'expired'
9089
+ WHERE ${sameCall} AND status = 'pending' AND expires_at <= $4`,
9090
+ params
9091
+ );
9092
+ for (let attempt = 0; attempt < 2; attempt++) {
9093
+ const found = await engine.pool.query(
9094
+ `SELECT * FROM context_engine_tool_approvals
9095
+ WHERE ${sameCall} AND status = 'pending' AND (expires_at IS NULL OR expires_at > $4)
9096
+ ORDER BY created_at ASC LIMIT 1`,
9097
+ params
9098
+ );
9099
+ if (found.rows[0]) return rowToRecord(found.rows[0]);
9100
+ try {
9101
+ return await insertPending(engine, opts, row);
9102
+ } catch (exc) {
9103
+ if (exc?.code !== "23505") throw exc;
9104
+ }
9105
+ }
9106
+ throw new Error("findOrCreatePending: lost the insert race twice and found no live pending row");
9107
+ }
8204
9108
  var DEFAULT_TIMEOUT_MINUTES, CONDITION_RE;
8205
9109
  var init_approval = __esm({
8206
9110
  "src/tools/approval.ts"() {
8207
9111
  init_errors();
9112
+ init_sentinels();
8208
9113
  init_acl();
8209
9114
  DEFAULT_TIMEOUT_MINUTES = 60;
8210
9115
  CONDITION_RE = /^\s*(?<field>[A-Za-z_][A-Za-z0-9_.]*)\s*(?<op>>=|<=|==|!=|>|<)\s*(?<value>.+?)\s*$/;
@@ -8315,8 +9220,48 @@ var init_audit = __esm({
8315
9220
  });
8316
9221
 
8317
9222
  // src/tools/config.ts
8318
- var KINDS, ToolConfig;
8319
- var init_config = __esm({
9223
+ function isPlainObject(value) {
9224
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9225
+ }
9226
+ function containsSentinel(value) {
9227
+ if (typeof value === "string") return value === REDACTED_SENTINEL;
9228
+ if (Array.isArray(value)) return value.some(containsSentinel);
9229
+ if (isPlainObject(value)) return Object.values(value).some(containsSentinel);
9230
+ return false;
9231
+ }
9232
+ function mergeRedacted(incoming, stored) {
9233
+ const resolve = (value, kept, keptPresent, path) => {
9234
+ if (isPlainObject(value)) {
9235
+ const keptDict = isPlainObject(kept) ? kept : {};
9236
+ const out2 = {};
9237
+ for (const [k, v] of Object.entries(value)) {
9238
+ out2[k] = resolve(v, keptDict[k], k in keptDict, path ? `${path}.${k}` : k);
9239
+ }
9240
+ return out2;
9241
+ }
9242
+ if (Array.isArray(value)) {
9243
+ const keptList = Array.isArray(kept) ? kept : [];
9244
+ return value.map((v, i) => resolve(v, keptList[i], i < keptList.length, `${path}[${i}]`));
9245
+ }
9246
+ if (value === REDACTED_SENTINEL) {
9247
+ if (!keptPresent) {
9248
+ throw new ConfigTemplateError(
9249
+ `config.${path} is ${JSON.stringify(REDACTED_SENTINEL)} but there is no stored value to keep \u2014 re-enter the secret or omit the field`
9250
+ );
9251
+ }
9252
+ return kept;
9253
+ }
9254
+ return value;
9255
+ };
9256
+ const keptRoot = stored ?? {};
9257
+ const out = {};
9258
+ for (const [k, v] of Object.entries(incoming)) {
9259
+ out[k] = resolve(v, keptRoot[k], k in keptRoot, k);
9260
+ }
9261
+ return out;
9262
+ }
9263
+ var KINDS, ToolConfig, REDACTED_SENTINEL, ConfigTemplateError;
9264
+ var init_config2 = __esm({
8320
9265
  "src/tools/config.ts"() {
8321
9266
  KINDS = ["http", "db", "mcp", "function"];
8322
9267
  ToolConfig = class _ToolConfig {
@@ -8330,6 +9275,10 @@ var init_config = __esm({
8330
9275
  requiresApproval;
8331
9276
  approvalPolicy;
8332
9277
  enabled;
9278
+ /** Engine-opaque user metadata (documents have the same column). Stored
9279
+ * and returned in CLEAR on the admin surface only — secrets go in
9280
+ * `config`, which is encrypted. */
9281
+ metaData;
8333
9282
  constructor(init) {
8334
9283
  this.id = init.id ?? null;
8335
9284
  this.name = init.name;
@@ -8341,6 +9290,7 @@ var init_config = __esm({
8341
9290
  this.requiresApproval = init.requiresApproval ?? init.requires_approval ?? false;
8342
9291
  this.approvalPolicy = init.approvalPolicy ?? init.approval_policy ?? {};
8343
9292
  this.enabled = init.enabled ?? true;
9293
+ this.metaData = init.metaData ?? init.meta_data ?? {};
8344
9294
  }
8345
9295
  static fromUnknown(body) {
8346
9296
  if (!body || typeof body !== "object" || Array.isArray(body)) {
@@ -8363,10 +9313,14 @@ var init_config = __esm({
8363
9313
  acl: b.acl ?? null,
8364
9314
  requiresApproval: Boolean(b.requiresApproval ?? b.requires_approval ?? false),
8365
9315
  approvalPolicy: b.approvalPolicy ?? b.approval_policy ?? {},
8366
- enabled: b.enabled === void 0 ? true : Boolean(b.enabled)
9316
+ enabled: b.enabled === void 0 ? true : Boolean(b.enabled),
9317
+ metaData: b.metaData ?? b.meta_data ?? {}
8367
9318
  });
8368
9319
  }
8369
9320
  };
9321
+ REDACTED_SENTINEL = "__redacted__";
9322
+ ConfigTemplateError = class extends Error {
9323
+ };
8370
9324
  }
8371
9325
  });
8372
9326
 
@@ -8376,6 +9330,198 @@ var init_crypto2 = __esm({
8376
9330
  init_crypto();
8377
9331
  }
8378
9332
  });
9333
+ function isRedirect(resp) {
9334
+ return resp.status >= 300 && resp.status < 400 || resp.type === "opaqueredirect";
9335
+ }
9336
+ function parseV4(text) {
9337
+ const parts = text.split(".");
9338
+ if (parts.length !== 4) return null;
9339
+ const out = new Uint8Array(4);
9340
+ for (let i = 0; i < 4; i++) {
9341
+ const part = parts[i];
9342
+ if (!/^\d{1,3}$/.test(part)) return null;
9343
+ const value = Number(part);
9344
+ if (value > 255) return null;
9345
+ out[i] = value;
9346
+ }
9347
+ return out;
9348
+ }
9349
+ function parseV6(text) {
9350
+ let body = text.split("%")[0] ?? "";
9351
+ const lastColon = body.lastIndexOf(":");
9352
+ if (lastColon < 0) return null;
9353
+ const tail = body.slice(lastColon + 1);
9354
+ if (tail.includes(".")) {
9355
+ const v4 = parseV4(tail);
9356
+ if (!v4) return null;
9357
+ const hi = (v4[0] << 8 | v4[1]).toString(16);
9358
+ const lo = (v4[2] << 8 | v4[3]).toString(16);
9359
+ body = `${body.slice(0, lastColon + 1)}${hi}:${lo}`;
9360
+ }
9361
+ const halves = body.split("::");
9362
+ if (halves.length > 2) return null;
9363
+ const head = halves[0] ? halves[0].split(":") : [];
9364
+ const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
9365
+ let groups;
9366
+ if (halves.length === 1) {
9367
+ if (head.length !== 8) return null;
9368
+ groups = head;
9369
+ } else {
9370
+ const missing = 8 - head.length - rest.length;
9371
+ if (missing < 0) return null;
9372
+ groups = [...head, ...Array(missing).fill("0"), ...rest];
9373
+ }
9374
+ const out = new Uint8Array(16);
9375
+ for (let i = 0; i < 8; i++) {
9376
+ const group = groups[i];
9377
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return null;
9378
+ const value = Number.parseInt(group, 16);
9379
+ out[2 * i] = value >> 8;
9380
+ out[2 * i + 1] = value & 255;
9381
+ }
9382
+ return out;
9383
+ }
9384
+ function parseAddress(text) {
9385
+ const family = isIP(text);
9386
+ if (family === 4) return parseV4(text);
9387
+ if (family === 6) return parseV6(text);
9388
+ return null;
9389
+ }
9390
+ function inNet(addr, net, prefix) {
9391
+ if (addr.length !== net.length) return false;
9392
+ const whole = prefix >> 3;
9393
+ for (let i = 0; i < whole; i++) if (addr[i] !== net[i]) return false;
9394
+ const bits = prefix & 7;
9395
+ if (bits === 0) return true;
9396
+ const mask = 255 << 8 - bits;
9397
+ return (addr[whole] & mask) === (net[whole] & mask);
9398
+ }
9399
+ function compile(table) {
9400
+ return table.map(([cidr, prefix]) => {
9401
+ const bytes = parseAddress(cidr);
9402
+ if (!bytes) throw new Error(`egress: unparseable network ${cidr}`);
9403
+ return [bytes, prefix];
9404
+ });
9405
+ }
9406
+ function embeddedV4(bytes) {
9407
+ if (inNet(bytes, MAPPED_V4[0], MAPPED_V4[1]) || inNet(bytes, NAT64[0], NAT64[1])) {
9408
+ return bytes.subarray(12, 16);
9409
+ }
9410
+ if (inNet(bytes, SIXTOFOUR[0], SIXTOFOUR[1])) return bytes.subarray(2, 6);
9411
+ return null;
9412
+ }
9413
+ function addressIsPrivate(address) {
9414
+ const bytes = parseAddress(address);
9415
+ if (!bytes) return true;
9416
+ if (bytes.length === 4) return V4_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
9417
+ const embedded = embeddedV4(bytes);
9418
+ if (embedded && V4_NETS.some(([net, prefix]) => inNet(embedded, net, prefix))) return true;
9419
+ return V6_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
9420
+ }
9421
+ async function isPrivateAddress(host) {
9422
+ let name = (host ?? "").trim().toLowerCase().replace(/^\.+|\.+$/g, "");
9423
+ if (!name) return true;
9424
+ if (name === "localhost") return true;
9425
+ if (name.startsWith("[") && name.endsWith("]")) name = name.slice(1, -1);
9426
+ if (isIP(name)) return addressIsPrivate(name);
9427
+ let addresses;
9428
+ try {
9429
+ addresses = await resolver.lookup(name);
9430
+ } catch {
9431
+ return true;
9432
+ }
9433
+ if (!addresses.length) return true;
9434
+ return addresses.some((address) => addressIsPrivate(address.split("%")[0] ?? address));
9435
+ }
9436
+ async function assertEgressAllowed(url, opts) {
9437
+ let parsed;
9438
+ try {
9439
+ parsed = new URL(url ?? "");
9440
+ } catch {
9441
+ throw new EgressDenied(`egress denied: ${JSON.stringify(url)} is not a valid URL`);
9442
+ }
9443
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
9444
+ if (scheme !== "http" && scheme !== "https") {
9445
+ throw new EgressDenied(
9446
+ `egress denied: unsupported URL scheme ${JSON.stringify(scheme)} \u2014 http tools may only reach http/https`
9447
+ );
9448
+ }
9449
+ if (opts.allowPrivate) return;
9450
+ const host = parsed.hostname;
9451
+ if (await isPrivateAddress(host)) {
9452
+ throw new EgressDenied(
9453
+ `egress denied: ${host || "(no host)"} is a private, loopback, link-local or metadata address; set allowPrivateEgress to permit it`
9454
+ );
9455
+ }
9456
+ }
9457
+ var MAX_RESPONSE_BYTES, MAX_REDIRECTS, EgressDenied, resolver, V4_PRIVATE, V6_PRIVATE, V4_NETS, V6_NETS, MAPPED_V4, NAT64, SIXTOFOUR;
9458
+ var init_egress = __esm({
9459
+ "src/tools/egress.ts"() {
9460
+ MAX_RESPONSE_BYTES = 1e6;
9461
+ MAX_REDIRECTS = 5;
9462
+ EgressDenied = class extends Error {
9463
+ constructor(message) {
9464
+ super(message);
9465
+ this.name = "EgressDenied";
9466
+ }
9467
+ };
9468
+ resolver = {
9469
+ async lookup(host) {
9470
+ const answers = await promises.lookup(host, { all: true, verbatim: true });
9471
+ return answers.map((a) => a.address);
9472
+ }
9473
+ };
9474
+ V4_PRIVATE = [
9475
+ ["0.0.0.0", 8],
9476
+ // "this network" / unspecified
9477
+ ["10.0.0.0", 8],
9478
+ // RFC 1918
9479
+ ["100.64.0.0", 10],
9480
+ // RFC 6598 carrier-grade NAT — overlay VPNs, pod CIDRs
9481
+ ["127.0.0.0", 8],
9482
+ // loopback
9483
+ ["169.254.0.0", 16],
9484
+ // link-local, incl. the cloud metadata address
9485
+ ["172.16.0.0", 12],
9486
+ // RFC 1918
9487
+ ["192.168.0.0", 16],
9488
+ // RFC 1918
9489
+ ["224.0.0.0", 4],
9490
+ // multicast
9491
+ ["240.0.0.0", 4],
9492
+ // reserved
9493
+ ["255.255.255.255", 32]
9494
+ // broadcast (inside 240/4; spelled out anyway)
9495
+ ];
9496
+ V6_PRIVATE = [
9497
+ ["::", 128],
9498
+ // unspecified
9499
+ ["::1", 128],
9500
+ // loopback
9501
+ ["::ffff:0:0", 96],
9502
+ // IPv4-mapped — the wrapped v4 is checked too
9503
+ ["64:ff9b::", 96],
9504
+ // NAT64 — the wrapped v4 is checked too
9505
+ ["2002::", 16],
9506
+ // 6to4 — the wrapped v4 is checked too, and this is denied
9507
+ ["fc00::", 7],
9508
+ // unique local
9509
+ ["fe80::", 10],
9510
+ // link-local
9511
+ ["fec0::", 10],
9512
+ // site-local (deprecated, still configured)
9513
+ ["ff00::", 8],
9514
+ // multicast
9515
+ ["3fff::", 20]
9516
+ // documentation
9517
+ ];
9518
+ V4_NETS = compile(V4_PRIVATE);
9519
+ V6_NETS = compile(V6_PRIVATE);
9520
+ MAPPED_V4 = compile([["::ffff:0:0", 96]])[0];
9521
+ NAT64 = compile([["64:ff9b::", 96]])[0];
9522
+ SIXTOFOUR = compile([["2002::", 16]])[0];
9523
+ }
9524
+ });
8379
9525
 
8380
9526
  // src/tools/executors/db.ts
8381
9527
  var db_exports = {};
@@ -8392,6 +9538,9 @@ __export(db_exports, {
8392
9538
  function stripSqlNoise(sql) {
8393
9539
  return sql.replace(SQL_NOISE_RE, (m) => " ".repeat(m.length));
8394
9540
  }
9541
+ function stripSqlNoiseBackslash(sql) {
9542
+ return sql.replace(SQL_NOISE_BACKSLASH_RE, (m) => " ".repeat(m.length));
9543
+ }
8395
9544
  function hasKeyword(sqlUpper, keyword) {
8396
9545
  return new RegExp(`\\b${keyword}\\b`).test(sqlUpper);
8397
9546
  }
@@ -8520,24 +9669,28 @@ async function executeQueryAsync(config, sql, maxRows, accessModeRaw) {
8520
9669
  `Invalid access_mode ${JSON.stringify(accessModeRaw)}; must be one of ${ALLOWED_ACCESS_MODES}`
8521
9670
  );
8522
9671
  }
8523
- const sqlUpper = stripSqlNoise(sql).trim().toUpperCase();
9672
+ const sqlVariants = [
9673
+ stripSqlNoise(sql).trim().toUpperCase(),
9674
+ stripSqlNoiseBackslash(sql).trim().toUpperCase()
9675
+ ];
9676
+ const sqlUpper = sqlVariants[0];
8524
9677
  for (const keyword of ALWAYS_BLOCKED) {
8525
- if (hasKeyword(sqlUpper, keyword)) {
9678
+ if ([...sqlVariants, sql.toUpperCase()].some((v) => hasKeyword(v, keyword))) {
8526
9679
  return { success: false, error: `${keyword} queries are not allowed` };
8527
9680
  }
8528
9681
  }
8529
9682
  if (accessMode === "readonly") {
8530
- if (!sqlUpper.startsWith("SELECT") && !sqlUpper.startsWith("WITH")) {
9683
+ if (!sqlVariants.every((v) => v.startsWith("SELECT") || v.startsWith("WITH"))) {
8531
9684
  return { success: false, error: "Only SELECT queries are allowed in read-only mode" };
8532
9685
  }
8533
9686
  for (const keyword of READONLY_BLOCKED) {
8534
- if (hasKeyword(sqlUpper, keyword)) {
9687
+ if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
8535
9688
  return { success: false, error: `${keyword} queries are not allowed in read-only mode` };
8536
9689
  }
8537
9690
  }
8538
9691
  } else if (accessMode === "readwrite") {
8539
9692
  for (const keyword of READWRITE_BLOCKED) {
8540
- if (hasKeyword(sqlUpper, keyword)) {
9693
+ if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
8541
9694
  return { success: false, error: `${keyword} queries are not allowed in read-write mode` };
8542
9695
  }
8543
9696
  }
@@ -8671,7 +9824,7 @@ async function getSchemaText(config, selectedTables) {
8671
9824
  }
8672
9825
  return rowsToText(schemaRows);
8673
9826
  }
8674
- var require2, MAX_ROWS, CONNECT_TIMEOUT, QUERY_TIMEOUT_MS, ALWAYS_BLOCKED, READONLY_BLOCKED, READWRITE_BLOCKED, ALLOWED_ACCESS_MODES, SQL_NOISE_RE;
9827
+ 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;
8675
9828
  var init_db2 = __esm({
8676
9829
  "src/tools/executors/db.ts"() {
8677
9830
  init_errors();
@@ -8680,10 +9833,22 @@ var init_db2 = __esm({
8680
9833
  CONNECT_TIMEOUT = 10;
8681
9834
  QUERY_TIMEOUT_MS = 3e4;
8682
9835
  ALWAYS_BLOCKED = ["GRANT", "REVOKE"];
8683
- READONLY_BLOCKED = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE"];
8684
- READWRITE_BLOCKED = ["DELETE", "DROP", "TRUNCATE"];
9836
+ READONLY_BLOCKED = [
9837
+ "INSERT",
9838
+ "UPDATE",
9839
+ "DELETE",
9840
+ "MERGE",
9841
+ "DROP",
9842
+ "ALTER",
9843
+ "CREATE",
9844
+ "TRUNCATE",
9845
+ "DO",
9846
+ "CALL"
9847
+ ];
9848
+ READWRITE_BLOCKED = ["DELETE", "DROP", "TRUNCATE", "DO", "CALL"];
8685
9849
  ALLOWED_ACCESS_MODES = ["readonly", "readwrite", "full"];
8686
- SQL_NOISE_RE = /'(?:[^']|'')*'|\$([A-Za-z_]\w*)?\$.*?\$\1?\$|--[^\n]*|\/\*[\s\S]*?\*\//g;
9850
+ SQL_NOISE_RE = /\b[eE]'(?:[^'\\]|\\[\s\S]|'')*'|'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
9851
+ SQL_NOISE_BACKSLASH_RE = /'(?:[^'\\]|\\[\s\S]|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
8687
9852
  }
8688
9853
  });
8689
9854
 
@@ -8718,10 +9883,38 @@ function withQuery(url, params) {
8718
9883
  if (!q) return url;
8719
9884
  return url.includes("?") ? `${url}&${q}` : `${url}?${q}`;
8720
9885
  }
9886
+ async function readCapped(resp) {
9887
+ if (!resp.body) return { text: await resp.text(), truncated: false };
9888
+ const reader = resp.body.getReader();
9889
+ const chunks = [];
9890
+ let size = 0;
9891
+ let truncated = false;
9892
+ for (; ; ) {
9893
+ const { done, value } = await reader.read();
9894
+ if (done) break;
9895
+ if (value) {
9896
+ chunks.push(Buffer.from(value));
9897
+ size += value.byteLength;
9898
+ }
9899
+ if (size > MAX_RESPONSE_BYTES) {
9900
+ truncated = true;
9901
+ await reader.cancel();
9902
+ break;
9903
+ }
9904
+ }
9905
+ const bytes = Buffer.concat(chunks);
9906
+ return {
9907
+ text: (truncated ? bytes.subarray(0, MAX_RESPONSE_BYTES) : bytes).toString("utf8"),
9908
+ truncated
9909
+ };
9910
+ }
8721
9911
  async function defaultRequest(init) {
8722
9912
  let url = init.url;
8723
9913
  if (init.params) url = withQuery(url, init.params);
8724
9914
  const headers = { ...init.headers ?? {} };
9915
+ if (!Object.keys(headers).some((k) => k.toLowerCase() === "accept-encoding")) {
9916
+ headers["Accept-Encoding"] = "identity";
9917
+ }
8725
9918
  let body;
8726
9919
  if (init.json !== void 0) {
8727
9920
  headers["Content-Type"] = headers["Content-Type"] ?? headers["content-type"] ?? "application/json";
@@ -8733,25 +9926,47 @@ async function defaultRequest(init) {
8733
9926
  method: init.method,
8734
9927
  headers,
8735
9928
  body,
8736
- redirect: "follow",
9929
+ // Redirects are followed by hand in `executeHttp` — an automatic follow
9930
+ // would jump to a destination no egress check ever saw.
9931
+ redirect: "manual",
8737
9932
  signal: AbortSignal.timeout(TIMEOUT_MS5)
8738
9933
  });
8739
9934
  const respHeaders = {};
8740
9935
  resp.headers.forEach((v, k) => {
8741
9936
  respHeaders[k] = v;
8742
9937
  });
8743
- const text = await resp.text();
9938
+ const { text, truncated } = await readCapped(resp);
8744
9939
  return {
8745
9940
  status: resp.status,
8746
9941
  headers: respHeaders,
9942
+ truncated,
8747
9943
  async json() {
8748
- return JSON.parse(text);
9944
+ return JSON.parse(text.replace(/^/, ""));
8749
9945
  },
8750
9946
  async text() {
8751
9947
  return text;
8752
9948
  }
8753
9949
  };
8754
9950
  }
9951
+ function nextHop(init, status, location) {
9952
+ const current = new URL(init.url);
9953
+ const target = new URL(location, current);
9954
+ let method = init.method;
9955
+ if ((status === 302 || status === 303) && method !== "HEAD") method = "GET";
9956
+ else if (status === 301 && method === "POST") method = "GET";
9957
+ let headers = { ...init.headers ?? {} };
9958
+ if (current.origin !== target.origin) {
9959
+ headers = Object.fromEntries(
9960
+ Object.entries(headers).filter(([k]) => !CROSS_ORIGIN_HEADERS.has(k.toLowerCase()))
9961
+ );
9962
+ }
9963
+ const hop = { method, url: target.toString(), headers };
9964
+ if (method === init.method) {
9965
+ if (init.json !== void 0) hop.json = init.json;
9966
+ if (init.content !== void 0) hop.content = init.content;
9967
+ }
9968
+ return hop;
9969
+ }
8755
9970
  async function executeHttp(config, args, opts = {}) {
8756
9971
  const method = String(config.method ?? "GET").toUpperCase();
8757
9972
  let url = config.url;
@@ -8772,7 +9987,7 @@ async function executeHttp(config, args, opts = {}) {
8772
9987
  }
8773
9988
  }
8774
9989
  }
8775
- const requestInit = {
9990
+ let requestInit = {
8776
9991
  method,
8777
9992
  url: url ?? "",
8778
9993
  headers
@@ -8808,27 +10023,56 @@ async function executeHttp(config, args, opts = {}) {
8808
10023
  }
8809
10024
  }
8810
10025
  const client = opts.client;
8811
- const resp = client ? await client.request(requestInit) : await defaultRequest(requestInit);
10026
+ const allowPrivate = opts.allowPrivate ?? false;
10027
+ let resp;
10028
+ let hops = 0;
10029
+ for (; ; ) {
10030
+ await assertEgressAllowed(requestInit.url, { allowPrivate });
10031
+ resp = client ? await client.request(requestInit) : await defaultRequest(requestInit);
10032
+ const location = resp.headers.location ?? resp.headers.Location;
10033
+ if (!REDIRECT_STATUS.has(resp.status) || !location) break;
10034
+ if (hops >= MAX_REDIRECTS) {
10035
+ throw new EngineActionError(`too many redirects (more than ${MAX_REDIRECTS}) starting at ${url}`);
10036
+ }
10037
+ hops += 1;
10038
+ requestInit = nextHop(requestInit, resp.status, location);
10039
+ }
8812
10040
  const contentType = resp.headers["content-type"] ?? resp.headers["Content-Type"] ?? "";
8813
- let data;
10041
+ let text = "";
8814
10042
  try {
8815
- data = contentType.includes("application/json") ? await resp.json() : await resp.text();
10043
+ text = await resp.text();
8816
10044
  } catch {
8817
- data = await resp.text();
10045
+ text = "";
10046
+ }
10047
+ const bytes = Buffer.from(text, "utf8");
10048
+ const truncated = resp.truncated === true || bytes.byteLength > MAX_RESPONSE_BYTES;
10049
+ let data;
10050
+ if (truncated) {
10051
+ data = bytes.subarray(0, MAX_RESPONSE_BYTES).toString("utf8");
10052
+ } else {
10053
+ try {
10054
+ data = contentType.includes("application/json") ? await resp.json() : text;
10055
+ } catch {
10056
+ data = text;
10057
+ }
8818
10058
  }
8819
10059
  const safeHeaders = {};
8820
10060
  for (const [k, v] of Object.entries(resp.headers)) {
8821
10061
  if (!SENSITIVE_RESPONSE_HEADERS.has(k.toLowerCase())) safeHeaders[k] = v;
8822
10062
  }
8823
- return {
10063
+ const result = {
8824
10064
  status_code: resp.status,
8825
10065
  headers: safeHeaders,
8826
10066
  data
8827
10067
  };
10068
+ if (truncated) result.truncated = true;
10069
+ return result;
8828
10070
  }
8829
- var TIMEOUT_MS5, SENSITIVE_RESPONSE_HEADERS;
10071
+ var TIMEOUT_MS5, SENSITIVE_RESPONSE_HEADERS, REDIRECT_STATUS, CROSS_ORIGIN_HEADERS;
8830
10072
  var init_http = __esm({
8831
10073
  "src/tools/executors/http.ts"() {
10074
+ init_errors();
10075
+ init_egress();
8832
10076
  TIMEOUT_MS5 = 3e4;
8833
10077
  SENSITIVE_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
8834
10078
  "set-cookie",
@@ -8837,10 +10081,19 @@ var init_http = __esm({
8837
10081
  "proxy-authenticate",
8838
10082
  "www-authenticate"
8839
10083
  ]);
10084
+ REDIRECT_STATUS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
10085
+ CROSS_ORIGIN_HEADERS = /* @__PURE__ */ new Set(["authorization", "proxy-authorization", "cookie"]);
8840
10086
  }
8841
10087
  });
8842
10088
 
8843
10089
  // src/tools/executors/mcp-client.ts
10090
+ async function checkEgress(url, allowPrivate) {
10091
+ let checked = url;
10092
+ const scheme = (/^([a-z0-9+.-]+):/i.exec(url ?? "")?.[1] ?? "").toLowerCase();
10093
+ if (scheme === "ws") checked = `http:${url.slice(scheme.length + 1)}`;
10094
+ else if (scheme === "wss") checked = `https:${url.slice(scheme.length + 1)}`;
10095
+ await assertEgressAllowed(checked, { allowPrivate });
10096
+ }
8844
10097
  function parseSseBuffer(raw) {
8845
10098
  const dataLines = raw.split("\n").filter((ln) => ln.startsWith("data:")).map((ln) => ln.slice(5).trimStart());
8846
10099
  if (!dataLines.length) return null;
@@ -8853,10 +10106,12 @@ function parseSseBuffer(raw) {
8853
10106
  }
8854
10107
  return null;
8855
10108
  }
8856
- var MCPError, HttpTransport, SseTransport, WsTransport, MCPClient, MCPRegistry, PromptevMCP;
10109
+ var REDIRECT_MESSAGE, MCPError, HttpTransport, SseTransport, WsTransport, MCPClient, MCPRegistry, PromptevMCP;
8857
10110
  var init_mcp_client = __esm({
8858
10111
  "src/tools/executors/mcp-client.ts"() {
8859
10112
  init_version();
10113
+ init_egress();
10114
+ REDIRECT_MESSAGE = "MCP server redirected; register the final URL";
8860
10115
  MCPError = class _MCPError extends Error {
8861
10116
  code;
8862
10117
  data;
@@ -8873,10 +10128,12 @@ var init_mcp_client = __esm({
8873
10128
  HttpTransport = class {
8874
10129
  kind = "http";
8875
10130
  url;
10131
+ allowPrivate;
8876
10132
  baseHeaders;
8877
10133
  sessionId = null;
8878
- constructor(url, headers) {
10134
+ constructor(url, headers, allowPrivate = false) {
8879
10135
  this.url = url;
10136
+ this.allowPrivate = allowPrivate;
8880
10137
  this.baseHeaders = {
8881
10138
  ...headers,
8882
10139
  "Content-Type": "application/json",
@@ -8884,6 +10141,7 @@ var init_mcp_client = __esm({
8884
10141
  };
8885
10142
  }
8886
10143
  async connect() {
10144
+ await checkEgress(this.url, this.allowPrivate);
8887
10145
  }
8888
10146
  async close() {
8889
10147
  this.sessionId = null;
@@ -8897,10 +10155,17 @@ var init_mcp_client = __esm({
8897
10155
  const resp = await fetch(this.url, {
8898
10156
  method: "POST",
8899
10157
  headers: this.buildHeaders(),
8900
- body: JSON.stringify(msg)
10158
+ body: JSON.stringify(msg),
10159
+ // `redirect: "manual"`: a followed redirect would carry this POST —
10160
+ // headers, Authorization and all — to a destination `connect()`'s
10161
+ // check never saw. A 3xx is reported, never chased.
10162
+ redirect: "manual"
8901
10163
  });
8902
10164
  const sid = resp.headers.get("Mcp-Session-Id");
8903
10165
  if (sid) this.sessionId = sid;
10166
+ if (isRedirect(resp)) {
10167
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
10168
+ }
8904
10169
  if (resp.status >= 400) {
8905
10170
  const body = await resp.text();
8906
10171
  console.error(`[HTTP Transport] ${resp.status} from ${this.url}: ${body.slice(0, 300)}`);
@@ -8933,11 +10198,20 @@ var init_mcp_client = __esm({
8933
10198
  eventsUrl;
8934
10199
  baseUrl;
8935
10200
  headers;
10201
+ allowPrivate;
10202
+ /** Where the server told us to POST — it arrives in an `endpoint` event on
10203
+ * the stream, so it is server-chosen and hence checked. Private: the only
10204
+ * way in is the parser, which is the path a real server takes. */
8936
10205
  postUrl = null;
10206
+ /** The last POST endpoint cleared by the egress check. The check costs a
10207
+ * DNS resolution and the endpoint changes at most once per connection, so
10208
+ * it is not repeated per message. */
10209
+ checkedPostUrl = null;
8937
10210
  queue = [];
8938
10211
  waiters = [];
8939
10212
  abort = null;
8940
- constructor(url, headers) {
10213
+ constructor(url, headers, allowPrivate = false) {
10214
+ this.allowPrivate = allowPrivate;
8941
10215
  this.eventsUrl = url.replace(/\/$/, "");
8942
10216
  if (url.endsWith("/events")) this.baseUrl = url.slice(0, -7);
8943
10217
  else if (url.endsWith("/sse")) this.baseUrl = url.slice(0, -4);
@@ -8950,14 +10224,19 @@ var init_mcp_client = __esm({
8950
10224
  else this.queue.push(msg);
8951
10225
  }
8952
10226
  async connect() {
10227
+ await checkEgress(this.eventsUrl, this.allowPrivate);
8953
10228
  this.abort = new AbortController();
8954
10229
  void this.listen();
8955
10230
  }
8956
10231
  async listen() {
8957
10232
  const resp = await fetch(this.eventsUrl, {
8958
10233
  headers: { ...this.headers, Accept: "text/event-stream" },
8959
- signal: this.abort?.signal
10234
+ signal: this.abort?.signal,
10235
+ redirect: "manual"
8960
10236
  });
10237
+ if (isRedirect(resp)) {
10238
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
10239
+ }
8961
10240
  if (!resp.ok) throw new Error(`SSE ${resp.status}`);
8962
10241
  if (!resp.body) throw new Error("SSE response has no body");
8963
10242
  const reader = resp.body.getReader();
@@ -9009,11 +10288,19 @@ var init_mcp_client = __esm({
9009
10288
  }
9010
10289
  }
9011
10290
  const postUrl = this.postUrl || this.baseUrl;
10291
+ if (postUrl !== this.checkedPostUrl) {
10292
+ await checkEgress(postUrl, this.allowPrivate);
10293
+ this.checkedPostUrl = postUrl;
10294
+ }
9012
10295
  const resp = await fetch(postUrl, {
9013
10296
  method: "POST",
9014
10297
  headers: { ...this.headers, "Content-Type": "application/json" },
9015
- body: JSON.stringify(msg)
10298
+ body: JSON.stringify(msg),
10299
+ redirect: "manual"
9016
10300
  });
10301
+ if (isRedirect(resp)) {
10302
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
10303
+ }
9017
10304
  if (!resp.ok) throw new Error(`SSE POST ${resp.status}`);
9018
10305
  try {
9019
10306
  const data = await resp.json();
@@ -9032,14 +10319,17 @@ var init_mcp_client = __esm({
9032
10319
  kind = "websocket";
9033
10320
  url;
9034
10321
  headers;
10322
+ allowPrivate;
9035
10323
  ws = null;
9036
10324
  queue = [];
9037
10325
  waiters = [];
9038
- constructor(url, headers) {
10326
+ constructor(url, headers, allowPrivate = false) {
9039
10327
  this.url = url;
10328
+ this.allowPrivate = allowPrivate;
9040
10329
  this.headers = headers;
9041
10330
  }
9042
10331
  async connect() {
10332
+ await checkEgress(this.url, this.allowPrivate);
9043
10333
  const WS = globalThis.WebSocket;
9044
10334
  if (!WS) throw new Error("WebSocket is not available in this runtime");
9045
10335
  this.ws = new WS(this.url);
@@ -9080,21 +10370,27 @@ var init_mcp_client = __esm({
9080
10370
  tools = [];
9081
10371
  resources = [];
9082
10372
  headers;
9083
- constructor(url, headers = null) {
10373
+ allowPrivate;
10374
+ constructor(url, headers = null, opts = {}) {
9084
10375
  this.url = url;
9085
10376
  this.headers = headers ?? {};
9086
- this.transport = _MCPClient.createTransport(url, this.headers);
10377
+ this.allowPrivate = opts.allowPrivate ?? false;
10378
+ this.transport = _MCPClient.createTransport(url, this.headers, this.allowPrivate);
9087
10379
  }
9088
- static createTransport(url, headers) {
10380
+ static createTransport(url, headers, allowPrivate = false) {
9089
10381
  if (url.startsWith("stdio:") || url === "stdio") {
9090
10382
  throw new Error("stdio MCP transport is not supported (cloud deployments use SSE/HTTP/WebSocket)");
9091
10383
  }
9092
- if (url.startsWith("ws://") || url.startsWith("wss://")) return new WsTransport(url, headers);
10384
+ if (url.startsWith("ws://") || url.startsWith("wss://")) {
10385
+ return new WsTransport(url, headers, allowPrivate);
10386
+ }
9093
10387
  if (url.startsWith("sse+http://") || url.startsWith("sse+https://") || url.endsWith("/events") || url.endsWith("/sse")) {
9094
10388
  const clean = url.replace("sse+http://", "http://").replace("sse+https://", "https://");
9095
- return new SseTransport(clean, headers);
10389
+ return new SseTransport(clean, headers, allowPrivate);
10390
+ }
10391
+ if (url.startsWith("http://") || url.startsWith("https://")) {
10392
+ return new HttpTransport(url, headers, allowPrivate);
9096
10393
  }
9097
- if (url.startsWith("http://") || url.startsWith("https://")) return new HttpTransport(url, headers);
9098
10394
  throw new Error(`Unsupported URL: ${url} (use http(s)://, ws(s)://, or .../sse)`);
9099
10395
  }
9100
10396
  nextId() {
@@ -9146,7 +10442,15 @@ var init_mcp_client = __esm({
9146
10442
  await this.transport.send({ jsonrpc: "2.0", method, params: params ?? {} });
9147
10443
  }
9148
10444
  async connect() {
9149
- await this.transport.connect();
10445
+ try {
10446
+ await this.transport.connect();
10447
+ } catch (e) {
10448
+ try {
10449
+ await this.transport.close();
10450
+ } catch {
10451
+ }
10452
+ throw e;
10453
+ }
9150
10454
  if (!(this.transport instanceof HttpTransport)) {
9151
10455
  this.connected = true;
9152
10456
  void this.recvLoop();
@@ -9229,7 +10533,7 @@ var init_mcp_client = __esm({
9229
10533
  }
9230
10534
  this.id = 0;
9231
10535
  this.pending.clear();
9232
- this.transport = _MCPClient.createTransport(url, headers);
10536
+ this.transport = _MCPClient.createTransport(url, headers, this.allowPrivate);
9233
10537
  await this.connect();
9234
10538
  await this.listTools();
9235
10539
  }
@@ -9237,11 +10541,11 @@ var init_mcp_client = __esm({
9237
10541
  MCPRegistry = class {
9238
10542
  clients = /* @__PURE__ */ new Map();
9239
10543
  healthTask = null;
9240
- async connect(name, url, headers = null, token = null) {
10544
+ async connect(name, url, headers = null, token = null, allowPrivate = false) {
9241
10545
  this.clients.delete(name);
9242
10546
  const hdrs = { ...headers ?? {} };
9243
10547
  if (token) hdrs.Authorization = hdrs.Authorization ?? `Bearer ${token}`;
9244
- const client = new MCPClient(url, hdrs);
10548
+ const client = new MCPClient(url, hdrs, { allowPrivate });
9245
10549
  await client.connect();
9246
10550
  await client.listTools();
9247
10551
  this.clients.set(name, client);
@@ -9293,8 +10597,18 @@ var init_mcp_client = __esm({
9293
10597
  };
9294
10598
  PromptevMCP = class {
9295
10599
  clients = new MCPRegistry();
10600
+ allowPrivate;
10601
+ /**
10602
+ * `allowPrivate` is the operator knob `allowPrivateEgress`, carried down to
10603
+ * every transport this facade builds. It defaults to `false`, so a
10604
+ * construction site that forgets to pass it denies private destinations
10605
+ * rather than permitting them.
10606
+ */
10607
+ constructor(opts = {}) {
10608
+ this.allowPrivate = opts.allowPrivate ?? false;
10609
+ }
9296
10610
  async addServer(name, url, opts = {}) {
9297
- await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null);
10611
+ await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null, this.allowPrivate);
9298
10612
  }
9299
10613
  async shutdown() {
9300
10614
  await this.clients.shutdown();
@@ -9419,6 +10733,9 @@ var init_registry = __esm({
9419
10733
  "src/tools/registry.ts"() {
9420
10734
  }
9421
10735
  });
10736
+ function allowPrivateEgress(engine) {
10737
+ return Boolean(engine?.config?.allowPrivateEgress);
10738
+ }
9422
10739
  function toolVisible(ct, principals) {
9423
10740
  return aclVisible(ct.acl, principals);
9424
10741
  }
@@ -9500,11 +10817,19 @@ function redactToolResult(result, policy, opts) {
9500
10817
  if (failed.length) note.rules_failed = failed;
9501
10818
  return [redacted, note];
9502
10819
  }
9503
- function rowToToolConfig(row, engine) {
9504
- let config = {};
9505
- if (row.config_encrypted) {
9506
- config = decryptDict(String(row.config_encrypted), getSecretKey(engine.config));
10820
+ function cryptoKey(engine) {
10821
+ const key = getSecretKey(engine.config);
10822
+ if (key.length !== 32) {
10823
+ throw new Error(`CE_SECRET_KEY is malformed: decoded to ${key.length} bytes, need 32`);
9507
10824
  }
10825
+ return key;
10826
+ }
10827
+ function decryptRowConfig(row, engine) {
10828
+ if (!row.config_encrypted) return {};
10829
+ return decryptDict(String(row.config_encrypted), cryptoKey(engine));
10830
+ }
10831
+ function rowToToolConfig(row, engine, config) {
10832
+ config ??= decryptRowConfig(row, engine);
9508
10833
  return new ToolConfig({
9509
10834
  id: String(row.id),
9510
10835
  name: String(row.name),
@@ -9515,9 +10840,24 @@ function rowToToolConfig(row, engine) {
9515
10840
  acl: row.acl != null ? [...row.acl] : null,
9516
10841
  requiresApproval: Boolean(row.requires_approval),
9517
10842
  approvalPolicy: row.approval_policy ?? {},
9518
- enabled: Boolean(row.enabled)
10843
+ enabled: Boolean(row.enabled),
10844
+ metaData: row.meta_data ?? {}
9519
10845
  });
9520
10846
  }
10847
+ function rowToToolConfigLenient(row, engine) {
10848
+ if (!row.config_encrypted) return rowToToolConfig(row, engine, {});
10849
+ const key = cryptoKey(engine);
10850
+ let config;
10851
+ try {
10852
+ config = decryptDict(String(row.config_encrypted), key);
10853
+ } catch {
10854
+ console.warn(
10855
+ `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`
10856
+ );
10857
+ return null;
10858
+ }
10859
+ return rowToToolConfig(row, engine, config);
10860
+ }
9521
10861
  async function loadPersistedCanonicals(engine, sourceId) {
9522
10862
  const params = [];
9523
10863
  let sql = `SELECT * FROM context_engine_tools WHERE enabled IS TRUE`;
@@ -9528,7 +10868,9 @@ async function loadPersistedCanonicals(engine, sourceId) {
9528
10868
  const result = await engine.pool.query(sql, params);
9529
10869
  const canonicals = [];
9530
10870
  for (const row of result.rows) {
9531
- canonicals.push(...canonicalFromConfig(rowToToolConfig(row, engine)));
10871
+ const tc = rowToToolConfigLenient(row, engine);
10872
+ if (tc === null) continue;
10873
+ canonicals.push(...canonicalFromConfig(tc));
9532
10874
  }
9533
10875
  return canonicals;
9534
10876
  }
@@ -9548,13 +10890,18 @@ function canonicalToPublic(ct) {
9548
10890
  async function registerTool(engine, tc) {
9549
10891
  canonicalFromConfig(tc);
9550
10892
  const config = tc.config ?? {};
9551
- const configEncrypted = Object.keys(config).length ? encryptDict(config, getSecretKey(engine.config)) : null;
10893
+ if (containsSentinel(config)) {
10894
+ throw new ConfigTemplateError(
10895
+ `config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a redacted template cannot be registered as a new tool; re-enter the secret values`
10896
+ );
10897
+ }
10898
+ const configEncrypted = Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null;
9552
10899
  const id = randomUUID();
9553
10900
  await engine.pool.query(
9554
10901
  `INSERT INTO context_engine_tools
9555
10902
  (id, name, kind, description, source_id, acl, config_encrypted,
9556
- requires_approval, approval_policy, enabled)
9557
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
10903
+ requires_approval, approval_policy, enabled, meta_data)
10904
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11::jsonb)`,
9558
10905
  [
9559
10906
  id,
9560
10907
  tc.name,
@@ -9565,7 +10912,8 @@ async function registerTool(engine, tc) {
9565
10912
  configEncrypted,
9566
10913
  tc.requiresApproval,
9567
10914
  JSON.stringify(tc.approvalPolicy ?? {}),
9568
- tc.enabled
10915
+ tc.enabled,
10916
+ JSON.stringify(tc.metaData ?? {})
9569
10917
  ]
9570
10918
  );
9571
10919
  return id;
@@ -9582,12 +10930,41 @@ async function updateTool(engine, id, opts) {
9582
10930
  let i = 1;
9583
10931
  for (const [key, value] of Object.entries(fields)) {
9584
10932
  if (key === "config") {
10933
+ let config = value;
10934
+ if (config && containsSentinel(config)) {
10935
+ if (Object.hasOwn(fields, "kind") && fields.kind !== row.kind) {
10936
+ throw new ConfigTemplateError(
10937
+ "cannot change kind and keep redacted secrets in one PATCH \u2014 re-enter the config in full"
10938
+ );
10939
+ }
10940
+ if (config.body_secret === false && containsSentinel(config.body)) {
10941
+ throw new ConfigTemplateError(
10942
+ `body_secret cannot be turned off while the body still contains ${JSON.stringify(REDACTED_SENTINEL)} \u2014 re-enter the body to declassify it`
10943
+ );
10944
+ }
10945
+ if (!row.config_encrypted) {
10946
+ throw new ConfigTemplateError(
10947
+ `config contains ${JSON.stringify(REDACTED_SENTINEL)} but this tool has no stored config to keep \u2014 re-enter the secret values`
10948
+ );
10949
+ }
10950
+ const key2 = cryptoKey(engine);
10951
+ let stored;
10952
+ try {
10953
+ stored = decryptDict(String(row.config_encrypted), key2);
10954
+ } catch (exc) {
10955
+ throw new ConfigTemplateError(
10956
+ `the stored config cannot be decrypted (was the secret key rotated?) \u2014 re-enter the config in full, without ${JSON.stringify(REDACTED_SENTINEL)} values`,
10957
+ { cause: exc }
10958
+ );
10959
+ }
10960
+ config = mergeRedacted(config, stored);
10961
+ }
9585
10962
  sets.push(`config_encrypted = $${i++}`);
9586
- params.push(value ? encryptDict(value, getSecretKey(engine.config)) : null);
10963
+ params.push(config && Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null);
9587
10964
  } else if (UPDATABLE_COLUMNS.has(key)) {
9588
- const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key;
10965
+ const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key === "metaData" ? "meta_data" : key;
9589
10966
  sets.push(`${col} = $${i++}`);
9590
- params.push(col === "approval_policy" ? JSON.stringify(value ?? {}) : value);
10967
+ params.push(col === "approval_policy" || col === "meta_data" ? JSON.stringify(value ?? {}) : value);
9591
10968
  }
9592
10969
  }
9593
10970
  sets.push(`updated_at = now()`);
@@ -9596,7 +10973,8 @@ async function updateTool(engine, id, opts) {
9596
10973
  `UPDATE context_engine_tools SET ${sets.join(", ")} WHERE id = $${i} RETURNING *`,
9597
10974
  params
9598
10975
  );
9599
- return rowToToolConfig(updated.rows[0], engine);
10976
+ const row_ = updated.rows[0];
10977
+ return rowToToolConfigLenient(row_, engine) ?? rowToToolConfig(row_, engine, {});
9600
10978
  }
9601
10979
  async function deleteTool(engine, id, opts = {}) {
9602
10980
  const existing = await engine.pool.query(`SELECT * FROM context_engine_tools WHERE id = $1`, [id]);
@@ -9645,17 +11023,38 @@ function probeFailure(exc, kind) {
9645
11023
  console.warn(`test_tool(kind=${kind}) failed:`, exc, "->", category);
9646
11024
  return { ok: false, error: category };
9647
11025
  }
9648
- async function testTool(_engine, tc) {
11026
+ async function testTool(engine, tc) {
9649
11027
  const kind = tc.kind;
9650
11028
  const config = tc.config ?? {};
11029
+ if (containsSentinel(config)) {
11030
+ throw new ConfigTemplateError(
11031
+ `config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a probe would send it verbatim as the credential; re-enter the secret values`
11032
+ );
11033
+ }
9651
11034
  if (kind === "http") {
9652
11035
  const url = config.url;
9653
11036
  if (!url) return { ok: false, error: "http config missing 'url'" };
9654
11037
  try {
9655
- const resp = await fetch(url, {
11038
+ await assertEgressAllowed(url, { allowPrivate: allowPrivateEgress(engine) });
11039
+ } catch (exc) {
11040
+ if (exc instanceof EgressDenied) {
11041
+ console.warn(`testTool(kind=http) refused: ${exc.message}`);
11042
+ return { ok: false, error: "egress_denied" };
11043
+ }
11044
+ throw exc;
11045
+ }
11046
+ const client = engine?._toolHttpClient ?? null;
11047
+ try {
11048
+ const resp = client ? await client.request({
11049
+ method: "HEAD",
11050
+ url,
11051
+ headers: config.headers ?? {}
11052
+ }) : await fetch(url, {
9656
11053
  method: "HEAD",
9657
11054
  headers: config.headers ?? {},
9658
- redirect: "follow",
11055
+ // No automatic follow: a redirect would reach a destination the
11056
+ // check above never saw.
11057
+ redirect: "manual",
9659
11058
  signal: AbortSignal.timeout(1e4)
9660
11059
  });
9661
11060
  return { ok: resp.status < 500, status_code: resp.status };
@@ -9676,12 +11075,16 @@ async function testTool(_engine, tc) {
9676
11075
  const url = config.url;
9677
11076
  if (!url) return { ok: false, error: "mcp config missing 'url'" };
9678
11077
  const token = config.oauth_token ?? config.bearer ?? config.access_token;
9679
- const mcp = new PromptevMCP();
11078
+ const mcp = new PromptevMCP({ allowPrivate: allowPrivateEgress(engine) });
9680
11079
  try {
9681
11080
  await mcp.addServer(tc.name, url, { token: token ?? null });
9682
11081
  const client = mcp.clients.get(tc.name);
9683
11082
  return { ok: true, tools: client.tools.map((t) => t.name) };
9684
11083
  } catch (exc) {
11084
+ if (exc instanceof EgressDenied) {
11085
+ console.warn(`testTool(kind=mcp) refused: ${exc.message}`);
11086
+ return { ok: false, error: "egress_denied" };
11087
+ }
9685
11088
  return probeFailure(exc, "mcp");
9686
11089
  } finally {
9687
11090
  await mcp.shutdown();
@@ -9689,11 +11092,20 @@ async function testTool(_engine, tc) {
9689
11092
  }
9690
11093
  return { ok: false, error: `test_tool does not support kind=${JSON.stringify(kind)}` };
9691
11094
  }
9692
- async function findAndClaimApproved(engine, toolName, args, sourceId) {
11095
+ async function findAndClaimApproved(engine, toolName, args, sourceId, principals, approvalScope) {
9693
11096
  const claimedAt = /* @__PURE__ */ new Date();
11097
+ const params = [toolName];
11098
+ let scopeSql2 = "approval_scope IS NULL";
11099
+ if (approvalScope !== null) {
11100
+ params.push(approvalScope);
11101
+ scopeSql2 = `approval_scope = $${params.length}`;
11102
+ }
11103
+ const wall = claimVisibilitySql(principals, params.length + 1);
11104
+ params.push(...wall.params);
9694
11105
  const candidates = await engine.pool.query(
9695
- `SELECT * FROM context_engine_tool_approvals WHERE tool_name = $1 AND status = 'approved'`,
9696
- [toolName]
11106
+ `SELECT * FROM context_engine_tool_approvals
11107
+ WHERE tool_name = $1 AND status = 'approved' AND ${scopeSql2} AND ${wall.sql}`,
11108
+ params
9697
11109
  );
9698
11110
  for (const row of candidates.rows) {
9699
11111
  if ((row.source_id ?? null) !== (sourceId ?? null)) continue;
@@ -9710,7 +11122,7 @@ async function findAndClaimApproved(engine, toolName, args, sourceId) {
9710
11122
  }
9711
11123
  return null;
9712
11124
  }
9713
- async function dispatchMcp(ct, config, args) {
11125
+ async function dispatchMcp(ct, config, args, opts = {}) {
9714
11126
  const url = config.url;
9715
11127
  if (!url) throw new EngineActionError(`mcp tool ${ct.callName} config missing 'url'`);
9716
11128
  const headers = { ...config.headers ?? {} };
@@ -9719,7 +11131,7 @@ async function dispatchMcp(ct, config, args) {
9719
11131
  if (token) headers.Authorization = `Bearer ${token}`;
9720
11132
  else if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
9721
11133
  const toolName = String(ct.raw?.tool_name ?? ct.displayName);
9722
- const mcp = new PromptevMCP();
11134
+ const mcp = new PromptevMCP({ allowPrivate: opts.allowPrivate ?? false });
9723
11135
  try {
9724
11136
  await mcp.addServer(ct.callName, url, { headers: Object.keys(headers).length ? headers : null });
9725
11137
  const client = mcp.clients.get(ct.callName);
@@ -9730,7 +11142,10 @@ async function dispatchMcp(ct, config, args) {
9730
11142
  }
9731
11143
  async function dispatch(engine, ct, config, args) {
9732
11144
  if (ct.kind === "http") {
9733
- return executeHttp(config, args, { client: engine._toolHttpClient ?? null });
11145
+ return executeHttp(config, args, {
11146
+ client: engine._toolHttpClient ?? null,
11147
+ allowPrivate: allowPrivateEgress(engine)
11148
+ });
9734
11149
  }
9735
11150
  if (ct.kind === "db") {
9736
11151
  const query = String(args.query ?? "");
@@ -9745,7 +11160,9 @@ async function dispatch(engine, ct, config, args) {
9745
11160
  }
9746
11161
  return result;
9747
11162
  }
9748
- if (ct.kind === "mcp") return dispatchMcp(ct, config, args);
11163
+ if (ct.kind === "mcp") {
11164
+ return dispatchMcp(ct, config, args, { allowPrivate: allowPrivateEgress(engine) });
11165
+ }
9749
11166
  if (ct.kind === "function") {
9750
11167
  const fn = ct.raw?.callable;
9751
11168
  if (!fn) throw new EngineActionError(`function tool ${ct.callName} has no callable`);
@@ -9766,10 +11183,19 @@ async function decryptCtConfig(engine, toolId) {
9766
11183
  ]);
9767
11184
  const row = result.rows[0];
9768
11185
  if (!row?.config_encrypted) return {};
9769
- return decryptDict(String(row.config_encrypted), getSecretKey(engine.config));
11186
+ return decryptDict(String(row.config_encrypted), cryptoKey(engine));
11187
+ }
11188
+ function warnUnscopedApproval(callName) {
11189
+ if (warnedUnscoped.has(callName)) return;
11190
+ warnedUnscoped.add(callName);
11191
+ process.emitWarning(
11192
+ `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.`,
11193
+ { type: "DeprecationWarning", code: "CE_APPROVAL_SCOPE_MISSING" }
11194
+ );
9770
11195
  }
9771
11196
  async function executeTool(engine, callName, args, opts = {}) {
9772
11197
  const runtimeArgs = args ?? {};
11198
+ const approvalScope = validateApprovalScope(opts.approvalScope);
9773
11199
  const ct = findTool(await mergedTools(engine, opts.sourceId ?? null), callName);
9774
11200
  if (!ct) throw new EngineActionError(`tool not found: ${callName}`);
9775
11201
  if (!toolVisible(ct, opts.principals ?? null)) {
@@ -9778,22 +11204,32 @@ async function executeTool(engine, callName, args, opts = {}) {
9778
11204
  const publicArgs = stripUnderscoreArgs(runtimeArgs);
9779
11205
  let approvalId = null;
9780
11206
  if (shouldRequireApproval(ct, publicArgs)) {
9781
- approvalId = await findAndClaimApproved(engine, ct.callName, publicArgs, opts.sourceId ?? null);
11207
+ if (approvalScope === null) warnUnscopedApproval(callName);
11208
+ approvalId = await findAndClaimApproved(
11209
+ engine,
11210
+ ct.callName,
11211
+ publicArgs,
11212
+ opts.sourceId ?? null,
11213
+ opts.principals ?? null,
11214
+ approvalScope
11215
+ );
9782
11216
  if (approvalId == null) {
9783
- const record = await createPending(engine, {
11217
+ const pendingOpts = {
9784
11218
  toolName: ct.callName,
9785
11219
  args: publicArgs,
9786
11220
  sourceId: opts.sourceId ?? null,
9787
11221
  principals: opts.principals ?? null,
9788
11222
  policy: ct.approvalPolicy ?? {}
9789
- });
11223
+ };
11224
+ const record = approvalScope === null ? await createPending(engine, pendingOpts) : await findOrCreatePending(engine, { ...pendingOpts, approvalScope });
9790
11225
  const reason = ct.requiresApproval ? "tool requires approval" : `approval policy condition met: ${ct.approvalPolicy?.condition}`;
9791
11226
  return {
9792
11227
  approval_required: {
9793
11228
  approval_id: String(record.id),
9794
11229
  tool_name: ct.callName,
9795
11230
  args: publicArgs,
9796
- reason
11231
+ reason,
11232
+ expires_at: record.expiresAt ? record.expiresAt.toISOString() : null
9797
11233
  }
9798
11234
  };
9799
11235
  }
@@ -9818,8 +11254,9 @@ async function executeTool(engine, callName, args, opts = {}) {
9818
11254
  let truncated = false;
9819
11255
  if (success) {
9820
11256
  try {
11257
+ const redactPrincipals = opts.principals === TRUSTED ? null : opts.principals ?? null;
9821
11258
  const [redacted] = redactToolResult(rawResult, engine.config.redaction, {
9822
- principals: opts.principals ?? null,
11259
+ principals: redactPrincipals,
9823
11260
  secretKey: engine.config.secretKey,
9824
11261
  hooks: engine.hooks
9825
11262
  });
@@ -9859,17 +11296,19 @@ async function executeTool(engine, callName, args, opts = {}) {
9859
11296
  usage: { units: 1, kind: "tool", tool_name: ct.callName, truncated }
9860
11297
  };
9861
11298
  }
9862
- var RESULT_MAX_CHARS, RESULT_MAX_ROWS, UPDATABLE_COLUMNS, PROBE_AUTH, PROBE_UNREACHABLE, PROBE_TIMEOUT, PROBE_MISCONFIGURED;
11299
+ var RESULT_MAX_CHARS, RESULT_MAX_ROWS, UPDATABLE_COLUMNS, PROBE_AUTH, PROBE_UNREACHABLE, PROBE_TIMEOUT, PROBE_MISCONFIGURED, warnedUnscoped;
9863
11300
  var init_governance = __esm({
9864
11301
  "src/tools/governance.ts"() {
9865
11302
  init_errors();
9866
11303
  init_hooks();
9867
11304
  init_redaction();
11305
+ init_sentinels();
9868
11306
  init_acl();
9869
11307
  init_approval();
9870
11308
  init_audit();
9871
- init_config();
11309
+ init_config2();
9872
11310
  init_crypto2();
11311
+ init_egress();
9873
11312
  init_db2();
9874
11313
  init_http();
9875
11314
  init_mcp_client();
@@ -9887,7 +11326,9 @@ var init_governance = __esm({
9887
11326
  "requires_approval",
9888
11327
  "approvalPolicy",
9889
11328
  "approval_policy",
9890
- "enabled"
11329
+ "enabled",
11330
+ "metaData",
11331
+ "meta_data"
9891
11332
  ]);
9892
11333
  PROBE_AUTH = [
9893
11334
  "authentication failed",
@@ -9926,6 +11367,7 @@ var init_governance = __esm({
9926
11367
  "not supported",
9927
11368
  "unsupported"
9928
11369
  ];
11370
+ warnedUnscoped = /* @__PURE__ */ new Set();
9929
11371
  }
9930
11372
  });
9931
11373
  function mulberry322(seed) {
@@ -10757,7 +12199,8 @@ var retrieval_exports = {};
10757
12199
  __export(retrieval_exports, {
10758
12200
  buildGraphRanked: () => buildGraphRanked,
10759
12201
  corpusIsAclUniform: () => corpusIsAclUniform,
10760
- shouldUseCommunitySummaries: () => shouldUseCommunitySummaries
12202
+ shouldUseCommunitySummaries: () => shouldUseCommunitySummaries,
12203
+ vectorSeedIds: () => vectorSeedIds
10761
12204
  });
10762
12205
  function vecLiteral2(vector) {
10763
12206
  return `[${vector.map((x) => Number(x)).join(",")}]`;
@@ -10783,8 +12226,8 @@ async function deriveQueryEntities(pool, chunkIds, opts) {
10783
12226
  JOIN context_engine_chunks c ON c.id = ce.chunk_id
10784
12227
  WHERE ce.chunk_id = ANY($1::uuid[]) ${SCOPE2}
10785
12228
  GROUP BY e.normalized_name, e.name, e.type
10786
- ORDER BY freq DESC LIMIT $4`,
10787
- [chunkIds, opts.sourceIds, opts.principals, ENTITY_LIMIT]
12229
+ ORDER BY freq DESC LIMIT $5`,
12230
+ [chunkIds, opts.sourceIds, opts.documentIds ?? null, opts.principals, ENTITY_LIMIT]
10788
12231
  );
10789
12232
  return result.rows.map((r) => ({
10790
12233
  normalized_name: r.normalized_name,
@@ -10849,8 +12292,8 @@ async function computeCommunityScores(pool, chunkIds, vector, opts) {
10849
12292
  `SELECT ce.chunk_id::text, ce.entity_id::text
10850
12293
  FROM context_engine_chunk_entities ce
10851
12294
  JOIN context_engine_chunks c ON c.id = ce.chunk_id
10852
- WHERE ce.chunk_id = ANY($1::uuid[]) AND ce.entity_id = ANY($4::uuid[]) ${SCOPE2}`,
10853
- [chunkIds, opts.sourceIds, opts.principals, Object.keys(entityScore)]
12295
+ WHERE ce.chunk_id = ANY($1::uuid[]) AND ce.entity_id = ANY($5::uuid[]) ${SCOPE2}`,
12296
+ [chunkIds, opts.sourceIds, opts.documentIds ?? null, opts.principals, Object.keys(entityScore)]
10854
12297
  );
10855
12298
  const chunkScores = {};
10856
12299
  for (const row of result.rows) {
@@ -10859,13 +12302,29 @@ async function computeCommunityScores(pool, chunkIds, vector, opts) {
10859
12302
  return chunkScores;
10860
12303
  }
10861
12304
  async function vectorSeedIds(pool, vector, opts) {
10862
- const result = await pool.query(
10863
- `SELECT c.id::text FROM context_engine_chunks c
10864
- WHERE c.embedding IS NOT NULL ${SCOPE2}
10865
- ORDER BY c.embedding <=> CAST($1 AS vector) LIMIT $4`,
10866
- [vecLiteral2(vector), opts.sourceIds, opts.principals, SEED_LIMIT]
10867
- );
10868
- return result.rows.map((r) => String(r.id));
12305
+ const binds = [opts.sourceIds, opts.documentIds ?? null, opts.principals];
12306
+ const scoped = binds.some((v) => v !== null);
12307
+ const client = await pool.connect();
12308
+ try {
12309
+ await client.query("BEGIN");
12310
+ await opts.backend?.tuneAnnScan?.(client, SEED_LIMIT, scoped);
12311
+ const result = await client.query(
12312
+ `SELECT c.id::text FROM context_engine_chunks c
12313
+ WHERE c.embedding IS NOT NULL ${SCOPE2}
12314
+ ORDER BY c.embedding <=> CAST($1 AS vector) LIMIT $5`,
12315
+ [vecLiteral2(vector), ...binds, SEED_LIMIT]
12316
+ );
12317
+ await client.query("COMMIT");
12318
+ return result.rows.map((r) => String(r.id));
12319
+ } catch (err) {
12320
+ try {
12321
+ await client.query("ROLLBACK");
12322
+ } catch {
12323
+ }
12324
+ throw err;
12325
+ } finally {
12326
+ client.release();
12327
+ }
10869
12328
  }
10870
12329
  async function buildGraphRanked(query, opts) {
10871
12330
  try {
@@ -10874,11 +12333,18 @@ async function buildGraphRanked(query, opts) {
10874
12333
  const vector = vectors[0] ? [...vectors[0]] : null;
10875
12334
  if (!vector) return [];
10876
12335
  const sourceIds = opts.sourceIds ?? null;
12336
+ const documentIds = opts.documentIds ?? null;
10877
12337
  const principals = opts.principals ?? null;
10878
- const seeds = await vectorSeedIds(opts.pool, vector, { sourceIds, principals });
12338
+ const seeds = await vectorSeedIds(opts.pool, vector, {
12339
+ sourceIds,
12340
+ documentIds,
12341
+ principals,
12342
+ backend: opts.backend
12343
+ });
10879
12344
  if (!seeds.length) return [];
10880
12345
  const queryEntities = await deriveQueryEntities(opts.pool, seeds.slice(0, TOP_SEEDS_FOR_ENTITIES), {
10881
12346
  sourceIds,
12347
+ documentIds,
10882
12348
  principals
10883
12349
  });
10884
12350
  const entityNorms = queryEntities.map((e) => String(e.normalized_name));
@@ -10900,6 +12366,7 @@ async function buildGraphRanked(query, opts) {
10900
12366
  const relScores = await computeRelationshipScores(opts.pool, allIds, entityNorms);
10901
12367
  const commScores = await computeCommunityScores(opts.pool, allIds, vector, {
10902
12368
  sourceIds,
12369
+ documentIds,
10903
12370
  principals,
10904
12371
  useSummaries
10905
12372
  });
@@ -10951,7 +12418,8 @@ var init_retrieval = __esm({
10951
12418
  TOP_SEEDS_FOR_ENTITIES = 20;
10952
12419
  SCOPE2 = `
10953
12420
  AND ($2::text[] IS NULL OR c.source_id = ANY($2::text[]))
10954
- AND ($3::text[] IS NULL OR c.acl IS NULL OR c.acl && $3::text[])
12421
+ AND ($3::uuid[] IS NULL OR c.document_id = ANY($3::uuid[]))
12422
+ AND ($4::text[] IS NULL OR c.acl IS NULL OR c.acl && $4::text[])
10955
12423
  `;
10956
12424
  }
10957
12425
  });
@@ -11115,11 +12583,19 @@ var init_engine = __esm({
11115
12583
  _graphStore = null;
11116
12584
  constructor(config, opts = {}) {
11117
12585
  this.config = config;
11118
- this.hooks = { onUsage: opts.onUsage ?? null, onError: opts.onError ?? null };
12586
+ this.hooks = {
12587
+ onUsage: opts.onUsage ?? null,
12588
+ onError: opts.onError ?? null,
12589
+ onProgress: opts.onProgress ?? null
12590
+ };
11119
12591
  }
11120
12592
  async ensurePool() {
11121
12593
  if (!this._pool) {
11122
- this._pool = await createPool(this.config.databaseUrl);
12594
+ this._pool = await createPool(this.config.databaseUrl, {
12595
+ max: this.config.storage.poolMax,
12596
+ idleTimeoutMillis: this.config.storage.poolIdleTimeoutMs,
12597
+ connectionTimeoutMillis: this.config.storage.poolConnectionTimeoutMs
12598
+ });
11123
12599
  this.pool = this._pool;
11124
12600
  }
11125
12601
  if (!this.backend) {
@@ -11305,7 +12781,9 @@ var init_engine = __esm({
11305
12781
  graphStore: await this.getGraphStore(),
11306
12782
  hooks: this.hooks,
11307
12783
  sourceIds: opts.sourceIds ?? null,
11308
- principals
12784
+ documentIds: opts.documentIds ?? null,
12785
+ principals,
12786
+ backend: this.backend
11309
12787
  });
11310
12788
  }
11311
12789
  return runSearch(query, {
@@ -11315,6 +12793,7 @@ var init_engine = __esm({
11315
12793
  embedder: this.embedder,
11316
12794
  pool,
11317
12795
  sourceIds: opts.sourceIds,
12796
+ documentIds: opts.documentIds,
11318
12797
  principals,
11319
12798
  topK: opts.topK,
11320
12799
  mode: opts.mode,
@@ -11420,7 +12899,7 @@ var init_engine = __esm({
11420
12899
  hooks: this.hooks,
11421
12900
  sourceIds: opts.sourceIds,
11422
12901
  principals: resolvePrincipals(opts.principals, "compute"),
11423
- docIds: opts.docIds,
12902
+ documentIds: opts.documentIds,
11424
12903
  modelCfg: opts.modelCfg,
11425
12904
  timeout: opts.timeout
11426
12905
  });
@@ -11473,151 +12952,16 @@ var init_engine = __esm({
11473
12952
  sourceId: opts.sourceId,
11474
12953
  principals: resolvePrincipals(opts.principals, "executeTool"),
11475
12954
  actor: opts.actor,
11476
- source: opts.source ?? "api"
12955
+ source: opts.source ?? "api",
12956
+ approvalScope: opts.approvalScope
11477
12957
  });
11478
12958
  }
11479
12959
  };
11480
12960
  }
11481
12961
  });
11482
12962
 
11483
- // src/config.ts
11484
- init_redaction();
11485
- var embeddingSchema = z.object({
11486
- provider: z.enum(["openai", "azure_openai", "gemini", "voyage", "cohere", "custom"]),
11487
- model: z.string(),
11488
- dim: z.number().int().positive().nullable().optional().default(null),
11489
- apiKey: z.string().nullable().optional().default(null),
11490
- baseUrl: z.string().nullable().optional().default(null)
11491
- });
11492
- var llmSchema = z.object({
11493
- provider: z.enum(["anthropic", "openai", "azure_openai", "gemini", "bedrock", "custom"]),
11494
- model: z.string(),
11495
- apiKey: z.string().nullable().optional().default(null),
11496
- baseUrl: z.string().nullable().optional().default(null)
11497
- });
11498
- var graphSchema = z.object({
11499
- enabled: z.boolean().default(false),
11500
- neo4jUri: z.string().nullable().optional().default(null),
11501
- neo4jUser: z.string().default("neo4j"),
11502
- neo4jPassword: z.string().nullable().optional().default(null),
11503
- neo4jDatabase: z.string().default("neo4j"),
11504
- extractionLlm: llmSchema.nullable().optional().default(null)
11505
- });
11506
- var rerankerSchema = z.object({
11507
- enabled: z.boolean().default(false),
11508
- provider: z.enum(["cohere", "voyage", "jina", "custom"]).nullable().optional().default(null),
11509
- model: z.string().nullable().optional().default(null),
11510
- apiKey: z.string().nullable().optional().default(null),
11511
- baseUrl: z.string().nullable().optional().default(null),
11512
- candidates: z.number().int().positive().default(50)
11513
- });
11514
- var fusionSchema = z.object({
11515
- method: z.literal("rrf").default("rrf"),
11516
- k: z.number().int().positive().default(60),
11517
- weights: z.record(z.string(), z.number()).default({ fts: 1, trgm: 0.8, ann: 1, graph: 1 })
11518
- });
11519
- var storageSchema = z.object({
11520
- backend: z.literal("postgres").default("postgres"),
11521
- annExactThreshold: z.number().int().positive().default(5e4)
11522
- });
11523
- var ContextEngineConfig = class _ContextEngineConfig {
11524
- databaseUrl;
11525
- storage;
11526
- defaultMode;
11527
- embedding;
11528
- llm;
11529
- visionLlm;
11530
- graph;
11531
- reranker;
11532
- fusion;
11533
- enableCodeExecution;
11534
- secretKey;
11535
- redaction;
11536
- constructor(init) {
11537
- this.databaseUrl = init.databaseUrl;
11538
- this.storage = storageSchema.parse(init.storage ?? {});
11539
- this.defaultMode = init.defaultMode ?? "hybrid";
11540
- this.embedding = embeddingSchema.parse(init.embedding);
11541
- this.llm = init.llm ? llmSchema.parse(init.llm) : null;
11542
- this.visionLlm = init.visionLlm ? llmSchema.parse(init.visionLlm) : null;
11543
- this.graph = graphSchema.parse(init.graph ?? {});
11544
- this.reranker = rerankerSchema.parse(init.reranker ?? {});
11545
- this.fusion = fusionSchema.parse(init.fusion ?? {});
11546
- this.enableCodeExecution = init.enableCodeExecution ?? false;
11547
- this.secretKey = init.secretKey ?? null;
11548
- this.redaction = init.redaction instanceof RedactionPolicy ? init.redaction : new RedactionPolicy(init.redaction ?? {});
11549
- this.validate();
11550
- }
11551
- validate() {
11552
- if (this.graph.enabled && !(this.graph.neo4jUri && this.graph.neo4jPassword && this.graph.extractionLlm)) {
11553
- throw new Error("graph enabled but neo4jUri/neo4jPassword/extractionLlm missing");
11554
- }
11555
- if (this.reranker.enabled && !(this.reranker.provider && this.reranker.apiKey)) {
11556
- throw new Error("reranker enabled but provider/apiKey missing");
11557
- }
11558
- if (this.defaultMode === "graph" && !this.graph.enabled) {
11559
- throw new Error("defaultMode is 'graph' but graph enabled is false");
11560
- }
11561
- for (const rule of this.redaction.rules) {
11562
- if (rule.action === "hash" && !this.secretKey) {
11563
- throw new Error(
11564
- `redaction rule '${rule.name}': action='hash' requires ContextEngineConfig.secretKey to be set`
11565
- );
11566
- }
11567
- }
11568
- }
11569
- static fromEnv(overrides = {}) {
11570
- const env = loadCeEnv();
11571
- const merged = deepMerge(env, overrides);
11572
- if (!merged.databaseUrl || !merged.embedding) {
11573
- throw new Error(
11574
- "ContextEngineConfig.fromEnv requires CE_DATABASE_URL and CE_EMBEDDING__PROVIDER/MODEL (or explicit overrides)"
11575
- );
11576
- }
11577
- return new _ContextEngineConfig(merged);
11578
- }
11579
- };
11580
- function camelize(key) {
11581
- return key.toLowerCase().replace(/_([a-z])/g, (_, c) => c.toUpperCase());
11582
- }
11583
- function loadCeEnv() {
11584
- const root = {};
11585
- for (const [raw, value] of Object.entries(process.env)) {
11586
- if (!raw.startsWith("CE_") || value === void 0) continue;
11587
- const path = raw.slice(3).split("__").map(camelize);
11588
- let cur = root;
11589
- for (let i = 0; i < path.length - 1; i++) {
11590
- const k = path[i];
11591
- const next = cur[k];
11592
- if (typeof next !== "object" || next === null) cur[k] = {};
11593
- cur = cur[k];
11594
- }
11595
- cur[path[path.length - 1]] = coerceEnv(value);
11596
- }
11597
- return root;
11598
- }
11599
- function coerceEnv(value) {
11600
- if (value === "true") return true;
11601
- if (value === "false") return false;
11602
- if (/^-?\d+$/.test(value)) return Number(value);
11603
- if (/^-?\d+\.\d+$/.test(value)) return Number(value);
11604
- return value;
11605
- }
11606
- function deepMerge(a, b) {
11607
- const out = { ...a };
11608
- for (const [k, v] of Object.entries(b)) {
11609
- if (v === void 0) continue;
11610
- const existing = out[k];
11611
- if (v && typeof v === "object" && !Array.isArray(v) && existing && typeof existing === "object" && !Array.isArray(existing)) {
11612
- out[k] = deepMerge(existing, v);
11613
- } else {
11614
- out[k] = v;
11615
- }
11616
- }
11617
- return out;
11618
- }
11619
-
11620
12963
  // src/cli.ts
12964
+ init_config();
11621
12965
  init_db();
11622
12966
 
11623
12967
  // src/diagnostics.ts