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