cortad 0.3.1-rc.9 → 0.3.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 +8 -5
- package/lib/app-port.mjs +9 -0
- package/lib/auto-reach.mjs +88 -0
- package/lib/backing.mjs +230 -0
- package/lib/calls.mjs +23 -0
- package/lib/cli.mjs +115 -30
- package/lib/contact-text.mjs +167 -0
- package/lib/data.mjs +16 -22
- package/lib/dissection-text.mjs +232 -0
- package/lib/document.mjs +75 -0
- package/lib/env-secrets.mjs +48 -0
- package/lib/failed-text.mjs +36 -0
- package/lib/fresh.mjs +33 -12
- package/lib/health.mjs +61 -0
- package/lib/held.mjs +67 -0
- package/lib/home.mjs +55 -17
- package/lib/hosted.mjs +201 -0
- package/lib/identities.mjs +1 -1
- package/lib/keeping.mjs +162 -0
- package/lib/ledger.mjs +20 -0
- package/lib/lock.mjs +6 -1
- package/lib/mcp.mjs +52 -18
- package/lib/mint.mjs +179 -38
- package/lib/mongo.mjs +74 -0
- package/lib/numbers-text.mjs +68 -0
- package/lib/pg.mjs +118 -0
- package/lib/proc.mjs +100 -0
- package/lib/proof.mjs +1014 -0
- package/lib/proxy.mjs +184 -0
- package/lib/pyhook/cortad_sites.py +218 -0
- package/lib/pyhook/cortad_tied.py +291 -0
- package/lib/pyhook/sitecustomize.py +1175 -166
- package/lib/qdrant.mjs +47 -0
- package/lib/reach.mjs +223 -0
- package/lib/read-text.mjs +78 -24
- package/lib/redis.mjs +130 -0
- package/lib/register.mjs +36 -27
- package/lib/replay.mjs +464 -34
- package/lib/runner.mjs +85 -0
- package/lib/service.mjs +25 -0
- package/lib/spec.mjs +7 -4
- package/lib/start.mjs +33 -2
- package/lib/stick.mjs +65 -25
- package/lib/stores.mjs +347 -0
- package/lib/switches.mjs +36 -0
- package/lib/tether.mjs +9 -0
- package/lib/text.mjs +314 -179
- package/lib/tied.cjs +217 -0
- package/lib/trace.cjs +386 -514
- package/lib/verbs.mjs +155 -38
- package/lib/wire.cjs +882 -0
- package/lib/words.mjs +2 -0
- package/lib/writes.mjs +2 -2
- package/local.mjs +661 -297
- package/package.json +31 -1
- package/skill/SKILL.md +49 -42
- package/skill/references/results.md +279 -102
package/lib/trace.cjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Loaded into your app by the command that started it (node --require), and only then. It watches
|
|
2
|
-
// for two things. First, a request to your app during which your app called a model:
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// model
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
2
|
+
// for two things. First, a request to your app during which your app called a model: an exchange,
|
|
3
|
+
// with the exact body it took, the sign-in it carried, what your app answered, the lines of your code
|
|
4
|
+
// that wrote it and the prompt it sent, so the door is proven by a message rather than guessed from
|
|
5
|
+
// code. The first few per route are kept, so two turns of one conversation are both seen. Second,
|
|
6
|
+
// every model call your app makes: the host, the model it asked for, the status, the seconds and the
|
|
7
|
+
// token counts the provider sent back, which tools it asked for (names and clipped arguments, never a
|
|
8
|
+
// secret) and the passages its prompt was handed; the model's words only for a call inside a kept
|
|
9
|
+
// exchange or a run's turn. Both go to a file only you can read, in the command's own folder on this
|
|
10
|
+
// machine. Nothing here talks to a network.
|
|
10
11
|
"use strict";
|
|
11
12
|
const FILE = process.env.CORTAD_TRACE_FILE;
|
|
12
13
|
if (FILE) {
|
|
@@ -16,28 +17,8 @@ if (FILE) {
|
|
|
16
17
|
const http = require("node:http");
|
|
17
18
|
const https = require("node:https");
|
|
18
19
|
const als = new AsyncLocalStorage();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// door row's sign-in headers are kept on purpose: the command replays them from this machine.
|
|
22
|
-
const SECRET_TEXT = [
|
|
23
|
-
/((?:pass(?:word|phrase|wd)|pwd|密码|口令)["']?\s*(?:is\b|was\b|[:=:]|是|为)\s*["']?)([^\s"'\\,;},。;、]+)/gi,
|
|
24
|
-
/((?:secret(?:[ _-]?key)?|api[ _-]?key|apikey|access[ _-]?key|private[ _-]?key|client[ _-]?secret|(?:auth|access|refresh)[ _-]?token|token)["']?\s*[:=:]\s*["']?)([^\s"'\\,;},。;、]+)/gi,
|
|
25
|
-
];
|
|
26
|
-
const SECRET_WORD = /\b(?:Bearer\s+(?=[\w.~+/=-]*\d)[\w.~+/=-]{16,}|[spr]k[-_](?=[\w-]*\d)[\w-]{8,}|gh[po]_\w{16,}|github_pat_\w{16,}|xox[abpr]-[\w-]{8,}|AKIA[0-9A-Z]{12,}|AIza[\w-]{20,}|eyJ[\w-]{10,}\.[\w-]{4,}\.[\w-]*)/g;
|
|
27
|
-
const maskText = (s) => SECRET_TEXT.reduce((t, re) => t.replace(re, "$1[secret]"), s).replace(SECRET_WORD, "[secret]");
|
|
28
|
-
const scrub = (v, depth = 0) => (typeof v === "string" ? maskText(v)
|
|
29
|
-
: Array.isArray(v) ? v.map((x) => scrub(x, depth + 1))
|
|
30
|
-
: v && typeof v === "object" && depth < 12 ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, k === "headers" && depth === 0 ? x : scrub(x, depth + 1)])) : v);
|
|
31
|
-
// A door's body goes up to the run as JSON, so it is masked as JSON: its string values, never its shape.
|
|
32
|
-
const bodyScrubbed = (text) => { try { const b = JSON.parse(text); return b && typeof b === "object" ? JSON.stringify(scrub(b)) : maskText(text); } catch { return maskText(text); } };
|
|
33
|
-
const row = (value) => {
|
|
34
|
-
try {
|
|
35
|
-
const plain = value.hello || value.listen || value.routes;
|
|
36
|
-
const out = plain ? value : scrub(value);
|
|
37
|
-
if (!plain && typeof value.body === "string") out.body = bodyScrubbed(value.body);
|
|
38
|
-
fs.appendFileSync(FILE, JSON.stringify(out) + "\n", { mode: 0o600 });
|
|
39
|
-
} catch { /* the command is gone */ }
|
|
40
|
-
};
|
|
20
|
+
const { MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, toolText, scrubbed, list, clipped, argsText, turnOf, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames, makeRecorder, asChat, GATEWAY_MODEL } = require("./wire.cjs");
|
|
21
|
+
const row = (value) => { try { fs.appendFileSync(FILE, JSON.stringify(scrubbed(value)) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ } };
|
|
41
22
|
// What the app writes into its own folder while a run is on: each file copied once before its
|
|
42
23
|
// first write, so the command puts every one back when the run ends (lib/writes.mjs). Trials
|
|
43
24
|
// wrote carts and tickets into an app's data/*.json and the developer's own tests read them.
|
|
@@ -48,7 +29,7 @@ if (FILE) {
|
|
|
48
29
|
const crypto = require("node:crypto");
|
|
49
30
|
const { fileURLToPath } = require("node:url");
|
|
50
31
|
// Not the app's data: what a build, a package manager or a log writes.
|
|
51
|
-
const NOT_DATA = /(?:^|\/)(?:node_modules|\.git|\.next|\.nuxt|\.svelte-kit|\.turbo|\.cache|\.parcel-cache|\.venv|venv|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|dist|build|out|coverage|\.cortad)(?:\/|$)|\.(?:
|
|
32
|
+
const NOT_DATA = /(?:^|\/)(?:node_modules|\.git|\.next|\.nuxt|\.svelte-kit|\.turbo|\.cache|\.parcel-cache|\.venv|venv|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|dist|build|out|coverage|\.cortad|logs?)(?:\/|$)|\.log(?:\.[\w-]+)?$|\.(?:pyc|tmp|swp)$/;
|
|
52
33
|
const raw = { copyFileSync: fs.copyFileSync, appendFileSync: fs.appendFileSync, existsSync: fs.existsSync, mkdirSync: fs.mkdirSync, statSync: fs.statSync, readFileSync: fs.readFileSync };
|
|
53
34
|
const root = p.resolve(APP_ROOT);
|
|
54
35
|
let run = null, runAt = -1;
|
|
@@ -122,28 +103,52 @@ if (FILE) {
|
|
|
122
103
|
// The frames of the stack it is called from, nearest first, outside this hook and node_modules,
|
|
123
104
|
// relative to the folder the app was started in. An awaited call keeps its awaiting frames in
|
|
124
105
|
// the stack, and an SDK is deeper than the default ten frames.
|
|
125
|
-
|
|
126
|
-
|
|
106
|
+
// A frame still inside a build folder is a bundle line no one edits: it is kept by its absolute
|
|
107
|
+
// path and column, and the command places it through the chunk's source map (lib/replay.mjs), so
|
|
108
|
+
// the app never reads a map on a request's path.
|
|
109
|
+
const BUILT = /\/(?:\.next|\.nuxt|\.svelte-kit|\.output|\.turbo|\.vercel|dist|build|out)\//;
|
|
110
|
+
// `typed`: each frame also carries the type of the object it was called on, read off the same
|
|
111
|
+
// stack as it is formatted; none at all when a formatter of the app's own does not keep one line a
|
|
112
|
+
// frame.
|
|
113
|
+
const stackFrames = (typed) => {
|
|
114
|
+
const limit = Error.stackTraceLimit, prepare = Error.prepareStackTrace;
|
|
115
|
+
let types = null;
|
|
127
116
|
Error.stackTraceLimit = 60;
|
|
128
|
-
|
|
129
|
-
|
|
117
|
+
if (typed) Error.prepareStackTrace = (err, calls) => { types = calls.map((c) => c.getTypeName()); return typeof prepare === "function" ? prepare(err, calls) : [String(err), ...calls.map((c) => ` at ${c}`)].join("\n"); };
|
|
118
|
+
let stack;
|
|
119
|
+
try { stack = String(new Error().stack); } finally { Error.stackTraceLimit = limit; if (typed) Error.prepareStackTrace = prepare; }
|
|
120
|
+
const lines = stack.split("\n").slice(1);
|
|
121
|
+
if (typed && lines.length !== types?.length) return [];
|
|
130
122
|
const out = [];
|
|
131
|
-
|
|
132
|
-
const m = /\(?(?:file:\/\/)?(\/[^()]+?):(\d+)
|
|
133
|
-
if (m
|
|
134
|
-
|
|
123
|
+
lines.forEach((line, i) => {
|
|
124
|
+
const m = /\(?(?:file:\/\/)?(\/[^()]+?):(\d+):(\d+)\)?$/.exec(line.trim());
|
|
125
|
+
if (!m || m[1] === __filename || m[1].includes("/node_modules/")) return;
|
|
126
|
+
const type = typed ? { type: types[i] } : {};
|
|
127
|
+
if (BUILT.test(m[1])) out.push({ file: decodeURI(m[1]), line: Number(m[2]), column: Number(m[3]), built: true, ...type });
|
|
128
|
+
else out.push({ file: nodePath.relative(process.cwd(), decodeURI(m[1])), line: Number(m[2]), ...type });
|
|
129
|
+
});
|
|
135
130
|
return out;
|
|
136
131
|
};
|
|
137
132
|
// The file that registered a route: the first frame outside this hook and outside node_modules.
|
|
138
|
-
const callerFile = () => { const f = stackFrames()
|
|
133
|
+
const callerFile = () => { const f = stackFrames().find((x) => !x.built); return f && !f.file.startsWith("..") ? f.file : ""; };
|
|
134
|
+
// A built frame is written as its chunk's absolute path, line and column, which no app frame's
|
|
135
|
+
// relative path can be mistaken for.
|
|
136
|
+
const atOf = (f) => (f.built ? `${f.file}:${f.line}:${f.column}` : `${f.file}:${f.line}`);
|
|
139
137
|
// Which of the app's own lines made a model call, nearest first: the run names the code path that
|
|
140
|
-
// ran by it, where the read could only name the door.
|
|
138
|
+
// ran by it, where the read could only name the door. Five lines: a provider class's stream, post
|
|
139
|
+
// and retry can be three lines of one file, and the line that says which path ran is past them.
|
|
140
|
+
// Built frames are handed on past five, since the SDK bundled beside the app's code fills the
|
|
141
|
+
// first of them; the command keeps five once its map has placed them.
|
|
142
|
+
const CALLERS_MAX = 5, CALLERS_BUILT = 20;
|
|
141
143
|
const callers = () => {
|
|
142
144
|
const out = [];
|
|
145
|
+
let own = 0;
|
|
143
146
|
for (const f of stackFrames()) {
|
|
144
|
-
const at =
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
+
const at = atOf(f);
|
|
148
|
+
if (f.file.startsWith("..") || out.includes(at)) continue;
|
|
149
|
+
out.push(at);
|
|
150
|
+
if (!f.built) own++;
|
|
151
|
+
if (own === CALLERS_MAX || out.length === CALLERS_BUILT) break;
|
|
147
152
|
}
|
|
148
153
|
return out.length ? out : undefined;
|
|
149
154
|
};
|
|
@@ -308,408 +313,120 @@ if (FILE) {
|
|
|
308
313
|
recheck = false;
|
|
309
314
|
setImmediate(() => { try { if (table().routes.length !== routesSaid) sayRoutes(lastPort); } catch { /* as above */ } });
|
|
310
315
|
};
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
};
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
// it.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
for (const it of items) if (it.type === "function_call" && it.call_id) names.set(it.call_id, it.name);
|
|
376
|
-
for (const it of since(items, (x) => x.role === "user")) if (it.type === "function_call_output") add(names.get(it.call_id), it.output);
|
|
377
|
-
for (const c of since(list(body.contents), personAsked)) for (const p of list(c.parts)) if (p.functionResponse) add(p.functionResponse.name, p.functionResponse.response);
|
|
378
|
-
const declared = declaredIn(body);
|
|
379
|
-
for (const o of observedIn(turnTexts(body, true, declared), declared)) add(o.name, o.text);
|
|
380
|
-
return out.length ? out : undefined;
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
// What the model asked the app to run: chat tool_calls (a stream's pieces joined by position), a
|
|
384
|
-
// legacy function_call, a responses function_call item, an Anthropic or Bedrock tool use, a
|
|
385
|
-
// Gemini functionCall. Read off the model's reply, and off this turn's earlier calls as the
|
|
386
|
-
// prompt resends them. Names and arguments only: every value clipped, a secret never written.
|
|
387
|
-
const CALLS_MAX = 12, VALUE_MAX = 200, ARGS_MAX = 1200;
|
|
388
|
-
const SECRET_KEY = /(?:^|_)(?:pass(?:word|phrase)?|secret|token|api_?key|authorization|cookie|session(?:_id)?|credentials?|private_key)$/;
|
|
389
|
-
const SECRET_VALUE = /^(?:Bearer\s|Basic\s|sk-|pk_|rk_|ghp_|gho_|github_pat_|xox[abpr]-|AKIA|AIza|eyJ[\w-]{10,}\.)/;
|
|
390
|
-
const clipped = (v, depth, max = VALUE_MAX) => {
|
|
391
|
-
if (typeof v === "string") return SECRET_VALUE.test(v) ? "[secret]" : v.length > max ? v.slice(0, max) + "…" : v;
|
|
392
|
-
if (Array.isArray(v)) return depth > 4 ? [] : v.slice(0, 20).map((x) => clipped(x, depth + 1, max));
|
|
393
|
-
if (!v || typeof v !== "object") return v;
|
|
394
|
-
const o = {};
|
|
395
|
-
if (depth > 4) return o;
|
|
396
|
-
for (const [k, x] of Object.entries(v).slice(0, 40)) o[k] = SECRET_KEY.test(k.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()) ? "[secret]" : clipped(x, depth + 1, max);
|
|
397
|
-
return o;
|
|
398
|
-
};
|
|
399
|
-
const argsText = (raw) => {
|
|
400
|
-
let v = raw;
|
|
401
|
-
if (typeof raw === "string") { try { v = raw.trim() ? JSON.parse(raw) : {}; } catch { v = raw; } }
|
|
402
|
-
return JSON.stringify(clipped(v === undefined || v === null ? {} : v, 0)).slice(0, ARGS_MAX);
|
|
403
|
-
};
|
|
404
|
-
const callsOf = (events) => {
|
|
405
|
-
const whole = new Map();
|
|
406
|
-
const parts = new Map();
|
|
407
|
-
let n = 0;
|
|
408
|
-
const put = (key, name, args) => { if (name) whole.set(key || `w${n++}`, { name, args }); };
|
|
409
|
-
const piece = (key, name, args) => {
|
|
410
|
-
const p = parts.get(key) || { name: "", args: "" };
|
|
411
|
-
if (name && !p.name) p.name = name;
|
|
412
|
-
if (typeof args === "string") p.args += args;
|
|
413
|
-
parts.set(key, p);
|
|
414
|
-
};
|
|
415
|
-
const blocks = (content) => {
|
|
416
|
-
for (const b of list(content)) {
|
|
417
|
-
if (b.type === "tool_use") put(b.id, b.name, b.input);
|
|
418
|
-
if (b.toolUse) put(b.toolUse.toolUseId, b.toolUse.name, b.toolUse.input);
|
|
419
|
-
}
|
|
316
|
+
// When the run last sent a request: a write made outside every request (a queue's worker, a
|
|
317
|
+
// timer) is held while a run is live, since it may be carrying out a trial's ask.
|
|
318
|
+
let lastTurnAt = -Infinity;
|
|
319
|
+
const { tied, requestCtx, note, ended, meter } = makeRecorder({
|
|
320
|
+
row,
|
|
321
|
+
rulesFile: process.env.CORTAD_RULES_FILE,
|
|
322
|
+
onTurn: () => { heldWarm(); lastTurnAt = Date.now(); },
|
|
323
|
+
onEnded: (ctx, steps) => writtenAt(ctx, steps),
|
|
324
|
+
});
|
|
325
|
+
// Where in the app's own code each part of a reply was written: the app's line on the stack of the
|
|
326
|
+
// write, or, for a web stream the app hands back, where its code put the chunk into the stream,
|
|
327
|
+
// kept when the reply carried it. One entry per line and event name, with the first bytes written
|
|
328
|
+
// there, so a block in the reply is placed at the write that sent it (lib/pyhook/cortad_sites.py).
|
|
329
|
+
// ponytail: a reply's first thousand writes are traced; a leak later in a longer stream goes unplaced.
|
|
330
|
+
const SITES_MAX = 16, SITE_TEXT = 300, SITE_WRITES = 1000, BUILT_WALK = 12;
|
|
331
|
+
const EVENT = /^event:[ \t]*([\w.:-]{1,40})/m, JSON_EVENT = /"(?:type|event)"\s*:\s*"([\w.:-]{1,40})"/;
|
|
332
|
+
const siteText = (chunk) => {
|
|
333
|
+
if (typeof chunk === "string") return chunk.slice(0, SITE_TEXT);
|
|
334
|
+
if (ArrayBuffer.isView(chunk)) return Buffer.from(chunk.buffer, chunk.byteOffset, Math.min(chunk.byteLength, SITE_TEXT)).toString("utf8").replace(/\uFFFD+$/, "");
|
|
335
|
+
try { return chunk && typeof chunk === "object" ? String(JSON.stringify(chunk)).slice(0, SITE_TEXT) : ""; } catch { return ""; }
|
|
336
|
+
};
|
|
337
|
+
const wrote = (ctx, chunk, streamed) => {
|
|
338
|
+
if (!ctx || !ctx.sites || ++ctx.traced > SITE_WRITES || ctx.sites.length >= SITES_MAX) return;
|
|
339
|
+
// A method the app put on the response, such as a res.end that logs and calls the one it replaced,
|
|
340
|
+
// only hands the write on: the app's line that called it wrote it.
|
|
341
|
+
const frames = stackFrames(true);
|
|
342
|
+
const i = frames.findIndex((f) => !f.file.startsWith("..") && f.type !== ctx.responseType);
|
|
343
|
+
const text = i >= 0 && siteText(chunk);
|
|
344
|
+
if (!text) return;
|
|
345
|
+
// In a bundle the first frames are the SDK compiled into the same chunk: the next few are
|
|
346
|
+
// handed on, and the command keeps the first that its map places in the app's own source.
|
|
347
|
+
const own = frames[i];
|
|
348
|
+
const at = own.built ? frames.slice(i, i + BUILT_WALK).filter((f) => f.built).map(atOf) : atOf(own);
|
|
349
|
+
const m = EVENT.exec(text) || JSON_EVENT.exec(text);
|
|
350
|
+
const event = m ? m[1] : undefined;
|
|
351
|
+
if (ctx.sites.some((s) => String(s.at) === String(at) && s.event === event)) return;
|
|
352
|
+
ctx.sites.push({ at, ...(event ? { event } : {}), text, ...(streamed ? { streamed } : {}) });
|
|
353
|
+
};
|
|
354
|
+
for (const Controller of [globalThis.ReadableStreamDefaultController, globalThis.TransformStreamDefaultController]) {
|
|
355
|
+
const enqueue = Controller && Controller.prototype.enqueue;
|
|
356
|
+
if (typeof enqueue !== "function") continue;
|
|
357
|
+
Controller.prototype.enqueue = function (chunk) { try { wrote(als.getStore(), chunk, true); } catch { /* the chunk goes on */ } return enqueue.apply(this, arguments); };
|
|
358
|
+
}
|
|
359
|
+
// Written under the id its exchange is written by.
|
|
360
|
+
const writtenAt = (ctx, steps) => {
|
|
361
|
+
const asker = (steps && steps.at(-1)) || ctx;
|
|
362
|
+
const turn = ctx.turn || asker.turn;
|
|
363
|
+
const list = ctx.sites.filter((s) => !s.streamed || ctx.reply.text.includes(s.text)).map(({ streamed, ...s }) => s);
|
|
364
|
+
if (list.length && (asker.kept || turn)) row({ sites: { ex: asker.id, ...(turn ? { turn } : {}), list } });
|
|
365
|
+
};
|
|
366
|
+
// What the app sends back, as it writes it: its own listeners and pipes see exactly the same.
|
|
367
|
+
const watchReply = (ctx, res) => {
|
|
368
|
+
if (!res || typeof res.write !== "function" || typeof res.end !== "function") return;
|
|
369
|
+
ctx.responseType = res.constructor && res.constructor.name;
|
|
370
|
+
const got = [];
|
|
371
|
+
let size = 0, writes = 0;
|
|
372
|
+
const keep = (chunk, encoding) => {
|
|
373
|
+
if (chunk == null || typeof chunk === "function") return;
|
|
374
|
+
writes += 1;
|
|
375
|
+
wrote(ctx, chunk);
|
|
376
|
+
if (size >= MAX) return;
|
|
377
|
+
const b = Buffer.isBuffer(chunk) ? chunk : typeof chunk === "string" ? Buffer.from(chunk, typeof encoding === "string" && Buffer.isEncoding(encoding) ? encoding : "utf8") : Buffer.from(chunk);
|
|
378
|
+
got.push(b);
|
|
379
|
+
size += b.length;
|
|
420
380
|
};
|
|
421
|
-
for (const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const m = c.message;
|
|
425
|
-
if (m) {
|
|
426
|
-
for (const t of list(m.tool_calls)) if (t.function) put(t.id, t.function.name, t.function.arguments);
|
|
427
|
-
if (m.function_call) put(null, m.function_call.name, m.function_call.arguments);
|
|
428
|
-
}
|
|
429
|
-
const d = c.delta;
|
|
430
|
-
if (d) {
|
|
431
|
-
for (const t of list(d.tool_calls)) piece(`c${c.index || 0}.${t.index ?? t.id}`, t.function && t.function.name, t.function && t.function.arguments);
|
|
432
|
-
if (d.function_call) piece(`f${c.index || 0}`, d.function_call.name, d.function_call.arguments);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
for (const it of [...list(e.output), ...list(e.item ? [e.item] : []), ...list(e.response && e.response.output)]) {
|
|
436
|
-
if (it.type === "function_call") put(it.call_id || it.id, it.name, it.arguments);
|
|
437
|
-
}
|
|
438
|
-
blocks(e.content);
|
|
439
|
-
blocks(e.message && e.message.content);
|
|
440
|
-
blocks(e.output && e.output.message && e.output.message.content);
|
|
441
|
-
if (e.type === "content_block_start" && e.content_block && e.content_block.type === "tool_use") piece(`a${e.index}`, e.content_block.name, "");
|
|
442
|
-
if (e.type === "content_block_delta" && e.delta && e.delta.type === "input_json_delta") piece(`a${e.index}`, "", e.delta.partial_json);
|
|
443
|
-
for (const c of list(e.candidates)) for (const p of list(c.content && c.content.parts)) if (p.functionCall) put(null, p.functionCall.name, p.functionCall.args);
|
|
444
|
-
}
|
|
445
|
-
return [...whole.values(), ...parts.values()].filter((p) => p.name).map((p) => ({ name: String(p.name).slice(0, 80), arguments: argsText(p.args) }));
|
|
446
|
-
};
|
|
447
|
-
// This turn's earlier calls, as the prompt resends them after the person's latest message.
|
|
448
|
-
const calledBefore = (body) => callsOf([
|
|
449
|
-
...since(list(body.messages), personSaid).filter((m) => m.role === "assistant").map((m) => ({ choices: [{ message: m }], content: m.content })),
|
|
450
|
-
{ output: since(list(body.input), (x) => x.role === "user") },
|
|
451
|
-
...since(list(body.contents), personAsked).filter((c) => c.role === "model").map((c) => ({ candidates: [{ content: c }] })),
|
|
452
|
-
]);
|
|
453
|
-
// Tool use a model writes in its words instead of as a structured call. A ReAct agent (CrewAI,
|
|
454
|
-
// LangChain) writes "Action: name" then "Action Input: {...}" and is handed "Observation: ..."
|
|
455
|
-
// back in its next prompt; others write <tool_call>{...}</tool_call>, <function=name>,
|
|
456
|
-
// <invoke name="..."> or a JSON object that names the tool. A name counts only when the request
|
|
457
|
-
// declares that tool, in its tools field or in the tool list its prompt carries, so a thought, a
|
|
458
|
-
// "Final Answer" or prose that says Action is never a call.
|
|
459
|
-
const FINAL = /^final[\s_-]*answer$/i;
|
|
460
|
-
const TOOL_NAME = /^[A-Za-z_][\w.-]{0,79}$/;
|
|
461
|
-
const promptText = (body) => [
|
|
462
|
-
...list(body.messages).filter((m) => m.role !== "assistant").map((m) => textOf(m.content)),
|
|
463
|
-
textOf(body.system), textOf(body.instructions), typeof body.prompt === "string" ? body.prompt : "", typeof body.input === "string" ? body.input : "",
|
|
464
|
-
...list(body.input).filter((x) => x.role && x.role !== "assistant").map((x) => textOf(x.content)),
|
|
465
|
-
textOf(body.systemInstruction && body.systemInstruction.parts), ...list(body.contents).filter((c) => c.role !== "model").map((c) => textOf(c.parts)),
|
|
466
|
-
].join("\n");
|
|
467
|
-
const declaredIn = (body) => {
|
|
468
|
-
const out = new Set();
|
|
469
|
-
const add = (n) => { const s = typeof n === "string" ? n.trim().replace(/^["'`]+|["'`]+$/g, "") : ""; if (TOOL_NAME.test(s) && !FINAL.test(s)) out.add(s); };
|
|
470
|
-
for (const t of list(body.tools)) { add(t.name); add(t.function && t.function.name); add(t.toolSpec && t.toolSpec.name); for (const d of list(t.functionDeclarations || t.function_declarations)) add(d.name); }
|
|
471
|
-
for (const f of list(body.functions)) add(f.name);
|
|
472
|
-
for (const t of list(body.toolConfig && body.toolConfig.tools)) add(t.toolSpec && t.toolSpec.name);
|
|
473
|
-
const text = promptText(body).slice(0, 200000);
|
|
474
|
-
for (const m of text.matchAll(/^[ \t]*Tool Name:[ \t]*([^\n]+)/gim)) add(m[1]);
|
|
475
|
-
for (const m of text.matchAll(/\b(?:one of|name of|names? from)[ \t]*\[([^\]\n]{1,2000})\]/gi)) for (const x of m[1].split(",")) add(x);
|
|
476
|
-
for (const m of text.matchAll(/valid "?action"? values?:?[ \t]*([^\n]{1,2000})/gi)) for (const x of m[1].split(/,|\bor\b/)) add(x);
|
|
477
|
-
for (const m of text.matchAll(/<(tools|functions)>([\s\S]*?)<\/\1>/gi)) for (const k of m[2].matchAll(/"name"\s*:\s*"([^"]+)"/g)) add(k[1]);
|
|
478
|
-
for (const m of text.matchAll(/"name"\s*:\s*"([^"]+)"\s*,\s*"(?:description|parameters|input_schema)"/g)) add(m[1]);
|
|
479
|
-
for (const m of text.matchAll(/<(?:tool|function)\s+name\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
|
|
480
|
-
return out;
|
|
481
|
-
};
|
|
482
|
-
const REACT = /(?:^|\n)[ \t>*_#]*Action[ \t]*\d*[ \t*_]*:[ \t*_`]*([^\n`*]*?)[ \t*_`]*\n+[ \t>*_#]*Action[ \t]*\d*[ \t_]*Input[ \t*_]*:[ \t*_]*([\s\S]*?)(?=\n[ \t>*_#]*(?:Observation|Thought|Final[ \t]*Answer|Action)\b|\u0000|$)/gi;
|
|
483
|
-
const TAG_CALL = /<(tool_call|function_call|tool_use)>([\s\S]*?)<\/\1>/gi;
|
|
484
|
-
const FN_TAG = /<function=([\w.-]+)>([\s\S]*?)<\/function>/gi;
|
|
485
|
-
const INVOKE = /<invoke\s+name\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/invoke>/gi;
|
|
486
|
-
const OBSERVED = /(?:^|\n)[ \t>*_]*Observation[ \t*_]*:[ \t]*([\s\S]*?)(?=\n[ \t>*_#]*(?:Thought|Action|Final[ \t]*Answer)\b|\u0000|$)|<(tool_response|tool_result|function_results?|observation)>([\s\S]*?)<\/\2>/gi;
|
|
487
|
-
const paramsOf = (s) => { const o = {}; for (const m of s.matchAll(/<parameter(?:=|\s+name\s*=\s*["'])([\w.-]+)["']?\s*>([\s\S]*?)<\/parameter>/gi)) o[m[1]] = m[2].trim(); return o; };
|
|
488
|
-
// Each balanced JSON object in the text, outermost first; its insides are not read again.
|
|
489
|
-
const objectsIn = (text) => {
|
|
490
|
-
const out = [];
|
|
491
|
-
for (let i = text.indexOf("{"); i !== -1 && out.length < 20; i = text.indexOf("{", i + 1)) {
|
|
492
|
-
let depth = 0, str = false, esc = false, end = -1;
|
|
493
|
-
for (let j = i; j < text.length && j < i + 20000; j++) {
|
|
494
|
-
const ch = text[j];
|
|
495
|
-
if (str) { if (esc) esc = false; else if (ch === "\\") esc = true; else if (ch === '"') str = false; continue; }
|
|
496
|
-
if (ch === '"') str = true; else if (ch === "{") depth++; else if (ch === "}" && --depth === 0) { end = j; break; }
|
|
497
|
-
}
|
|
498
|
-
const v = end === -1 ? null : parse(text.slice(i, end + 1));
|
|
499
|
-
if (v && typeof v === "object") { out.push({ at: i, v }); i = end; }
|
|
500
|
-
}
|
|
501
|
-
return out;
|
|
502
|
-
};
|
|
503
|
-
// An Action Input that opens with a JSON object is that object: a model that runs on past it
|
|
504
|
-
// ("Observ: ...") does not put its own invention into the arguments.
|
|
505
|
-
const leadingJson = (s) => { const t = s.trim(); const o = t[0] === "{" ? objectsIn(t)[0] : null; return o && o.at === 0 ? o.v : t; };
|
|
506
|
-
// A tool's schema carries a description; a call does not.
|
|
507
|
-
const jsonCall = (v) => {
|
|
508
|
-
const f = v.function && typeof v.function === "object" ? v.function : v;
|
|
509
|
-
if ("description" in f) return null;
|
|
510
|
-
const name = [f.name, v.tool, v.tool_name, v.action, typeof v.function === "string" ? v.function : undefined].find((x) => typeof x === "string");
|
|
511
|
-
return name ? { name, args: [f.arguments, f.args, f.parameters, v.action_input, v.tool_input, v.input, v.args, v.arguments, v.parameters].find((x) => x !== undefined) } : null;
|
|
512
|
-
};
|
|
513
|
-
const writtenCalls = (text, declared) => {
|
|
514
|
-
const found = [];
|
|
515
|
-
if (!text || !declared.size) return found;
|
|
516
|
-
const s = text.slice(0, 50000);
|
|
517
|
-
const take = (at, name, args) => { const n = String(name || "").trim(); if (declared.has(n)) found.push({ at, name: n, args: typeof args === "string" ? args.trim() : args }); };
|
|
518
|
-
for (const m of s.matchAll(REACT)) take(m.index, m[1], leadingJson(m[2]));
|
|
519
|
-
for (const m of s.matchAll(TAG_CALL)) { const v = parse(m[2].trim()); const c = v && typeof v === "object" && jsonCall(v); if (c) take(m.index, c.name, c.args); }
|
|
520
|
-
for (const m of s.matchAll(FN_TAG)) { const v = parse(m[2].trim()); take(m.index, m[1], v && typeof v === "object" ? v : paramsOf(m[2])); }
|
|
521
|
-
for (const m of s.matchAll(INVOKE)) take(m.index, m[1], paramsOf(m[2]));
|
|
522
|
-
for (const { at, v } of objectsIn(s)) { const c = jsonCall(v); if (c) take(at, c.name, c.args); }
|
|
523
|
-
return found.sort((a, b) => a.at - b.at);
|
|
524
|
-
};
|
|
525
|
-
// What a tool answered, named by the call written just before it.
|
|
526
|
-
const observedIn = (text, declared) => {
|
|
527
|
-
const s = String(text || "").slice(0, 50000);
|
|
528
|
-
const calls = writtenCalls(s, declared);
|
|
529
|
-
const out = [];
|
|
530
|
-
if (calls.length) for (const m of s.matchAll(OBSERVED)) { const call = calls.filter((c) => c.at < m.index).pop(); if (call) out.push({ name: call.name, text: (m[1] ?? m[3]).trim() }); }
|
|
531
|
-
return out;
|
|
532
|
-
};
|
|
533
|
-
// The model's own words: the text it answered, whole or streamed, in every provider's shape.
|
|
534
|
-
const replyText = (events) => {
|
|
535
|
-
let s = "";
|
|
536
|
-
for (const e of events) {
|
|
537
|
-
if (!e || typeof e !== "object") continue;
|
|
538
|
-
for (const c of list(e.choices)) { if (c.message) s += textOf(c.message.content); if (c.delta && typeof c.delta.content === "string") s += c.delta.content; if (typeof c.text === "string") s += c.text; }
|
|
539
|
-
if (e.type === "response.output_text.delta" && typeof e.delta === "string") s += e.delta;
|
|
540
|
-
for (const it of [...list(e.output), ...list(e.response && e.response.output)]) if (it.type === "message") for (const p of list(it.content)) if (typeof p.text === "string") s += p.text;
|
|
541
|
-
if (e.type === "content_block_delta" && e.delta && typeof e.delta.text === "string") s += e.delta.text;
|
|
542
|
-
for (const b of [...list(e.content), ...list(e.message && e.message.content), ...list(e.output && e.output.message && e.output.message.content)]) if (typeof b.text === "string") s += b.text;
|
|
543
|
-
if (e.message && typeof e.message.content === "string" && !e.choices) s += e.message.content;
|
|
544
|
-
for (const c of list(e.candidates)) for (const p of list(c.content && c.content.parts)) if (typeof p.text === "string" && !p.thought) s += p.text;
|
|
545
|
-
if (typeof e.response === "string") s += e.response;
|
|
381
|
+
for (const name of ["write", "end"]) {
|
|
382
|
+
const orig = res[name];
|
|
383
|
+
res[name] = function (chunk, encoding) { try { keep(chunk, encoding); if (name === "end") tied.replied(ctx, res.statusCode, () => decoded(Buffer.concat(got), sentHeader("content-encoding"))); } catch { /* the reply goes on */ } return orig.apply(this, arguments); };
|
|
546
384
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const ours = (r) => withPerson ? r !== "system" && r !== "developer" : r === "assistant" || r === "model";
|
|
556
|
-
const ms = list(body.messages);
|
|
557
|
-
const person = (m) => personSaid(m) && !(ms[ms.indexOf(m) - 1] && ms[ms.indexOf(m) - 1].role === "assistant" && writtenCalls(textOf(ms[ms.indexOf(m) - 1].content), declared).length);
|
|
558
|
-
return [
|
|
559
|
-
...from(ms, person).filter((m) => ours(m.role)).map((m) => textOf(m.content)),
|
|
560
|
-
...from(list(body.input), (x) => x.role === "user").filter((x) => ours(x.role)).map((x) => textOf(x.content ?? x.output)),
|
|
561
|
-
...from(list(body.contents), personAsked).filter((c) => ours(c.role)).map((c) => textOf(c.parts)),
|
|
562
|
-
...(withPerson ? [typeof body.prompt === "string" ? body.prompt : "", typeof body.input === "string" ? body.input : ""] : []),
|
|
563
|
-
].join("\u0000\n");
|
|
564
|
-
};
|
|
565
|
-
|
|
566
|
-
const calledIn = (sent, events) => {
|
|
567
|
-
const body = parse(sent);
|
|
568
|
-
const out = [];
|
|
569
|
-
const declared = body && typeof body === "object" ? declaredIn(body) : new Set();
|
|
570
|
-
const written = [...writtenCalls(body && typeof body === "object" ? turnTexts(body, false, declared) : "", declared), ...writtenCalls(replyText(events), declared)].map((c) => ({ name: c.name, arguments: argsText(c.args) }));
|
|
571
|
-
for (const c of [...(body && typeof body === "object" ? calledBefore(body) : []), ...callsOf(events), ...written]) {
|
|
572
|
-
if (out.length < CALLS_MAX && !out.some((o) => o.name === c.name && o.arguments === c.arguments)) out.push(c);
|
|
573
|
-
}
|
|
574
|
-
return out.length ? out : undefined;
|
|
575
|
-
};
|
|
576
|
-
|
|
577
|
-
// The passages the prompt carried as retrieved context: a block the app itself labels as
|
|
578
|
-
// context, documents, knowledge, sources or search results, in its system prompt or from the
|
|
579
|
-
// person's latest message on, and Anthropic document and search_result blocks. The app's own
|
|
580
|
-
// instructions are not passages: only what sits under such a label is.
|
|
581
|
-
const PASSAGE_TEXT = 3000, PASSAGES_MAX = 6;
|
|
582
|
-
const MATERIAL = /\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq|relevant)\b|检索|知识|参考资料|资料|上下文|文档|背景|相关/i;
|
|
583
|
-
const HEADING = /^\s*(?:#{1,6}\s+[^\n]{1,80}|[^\n]{1,80}[::]\s*(?:\([^\n)]*\))?|\[[^\n\]]{1,80}\]|={2,}\s*[^\n]{1,80}?\s*={2,})\s*$/;
|
|
584
|
-
// An inline label is a noun phrase that ends in the material word ("context:", "background:",
|
|
585
|
-
// "retrieved passages:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
|
|
586
|
-
const INLINE = /^\s*(?:[-*]\s+)?([A-Za-z\u4e00-\u9fff][A-Za-z\u4e00-\u9fff '’_-]{0,29})[::]\s*\S/;
|
|
587
|
-
const NAMED = new RegExp(`(?:${MATERIAL.source})\\s*$`, "i");
|
|
588
|
-
const TAGGED = /<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)<\/\1>/g;
|
|
589
|
-
// A label's block runs on to the next heading of its own kind: "资料:" over retrieved chunks
|
|
590
|
-
// that open on markdown headings holds every chunk, where stopping at its first paragraph kept
|
|
591
|
-
// one chunk of five and the reader called the knowledge base's own 1% fee made up. A markdown
|
|
592
|
-
// label holds deeper headings. A closing paragraph that opens on a label of its own ("问题:...",
|
|
593
|
-
// "Question: ...") is the prompt's ask, not material. A long block is several passages, cut
|
|
594
|
-
// between its paragraphs.
|
|
595
|
-
const MARKDOWN = /^\s*(#{1,6})\s/;
|
|
596
|
-
const endsBlock = (label, line) => {
|
|
597
|
-
if (!HEADING.test(line)) return false;
|
|
598
|
-
const outer = MARKDOWN.exec(label), inner = MARKDOWN.exec(line);
|
|
599
|
-
return !inner || Boolean(outer && inner[1].length <= outer[1].length);
|
|
600
|
-
};
|
|
601
|
-
const asks = (para) => { const m = INLINE.exec(para.split("\n")[0]); return Boolean(m && !NAMED.test(m[1])); };
|
|
602
|
-
// A heading inside a fenced block is the material's own text: a README chunk's "## Features".
|
|
603
|
-
const fences = (para) => (para.match(/^\s*(?:```|~~~)/gm) || []).length;
|
|
604
|
-
const addBlock = (add, name, paras) => {
|
|
605
|
-
let text = "";
|
|
606
|
-
for (const p of paras) {
|
|
607
|
-
if (text && text.length + p.length + 2 > PASSAGE_TEXT) { add(name, text); text = ""; }
|
|
608
|
-
text += (text ? "\n\n" : "") + p;
|
|
609
|
-
}
|
|
610
|
-
add(name, text);
|
|
611
|
-
};
|
|
612
|
-
const labelled = (text, add) => {
|
|
613
|
-
const rest = String(text || "").replace(TAGGED, (all, tag, inner) => { if (MATERIAL.test(tag.replace(/_/g, " "))) { add(tag, inner); return ""; } return all; });
|
|
614
|
-
const paras = rest.split(/\n\s*\n/);
|
|
615
|
-
for (let i = 0; i < paras.length; i++) {
|
|
616
|
-
const lines = paras[i].split("\n");
|
|
617
|
-
const head = lines[0];
|
|
618
|
-
if (HEADING.test(head) && MATERIAL.test(head)) {
|
|
619
|
-
const block = [lines.slice(1).join("\n")];
|
|
620
|
-
let fenced = fences(block[0]) % 2 === 1;
|
|
621
|
-
while (i + 1 < paras.length && (fenced || (!endsBlock(head, paras[i + 1].split("\n")[0]) && !(i + 2 === paras.length && asks(paras[i + 1]))))) {
|
|
622
|
-
block.push(paras[++i]);
|
|
623
|
-
if (fences(paras[i]) % 2 === 1) fenced = !fenced;
|
|
624
|
-
}
|
|
625
|
-
addBlock(add, head.replace(/[#::[\]=]/g, "").trim(), block);
|
|
626
|
-
continue;
|
|
627
|
-
}
|
|
628
|
-
const inline = lines.map((l) => INLINE.exec(l)).find((m) => m && NAMED.test(m[1]));
|
|
629
|
-
if (inline) add(inline[1].trim(), paras[i]);
|
|
630
|
-
}
|
|
631
|
-
};
|
|
632
|
-
const passagesIn = (sent) => {
|
|
633
|
-
const body = parse(sent);
|
|
634
|
-
if (!body || typeof body !== "object") return undefined;
|
|
635
|
-
const out = [];
|
|
636
|
-
const add = (name, text) => { const t = String(text || "").trim().slice(0, PASSAGE_TEXT); if (t.length >= 20 && out.length < PASSAGES_MAX && !out.some((o) => o.text === t)) out.push({ name: String(name || "").slice(0, 80), text: t }); };
|
|
637
|
-
const scan = (content) => {
|
|
638
|
-
if (typeof content === "string") return labelled(content, add);
|
|
639
|
-
for (const b of list(content)) {
|
|
640
|
-
if (b.type === "document") add(b.title || "document", b.source ? textOf(b.source.data ?? b.source.content) : textOf(b.content));
|
|
641
|
-
else if (b.type === "search_result") add(b.title || b.source || "search result", textOf(b.content));
|
|
642
|
-
else if (typeof b.text === "string") labelled(b.text, add);
|
|
643
|
-
}
|
|
385
|
+
// writeHead(status, headers) sends its headers without keeping them where getHeader looks: the
|
|
386
|
+
// header block as sent says them either way.
|
|
387
|
+
const sentHeader = (name) => {
|
|
388
|
+
const kept = res.getHeader(name);
|
|
389
|
+
if (kept !== undefined) return kept;
|
|
390
|
+
const block = typeof res._header === "string" ? res._header : "";
|
|
391
|
+
const found = block.split("\r\n").filter((l) => l.toLowerCase().startsWith(`${name}:`)).map((l) => l.slice(name.length + 1).trim());
|
|
392
|
+
return name === "set-cookie" ? found : found[0];
|
|
644
393
|
};
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
scan(body.systemInstruction && body.systemInstruction.parts);
|
|
651
|
-
const input = typeof body.input === "string" ? [{ role: "user", content: body.input }] : list(body.input);
|
|
652
|
-
for (const it of input.slice(Math.max(0, input.findLastIndex((x) => x.role === "user")))) if (it.role === "user" || it.role === "system" || it.role === "developer") scan(it.content);
|
|
653
|
-
return out.length ? out : undefined;
|
|
654
|
-
};
|
|
655
|
-
// The passages a retrieval call answered with: a vector store, a search index or a web search,
|
|
656
|
-
// read off its reply when it is JSON. Text fields only, bounded like the rest.
|
|
657
|
-
const RETRIEVAL_HOST = /pinecone\.io|qdrant|weaviate|chroma|zilliz|milvus|turbopuffer|upstash\.io|algolia|typesense|meilisearch|elastic|opensearch|vespa|tavily\.com|exa\.ai|serper\.dev|serpapi\.com|search\.brave\.com|bing\.microsoft\.com|jina\.ai/i;
|
|
658
|
-
const RETRIEVAL_PATH = /\/(?:query|search|_search|retrieve|similarity_search|rerank|hybrid)(?:\/|$)|\/points\/(?:search|query)|\/rpc\/match\w*/i;
|
|
659
|
-
const isRetrieval = (u) => RETRIEVAL_HOST.test(u.hostname) || RETRIEVAL_PATH.test(u.pathname);
|
|
660
|
-
const PASSAGE_KEY = /^(?:text|content|page_?content|pageContent|chunk|snippet|passage|body|document|documents|description|answer|raw_content|highlights?|excerpt)$/i;
|
|
661
|
-
const passagesFrom = (raw) => {
|
|
662
|
-
const out = [];
|
|
663
|
-
let nodes = 0;
|
|
664
|
-
const walk = (v, key, depth) => {
|
|
665
|
-
if (out.length >= PASSAGES_MAX || depth > 7 || ++nodes > 4000) return;
|
|
666
|
-
const t = typeof v === "string" ? v.trim().slice(0, PASSAGE_TEXT) : "";
|
|
667
|
-
if (t) { if (PASSAGE_KEY.test(key) && t.length >= 20 && !out.some((o) => o.text === t)) out.push({ name: key, text: t }); return; }
|
|
668
|
-
if (Array.isArray(v)) { for (const x of v) walk(x, key, depth + 1); return; }
|
|
669
|
-
if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) walk(x, k, depth + 1);
|
|
394
|
+
// `cut`: the app began its reply and the connection closed before it finished, so the row holds
|
|
395
|
+
// part of it. Either side may have closed it (a client that stopped reading, an app that broke
|
|
396
|
+
// off mid-stream), so nothing downstream says which.
|
|
397
|
+
const done = () => {
|
|
398
|
+
try { ended(ctx, { status: res.statusCode, type: sentHeader("content-type"), writes, text: decoded(Buffer.concat(got), sentHeader("content-encoding")), cookies: cookieNames(sentHeader("set-cookie")), cut: res.headersSent && !res.writableFinished }); } catch { /* the command is gone */ }
|
|
670
399
|
};
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
};
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const h = String(host || "").replace(/:\d+$/, "");
|
|
687
|
-
const p = String(path || "").split("?")[0];
|
|
688
|
-
if (/googleapis\.com$/i.test(h)) return /generativelanguage|aiplatform/i.test(h) && MODEL_PATH.test(p);
|
|
689
|
-
if (/amazonaws\.com$/i.test(h)) return /^bedrock/i.test(h);
|
|
690
|
-
return MODEL_HOST.test(h) ? true : MODEL_PATH.test(p) && /\/v\d|\/api\//.test(p);
|
|
691
|
-
};
|
|
692
|
-
const said = new Set();
|
|
693
|
-
const note = (ctx, sent) => {
|
|
694
|
-
if (!ctx || ctx.noted) return;
|
|
695
|
-
ctx.noted = true;
|
|
696
|
-
const key = `${ctx.method} ${ctx.path.split("?")[0]}`;
|
|
697
|
-
if (said.has(key)) return;
|
|
698
|
-
said.add(key);
|
|
699
|
-
const write = (chunks) => row({ at: Date.now(), method: ctx.method, path: ctx.path, headers: ctx.headers, body: Buffer.concat(chunks).toString("utf8").slice(0, MAX), sent: String(sent || "").slice(0, MAX) });
|
|
700
|
-
if (ctx.body) ctx.body.then((b) => write([b]));
|
|
701
|
-
else write(ctx.chunks);
|
|
400
|
+
res.once("finish", done);
|
|
401
|
+
res.once("close", done);
|
|
402
|
+
};
|
|
403
|
+
// Bun hands back a Response; its body is read from a clone, so the client's copy is untouched.
|
|
404
|
+
const watchResponse = (ctx, res) => {
|
|
405
|
+
// Nothing to read: the request is closed and never written.
|
|
406
|
+
const gone = () => { ctx.ended = Date.now(); ctx.reply = { status: 0, type: "", text: "", cookies: [] }; tied.closed(ctx); };
|
|
407
|
+
if (!(res instanceof Response) || Number(res.headers.get("content-length") || 0) > REPLY_MAX) return gone();
|
|
408
|
+
let copy;
|
|
409
|
+
try { copy = res.clone(); } catch { return gone(); }
|
|
410
|
+
copy.arrayBuffer().then((b) => Buffer.from(b), () => Buffer.alloc(0)).then((buf) => {
|
|
411
|
+
const set = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : res.headers.get("set-cookie");
|
|
412
|
+
tied.replied(ctx, res.status, () => decoded(buf.subarray(0, MAX), res.headers.get("content-encoding")));
|
|
413
|
+
ended(ctx, { status: res.status, type: res.headers.get("content-type"), text: decoded(buf.subarray(0, MAX), res.headers.get("content-encoding")), cookies: cookieNames(set) });
|
|
414
|
+
}).catch(() => { /* the command is gone */ });
|
|
702
415
|
};
|
|
703
416
|
|
|
704
417
|
// Inbound: each request is handled inside its own context, and its body is seen as it arrives
|
|
705
|
-
// without reading it, so your own body parser is untouched.
|
|
418
|
+
// without reading it, so your own body parser is untouched. A GET is watched but never made the
|
|
419
|
+
// context a model call is pinned by: it can only be the step an answer comes on.
|
|
706
420
|
const emit = http.Server.prototype.emit;
|
|
707
421
|
http.Server.prototype.emit = function (type, req, ...rest) {
|
|
422
|
+
if (this[HELD]) return emit.call(this, type, req, ...rest);
|
|
708
423
|
if (type === "listening") { try { listening(this.address()?.port); } catch { /* not a TCP server */ } }
|
|
709
424
|
if (type === "request") { recheckRoutes(); sweepTools(); }
|
|
710
|
-
if (type !== "request" || !req || !req.method || /^(?:
|
|
425
|
+
if (type !== "request" || !req || !req.method || /^(?:HEAD|OPTIONS)$/.test(req.method)) return emit.call(this, type, req, ...rest);
|
|
711
426
|
const { "x-cortad-turn": _turn, ...headers } = req.headers;
|
|
712
|
-
const ctx =
|
|
427
|
+
const ctx = requestCtx(req.method, req.url || "/", headers, turnOf(req.headers));
|
|
428
|
+
try { watchReply(ctx, rest[0]); } catch { /* the reply goes on unwatched */ }
|
|
429
|
+
if (req.method === "GET") return emit.call(this, type, req, ...rest);
|
|
713
430
|
const push = req.push;
|
|
714
431
|
req.push = function (chunk, encoding) {
|
|
715
432
|
if (chunk && ctx.size < MAX) { const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding); ctx.chunks.push(b); ctx.size += b.length; }
|
|
@@ -743,16 +460,26 @@ if (FILE) {
|
|
|
743
460
|
const inside = (handler, self) => function (req, server) {
|
|
744
461
|
recheckRoutes();
|
|
745
462
|
sweepTools();
|
|
746
|
-
if (!req || /^(?:
|
|
463
|
+
if (!req || /^(?:HEAD|OPTIONS)$/.test(req.method)) return handler.call(self, req, server);
|
|
747
464
|
let path = "/";
|
|
748
465
|
try { const u = new URL(req.url); path = u.pathname + u.search; } catch { /* keep "/" */ }
|
|
749
|
-
const ctx =
|
|
466
|
+
const ctx = requestCtx(req.method, path, Object.fromEntries([...req.headers].filter(([k]) => k !== "x-cortad-turn")), turnOf(req.headers));
|
|
467
|
+
if (req.method === "GET") {
|
|
468
|
+
const out = handler.call(self, req, server);
|
|
469
|
+
Promise.resolve(out).then((res) => watchResponse(ctx, res), () => watchResponse(ctx, null));
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
750
472
|
// ponytail: a body is copied only when it is small or says it is text; an upload is left alone.
|
|
751
473
|
const size = Number(req.headers.get("content-length") || 0);
|
|
752
474
|
if (size <= MAX || /json|text|form/i.test(req.headers.get("content-type") || "")) {
|
|
753
|
-
|
|
475
|
+
// Read as it arrives, so a model call made while the request is open knows the person's words.
|
|
476
|
+
try { ctx.body = req.clone().arrayBuffer().then((b) => { ctx.bodyText = Buffer.from(b).subarray(0, MAX).toString("utf8"); }, () => { ctx.bodyText = ""; }); } catch { /* unread */ }
|
|
754
477
|
}
|
|
755
|
-
return als.run(ctx, () =>
|
|
478
|
+
return als.run(ctx, () => {
|
|
479
|
+
const out = handler.call(self, req, server);
|
|
480
|
+
Promise.resolve(out).then((res) => watchResponse(ctx, res), () => {});
|
|
481
|
+
return out;
|
|
482
|
+
});
|
|
756
483
|
};
|
|
757
484
|
const routes = (table) => {
|
|
758
485
|
if (!table || typeof table !== "object") return table;
|
|
@@ -791,61 +518,6 @@ if (FILE) {
|
|
|
791
518
|
};
|
|
792
519
|
}
|
|
793
520
|
|
|
794
|
-
// The meter. One row per model call: host, model id, status, and the provider's own token counts.
|
|
795
|
-
const zlib = require("node:zlib");
|
|
796
|
-
const REPLY_MAX = 8 * 1024 * 1024;
|
|
797
|
-
const decoded = (buf, encoding) => {
|
|
798
|
-
try {
|
|
799
|
-
const e = String(encoding || "").toLowerCase();
|
|
800
|
-
return (e === "gzip" ? zlib.gunzipSync(buf) : e === "br" ? zlib.brotliDecompressSync(buf) : e === "deflate" ? zlib.inflateSync(buf) : buf).toString("utf8");
|
|
801
|
-
} catch { return ""; }
|
|
802
|
-
};
|
|
803
|
-
const parse = (text) => { try { return JSON.parse(text); } catch { return null; } };
|
|
804
|
-
const n = (v) => (Number.isInteger(v) && v > 0 ? v : 0);
|
|
805
|
-
// The shapes the providers answer in. promptTokens is the whole input, cache included, so totals add up.
|
|
806
|
-
const tokensOf = (u) => {
|
|
807
|
-
if (!u || typeof u !== "object") return null;
|
|
808
|
-
if (u.tokens && typeof u.tokens === "object") return tokensOf(u.tokens);
|
|
809
|
-
if ("prompt_tokens" in u) return { promptTokens: n(u.prompt_tokens), cachedTokens: n(u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens), completionTokens: n(u.completion_tokens) };
|
|
810
|
-
if ("input_tokens" in u && u.input_tokens_details) return { promptTokens: n(u.input_tokens), cachedTokens: n(u.input_tokens_details.cached_tokens), completionTokens: n(u.output_tokens) };
|
|
811
|
-
if ("input_tokens" in u) { const read = n(u.cache_read_input_tokens); return { promptTokens: n(u.input_tokens) + read + n(u.cache_creation_input_tokens), cachedTokens: read, completionTokens: n(u.output_tokens) }; }
|
|
812
|
-
if ("inputTokens" in u) { const read = n(u.cacheReadInputTokens); return { promptTokens: n(u.inputTokens) + read + n(u.cacheWriteInputTokens), cachedTokens: read, completionTokens: n(u.outputTokens) }; }
|
|
813
|
-
if ("promptTokenCount" in u) return { promptTokens: n(u.promptTokenCount), cachedTokens: n(u.cachedContentTokenCount), completionTokens: n(u.candidatesTokenCount) };
|
|
814
|
-
return null;
|
|
815
|
-
};
|
|
816
|
-
// A stream spreads its counts over events (Anthropic's input in the first, output in the last);
|
|
817
|
-
// later values win, so the fold ends on the final figures.
|
|
818
|
-
const readReply = (type, text) => {
|
|
819
|
-
const events = /event-stream/i.test(type || "")
|
|
820
|
-
? text.split("\n").filter((l) => l.startsWith("data:")).map((l) => parse(l.slice(5).trim()))
|
|
821
|
-
: [].concat(parse(text));
|
|
822
|
-
const usage = {};
|
|
823
|
-
let model = null;
|
|
824
|
-
for (const e of events) {
|
|
825
|
-
if (!e || typeof e !== "object") continue;
|
|
826
|
-
const part = e.usage || (e.message && e.message.usage) || (e.response && e.response.usage) || e.usageMetadata;
|
|
827
|
-
if (part && typeof part === "object") Object.assign(usage, part);
|
|
828
|
-
model = e.model || (e.message && e.message.model) || (e.response && e.response.model) || e.modelVersion || model;
|
|
829
|
-
}
|
|
830
|
-
return { tokens: tokensOf(Object.keys(usage).length ? usage : null), model, events };
|
|
831
|
-
};
|
|
832
|
-
// The model asked for, from what was sent; Gemini and Bedrock put it in the path instead.
|
|
833
|
-
const askedFor = (path, sent) => {
|
|
834
|
-
const body = parse(sent);
|
|
835
|
-
if (body && typeof body.model === "string") return body.model;
|
|
836
|
-
const m = /\/models\/([^/:]+):|\/model\/([^/]+)\/(?:invoke|converse)/.exec(path || "");
|
|
837
|
-
return m ? decodeURIComponent(m[1] || m[2]) : "";
|
|
838
|
-
};
|
|
839
|
-
const meter = ({ host, path, sent, status, type, body, turn, caller }) => {
|
|
840
|
-
try {
|
|
841
|
-
const reply = readReply(type, String(body || "").slice(0, REPLY_MAX));
|
|
842
|
-
const rules = rulesIn(sent);
|
|
843
|
-
const tools = toolsIn(sent);
|
|
844
|
-
const called = calledIn(sent, reply.events);
|
|
845
|
-
const passages = passagesIn(sent);
|
|
846
|
-
row({ call: { at: Date.now(), host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}) } });
|
|
847
|
-
} catch { /* the command is gone */ }
|
|
848
|
-
};
|
|
849
521
|
|
|
850
522
|
// Outbound to a service their own settings name (a vector store, a database API, a search
|
|
851
523
|
// host): which setting, which host and whether it answered. No body, no headers, no query. A
|
|
@@ -868,8 +540,10 @@ if (FILE) {
|
|
|
868
540
|
return name ? { env: name, host: u.hostname } : null;
|
|
869
541
|
} catch { return null; }
|
|
870
542
|
};
|
|
871
|
-
|
|
872
|
-
|
|
543
|
+
// A retrieval says so, with the app's line that asked, so one that came back empty has an address.
|
|
544
|
+
const depRow = (dep, status, { code, passages, ctx, retrieval, caller } = {}) => {
|
|
545
|
+
const turn = ctx && ctx.turn;
|
|
546
|
+
row({ dep: { at: Date.now(), ...(dep.env ? { env: dep.env } : {}), host: dep.host, status, code: code ? String(code).slice(0, 40) : undefined, ...(ctx ? { ex: ctx.id } : {}), ...(turn ? { turn } : {}), ...(retrieval ? { retrieval: true } : {}), ...(caller ? { caller } : {}), ...(passages ? { passages } : {}) } });
|
|
873
547
|
};
|
|
874
548
|
|
|
875
549
|
// The app's own functions the read names as its tools. An app whose code picks the tool itself
|
|
@@ -931,12 +605,13 @@ if (FILE) {
|
|
|
931
605
|
if (!m || /[{[(]/.test(m[1])) return [];
|
|
932
606
|
return m[1].split(",").map((p) => p.replace(/=[\s\S]*$/, "").replace(/^\s*\.\.\./, "").replace(/:[\s\S]*$/, "").trim());
|
|
933
607
|
};
|
|
934
|
-
const toolRow = (name, names, args, value, error,
|
|
608
|
+
const toolRow = (name, names, args, value, error, ctx) => {
|
|
609
|
+
const turn = ctx && ctx.turn;
|
|
935
610
|
const named = {};
|
|
936
611
|
args.forEach((a, i) => { named[IDENT.test(names[i] || "") ? names[i] : String(i)] = plainOf(a); });
|
|
937
612
|
let text;
|
|
938
613
|
try { text = error ? `raised ${(error && error.name) || "an error"}: ${(error && error.message) || String(error)}` : returned(value); } catch { text = "returned a value this hook cannot read"; }
|
|
939
|
-
row({ dep: { at: Date.now(), host: "in-app", status: 200, ...(turn ? { turn } : {}), called: [{ name, arguments: argsText(named) }], tools: [
|
|
614
|
+
row({ dep: { at: Date.now(), host: "in-app", status: 200, ...(ctx ? { ex: ctx.id } : {}), ...(turn ? { turn } : {}), called: [{ name, arguments: argsText(named) }], tools: [toolText(name, String(text))] } });
|
|
940
615
|
};
|
|
941
616
|
const TOOL = Symbol.for("cortad.wrapped");
|
|
942
617
|
const wrappers = new WeakMap();
|
|
@@ -946,14 +621,14 @@ if (FILE) {
|
|
|
946
621
|
try { if (/^class\b/.test(Function.prototype.toString.call(fn))) return fn; } catch { return fn; }
|
|
947
622
|
const names = paramNames(fn);
|
|
948
623
|
const w = function (...args) {
|
|
949
|
-
const
|
|
624
|
+
const ctx = als.getStore();
|
|
950
625
|
let out;
|
|
951
626
|
try { out = new.target ? Reflect.construct(fn, args, new.target) : fn.apply(this, args); }
|
|
952
|
-
catch (e) { toolRow(name, names, args, undefined, e,
|
|
627
|
+
catch (e) { toolRow(name, names, args, undefined, e, ctx); throw e; }
|
|
953
628
|
// A promise is followed, and the one handed back is a new one, so a rejection nobody
|
|
954
629
|
// handles still reaches the app as unhandled.
|
|
955
|
-
if (out instanceof Promise) return out.then((v) => { toolRow(name, names, args, v, undefined,
|
|
956
|
-
toolRow(name, names, args, out, undefined,
|
|
630
|
+
if (out instanceof Promise) return out.then((v) => { toolRow(name, names, args, v, undefined, ctx); return v; }, (e) => { toolRow(name, names, args, undefined, e, ctx); throw e; });
|
|
631
|
+
toolRow(name, names, args, out, undefined, ctx);
|
|
957
632
|
return out;
|
|
958
633
|
};
|
|
959
634
|
try {
|
|
@@ -1026,16 +701,208 @@ if (FILE) {
|
|
|
1026
701
|
}
|
|
1027
702
|
};
|
|
1028
703
|
|
|
704
|
+
// A fetched body read to its end, or to where it broke off. Aborting the call (the client that
|
|
705
|
+
// asked went away, or the framework ended the request once its own reply was sent) errors this
|
|
706
|
+
// copy too, and what the model had said by then is still what it said.
|
|
707
|
+
const readAll = async (res) => {
|
|
708
|
+
const parts = [];
|
|
709
|
+
let size = 0;
|
|
710
|
+
try {
|
|
711
|
+
const reader = res.body && res.body.getReader();
|
|
712
|
+
for (let r = reader && await reader.read(); r && !r.done; r = await reader.read()) {
|
|
713
|
+
if (size < REPLY_MAX) { parts.push(Buffer.from(r.value)); size += r.value.length; }
|
|
714
|
+
}
|
|
715
|
+
} catch { /* broken off: what arrived stands */ }
|
|
716
|
+
return Buffer.concat(parts).toString("utf8");
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// Every server the app opens a connection to, once each, by host and port only: a database named
|
|
720
|
+
// in a config file rather than in the environment is seen the first time the app reaches it.
|
|
721
|
+
// HTTP clients connect through here too; the command names only the ports stores listen on.
|
|
722
|
+
const net = require("node:net");
|
|
723
|
+
const connected = new Set();
|
|
724
|
+
const socketConnect = net.Socket.prototype.connect;
|
|
725
|
+
net.Socket.prototype.connect = function (...args) {
|
|
726
|
+
try {
|
|
727
|
+
const first = Array.isArray(args[0]) ? args[0][0] : args[0];
|
|
728
|
+
const o = first && typeof first === "object" ? first : { port: first, host: typeof args[1] === "string" ? args[1] : undefined };
|
|
729
|
+
const port = Number(o.port);
|
|
730
|
+
if (!o.path && Number.isInteger(port) && port > 0 && port !== heldPort && connected.size < 64) {
|
|
731
|
+
const host = String(o.host || "localhost").toLowerCase().slice(0, 253);
|
|
732
|
+
if (!connected.has(`${host}:${port}`)) { connected.add(`${host}:${port}`); row({ conn: { host, port } }); }
|
|
733
|
+
}
|
|
734
|
+
} catch { /* the connection goes on unrecorded */ }
|
|
735
|
+
return socketConnect.apply(this, args);
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
// Outbound writes a trial makes are held in the app, never sent: a trial that books, charges,
|
|
739
|
+
// emails or deletes would otherwise do it for real. Held: a call made inside a request the run
|
|
740
|
+
// tagged, with a writing method, to a host that is not a model, a retrieval, this machine, a copy
|
|
741
|
+
// of ours or a host the person said yes to. A call carrying a provider's test key goes to that
|
|
742
|
+
// provider's own sandbox as sent. The app is answered with a success shaped like what it sent,
|
|
743
|
+
// and the run is told by a dep row with `held` and the clipped call.
|
|
744
|
+
const OUTBOUND = process.env.CORTAD_OUTBOUND_FILE;
|
|
745
|
+
let passed = [], passedAt = -1;
|
|
746
|
+
const passNow = () => {
|
|
747
|
+
try {
|
|
748
|
+
const at = fs.statSync(OUTBOUND).mtimeMs;
|
|
749
|
+
if (at !== passedAt) { passedAt = at; const got = JSON.parse(fs.readFileSync(OUTBOUND, "utf8")); passed = Array.isArray(got.pass) ? got.pass.filter((h) => typeof h === "string" && h) : []; }
|
|
750
|
+
} catch { /* not written yet: nothing passes */ }
|
|
751
|
+
return passed;
|
|
752
|
+
};
|
|
753
|
+
const WRITES = /^(?:POST|PUT|PATCH|DELETE)$/;
|
|
754
|
+
const THIS_MACHINE = /^(?:localhost|127(?:\.\d+){3}|\[?::1\]?|0\.0\.0\.0)$/i;
|
|
755
|
+
// A provider's own sandbox, named so in the host: api.sandbox.paypal.com, sandbox-api.polar.sh.
|
|
756
|
+
const SANDBOX_HOST = /(?:^|[.-])(?:sandbox|sbox)[.-]/i;
|
|
757
|
+
// ponytail: a sign-in's token refresh is a POST that changes nothing, told apart by its path only.
|
|
758
|
+
const TOKEN_PATH = /\/(?:oauth2?\/)?token(?:[/?]|$)/i;
|
|
759
|
+
// An API where every call is a POST names what a call does in its last segment, as
|
|
760
|
+
// resource.verb_words (customers.get, billing.preview_attach) or as a gRPC or Connect method
|
|
761
|
+
// (acme.v1.UserService/GetUser). A POST so named that starts with a reading word, and holds no word
|
|
762
|
+
// that makes the read a write (get_or_create, check_in, GetLease), goes out.
|
|
763
|
+
// A preview commits nothing whatever it previews; any other reading word reads only while no word
|
|
764
|
+
// after it writes: credits.check_deduct and usage.count_increment went out as reads.
|
|
765
|
+
// ponytail: decided on the name alone, so a POST named as a plain read whose body writes goes out.
|
|
766
|
+
// A rule on the body is the upgrade if one is ever seen.
|
|
767
|
+
const WRITE_WORD = "create|update|delete|remove|set|add|put|insert|upsert|write|save|store|record|track|log|send|deduct|increment|decrement|consume|use|spend|charge|pay|refund|cancel|reserve|lock|unlock|commit|apply|attach|detach|start|stop|run|trigger|reset|rotate|revoke|grant|issue|mint|transfer|move|merge|sync|import|export|upload|publish|enqueue|push|pop|ack|touch|mark|close|approve|reject|submit|confirm|redeem|claim";
|
|
768
|
+
const DOT_CALL = /^[A-Za-z][\w-]*\.[a-z][a-z_]*$/, DOT_READ = /^(?:get|list|check|search|query|find|lookup|retrieve|describe|count|preview)$/, DOT_NOT = new RegExp(`^(?:or|and|in|out|next|${WRITE_WORD})$`);
|
|
769
|
+
const RPC_CALL = /^(?:[A-Z][a-z0-9]*)+$/, RPC_READ = /^(?:Get|List|Search|Query|Find|Lookup|Describe)$/, RPC_NOT = new RegExp(`^(?:or|and|next|lease|${WRITE_WORD})$`, "i");
|
|
770
|
+
const readWords = (words, read, not) => read.test(words[0]) && (/^preview$/i.test(words[0]) || !words.slice(1).some((w) => not.test(w)));
|
|
771
|
+
const readsByName = (method, pathname) => {
|
|
772
|
+
if (method !== "POST") return false;
|
|
773
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
774
|
+
const last = parts[parts.length - 1] || "", before = parts[parts.length - 2] || "";
|
|
775
|
+
if (DOT_CALL.test(last)) return readWords(last.split(".")[1].split("_"), DOT_READ, DOT_NOT);
|
|
776
|
+
return /\.[A-Z]\w*$/.test(before) && RPC_CALL.test(last) && readWords(last.split(/(?=[A-Z])/), RPC_READ, RPC_NOT);
|
|
777
|
+
};
|
|
778
|
+
// A provider's test-mode key says so in the prefix of the key itself, set off by underscores
|
|
779
|
+
// (Stripe's sk_test_, am_sk_test_, pdl_sdbx_), or it is a value of a setting the app keeps as a
|
|
780
|
+
// test one. The word elsewhere is not it: a Basic user named test-admin and a live key holding
|
|
781
|
+
// "-Test-" both went out as test keys.
|
|
782
|
+
const TEST_KEY = /^(?:[a-z0-9]{2,8}_){0,3}(?:test|sandbox|sbox|sdbx)_[\w-]{4,}$/i;
|
|
783
|
+
const testKey = (auth) => Boolean(auth) && (auth.split(/[\s:]+/).some((piece) => TEST_KEY.test(piece))
|
|
784
|
+
|| Object.entries(process.env).some(([k, v]) => /(?:^|_)TEST(?:_|$)/i.test(k) && v && v.length >= 8 && auth.includes(v)));
|
|
785
|
+
const authText = (value) => { const a = String(value || ""); const m = /^Basic\s+(\S+)$/i.exec(a); return m ? Buffer.from(m[1], "base64").toString("utf8") : a; };
|
|
786
|
+
// The headers a key travels in.
|
|
787
|
+
const KEY_HEADERS = ["authorization", "x-api-key", "api-key", "apikey", "x-auth-token"];
|
|
788
|
+
// Neon's HTTP driver sends every query, reads too, to its region's API host and names the
|
|
789
|
+
// database in a header: the call is decided on that database's host. No header: the API host.
|
|
790
|
+
const NEON_SQL = /^api(?:auth)?\.[a-z0-9.-]+\.neon\.tech$/;
|
|
791
|
+
const targetOf = (u, headerOf) => {
|
|
792
|
+
const host = u.hostname.toLowerCase();
|
|
793
|
+
if (!NEON_SQL.test(host) || u.pathname !== "/sql") return host;
|
|
794
|
+
try { return new URL(headerOf("neon-connection-string")).hostname.toLowerCase() || host; } catch { return host; }
|
|
795
|
+
};
|
|
796
|
+
// `headerOf(name)`: one of the call's headers, read only for a call that is otherwise held. Once a
|
|
797
|
+
// tagged write is being decided, a failure to decide holds it.
|
|
798
|
+
// ponytail: while a run is live, a person's own queued write is held too; per-ask pinning of
|
|
799
|
+
// background writes is the upgrade when that matters.
|
|
800
|
+
const RUN_LIVE_MS = 10 * 60_000;
|
|
801
|
+
const heldHere = (u, method, headerOf) => {
|
|
802
|
+
const ctx = als.getStore();
|
|
803
|
+
if (!WRITES.test(method) || (ctx ? !ctx.turn : Date.now() - lastTurnAt > RUN_LIVE_MS)) return false;
|
|
804
|
+
try {
|
|
805
|
+
const host = targetOf(u, headerOf);
|
|
806
|
+
if (THIS_MACHINE.test(host) || SANDBOX_HOST.test(host) || isModelCall(u.host, u.pathname) || isRetrieval(u) || TOKEN_PATH.test(u.pathname) || readsByName(method, u.pathname) || KEY_HEADERS.some((name) => testKey(authText(headerOf(name))))) return false;
|
|
807
|
+
return !passNow().some((p) => host === p || host.endsWith("." + p));
|
|
808
|
+
} catch { return true; }
|
|
809
|
+
};
|
|
810
|
+
// A GraphQL query reads; a mutation writes. A `query` that is not GraphQL (Neon's SQL) is held.
|
|
811
|
+
const readsOnly = (text) => { try { const q = JSON.parse(text)?.query; return typeof q === "string" && /^\s*(?:\{|query\b|fragment\b)/.test(q) && !/\bmutation\b/.test(q); } catch { return false; } };
|
|
812
|
+
let heldCount = 0;
|
|
813
|
+
const fieldsOf = (text, type) => {
|
|
814
|
+
try { const b = /form/i.test(type || "") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text); return b && typeof b === "object" && !Array.isArray(b) ? b : {}; } catch { return {}; }
|
|
815
|
+
};
|
|
816
|
+
const heldReply = (text, type) => JSON.stringify({ ...fieldsOf(text, type), id: `cortad-held-${++heldCount}`, status: "ok" });
|
|
817
|
+
// Said once per method and host in the app's own output, where the error that follows is read:
|
|
818
|
+
// a stand-in an SDK cannot parse otherwise reads as a bug in the app. It holds no word that marks
|
|
819
|
+
// an error, so it is never quoted back as the app's own.
|
|
820
|
+
const heldSaid = new Set();
|
|
821
|
+
const heldSay = (method, host) => {
|
|
822
|
+
if (heldSaid.has(`${method} ${host}`)) return;
|
|
823
|
+
heldSaid.add(`${method} ${host}`);
|
|
824
|
+
try { process.stderr.write(`cortad: a request Cortad sent made this app send ${method} to ${host}. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line comes from that stand-in, not from your code. With that service's test key the call goes out as sent.\n`); } catch { /* no stderr */ }
|
|
825
|
+
};
|
|
826
|
+
// Named by method and host only: a webhook's path is its credential.
|
|
827
|
+
const heldRow = (u, method, text, type, ctx) => { heldSay(method, u.hostname); row({ dep: { at: Date.now(), host: u.hostname, status: 200, held: true, ...(ctx ? { ex: ctx.id, turn: ctx.turn } : {}), called: [{ name: `${method} ${u.hostname}`.slice(0, 80), arguments: argsText(fieldsOf(text, type)) }] } }); };
|
|
828
|
+
// http.request hands back a request before its body is written, so a held one is sent to a server
|
|
829
|
+
// of this hook's own on this machine, which answers it. Started at the first tagged request.
|
|
830
|
+
const HELD = Symbol("cortad.held");
|
|
831
|
+
const heldWaiting = new Map();
|
|
832
|
+
let heldPort = null, heldStarted = false, heldSent = 0;
|
|
833
|
+
function heldWarm() {
|
|
834
|
+
if (heldStarted) return;
|
|
835
|
+
heldStarted = true;
|
|
836
|
+
try {
|
|
837
|
+
const server = http.createServer((req, res) => {
|
|
838
|
+
const parts = [];
|
|
839
|
+
req.on("data", (c) => { if (parts.length < 256) parts.push(c); });
|
|
840
|
+
req.on("end", () => {
|
|
841
|
+
const id = String(req.headers["x-cortad-held"] || "");
|
|
842
|
+
const w = heldWaiting.get(id);
|
|
843
|
+
heldWaiting.delete(id);
|
|
844
|
+
const text = Buffer.concat(parts).toString("utf8").slice(0, MAX);
|
|
845
|
+
if (w) heldRow(w.u, w.method, text, req.headers["content-type"], w.ctx);
|
|
846
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
847
|
+
res.end(heldReply(text, req.headers["content-type"]));
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
server[HELD] = true;
|
|
851
|
+
server.listen(0, "127.0.0.1", () => { heldPort = server.address().port; });
|
|
852
|
+
server.unref();
|
|
853
|
+
} catch { /* no server: a held write fails to connect instead of leaving */ }
|
|
854
|
+
}
|
|
855
|
+
const headerIn = (headers, name) => {
|
|
856
|
+
if (!headers || typeof headers !== "object" || Array.isArray(headers)) return "";
|
|
857
|
+
const k = Object.keys(headers).find((n) => n.toLowerCase() === name);
|
|
858
|
+
return k ? String(headers[k]) : "";
|
|
859
|
+
};
|
|
860
|
+
// The call http.request was asked for, when it is one to hold.
|
|
861
|
+
const heldCall = (args, secure) => {
|
|
862
|
+
const first = args[0];
|
|
863
|
+
const given = typeof first === "string" || first instanceof URL ? new URL(String(first)) : null;
|
|
864
|
+
const o = (given ? (args[1] && typeof args[1] === "object" ? args[1] : {}) : first) || {};
|
|
865
|
+
const host = String(o.hostname || (o.host && String(o.host).replace(/:\d+$/, "")) || (given && given.hostname) || "localhost");
|
|
866
|
+
const path = String(o.path || (given ? given.pathname + given.search : "/"));
|
|
867
|
+
const method = String(o.method || "GET").toUpperCase();
|
|
868
|
+
const u = new URL(`${secure ? "https" : "http"}://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}${path.startsWith("/") ? path : "/" + path}`);
|
|
869
|
+
const headerOf = (name) => headerIn(o.headers, name) || (name === "authorization" && o.auth ? `Basic ${Buffer.from(String(o.auth)).toString("base64")}` : "");
|
|
870
|
+
return heldHere(u, method, headerOf) ? { u, method, o, cb: args.find((a) => typeof a === "function") } : null;
|
|
871
|
+
};
|
|
872
|
+
const httpRequest = http.request;
|
|
873
|
+
const sendHeld = ({ u, method, o, cb }, secure) => {
|
|
874
|
+
const id = String(++heldSent);
|
|
875
|
+
heldWaiting.set(id, { u, method, ctx: als.getStore() });
|
|
876
|
+
const headers = { ...(o.headers && typeof o.headers === "object" && !Array.isArray(o.headers) ? o.headers : {}), "x-cortad-held": id };
|
|
877
|
+
// Port 1 answers nothing: a write held before the server listens fails instead of leaving.
|
|
878
|
+
const req = httpRequest({ host: "127.0.0.1", port: heldPort || 1, method, path: u.pathname + u.search, headers, ...(o.timeout ? { timeout: o.timeout } : {}), ...(o.signal ? { signal: o.signal } : {}) }, cb);
|
|
879
|
+
// A TLS client waits for the handshake before it writes (Stripe's does).
|
|
880
|
+
if (secure) req.once("socket", (socket) => socket.once("connect", () => socket.emit("secureConnect")));
|
|
881
|
+
return req;
|
|
882
|
+
};
|
|
883
|
+
const heldFetch = async (realFetch, input, init) => {
|
|
884
|
+
const req = new Request(input, init);
|
|
885
|
+
const text = await req.clone().text().catch(() => "");
|
|
886
|
+
if (readsOnly(text)) return realFetch(req);
|
|
887
|
+
const u = new URL(req.url);
|
|
888
|
+
heldRow(u, req.method, text, req.headers.get("content-type"), als.getStore());
|
|
889
|
+
return new Response(heldReply(text, req.headers.get("content-type")), { status: 200, headers: { "content-type": "application/json" } });
|
|
890
|
+
};
|
|
891
|
+
|
|
1029
892
|
// Outbound: the model call your app makes while it handles that request.
|
|
1030
893
|
const bodyText = (body) => (typeof body === "string" ? body : Buffer.isBuffer(body) ? body.toString("utf8") : body instanceof Uint8Array ? Buffer.from(body).toString("utf8") : "");
|
|
1031
894
|
if (typeof globalThis.fetch === "function") {
|
|
1032
895
|
const realFetch = globalThis.fetch;
|
|
1033
896
|
globalThis.fetch = function (input, init) {
|
|
1034
897
|
let url = null;
|
|
898
|
+
let hold = false;
|
|
1035
899
|
try {
|
|
1036
900
|
const u = new URL(typeof input === "string" ? input : input && input.url ? input.url : String(input));
|
|
1037
901
|
if (isModelCall(u.host, u.pathname)) url = u;
|
|
902
|
+
const method = String((init && init.method) || (input && input.method) || "GET").toUpperCase();
|
|
903
|
+
hold = !url && heldHere(u, method, (name) => new Headers((init && init.headers) || (input && input.headers) || undefined).get(name));
|
|
1038
904
|
} catch { /* not a URL we can read */ }
|
|
905
|
+
if (hold) return heldFetch(realFetch, input, init);
|
|
1039
906
|
if (!url) {
|
|
1040
907
|
// Every outbound host, named by the setting that points at it when one does, and by the
|
|
1041
908
|
// host alone otherwise: the search provider behind TAVILY_API_KEY has no URL setting.
|
|
@@ -1048,26 +915,27 @@ if (FILE) {
|
|
|
1048
915
|
if (!dep && /^https?:$/.test(u.protocol) && (retrieval || !/^(localhost|127\.0\.0\.1|\[::1\])$/.test(u.hostname))) dep = { host: u.hostname };
|
|
1049
916
|
} catch { /* not a URL */ }
|
|
1050
917
|
if (!dep) return realFetch.apply(this, arguments);
|
|
1051
|
-
const
|
|
918
|
+
const asked = { ctx: als.getStore(), retrieval, caller: retrieval ? callers() : undefined };
|
|
1052
919
|
return realFetch.apply(this, arguments).then(
|
|
1053
920
|
(res) => {
|
|
1054
|
-
if (!retrieval) { depRow(dep, res.status,
|
|
1055
|
-
try { res.clone().text().then((t) => depRow(dep, res.status,
|
|
921
|
+
if (!retrieval) { depRow(dep, res.status, asked); return res; }
|
|
922
|
+
try { res.clone().text().then((t) => depRow(dep, res.status, { ...asked, passages: passagesFrom(t) }), () => depRow(dep, res.status, asked)); } catch { depRow(dep, res.status, asked); }
|
|
1056
923
|
return res;
|
|
1057
924
|
},
|
|
1058
|
-
(err) => { depRow(dep, 0, err && err.cause && err.cause.code); throw err; },
|
|
925
|
+
(err) => { depRow(dep, 0, { ...asked, code: err && err.cause && err.cause.code }); throw err; },
|
|
1059
926
|
);
|
|
1060
927
|
}
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
const
|
|
928
|
+
let named = null;
|
|
929
|
+
try { const headers = new Headers((init && init.headers) || (input && input.headers) || undefined); named = GATEWAY_MODEL.map((h) => headers.get(h)).find(Boolean); } catch { /* headers we cannot read name no model */ }
|
|
930
|
+
const sent = asChat(bodyText(init && init.body), named);
|
|
931
|
+
const ctx = note(als.getStore(), sent);
|
|
932
|
+
const call = { host: url.host, path: url.pathname, sent, ctx, t0: Date.now(), caller: EMBEDDING.test(url.pathname) ? undefined : callers() };
|
|
1064
933
|
return realFetch.apply(this, arguments).then((res) => {
|
|
934
|
+
if (ctx) ctx.heard = true;
|
|
1065
935
|
try {
|
|
1066
936
|
// A clone is a tee: your app's branch gets every byte as fast as it reads, this one is read to the end.
|
|
1067
|
-
res.
|
|
1068
|
-
|
|
1069
|
-
() => meter({ ...call, status: res.status, type: "", body: "" }),
|
|
1070
|
-
);
|
|
937
|
+
const type = res.headers.get("content-type");
|
|
938
|
+
readAll(res.clone()).then((text) => meter({ ...call, status: res.status, type, body: text }));
|
|
1071
939
|
} catch { meter({ ...call, status: res.status, type: "", body: "" }); }
|
|
1072
940
|
return res;
|
|
1073
941
|
}, (err) => { meter({ ...call, status: 0, type: "", body: "" }); throw err; });
|
|
@@ -1076,6 +944,9 @@ if (FILE) {
|
|
|
1076
944
|
for (const mod of [http, https]) {
|
|
1077
945
|
const request = mod.request;
|
|
1078
946
|
mod.request = function (...args) {
|
|
947
|
+
let held = null;
|
|
948
|
+
try { held = heldCall(args, mod === https); } catch { /* not a call we can read: sent as asked */ }
|
|
949
|
+
if (held) return sendHeld(held, mod === https);
|
|
1079
950
|
const req = request.apply(this, args);
|
|
1080
951
|
try {
|
|
1081
952
|
const o = typeof args[0] === "string" || args[0] instanceof URL ? new URL(String(args[0])) : args[0] || {};
|
|
@@ -1088,13 +959,14 @@ if (FILE) {
|
|
|
1088
959
|
const write = req.write;
|
|
1089
960
|
const end = req.end;
|
|
1090
961
|
req.write = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); return write.call(this, chunk, ...r); };
|
|
1091
|
-
req.end = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); sent = Buffer.concat(parts).toString("utf8"); note(ctx, sent); return end.call(this, chunk, ...r); };
|
|
962
|
+
req.end = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); sent = asChat(Buffer.concat(parts).toString("utf8"), GATEWAY_MODEL.map((h) => req.getHeader && req.getHeader(h)).find(Boolean)); call.ctx = note(ctx, sent); return end.call(this, chunk, ...r); };
|
|
1092
963
|
const bare = String(path || "").split("?")[0];
|
|
1093
|
-
const call = { host: String(host || ""), path: bare,
|
|
964
|
+
const call = { host: String(host || ""), path: bare, ctx, t0: Date.now(), caller: EMBEDDING.test(bare) ? undefined : callers() };
|
|
1094
965
|
let done = false;
|
|
1095
966
|
const once = (row) => { if (!done) { done = true; meter({ ...call, sent, ...row }); } };
|
|
1096
967
|
req.once("error", () => once({ status: 0, type: "", body: "" }));
|
|
1097
968
|
req.once("response", (res) => {
|
|
969
|
+
if (call.ctx) call.ctx.heard = true;
|
|
1098
970
|
// The reply's bytes as the parser hands them over, without reading the stream: your
|
|
1099
971
|
// app's own listeners and pipes see exactly what they would have.
|
|
1100
972
|
const got = [];
|