@promptev/context-engine 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +93 -5
  2. package/dist/cli.js +1761 -501
  3. package/dist/cli.js.map +1 -1
  4. package/dist/{config-Bt9bUQqU.d.ts → config-CNnASw5X.d.cts} +42 -5
  5. package/dist/{config-Bl9U789m.d.cts → config-CdlSkKgV.d.ts} +42 -5
  6. package/dist/express.cjs +925 -151
  7. package/dist/express.cjs.map +1 -1
  8. package/dist/express.d.cts +11 -4
  9. package/dist/express.d.ts +11 -4
  10. package/dist/express.js +926 -152
  11. package/dist/express.js.map +1 -1
  12. package/dist/fastify.cjs +923 -151
  13. package/dist/fastify.cjs.map +1 -1
  14. package/dist/fastify.d.cts +8 -4
  15. package/dist/fastify.d.ts +8 -4
  16. package/dist/fastify.js +924 -152
  17. package/dist/fastify.js.map +1 -1
  18. package/dist/{governance-BDkcv4qZ.d.cts → governance-D8g6Wyvb.d.cts} +8 -2
  19. package/dist/{governance-XIScatRO.d.ts → governance-XFVgtEdV.d.ts} +8 -2
  20. package/dist/graph/index.cjs +122 -43
  21. package/dist/graph/index.cjs.map +1 -1
  22. package/dist/graph/index.d.cts +5 -3
  23. package/dist/graph/index.d.ts +5 -3
  24. package/dist/graph/index.js +122 -43
  25. package/dist/graph/index.js.map +1 -1
  26. package/dist/hono.cjs +923 -151
  27. package/dist/hono.cjs.map +1 -1
  28. package/dist/hono.d.cts +8 -4
  29. package/dist/hono.d.ts +8 -4
  30. package/dist/hono.js +924 -152
  31. package/dist/hono.js.map +1 -1
  32. package/dist/index.cjs +2186 -910
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +70 -107
  35. package/dist/index.d.ts +70 -107
  36. package/dist/index.js +2186 -908
  37. package/dist/index.js.map +1 -1
  38. package/dist/mcp.cjs +100 -14
  39. package/dist/mcp.cjs.map +1 -1
  40. package/dist/mcp.d.cts +5 -0
  41. package/dist/mcp.d.ts +5 -0
  42. package/dist/mcp.js +100 -14
  43. package/dist/mcp.js.map +1 -1
  44. package/dist/migrations/sql/0003_tools.sql +2 -0
  45. package/dist/migrations/sql/0004_acl_indexes.sql +23 -2
  46. package/dist/{redaction-BmDSWJ7h.d.cts → redaction-BqD_DEUQ.d.cts} +22 -1
  47. package/dist/{redaction-BmDSWJ7h.d.ts → redaction-BqD_DEUQ.d.ts} +22 -1
  48. package/dist/redaction-presidio.d.cts +1 -1
  49. package/dist/redaction-presidio.d.ts +1 -1
  50. package/dist/{router-CrxZ2y_Z.d.ts → router-B_DTkQgU.d.ts} +17 -2
  51. package/dist/{router-OPgSoYAB.d.cts → router-Dsv3fv0R.d.cts} +17 -2
  52. package/dist/storage-DU1JRno5.d.cts +164 -0
  53. package/dist/storage-Dvt2ZxsV.d.ts +164 -0
  54. package/package.json +61 -23
  55. package/src/migrations/sql/0003_tools.sql +2 -0
  56. package/src/migrations/sql/0004_acl_indexes.sql +23 -2
  57. package/dist/embeddings-B-jZ42mk.d.cts +0 -67
  58. package/dist/embeddings-DaSdAZN3.d.ts +0 -67
package/dist/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,184 @@ 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", "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
+ });
423
+ llmSchema = z.object({
424
+ provider: z.enum(["anthropic", "openai", "azure_openai", "gemini", "bedrock", "custom"]),
425
+ model: z.string(),
426
+ apiKey: z.string().nullable().optional().default(null),
427
+ baseUrl: z.string().nullable().optional().default(null)
428
+ });
429
+ graphSchema = z.object({
430
+ enabled: z.boolean().default(false),
431
+ neo4jUri: z.string().nullable().optional().default(null),
432
+ neo4jUser: z.string().default("neo4j"),
433
+ neo4jPassword: z.string().nullable().optional().default(null),
434
+ neo4jDatabase: z.string().default("neo4j"),
435
+ extractionLlm: llmSchema.nullable().optional().default(null)
436
+ });
437
+ rerankerSchema = z.object({
438
+ enabled: z.boolean().default(false),
439
+ provider: z.enum(["cohere", "voyage", "jina", "custom"]).nullable().optional().default(null),
440
+ model: z.string().nullable().optional().default(null),
441
+ apiKey: z.string().nullable().optional().default(null),
442
+ baseUrl: z.string().nullable().optional().default(null),
443
+ candidates: z.number().int().positive().default(50)
444
+ });
445
+ fusionSchema = z.object({
446
+ method: z.literal("rrf").default("rrf"),
447
+ k: z.number().int().positive().default(60),
448
+ weights: z.record(z.string(), z.number()).default({ fts: 1, trgm: 0.8, ann: 1, graph: 1 })
449
+ });
450
+ storageSchema = z.object({
451
+ backend: z.literal("postgres").default("postgres"),
452
+ annExactThreshold: z.number().int().positive().default(5e4),
453
+ // Connection pool, passed straight through to `pg.Pool`. Sizing is a
454
+ // deployment decision (how many workers times how many concurrent searches
455
+ // each runs, against what the database allows), so these are pg's own
456
+ // defaults rather than a guess at your topology. (`pg` has no pre-ping.)
457
+ //
458
+ // Connections kept open in the pool.
459
+ poolMax: z.number().int().positive().default(10),
460
+ // Milliseconds an idle connection is kept before it is closed.
461
+ poolIdleTimeoutMs: z.number().int().nonnegative().default(1e4),
462
+ // Milliseconds a caller waits for a connection before the attempt fails.
463
+ poolConnectionTimeoutMs: z.number().int().nonnegative().default(3e4)
464
+ });
465
+ extractionSchema = z.object({
466
+ // Ceiling on an image's LONG EDGE in pixels, for rendered PDF pages and
467
+ // for images sent to the vision LLM.
468
+ maxRenderPx: z.number().int().positive().default(2e3),
469
+ // Ceiling on ONE vision batch's transcription, in output tokens — a reply
470
+ // far past a batch's honest bound is a repetition loop. Raising it is the
471
+ // fix if very dense pages come back truncated.
472
+ visionMaxOutputTokens: z.number().int().positive().default(16e3),
473
+ // Pages per vision-LLM call. Smaller batches mean more calls but more
474
+ // concurrency and less risk of hitting the output ceiling or a provider
475
+ // deadline; larger batches the reverse. Measure on your own corpus.
476
+ pagesPerVisionBatch: z.number().int().positive().default(5),
477
+ // Language hint for the Tesseract OCR fallback ('en', 'ur', 'mixed', any
478
+ // Tesseract code, or 'auto' for script detection). null (the default)
479
+ // detects the document's language from its usable text-layer pages and
480
+ // falls back to 'auto' when there is nothing to sample.
481
+ ocrLanguage: z.string().nullable().default(null)
482
+ });
483
+ ContextEngineConfig = class _ContextEngineConfig {
484
+ databaseUrl;
485
+ storage;
486
+ defaultMode;
487
+ embedding;
488
+ llm;
489
+ visionLlm;
490
+ graph;
491
+ reranker;
492
+ fusion;
493
+ extraction;
494
+ enableCodeExecution;
495
+ secretKey;
496
+ /**
497
+ * Lets http tools and probes reach loopback/private/link-local/metadata
498
+ * addresses. Off by default — an http tool is registered by a caller, and a
499
+ * private destination is server-side request forgery. See `tools/egress.ts`.
500
+ */
501
+ allowPrivateEgress;
502
+ redaction;
503
+ constructor(init) {
504
+ this.databaseUrl = init.databaseUrl;
505
+ this.storage = storageSchema.parse(init.storage ?? {});
506
+ this.defaultMode = init.defaultMode ?? "hybrid";
507
+ this.embedding = embeddingSchema.parse(init.embedding);
508
+ this.llm = init.llm ? llmSchema.parse(init.llm) : null;
509
+ this.visionLlm = init.visionLlm ? llmSchema.parse(init.visionLlm) : null;
510
+ this.graph = graphSchema.parse(init.graph ?? {});
511
+ this.reranker = rerankerSchema.parse(init.reranker ?? {});
512
+ this.fusion = fusionSchema.parse(init.fusion ?? {});
513
+ this.extraction = extractionSchema.parse(init.extraction ?? {});
514
+ this.enableCodeExecution = init.enableCodeExecution ?? false;
515
+ this.secretKey = init.secretKey ?? null;
516
+ this.allowPrivateEgress = init.allowPrivateEgress ?? false;
517
+ this.redaction = init.redaction instanceof RedactionPolicy ? init.redaction : new RedactionPolicy(init.redaction ?? {});
518
+ this.validate();
519
+ }
520
+ validate() {
521
+ if (this.graph.enabled && !(this.graph.neo4jUri && this.graph.neo4jPassword && this.graph.extractionLlm)) {
522
+ throw new Error("graph enabled but neo4jUri/neo4jPassword/extractionLlm missing");
523
+ }
524
+ if (this.reranker.enabled && !(this.reranker.provider && this.reranker.apiKey)) {
525
+ throw new Error("reranker enabled but provider/apiKey missing");
526
+ }
527
+ if (this.defaultMode === "graph" && !this.graph.enabled) {
528
+ throw new Error("defaultMode is 'graph' but graph enabled is false");
529
+ }
530
+ for (const rule of this.redaction.rules) {
531
+ if (rule.action === "hash" && !this.secretKey) {
532
+ throw new Error(
533
+ `redaction rule '${rule.name}': action='hash' requires ContextEngineConfig.secretKey to be set`
534
+ );
535
+ }
536
+ }
537
+ }
538
+ static fromEnv(overrides = {}) {
539
+ const env = loadCeEnv();
540
+ const merged = deepMerge(env, overrides);
541
+ if (!merged.databaseUrl || !merged.embedding) {
542
+ throw new Error(
543
+ "ContextEngineConfig.fromEnv requires CE_DATABASE_URL and CE_EMBEDDING__PROVIDER/MODEL (or explicit overrides)"
544
+ );
545
+ }
546
+ return new _ContextEngineConfig(merged);
547
+ }
548
+ };
549
+ }
550
+ });
363
551
 
364
552
  // src/errors.ts
365
553
  var EngineActionError, DocumentNotFoundError, GraphLegUnavailable, ExtraMissingError;
@@ -462,6 +650,7 @@ async function runMigrate(databaseUrl, opts = {}) {
462
650
  recorded = rev;
463
651
  done.add(rev);
464
652
  }
653
+ await client.query(loadSql("0004_acl_indexes", dim));
465
654
  const meta = await client.query("SELECT embedding_dim FROM context_engine_meta WHERE id = 1");
466
655
  if (!meta.rows.length) {
467
656
  await client.query("INSERT INTO context_engine_meta (id, embedding_dim) VALUES (1, $1)", [dim]);
@@ -517,16 +706,116 @@ var init_extras = __esm({
517
706
  }
518
707
  });
519
708
 
520
- // src/tools/mcp-tools.ts
709
+ // src/sentinels.ts
710
+ function resolvePrincipals(value, method) {
711
+ if (value === TRUSTED) return null;
712
+ if (value === void 0 || value === null) {
713
+ if (!warnedMethods.has(method)) {
714
+ warnedMethods.add(method);
715
+ process.emitWarning(
716
+ `${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.`,
717
+ { type: "DeprecationWarning", code: "CE_PRINCIPALS_NULL" }
718
+ );
719
+ }
720
+ return null;
721
+ }
722
+ if (!Array.isArray(value)) {
723
+ throw new TypeError(
724
+ `${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.`
725
+ );
726
+ }
727
+ return value;
728
+ }
729
+ var UNSET, TRUSTED, warnedMethods;
730
+ var init_sentinels = __esm({
731
+ "src/sentinels.ts"() {
732
+ UNSET = /* @__PURE__ */ Symbol.for("context_engine.UNSET");
733
+ TRUSTED = /* @__PURE__ */ Symbol.for("context_engine.TRUSTED");
734
+ warnedMethods = /* @__PURE__ */ new Set();
735
+ }
736
+ });
737
+ function requireValidUuid(documentId) {
738
+ if (!UUID_RE2.test(String(documentId))) {
739
+ throw new HandlerError(400, `invalid document id: ${JSON.stringify(documentId)}`);
740
+ }
741
+ }
742
+ function resolveRequestPrincipals(value, opts) {
743
+ if (value === TRUSTED) return TRUSTED;
744
+ if (value == null) {
745
+ throw new HandlerError(
746
+ 500,
747
+ `${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.`
748
+ );
749
+ }
750
+ if (!Array.isArray(value) || value.some((p) => typeof p !== "string")) {
751
+ throw new HandlerError(
752
+ 500,
753
+ `${opts.surface}: the \`principals\` dependency must return a list of principal strings, [] for an anonymous caller, or TRUSTED \u2014 got ${typeof value}.`
754
+ );
755
+ }
756
+ return value;
757
+ }
758
+ var HandlerError, UUID_RE2;
759
+ var init_routing_core = __esm({
760
+ "src/routing-core.ts"() {
761
+ init_sentinels();
762
+ HandlerError = class extends Error {
763
+ status;
764
+ detail;
765
+ constructor(status, detail) {
766
+ super(typeof detail === "string" ? detail : JSON.stringify(detail));
767
+ this.name = "HandlerError";
768
+ this.status = status;
769
+ this.detail = detail;
770
+ }
771
+ };
772
+ z.object({
773
+ text: z.string(),
774
+ name: z.string(),
775
+ source_id: z.string().nullable().optional(),
776
+ external_id: z.string().nullable().optional(),
777
+ description: z.string().nullable().optional(),
778
+ meta_data: z.record(z.unknown()).nullable().optional(),
779
+ acl: z.array(z.string()).nullable().optional(),
780
+ mode: z.enum(["hybrid", "graph"]).nullable().optional(),
781
+ extract_structured: z.boolean().optional().default(false),
782
+ batch: z.boolean().optional().default(false)
783
+ }).strip();
784
+ z.object({
785
+ acl: z.array(z.string()).nullable().optional(),
786
+ name: z.string().nullable().optional(),
787
+ description: z.string().nullable().optional(),
788
+ meta_data: z.record(z.unknown()).nullable().optional()
789
+ }).strict();
790
+ z.object({
791
+ query: z.string(),
792
+ source_ids: z.array(z.string()).nullable().optional(),
793
+ // Narrows WITHIN a source and INTERSECTS with source_ids — it can only
794
+ // shrink the result set (the ACL predicate still applies in the same SQL
795
+ // conjunction), so exposing it needs no authorizeAcl-style grant check.
796
+ document_ids: z.array(z.string()).nullable().optional(),
797
+ top_k: z.number().int().optional().default(10),
798
+ mode: z.enum(["hybrid", "graph"]).optional().default("hybrid"),
799
+ compress_to_tokens: z.number().int().nullable().optional()
800
+ }).strip();
801
+ UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
802
+ }
803
+ });
521
804
  async function resolvePrincipalsFn(principals) {
522
805
  const result = await Promise.resolve(principals());
523
- return result;
806
+ try {
807
+ return resolveRequestPrincipals(result, { surface: "the MCP `principals` dependency" });
808
+ } catch (exc) {
809
+ if (exc instanceof HandlerError) throw new Error(exc.message, { cause: exc });
810
+ throw exc;
811
+ }
524
812
  }
525
- function registerToolGateway(mcp, engine, principals) {
813
+ function registerToolGateway(mcp, engine, principals, approvalScope = null) {
526
814
  mcp.tool(
527
815
  "search_tools",
528
816
  "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" } },
817
+ // Zod raw shape see the note on the search tool in mcp.ts.
818
+ { query: z.string(), limit: z.number().int().optional() },
530
819
  async (...raw) => {
531
820
  const args = raw[0] ?? {};
532
821
  const callerPrincipals = await resolvePrincipalsFn(principals);
@@ -540,13 +829,15 @@ function registerToolGateway(mcp, engine, principals) {
540
829
  mcp.tool(
541
830
  "execute_tool",
542
831
  "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" } },
832
+ { name: z.string(), args: z.record(z.unknown()).optional() },
544
833
  async (...raw) => {
545
834
  const body = raw[0] ?? {};
546
835
  const callerPrincipals = await resolvePrincipalsFn(principals);
836
+ const scope = approvalScope ? await Promise.resolve(approvalScope()) : null;
547
837
  return engine.executeTool(String(body.name), body.args ?? null, {
548
838
  principals: callerPrincipals,
549
- source: "mcp"
839
+ source: "mcp",
840
+ approvalScope: scope
550
841
  });
551
842
  }
552
843
  );
@@ -554,6 +845,7 @@ function registerToolGateway(mcp, engine, principals) {
554
845
  var SCOPE_NOTE;
555
846
  var init_mcp_tools = __esm({
556
847
  "src/tools/mcp-tools.ts"() {
848
+ init_routing_core();
557
849
  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
850
  }
559
851
  });
@@ -606,15 +898,24 @@ async function createMcpApp(engine, opts) {
606
898
  "search_knowledge_base",
607
899
  `Hybrid search (full-text + trigram + vector, RRF-fused) over the ingested corpus. Returns the top-matching chunks with their source document.${SCOPE_NOTE}`,
608
900
  {
609
- query: { type: "string" },
610
- source_ids: { type: "array", items: { type: "string" } },
611
- top_k: { type: "number" },
612
- mode: { type: "string", enum: ["hybrid", "graph"] }
901
+ // Zod RAW SHAPES, not JSON schema: the SDK's isZodRawShape test
902
+ // rejects a plain schema object on current SDK versions that made
903
+ // registration THROW at startup, and on 1.12.0 the object was consumed
904
+ // as annotations and every handler ran with NO arguments.
905
+ query: z.string(),
906
+ source_ids: z.array(z.string()).optional(),
907
+ document_ids: z.array(z.string()).optional(),
908
+ top_k: z.number().int().optional(),
909
+ mode: z.enum(["hybrid", "graph"]).optional()
613
910
  },
614
911
  async (args) => {
912
+ for (const did of args.document_ids ?? []) {
913
+ requireValidUuid(did);
914
+ }
615
915
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
616
916
  const result = await engine.search(String(args.query ?? ""), {
617
917
  sourceIds: args.source_ids,
918
+ documentIds: args.document_ids,
618
919
  principals: callerPrincipals,
619
920
  topK: args.top_k ?? 10,
620
921
  mode: args.mode ?? "hybrid"
@@ -636,7 +937,7 @@ async function createMcpApp(engine, opts) {
636
937
  tool(
637
938
  "get_document",
638
939
  `Fetch one document's full text and metadata by id.${SCOPE_NOTE}`,
639
- { document_id: { type: "string" } },
940
+ { document_id: z.string() },
640
941
  async (args) => {
641
942
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
642
943
  return engine.getDocument(String(args.document_id), { principals: callerPrincipals });
@@ -646,7 +947,7 @@ async function createMcpApp(engine, opts) {
646
947
  tool(
647
948
  "query_structured",
648
949
  `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" } } },
950
+ { question: z.string(), source_ids: z.array(z.string()).optional() },
650
951
  async (args) => {
651
952
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
652
953
  if (!engine.queryStructured) throw new Error("queryStructured is not available on this engine");
@@ -660,7 +961,7 @@ async function createMcpApp(engine, opts) {
660
961
  tool(
661
962
  "compute",
662
963
  `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" } } },
964
+ { instruction: z.string(), source_ids: z.array(z.string()).optional() },
664
965
  async (args) => {
665
966
  const callerPrincipals = await resolvePrincipalsFn(opts.principals);
666
967
  if (!engine.compute) throw new Error("compute is not available on this engine");
@@ -679,7 +980,8 @@ async function createMcpApp(engine, opts) {
679
980
  }
680
981
  },
681
982
  engine,
682
- opts.principals
983
+ opts.principals,
984
+ opts.approvalScope ?? null
683
985
  );
684
986
  const handler = (async (req, res) => {
685
987
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
@@ -693,6 +995,7 @@ var init_mcp = __esm({
693
995
  "src/mcp.ts"() {
694
996
  init_errors();
695
997
  init_extras();
998
+ init_routing_core();
696
999
  init_mcp_tools();
697
1000
  init_version();
698
1001
  }
@@ -1967,6 +2270,8 @@ var init_chunkers = __esm({
1967
2270
  // src/providers/llm.ts
1968
2271
  var llm_exports = {};
1969
2272
  __export(llm_exports, {
2273
+ CALL_TIMEOUT_MS: () => CALL_TIMEOUT_MS,
2274
+ GEMINI_CALL_TIMEOUT_MS: () => GEMINI_CALL_TIMEOUT_MS,
1970
2275
  LLMClient: () => LLMClient,
1971
2276
  TIMEOUT_MS: () => TIMEOUT_MS,
1972
2277
  buildLlmClient: () => buildLlmClient,
@@ -2009,7 +2314,7 @@ async function loadGeminiChatClient(apiKey) {
2009
2314
  if (!Ctor) {
2010
2315
  throw new ExtraMissingError("gemini", specifier, "gemini llm");
2011
2316
  }
2012
- return new Ctor({ apiKey: apiKey ?? null });
2317
+ return new Ctor({ apiKey: apiKey ?? null, httpOptions: { timeout: GEMINI_CALL_TIMEOUT_MS } });
2013
2318
  }
2014
2319
  async function loadBedrockSdk() {
2015
2320
  const specifier = "@aws-sdk/client-bedrock-runtime";
@@ -2046,11 +2351,13 @@ async function callLlm(cfg2, opts) {
2046
2351
  await owned.aclose();
2047
2352
  }
2048
2353
  }
2049
- var TIMEOUT_MS, ANTHROPIC_VERSION, ANTHROPIC_MAX_TOKENS, OPENAI_FAMILY, LLMClient;
2354
+ var CALL_TIMEOUT_MS, TIMEOUT_MS, GEMINI_CALL_TIMEOUT_MS, ANTHROPIC_VERSION, ANTHROPIC_MAX_TOKENS, OPENAI_FAMILY, LLMClient;
2050
2355
  var init_llm = __esm({
2051
2356
  "src/providers/llm.ts"() {
2052
2357
  init_errors();
2053
- TIMEOUT_MS = 3e4;
2358
+ CALL_TIMEOUT_MS = 24e4;
2359
+ TIMEOUT_MS = CALL_TIMEOUT_MS;
2360
+ GEMINI_CALL_TIMEOUT_MS = CALL_TIMEOUT_MS;
2054
2361
  ANTHROPIC_VERSION = "2023-06-01";
2055
2362
  ANTHROPIC_MAX_TOKENS = 4096;
2056
2363
  OPENAI_FAMILY = /* @__PURE__ */ new Set(["openai", "azure_openai", "custom"]);
@@ -2078,22 +2385,30 @@ var init_llm = __esm({
2078
2385
  await this.aclose();
2079
2386
  }
2080
2387
  async call(opts) {
2081
- const { system, user, jsonMode = false, images = null } = opts;
2388
+ const {
2389
+ system,
2390
+ user,
2391
+ jsonMode = false,
2392
+ images = null,
2393
+ maxTokens = null,
2394
+ thinkingBudget = null,
2395
+ temperature = null
2396
+ } = opts;
2082
2397
  if (this.provider === "anthropic") {
2083
- return this.callAnthropic(system, user, jsonMode, images);
2398
+ return this.callAnthropic(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
2084
2399
  }
2085
2400
  if (OPENAI_FAMILY.has(this.provider)) {
2086
- return this.callOpenAI(system, user, jsonMode, images);
2401
+ return this.callOpenAI(system, user, jsonMode, images, maxTokens, temperature);
2087
2402
  }
2088
2403
  if (this.provider === "gemini") {
2089
- return this.callGemini(system, user, jsonMode, images);
2404
+ return this.callGemini(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
2090
2405
  }
2091
2406
  if (this.provider === "bedrock") {
2092
- return this.callBedrock(system, user, jsonMode, images);
2407
+ return this.callBedrock(system, user, jsonMode, images, maxTokens, thinkingBudget, temperature);
2093
2408
  }
2094
2409
  throw new Error(`unknown llm provider: ${JSON.stringify(this.provider)}`);
2095
2410
  }
2096
- async callAnthropic(system, user, jsonMode, images) {
2411
+ async callAnthropic(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
2097
2412
  if (!this.fetchImpl) {
2098
2413
  throw new Error("anthropic llm client has no fetch implementation");
2099
2414
  }
@@ -2114,26 +2429,27 @@ Respond with valid JSON only.`;
2114
2429
  });
2115
2430
  }
2116
2431
  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
- );
2432
+ const body = {
2433
+ model: this.model,
2434
+ max_tokens: maxTokens ?? ANTHROPIC_MAX_TOKENS,
2435
+ system,
2436
+ messages: [{ role: "user", content }]
2437
+ };
2438
+ if (thinkingBudget) {
2439
+ body.thinking = { type: "enabled", budget_tokens: thinkingBudget };
2440
+ } else if (temperature !== null) {
2441
+ body.temperature = temperature;
2442
+ }
2443
+ const data = await postJson(this.fetchImpl, "https://api.anthropic.com/v1/messages", body, {
2444
+ "x-api-key": this.cfg.apiKey ?? "",
2445
+ "anthropic-version": ANTHROPIC_VERSION,
2446
+ "content-type": "application/json"
2447
+ });
2132
2448
  const text = data.content[0].text;
2133
2449
  const usage = data.usage ?? {};
2134
2450
  return [text, { input: usage.input_tokens ?? 0, output: usage.output_tokens ?? 0 }];
2135
2451
  }
2136
- async callOpenAI(system, user, jsonMode, images) {
2452
+ async callOpenAI(system, user, jsonMode, images, maxTokens, temperature = null) {
2137
2453
  if (!this.client) {
2138
2454
  throw new Error("openai-family llm client has no client");
2139
2455
  }
@@ -2154,12 +2470,18 @@ Respond with valid JSON only.`;
2154
2470
  if (jsonMode) {
2155
2471
  body.response_format = { type: "json_object" };
2156
2472
  }
2473
+ if (maxTokens) {
2474
+ body.max_completion_tokens = maxTokens;
2475
+ }
2476
+ if (temperature !== null) {
2477
+ body.temperature = temperature;
2478
+ }
2157
2479
  const resp = await this.client.chat.completions.create(body);
2158
2480
  const text = resp.choices[0]?.message?.content ?? "";
2159
2481
  const usage = resp.usage;
2160
2482
  return [text, { input: usage?.prompt_tokens ?? 0, output: usage?.completion_tokens ?? 0 }];
2161
2483
  }
2162
- async callGemini(system, user, jsonMode, images) {
2484
+ async callGemini(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
2163
2485
  if (!this.genaiClient) {
2164
2486
  this.genaiClient = await loadGeminiChatClient(this.cfg.apiKey);
2165
2487
  }
@@ -2168,10 +2490,30 @@ Respond with valid JSON only.`;
2168
2490
  parts.push({ inlineData: { mimeType: "image/png", data: toBase64(img) } });
2169
2491
  }
2170
2492
  parts.push({ text: user });
2171
- const config = { systemInstruction: system };
2493
+ const config = {
2494
+ systemInstruction: system,
2495
+ // ALWAYS off, and not as a preference. Automatic function calling means
2496
+ // the SDK itself EXECUTES a callable it was handed as a tool and loops
2497
+ // on the result — up to ten round trips — before returning anything.
2498
+ // This package passes declarations only, so today nothing is executable;
2499
+ // but that depends on every future caller continuing to do the same, and
2500
+ // an application that gates tool execution behind human approval would
2501
+ // have that gate bypassed silently, by a library, with the loop already
2502
+ // run before it could object.
2503
+ automaticFunctionCalling: { disable: true }
2504
+ };
2172
2505
  if (jsonMode) {
2173
2506
  config.responseMimeType = "application/json";
2174
2507
  }
2508
+ if (maxTokens) {
2509
+ config.maxOutputTokens = maxTokens;
2510
+ }
2511
+ if (temperature !== null) {
2512
+ config.temperature = temperature;
2513
+ }
2514
+ if (thinkingBudget !== null) {
2515
+ config.thinkingConfig = { thinkingBudget };
2516
+ }
2175
2517
  const resp = await this.genaiClient.models.generateContent({
2176
2518
  model: this.model,
2177
2519
  contents: parts,
@@ -2183,7 +2525,7 @@ Respond with valid JSON only.`;
2183
2525
  const outputTokens = usageMeta?.candidatesTokenCount ?? usageMeta?.candidates_token_count ?? 0;
2184
2526
  return [text, { input: inputTokens || 0, output: outputTokens || 0 }];
2185
2527
  }
2186
- async callBedrock(system, user, jsonMode, images) {
2528
+ async callBedrock(system, user, jsonMode, images, maxTokens, thinkingBudget = null, temperature = null) {
2187
2529
  const { BedrockRuntimeClient, ConverseCommand } = await loadBedrockSdk();
2188
2530
  if (jsonMode) {
2189
2531
  system = `${system}
@@ -2207,7 +2549,21 @@ Respond with valid JSON only.`;
2207
2549
  new ConverseCommand({
2208
2550
  modelId: this.model,
2209
2551
  system: [{ text: system }],
2210
- messages: [{ role: "user", content }]
2552
+ messages: [{ role: "user", content }],
2553
+ ...maxTokens || temperature !== null ? {
2554
+ inferenceConfig: {
2555
+ ...maxTokens ? { maxTokens } : {},
2556
+ ...temperature !== null ? { temperature } : {}
2557
+ }
2558
+ } : {},
2559
+ // Anthropic-style passthrough — Converse forwards it to the model.
2560
+ // Zero (the vision transcription contract) sends nothing: thinking
2561
+ // is opt-in for the anthropic models bedrock hosts.
2562
+ ...thinkingBudget ? {
2563
+ additionalModelRequestFields: {
2564
+ thinking: { type: "enabled", budget_tokens: thinkingBudget }
2565
+ }
2566
+ } : {}
2211
2567
  })
2212
2568
  );
2213
2569
  const text = result.output?.message?.content?.[0]?.text ?? "";
@@ -2802,6 +3158,20 @@ RETURN JSON SCHEMA (EXACT):
2802
3158
  }`;
2803
3159
  }
2804
3160
  });
3161
+
3162
+ // src/tools/acl.ts
3163
+ function aclVisible(acl, principals) {
3164
+ if (principals === null || principals === TRUSTED) return true;
3165
+ if (acl == null) return true;
3166
+ if (!acl.length) return false;
3167
+ const held = new Set(principals ?? []);
3168
+ return acl.some((p) => held.has(p));
3169
+ }
3170
+ var init_acl = __esm({
3171
+ "src/tools/acl.ts"() {
3172
+ init_sentinels();
3173
+ }
3174
+ });
2805
3175
  function getFileExtension(filename) {
2806
3176
  if (!filename?.includes(".")) return "";
2807
3177
  return filename.slice(filename.lastIndexOf(".")).toLowerCase();
@@ -3618,8 +3988,26 @@ __export(pdf_exports, {
3618
3988
  embeddedTextPerPage: () => embeddedTextPerPage,
3619
3989
  extract: () => extract,
3620
3990
  extractPdf: () => extract,
3991
+ ocrLanguageHint: () => ocrLanguageHint,
3621
3992
  pageCount: () => pageCount
3622
3993
  });
3994
+ function ocrLanguageHint(texts, override) {
3995
+ if (override) return override;
3996
+ let sample = "";
3997
+ const indices = [...texts.keys()].sort((a, b) => a - b).slice(0, 3);
3998
+ for (const idx of indices) {
3999
+ sample += texts.get(idx) ?? "";
4000
+ if (sample.length >= OCR_LANG_SAMPLE_MIN) break;
4001
+ }
4002
+ if (sample.length >= OCR_LANG_SAMPLE_MIN) {
4003
+ try {
4004
+ const detected = detectLanguage(sample, 30);
4005
+ if (detected) return detected;
4006
+ } catch {
4007
+ }
4008
+ }
4009
+ return "auto";
4010
+ }
3623
4011
  function isCcControl(ch) {
3624
4012
  if (ch === "\n" || ch === "\r" || ch === " ") return false;
3625
4013
  const cp = ch.codePointAt(0);
@@ -3672,16 +4060,27 @@ async function extract(content, opts) {
3672
4060
  const { pages, structured: structuredMarkdown } = await classifyPages(content);
3673
4061
  const providerTokens = {};
3674
4062
  let visionMarkdown = false;
4063
+ let benignBlank = /* @__PURE__ */ new Set();
3675
4064
  if (pages.needsOcr.length) {
3676
4065
  if (opts.visionLlm) {
3677
4066
  try {
3678
4067
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
3679
- const [visionTexts, tokens] = await vision.extractPdfPages(content, pages.needsOcr, {
3680
- visionLlm: opts.visionLlm
4068
+ const [visionTexts, tokens, outcomes] = await vision.extractPdfPages(content, pages.needsOcr, {
4069
+ visionLlm: opts.visionLlm,
4070
+ extraction: opts.extraction
3681
4071
  });
3682
4072
  for (const [idx, text] of Object.entries(visionTexts)) {
3683
4073
  pages.texts.set(Number(idx), text);
3684
4074
  }
4075
+ benignBlank = new Set(
4076
+ Object.entries(outcomes).filter(([, o]) => o.status === "blank").map(([idx]) => Number(idx))
4077
+ );
4078
+ const failed = Object.entries(outcomes).filter(([, o]) => o.status === "failed");
4079
+ if (failed.length) {
4080
+ console.warn(
4081
+ `[pdf] vision failed on ${failed.length} of ${pages.needsOcr.length} pages: ` + failed.map(([idx, o]) => `page ${Number(idx) + 1}: ${o.error}`).join("; ")
4082
+ );
4083
+ }
3685
4084
  visionMarkdown = Object.keys(visionTexts).length > 0;
3686
4085
  for (const [key, val] of Object.entries(tokens)) {
3687
4086
  providerTokens[key] = (providerTokens[key] ?? 0) + val;
@@ -3693,9 +4092,17 @@ async function extract(content, opts) {
3693
4092
  try {
3694
4093
  const ocr = await Promise.resolve().then(() => (init_ocr(), ocr_exports));
3695
4094
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
3696
- const rendered = await vision.renderPdfPagesAsImages(content, pages.needsOcr);
4095
+ const rendered = await vision.renderPdfPagesAsImages(
4096
+ content,
4097
+ pages.needsOcr,
4098
+ 150,
4099
+ opts.extraction?.maxRenderPx ?? vision.MAX_RENDER_PX
4100
+ );
4101
+ const ocrConfig = {
4102
+ language: ocrLanguageHint(pages.texts, opts.extraction?.ocrLanguage)
4103
+ };
3697
4104
  for (const [idx, pngBytes] of rendered) {
3698
- const result = await ocr.extractImageTextOcr(pngBytes);
4105
+ const result = await ocr.extractImageTextOcr(pngBytes, ocrConfig);
3699
4106
  if (result.text.trim()) pages.texts.set(idx, result.text.trim());
3700
4107
  }
3701
4108
  } catch (exc) {
@@ -3713,31 +4120,38 @@ async function extract(content, opts) {
3713
4120
  if (t) textParts.push(`--- Page ${i + 1} ---
3714
4121
  ${t}`);
3715
4122
  }
4123
+ const unread = pages.needsOcr.filter((i) => !pages.texts.get(i) && !benignBlank.has(i));
3716
4124
  return new Extracted2({
3717
4125
  text: textParts.join("\n\n"),
3718
4126
  pages: pages.total,
3719
4127
  slides: null,
3720
4128
  mediaOnly: false,
3721
4129
  providerTokens,
3722
- isMarkdown: visionMarkdown || structuredMarkdown
4130
+ isMarkdown: visionMarkdown || structuredMarkdown,
4131
+ unreadableReason: unread.length ? opts.visionLlm ? "vision_failed" : "needs_vision" : null,
4132
+ unreadablePages: unread.length
3723
4133
  });
3724
4134
  }
3725
- var GARBAGE_SCAN_LIMIT, MIN_USABLE_TEXT_LEN;
4135
+ var GARBAGE_SCAN_LIMIT, MIN_USABLE_TEXT_LEN, OCR_LANG_SAMPLE_MIN;
3726
4136
  var init_pdf = __esm({
3727
4137
  "src/extraction/pdf.ts"() {
3728
4138
  init_errors();
3729
4139
  init_hooks();
4140
+ init_text();
3730
4141
  init_pdf_structure();
3731
4142
  GARBAGE_SCAN_LIMIT = 2e4;
3732
4143
  MIN_USABLE_TEXT_LEN = 50;
4144
+ OCR_LANG_SAMPLE_MIN = 100;
3733
4145
  }
3734
4146
  });
3735
4147
 
3736
4148
  // src/extraction/vision.ts
3737
4149
  var vision_exports = {};
3738
4150
  __export(vision_exports, {
4151
+ MAX_RENDER_PX: () => MAX_RENDER_PX,
3739
4152
  PAGES_PER_VISION_BATCH: () => PAGES_PER_VISION_BATCH,
3740
4153
  VISION_BATCH_ATTEMPTS: () => VISION_BATCH_ATTEMPTS,
4154
+ VISION_MAX_OUTPUT_TOKENS: () => VISION_MAX_OUTPUT_TOKENS,
3741
4155
  assignBatchPages: () => assignBatchPages,
3742
4156
  coercePagesResult: () => coercePagesResult,
3743
4157
  detectPdfVisionPages: () => detectPdfVisionPages,
@@ -3754,6 +4168,17 @@ function pagesPrompt(nImages) {
3754
4168
  Return JSON: {"pages": [{"page": 1, "text": "<markdown for this page>"}]}
3755
4169
  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
4170
  }
4171
+ function asText(value) {
4172
+ if (typeof value === "string") return value;
4173
+ if (value == null || typeof value === "boolean") return "";
4174
+ if (Array.isArray(value)) {
4175
+ return value.filter((v) => v != null && v !== "").map(asText).join("\n");
4176
+ }
4177
+ if (typeof value === "object") {
4178
+ return Object.values(value).filter((v) => v != null && v !== "").map(asText).join("\n");
4179
+ }
4180
+ return String(value);
4181
+ }
3757
4182
  function coercePagesResult(parsed) {
3758
4183
  let base;
3759
4184
  let rawPages;
@@ -3773,7 +4198,7 @@ function coercePagesResult(parsed) {
3773
4198
  const rec = entry;
3774
4199
  pages.push({
3775
4200
  page: typeof rec.page === "number" ? rec.page : i + 1,
3776
- text: typeof rec.text === "string" ? rec.text : ""
4201
+ text: asText(rec.text)
3777
4202
  });
3778
4203
  } else if (typeof entry === "string") {
3779
4204
  pages.push({ page: i + 1, text: entry });
@@ -3804,16 +4229,28 @@ function parsePagesResponse(rawText) {
3804
4229
  function sleep(ms) {
3805
4230
  return new Promise((r) => setTimeout(r, ms));
3806
4231
  }
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));
4232
+ async function capped(image, canvas, maxPx) {
4233
+ if (!canvas) return image;
4234
+ try {
4235
+ const img = await canvas.loadImage(image);
4236
+ const longEdge = Math.max(img.width, img.height);
4237
+ if (longEdge <= maxPx) return image;
4238
+ const ratio = maxPx / longEdge;
4239
+ const w = Math.max(Math.round(img.width * ratio), 1);
4240
+ const h = Math.max(Math.round(img.height * ratio), 1);
4241
+ const out = canvas.createCanvas(w, h);
4242
+ out.getContext("2d").drawImage(img, 0, 0, w, h);
4243
+ return out.toBuffer("image/png");
4244
+ } catch {
4245
+ return image;
3813
4246
  }
3814
- const results = Array.from({ length: images.length }, () => "");
4247
+ }
4248
+ async function runBatches(batchSource, opts) {
4249
+ const started = Date.now();
4250
+ const results = /* @__PURE__ */ new Map();
4251
+ const outcomes = /* @__PURE__ */ new Map();
3815
4252
  const tokensTotal = { input: 0, output: 0 };
3816
- const semaphore = new Semaphore(maxConcurrent);
4253
+ const maxConcurrent = opts.maxConcurrent ?? 8;
3817
4254
  let lock = Promise.resolve();
3818
4255
  const withLock = async (fn) => {
3819
4256
  const prev = lock;
@@ -3829,66 +4266,183 @@ async function extractTextFromImages(images, opts) {
3829
4266
  }
3830
4267
  };
3831
4268
  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
4269
+ let total = 0;
4270
+ let nBatches = 0;
4271
+ const queue = new BoundedQueue(maxConcurrent);
4272
+ const processBatch = async (batchNum, baseIdx, batchImages) => {
4273
+ const batchStarted = Date.now();
4274
+ let pendingLocal = batchImages.map((_, i) => i);
4275
+ for (let attempt = 1; attempt <= VISION_BATCH_ATTEMPTS; attempt++) {
4276
+ const ask = pendingLocal.map((i) => batchImages[i]);
4277
+ try {
4278
+ const [rawText, usage] = await callLlm(opts.visionLlm, {
4279
+ system: VISION_SYSTEM_PROMPT,
4280
+ user: pagesPrompt(ask.length),
4281
+ jsonMode: true,
4282
+ images: ask,
4283
+ maxTokens: opts.extCfg.visionMaxOutputTokens,
4284
+ // Transcription is not reasoning, and on a thinking model the two
4285
+ // compete for the SAME budget — see `LlmCallOpts.thinkingBudget`.
4286
+ // Zero, not small: there is nothing here to reason ABOUT, the model
4287
+ // is being asked what is on the page. `temperature: 0` for the same
4288
+ // reason — one scan must transcribe to the same text twice.
4289
+ thinkingBudget: 0,
4290
+ temperature: 0,
4291
+ client
4292
+ });
4293
+ if (!rawText?.trim()) throw new Error("empty vision response");
4294
+ const [, pages] = parsePagesResponse(rawText);
4295
+ const assigned = assignBatchPages(pages, ask.length);
4296
+ const batchSeconds = (Date.now() - batchStarted) / 1e3;
4297
+ const stillMissing = [];
4298
+ await withLock(() => {
4299
+ tokensTotal.input += usage.input ?? 0;
4300
+ tokensTotal.output += usage.output ?? 0;
4301
+ pendingLocal.forEach((localIdx, position) => {
4302
+ const pageText = assigned[position];
4303
+ if (pageText === void 0) {
4304
+ stillMissing.push(localIdx);
4305
+ return;
4306
+ }
4307
+ results.set(baseIdx + localIdx, pageText);
4308
+ outcomes.set(baseIdx + localIdx, {
4309
+ status: pageText.trim() ? "read" : "blank",
4310
+ attempts: attempt,
4311
+ error: null,
4312
+ seconds: batchSeconds
4313
+ });
3845
4314
  });
3846
- if (!rawText?.trim()) throw new Error("empty vision response");
3847
- const [, pages] = parsePagesResponse(rawText);
3848
- const assigned = assignBatchPages(pages, nImages);
4315
+ });
4316
+ pendingLocal = stillMissing;
4317
+ if (pendingLocal.length === 0) return;
4318
+ if (attempt < VISION_BATCH_ATTEMPTS) {
4319
+ console.warn(
4320
+ `[vision] batch ${batchNum} attempt ${attempt} answered ${ask.length - pendingLocal.length} of ${ask.length} pages \u2014 asking again for ${pendingLocal.length}`
4321
+ );
4322
+ continue;
4323
+ }
4324
+ console.error(
4325
+ `[vision] batch ${batchNum}: ${pendingLocal.length} page(s) never came back after ${attempt} attempts`
4326
+ );
4327
+ await withLock(() => {
4328
+ for (const localIdx of pendingLocal) {
4329
+ outcomes.set(baseIdx + localIdx, {
4330
+ status: "failed",
4331
+ attempts: attempt,
4332
+ error: "page missing from batch response",
4333
+ seconds: batchSeconds
4334
+ });
4335
+ }
4336
+ });
4337
+ return;
4338
+ } catch (exc) {
4339
+ if (attempt < VISION_BATCH_ATTEMPTS) {
4340
+ console.warn(`[vision] batch ${batchNum} attempt ${attempt} failed: ${exc} \u2014 retrying`);
4341
+ await sleep(2e3 * attempt);
4342
+ } else {
4343
+ console.error(`[vision] batch ${batchNum} failed after ${VISION_BATCH_ATTEMPTS} attempts: ${exc}`);
4344
+ const batchSeconds = (Date.now() - batchStarted) / 1e3;
3849
4345
  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;
4346
+ for (const localIdx of pendingLocal) {
4347
+ outcomes.set(baseIdx + localIdx, {
4348
+ status: "failed",
4349
+ attempts: attempt,
4350
+ error: String(exc),
4351
+ seconds: batchSeconds
4352
+ });
3853
4353
  }
3854
- tokensTotal.input += usage.input ?? 0;
3855
- tokensTotal.output += usage.output ?? 0;
3856
4354
  });
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
4355
  }
3868
4356
  }
3869
- } finally {
3870
- semaphore.release();
3871
4357
  }
3872
4358
  };
4359
+ const worker = async () => {
4360
+ for (; ; ) {
4361
+ const item = await queue.get();
4362
+ if (item === null) return;
4363
+ await processBatch(...item);
4364
+ }
4365
+ };
4366
+ const workers = Array.from({ length: maxConcurrent }, () => worker());
3873
4367
  try {
3874
- await Promise.all(batches.map((b, i) => processBatch(i, b)));
4368
+ for await (const [baseIdx, batchImages] of batchSource) {
4369
+ if (!batchImages.length) continue;
4370
+ for (let j = 0; j < batchImages.length; j++) {
4371
+ outcomes.set(baseIdx + j, { status: "failed", attempts: 0, error: "never attempted", seconds: 0 });
4372
+ }
4373
+ total = Math.max(total, baseIdx + batchImages.length);
4374
+ await queue.put([nBatches, baseIdx, batchImages]);
4375
+ nBatches += 1;
4376
+ }
4377
+ queue.close();
4378
+ await Promise.all(workers);
4379
+ } catch (exc) {
4380
+ queue.abort();
4381
+ await Promise.allSettled(workers);
4382
+ throw exc;
3875
4383
  } finally {
3876
4384
  await client.aclose();
3877
4385
  }
3878
- return [results, tokensTotal];
4386
+ const texts = Array.from({ length: total }, (_, i) => results.get(i) ?? "");
4387
+ const outcomeList = Array.from(
4388
+ { length: total },
4389
+ (_, i) => outcomes.get(i) ?? { status: "failed", attempts: 0, error: "never attempted", seconds: 0 }
4390
+ );
4391
+ const counts = { read: 0, blank: 0, failed: 0 };
4392
+ for (const o of outcomeList) counts[o.status] += 1;
4393
+ console.info(
4394
+ `[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`
4395
+ );
4396
+ return [texts, tokensTotal, outcomeList];
4397
+ }
4398
+ async function extractTextFromImages(images, opts) {
4399
+ if (!images.length) return [[], {}, []];
4400
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
4401
+ const canvasModule = await tryImport("@napi-rs/canvas");
4402
+ const sendable = await Promise.all(images.map((img) => capped(img, canvasModule, extCfg.maxRenderPx)));
4403
+ const batchSize = extCfg.pagesPerVisionBatch ?? PAGES_PER_VISION_BATCH;
4404
+ async function* batches() {
4405
+ for (let i = 0; i < sendable.length; i += batchSize) {
4406
+ yield [i, sendable.slice(i, i + batchSize)];
4407
+ }
4408
+ }
4409
+ return runBatches(batches(), {
4410
+ visionLlm: opts.visionLlm,
4411
+ extCfg,
4412
+ maxConcurrent: opts.maxConcurrent
4413
+ });
3879
4414
  }
3880
4415
  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 });
4416
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
4417
+ const batchSize = extCfg.pagesPerVisionBatch ?? PAGES_PER_VISION_BATCH;
4418
+ const pageOrder = [];
4419
+ async function* renderedBatches() {
4420
+ let pos = 0;
4421
+ for (let start = 0; start < pageIndices.length; start += batchSize) {
4422
+ const group = pageIndices.slice(start, start + batchSize);
4423
+ const rendered = await renderPdfPagesAsImages(content, group, 150, extCfg.maxRenderPx);
4424
+ if (!rendered.length) continue;
4425
+ pageOrder.push(...rendered.map(([idx]) => idx));
4426
+ yield [pos, rendered.map(([, png]) => png)];
4427
+ pos += rendered.length;
4428
+ }
4429
+ }
4430
+ const [texts, tokens, outcomes] = await runBatches(renderedBatches(), {
4431
+ visionLlm: opts.visionLlm,
4432
+ extCfg
4433
+ });
3884
4434
  const pageTexts = {};
3885
- for (let i = 0; i < rendered.length; i++) {
4435
+ const pageOutcomes = {};
4436
+ for (let i = 0; i < pageOrder.length; i++) {
3886
4437
  const text = texts[i];
3887
- if (text?.trim()) pageTexts[rendered[i][0]] = text;
4438
+ if (text?.trim()) pageTexts[pageOrder[i]] = text;
4439
+ const outcome = outcomes[i];
4440
+ if (outcome) pageOutcomes[pageOrder[i]] = outcome;
3888
4441
  }
3889
- return [pageTexts, tokens];
4442
+ return [pageTexts, tokens, pageOutcomes];
3890
4443
  }
3891
- async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
4444
+ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150, maxPx = MAX_RENDER_PX) {
4445
+ const started = Date.now();
3892
4446
  const scale = dpi / 72;
3893
4447
  const data = new Uint8Array(content);
3894
4448
  const pdf = await getDocumentProxy(data);
@@ -3897,9 +4451,12 @@ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
3897
4451
  for (const idx of pageIndices) {
3898
4452
  if (idx < 0 || idx >= pdf.numPages) continue;
3899
4453
  try {
4454
+ const page = await pdf.getPage(idx + 1);
4455
+ const viewport = page.getViewport({ scale: 1 });
4456
+ const pageScale = Math.min(scale, maxPx / Math.max(viewport.width, viewport.height, 1));
3900
4457
  const buf = await renderPageAsImage(pdf, idx + 1, {
3901
4458
  canvasImport: canvasImport ? async () => canvasImport : void 0,
3902
- scale
4459
+ scale: pageScale
3903
4460
  });
3904
4461
  const bytes = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
3905
4462
  results.push([idx, Buffer.from(bytes)]);
@@ -3907,6 +4464,7 @@ async function renderPdfPagesAsImages(content, pageIndices, dpi = 150) {
3907
4464
  console.warn(`[vision] failed to render PDF page ${idx}:`, exc);
3908
4465
  }
3909
4466
  }
4467
+ console.info(`[pdf] rastered ${results.length} pages in ${((Date.now() - started) / 1e3).toFixed(1)}s`);
3910
4468
  return results;
3911
4469
  }
3912
4470
  async function detectPdfVisionPages(content) {
@@ -3928,7 +4486,13 @@ async function detectPdfVisionPages(content) {
3928
4486
  return visionPages;
3929
4487
  }
3930
4488
  async function extractDocxImages(content) {
3931
- const zip = await JSZip.loadAsync(content);
4489
+ let zip;
4490
+ try {
4491
+ zip = await JSZip.loadAsync(content);
4492
+ } catch (exc) {
4493
+ console.warn(`[vision] cannot open DOCX for images: ${exc}`);
4494
+ return [];
4495
+ }
3932
4496
  const rels = zip.file("word/_rels/document.xml.rels");
3933
4497
  if (!rels) return [];
3934
4498
  const xml = await rels.async("string");
@@ -3954,7 +4518,13 @@ async function extractDocxImages(content) {
3954
4518
  return images;
3955
4519
  }
3956
4520
  async function extractPptxImages(content) {
3957
- const zip = await JSZip.loadAsync(content);
4521
+ let zip;
4522
+ try {
4523
+ zip = await JSZip.loadAsync(content);
4524
+ } catch (exc) {
4525
+ console.warn(`[vision] cannot open PPTX for images: ${exc}`);
4526
+ return [];
4527
+ }
3958
4528
  const slideNames = Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n)).sort((a, b) => {
3959
4529
  const na = Number(/slide(\d+)/.exec(a)?.[1] ?? 0);
3960
4530
  const nb = Number(/slide(\d+)/.exec(b)?.[1] ?? 0);
@@ -3987,12 +4557,22 @@ async function extractPptxImages(content) {
3987
4557
  }
3988
4558
  async function extractImage(content, opts) {
3989
4559
  const { Extracted: Extracted2 } = await Promise.resolve().then(() => (init_extraction(), extraction_exports));
4560
+ const extCfg = opts.extraction ?? EXTRACTION_DEFAULTS;
4561
+ const canvasModule = await tryImport("@napi-rs/canvas");
4562
+ const image = await capped(content, canvasModule, extCfg.maxRenderPx);
3990
4563
  const [rawText, usage] = await callLlm(opts.visionLlm, {
3991
4564
  system: VISION_SYSTEM_PROMPT,
3992
4565
  user: `${MARKDOWN_RULES}
3993
4566
 
3994
4567
  Transcribe this single image. Return the Markdown only.`,
3995
- images: [content]
4568
+ images: [image],
4569
+ maxTokens: extCfg.visionMaxOutputTokens,
4570
+ // Same contract as the batched path (see processBatch): with the output
4571
+ // cap in place, default-on thinking bills against it — an all-thinking
4572
+ // truncated-empty reply lands as vision_failed. Transcription is not
4573
+ // reasoning, and it must be deterministic.
4574
+ thinkingBudget: 0,
4575
+ temperature: 0
3996
4576
  });
3997
4577
  const text = (rawText || "").trim();
3998
4578
  return new Extracted2({
@@ -4001,37 +4581,75 @@ Transcribe this single image. Return the Markdown only.`,
4001
4581
  slides: null,
4002
4582
  mediaOnly: !text,
4003
4583
  providerTokens: usage ?? {},
4004
- isMarkdown: Boolean(text)
4584
+ isMarkdown: Boolean(text),
4585
+ // A configured reader that returns nothing (refusal, filter, truncation)
4586
+ // is the same "reader read nothing" state the PDF path labels
4587
+ // vision_failed — not a blank image with no explanation.
4588
+ unreadableReason: text ? null : "vision_failed",
4589
+ unreadablePages: text ? 0 : 1
4005
4590
  });
4006
4591
  }
4007
- var PAGES_PER_VISION_BATCH, VISION_BATCH_ATTEMPTS, VISION_SYSTEM_PROMPT, MARKDOWN_RULES, Semaphore;
4592
+ var VISION_BATCH_ATTEMPTS, EXTRACTION_DEFAULTS, MAX_RENDER_PX, VISION_MAX_OUTPUT_TOKENS, PAGES_PER_VISION_BATCH, VISION_SYSTEM_PROMPT, MARKDOWN_RULES, BoundedQueue;
4008
4593
  var init_vision = __esm({
4009
4594
  "src/extraction/vision.ts"() {
4595
+ init_config();
4010
4596
  init_extras();
4011
4597
  init_llm();
4012
4598
  init_json();
4013
4599
  init_pdf();
4014
- PAGES_PER_VISION_BATCH = 5;
4015
4600
  VISION_BATCH_ATTEMPTS = 3;
4601
+ EXTRACTION_DEFAULTS = extractionSchema.parse({});
4602
+ MAX_RENDER_PX = EXTRACTION_DEFAULTS.maxRenderPx;
4603
+ VISION_MAX_OUTPUT_TOKENS = EXTRACTION_DEFAULTS.visionMaxOutputTokens;
4604
+ PAGES_PER_VISION_BATCH = EXTRACTION_DEFAULTS.pagesPerVisionBatch;
4016
4605
  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
4606
  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;
4607
+ BoundedQueue = class {
4608
+ constructor(maxsize) {
4609
+ this.maxsize = maxsize;
4610
+ }
4611
+ maxsize;
4612
+ items = [];
4613
+ putters = [];
4614
+ getters = [];
4615
+ closed = false;
4616
+ async put(item) {
4617
+ while (!this.closed && this.items.length >= this.maxsize) {
4618
+ await new Promise((resolve) => this.putters.push(resolve));
4619
+ }
4620
+ if (this.closed) return;
4621
+ this.items.push(item);
4622
+ this.getters.shift()?.();
4623
+ }
4624
+ /** `null` once the queue is closed AND drained — the worker's exit signal. */
4625
+ async get() {
4626
+ for (; ; ) {
4627
+ if (this.items.length) {
4628
+ const item = this.items.shift();
4629
+ this.putters.shift()?.();
4630
+ return item;
4631
+ }
4632
+ if (this.closed) return null;
4633
+ await new Promise((resolve) => this.getters.push(resolve));
4028
4634
  }
4029
- await new Promise((resolve) => this.waiters.push(resolve));
4030
4635
  }
4031
- release() {
4032
- const next = this.waiters.shift();
4033
- if (next) next();
4034
- else this.n += 1;
4636
+ close() {
4637
+ this.closed = true;
4638
+ for (const wake of this.getters.splice(0)) wake();
4639
+ for (const wake of this.putters.splice(0)) wake();
4640
+ }
4641
+ /**
4642
+ * Like `close()`, but for the error path: DISCARDS whatever is still
4643
+ * queued rather than letting it drain. A batch already in a worker's hands
4644
+ * keeps running (there is no cancelling that), but one that only ever sat
4645
+ * in the queue must never start — the source has already failed, so
4646
+ * spending a full vision-LLM retry budget on it buys nothing.
4647
+ */
4648
+ abort() {
4649
+ this.closed = true;
4650
+ this.items.length = 0;
4651
+ for (const wake of this.getters.splice(0)) wake();
4652
+ for (const wake of this.putters.splice(0)) wake();
4035
4653
  }
4036
4654
  };
4037
4655
  }
@@ -4041,7 +4659,8 @@ var init_vision = __esm({
4041
4659
  var extraction_exports = {};
4042
4660
  __export(extraction_exports, {
4043
4661
  Extracted: () => Extracted,
4044
- extract: () => extract2
4662
+ extract: () => extract2,
4663
+ extractEmbeddedImagesText: () => extractEmbeddedImagesText
4045
4664
  });
4046
4665
  function isPdf(ext, mime) {
4047
4666
  return ext === ".pdf" || mime === "application/pdf";
@@ -4049,14 +4668,50 @@ function isPdf(ext, mime) {
4049
4668
  function isImage(ext, mime) {
4050
4669
  return mime.startsWith("image/") || IMAGE_EXTS.has(ext);
4051
4670
  }
4052
- async function extractEmbeddedImagesText(images, visionLlm, hooks) {
4671
+ async function officeUnreadable(text, images, visionLlm) {
4672
+ if (text.trim()) return { reason: null, unread: 0 };
4673
+ let readable = 0;
4674
+ for (const img of images) {
4675
+ if (!await embeddedImageIsNegligible(img.imageBytes)) readable += 1;
4676
+ }
4677
+ if (!readable) return { reason: null, unread: 0 };
4678
+ return { reason: visionLlm ? "vision_failed" : "needs_vision", unread: readable };
4679
+ }
4680
+ async function embeddedImageIsNegligible(data) {
4681
+ if (data.length < MIN_EMBEDDED_IMAGE_BYTES) return true;
4682
+ const canvas = await tryImport("@napi-rs/canvas");
4683
+ if (!canvas) return false;
4684
+ try {
4685
+ const img = await canvas.loadImage(data);
4686
+ return Math.max(img.width, img.height) < MIN_EMBEDDED_IMAGE_PX;
4687
+ } catch {
4688
+ return false;
4689
+ }
4690
+ }
4691
+ async function extractEmbeddedImagesText(images, visionLlm, hooks, extraction) {
4053
4692
  if (!images.length) return [[], {}];
4054
4693
  try {
4055
4694
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
4056
- return await vision.extractTextFromImages(
4057
- images.map((img) => img.imageBytes),
4058
- { visionLlm }
4059
- );
4695
+ const { createHash: createHash3 } = await import('crypto');
4696
+ const keys = [];
4697
+ const firstAt = /* @__PURE__ */ new Map();
4698
+ const send = [];
4699
+ for (const img of images) {
4700
+ const blob = img.imageBytes;
4701
+ if (await embeddedImageIsNegligible(blob)) {
4702
+ keys.push(null);
4703
+ continue;
4704
+ }
4705
+ const digest = createHash3("sha256").update(blob).digest("hex");
4706
+ if (!firstAt.has(digest)) {
4707
+ firstAt.set(digest, send.length);
4708
+ send.push(blob);
4709
+ }
4710
+ keys.push(digest);
4711
+ }
4712
+ if (!send.length) return [images.map(() => ""), {}];
4713
+ const [texts, tokens] = await vision.extractTextFromImages(send, { visionLlm, extraction });
4714
+ return [keys.map((k) => k == null ? "" : texts[firstAt.get(k)] ?? ""), tokens];
4060
4715
  } catch (exc) {
4061
4716
  emitError(hooks, exc, { stage: "embedded_image_vision" });
4062
4717
  return [[], {}];
@@ -4067,22 +4722,35 @@ async function extract2(content, filename, mime, opts) {
4067
4722
  const ext = getFileExtension(filename);
4068
4723
  const visionLlm = opts.visionLlm ?? null;
4069
4724
  const hooks = opts.hooks;
4725
+ const extraction = opts.extraction ?? null;
4070
4726
  try {
4071
4727
  if (isNonIngestibleMedia(filename, m)) {
4072
4728
  return new Extracted({ text: "", mediaOnly: true });
4073
4729
  }
4074
4730
  if (isPdf(ext, m)) {
4075
4731
  const pdf = await Promise.resolve().then(() => (init_pdf(), pdf_exports));
4076
- return await pdf.extract(content, { visionLlm, hooks });
4732
+ return await pdf.extract(content, { visionLlm, hooks, extraction });
4077
4733
  }
4078
4734
  if (isImage(ext, m)) {
4079
- if (!visionLlm) return new Extracted({ text: "", mediaOnly: true });
4735
+ if (!visionLlm) {
4736
+ return new Extracted({
4737
+ text: "",
4738
+ mediaOnly: true,
4739
+ unreadableReason: "needs_vision",
4740
+ unreadablePages: 1
4741
+ });
4742
+ }
4080
4743
  try {
4081
4744
  const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
4082
- return await vision.extractImage(content, { visionLlm });
4745
+ return await vision.extractImage(content, { visionLlm, extraction });
4083
4746
  } catch (exc) {
4084
4747
  emitError(hooks, exc, { stage: "image_vision", filename });
4085
- return new Extracted({ text: "", mediaOnly: true });
4748
+ return new Extracted({
4749
+ text: "",
4750
+ mediaOnly: true,
4751
+ unreadableReason: "vision_failed",
4752
+ unreadablePages: 1
4753
+ });
4086
4754
  }
4087
4755
  }
4088
4756
  if (ext === ".docx" || m === DOCX_MIME) {
@@ -4090,10 +4758,11 @@ async function extract2(content, filename, mime, opts) {
4090
4758
  let providerTokens = {};
4091
4759
  let llmPictures = 0;
4092
4760
  let combined = text;
4761
+ let images = [];
4093
4762
  if (visionLlm) {
4094
4763
  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);
4764
+ images = await vision.extractDocxImages(content);
4765
+ const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks, extraction);
4097
4766
  for (let i = 0; i < texts.length; i++) {
4098
4767
  const imgText = texts[i];
4099
4768
  if (imgText) {
@@ -4104,12 +4773,18 @@ ${imgText}`;
4104
4773
  }
4105
4774
  }
4106
4775
  providerTokens = tokens;
4776
+ } else if (!text.trim()) {
4777
+ const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
4778
+ images = await vision.extractDocxImages(content);
4107
4779
  }
4780
+ const { reason, unread } = await officeUnreadable(combined, images, visionLlm);
4108
4781
  return new Extracted({
4109
4782
  text: combined,
4110
4783
  pages: await countDocxPages(content),
4111
4784
  providerTokens,
4112
- llmPictures
4785
+ llmPictures,
4786
+ unreadableReason: reason,
4787
+ unreadablePages: unread
4113
4788
  });
4114
4789
  }
4115
4790
  if (ext === ".pptx" || m === PPTX_MIME) {
@@ -4117,10 +4792,11 @@ ${imgText}`;
4117
4792
  let providerTokens = {};
4118
4793
  let llmPictures = 0;
4119
4794
  let combined = text;
4795
+ let images = [];
4120
4796
  if (visionLlm) {
4121
4797
  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);
4798
+ images = await vision.extractPptxImages(content);
4799
+ const [texts, tokens] = await extractEmbeddedImagesText(images, visionLlm, hooks, extraction);
4124
4800
  for (let i = 0; i < texts.length; i++) {
4125
4801
  const imgText = texts[i];
4126
4802
  if (imgText) {
@@ -4132,12 +4808,18 @@ ${imgText}`;
4132
4808
  }
4133
4809
  }
4134
4810
  providerTokens = tokens;
4811
+ } else if (!text.trim()) {
4812
+ const vision = await Promise.resolve().then(() => (init_vision(), vision_exports));
4813
+ images = await vision.extractPptxImages(content);
4135
4814
  }
4815
+ const { reason, unread } = await officeUnreadable(combined, images, visionLlm);
4136
4816
  return new Extracted({
4137
4817
  text: combined,
4138
4818
  slides: await countPptxSlides(content),
4139
4819
  providerTokens,
4140
- llmPictures
4820
+ llmPictures,
4821
+ unreadableReason: reason,
4822
+ unreadablePages: unread
4141
4823
  });
4142
4824
  }
4143
4825
  if (ext === ".xlsx" || m === XLSX_MIME) {
@@ -4161,9 +4843,10 @@ ${imgText}`;
4161
4843
  return new Extracted({ text: content.toString("utf8") });
4162
4844
  }
4163
4845
  }
4164
- var IMAGE_EXTS, Extracted;
4846
+ var IMAGE_EXTS, Extracted, MIN_EMBEDDED_IMAGE_BYTES, MIN_EMBEDDED_IMAGE_PX;
4165
4847
  var init_extraction = __esm({
4166
4848
  "src/extraction/index.ts"() {
4849
+ init_extras();
4167
4850
  init_hooks();
4168
4851
  init_files();
4169
4852
  IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".webp"]);
@@ -4175,6 +4858,10 @@ var init_extraction = __esm({
4175
4858
  providerTokens;
4176
4859
  llmPictures;
4177
4860
  isMarkdown;
4861
+ /** See `UnreadableReason`. `mediaOnly` is the same idea for whole files and
4862
+ * stays set alongside this, so existing callers keep working. */
4863
+ unreadableReason;
4864
+ unreadablePages;
4178
4865
  constructor(init) {
4179
4866
  this.text = init.text;
4180
4867
  this.pages = init.pages ?? null;
@@ -4183,8 +4870,12 @@ var init_extraction = __esm({
4183
4870
  this.providerTokens = init.providerTokens ?? {};
4184
4871
  this.llmPictures = init.llmPictures ?? 0;
4185
4872
  this.isMarkdown = init.isMarkdown ?? false;
4873
+ this.unreadableReason = init.unreadableReason ?? null;
4874
+ this.unreadablePages = init.unreadablePages ?? 0;
4186
4875
  }
4187
4876
  };
4877
+ MIN_EMBEDDED_IMAGE_BYTES = 3 * 1024;
4878
+ MIN_EMBEDDED_IMAGE_PX = 100;
4188
4879
  }
4189
4880
  });
4190
4881
  function checkProvider(cfg2) {
@@ -4362,7 +5053,9 @@ async function prepare(opts) {
4362
5053
  mediaOnly: false,
4363
5054
  providerTokens: {},
4364
5055
  contentHash: calculateContentHash({ fullText: clean }),
4365
- isMarkdown: false
5056
+ isMarkdown: false,
5057
+ unreadableReason: null,
5058
+ unreadablePages: 0
4366
5059
  };
4367
5060
  }
4368
5061
  const content = opts.content;
@@ -4370,11 +5063,12 @@ async function prepare(opts) {
4370
5063
  const mime = mimeForFilename(fname) || "";
4371
5064
  const extracted = await extract2(content, fname, mime || null, {
4372
5065
  visionLlm: opts.config.visionLlm,
5066
+ extraction: opts.config.extraction,
4373
5067
  hooks: opts.hooks
4374
5068
  });
4375
5069
  let body = sanitizeText(extracted.text || "") || "";
4376
5070
  const effectiveMime = mime || inferMime(body, fname) || DEFAULT_MIME;
4377
- if (effectiveMime === "application/pdf" && body) body = dedupLines(body);
5071
+ if (effectiveMime === "application/pdf" && body && !extracted.isMarkdown) body = dedupLines(body);
4378
5072
  return {
4379
5073
  text: body,
4380
5074
  mime: effectiveMime,
@@ -4385,7 +5079,9 @@ async function prepare(opts) {
4385
5079
  mediaOnly: Boolean(extracted.mediaOnly),
4386
5080
  providerTokens: { ...extracted.providerTokens ?? {} },
4387
5081
  contentHash: calculateContentHash({ docBytes: content }),
4388
- isMarkdown: Boolean(extracted.isMarkdown)
5082
+ isMarkdown: Boolean(extracted.isMarkdown),
5083
+ unreadableReason: extracted.unreadableReason ?? null,
5084
+ unreadablePages: extracted.unreadablePages ?? 0
4389
5085
  };
4390
5086
  }
4391
5087
  function computeUnits(prepared) {
@@ -4563,7 +5259,11 @@ async function decide(pool, opts) {
4563
5259
  }
4564
5260
  const same = stored != null && incoming != null && (Buffer.isBuffer(stored) && Buffer.isBuffer(incoming) ? Buffer.compare(stored, incoming) === 0 : stored === incoming);
4565
5261
  if (!same) return { action: "process", documentId: existingId };
4566
- if (existing.status === "skipped") return { action: "skip", documentId: existingId };
5262
+ if (existing.status === "skipped") {
5263
+ const metaData = existing.meta_data;
5264
+ if (metaData?.unreadable_reason) return { action: "process", documentId: existingId };
5265
+ return { action: "skip", documentId: existingId };
5266
+ }
4567
5267
  const reqMode = opts.request.mode ?? "hybrid";
4568
5268
  if ((MODE_RANK[reqMode] ?? 0) <= (MODE_RANK[existing.mode || "hybrid"] ?? 0)) {
4569
5269
  return { action: "skip", documentId: existingId };
@@ -4854,6 +5554,22 @@ function singleReport(doc) {
4854
5554
  async function maybeAwait(value) {
4855
5555
  return value;
4856
5556
  }
5557
+ function progress(hooks, request, displayName, documentId, stage, state, detail = {}) {
5558
+ emitProgress(hooks, {
5559
+ sourceId: request.sourceId ?? null,
5560
+ externalId: request.externalId ?? null,
5561
+ documentId: documentId == null ? null : String(documentId),
5562
+ name: displayName,
5563
+ stage,
5564
+ state,
5565
+ detail: { ...detail }
5566
+ });
5567
+ }
5568
+ function extractMethod(prepared, text) {
5569
+ if (text != null) return "text";
5570
+ if (Object.keys(prepared.providerTokens).length || prepared.isMarkdown) return "vision";
5571
+ return "parser";
5572
+ }
4857
5573
  async function runModeUpgrade(request, opts) {
4858
5574
  await refreshOnSkip(request, {
4859
5575
  pool: opts.pool,
@@ -4867,6 +5583,7 @@ async function runModeUpgrade(request, opts) {
4867
5583
  let chunkCount = 0;
4868
5584
  try {
4869
5585
  if (request.mode === "graph" && opts.graphStage) {
5586
+ progress(opts.hooks, request, opts.displayName, opts.documentId, "graph", "started");
4870
5587
  graphUnits2 = Number(
4871
5588
  await maybeAwait(
4872
5589
  opts.graphStage({
@@ -4877,6 +5594,9 @@ async function runModeUpgrade(request, opts) {
4877
5594
  })
4878
5595
  ) || 0
4879
5596
  );
5597
+ progress(opts.hooks, request, opts.displayName, opts.documentId, "graph", "done", {
5598
+ graph_units: graphUnits2
5599
+ });
4880
5600
  }
4881
5601
  chunkCount = await upgradeMode(opts.pool, opts.documentId, request.mode ?? "hybrid");
4882
5602
  } catch (exc) {
@@ -4966,6 +5686,7 @@ async function runIngest(request, opts) {
4966
5686
  );
4967
5687
  }
4968
5688
  }
5689
+ progress(opts.hooks, request, displayName, null, "extract", "started");
4969
5690
  const prepared = await prepare({
4970
5691
  content,
4971
5692
  filename,
@@ -4974,6 +5695,11 @@ async function runIngest(request, opts) {
4974
5695
  config: opts.config,
4975
5696
  hooks: opts.hooks
4976
5697
  });
5698
+ progress(opts.hooks, request, displayName, null, "extract", "done", {
5699
+ pages: prepared.pages,
5700
+ method: extractMethod(prepared, text),
5701
+ mime: prepared.mime
5702
+ });
4977
5703
  const ingestHash = sha256Bytes(prepared.text || "");
4978
5704
  const decision = await decide(opts.pool, {
4979
5705
  request: { ...request, mode },
@@ -5060,8 +5786,11 @@ async function runIngest(request, opts) {
5060
5786
  const metaUpdates = { ...request.metaData ?? {} };
5061
5787
  metaUpdates.mime_type = prepared.mime;
5062
5788
  if (prepared.contentHash) metaUpdates.content_hash = prepared.contentHash;
5789
+ metaUpdates.unreadable_reason = prepared.unreadableReason;
5790
+ metaUpdates.unreadable_pages = prepared.unreadablePages;
5063
5791
  const redactionFailed = [];
5064
5792
  let documentText;
5793
+ progress(opts.hooks, request, displayName, documentId, "redact", "started");
5065
5794
  try {
5066
5795
  documentText = redactDocumentText(
5067
5796
  prepared.text,
@@ -5074,6 +5803,9 @@ async function runIngest(request, opts) {
5074
5803
  await finalizeDocument(opts.pool, documentId, { status: "failed", error: String(exc) });
5075
5804
  throw exc;
5076
5805
  }
5806
+ progress(opts.hooks, request, displayName, documentId, "redact", "done", {
5807
+ rules_failed: redactionFailed.length
5808
+ });
5077
5809
  if (!prepared.text.trim() || prepared.mediaOnly || looksMostlyBoilerplate(prepared.text)) {
5078
5810
  const reason = prepared.mediaOnly ? "media-only (no extractable text)" : "no extractable text";
5079
5811
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, []);
@@ -5099,7 +5831,13 @@ async function runIngest(request, opts) {
5099
5831
  graphUnits: 0,
5100
5832
  providerTokens: {},
5101
5833
  error: reason,
5102
- redactionFailed
5834
+ redactionFailed,
5835
+ // Extraction RAN here — an unreadable scan is exactly what lands in
5836
+ // this branch, so this is the report that has to say why. The
5837
+ // dedup-skip/upgrade sites stay at the defaults: they never extracted
5838
+ // and would be claiming blind.
5839
+ unreadableReason: prepared.unreadableReason,
5840
+ unreadablePages: prepared.unreadablePages
5103
5841
  });
5104
5842
  }
5105
5843
  const units = computeUnits(prepared);
@@ -5109,18 +5847,26 @@ async function runIngest(request, opts) {
5109
5847
  let rows = [];
5110
5848
  let effectiveMime = prepared.mime;
5111
5849
  try {
5850
+ progress(opts.hooks, request, displayName, documentId, "chunk", "started");
5112
5851
  [rows, effectiveMime] = buildChunks(prepared, request.name, {
5113
5852
  policy: opts.config.redaction,
5114
5853
  secretKey: opts.config.secretKey,
5115
5854
  hooks: opts.hooks,
5116
5855
  failed: redactionFailed
5117
5856
  });
5857
+ progress(opts.hooks, request, displayName, documentId, "chunk", "done", { chunks: rows.length });
5858
+ progress(opts.hooks, request, displayName, documentId, "embed", "started");
5118
5859
  if (request.batch) {
5119
5860
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, rows);
5120
5861
  const batchId = await submitEmbeddingBatch(
5121
5862
  opts.config.embedding,
5122
5863
  rows.map((r) => r.text)
5123
5864
  );
5865
+ progress(opts.hooks, request, displayName, documentId, "embed", "done", {
5866
+ batch: true,
5867
+ batch_id: batchId,
5868
+ chunks: rows.length
5869
+ });
5124
5870
  metaUpdates.mime_type = effectiveMime;
5125
5871
  metaUpdates.batch = {
5126
5872
  id: batchId,
@@ -5150,15 +5896,26 @@ async function runIngest(request, opts) {
5150
5896
  graphUnits: 0,
5151
5897
  providerTokens,
5152
5898
  error: null,
5153
- redactionFailed
5899
+ redactionFailed,
5900
+ // Extraction RAN here just like the skipped/failed/completed sites —
5901
+ // this report has to say why pages were unreadable too, not leave
5902
+ // the caller to re-derive it later from listDocuments.
5903
+ unreadableReason: prepared.unreadableReason,
5904
+ unreadablePages: prepared.unreadablePages
5154
5905
  });
5155
5906
  }
5156
5907
  const embeddingTokens = await embedChunks(opts.embedder, rows);
5157
5908
  if (embeddingTokens) providerTokens.embedding_tokens = embeddingTokens;
5158
5909
  await recordEmbeddingDim(opts.pool, opts.embedder.dim);
5159
5910
  await opts.backend.upsertChunks(documentId, request.sourceId ?? null, request.acl ?? null, rows);
5911
+ progress(opts.hooks, request, displayName, documentId, "embed", "done", {
5912
+ batch: false,
5913
+ chunks: rows.length,
5914
+ tokens: embeddingTokens
5915
+ });
5160
5916
  const structuredKw = {};
5161
5917
  if (request.extractStructured && opts.config.llm) {
5918
+ progress(opts.hooks, request, displayName, documentId, "structured", "started");
5162
5919
  const extraction = await extractStructuredData(documentText, {
5163
5920
  llmCfg: opts.config.llm,
5164
5921
  fieldHints: request.fieldHints
@@ -5199,9 +5956,15 @@ async function runIngest(request, opts) {
5199
5956
  document: displayName
5200
5957
  });
5201
5958
  }
5959
+ progress(opts.hooks, request, displayName, documentId, "structured", "done", {
5960
+ document_type: extraction.documentType,
5961
+ keys: extraction.keysNormalized.length,
5962
+ quality: extraction.quality
5963
+ });
5202
5964
  }
5203
5965
  let graphUnits2 = 0;
5204
5966
  if (mode === "graph" && opts.graphStage) {
5967
+ progress(opts.hooks, request, displayName, documentId, "graph", "started");
5205
5968
  graphUnits2 = Number(
5206
5969
  await maybeAwait(
5207
5970
  opts.graphStage({
@@ -5212,6 +5975,7 @@ async function runIngest(request, opts) {
5212
5975
  })
5213
5976
  ) || 0
5214
5977
  );
5978
+ progress(opts.hooks, request, displayName, documentId, "graph", "done", { graph_units: graphUnits2 });
5215
5979
  }
5216
5980
  metaUpdates.mime_type = effectiveMime;
5217
5981
  await finalizeDocument(opts.pool, documentId, {
@@ -5251,7 +6015,9 @@ async function runIngest(request, opts) {
5251
6015
  graphUnits: graphUnits2,
5252
6016
  providerTokens,
5253
6017
  error: null,
5254
- redactionFailed
6018
+ redactionFailed,
6019
+ unreadableReason: prepared.unreadableReason,
6020
+ unreadablePages: prepared.unreadablePages
5255
6021
  });
5256
6022
  } catch (exc) {
5257
6023
  emitError(opts.hooks, exc, { stage: "ingest", document: displayName });
@@ -5266,7 +6032,9 @@ async function runIngest(request, opts) {
5266
6032
  graphUnits: 0,
5267
6033
  providerTokens,
5268
6034
  error: String(exc),
5269
- redactionFailed
6035
+ redactionFailed,
6036
+ unreadableReason: prepared.unreadableReason,
6037
+ unreadablePages: prepared.unreadablePages
5270
6038
  });
5271
6039
  }
5272
6040
  }
@@ -5310,17 +6078,11 @@ var init_ingest = __esm({
5310
6078
  // src/actions.ts
5311
6079
  function parseDocumentId(documentId) {
5312
6080
  const raw = String(documentId ?? "").replace(/[ \n\t]/g, "").trim();
5313
- if (!UUID_RE2.test(raw)) {
6081
+ if (!UUID_RE3.test(raw)) {
5314
6082
  throw new EngineActionError(`invalid document id: ${JSON.stringify(documentId)}`);
5315
6083
  }
5316
6084
  return raw;
5317
6085
  }
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
6086
  function scopeSql(sourceIds, principals, params) {
5325
6087
  const where = [];
5326
6088
  if (sourceIds != null) {
@@ -5409,7 +6171,9 @@ async function getDocumentRow(pool, documentId, principals) {
5409
6171
  createdAt: doc.created_at,
5410
6172
  updatedAt: doc.updated_at,
5411
6173
  startedAt: doc.started_at,
5412
- completedAt: doc.completed_at
6174
+ completedAt: doc.completed_at,
6175
+ unreadableReason: doc.meta_data?.unreadable_reason ?? null,
6176
+ unreadablePages: doc.meta_data?.unreadable_pages ?? 0
5413
6177
  };
5414
6178
  }
5415
6179
  async function stats(pool, sourceId) {
@@ -5458,7 +6222,7 @@ async function listDocuments(opts) {
5458
6222
  let where = scopeSql(sourceIds, principals, params);
5459
6223
  const cursorTime = parsedCursor?.time ?? parsedCursor?.created_at;
5460
6224
  const cursorId = parsedCursor?.id;
5461
- if (cursorTime && cursorId && UUID_RE2.test(String(cursorId))) {
6225
+ if (cursorTime && cursorId && UUID_RE3.test(String(cursorId))) {
5462
6226
  params.push(cursorTime, cursorId);
5463
6227
  const extra = `(created_at < $${params.length - 1} OR (created_at = $${params.length - 1} AND id < $${params.length}::uuid))`;
5464
6228
  where = where ? `${where} AND ${extra}` : `WHERE ${extra}`;
@@ -5466,7 +6230,7 @@ async function listDocuments(opts) {
5466
6230
  params.push(limit + 1);
5467
6231
  const { rows } = await opts.pool.query(
5468
6232
  `SELECT id, source_id, external_id, name, description, mode, mime_type, lang, status,
5469
- document_type, created_at, updated_at, acl
6233
+ document_type, created_at, updated_at, acl, meta_data
5470
6234
  FROM context_engine_documents
5471
6235
  ${where}
5472
6236
  ORDER BY created_at DESC, id DESC
@@ -5491,7 +6255,15 @@ async function listDocuments(opts) {
5491
6255
  hooks: opts.hooks
5492
6256
  }),
5493
6257
  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
6258
+ updatedAt: doc.updated_at instanceof Date ? doc.updated_at.toISOString() : doc.updated_at,
6259
+ unreadableReason: doc.meta_data?.unreadable_reason ?? null,
6260
+ unreadablePages: doc.meta_data?.unreadable_pages ?? 0,
6261
+ // WHO can see this. Stored, enforced on every query and editable through
6262
+ // updateDocument — and, until this line, invisible to every caller,
6263
+ // because this serializer builds a FIXED object. `null` is UNRESTRICTED
6264
+ // and must stay null; an empty array would read as "nobody", which is the
6265
+ // opposite claim.
6266
+ acl: doc.acl ?? null
5495
6267
  }));
5496
6268
  const result = {
5497
6269
  documents,
@@ -5649,9 +6421,9 @@ async function compute(instruction, opts) {
5649
6421
  return `mime_type ILIKE $${params.length}`;
5650
6422
  }).join(" OR ");
5651
6423
  where = where ? `${where} AND (${mimeClause})` : `WHERE (${mimeClause})`;
5652
- if (opts.docIds?.length) {
6424
+ if (opts.documentIds?.length) {
5653
6425
  const parsedIds = [];
5654
- for (const did of opts.docIds) {
6426
+ for (const did of opts.documentIds) {
5655
6427
  try {
5656
6428
  parsedIds.push(parseDocumentId(did));
5657
6429
  } catch {
@@ -5679,13 +6451,13 @@ async function compute(instruction, opts) {
5679
6451
  }
5680
6452
  if (tabular.length > MAX_COMPUTE_DOCUMENTS) {
5681
6453
  throw new EngineActionError(
5682
- `more than ${MAX_COMPUTE_DOCUMENTS} tabular documents are in scope for compute() \u2014 narrow the request with docIds or sourceIds`
6454
+ `more than ${MAX_COMPUTE_DOCUMENTS} tabular documents are in scope for compute() \u2014 narrow the request with documentIds or sourceIds`
5683
6455
  );
5684
6456
  }
5685
6457
  const totalChars = tabular.reduce((n, d) => n + String(d.text ?? "").length, 0);
5686
6458
  if (totalChars > MAX_COMPUTE_TEXT_CHARS) {
5687
6459
  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`
6460
+ `in-scope spreadsheet text too large to load (${totalChars} chars > ${MAX_COMPUTE_TEXT_CHARS} cap) \u2014 narrow the request with documentIds or sourceIds`
5689
6461
  );
5690
6462
  }
5691
6463
  const dfs = {};
@@ -5766,7 +6538,7 @@ ${schemaLines.join("\n")}`;
5766
6538
  hooks: opts.hooks
5767
6539
  });
5768
6540
  }
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;
6541
+ 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
6542
  var init_actions = __esm({
5771
6543
  "src/actions.ts"() {
5772
6544
  init_chunkers();
@@ -5776,13 +6548,14 @@ var init_actions = __esm({
5776
6548
  init_redaction();
5777
6549
  init_sandbox();
5778
6550
  init_structured();
6551
+ init_acl();
5779
6552
  init_ingest();
5780
6553
  MAX_LIST_LIMIT = 200;
5781
6554
  MAX_COMPUTE_TEXT_CHARS = 2e6;
5782
6555
  DEFAULT_COMPUTE_TIMEOUT = 30;
5783
6556
  MAX_COMPUTE_DOCUMENTS = 50;
5784
6557
  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;
6558
+ UUID_RE3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5786
6559
  WORD_RE = /[^\W\d_]+/gu;
5787
6560
  STOPWORDS = /* @__PURE__ */ new Set([
5788
6561
  "the",
@@ -5861,6 +6634,7 @@ Rules:
5861
6634
  1. Set a variable named \`result\` to the final answer (a number, string, array, or object).
5862
6635
  2. No file I/O, no network calls, no imports, no require, no process.
5863
6636
  3. Return ONLY the JavaScript code \u2014 no markdown fences, no explanation, no commentary.`;
6637
+ visible = aclVisible;
5864
6638
  }
5865
6639
  });
5866
6640
  function estimatedTokens(texts) {
@@ -5899,7 +6673,7 @@ async function loadGeminiEmbedClient(apiKey) {
5899
6673
  if (!Ctor) {
5900
6674
  throw new ExtraMissingError("gemini", specifier, "gemini embeddings");
5901
6675
  }
5902
- return new Ctor({ apiKey: apiKey ?? null });
6676
+ return new Ctor({ apiKey: apiKey ?? null, httpOptions: { timeout: TIMEOUT_MS3 } });
5903
6677
  }
5904
6678
  function buildEmbedder(cfg2, opts) {
5905
6679
  if (OPENAI_FAMILY2.has(cfg2.provider)) {
@@ -7447,10 +8221,10 @@ async function runLegs(query, opts) {
7447
8221
  return ranked;
7448
8222
  }
7449
8223
  async function hydrate(pool, chunkIds) {
7450
- const parsed = chunkIds.filter((id) => UUID_RE3.test(id));
8224
+ const parsed = chunkIds.filter((id) => UUID_RE4.test(id));
7451
8225
  if (parsed.length !== chunkIds.length) {
7452
8226
  for (const cid of chunkIds) {
7453
- if (!UUID_RE3.test(cid)) {
8227
+ if (!UUID_RE4.test(cid)) {
7454
8228
  console.warn("search: skipping unhydratable chunk id %s", cid);
7455
8229
  }
7456
8230
  }
@@ -7549,6 +8323,7 @@ async function runSearch(query, opts) {
7549
8323
  const topK = Math.max(1, Math.trunc(opts.topK ?? 10));
7550
8324
  const scope = {
7551
8325
  sourceIds: opts.sourceIds != null ? [...opts.sourceIds] : null,
8326
+ documentIds: opts.documentIds != null ? [...opts.documentIds] : null,
7552
8327
  principals: opts.principals != null ? [...opts.principals] : null,
7553
8328
  limit: legLimit(topK, opts.config)
7554
8329
  };
@@ -7569,13 +8344,8 @@ async function runSearch(query, opts) {
7569
8344
  let degraded = null;
7570
8345
  if (mode === "graph" && !graphLeg) {
7571
8346
  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
- );
8347
+ 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.";
8348
+ emitError(opts.hooks, new GraphLegUnavailable(reason), { stage: "graph_leg", mode, degraded });
7579
8349
  }
7580
8350
  const fused = rrfFuse(ranked, { k: opts.config.fusion.k, weights: opts.config.fusion.weights });
7581
8351
  const window = opts.config.reranker.enabled ? Math.max(topK, opts.config.reranker.candidates) : topK;
@@ -7649,7 +8419,7 @@ async function runSearch(query, opts) {
7649
8419
  });
7650
8420
  return { hits, usage };
7651
8421
  }
7652
- var MIN_LEG_CANDIDATES, MAX_LEG_CANDIDATES, UNITS_PER_SCOPE_HYBRID, UNITS_PER_SCOPE_GRAPH, UUID_RE3;
8422
+ var MIN_LEG_CANDIDATES, MAX_LEG_CANDIDATES, UNITS_PER_SCOPE_HYBRID, UNITS_PER_SCOPE_GRAPH, UUID_RE4;
7653
8423
  var init_search = __esm({
7654
8424
  "src/search.ts"() {
7655
8425
  init_compression();
@@ -7662,37 +8432,7 @@ var init_search = __esm({
7662
8432
  MAX_LEG_CANDIDATES = 500;
7663
8433
  UNITS_PER_SCOPE_HYBRID = 1;
7664
8434
  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());
8435
+ UUID_RE4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7696
8436
  }
7697
8437
  });
7698
8438
  function isSpacelessQuery(query) {
@@ -7735,7 +8475,7 @@ FROM context_engine_chunks c
7735
8475
  WHERE c.embedding IS NOT NULL
7736
8476
  ${SCOPE}
7737
8477
  ORDER BY c.embedding <=> ${vec}
7738
- LIMIT $3
8478
+ LIMIT $4
7739
8479
  `;
7740
8480
  }
7741
8481
  function sqlAnnExact(vec) {
@@ -7748,13 +8488,13 @@ WITH eligible AS MATERIALIZED (
7748
8488
  )
7749
8489
  SELECT id::text FROM eligible
7750
8490
  ORDER BY embedding <=> ${vec}
7751
- LIMIT $3
8491
+ LIMIT $4
7752
8492
  `;
7753
8493
  }
7754
8494
  function idsOf(rows) {
7755
8495
  return rows.map((r) => String(r.id));
7756
8496
  }
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;
8497
+ 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
8498
  var init_storage = __esm({
7759
8499
  "src/storage.ts"() {
7760
8500
  init_text();
@@ -7769,25 +8509,26 @@ var init_storage = __esm({
7769
8509
  ANN_EXACT_THRESHOLD = 5e4;
7770
8510
  SCOPE = `
7771
8511
  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)
8512
+ AND ($2::uuid[] IS NULL OR c.document_id = ANY($2::uuid[]))
8513
+ AND ($3::text[] IS NULL OR c.acl IS NULL OR c.acl && $3)
7773
8514
  `;
7774
8515
  SQL_FTS = `
7775
8516
  SELECT c.id::text
7776
8517
  FROM context_engine_chunks c
7777
8518
  WHERE c.text IS NOT NULL
7778
- AND c.text_search @@ websearch_to_tsquery('simple'::regconfig, $3)
8519
+ AND c.text_search @@ websearch_to_tsquery('simple'::regconfig, $4)
7779
8520
  ${SCOPE}
7780
- ORDER BY ts_rank_cd(c.text_search, websearch_to_tsquery('simple'::regconfig, $3)) DESC, c.id
7781
- LIMIT $4
8521
+ ORDER BY ts_rank_cd(c.text_search, websearch_to_tsquery('simple'::regconfig, $4)) DESC, c.id
8522
+ LIMIT $5
7782
8523
  `;
7783
8524
  SQL_TRGM = `
7784
8525
  SELECT c.id::text
7785
8526
  FROM context_engine_chunks c
7786
8527
  WHERE c.text IS NOT NULL
7787
- AND c.text_trgm_norm % $3
8528
+ AND c.text_trgm_norm % $4
7788
8529
  ${SCOPE}
7789
- ORDER BY similarity(c.text_trgm_norm, $3) DESC, c.id
7790
- LIMIT $4
8530
+ ORDER BY similarity(c.text_trgm_norm, $4) DESC, c.id
8531
+ LIMIT $5
7791
8532
  `;
7792
8533
  SQL_COUNT_ELIGIBLE2 = `
7793
8534
  SELECT count(*)::int AS count FROM (
@@ -7795,16 +8536,16 @@ SELECT count(*)::int AS count FROM (
7795
8536
  FROM context_engine_chunks c
7796
8537
  WHERE c.embedding IS NOT NULL
7797
8538
  ${SCOPE}
7798
- LIMIT $3
8539
+ LIMIT $4
7799
8540
  ) probe
7800
8541
  `;
7801
8542
  SQL_FILTER_IDS = `
7802
8543
  SELECT c.id::text
7803
8544
  FROM context_engine_chunks c
7804
- WHERE c.id = ANY($3::uuid[])
8545
+ WHERE c.id = ANY($4::uuid[])
7805
8546
  ${SCOPE}
7806
8547
  `;
7807
- UUID_RE4 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8548
+ UUID_RE5 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7808
8549
  PostgresBackend = class {
7809
8550
  supportsFts = true;
7810
8551
  supportsTrgm = true;
@@ -7826,24 +8567,26 @@ WHERE c.id = ANY($3::uuid[])
7826
8567
  * mean different things.
7827
8568
  */
7828
8569
  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
- ];
8570
+ return {
8571
+ binds: [
8572
+ scope.sourceIds != null ? [...scope.sourceIds] : null,
8573
+ scope.documentIds != null ? [...scope.documentIds] : null,
8574
+ scope.principals != null ? [...scope.principals] : null
8575
+ ],
8576
+ lim: Math.max(1, Math.trunc(scope.limit ?? 50))
8577
+ };
7834
8578
  }
7835
8579
  async upsertChunks(documentId, sourceId, acl, chunks) {
7836
8580
  const client = await this.pool.connect();
7837
8581
  try {
7838
8582
  await client.query("BEGIN");
7839
8583
  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
- [
8584
+ const BATCH = 500;
8585
+ for (let start = 0; start < chunks.length; start += BATCH) {
8586
+ const batch = chunks.slice(start, start + BATCH);
8587
+ const params = [];
8588
+ const rows = batch.map((c) => {
8589
+ params.push(
7847
8590
  randomUUID(),
7848
8591
  documentId,
7849
8592
  sourceId,
@@ -7852,7 +8595,16 @@ WHERE c.id = ANY($3::uuid[])
7852
8595
  c.text,
7853
8596
  c.lang ?? null,
7854
8597
  JSON.stringify(c.meta ?? {})
7855
- ]
8598
+ );
8599
+ const p = params.length;
8600
+ const emb = c.embedding != null ? vectorLiteral2(c.embedding) : "NULL";
8601
+ return `($${p - 7}, $${p - 6}, $${p - 5}, $${p - 4}, $${p - 3}, $${p - 2}, $${p - 1}, ${emb}, $${p}::jsonb)`;
8602
+ });
8603
+ await client.query(
8604
+ `INSERT INTO context_engine_chunks
8605
+ (id, document_id, source_id, acl, idx, text, lang, embedding, meta_data)
8606
+ VALUES ${rows.join(", ")}`,
8607
+ params
7856
8608
  );
7857
8609
  }
7858
8610
  await client.query("COMMIT");
@@ -7912,24 +8664,24 @@ WHERE c.id = ANY($3::uuid[])
7912
8664
  const parsed = [];
7913
8665
  for (const cid of chunkIds) {
7914
8666
  try {
7915
- if (UUID_RE4.test(String(cid))) parsed.push(String(cid));
8667
+ if (UUID_RE5.test(String(cid))) parsed.push(String(cid));
7916
8668
  } catch {
7917
8669
  }
7918
8670
  }
7919
8671
  if (!parsed.length) return [];
7920
- const [src, principals] = this.scopeParams(scope);
7921
- const { rows } = await this.pool.query(SQL_FILTER_IDS, [src, principals, parsed]);
8672
+ const { binds } = this.scopeParams(scope);
8673
+ const { rows } = await this.pool.query(SQL_FILTER_IDS, [...binds, parsed]);
7922
8674
  const visible2 = new Set(idsOf(rows));
7923
8675
  return chunkIds.filter((cid) => visible2.has(String(cid))).map(String);
7924
8676
  }
7925
8677
  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]);
8678
+ const { binds, lim } = this.scopeParams(scope);
8679
+ const { rows } = await this.pool.query(SQL_FTS, [...binds, query, lim]);
7928
8680
  return idsOf(rows);
7929
8681
  }
7930
8682
  async trgmSearch(query, scope) {
7931
8683
  const threshold = trigramThreshold(query);
7932
- const [src, principals, lim] = this.scopeParams(scope);
8684
+ const { binds, lim } = this.scopeParams(scope);
7933
8685
  const client = await this.pool.connect();
7934
8686
  let previous = null;
7935
8687
  let usedSetLimit = false;
@@ -7938,7 +8690,7 @@ WHERE c.id = ANY($3::uuid[])
7938
8690
  const applied = await this.applyTrgmThreshold(client, threshold);
7939
8691
  previous = applied.previous;
7940
8692
  usedSetLimit = applied.usedSetLimit;
7941
- const { rows } = await client.query(SQL_TRGM, [src, principals, query, lim]);
8693
+ const { rows } = await client.query(SQL_TRGM, [...binds, query, lim]);
7942
8694
  if (usedSetLimit) await this.restoreTrgmLimit(client, previous);
7943
8695
  await client.query("COMMIT");
7944
8696
  return idsOf(rows);
@@ -7984,18 +8736,18 @@ WHERE c.id = ANY($3::uuid[])
7984
8736
  }
7985
8737
  async annSearch(vector, scope) {
7986
8738
  const literal = vectorLiteral2(vector);
7987
- const [src, principals, lim] = this.scopeParams(scope);
7988
- const scoped = src !== null || principals !== null;
8739
+ const { binds, lim } = this.scopeParams(scope);
8740
+ const scoped = binds.some((v) => v !== null);
7989
8741
  const client = await this.pool.connect();
7990
8742
  try {
7991
8743
  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]);
8744
+ if (scoped && await this.eligibleIsSmall(client, binds)) {
8745
+ const { rows: rows2 } = await client.query(sqlAnnExact(literal), [...binds, lim]);
7994
8746
  await client.query("COMMIT");
7995
8747
  return idsOf(rows2);
7996
8748
  }
7997
8749
  await this.tuneAnnScan(client, lim, scoped);
7998
- const { rows } = await client.query(sqlAnn(literal), [src, principals, lim]);
8750
+ const { rows } = await client.query(sqlAnn(literal), [...binds, lim]);
7999
8751
  await client.query("COMMIT");
8000
8752
  return idsOf(rows);
8001
8753
  } catch (err) {
@@ -8008,11 +8760,17 @@ WHERE c.id = ANY($3::uuid[])
8008
8760
  client.release();
8009
8761
  }
8010
8762
  }
8011
- async eligibleIsSmall(client, src, principals) {
8763
+ async eligibleIsSmall(client, binds) {
8012
8764
  const cap = this.exactThreshold + 1;
8013
- const { rows } = await client.query(SQL_COUNT_ELIGIBLE2, [src, principals, cap]);
8765
+ const { rows } = await client.query(SQL_COUNT_ELIGIBLE2, [...binds, cap]);
8014
8766
  return Number(rows[0]?.count ?? 0) <= this.exactThreshold;
8015
8767
  }
8768
+ /** Size the HNSW candidate budget, and make it iterative when filtered.
8769
+ *
8770
+ * Public because the graph leg's seed query (`graph/retrieval.ts`) is the
8771
+ * same shape — an ANN walk with the scope predicate as a POST-filter — and
8772
+ * must not carry a second copy of this. Call it inside a transaction on the
8773
+ * client the query itself will run on: every setting here is `SET LOCAL`. */
8016
8774
  async tuneAnnScan(client, limit, scoped) {
8017
8775
  const efSearch = Math.min(Math.max(Math.trunc(limit) * 4, ANN_MIN_EF_SEARCH), ANN_MAX_EF_SEARCH);
8018
8776
  await client.query(`SET LOCAL hnsw.ef_search = ${efSearch}`);
@@ -8107,19 +8865,6 @@ var init_function = __esm({
8107
8865
  "src/tools/executors/function.ts"() {
8108
8866
  }
8109
8867
  });
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
8868
  function coerceValue(raw) {
8124
8869
  const trimmed = raw.trim();
8125
8870
  if (trimmed.length >= 2 && trimmed[0] === trimmed[trimmed.length - 1] && (trimmed[0] === "'" || trimmed[0] === '"')) {
@@ -8166,45 +8911,121 @@ function shouldRequireApproval(ct, args) {
8166
8911
  if (!condition) return false;
8167
8912
  return evaluateCondition(condition, args ?? {});
8168
8913
  }
8169
- async function createPending(engine, opts) {
8914
+ function rowToRecord(row) {
8915
+ return {
8916
+ id: String(row.id),
8917
+ toolName: String(row.tool_name),
8918
+ toolArgsFrozen: row.tool_args_frozen ?? {},
8919
+ sourceId: row.source_id ?? null,
8920
+ approvalScope: row.approval_scope ?? null,
8921
+ principals: row.principals,
8922
+ status: String(row.status),
8923
+ approver: row.approver ?? null,
8924
+ approverMeta: row.approver_meta ?? null,
8925
+ expiresAt: toDate(row.expires_at),
8926
+ resolvedAt: toDate(row.resolved_at),
8927
+ createdAt: toDate(row.created_at)
8928
+ };
8929
+ }
8930
+ function toDate(value) {
8931
+ if (value == null) return null;
8932
+ if (value instanceof Date) return value;
8933
+ return new Date(String(value));
8934
+ }
8935
+ function claimVisibilitySql(principals, paramIndex) {
8936
+ if (principals === null || principals === TRUSTED) return { sql: "TRUE", params: [] };
8937
+ const noWall = `principals IS NULL OR principals = 'null'::jsonb OR principals = '[]'::jsonb`;
8938
+ principals = principals;
8939
+ if (!principals.length) return { sql: `(${noWall})`, params: [] };
8940
+ return { sql: `(${noWall} OR principals ?| $${paramIndex}::text[])`, params: [principals] };
8941
+ }
8942
+ function validateApprovalScope(approvalScope) {
8943
+ if (approvalScope === null || approvalScope === void 0) return null;
8944
+ if (typeof approvalScope !== "string") {
8945
+ throw new TypeError(`approvalScope must be a non-empty string or null, got ${typeof approvalScope}`);
8946
+ }
8947
+ if (!approvalScope) throw new Error("approvalScope must be a non-empty string or null, got ''");
8948
+ return approvalScope;
8949
+ }
8950
+ function pendingRow(opts, approvalScope) {
8170
8951
  const policy = opts.policy ?? {};
8171
8952
  const frozen = structuredClone(opts.args);
8172
8953
  const createdAt = opts.now ?? /* @__PURE__ */ new Date();
8173
8954
  const timeout = Number(policy.timeout_minutes ?? DEFAULT_TIMEOUT_MINUTES);
8174
8955
  const expiresAt = new Date(createdAt.getTime() + timeout * 6e4);
8956
+ const principals = opts.principals === void 0 || opts.principals === TRUSTED ? null : opts.principals;
8957
+ return { frozen, createdAt, expiresAt, principals, approvalScope };
8958
+ }
8959
+ async function insertPending(engine, opts, row) {
8175
8960
  const id = randomUUID();
8176
8961
  await engine.pool.query(
8177
8962
  `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)`,
8963
+ (id, tool_name, tool_args_frozen, source_id, approval_scope, principals, status, expires_at, created_at)
8964
+ VALUES ($1,$2,$3::jsonb,$4,$5,$6::jsonb,'pending',$7,$8)`,
8180
8965
  [
8181
8966
  id,
8182
8967
  opts.toolName,
8183
- JSON.stringify(frozen),
8968
+ JSON.stringify(row.frozen),
8184
8969
  opts.sourceId ?? null,
8185
- opts.principals === void 0 ? null : JSON.stringify(opts.principals),
8186
- expiresAt,
8187
- createdAt
8970
+ row.approvalScope,
8971
+ row.principals === null ? null : JSON.stringify(row.principals),
8972
+ row.expiresAt,
8973
+ row.createdAt
8188
8974
  ]
8189
8975
  );
8190
8976
  return {
8191
8977
  id,
8192
8978
  toolName: opts.toolName,
8193
- toolArgsFrozen: frozen,
8979
+ toolArgsFrozen: row.frozen,
8194
8980
  sourceId: opts.sourceId ?? null,
8195
- principals: opts.principals ?? null,
8981
+ approvalScope: row.approvalScope,
8982
+ principals: row.principals,
8196
8983
  status: "pending",
8197
8984
  approver: null,
8198
8985
  approverMeta: null,
8199
- expiresAt,
8986
+ expiresAt: row.expiresAt,
8200
8987
  resolvedAt: null,
8201
- createdAt
8988
+ createdAt: row.createdAt
8202
8989
  };
8203
8990
  }
8991
+ async function createPending(engine, opts) {
8992
+ const approvalScope = validateApprovalScope(opts.approvalScope);
8993
+ return insertPending(engine, opts, pendingRow(opts, approvalScope));
8994
+ }
8995
+ async function findOrCreatePending(engine, opts) {
8996
+ const approvalScope = validateApprovalScope(opts.approvalScope);
8997
+ if (approvalScope === null)
8998
+ throw new Error("findOrCreatePending requires an approvalScope; use createPending");
8999
+ const row = pendingRow(opts, approvalScope);
9000
+ const frozenJson = JSON.stringify(row.frozen);
9001
+ const sameCall = `tool_name = $1 AND approval_scope = $2 AND tool_args_frozen = $3::jsonb`;
9002
+ const params = [opts.toolName, approvalScope, frozenJson, row.createdAt];
9003
+ await engine.pool.query(
9004
+ `UPDATE context_engine_tool_approvals SET status = 'expired'
9005
+ WHERE ${sameCall} AND status = 'pending' AND expires_at <= $4`,
9006
+ params
9007
+ );
9008
+ for (let attempt = 0; attempt < 2; attempt++) {
9009
+ const found = await engine.pool.query(
9010
+ `SELECT * FROM context_engine_tool_approvals
9011
+ WHERE ${sameCall} AND status = 'pending' AND (expires_at IS NULL OR expires_at > $4)
9012
+ ORDER BY created_at ASC LIMIT 1`,
9013
+ params
9014
+ );
9015
+ if (found.rows[0]) return rowToRecord(found.rows[0]);
9016
+ try {
9017
+ return await insertPending(engine, opts, row);
9018
+ } catch (exc) {
9019
+ if (exc?.code !== "23505") throw exc;
9020
+ }
9021
+ }
9022
+ throw new Error("findOrCreatePending: lost the insert race twice and found no live pending row");
9023
+ }
8204
9024
  var DEFAULT_TIMEOUT_MINUTES, CONDITION_RE;
8205
9025
  var init_approval = __esm({
8206
9026
  "src/tools/approval.ts"() {
8207
9027
  init_errors();
9028
+ init_sentinels();
8208
9029
  init_acl();
8209
9030
  DEFAULT_TIMEOUT_MINUTES = 60;
8210
9031
  CONDITION_RE = /^\s*(?<field>[A-Za-z_][A-Za-z0-9_.]*)\s*(?<op>>=|<=|==|!=|>|<)\s*(?<value>.+?)\s*$/;
@@ -8315,8 +9136,48 @@ var init_audit = __esm({
8315
9136
  });
8316
9137
 
8317
9138
  // src/tools/config.ts
8318
- var KINDS, ToolConfig;
8319
- var init_config = __esm({
9139
+ function isPlainObject(value) {
9140
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9141
+ }
9142
+ function containsSentinel(value) {
9143
+ if (typeof value === "string") return value === REDACTED_SENTINEL;
9144
+ if (Array.isArray(value)) return value.some(containsSentinel);
9145
+ if (isPlainObject(value)) return Object.values(value).some(containsSentinel);
9146
+ return false;
9147
+ }
9148
+ function mergeRedacted(incoming, stored) {
9149
+ const resolve = (value, kept, keptPresent, path) => {
9150
+ if (isPlainObject(value)) {
9151
+ const keptDict = isPlainObject(kept) ? kept : {};
9152
+ const out2 = {};
9153
+ for (const [k, v] of Object.entries(value)) {
9154
+ out2[k] = resolve(v, keptDict[k], k in keptDict, path ? `${path}.${k}` : k);
9155
+ }
9156
+ return out2;
9157
+ }
9158
+ if (Array.isArray(value)) {
9159
+ const keptList = Array.isArray(kept) ? kept : [];
9160
+ return value.map((v, i) => resolve(v, keptList[i], i < keptList.length, `${path}[${i}]`));
9161
+ }
9162
+ if (value === REDACTED_SENTINEL) {
9163
+ if (!keptPresent) {
9164
+ throw new ConfigTemplateError(
9165
+ `config.${path} is ${JSON.stringify(REDACTED_SENTINEL)} but there is no stored value to keep \u2014 re-enter the secret or omit the field`
9166
+ );
9167
+ }
9168
+ return kept;
9169
+ }
9170
+ return value;
9171
+ };
9172
+ const keptRoot = stored ?? {};
9173
+ const out = {};
9174
+ for (const [k, v] of Object.entries(incoming)) {
9175
+ out[k] = resolve(v, keptRoot[k], k in keptRoot, k);
9176
+ }
9177
+ return out;
9178
+ }
9179
+ var KINDS, ToolConfig, REDACTED_SENTINEL, ConfigTemplateError;
9180
+ var init_config2 = __esm({
8320
9181
  "src/tools/config.ts"() {
8321
9182
  KINDS = ["http", "db", "mcp", "function"];
8322
9183
  ToolConfig = class _ToolConfig {
@@ -8330,6 +9191,10 @@ var init_config = __esm({
8330
9191
  requiresApproval;
8331
9192
  approvalPolicy;
8332
9193
  enabled;
9194
+ /** Engine-opaque user metadata (documents have the same column). Stored
9195
+ * and returned in CLEAR on the admin surface only — secrets go in
9196
+ * `config`, which is encrypted. */
9197
+ metaData;
8333
9198
  constructor(init) {
8334
9199
  this.id = init.id ?? null;
8335
9200
  this.name = init.name;
@@ -8341,6 +9206,7 @@ var init_config = __esm({
8341
9206
  this.requiresApproval = init.requiresApproval ?? init.requires_approval ?? false;
8342
9207
  this.approvalPolicy = init.approvalPolicy ?? init.approval_policy ?? {};
8343
9208
  this.enabled = init.enabled ?? true;
9209
+ this.metaData = init.metaData ?? init.meta_data ?? {};
8344
9210
  }
8345
9211
  static fromUnknown(body) {
8346
9212
  if (!body || typeof body !== "object" || Array.isArray(body)) {
@@ -8363,10 +9229,14 @@ var init_config = __esm({
8363
9229
  acl: b.acl ?? null,
8364
9230
  requiresApproval: Boolean(b.requiresApproval ?? b.requires_approval ?? false),
8365
9231
  approvalPolicy: b.approvalPolicy ?? b.approval_policy ?? {},
8366
- enabled: b.enabled === void 0 ? true : Boolean(b.enabled)
9232
+ enabled: b.enabled === void 0 ? true : Boolean(b.enabled),
9233
+ metaData: b.metaData ?? b.meta_data ?? {}
8367
9234
  });
8368
9235
  }
8369
9236
  };
9237
+ REDACTED_SENTINEL = "__redacted__";
9238
+ ConfigTemplateError = class extends Error {
9239
+ };
8370
9240
  }
8371
9241
  });
8372
9242
 
@@ -8376,6 +9246,198 @@ var init_crypto2 = __esm({
8376
9246
  init_crypto();
8377
9247
  }
8378
9248
  });
9249
+ function isRedirect(resp) {
9250
+ return resp.status >= 300 && resp.status < 400 || resp.type === "opaqueredirect";
9251
+ }
9252
+ function parseV4(text) {
9253
+ const parts = text.split(".");
9254
+ if (parts.length !== 4) return null;
9255
+ const out = new Uint8Array(4);
9256
+ for (let i = 0; i < 4; i++) {
9257
+ const part = parts[i];
9258
+ if (!/^\d{1,3}$/.test(part)) return null;
9259
+ const value = Number(part);
9260
+ if (value > 255) return null;
9261
+ out[i] = value;
9262
+ }
9263
+ return out;
9264
+ }
9265
+ function parseV6(text) {
9266
+ let body = text.split("%")[0] ?? "";
9267
+ const lastColon = body.lastIndexOf(":");
9268
+ if (lastColon < 0) return null;
9269
+ const tail = body.slice(lastColon + 1);
9270
+ if (tail.includes(".")) {
9271
+ const v4 = parseV4(tail);
9272
+ if (!v4) return null;
9273
+ const hi = (v4[0] << 8 | v4[1]).toString(16);
9274
+ const lo = (v4[2] << 8 | v4[3]).toString(16);
9275
+ body = `${body.slice(0, lastColon + 1)}${hi}:${lo}`;
9276
+ }
9277
+ const halves = body.split("::");
9278
+ if (halves.length > 2) return null;
9279
+ const head = halves[0] ? halves[0].split(":") : [];
9280
+ const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
9281
+ let groups;
9282
+ if (halves.length === 1) {
9283
+ if (head.length !== 8) return null;
9284
+ groups = head;
9285
+ } else {
9286
+ const missing = 8 - head.length - rest.length;
9287
+ if (missing < 0) return null;
9288
+ groups = [...head, ...Array(missing).fill("0"), ...rest];
9289
+ }
9290
+ const out = new Uint8Array(16);
9291
+ for (let i = 0; i < 8; i++) {
9292
+ const group = groups[i];
9293
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return null;
9294
+ const value = Number.parseInt(group, 16);
9295
+ out[2 * i] = value >> 8;
9296
+ out[2 * i + 1] = value & 255;
9297
+ }
9298
+ return out;
9299
+ }
9300
+ function parseAddress(text) {
9301
+ const family = isIP(text);
9302
+ if (family === 4) return parseV4(text);
9303
+ if (family === 6) return parseV6(text);
9304
+ return null;
9305
+ }
9306
+ function inNet(addr, net, prefix) {
9307
+ if (addr.length !== net.length) return false;
9308
+ const whole = prefix >> 3;
9309
+ for (let i = 0; i < whole; i++) if (addr[i] !== net[i]) return false;
9310
+ const bits = prefix & 7;
9311
+ if (bits === 0) return true;
9312
+ const mask = 255 << 8 - bits;
9313
+ return (addr[whole] & mask) === (net[whole] & mask);
9314
+ }
9315
+ function compile(table) {
9316
+ return table.map(([cidr, prefix]) => {
9317
+ const bytes = parseAddress(cidr);
9318
+ if (!bytes) throw new Error(`egress: unparseable network ${cidr}`);
9319
+ return [bytes, prefix];
9320
+ });
9321
+ }
9322
+ function embeddedV4(bytes) {
9323
+ if (inNet(bytes, MAPPED_V4[0], MAPPED_V4[1]) || inNet(bytes, NAT64[0], NAT64[1])) {
9324
+ return bytes.subarray(12, 16);
9325
+ }
9326
+ if (inNet(bytes, SIXTOFOUR[0], SIXTOFOUR[1])) return bytes.subarray(2, 6);
9327
+ return null;
9328
+ }
9329
+ function addressIsPrivate(address) {
9330
+ const bytes = parseAddress(address);
9331
+ if (!bytes) return true;
9332
+ if (bytes.length === 4) return V4_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
9333
+ const embedded = embeddedV4(bytes);
9334
+ if (embedded && V4_NETS.some(([net, prefix]) => inNet(embedded, net, prefix))) return true;
9335
+ return V6_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
9336
+ }
9337
+ async function isPrivateAddress(host) {
9338
+ let name = (host ?? "").trim().toLowerCase().replace(/^\.+|\.+$/g, "");
9339
+ if (!name) return true;
9340
+ if (name === "localhost") return true;
9341
+ if (name.startsWith("[") && name.endsWith("]")) name = name.slice(1, -1);
9342
+ if (isIP(name)) return addressIsPrivate(name);
9343
+ let addresses;
9344
+ try {
9345
+ addresses = await resolver.lookup(name);
9346
+ } catch {
9347
+ return true;
9348
+ }
9349
+ if (!addresses.length) return true;
9350
+ return addresses.some((address) => addressIsPrivate(address.split("%")[0] ?? address));
9351
+ }
9352
+ async function assertEgressAllowed(url, opts) {
9353
+ let parsed;
9354
+ try {
9355
+ parsed = new URL(url ?? "");
9356
+ } catch {
9357
+ throw new EgressDenied(`egress denied: ${JSON.stringify(url)} is not a valid URL`);
9358
+ }
9359
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
9360
+ if (scheme !== "http" && scheme !== "https") {
9361
+ throw new EgressDenied(
9362
+ `egress denied: unsupported URL scheme ${JSON.stringify(scheme)} \u2014 http tools may only reach http/https`
9363
+ );
9364
+ }
9365
+ if (opts.allowPrivate) return;
9366
+ const host = parsed.hostname;
9367
+ if (await isPrivateAddress(host)) {
9368
+ throw new EgressDenied(
9369
+ `egress denied: ${host || "(no host)"} is a private, loopback, link-local or metadata address; set allowPrivateEgress to permit it`
9370
+ );
9371
+ }
9372
+ }
9373
+ var MAX_RESPONSE_BYTES, MAX_REDIRECTS, EgressDenied, resolver, V4_PRIVATE, V6_PRIVATE, V4_NETS, V6_NETS, MAPPED_V4, NAT64, SIXTOFOUR;
9374
+ var init_egress = __esm({
9375
+ "src/tools/egress.ts"() {
9376
+ MAX_RESPONSE_BYTES = 1e6;
9377
+ MAX_REDIRECTS = 5;
9378
+ EgressDenied = class extends Error {
9379
+ constructor(message) {
9380
+ super(message);
9381
+ this.name = "EgressDenied";
9382
+ }
9383
+ };
9384
+ resolver = {
9385
+ async lookup(host) {
9386
+ const answers = await promises.lookup(host, { all: true, verbatim: true });
9387
+ return answers.map((a) => a.address);
9388
+ }
9389
+ };
9390
+ V4_PRIVATE = [
9391
+ ["0.0.0.0", 8],
9392
+ // "this network" / unspecified
9393
+ ["10.0.0.0", 8],
9394
+ // RFC 1918
9395
+ ["100.64.0.0", 10],
9396
+ // RFC 6598 carrier-grade NAT — overlay VPNs, pod CIDRs
9397
+ ["127.0.0.0", 8],
9398
+ // loopback
9399
+ ["169.254.0.0", 16],
9400
+ // link-local, incl. the cloud metadata address
9401
+ ["172.16.0.0", 12],
9402
+ // RFC 1918
9403
+ ["192.168.0.0", 16],
9404
+ // RFC 1918
9405
+ ["224.0.0.0", 4],
9406
+ // multicast
9407
+ ["240.0.0.0", 4],
9408
+ // reserved
9409
+ ["255.255.255.255", 32]
9410
+ // broadcast (inside 240/4; spelled out anyway)
9411
+ ];
9412
+ V6_PRIVATE = [
9413
+ ["::", 128],
9414
+ // unspecified
9415
+ ["::1", 128],
9416
+ // loopback
9417
+ ["::ffff:0:0", 96],
9418
+ // IPv4-mapped — the wrapped v4 is checked too
9419
+ ["64:ff9b::", 96],
9420
+ // NAT64 — the wrapped v4 is checked too
9421
+ ["2002::", 16],
9422
+ // 6to4 — the wrapped v4 is checked too, and this is denied
9423
+ ["fc00::", 7],
9424
+ // unique local
9425
+ ["fe80::", 10],
9426
+ // link-local
9427
+ ["fec0::", 10],
9428
+ // site-local (deprecated, still configured)
9429
+ ["ff00::", 8],
9430
+ // multicast
9431
+ ["3fff::", 20]
9432
+ // documentation
9433
+ ];
9434
+ V4_NETS = compile(V4_PRIVATE);
9435
+ V6_NETS = compile(V6_PRIVATE);
9436
+ MAPPED_V4 = compile([["::ffff:0:0", 96]])[0];
9437
+ NAT64 = compile([["64:ff9b::", 96]])[0];
9438
+ SIXTOFOUR = compile([["2002::", 16]])[0];
9439
+ }
9440
+ });
8379
9441
 
8380
9442
  // src/tools/executors/db.ts
8381
9443
  var db_exports = {};
@@ -8392,6 +9454,9 @@ __export(db_exports, {
8392
9454
  function stripSqlNoise(sql) {
8393
9455
  return sql.replace(SQL_NOISE_RE, (m) => " ".repeat(m.length));
8394
9456
  }
9457
+ function stripSqlNoiseBackslash(sql) {
9458
+ return sql.replace(SQL_NOISE_BACKSLASH_RE, (m) => " ".repeat(m.length));
9459
+ }
8395
9460
  function hasKeyword(sqlUpper, keyword) {
8396
9461
  return new RegExp(`\\b${keyword}\\b`).test(sqlUpper);
8397
9462
  }
@@ -8520,24 +9585,28 @@ async function executeQueryAsync(config, sql, maxRows, accessModeRaw) {
8520
9585
  `Invalid access_mode ${JSON.stringify(accessModeRaw)}; must be one of ${ALLOWED_ACCESS_MODES}`
8521
9586
  );
8522
9587
  }
8523
- const sqlUpper = stripSqlNoise(sql).trim().toUpperCase();
9588
+ const sqlVariants = [
9589
+ stripSqlNoise(sql).trim().toUpperCase(),
9590
+ stripSqlNoiseBackslash(sql).trim().toUpperCase()
9591
+ ];
9592
+ const sqlUpper = sqlVariants[0];
8524
9593
  for (const keyword of ALWAYS_BLOCKED) {
8525
- if (hasKeyword(sqlUpper, keyword)) {
9594
+ if ([...sqlVariants, sql.toUpperCase()].some((v) => hasKeyword(v, keyword))) {
8526
9595
  return { success: false, error: `${keyword} queries are not allowed` };
8527
9596
  }
8528
9597
  }
8529
9598
  if (accessMode === "readonly") {
8530
- if (!sqlUpper.startsWith("SELECT") && !sqlUpper.startsWith("WITH")) {
9599
+ if (!sqlVariants.every((v) => v.startsWith("SELECT") || v.startsWith("WITH"))) {
8531
9600
  return { success: false, error: "Only SELECT queries are allowed in read-only mode" };
8532
9601
  }
8533
9602
  for (const keyword of READONLY_BLOCKED) {
8534
- if (hasKeyword(sqlUpper, keyword)) {
9603
+ if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
8535
9604
  return { success: false, error: `${keyword} queries are not allowed in read-only mode` };
8536
9605
  }
8537
9606
  }
8538
9607
  } else if (accessMode === "readwrite") {
8539
9608
  for (const keyword of READWRITE_BLOCKED) {
8540
- if (hasKeyword(sqlUpper, keyword)) {
9609
+ if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
8541
9610
  return { success: false, error: `${keyword} queries are not allowed in read-write mode` };
8542
9611
  }
8543
9612
  }
@@ -8671,7 +9740,7 @@ async function getSchemaText(config, selectedTables) {
8671
9740
  }
8672
9741
  return rowsToText(schemaRows);
8673
9742
  }
8674
- var require2, MAX_ROWS, CONNECT_TIMEOUT, QUERY_TIMEOUT_MS, ALWAYS_BLOCKED, READONLY_BLOCKED, READWRITE_BLOCKED, ALLOWED_ACCESS_MODES, SQL_NOISE_RE;
9743
+ 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
9744
  var init_db2 = __esm({
8676
9745
  "src/tools/executors/db.ts"() {
8677
9746
  init_errors();
@@ -8680,10 +9749,22 @@ var init_db2 = __esm({
8680
9749
  CONNECT_TIMEOUT = 10;
8681
9750
  QUERY_TIMEOUT_MS = 3e4;
8682
9751
  ALWAYS_BLOCKED = ["GRANT", "REVOKE"];
8683
- READONLY_BLOCKED = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE"];
8684
- READWRITE_BLOCKED = ["DELETE", "DROP", "TRUNCATE"];
9752
+ READONLY_BLOCKED = [
9753
+ "INSERT",
9754
+ "UPDATE",
9755
+ "DELETE",
9756
+ "MERGE",
9757
+ "DROP",
9758
+ "ALTER",
9759
+ "CREATE",
9760
+ "TRUNCATE",
9761
+ "DO",
9762
+ "CALL"
9763
+ ];
9764
+ READWRITE_BLOCKED = ["DELETE", "DROP", "TRUNCATE", "DO", "CALL"];
8685
9765
  ALLOWED_ACCESS_MODES = ["readonly", "readwrite", "full"];
8686
- SQL_NOISE_RE = /'(?:[^']|'')*'|\$([A-Za-z_]\w*)?\$.*?\$\1?\$|--[^\n]*|\/\*[\s\S]*?\*\//g;
9766
+ SQL_NOISE_RE = /\b[eE]'(?:[^'\\]|\\[\s\S]|'')*'|'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
9767
+ SQL_NOISE_BACKSLASH_RE = /'(?:[^'\\]|\\[\s\S]|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
8687
9768
  }
8688
9769
  });
8689
9770
 
@@ -8718,10 +9799,38 @@ function withQuery(url, params) {
8718
9799
  if (!q) return url;
8719
9800
  return url.includes("?") ? `${url}&${q}` : `${url}?${q}`;
8720
9801
  }
9802
+ async function readCapped(resp) {
9803
+ if (!resp.body) return { text: await resp.text(), truncated: false };
9804
+ const reader = resp.body.getReader();
9805
+ const chunks = [];
9806
+ let size = 0;
9807
+ let truncated = false;
9808
+ for (; ; ) {
9809
+ const { done, value } = await reader.read();
9810
+ if (done) break;
9811
+ if (value) {
9812
+ chunks.push(Buffer.from(value));
9813
+ size += value.byteLength;
9814
+ }
9815
+ if (size > MAX_RESPONSE_BYTES) {
9816
+ truncated = true;
9817
+ await reader.cancel();
9818
+ break;
9819
+ }
9820
+ }
9821
+ const bytes = Buffer.concat(chunks);
9822
+ return {
9823
+ text: (truncated ? bytes.subarray(0, MAX_RESPONSE_BYTES) : bytes).toString("utf8"),
9824
+ truncated
9825
+ };
9826
+ }
8721
9827
  async function defaultRequest(init) {
8722
9828
  let url = init.url;
8723
9829
  if (init.params) url = withQuery(url, init.params);
8724
9830
  const headers = { ...init.headers ?? {} };
9831
+ if (!Object.keys(headers).some((k) => k.toLowerCase() === "accept-encoding")) {
9832
+ headers["Accept-Encoding"] = "identity";
9833
+ }
8725
9834
  let body;
8726
9835
  if (init.json !== void 0) {
8727
9836
  headers["Content-Type"] = headers["Content-Type"] ?? headers["content-type"] ?? "application/json";
@@ -8733,25 +9842,47 @@ async function defaultRequest(init) {
8733
9842
  method: init.method,
8734
9843
  headers,
8735
9844
  body,
8736
- redirect: "follow",
9845
+ // Redirects are followed by hand in `executeHttp` — an automatic follow
9846
+ // would jump to a destination no egress check ever saw.
9847
+ redirect: "manual",
8737
9848
  signal: AbortSignal.timeout(TIMEOUT_MS5)
8738
9849
  });
8739
9850
  const respHeaders = {};
8740
9851
  resp.headers.forEach((v, k) => {
8741
9852
  respHeaders[k] = v;
8742
9853
  });
8743
- const text = await resp.text();
9854
+ const { text, truncated } = await readCapped(resp);
8744
9855
  return {
8745
9856
  status: resp.status,
8746
9857
  headers: respHeaders,
9858
+ truncated,
8747
9859
  async json() {
8748
- return JSON.parse(text);
9860
+ return JSON.parse(text.replace(/^/, ""));
8749
9861
  },
8750
9862
  async text() {
8751
9863
  return text;
8752
9864
  }
8753
9865
  };
8754
9866
  }
9867
+ function nextHop(init, status, location) {
9868
+ const current = new URL(init.url);
9869
+ const target = new URL(location, current);
9870
+ let method = init.method;
9871
+ if ((status === 302 || status === 303) && method !== "HEAD") method = "GET";
9872
+ else if (status === 301 && method === "POST") method = "GET";
9873
+ let headers = { ...init.headers ?? {} };
9874
+ if (current.origin !== target.origin) {
9875
+ headers = Object.fromEntries(
9876
+ Object.entries(headers).filter(([k]) => !CROSS_ORIGIN_HEADERS.has(k.toLowerCase()))
9877
+ );
9878
+ }
9879
+ const hop = { method, url: target.toString(), headers };
9880
+ if (method === init.method) {
9881
+ if (init.json !== void 0) hop.json = init.json;
9882
+ if (init.content !== void 0) hop.content = init.content;
9883
+ }
9884
+ return hop;
9885
+ }
8755
9886
  async function executeHttp(config, args, opts = {}) {
8756
9887
  const method = String(config.method ?? "GET").toUpperCase();
8757
9888
  let url = config.url;
@@ -8772,7 +9903,7 @@ async function executeHttp(config, args, opts = {}) {
8772
9903
  }
8773
9904
  }
8774
9905
  }
8775
- const requestInit = {
9906
+ let requestInit = {
8776
9907
  method,
8777
9908
  url: url ?? "",
8778
9909
  headers
@@ -8808,27 +9939,56 @@ async function executeHttp(config, args, opts = {}) {
8808
9939
  }
8809
9940
  }
8810
9941
  const client = opts.client;
8811
- const resp = client ? await client.request(requestInit) : await defaultRequest(requestInit);
9942
+ const allowPrivate = opts.allowPrivate ?? false;
9943
+ let resp;
9944
+ let hops = 0;
9945
+ for (; ; ) {
9946
+ await assertEgressAllowed(requestInit.url, { allowPrivate });
9947
+ resp = client ? await client.request(requestInit) : await defaultRequest(requestInit);
9948
+ const location = resp.headers.location ?? resp.headers.Location;
9949
+ if (!REDIRECT_STATUS.has(resp.status) || !location) break;
9950
+ if (hops >= MAX_REDIRECTS) {
9951
+ throw new EngineActionError(`too many redirects (more than ${MAX_REDIRECTS}) starting at ${url}`);
9952
+ }
9953
+ hops += 1;
9954
+ requestInit = nextHop(requestInit, resp.status, location);
9955
+ }
8812
9956
  const contentType = resp.headers["content-type"] ?? resp.headers["Content-Type"] ?? "";
8813
- let data;
9957
+ let text = "";
8814
9958
  try {
8815
- data = contentType.includes("application/json") ? await resp.json() : await resp.text();
9959
+ text = await resp.text();
8816
9960
  } catch {
8817
- data = await resp.text();
9961
+ text = "";
9962
+ }
9963
+ const bytes = Buffer.from(text, "utf8");
9964
+ const truncated = resp.truncated === true || bytes.byteLength > MAX_RESPONSE_BYTES;
9965
+ let data;
9966
+ if (truncated) {
9967
+ data = bytes.subarray(0, MAX_RESPONSE_BYTES).toString("utf8");
9968
+ } else {
9969
+ try {
9970
+ data = contentType.includes("application/json") ? await resp.json() : text;
9971
+ } catch {
9972
+ data = text;
9973
+ }
8818
9974
  }
8819
9975
  const safeHeaders = {};
8820
9976
  for (const [k, v] of Object.entries(resp.headers)) {
8821
9977
  if (!SENSITIVE_RESPONSE_HEADERS.has(k.toLowerCase())) safeHeaders[k] = v;
8822
9978
  }
8823
- return {
9979
+ const result = {
8824
9980
  status_code: resp.status,
8825
9981
  headers: safeHeaders,
8826
9982
  data
8827
9983
  };
9984
+ if (truncated) result.truncated = true;
9985
+ return result;
8828
9986
  }
8829
- var TIMEOUT_MS5, SENSITIVE_RESPONSE_HEADERS;
9987
+ var TIMEOUT_MS5, SENSITIVE_RESPONSE_HEADERS, REDIRECT_STATUS, CROSS_ORIGIN_HEADERS;
8830
9988
  var init_http = __esm({
8831
9989
  "src/tools/executors/http.ts"() {
9990
+ init_errors();
9991
+ init_egress();
8832
9992
  TIMEOUT_MS5 = 3e4;
8833
9993
  SENSITIVE_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
8834
9994
  "set-cookie",
@@ -8837,10 +9997,19 @@ var init_http = __esm({
8837
9997
  "proxy-authenticate",
8838
9998
  "www-authenticate"
8839
9999
  ]);
10000
+ REDIRECT_STATUS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
10001
+ CROSS_ORIGIN_HEADERS = /* @__PURE__ */ new Set(["authorization", "proxy-authorization", "cookie"]);
8840
10002
  }
8841
10003
  });
8842
10004
 
8843
10005
  // src/tools/executors/mcp-client.ts
10006
+ async function checkEgress(url, allowPrivate) {
10007
+ let checked = url;
10008
+ const scheme = (/^([a-z0-9+.-]+):/i.exec(url ?? "")?.[1] ?? "").toLowerCase();
10009
+ if (scheme === "ws") checked = `http:${url.slice(scheme.length + 1)}`;
10010
+ else if (scheme === "wss") checked = `https:${url.slice(scheme.length + 1)}`;
10011
+ await assertEgressAllowed(checked, { allowPrivate });
10012
+ }
8844
10013
  function parseSseBuffer(raw) {
8845
10014
  const dataLines = raw.split("\n").filter((ln) => ln.startsWith("data:")).map((ln) => ln.slice(5).trimStart());
8846
10015
  if (!dataLines.length) return null;
@@ -8853,10 +10022,12 @@ function parseSseBuffer(raw) {
8853
10022
  }
8854
10023
  return null;
8855
10024
  }
8856
- var MCPError, HttpTransport, SseTransport, WsTransport, MCPClient, MCPRegistry, PromptevMCP;
10025
+ var REDIRECT_MESSAGE, MCPError, HttpTransport, SseTransport, WsTransport, MCPClient, MCPRegistry, PromptevMCP;
8857
10026
  var init_mcp_client = __esm({
8858
10027
  "src/tools/executors/mcp-client.ts"() {
8859
10028
  init_version();
10029
+ init_egress();
10030
+ REDIRECT_MESSAGE = "MCP server redirected; register the final URL";
8860
10031
  MCPError = class _MCPError extends Error {
8861
10032
  code;
8862
10033
  data;
@@ -8873,10 +10044,12 @@ var init_mcp_client = __esm({
8873
10044
  HttpTransport = class {
8874
10045
  kind = "http";
8875
10046
  url;
10047
+ allowPrivate;
8876
10048
  baseHeaders;
8877
10049
  sessionId = null;
8878
- constructor(url, headers) {
10050
+ constructor(url, headers, allowPrivate = false) {
8879
10051
  this.url = url;
10052
+ this.allowPrivate = allowPrivate;
8880
10053
  this.baseHeaders = {
8881
10054
  ...headers,
8882
10055
  "Content-Type": "application/json",
@@ -8884,6 +10057,7 @@ var init_mcp_client = __esm({
8884
10057
  };
8885
10058
  }
8886
10059
  async connect() {
10060
+ await checkEgress(this.url, this.allowPrivate);
8887
10061
  }
8888
10062
  async close() {
8889
10063
  this.sessionId = null;
@@ -8897,10 +10071,17 @@ var init_mcp_client = __esm({
8897
10071
  const resp = await fetch(this.url, {
8898
10072
  method: "POST",
8899
10073
  headers: this.buildHeaders(),
8900
- body: JSON.stringify(msg)
10074
+ body: JSON.stringify(msg),
10075
+ // `redirect: "manual"`: a followed redirect would carry this POST —
10076
+ // headers, Authorization and all — to a destination `connect()`'s
10077
+ // check never saw. A 3xx is reported, never chased.
10078
+ redirect: "manual"
8901
10079
  });
8902
10080
  const sid = resp.headers.get("Mcp-Session-Id");
8903
10081
  if (sid) this.sessionId = sid;
10082
+ if (isRedirect(resp)) {
10083
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
10084
+ }
8904
10085
  if (resp.status >= 400) {
8905
10086
  const body = await resp.text();
8906
10087
  console.error(`[HTTP Transport] ${resp.status} from ${this.url}: ${body.slice(0, 300)}`);
@@ -8933,11 +10114,20 @@ var init_mcp_client = __esm({
8933
10114
  eventsUrl;
8934
10115
  baseUrl;
8935
10116
  headers;
10117
+ allowPrivate;
10118
+ /** Where the server told us to POST — it arrives in an `endpoint` event on
10119
+ * the stream, so it is server-chosen and hence checked. Private: the only
10120
+ * way in is the parser, which is the path a real server takes. */
8936
10121
  postUrl = null;
10122
+ /** The last POST endpoint cleared by the egress check. The check costs a
10123
+ * DNS resolution and the endpoint changes at most once per connection, so
10124
+ * it is not repeated per message. */
10125
+ checkedPostUrl = null;
8937
10126
  queue = [];
8938
10127
  waiters = [];
8939
10128
  abort = null;
8940
- constructor(url, headers) {
10129
+ constructor(url, headers, allowPrivate = false) {
10130
+ this.allowPrivate = allowPrivate;
8941
10131
  this.eventsUrl = url.replace(/\/$/, "");
8942
10132
  if (url.endsWith("/events")) this.baseUrl = url.slice(0, -7);
8943
10133
  else if (url.endsWith("/sse")) this.baseUrl = url.slice(0, -4);
@@ -8950,14 +10140,19 @@ var init_mcp_client = __esm({
8950
10140
  else this.queue.push(msg);
8951
10141
  }
8952
10142
  async connect() {
10143
+ await checkEgress(this.eventsUrl, this.allowPrivate);
8953
10144
  this.abort = new AbortController();
8954
10145
  void this.listen();
8955
10146
  }
8956
10147
  async listen() {
8957
10148
  const resp = await fetch(this.eventsUrl, {
8958
10149
  headers: { ...this.headers, Accept: "text/event-stream" },
8959
- signal: this.abort?.signal
10150
+ signal: this.abort?.signal,
10151
+ redirect: "manual"
8960
10152
  });
10153
+ if (isRedirect(resp)) {
10154
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
10155
+ }
8961
10156
  if (!resp.ok) throw new Error(`SSE ${resp.status}`);
8962
10157
  if (!resp.body) throw new Error("SSE response has no body");
8963
10158
  const reader = resp.body.getReader();
@@ -9009,11 +10204,19 @@ var init_mcp_client = __esm({
9009
10204
  }
9010
10205
  }
9011
10206
  const postUrl = this.postUrl || this.baseUrl;
10207
+ if (postUrl !== this.checkedPostUrl) {
10208
+ await checkEgress(postUrl, this.allowPrivate);
10209
+ this.checkedPostUrl = postUrl;
10210
+ }
9012
10211
  const resp = await fetch(postUrl, {
9013
10212
  method: "POST",
9014
10213
  headers: { ...this.headers, "Content-Type": "application/json" },
9015
- body: JSON.stringify(msg)
10214
+ body: JSON.stringify(msg),
10215
+ redirect: "manual"
9016
10216
  });
10217
+ if (isRedirect(resp)) {
10218
+ throw new MCPError(resp.status, REDIRECT_MESSAGE);
10219
+ }
9017
10220
  if (!resp.ok) throw new Error(`SSE POST ${resp.status}`);
9018
10221
  try {
9019
10222
  const data = await resp.json();
@@ -9032,14 +10235,17 @@ var init_mcp_client = __esm({
9032
10235
  kind = "websocket";
9033
10236
  url;
9034
10237
  headers;
10238
+ allowPrivate;
9035
10239
  ws = null;
9036
10240
  queue = [];
9037
10241
  waiters = [];
9038
- constructor(url, headers) {
10242
+ constructor(url, headers, allowPrivate = false) {
9039
10243
  this.url = url;
10244
+ this.allowPrivate = allowPrivate;
9040
10245
  this.headers = headers;
9041
10246
  }
9042
10247
  async connect() {
10248
+ await checkEgress(this.url, this.allowPrivate);
9043
10249
  const WS = globalThis.WebSocket;
9044
10250
  if (!WS) throw new Error("WebSocket is not available in this runtime");
9045
10251
  this.ws = new WS(this.url);
@@ -9080,21 +10286,27 @@ var init_mcp_client = __esm({
9080
10286
  tools = [];
9081
10287
  resources = [];
9082
10288
  headers;
9083
- constructor(url, headers = null) {
10289
+ allowPrivate;
10290
+ constructor(url, headers = null, opts = {}) {
9084
10291
  this.url = url;
9085
10292
  this.headers = headers ?? {};
9086
- this.transport = _MCPClient.createTransport(url, this.headers);
10293
+ this.allowPrivate = opts.allowPrivate ?? false;
10294
+ this.transport = _MCPClient.createTransport(url, this.headers, this.allowPrivate);
9087
10295
  }
9088
- static createTransport(url, headers) {
10296
+ static createTransport(url, headers, allowPrivate = false) {
9089
10297
  if (url.startsWith("stdio:") || url === "stdio") {
9090
10298
  throw new Error("stdio MCP transport is not supported (cloud deployments use SSE/HTTP/WebSocket)");
9091
10299
  }
9092
- if (url.startsWith("ws://") || url.startsWith("wss://")) return new WsTransport(url, headers);
10300
+ if (url.startsWith("ws://") || url.startsWith("wss://")) {
10301
+ return new WsTransport(url, headers, allowPrivate);
10302
+ }
9093
10303
  if (url.startsWith("sse+http://") || url.startsWith("sse+https://") || url.endsWith("/events") || url.endsWith("/sse")) {
9094
10304
  const clean = url.replace("sse+http://", "http://").replace("sse+https://", "https://");
9095
- return new SseTransport(clean, headers);
10305
+ return new SseTransport(clean, headers, allowPrivate);
10306
+ }
10307
+ if (url.startsWith("http://") || url.startsWith("https://")) {
10308
+ return new HttpTransport(url, headers, allowPrivate);
9096
10309
  }
9097
- if (url.startsWith("http://") || url.startsWith("https://")) return new HttpTransport(url, headers);
9098
10310
  throw new Error(`Unsupported URL: ${url} (use http(s)://, ws(s)://, or .../sse)`);
9099
10311
  }
9100
10312
  nextId() {
@@ -9146,7 +10358,15 @@ var init_mcp_client = __esm({
9146
10358
  await this.transport.send({ jsonrpc: "2.0", method, params: params ?? {} });
9147
10359
  }
9148
10360
  async connect() {
9149
- await this.transport.connect();
10361
+ try {
10362
+ await this.transport.connect();
10363
+ } catch (e) {
10364
+ try {
10365
+ await this.transport.close();
10366
+ } catch {
10367
+ }
10368
+ throw e;
10369
+ }
9150
10370
  if (!(this.transport instanceof HttpTransport)) {
9151
10371
  this.connected = true;
9152
10372
  void this.recvLoop();
@@ -9229,7 +10449,7 @@ var init_mcp_client = __esm({
9229
10449
  }
9230
10450
  this.id = 0;
9231
10451
  this.pending.clear();
9232
- this.transport = _MCPClient.createTransport(url, headers);
10452
+ this.transport = _MCPClient.createTransport(url, headers, this.allowPrivate);
9233
10453
  await this.connect();
9234
10454
  await this.listTools();
9235
10455
  }
@@ -9237,11 +10457,11 @@ var init_mcp_client = __esm({
9237
10457
  MCPRegistry = class {
9238
10458
  clients = /* @__PURE__ */ new Map();
9239
10459
  healthTask = null;
9240
- async connect(name, url, headers = null, token = null) {
10460
+ async connect(name, url, headers = null, token = null, allowPrivate = false) {
9241
10461
  this.clients.delete(name);
9242
10462
  const hdrs = { ...headers ?? {} };
9243
10463
  if (token) hdrs.Authorization = hdrs.Authorization ?? `Bearer ${token}`;
9244
- const client = new MCPClient(url, hdrs);
10464
+ const client = new MCPClient(url, hdrs, { allowPrivate });
9245
10465
  await client.connect();
9246
10466
  await client.listTools();
9247
10467
  this.clients.set(name, client);
@@ -9293,8 +10513,18 @@ var init_mcp_client = __esm({
9293
10513
  };
9294
10514
  PromptevMCP = class {
9295
10515
  clients = new MCPRegistry();
10516
+ allowPrivate;
10517
+ /**
10518
+ * `allowPrivate` is the operator knob `allowPrivateEgress`, carried down to
10519
+ * every transport this facade builds. It defaults to `false`, so a
10520
+ * construction site that forgets to pass it denies private destinations
10521
+ * rather than permitting them.
10522
+ */
10523
+ constructor(opts = {}) {
10524
+ this.allowPrivate = opts.allowPrivate ?? false;
10525
+ }
9296
10526
  async addServer(name, url, opts = {}) {
9297
- await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null);
10527
+ await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null, this.allowPrivate);
9298
10528
  }
9299
10529
  async shutdown() {
9300
10530
  await this.clients.shutdown();
@@ -9419,6 +10649,9 @@ var init_registry = __esm({
9419
10649
  "src/tools/registry.ts"() {
9420
10650
  }
9421
10651
  });
10652
+ function allowPrivateEgress(engine) {
10653
+ return Boolean(engine?.config?.allowPrivateEgress);
10654
+ }
9422
10655
  function toolVisible(ct, principals) {
9423
10656
  return aclVisible(ct.acl, principals);
9424
10657
  }
@@ -9500,11 +10733,19 @@ function redactToolResult(result, policy, opts) {
9500
10733
  if (failed.length) note.rules_failed = failed;
9501
10734
  return [redacted, note];
9502
10735
  }
9503
- function rowToToolConfig(row, engine) {
9504
- let config = {};
9505
- if (row.config_encrypted) {
9506
- config = decryptDict(String(row.config_encrypted), getSecretKey(engine.config));
10736
+ function cryptoKey(engine) {
10737
+ const key = getSecretKey(engine.config);
10738
+ if (key.length !== 32) {
10739
+ throw new Error(`CE_SECRET_KEY is malformed: decoded to ${key.length} bytes, need 32`);
9507
10740
  }
10741
+ return key;
10742
+ }
10743
+ function decryptRowConfig(row, engine) {
10744
+ if (!row.config_encrypted) return {};
10745
+ return decryptDict(String(row.config_encrypted), cryptoKey(engine));
10746
+ }
10747
+ function rowToToolConfig(row, engine, config) {
10748
+ config ??= decryptRowConfig(row, engine);
9508
10749
  return new ToolConfig({
9509
10750
  id: String(row.id),
9510
10751
  name: String(row.name),
@@ -9515,9 +10756,24 @@ function rowToToolConfig(row, engine) {
9515
10756
  acl: row.acl != null ? [...row.acl] : null,
9516
10757
  requiresApproval: Boolean(row.requires_approval),
9517
10758
  approvalPolicy: row.approval_policy ?? {},
9518
- enabled: Boolean(row.enabled)
10759
+ enabled: Boolean(row.enabled),
10760
+ metaData: row.meta_data ?? {}
9519
10761
  });
9520
10762
  }
10763
+ function rowToToolConfigLenient(row, engine) {
10764
+ if (!row.config_encrypted) return rowToToolConfig(row, engine, {});
10765
+ const key = cryptoKey(engine);
10766
+ let config;
10767
+ try {
10768
+ config = decryptDict(String(row.config_encrypted), key);
10769
+ } catch {
10770
+ console.warn(
10771
+ `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`
10772
+ );
10773
+ return null;
10774
+ }
10775
+ return rowToToolConfig(row, engine, config);
10776
+ }
9521
10777
  async function loadPersistedCanonicals(engine, sourceId) {
9522
10778
  const params = [];
9523
10779
  let sql = `SELECT * FROM context_engine_tools WHERE enabled IS TRUE`;
@@ -9528,7 +10784,9 @@ async function loadPersistedCanonicals(engine, sourceId) {
9528
10784
  const result = await engine.pool.query(sql, params);
9529
10785
  const canonicals = [];
9530
10786
  for (const row of result.rows) {
9531
- canonicals.push(...canonicalFromConfig(rowToToolConfig(row, engine)));
10787
+ const tc = rowToToolConfigLenient(row, engine);
10788
+ if (tc === null) continue;
10789
+ canonicals.push(...canonicalFromConfig(tc));
9532
10790
  }
9533
10791
  return canonicals;
9534
10792
  }
@@ -9548,13 +10806,18 @@ function canonicalToPublic(ct) {
9548
10806
  async function registerTool(engine, tc) {
9549
10807
  canonicalFromConfig(tc);
9550
10808
  const config = tc.config ?? {};
9551
- const configEncrypted = Object.keys(config).length ? encryptDict(config, getSecretKey(engine.config)) : null;
10809
+ if (containsSentinel(config)) {
10810
+ throw new ConfigTemplateError(
10811
+ `config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a redacted template cannot be registered as a new tool; re-enter the secret values`
10812
+ );
10813
+ }
10814
+ const configEncrypted = Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null;
9552
10815
  const id = randomUUID();
9553
10816
  await engine.pool.query(
9554
10817
  `INSERT INTO context_engine_tools
9555
10818
  (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)`,
10819
+ requires_approval, approval_policy, enabled, meta_data)
10820
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11::jsonb)`,
9558
10821
  [
9559
10822
  id,
9560
10823
  tc.name,
@@ -9565,7 +10828,8 @@ async function registerTool(engine, tc) {
9565
10828
  configEncrypted,
9566
10829
  tc.requiresApproval,
9567
10830
  JSON.stringify(tc.approvalPolicy ?? {}),
9568
- tc.enabled
10831
+ tc.enabled,
10832
+ JSON.stringify(tc.metaData ?? {})
9569
10833
  ]
9570
10834
  );
9571
10835
  return id;
@@ -9582,12 +10846,41 @@ async function updateTool(engine, id, opts) {
9582
10846
  let i = 1;
9583
10847
  for (const [key, value] of Object.entries(fields)) {
9584
10848
  if (key === "config") {
10849
+ let config = value;
10850
+ if (config && containsSentinel(config)) {
10851
+ if (Object.hasOwn(fields, "kind") && fields.kind !== row.kind) {
10852
+ throw new ConfigTemplateError(
10853
+ "cannot change kind and keep redacted secrets in one PATCH \u2014 re-enter the config in full"
10854
+ );
10855
+ }
10856
+ if (config.body_secret === false && containsSentinel(config.body)) {
10857
+ throw new ConfigTemplateError(
10858
+ `body_secret cannot be turned off while the body still contains ${JSON.stringify(REDACTED_SENTINEL)} \u2014 re-enter the body to declassify it`
10859
+ );
10860
+ }
10861
+ if (!row.config_encrypted) {
10862
+ throw new ConfigTemplateError(
10863
+ `config contains ${JSON.stringify(REDACTED_SENTINEL)} but this tool has no stored config to keep \u2014 re-enter the secret values`
10864
+ );
10865
+ }
10866
+ const key2 = cryptoKey(engine);
10867
+ let stored;
10868
+ try {
10869
+ stored = decryptDict(String(row.config_encrypted), key2);
10870
+ } catch (exc) {
10871
+ throw new ConfigTemplateError(
10872
+ `the stored config cannot be decrypted (was the secret key rotated?) \u2014 re-enter the config in full, without ${JSON.stringify(REDACTED_SENTINEL)} values`,
10873
+ { cause: exc }
10874
+ );
10875
+ }
10876
+ config = mergeRedacted(config, stored);
10877
+ }
9585
10878
  sets.push(`config_encrypted = $${i++}`);
9586
- params.push(value ? encryptDict(value, getSecretKey(engine.config)) : null);
10879
+ params.push(config && Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null);
9587
10880
  } else if (UPDATABLE_COLUMNS.has(key)) {
9588
- const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key;
10881
+ const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key === "metaData" ? "meta_data" : key;
9589
10882
  sets.push(`${col} = $${i++}`);
9590
- params.push(col === "approval_policy" ? JSON.stringify(value ?? {}) : value);
10883
+ params.push(col === "approval_policy" || col === "meta_data" ? JSON.stringify(value ?? {}) : value);
9591
10884
  }
9592
10885
  }
9593
10886
  sets.push(`updated_at = now()`);
@@ -9596,7 +10889,8 @@ async function updateTool(engine, id, opts) {
9596
10889
  `UPDATE context_engine_tools SET ${sets.join(", ")} WHERE id = $${i} RETURNING *`,
9597
10890
  params
9598
10891
  );
9599
- return rowToToolConfig(updated.rows[0], engine);
10892
+ const row_ = updated.rows[0];
10893
+ return rowToToolConfigLenient(row_, engine) ?? rowToToolConfig(row_, engine, {});
9600
10894
  }
9601
10895
  async function deleteTool(engine, id, opts = {}) {
9602
10896
  const existing = await engine.pool.query(`SELECT * FROM context_engine_tools WHERE id = $1`, [id]);
@@ -9645,17 +10939,38 @@ function probeFailure(exc, kind) {
9645
10939
  console.warn(`test_tool(kind=${kind}) failed:`, exc, "->", category);
9646
10940
  return { ok: false, error: category };
9647
10941
  }
9648
- async function testTool(_engine, tc) {
10942
+ async function testTool(engine, tc) {
9649
10943
  const kind = tc.kind;
9650
10944
  const config = tc.config ?? {};
10945
+ if (containsSentinel(config)) {
10946
+ throw new ConfigTemplateError(
10947
+ `config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a probe would send it verbatim as the credential; re-enter the secret values`
10948
+ );
10949
+ }
9651
10950
  if (kind === "http") {
9652
10951
  const url = config.url;
9653
10952
  if (!url) return { ok: false, error: "http config missing 'url'" };
9654
10953
  try {
9655
- const resp = await fetch(url, {
10954
+ await assertEgressAllowed(url, { allowPrivate: allowPrivateEgress(engine) });
10955
+ } catch (exc) {
10956
+ if (exc instanceof EgressDenied) {
10957
+ console.warn(`testTool(kind=http) refused: ${exc.message}`);
10958
+ return { ok: false, error: "egress_denied" };
10959
+ }
10960
+ throw exc;
10961
+ }
10962
+ const client = engine?._toolHttpClient ?? null;
10963
+ try {
10964
+ const resp = client ? await client.request({
10965
+ method: "HEAD",
10966
+ url,
10967
+ headers: config.headers ?? {}
10968
+ }) : await fetch(url, {
9656
10969
  method: "HEAD",
9657
10970
  headers: config.headers ?? {},
9658
- redirect: "follow",
10971
+ // No automatic follow: a redirect would reach a destination the
10972
+ // check above never saw.
10973
+ redirect: "manual",
9659
10974
  signal: AbortSignal.timeout(1e4)
9660
10975
  });
9661
10976
  return { ok: resp.status < 500, status_code: resp.status };
@@ -9676,12 +10991,16 @@ async function testTool(_engine, tc) {
9676
10991
  const url = config.url;
9677
10992
  if (!url) return { ok: false, error: "mcp config missing 'url'" };
9678
10993
  const token = config.oauth_token ?? config.bearer ?? config.access_token;
9679
- const mcp = new PromptevMCP();
10994
+ const mcp = new PromptevMCP({ allowPrivate: allowPrivateEgress(engine) });
9680
10995
  try {
9681
10996
  await mcp.addServer(tc.name, url, { token: token ?? null });
9682
10997
  const client = mcp.clients.get(tc.name);
9683
10998
  return { ok: true, tools: client.tools.map((t) => t.name) };
9684
10999
  } catch (exc) {
11000
+ if (exc instanceof EgressDenied) {
11001
+ console.warn(`testTool(kind=mcp) refused: ${exc.message}`);
11002
+ return { ok: false, error: "egress_denied" };
11003
+ }
9685
11004
  return probeFailure(exc, "mcp");
9686
11005
  } finally {
9687
11006
  await mcp.shutdown();
@@ -9689,11 +11008,20 @@ async function testTool(_engine, tc) {
9689
11008
  }
9690
11009
  return { ok: false, error: `test_tool does not support kind=${JSON.stringify(kind)}` };
9691
11010
  }
9692
- async function findAndClaimApproved(engine, toolName, args, sourceId) {
11011
+ async function findAndClaimApproved(engine, toolName, args, sourceId, principals, approvalScope) {
9693
11012
  const claimedAt = /* @__PURE__ */ new Date();
11013
+ const params = [toolName];
11014
+ let scopeSql2 = "approval_scope IS NULL";
11015
+ if (approvalScope !== null) {
11016
+ params.push(approvalScope);
11017
+ scopeSql2 = `approval_scope = $${params.length}`;
11018
+ }
11019
+ const wall = claimVisibilitySql(principals, params.length + 1);
11020
+ params.push(...wall.params);
9694
11021
  const candidates = await engine.pool.query(
9695
- `SELECT * FROM context_engine_tool_approvals WHERE tool_name = $1 AND status = 'approved'`,
9696
- [toolName]
11022
+ `SELECT * FROM context_engine_tool_approvals
11023
+ WHERE tool_name = $1 AND status = 'approved' AND ${scopeSql2} AND ${wall.sql}`,
11024
+ params
9697
11025
  );
9698
11026
  for (const row of candidates.rows) {
9699
11027
  if ((row.source_id ?? null) !== (sourceId ?? null)) continue;
@@ -9710,7 +11038,7 @@ async function findAndClaimApproved(engine, toolName, args, sourceId) {
9710
11038
  }
9711
11039
  return null;
9712
11040
  }
9713
- async function dispatchMcp(ct, config, args) {
11041
+ async function dispatchMcp(ct, config, args, opts = {}) {
9714
11042
  const url = config.url;
9715
11043
  if (!url) throw new EngineActionError(`mcp tool ${ct.callName} config missing 'url'`);
9716
11044
  const headers = { ...config.headers ?? {} };
@@ -9719,7 +11047,7 @@ async function dispatchMcp(ct, config, args) {
9719
11047
  if (token) headers.Authorization = `Bearer ${token}`;
9720
11048
  else if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
9721
11049
  const toolName = String(ct.raw?.tool_name ?? ct.displayName);
9722
- const mcp = new PromptevMCP();
11050
+ const mcp = new PromptevMCP({ allowPrivate: opts.allowPrivate ?? false });
9723
11051
  try {
9724
11052
  await mcp.addServer(ct.callName, url, { headers: Object.keys(headers).length ? headers : null });
9725
11053
  const client = mcp.clients.get(ct.callName);
@@ -9730,7 +11058,10 @@ async function dispatchMcp(ct, config, args) {
9730
11058
  }
9731
11059
  async function dispatch(engine, ct, config, args) {
9732
11060
  if (ct.kind === "http") {
9733
- return executeHttp(config, args, { client: engine._toolHttpClient ?? null });
11061
+ return executeHttp(config, args, {
11062
+ client: engine._toolHttpClient ?? null,
11063
+ allowPrivate: allowPrivateEgress(engine)
11064
+ });
9734
11065
  }
9735
11066
  if (ct.kind === "db") {
9736
11067
  const query = String(args.query ?? "");
@@ -9745,7 +11076,9 @@ async function dispatch(engine, ct, config, args) {
9745
11076
  }
9746
11077
  return result;
9747
11078
  }
9748
- if (ct.kind === "mcp") return dispatchMcp(ct, config, args);
11079
+ if (ct.kind === "mcp") {
11080
+ return dispatchMcp(ct, config, args, { allowPrivate: allowPrivateEgress(engine) });
11081
+ }
9749
11082
  if (ct.kind === "function") {
9750
11083
  const fn = ct.raw?.callable;
9751
11084
  if (!fn) throw new EngineActionError(`function tool ${ct.callName} has no callable`);
@@ -9766,10 +11099,19 @@ async function decryptCtConfig(engine, toolId) {
9766
11099
  ]);
9767
11100
  const row = result.rows[0];
9768
11101
  if (!row?.config_encrypted) return {};
9769
- return decryptDict(String(row.config_encrypted), getSecretKey(engine.config));
11102
+ return decryptDict(String(row.config_encrypted), cryptoKey(engine));
11103
+ }
11104
+ function warnUnscopedApproval(callName) {
11105
+ if (warnedUnscoped.has(callName)) return;
11106
+ warnedUnscoped.add(callName);
11107
+ process.emitWarning(
11108
+ `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.`,
11109
+ { type: "DeprecationWarning", code: "CE_APPROVAL_SCOPE_MISSING" }
11110
+ );
9770
11111
  }
9771
11112
  async function executeTool(engine, callName, args, opts = {}) {
9772
11113
  const runtimeArgs = args ?? {};
11114
+ const approvalScope = validateApprovalScope(opts.approvalScope);
9773
11115
  const ct = findTool(await mergedTools(engine, opts.sourceId ?? null), callName);
9774
11116
  if (!ct) throw new EngineActionError(`tool not found: ${callName}`);
9775
11117
  if (!toolVisible(ct, opts.principals ?? null)) {
@@ -9778,22 +11120,32 @@ async function executeTool(engine, callName, args, opts = {}) {
9778
11120
  const publicArgs = stripUnderscoreArgs(runtimeArgs);
9779
11121
  let approvalId = null;
9780
11122
  if (shouldRequireApproval(ct, publicArgs)) {
9781
- approvalId = await findAndClaimApproved(engine, ct.callName, publicArgs, opts.sourceId ?? null);
11123
+ if (approvalScope === null) warnUnscopedApproval(callName);
11124
+ approvalId = await findAndClaimApproved(
11125
+ engine,
11126
+ ct.callName,
11127
+ publicArgs,
11128
+ opts.sourceId ?? null,
11129
+ opts.principals ?? null,
11130
+ approvalScope
11131
+ );
9782
11132
  if (approvalId == null) {
9783
- const record = await createPending(engine, {
11133
+ const pendingOpts = {
9784
11134
  toolName: ct.callName,
9785
11135
  args: publicArgs,
9786
11136
  sourceId: opts.sourceId ?? null,
9787
11137
  principals: opts.principals ?? null,
9788
11138
  policy: ct.approvalPolicy ?? {}
9789
- });
11139
+ };
11140
+ const record = approvalScope === null ? await createPending(engine, pendingOpts) : await findOrCreatePending(engine, { ...pendingOpts, approvalScope });
9790
11141
  const reason = ct.requiresApproval ? "tool requires approval" : `approval policy condition met: ${ct.approvalPolicy?.condition}`;
9791
11142
  return {
9792
11143
  approval_required: {
9793
11144
  approval_id: String(record.id),
9794
11145
  tool_name: ct.callName,
9795
11146
  args: publicArgs,
9796
- reason
11147
+ reason,
11148
+ expires_at: record.expiresAt ? record.expiresAt.toISOString() : null
9797
11149
  }
9798
11150
  };
9799
11151
  }
@@ -9818,8 +11170,9 @@ async function executeTool(engine, callName, args, opts = {}) {
9818
11170
  let truncated = false;
9819
11171
  if (success) {
9820
11172
  try {
11173
+ const redactPrincipals = opts.principals === TRUSTED ? null : opts.principals ?? null;
9821
11174
  const [redacted] = redactToolResult(rawResult, engine.config.redaction, {
9822
- principals: opts.principals ?? null,
11175
+ principals: redactPrincipals,
9823
11176
  secretKey: engine.config.secretKey,
9824
11177
  hooks: engine.hooks
9825
11178
  });
@@ -9859,17 +11212,19 @@ async function executeTool(engine, callName, args, opts = {}) {
9859
11212
  usage: { units: 1, kind: "tool", tool_name: ct.callName, truncated }
9860
11213
  };
9861
11214
  }
9862
- var RESULT_MAX_CHARS, RESULT_MAX_ROWS, UPDATABLE_COLUMNS, PROBE_AUTH, PROBE_UNREACHABLE, PROBE_TIMEOUT, PROBE_MISCONFIGURED;
11215
+ var RESULT_MAX_CHARS, RESULT_MAX_ROWS, UPDATABLE_COLUMNS, PROBE_AUTH, PROBE_UNREACHABLE, PROBE_TIMEOUT, PROBE_MISCONFIGURED, warnedUnscoped;
9863
11216
  var init_governance = __esm({
9864
11217
  "src/tools/governance.ts"() {
9865
11218
  init_errors();
9866
11219
  init_hooks();
9867
11220
  init_redaction();
11221
+ init_sentinels();
9868
11222
  init_acl();
9869
11223
  init_approval();
9870
11224
  init_audit();
9871
- init_config();
11225
+ init_config2();
9872
11226
  init_crypto2();
11227
+ init_egress();
9873
11228
  init_db2();
9874
11229
  init_http();
9875
11230
  init_mcp_client();
@@ -9887,7 +11242,9 @@ var init_governance = __esm({
9887
11242
  "requires_approval",
9888
11243
  "approvalPolicy",
9889
11244
  "approval_policy",
9890
- "enabled"
11245
+ "enabled",
11246
+ "metaData",
11247
+ "meta_data"
9891
11248
  ]);
9892
11249
  PROBE_AUTH = [
9893
11250
  "authentication failed",
@@ -9926,6 +11283,7 @@ var init_governance = __esm({
9926
11283
  "not supported",
9927
11284
  "unsupported"
9928
11285
  ];
11286
+ warnedUnscoped = /* @__PURE__ */ new Set();
9929
11287
  }
9930
11288
  });
9931
11289
  function mulberry322(seed) {
@@ -10757,7 +12115,8 @@ var retrieval_exports = {};
10757
12115
  __export(retrieval_exports, {
10758
12116
  buildGraphRanked: () => buildGraphRanked,
10759
12117
  corpusIsAclUniform: () => corpusIsAclUniform,
10760
- shouldUseCommunitySummaries: () => shouldUseCommunitySummaries
12118
+ shouldUseCommunitySummaries: () => shouldUseCommunitySummaries,
12119
+ vectorSeedIds: () => vectorSeedIds
10761
12120
  });
10762
12121
  function vecLiteral2(vector) {
10763
12122
  return `[${vector.map((x) => Number(x)).join(",")}]`;
@@ -10783,8 +12142,8 @@ async function deriveQueryEntities(pool, chunkIds, opts) {
10783
12142
  JOIN context_engine_chunks c ON c.id = ce.chunk_id
10784
12143
  WHERE ce.chunk_id = ANY($1::uuid[]) ${SCOPE2}
10785
12144
  GROUP BY e.normalized_name, e.name, e.type
10786
- ORDER BY freq DESC LIMIT $4`,
10787
- [chunkIds, opts.sourceIds, opts.principals, ENTITY_LIMIT]
12145
+ ORDER BY freq DESC LIMIT $5`,
12146
+ [chunkIds, opts.sourceIds, opts.documentIds ?? null, opts.principals, ENTITY_LIMIT]
10788
12147
  );
10789
12148
  return result.rows.map((r) => ({
10790
12149
  normalized_name: r.normalized_name,
@@ -10849,8 +12208,8 @@ async function computeCommunityScores(pool, chunkIds, vector, opts) {
10849
12208
  `SELECT ce.chunk_id::text, ce.entity_id::text
10850
12209
  FROM context_engine_chunk_entities ce
10851
12210
  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)]
12211
+ WHERE ce.chunk_id = ANY($1::uuid[]) AND ce.entity_id = ANY($5::uuid[]) ${SCOPE2}`,
12212
+ [chunkIds, opts.sourceIds, opts.documentIds ?? null, opts.principals, Object.keys(entityScore)]
10854
12213
  );
10855
12214
  const chunkScores = {};
10856
12215
  for (const row of result.rows) {
@@ -10859,13 +12218,29 @@ async function computeCommunityScores(pool, chunkIds, vector, opts) {
10859
12218
  return chunkScores;
10860
12219
  }
10861
12220
  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));
12221
+ const binds = [opts.sourceIds, opts.documentIds ?? null, opts.principals];
12222
+ const scoped = binds.some((v) => v !== null);
12223
+ const client = await pool.connect();
12224
+ try {
12225
+ await client.query("BEGIN");
12226
+ await opts.backend?.tuneAnnScan?.(client, SEED_LIMIT, scoped);
12227
+ const result = await client.query(
12228
+ `SELECT c.id::text FROM context_engine_chunks c
12229
+ WHERE c.embedding IS NOT NULL ${SCOPE2}
12230
+ ORDER BY c.embedding <=> CAST($1 AS vector) LIMIT $5`,
12231
+ [vecLiteral2(vector), ...binds, SEED_LIMIT]
12232
+ );
12233
+ await client.query("COMMIT");
12234
+ return result.rows.map((r) => String(r.id));
12235
+ } catch (err) {
12236
+ try {
12237
+ await client.query("ROLLBACK");
12238
+ } catch {
12239
+ }
12240
+ throw err;
12241
+ } finally {
12242
+ client.release();
12243
+ }
10869
12244
  }
10870
12245
  async function buildGraphRanked(query, opts) {
10871
12246
  try {
@@ -10874,11 +12249,18 @@ async function buildGraphRanked(query, opts) {
10874
12249
  const vector = vectors[0] ? [...vectors[0]] : null;
10875
12250
  if (!vector) return [];
10876
12251
  const sourceIds = opts.sourceIds ?? null;
12252
+ const documentIds = opts.documentIds ?? null;
10877
12253
  const principals = opts.principals ?? null;
10878
- const seeds = await vectorSeedIds(opts.pool, vector, { sourceIds, principals });
12254
+ const seeds = await vectorSeedIds(opts.pool, vector, {
12255
+ sourceIds,
12256
+ documentIds,
12257
+ principals,
12258
+ backend: opts.backend
12259
+ });
10879
12260
  if (!seeds.length) return [];
10880
12261
  const queryEntities = await deriveQueryEntities(opts.pool, seeds.slice(0, TOP_SEEDS_FOR_ENTITIES), {
10881
12262
  sourceIds,
12263
+ documentIds,
10882
12264
  principals
10883
12265
  });
10884
12266
  const entityNorms = queryEntities.map((e) => String(e.normalized_name));
@@ -10900,6 +12282,7 @@ async function buildGraphRanked(query, opts) {
10900
12282
  const relScores = await computeRelationshipScores(opts.pool, allIds, entityNorms);
10901
12283
  const commScores = await computeCommunityScores(opts.pool, allIds, vector, {
10902
12284
  sourceIds,
12285
+ documentIds,
10903
12286
  principals,
10904
12287
  useSummaries
10905
12288
  });
@@ -10951,7 +12334,8 @@ var init_retrieval = __esm({
10951
12334
  TOP_SEEDS_FOR_ENTITIES = 20;
10952
12335
  SCOPE2 = `
10953
12336
  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[])
12337
+ AND ($3::uuid[] IS NULL OR c.document_id = ANY($3::uuid[]))
12338
+ AND ($4::text[] IS NULL OR c.acl IS NULL OR c.acl && $4::text[])
10955
12339
  `;
10956
12340
  }
10957
12341
  });
@@ -11115,11 +12499,19 @@ var init_engine = __esm({
11115
12499
  _graphStore = null;
11116
12500
  constructor(config, opts = {}) {
11117
12501
  this.config = config;
11118
- this.hooks = { onUsage: opts.onUsage ?? null, onError: opts.onError ?? null };
12502
+ this.hooks = {
12503
+ onUsage: opts.onUsage ?? null,
12504
+ onError: opts.onError ?? null,
12505
+ onProgress: opts.onProgress ?? null
12506
+ };
11119
12507
  }
11120
12508
  async ensurePool() {
11121
12509
  if (!this._pool) {
11122
- this._pool = await createPool(this.config.databaseUrl);
12510
+ this._pool = await createPool(this.config.databaseUrl, {
12511
+ max: this.config.storage.poolMax,
12512
+ idleTimeoutMillis: this.config.storage.poolIdleTimeoutMs,
12513
+ connectionTimeoutMillis: this.config.storage.poolConnectionTimeoutMs
12514
+ });
11123
12515
  this.pool = this._pool;
11124
12516
  }
11125
12517
  if (!this.backend) {
@@ -11305,7 +12697,9 @@ var init_engine = __esm({
11305
12697
  graphStore: await this.getGraphStore(),
11306
12698
  hooks: this.hooks,
11307
12699
  sourceIds: opts.sourceIds ?? null,
11308
- principals
12700
+ documentIds: opts.documentIds ?? null,
12701
+ principals,
12702
+ backend: this.backend
11309
12703
  });
11310
12704
  }
11311
12705
  return runSearch(query, {
@@ -11315,6 +12709,7 @@ var init_engine = __esm({
11315
12709
  embedder: this.embedder,
11316
12710
  pool,
11317
12711
  sourceIds: opts.sourceIds,
12712
+ documentIds: opts.documentIds,
11318
12713
  principals,
11319
12714
  topK: opts.topK,
11320
12715
  mode: opts.mode,
@@ -11420,7 +12815,7 @@ var init_engine = __esm({
11420
12815
  hooks: this.hooks,
11421
12816
  sourceIds: opts.sourceIds,
11422
12817
  principals: resolvePrincipals(opts.principals, "compute"),
11423
- docIds: opts.docIds,
12818
+ documentIds: opts.documentIds,
11424
12819
  modelCfg: opts.modelCfg,
11425
12820
  timeout: opts.timeout
11426
12821
  });
@@ -11473,151 +12868,16 @@ var init_engine = __esm({
11473
12868
  sourceId: opts.sourceId,
11474
12869
  principals: resolvePrincipals(opts.principals, "executeTool"),
11475
12870
  actor: opts.actor,
11476
- source: opts.source ?? "api"
12871
+ source: opts.source ?? "api",
12872
+ approvalScope: opts.approvalScope
11477
12873
  });
11478
12874
  }
11479
12875
  };
11480
12876
  }
11481
12877
  });
11482
12878
 
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
12879
  // src/cli.ts
12880
+ init_config();
11621
12881
  init_db();
11622
12882
 
11623
12883
  // src/diagnostics.ts