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.
Files changed (57) hide show
  1. package/README.md +8 -5
  2. package/lib/app-port.mjs +9 -0
  3. package/lib/auto-reach.mjs +88 -0
  4. package/lib/backing.mjs +230 -0
  5. package/lib/calls.mjs +23 -0
  6. package/lib/cli.mjs +115 -30
  7. package/lib/contact-text.mjs +167 -0
  8. package/lib/data.mjs +16 -22
  9. package/lib/dissection-text.mjs +232 -0
  10. package/lib/document.mjs +75 -0
  11. package/lib/env-secrets.mjs +48 -0
  12. package/lib/failed-text.mjs +36 -0
  13. package/lib/fresh.mjs +33 -12
  14. package/lib/health.mjs +61 -0
  15. package/lib/held.mjs +67 -0
  16. package/lib/home.mjs +55 -17
  17. package/lib/hosted.mjs +201 -0
  18. package/lib/identities.mjs +1 -1
  19. package/lib/keeping.mjs +162 -0
  20. package/lib/ledger.mjs +20 -0
  21. package/lib/lock.mjs +6 -1
  22. package/lib/mcp.mjs +52 -18
  23. package/lib/mint.mjs +179 -38
  24. package/lib/mongo.mjs +74 -0
  25. package/lib/numbers-text.mjs +68 -0
  26. package/lib/pg.mjs +118 -0
  27. package/lib/proc.mjs +100 -0
  28. package/lib/proof.mjs +1014 -0
  29. package/lib/proxy.mjs +184 -0
  30. package/lib/pyhook/cortad_sites.py +218 -0
  31. package/lib/pyhook/cortad_tied.py +291 -0
  32. package/lib/pyhook/sitecustomize.py +1175 -166
  33. package/lib/qdrant.mjs +47 -0
  34. package/lib/reach.mjs +223 -0
  35. package/lib/read-text.mjs +78 -24
  36. package/lib/redis.mjs +130 -0
  37. package/lib/register.mjs +36 -27
  38. package/lib/replay.mjs +464 -34
  39. package/lib/runner.mjs +85 -0
  40. package/lib/service.mjs +25 -0
  41. package/lib/spec.mjs +7 -4
  42. package/lib/start.mjs +33 -2
  43. package/lib/stick.mjs +65 -25
  44. package/lib/stores.mjs +347 -0
  45. package/lib/switches.mjs +36 -0
  46. package/lib/tether.mjs +9 -0
  47. package/lib/text.mjs +314 -179
  48. package/lib/tied.cjs +217 -0
  49. package/lib/trace.cjs +386 -514
  50. package/lib/verbs.mjs +155 -38
  51. package/lib/wire.cjs +882 -0
  52. package/lib/words.mjs +2 -0
  53. package/lib/writes.mjs +2 -2
  54. package/local.mjs +661 -297
  55. package/package.json +31 -1
  56. package/skill/SKILL.md +49 -42
  57. package/skill/references/results.md +279 -102
package/lib/wire.cjs ADDED
@@ -0,0 +1,882 @@
1
+ // What lib/trace.cjs and lib/proxy.mjs both read off the wire: a model call's prompt and reply in
2
+ // every provider's shape, and the exchange a request and its model calls make. The hook sees them
3
+ // from inside the app's process; the proxy from outside it, for an app the hook cannot load into.
4
+ // Both write the same rows, so the command reads them one way (lib/replay.mjs). Pure functions,
5
+ // and one recorder per process. Nothing here talks to a network.
6
+ "use strict";
7
+ const fs = require("node:fs");
8
+ // Every value a trace row carries passes one mask, whichever path wrote it: a key inside a value
9
+ // ("sk-...", a bearer token, a JWT) and what anyone wrote after "password is" or "token:". The
10
+ // door row's sign-in headers are kept on purpose: the command replays them from this machine.
11
+ const SECRET_TEXT = [
12
+ /((?:pass(?:word|phrase|wd)|pwd|密码|口令)["']?\s*(?:is\b|was\b|[:=:]|是|为)\s*["']?)([^\s"'\\,;},。;、]+)/gi,
13
+ /((?:secret(?:[ _-]?key)?|api[ _-]?key|apikey|access[ _-]?key|private[ _-]?key|client[ _-]?secret|(?:auth|access|refresh)[ _-]?token|token)["']?\s*[:=:]\s*["']?)([^\s"'\\,;},。;、]+)/gi,
14
+ ];
15
+ 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;
16
+ const maskText = (s) => SECRET_TEXT.reduce((t, re) => t.replace(re, "$1[secret]"), s).replace(SECRET_WORD, "[secret]");
17
+ const scrub = (v, depth = 0) => (typeof v === "string" ? maskText(v)
18
+ : Array.isArray(v) ? v.map((x) => scrub(x, depth + 1))
19
+ : 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);
20
+ // A door's body goes up to the run as JSON, so it is masked as JSON: its string values, never its shape.
21
+ const bodyScrubbed = (text) => { try { const b = JSON.parse(text); return b && typeof b === "object" ? JSON.stringify(scrub(b)) : maskText(text); } catch { return maskText(text); } };
22
+ // A reply is JSON, or a stream of JSON events: masked the same way, event by event, so a stream
23
+ // whose events say {"token": "..."} keeps its words.
24
+ const replyScrubbed = (text) => {
25
+ try { const b = JSON.parse(text); if (b && typeof b === "object") return JSON.stringify(scrub(b)); } catch { /* a stream, or text */ }
26
+ return text.split("\n").map((line) => { const m = /^(data:\s?)?([{[].*)$/.exec(line); return m ? (m[1] || "") + bodyScrubbed(m[2]) : maskText(line); }).join("\n");
27
+ };
28
+ // A row as it is written: the four plain kinds as they are, every other one masked.
29
+ const scrubbed = (value) => {
30
+ const plain = value.hello || value.listen || value.routes || value.conn;
31
+ const out = plain ? value : scrub(value);
32
+ if (!plain && typeof value.body === "string") out.body = bodyScrubbed(value.body);
33
+ if (!plain && typeof value.reply === "string") out.reply = replyScrubbed(value.reply);
34
+ if (!plain && Array.isArray(value.sent)) out.sent = value.sent.map(bodyScrubbed);
35
+ if (!plain && Array.isArray(value.after)) out.after = value.after.map((s, i) => ({ ...out.after[i], body: bodyScrubbed(s.body), reply: replyScrubbed(s.reply) }));
36
+ return out;
37
+ };
38
+
39
+ const MAX = 65536;
40
+ // The turn a message came in under, when the run tagged it: one opaque id per request, so a
41
+ // model call and its prompt can be pinned to the reply they produced even while five turns are
42
+ // in flight. It is read here and never shown to your app's own code path beyond the header.
43
+ const TURN = /^[A-Za-z0-9:_.-]{1,80}$/;
44
+ const turnOf = (headers) => {
45
+ const t = headers && (typeof headers.get === "function" ? headers.get("x-cortad-turn") : headers["x-cortad-turn"]);
46
+ return typeof t === "string" && TURN.test(t) ? t : undefined;
47
+ };
48
+ const norm = (s) => String(s || "")
49
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
50
+ .replace(/\\[nrt]/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\")
51
+ .toLowerCase().replace(/\s+/g, " ").trim();
52
+ // A sentence with a slot in it ("answer in {language}") is matched by its literal parts.
53
+ const partsOf = (text) => text.split(/\{[^}]*\}|\$\{[^}]*\}|%[sd]|<[^>]{1,40}>/).map(norm).filter((p) => p.length >= 12);
54
+ // What the app's tools answered, as the prompt of the next model call carries them: the tool
55
+ // messages of a chat body, the function outputs of a responses body, the tool_result blocks of
56
+ // an Anthropic one, the functionResponse parts of a Gemini one. They are the material a reply's
57
+ // facts rest on, and without them a real ticket id read as invented. Bounded per call.
58
+ // A tool's answer is kept to the cap the reader takes (12,000 characters), and one longer says it was
59
+ // cut: MIT Learn AI's 8,000-character search results were kept at 3,000, and a podcast episode in
60
+ // the part cut off was graded as made up. A cut answer stands the passage questions down.
61
+ const TOOL_TEXT = 12000, TOOLS_MAX = 12;
62
+ const toolText = (name, full, extra = {}) => ({ name: String(name || "").slice(0, 80), text: full.slice(0, TOOL_TEXT), ...extra, ...(full.length > TOOL_TEXT ? { cut: true } : {}) });
63
+ const textOf = (v) => (typeof v === "string" ? v : Array.isArray(v) ? v.map((p) => (p && typeof p === "object" ? (p.text || p.content || "") : String(p || ""))).filter(Boolean).join("\n") : v && typeof v === "object" ? JSON.stringify(v) : "");
64
+ // Only what came after the person's latest message belongs to this turn: a thread the app
65
+ // resends whole carries every earlier turn's tool answers too.
66
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => x && typeof x === "object") : []);
67
+ const since = (items, isPerson) => { let at = -1; items.forEach((m, i) => { if (isPerson(m)) at = i; }); return items.slice(at + 1); };
68
+ // A user message that only hands a tool's answer back ("Observation: ...", <tool_response>) is
69
+ // the agent loop talking, not the person.
70
+ const HANDED_BACK = /^\s*(?:Observation\s*:|<(?:tool_response|tool_result|function_results?)>)/i;
71
+ const personSaid = (m) => m.role === "user" && !(Array.isArray(m.content) && m.content.some((c) => c && c.type === "tool_result")) && !HANDED_BACK.test(textOf(m.content));
72
+ const personAsked = (c) => c.role === "user" && !list(c.parts).some((p) => p.functionResponse);
73
+ const toolsIn = (sent) => {
74
+ let body; try { body = JSON.parse(sent); } catch { return undefined; }
75
+ if (!body || typeof body !== "object") return undefined;
76
+ const out = [];
77
+ const names = new Map();
78
+ const add = (name, text) => { const full = textOf(text); const t = full.slice(0, TOOL_TEXT); if (t.trim() && out.length < TOOLS_MAX && !out.some((o) => o.text === t)) out.push(toolText(name, full)); };
79
+ for (const m of list(body.messages)) {
80
+ for (const c of list(m.tool_calls)) if (c.id && c.function) names.set(c.id, c.function.name);
81
+ for (const c of list(m.content)) if (c.type === "tool_use" && c.id) names.set(c.id, c.name);
82
+ }
83
+ for (const m of since(list(body.messages), personSaid)) {
84
+ if (m.role === "tool" || m.role === "function") add(m.name || names.get(m.tool_call_id), m.content);
85
+ for (const c of list(m.content)) if (c.type === "tool_result") add(names.get(c.tool_use_id), c.content);
86
+ }
87
+ const items = list(body.input);
88
+ for (const it of items) if (it.type === "function_call" && it.call_id) names.set(it.call_id, it.name);
89
+ for (const it of since(items, (x) => x.role === "user")) if (it.type === "function_call_output") add(names.get(it.call_id), it.output);
90
+ 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);
91
+ const declared = declaredIn(body);
92
+ for (const o of observedIn(turnTexts(body, true, declared), declared)) add(o.name, o.text);
93
+ return out.length ? out : undefined;
94
+ };
95
+
96
+ // What the model asked the app to run: chat tool_calls (a stream's pieces joined by position), a
97
+ // legacy function_call, a responses function_call item, an Anthropic or Bedrock tool use, a
98
+ // Gemini functionCall. Read off the model's reply, and off this turn's earlier calls as the
99
+ // prompt resends them. Names and arguments only: every value clipped, a secret never written.
100
+ const CALLS_MAX = 12, VALUE_MAX = 200, ARGS_MAX = 1200;
101
+ const SECRET_KEY = /(?:^|_)(?:pass(?:word|phrase)?|secret|token|api_?key|authorization|cookie|session(?:_id)?|credentials?|private_key)$/;
102
+ const SECRET_VALUE = /^(?:Bearer\s|Basic\s|sk-|pk_|rk_|ghp_|gho_|github_pat_|xox[abpr]-|AKIA|AIza|eyJ[\w-]{10,}\.)/;
103
+ const clipped = (v, depth, max = VALUE_MAX) => {
104
+ if (typeof v === "string") return SECRET_VALUE.test(v) ? "[secret]" : v.length > max ? v.slice(0, max) + "…" : v;
105
+ if (Array.isArray(v)) return depth > 4 ? [] : v.slice(0, 20).map((x) => clipped(x, depth + 1, max));
106
+ if (!v || typeof v !== "object") return v;
107
+ const o = {};
108
+ if (depth > 4) return o;
109
+ 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);
110
+ return o;
111
+ };
112
+ const argsText = (raw) => {
113
+ let v = raw;
114
+ if (typeof raw === "string") { try { v = raw.trim() ? JSON.parse(raw) : {}; } catch { v = raw; } }
115
+ return JSON.stringify(clipped(v === undefined || v === null ? {} : v, 0)).slice(0, ARGS_MAX);
116
+ };
117
+ // Each function tool the request declares, in every provider's shape: its name, what its
118
+ // description says it does, and its JSON schema (null where it declares none).
119
+ const toolDefs = (body) => {
120
+ const out = [];
121
+ const add = (name, does, schema) => { if (typeof name === "string" && name) out.push({ name, does: typeof does === "string" ? does : "", schema: schema && typeof schema === "object" ? schema : null }); };
122
+ for (const t of [...list(body && body.tools), ...list(body && body.toolConfig && body.toolConfig.tools)]) {
123
+ if (t.function) add(t.function.name, t.function.description, t.function.parameters);
124
+ else if (t.toolSpec) add(t.toolSpec.name, t.toolSpec.description, t.toolSpec.inputSchema && t.toolSpec.inputSchema.json);
125
+ else add(t.name, t.description, t.parameters || t.input_schema || t.inputSchema);
126
+ for (const d of list(t.functionDeclarations || t.function_declarations)) add(d.name, d.description, d.parameters);
127
+ }
128
+ for (const f of list(body && body.functions)) add(f.name, f.description, f.parameters);
129
+ return out;
130
+ };
131
+ const schemasOf = (body) => new Map(toolDefs(body).filter((d) => d.schema).map((d) => [d.name, d.schema]));
132
+ // What a tool's declared schema refuses in the arguments the model sent, said plainly, or null.
133
+ // Read before any value is clipped: a clipped value is ours, never the model's.
134
+ const refusedBy = (schema, raw) => {
135
+ let args = raw === undefined || raw === null ? {} : raw;
136
+ if (typeof args === "string") { if (!args.trim()) args = {}; else { try { args = JSON.parse(args); } catch { return "the arguments are not valid JSON"; } } }
137
+ if (!args || typeof args !== "object" || Array.isArray(args)) return "the arguments are not a JSON object";
138
+ const missing = (Array.isArray(schema.required) ? schema.required : []).find((k) => typeof k === "string" && !Object.prototype.hasOwnProperty.call(args, k));
139
+ return missing ? `the required input "${missing}" is missing` : null;
140
+ };
141
+ // `schemas`: the request's declared tools, so each call the reply makes is checked against its own.
142
+ const callsOf = (events, schemas) => {
143
+ const whole = new Map();
144
+ const parts = new Map();
145
+ let n = 0;
146
+ const put = (key, name, args) => { if (name) whole.set(key || `w${n++}`, { name, args }); };
147
+ const piece = (key, name, args) => {
148
+ const p = parts.get(key) || { name: "", args: "" };
149
+ if (name && !p.name) p.name = name;
150
+ if (typeof args === "string") p.args += args;
151
+ parts.set(key, p);
152
+ };
153
+ const blocks = (content) => {
154
+ for (const b of list(content)) {
155
+ if (b.type === "tool_use") put(b.id, b.name, b.input);
156
+ if (b.type === "tool-call") put(b.toolCallId, b.toolName, b.input);
157
+ if (b.toolUse) put(b.toolUse.toolUseId, b.toolUse.name, b.toolUse.input);
158
+ }
159
+ };
160
+ for (const e of events) {
161
+ if (!e || typeof e !== "object") continue;
162
+ for (const c of list(e.choices)) {
163
+ const m = c.message;
164
+ if (m) {
165
+ for (const t of list(m.tool_calls)) if (t.function) put(t.id, t.function.name, t.function.arguments);
166
+ if (m.function_call) put(null, m.function_call.name, m.function_call.arguments);
167
+ }
168
+ const d = c.delta;
169
+ if (d) {
170
+ 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);
171
+ if (d.function_call) piece(`f${c.index || 0}`, d.function_call.name, d.function_call.arguments);
172
+ }
173
+ }
174
+ for (const it of [...list(e.output), ...list(e.item ? [e.item] : []), ...list(e.response && e.response.output)]) {
175
+ if (it.type === "function_call") put(it.call_id || it.id, it.name, it.arguments);
176
+ }
177
+ blocks(e.content);
178
+ blocks(e.message && e.message.content);
179
+ blocks(e.output && e.output.message && e.output.message.content);
180
+ if (e.type === "content_block_start" && e.content_block && e.content_block.type === "tool_use") piece(`a${e.index}`, e.content_block.name, "");
181
+ if (e.type === "content_block_delta" && e.delta && e.delta.type === "input_json_delta") piece(`a${e.index}`, "", e.delta.partial_json);
182
+ 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);
183
+ if (e.type === "tool-call" && e.toolName) put(e.toolCallId, e.toolName, e.input);
184
+ }
185
+ return [...whole.values(), ...parts.values()].filter((p) => p.name).map((p) => {
186
+ const refused = schemas && schemas.has(p.name) ? refusedBy(schemas.get(p.name), p.args) : null;
187
+ return { name: String(p.name).slice(0, 80), arguments: argsText(p.args), ...(refused ? { refused } : {}) };
188
+ });
189
+ };
190
+ // This turn's earlier calls, as the prompt resends them after the person's latest message.
191
+ const calledBefore = (body) => callsOf([
192
+ ...since(list(body.messages), personSaid).filter((m) => m.role === "assistant").map((m) => ({ choices: [{ message: m }], content: m.content })),
193
+ { output: since(list(body.input), (x) => x.role === "user") },
194
+ ...since(list(body.contents), personAsked).filter((c) => c.role === "model").map((c) => ({ candidates: [{ content: c }] })),
195
+ ]);
196
+ // Tool use a model writes in its words instead of as a structured call. A ReAct agent (CrewAI,
197
+ // LangChain) writes "Action: name" then "Action Input: {...}" and is handed "Observation: ..."
198
+ // back in its next prompt; others write <tool_call>{...}</tool_call>, <function=name>,
199
+ // <invoke name="..."> or a JSON object that names the tool. A name counts only when the request
200
+ // declares that tool, in its tools field or in the tool list its prompt carries, so a thought, a
201
+ // "Final Answer" or prose that says Action is never a call.
202
+ const FINAL = /^final[\s_-]*answer$/i;
203
+ const TOOL_NAME = /^[A-Za-z_][\w.-]{0,79}$/;
204
+ const promptText = (body) => [
205
+ ...list(body.messages).filter((m) => m.role !== "assistant").map((m) => textOf(m.content)),
206
+ textOf(body.system), textOf(body.instructions), typeof body.prompt === "string" ? body.prompt : "", typeof body.input === "string" ? body.input : "",
207
+ ...list(body.input).filter((x) => x.role && x.role !== "assistant").map((x) => textOf(x.content)),
208
+ textOf(body.systemInstruction && body.systemInstruction.parts), ...list(body.contents).filter((c) => c.role !== "model").map((c) => textOf(c.parts)),
209
+ ].join("\n");
210
+ const declaredIn = (body) => {
211
+ const out = new Set();
212
+ const add = (n) => { const s = typeof n === "string" ? n.trim().replace(/^["'`]+|["'`]+$/g, "") : ""; if (TOOL_NAME.test(s) && !FINAL.test(s)) out.add(s); };
213
+ for (const d of toolDefs(body)) add(d.name);
214
+ const text = promptText(body).slice(0, 200000);
215
+ for (const m of text.matchAll(/^[ \t]*Tool Name:[ \t]*([^\n]+)/gim)) add(m[1]);
216
+ 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);
217
+ 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);
218
+ 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]);
219
+ for (const m of text.matchAll(/"name"\s*:\s*"([^"]+)"\s*,\s*"(?:description|parameters|input_schema)"/g)) add(m[1]);
220
+ for (const m of text.matchAll(/<(?:tool|function)\s+name\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
221
+ return out;
222
+ };
223
+ 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;
224
+ const TAG_CALL = /<(tool_call|function_call|tool_use)>([\s\S]*?)<\/\1>/gi;
225
+ const FN_TAG = /<function=([\w.-]+)>([\s\S]*?)<\/function>/gi;
226
+ const INVOKE = /<invoke\s+name\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/invoke>/gi;
227
+ 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;
228
+ 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; };
229
+ // Each balanced JSON object in the text, outermost first; its insides are not read again.
230
+ const objectsIn = (text) => {
231
+ const out = [];
232
+ for (let i = text.indexOf("{"); i !== -1 && out.length < 20; i = text.indexOf("{", i + 1)) {
233
+ let depth = 0, str = false, esc = false, end = -1;
234
+ for (let j = i; j < text.length && j < i + 20000; j++) {
235
+ const ch = text[j];
236
+ if (str) { if (esc) esc = false; else if (ch === "\\") esc = true; else if (ch === '"') str = false; continue; }
237
+ if (ch === '"') str = true; else if (ch === "{") depth++; else if (ch === "}" && --depth === 0) { end = j; break; }
238
+ }
239
+ const v = end === -1 ? null : parse(text.slice(i, end + 1));
240
+ if (v && typeof v === "object") { out.push({ at: i, v }); i = end; }
241
+ }
242
+ return out;
243
+ };
244
+ // An Action Input that opens with a JSON object is that object: a model that runs on past it
245
+ // ("Observ: ...") does not put its own invention into the arguments.
246
+ const leadingJson = (s) => { const t = s.trim(); const o = t[0] === "{" ? objectsIn(t)[0] : null; return o && o.at === 0 ? o.v : t; };
247
+ // A tool's schema carries a description; a call does not.
248
+ const jsonCall = (v) => {
249
+ const f = v.function && typeof v.function === "object" ? v.function : v;
250
+ if ("description" in f) return null;
251
+ const name = [f.name, v.tool, v.tool_name, v.action, typeof v.function === "string" ? v.function : undefined].find((x) => typeof x === "string");
252
+ 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;
253
+ };
254
+ const writtenCalls = (text, declared) => {
255
+ const found = [];
256
+ if (!text || !declared.size) return found;
257
+ const s = text.slice(0, 50000);
258
+ 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 }); };
259
+ for (const m of s.matchAll(REACT)) take(m.index, m[1], leadingJson(m[2]));
260
+ 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); }
261
+ 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])); }
262
+ for (const m of s.matchAll(INVOKE)) take(m.index, m[1], paramsOf(m[2]));
263
+ for (const { at, v } of objectsIn(s)) { const c = jsonCall(v); if (c) take(at, c.name, c.args); }
264
+ return found.sort((a, b) => a.at - b.at);
265
+ };
266
+ // What a tool answered, named by the call written just before it.
267
+ const observedIn = (text, declared) => {
268
+ const s = String(text || "").slice(0, 50000);
269
+ const calls = writtenCalls(s, declared);
270
+ const out = [];
271
+ 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() }); }
272
+ return out;
273
+ };
274
+ // The model's own words: the text it answered, whole or streamed, in every provider's shape. A
275
+ // responses stream sends its words as deltas and then whole again in its closing event: the
276
+ // closing copy counts only where no delta came.
277
+ const replyText = (events) => {
278
+ let s = "", closing = "", streamed = false;
279
+ for (const e of events) {
280
+ if (!e || typeof e !== "object") continue;
281
+ 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; }
282
+ if (e.type === "response.output_text.delta" && typeof e.delta === "string") { s += e.delta; streamed = true; }
283
+ for (const it of list(e.output)) if (it.type === "message") for (const p of list(it.content)) if (typeof p.text === "string") s += p.text;
284
+ for (const it of list(e.response && e.response.output)) if (it.type === "message") for (const p of list(it.content)) if (typeof p.text === "string") closing += p.text;
285
+ if (e.type === "content_block_delta" && e.delta && typeof e.delta.text === "string") s += e.delta.text;
286
+ if (e.type === "text-delta" && typeof e.delta === "string") s += e.delta;
287
+ 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" && b.type !== "reasoning") s += b.text;
288
+ if (e.message && typeof e.message.content === "string" && !e.choices) s += e.message.content;
289
+ 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;
290
+ if (typeof e.response === "string") s += e.response;
291
+ }
292
+ return streamed ? s : s + closing;
293
+ };
294
+ // This turn as the prompt carries it: the model's earlier words after the person's latest
295
+ // message, and everything from that message on, where a single-prompt agent keeps its scratchpad.
296
+ // A user message right after the model wrote a tool call is the agent loop's nudge ("Analyze the
297
+ // tool result"), never the person: after a call, only the loop speaks.
298
+ const turnTexts = (body, withPerson, declared) => {
299
+ const from = (items, isPerson) => { const at = items.findLastIndex(isPerson); return items.slice(withPerson ? Math.max(0, at) : at + 1); };
300
+ const ours = (r) => withPerson ? r !== "system" && r !== "developer" : r === "assistant" || r === "model";
301
+ const ms = list(body.messages);
302
+ 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);
303
+ return [
304
+ ...from(ms, person).filter((m) => ours(m.role)).map((m) => textOf(m.content)),
305
+ ...from(list(body.input), (x) => x.role === "user").filter((x) => ours(x.role)).map((x) => textOf(x.content ?? x.output)),
306
+ ...from(list(body.contents), personAsked).filter((c) => ours(c.role)).map((c) => textOf(c.parts)),
307
+ ...(withPerson ? [typeof body.prompt === "string" ? body.prompt : "", typeof body.input === "string" ? body.input : ""] : []),
308
+ ].join("\u0000\n");
309
+ };
310
+
311
+ const calledIn = (sent, events, provided = []) => {
312
+ const body = parse(sent);
313
+ const out = [];
314
+ const declared = body && typeof body === "object" ? declaredIn(body) : new Set();
315
+ 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) }));
316
+ for (const c of [...(body && typeof body === "object" ? calledBefore(body) : []), ...callsOf(events, schemasOf(body)), ...provided, ...written]) {
317
+ if (out.length < CALLS_MAX && !out.some((o) => o.name === c.name && o.arguments === c.arguments)) out.push(c);
318
+ }
319
+ return out.length ? out : undefined;
320
+ };
321
+
322
+ // Tools the model's provider runs for the app (a web search, a file search, a code interpreter, a
323
+ // remote MCP server): the calls come back in the model's own reply, and so does what each answered
324
+ // where the provider says it. A call it marks failed, or an error it hands back, is the tool failing
325
+ // there, in the provider's own words. Answers pair with calls in the order the reply lists them.
326
+ const PROVIDER_CALL = /^(web_search|file_search|code_interpreter|image_generation|mcp)_call$/;
327
+ const PROVIDER_RESULT = /^\w+_tool_result$/;
328
+ const providerIn = (sent, events) => {
329
+ const body = parse(sent);
330
+ const offered = list(body && body.tools).map((t) => t.type).filter((t) => typeof t === "string" && t !== "function" && t !== "custom");
331
+ const named = (kind) => offered.find((t) => t.startsWith(kind)) || kind;
332
+ const calls = new Map(), names = new Map(), answers = [];
333
+ const answer = (name, text) => { const full = textOf(text); const t = full.slice(0, TOOL_TEXT); if (name && t.trim() && answers.length < TOOLS_MAX && !answers.some((a) => a.name === name && a.text === t)) answers.push(toolText(name, full, { provider: true })); };
334
+ for (const e of events) {
335
+ if (!e || typeof e !== "object") continue;
336
+ const order = [];
337
+ for (const it of [...list(e.output), ...list(e.item ? [e.item] : []), ...list(e.response && e.response.output)]) {
338
+ const m = PROVIDER_CALL.exec(it.type || "");
339
+ if (m) {
340
+ const name = m[1] === "mcp" ? String(it.name || "mcp") : named(m[1]);
341
+ order.push(name);
342
+ if (it.id) calls.set(it.id, { name: name.slice(0, 80), arguments: argsText(it.action ?? it.arguments ?? {}) });
343
+ if (it.status === "failed" || it.error) answer(name, it.error || `${it.type} ${it.status}`);
344
+ } else if (it.type === "tool_output") answer(order.shift() || (offered.length === 1 ? offered[0] : ""), it.output);
345
+ }
346
+ for (const b of [...list(e.content), ...list(e.message && e.message.content), ...list(e.content_block ? [e.content_block] : [])]) {
347
+ if ((b.type === "server_tool_use" || b.type === "mcp_tool_use") && b.id) { names.set(b.id, b.name); calls.set(b.id, { name: String(b.name).slice(0, 80), arguments: argsText(b.input ?? {}) }); }
348
+ const c = b.content;
349
+ if (PROVIDER_RESULT.test(b.type || "") && (b.is_error === true || (c && !Array.isArray(c) && typeof c === "object" && /_error$/.test(c.type || "")))) answer(names.get(b.tool_use_id) || b.type.replace(/_tool_result$/, ""), c);
350
+ }
351
+ }
352
+ return { calls: [...calls.values()], answers };
353
+ };
354
+
355
+ // What the prompt was handed besides its instructions, of two kinds. What the app retrieved: a
356
+ // block it labels as context, documents, knowledge, sources or search results. And its own
357
+ // record of the person, passed beside the ask: a profile, a résumé, an account, orders, a JSON
358
+ // or key: value block of their data, under any heading or none ("data"). A reply's fact taken
359
+ // from either was given, not made up: resumeforge put the saved profile into every prompt, and
360
+ // its replies' facts from it were read as invention. In the system prompt or from the person's
361
+ // latest message on, and Anthropic document and search_result blocks.
362
+ const PASSAGE_TEXT = 3000, PASSAGES_MAX = 6;
363
+ const MATERIAL = /\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq|relevant)\b|检索|知识|参考资料|资料|上下文|文档|背景|相关/i;
364
+ const RECORD = /\b(?:profiles?|r[eé]sum[eé]s?|cv|(?<!into )accounts?|orders|purchases|records|(?:user|customer|member|patient|student|client|candidate)[ _]?(?:info\w*|data|details?|facts)|(?:order|purchase|account|medical|payment|employment|transaction|work) history)\b|个人资料|用户资料|个人信息|用户信息|简历|档案|订单|账户|账号|会员/i;
365
+ const nameOf = (label) => label.replace(/[#::[\]=]/g, "").trim().replace(/(?:开始|\s+(?:start|begin))$/i, "").trim();
366
+ // A record is named by a label that ends in its word ("User profile", "[已选个人资料开始]"): a
367
+ // heading of the instructions that only mentions one ("关于简历通本身与使用平台") is not it.
368
+ const RECORD_END = new RegExp(`(?:${RECORD.source})$`, "i");
369
+ const isRecord = (label) => RECORD_END.test(nameOf(label));
370
+ const isLabel = (label) => MATERIAL.test(label) || isRecord(label);
371
+ const kindOf = (label) => (isRecord(label) ? "data" : undefined);
372
+ 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*$/;
373
+ // An inline label is a noun phrase that ends in the label word ("context:", "background:",
374
+ // "user profile:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
375
+ const INLINE = /^\s*(?:[-*]\s+)?([A-Za-z一-鿿][A-Za-z一-鿿 '’_-]{0,29})[::]\s*\S/;
376
+ const NAMED = new RegExp(`(?:${MATERIAL.source}|${RECORD.source})\\s*$`, "i");
377
+ const TAGGED = /<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)<\/\1>/g;
378
+ // A label's block runs on to the next heading of its own kind: "资料:" over retrieved chunks
379
+ // that open on markdown headings holds every chunk, where stopping at its first paragraph kept
380
+ // one chunk of five and the reader called the knowledge base's own 1% fee made up. A markdown
381
+ // label holds deeper headings. A closing paragraph that opens on a label of its own ("问题:...",
382
+ // "Question: ...") is the prompt's ask, not material. A long block is several passages, cut
383
+ // between its paragraphs, then its lines.
384
+ const MARKDOWN = /^\s*(#{1,6})\s/;
385
+ const endsBlock = (label, line) => {
386
+ if (!HEADING.test(line)) return false;
387
+ const outer = MARKDOWN.exec(label), inner = MARKDOWN.exec(line);
388
+ return !inner || Boolean(outer && inner[1].length <= outer[1].length);
389
+ };
390
+ const asks = (para) => { const m = INLINE.exec(para.split("\n")[0]); return Boolean(m && !NAMED.test(m[1])); };
391
+ // A heading inside a fenced block is the material's own text: a README chunk's "## Features".
392
+ const FENCE = /^\s*(?:```|~~~)/;
393
+ const fences = (para) => para.split("\n").filter((l) => FENCE.test(l)).length;
394
+ const pieces = (p) => (p.length <= PASSAGE_TEXT ? [p] : p.split("\n").flatMap((l) => l.match(new RegExp(`[\\s\\S]{1,${PASSAGE_TEXT}}`, "g")) || []));
395
+ const addBlock = (add, name, paras, kind) => {
396
+ let text = "";
397
+ for (const para of paras) {
398
+ pieces(para).forEach((p, j) => {
399
+ if (text && text.length + p.length + 2 > PASSAGE_TEXT) { add(name, text, kind); text = ""; }
400
+ text += (text ? (j ? "\n" : "\n\n") : "") + p;
401
+ });
402
+ }
403
+ add(name, text, kind);
404
+ };
405
+ // The person's data with no label word: a JSON object, or key: value lines (YAML too) under a
406
+ // heading. A transcript pasted as "User: ... / Assistant: ..." is the conversation; a tool's
407
+ // schema, an agent's Thought/Action scaffold and an answer's shape are instructions.
408
+ const KV = /^\s*(?:[-*]\s+)?["']?([\p{L}_][\p{L}\p{N} _.'’()/-]{0,39})["']?\s*[::]\s*(\S.*)?$/u;
409
+ const ROLE = /^(?:user|assistant|human|ai|system|bot|model|agent|customer|用户|助手|客服|顾客|系统)$/i;
410
+ const SCAFFOLD = /^(?:tool\b.*|action(?: input)?|thought|observation|final answer|question|answer|input|output)$/i;
411
+ const SHAPE = /format|schema|example|output|respon|return|reply|格式|示例|输出|返回/i;
412
+ const TOOL_SCHEMA = /"(?:parameters|input_schema|inputSchema)"\s*:/;
413
+ // "[已选岗位结束]": the closing marker an app puts after a record is not part of it.
414
+ const CLOSER = /^\s*\[[^\]\n]{1,80}\]\s*$/;
415
+ const dataIn = (para, before) => {
416
+ const lines = para.split("\n");
417
+ const head = HEADING.test(lines[0]) ? lines[0] : null;
418
+ const body = (head ? lines.slice(1) : lines).filter((l) => l.trim() && !FENCE.test(l));
419
+ if (body.length > 1 && CLOSER.test(body[body.length - 1])) body.pop();
420
+ const name = head || (before && !before.includes("\n") && HEADING.test(before) ? before : "");
421
+ const text = body.join("\n");
422
+ if (SHAPE.test(name)) return null;
423
+ if (/^\s*[{[]/.test(text)) {
424
+ try { const v = JSON.parse(text); return v && typeof v === "object" && Object.keys(v).length && !TOOL_SCHEMA.test(text) ? { name: nameOf(name) || "data", text } : null; } catch { return null; }
425
+ }
426
+ const kv = body.map((l) => KV.exec(l));
427
+ const valued = kv.filter((m) => m && m[2]).map((m) => m[1].trim());
428
+ const shaped = name && body.every((l, i) => kv[i] || /^\s+\S|^\s*-\s/.test(l));
429
+ return shaped && valued.length >= 2 && valued.filter((k) => ROLE.test(k)).length < 2 && !valued.some((k) => SCAFFOLD.test(k)) ? { name: nameOf(name), text } : null;
430
+ };
431
+ const labelled = (text, add) => {
432
+ const rest = String(text || "").replace(TAGGED, (all, tag, inner) => { const label = tag.replace(/_/g, " "); if (isLabel(label)) { add(tag, inner, kindOf(label)); return ""; } return all; });
433
+ const paras = rest.split(/\n\s*\n/);
434
+ for (let i = 0; i < paras.length; i++) {
435
+ const lines = paras[i].split("\n");
436
+ const head = lines[0];
437
+ if (HEADING.test(head) && isLabel(head)) {
438
+ const block = [lines.slice(1).join("\n")];
439
+ let fenced = fences(block[0]) % 2 === 1;
440
+ while (i + 1 < paras.length && (fenced || (!endsBlock(head, paras[i + 1].split("\n")[0]) && !(i + 2 === paras.length && asks(paras[i + 1]))))) {
441
+ block.push(paras[++i]);
442
+ if (fences(paras[i]) % 2 === 1) fenced = !fenced;
443
+ }
444
+ // A block that opens on a record's own label ("[已选个人资料开始]" under "参考资料:") is that record.
445
+ const inner = block[0].trimStart().split("\n")[0];
446
+ const label = HEADING.test(inner) && isRecord(inner) ? inner : head;
447
+ addBlock(add, nameOf(label), block, kindOf(label));
448
+ continue;
449
+ }
450
+ const inline = lines.map((l) => INLINE.exec(l)).find((m) => m && NAMED.test(m[1]));
451
+ if (inline) { add(inline[1].trim(), paras[i], kindOf(inline[1])); continue; }
452
+ const data = dataIn(paras[i], paras[i - 1]);
453
+ if (data) addBlock(add, data.name, [data.text], "data");
454
+ }
455
+ };
456
+ const passagesIn = (sent) => {
457
+ const body = parse(sent);
458
+ if (!body || typeof body !== "object") return undefined;
459
+ const out = [];
460
+ const add = (name, text, kind) => { 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, ...(kind ? { kind } : {}) }); };
461
+ const scan = (content) => {
462
+ if (typeof content === "string") return labelled(content, add);
463
+ for (const b of list(content)) {
464
+ if (b.type === "document") add(b.title || "document", b.source ? textOf(b.source.data ?? b.source.content) : textOf(b.content));
465
+ else if (b.type === "search_result") add(b.title || b.source || "search result", textOf(b.content));
466
+ else if (typeof b.text === "string") labelled(b.text, add);
467
+ }
468
+ };
469
+ const messages = list(body.messages);
470
+ for (const m of messages) if (m.role === "system" || m.role === "developer") scan(m.content);
471
+ for (const m of messages.slice(Math.max(0, messages.findLastIndex(personSaid)))) if (m.role === "user") scan(m.content);
472
+ scan(body.system);
473
+ scan(body.instructions);
474
+ scan(body.systemInstruction && body.systemInstruction.parts);
475
+ const input = typeof body.input === "string" ? [{ role: "user", content: body.input }] : list(body.input);
476
+ 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);
477
+ return out.length ? out : undefined;
478
+ };
479
+ // The passages a retrieval call answered with: a vector store, a search index or a web search,
480
+ // read off its reply when it is JSON. Text fields only, bounded like the rest.
481
+ 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;
482
+ const RETRIEVAL_PATH = /\/(?:query|search|_search|retrieve|similarity_search|rerank|hybrid)(?:\/|$)|\/points\/(?:search|query)|\/rpc\/match\w*/i;
483
+ const isRetrieval = (u) => RETRIEVAL_HOST.test(u.hostname) || RETRIEVAL_PATH.test(u.pathname);
484
+ const PASSAGE_KEY = /^(?:text|content|page_?content|pageContent|chunk|snippet|passage|body|document|documents|description|answer|raw_content|highlights?|excerpt)$/i;
485
+ const passagesFrom = (raw) => {
486
+ const out = [];
487
+ let nodes = 0;
488
+ const walk = (v, key, depth) => {
489
+ if (out.length >= PASSAGES_MAX || depth > 7 || ++nodes > 4000) return;
490
+ const t = typeof v === "string" ? v.trim().slice(0, PASSAGE_TEXT) : "";
491
+ if (t) { if (PASSAGE_KEY.test(key) && t.length >= 20 && !out.some((o) => o.text === t)) out.push({ name: key, text: t }); return; }
492
+ if (Array.isArray(v)) { for (const x of v) walk(x, key, depth + 1); return; }
493
+ if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) walk(x, k, depth + 1);
494
+ };
495
+ walk(parse(raw), "", 0);
496
+ return out.length ? out : undefined;
497
+ };
498
+
499
+ // What the app told the model on this call: its system prompt and every block it or its framework
500
+ // put among the messages (a reminder, injected context, a prompt template), in prompt order. Never
501
+ // the person's words: what a request carried is taken out of the person's side, and a message
502
+ // that held only that is not told. Never the model's own words or a tool's answer. A reply is
503
+ // read against this, and a block of it the reply repeats is a leak code decides.
504
+ const INSTRUCTIONS_MAX = 12000, TOLD_MIN = 12;
505
+ // The fields read below, in the shapes they are read in. A body with none of them is a shape
506
+ // this hook cannot read: its instructions stay unrecorded, so the run keeps the read's copy of
507
+ // the prompt, and "" always means the call really carried none.
508
+ const shapeOf = (v) => (Array.isArray(v) ? "array" : v === null ? "null" : typeof v);
509
+ const TOLD_FIELDS = { system: /^(?:string|array|object)$/, instructions: /^(?:string|array|object)$/, systemInstruction: /^object$/, messages: /^array$/, input: /^(?:string|array)$/, contents: /^array$/, prompt: /^string$/ };
510
+ const instructionsIn = (body, words) => {
511
+ if (!Object.entries(TOLD_FIELDS).some(([k, shape]) => shape.test(shapeOf(body[k])))) return undefined;
512
+ // Longest first, so a short phrase the person repeated never splits a longer message of theirs.
513
+ const said = [...words].sort((a, b) => b.length - a.length);
514
+ const told = [];
515
+ const own = (content) => { const t = textOf(content).trim(); if (t) told.push(t); };
516
+ const added = (text) => {
517
+ let left = text;
518
+ for (const w of said) if (left.includes(w)) left = left.split(w).join(" ");
519
+ if ((left.match(/[\p{L}\p{N}]/gu) || []).length >= TOLD_MIN) told.push(left.trim());
520
+ };
521
+ const texts = (content) => (typeof content === "string" ? [content] : list(content).filter((p) => typeof p.text === "string").map((p) => p.text));
522
+ own(body.system);
523
+ own(body.instructions);
524
+ own(body.systemInstruction && body.systemInstruction.parts);
525
+ for (const m of list(body.messages)) {
526
+ if (m.role === "system" || m.role === "developer") own(m.content);
527
+ else if (m.role === "user" && !HANDED_BACK.test(textOf(m.content))) texts(m.content).forEach(added);
528
+ }
529
+ for (const it of typeof body.input === "string" ? [{ role: "user", content: body.input }] : list(body.input)) {
530
+ if (it.role === "system" || it.role === "developer") own(it.content);
531
+ else if (it.role === "user") texts(it.content).forEach(added);
532
+ }
533
+ for (const c of list(body.contents)) if (c.role !== "model") texts(c.parts).forEach(added);
534
+ if (typeof body.prompt === "string") added(body.prompt);
535
+ return told.join("\n\n").slice(0, INSTRUCTIONS_MAX);
536
+ };
537
+ // The tools the call offered its model: each by name, a provider's own tool by its type, and the
538
+ // declared ones as their schemas say: what each does and the inputs it requires.
539
+ const TOOLS_OFFERED = 60, DOES_MAX = 300, INPUTS_MAX = 12;
540
+ // `words`: what the person said, taken out of the instructions.
541
+ const toldOf = (body, words) => {
542
+ const provider = list(body.tools).filter((t) => !t.name && !t.function && typeof t.type === "string" && !/^(?:function|custom)$/.test(t.type)).map((t) => t.type);
543
+ const offered = [...new Set([...[...declaredIn(body)].sort(), ...provider])].slice(0, TOOLS_OFFERED);
544
+ const declared = [...new Map(toolDefs(body).filter((d) => d.schema).map((d) => [d.name, d])).values()].slice(0, TOOLS_OFFERED).map((d) => ({
545
+ name: d.name.slice(0, 80),
546
+ ...(d.does.trim() ? { does: d.does.trim().slice(0, DOES_MAX) } : {}),
547
+ requires: (Array.isArray(d.schema.required) ? d.schema.required : []).filter((k) => typeof k === "string").slice(0, INPUTS_MAX),
548
+ }));
549
+ const instructions = instructionsIn(body, words);
550
+ return { ...(instructions !== undefined ? { instructions } : {}), ...(offered.length ? { offered } : {}), ...(declared.length ? { declared } : {}) };
551
+ };
552
+ // Where models are served, by host, and the paths every OpenAI-shaped or vendor endpoint ends with.
553
+ const MODEL_HOST = /(?:^|\.)(?:openai\.com|anthropic\.com|fireworks\.ai|openrouter\.ai|groq\.com|mistral\.ai|together\.xyz|together\.ai|deepseek\.com|cohere\.ai|cohere\.com|perplexity\.ai|x\.ai|googleapis\.com|openai\.azure\.com|cognitiveservices\.azure\.com|amazonaws\.com|replicate\.com|huggingface\.co|cerebras\.ai|deepinfra\.com|novita\.ai|moonshot\.cn|dashscope\.aliyuncs\.com|bigmodel\.cn|ai-gateway\.vercel\.sh|gateway\.ai\.cloudflare\.com|helicone\.ai|portkey\.ai)$/i;
554
+ const MODEL_PATH = /\/(?:chat\/completions|completions|responses|messages|embeddings|language-model|embedding-model)$|:(?:generateContent|streamGenerateContent)|\/invoke(?:-with-response-stream)?$|\/api\/(?:chat|generate)$/i;
555
+ // An embedding call answers nobody, so where it was made names no path a reply came down.
556
+ const EMBEDDING = /\/(?:embeddings|embedding-model)$/i;
557
+ // The OpenAI shape's own paths name a model call wherever they are served: a gateway whose base URL
558
+ // has no /v1 (LiteLLM, a self-hosted proxy) is called at plain /chat/completions, and Ask A
559
+ // Question's five model calls a request went unseen. /messages and /responses stay behind a
560
+ // version or /api/, since other services name routes that way.
561
+ const OPENAI_PATH = /\/(?:chat\/completions|completions|embeddings)$/i;
562
+ // /messages and /responses name a model call only as the two APIs spell them. Mailgun's
563
+ // /v3/<domain>/messages, WhatsApp's /v19.0/<id>/messages and Discord's /api/v10/channels/<id>/messages
564
+ // end the same way and send a real message: each passed as a model call and was never held in a run
565
+ // (the release review of 2026-10-05). Vonage sends at the very path Anthropic answers on, so the
566
+ // senders that share it are named.
567
+ const NAMED_PATH = /\/(?:messages|responses)$/i;
568
+ const AS_SPELLED = /\/v1\/(?:messages|responses)$/i;
569
+ const MESSAGING_HOST = /(?:^|\.)(?:nexmo\.com|vonage\.com|telnyx\.com|twilio\.com|mailgun\.net|sinch\.com|messagebird\.com|bird\.com)$/i;
570
+ const isModelCall = (host, path) => {
571
+ const h = String(host || "").replace(/:\d+$/, "");
572
+ const p = String(path || "").split("?")[0];
573
+ if (/googleapis\.com$/i.test(h)) return /generativelanguage|aiplatform/i.test(h) && MODEL_PATH.test(p);
574
+ if (/amazonaws\.com$/i.test(h)) return /^bedrock/i.test(h);
575
+ if (MODEL_HOST.test(h) || OPENAI_PATH.test(p)) return true;
576
+ if (NAMED_PATH.test(p)) return AS_SPELLED.test(p) && !MESSAGING_HOST.test(h);
577
+ return MODEL_PATH.test(p) && /\/v\d|\/api\//.test(p);
578
+ };
579
+
580
+ const cookieNames = (v) => [].concat(v || []).map((c) => String(c).split(";")[0].split("=")[0].trim()).filter(Boolean).slice(0, 20);
581
+ const bodyOf = (ctx) => (ctx.bodyText ?? Buffer.concat(ctx.chunks).toString("utf8")).slice(0, MAX);
582
+
583
+ // The meter. One row per model call: host, model id, status, and the provider's own token counts.
584
+ const zlib = require("node:zlib");
585
+ const REPLY_MAX = 8 * 1024 * 1024;
586
+ const decoded = (buf, encoding) => {
587
+ try {
588
+ const e = String(encoding || "").toLowerCase();
589
+ return (e === "gzip" ? zlib.gunzipSync(buf) : e === "br" ? zlib.brotliDecompressSync(buf) : e === "deflate" ? zlib.inflateSync(buf) : buf).toString("utf8");
590
+ } catch { return ""; }
591
+ };
592
+ const parse = (text) => { try { return JSON.parse(text); } catch { return null; } };
593
+ // A model request is kept whole when it fits. A longer one keeps its shape and every value short
594
+ // enough to be a turn, its longest texts cut in their middle: every provider puts the newest turn
595
+ // last, and cutting the body's end kept ulaim's 25,000-token prompt without the person's words.
596
+ // ponytail: a turn longer than the cap that fits is cut too; keep turns whole if one is ever lost.
597
+ const fitted = (text) => {
598
+ if (text.length <= MAX) return text;
599
+ const body = parse(text);
600
+ for (let cap = MAX / 2; body && typeof body === "object" && cap >= 256; cap /= 2) {
601
+ const out = JSON.stringify(body, (_, v) => (typeof v === "string" && v.length > cap ? `${v.slice(0, cap / 2)} [...] ${v.slice(-cap / 2)}` : v));
602
+ if (out.length <= MAX) return out;
603
+ }
604
+ return text.slice(0, MAX);
605
+ };
606
+ const n = (v) => (Number.isInteger(v) && v > 0 ? v : 0);
607
+ // The shapes the providers answer in. promptTokens is the whole input, cache included, so totals add up.
608
+ const tokensOf = (u) => {
609
+ if (!u || typeof u !== "object") return null;
610
+ if (u.tokens && typeof u.tokens === "object") return tokensOf(u.tokens);
611
+ 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) };
612
+ 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) };
613
+ 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) }; }
614
+ // The AI SDK's own counts, as a gateway answers them: by kind in its newer protocol, flat before it.
615
+ if (u.inputTokens && typeof u.inputTokens === "object") return { promptTokens: n(u.inputTokens.total), cachedTokens: n(u.inputTokens.cacheRead), completionTokens: n(u.outputTokens && u.outputTokens.total) };
616
+ if ("inputTokens" in u && "cachedInputTokens" in u) return { promptTokens: n(u.inputTokens), cachedTokens: n(u.cachedInputTokens), completionTokens: n(u.outputTokens) };
617
+ if ("inputTokens" in u) { const read = n(u.cacheReadInputTokens); return { promptTokens: n(u.inputTokens) + read + n(u.cacheWriteInputTokens), cachedTokens: read, completionTokens: n(u.outputTokens) }; }
618
+ if ("promptTokenCount" in u) return { promptTokens: n(u.promptTokenCount), cachedTokens: n(u.cachedContentTokenCount), completionTokens: n(u.candidatesTokenCount) };
619
+ return null;
620
+ };
621
+ // The model stopped because it reached the output cap its request set, in each provider's words: its
622
+ // answer is cut short or, for a reasoning model, may never have been written.
623
+ const stoppedAtLimit = (e) => {
624
+ const r = e.response || e;
625
+ return (Array.isArray(e.choices) && e.choices.some((c) => c && c.finish_reason === "length"))
626
+ || (r.status === "incomplete" && Boolean(r.incomplete_details) && r.incomplete_details.reason === "max_output_tokens")
627
+ || e.stop_reason === "max_tokens" || Boolean(e.delta && e.delta.stop_reason === "max_tokens") || e.stopReason === "max_tokens"
628
+ || (Array.isArray(e.candidates) && e.candidates.some((c) => c && c.finishReason === "MAX_TOKENS"))
629
+ || (e.type === "finish" && (e.finishReason === "length" || Boolean(e.finishReason && e.finishReason.unified === "length")));
630
+ };
631
+ const reasoningOf = (u) => n((u.output_tokens_details && u.output_tokens_details.reasoning_tokens) || (u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens) || u.thoughtsTokenCount || (u.outputTokens && u.outputTokens.reasoning));
632
+ // The output cap the app's request set, in each provider's field.
633
+ const capOf = (b) => (b && typeof b === "object" ? n(b.max_tokens) || n(b.max_completion_tokens) || n(b.max_output_tokens) || n(b.maxOutputTokens) || n(b.generationConfig && b.generationConfig.maxOutputTokens) || n(b.inferenceConfig && b.inferenceConfig.maxTokens) || null : null);
634
+ // A stream spreads its counts over events (Anthropic's input in the first, output in the last);
635
+ // later values win, so the fold ends on the final figures.
636
+ const readReply = (type, text) => {
637
+ const events = /event-stream/i.test(type || "")
638
+ ? text.split("\n").filter((l) => l.startsWith("data:")).map((l) => parse(l.slice(5).trim()))
639
+ : [].concat(parse(text));
640
+ const usage = {};
641
+ let model = null;
642
+ let limit = false;
643
+ for (const e of events) {
644
+ if (!e || typeof e !== "object") continue;
645
+ const part = e.usage || (e.message && e.message.usage) || (e.response && e.response.usage) || e.usageMetadata;
646
+ if (part && typeof part === "object") Object.assign(usage, part);
647
+ model = e.model || (e.message && e.message.model) || (e.response && e.response.model) || e.modelVersion || (e.type === "response-metadata" && e.modelId) || model;
648
+ limit = limit || stoppedAtLimit(e);
649
+ }
650
+ return { tokens: tokensOf(Object.keys(usage).length ? usage : null), model, events, limit, reasoning: reasoningOf(usage) };
651
+ };
652
+ // The model that answered, when a gateway or proxy answered with another than the one asked for
653
+ // (a dated version of the same model is the same model).
654
+ const answeredBy = (asked, got) => (got && asked && !String(got).includes(asked) && !String(asked).includes(got) ? { answered: String(got).slice(0, 160) } : {});
655
+ // The model asked for, from what was sent; Gemini and Bedrock put it in the path instead.
656
+ const askedFor = (path, sent) => {
657
+ const body = parse(sent);
658
+ if (body && typeof body.model === "string") return body.model;
659
+ const m = /\/models\/([^/:]+):|\/model\/([^/]+)\/(?:invoke|converse)/.exec(path || "");
660
+ return m ? decodeURIComponent(m[1] || m[2]) : "";
661
+ };
662
+
663
+ // The AI SDK speaks its own format to a gateway (Vercel's AI Gateway): the model's id rides in a
664
+ // header, the conversation is a list under `prompt`, and tool calls and their results are typed
665
+ // parts. It is read as the chat format every reader here already knows; any other body is left as
666
+ // sent. Databuddy's calls went unpriced and unread: no model, no counts, no words.
667
+ const GATEWAY_MODEL = ["ai-language-model-id", "ai-model-id"];
668
+ const resultText = (o) => (o && typeof o === "object" && "value" in o ? (typeof o.value === "string" ? o.value : JSON.stringify(o.value)) : textOf(o));
669
+ const asChat = (sent, model) => {
670
+ const body = parse(sent);
671
+ if (!body || typeof body !== "object" || Array.isArray(body)) return sent;
672
+ const named = model && typeof body.model !== "string" ? { model: String(model).slice(0, 160) } : {};
673
+ const turns = Array.isArray(body.prompt) && body.prompt.length && body.prompt.every((m) => m && typeof m === "object" && typeof m.role === "string") ? body.prompt : null;
674
+ if (!turns) return Object.keys(named).length ? JSON.stringify({ ...body, ...named }) : sent;
675
+ const messages = [];
676
+ for (const m of turns) {
677
+ const parts = list(m.content);
678
+ if (m.role === "tool") { for (const p of parts) if (p.type === "tool-result") messages.push({ role: "tool", tool_call_id: p.toolCallId, name: p.toolName, content: resultText(p.output) }); continue; }
679
+ const calls = m.role === "assistant" ? parts.filter((p) => p.type === "tool-call").map((p) => ({ id: p.toolCallId, type: "function", function: { name: p.toolName, arguments: typeof p.input === "string" ? p.input : JSON.stringify(p.input ?? {}) } })) : [];
680
+ const text = typeof m.content === "string" ? m.content : parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("\n");
681
+ messages.push({ role: m.role, content: text, ...(calls.length ? { tool_calls: calls } : {}) });
682
+ }
683
+ const { prompt: _turns, ...rest } = body;
684
+ return JSON.stringify({ ...rest, ...named, messages });
685
+ };
686
+
687
+ // The exchanges and model calls of one process, written as rows by `row`. `onTurn`: a request the
688
+ // run tagged has arrived. `onEnded(ctx, steps)`: a request has been answered, before its row is written.
689
+ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} }) {
690
+ // The customer's own rule sentences, written beside the trace by the run, so each model call can
691
+ // say which of them its prompt carried: a rule is then asked only of a reply whose call was told
692
+ // it. Absent file, nothing is claimed either way. Reloaded when the file changes.
693
+ const RULES = rulesFile;
694
+ let rules = null, rulesAt = -1;
695
+ const rulesNow = () => {
696
+ if (!RULES) return null;
697
+ try {
698
+ const at = fs.statSync(RULES).mtimeMs;
699
+ if (at !== rulesAt) {
700
+ rulesAt = at;
701
+ const list = JSON.parse(fs.readFileSync(RULES, "utf8"));
702
+ rules = Array.isArray(list)
703
+ ? list.filter((r) => r && typeof r.id === "string" && typeof r.text === "string").map((r) => ({ id: r.id, parts: partsOf(r.text) })).filter((r) => r.parts.length)
704
+ : [];
705
+ }
706
+ } catch { /* not written yet, or gone */ }
707
+ return rules;
708
+ };
709
+ const rulesIn = (sent) => {
710
+ const list = rulesNow();
711
+ // An empty list claims nothing either way: "carried none" needs rules to carry.
712
+ if (!list || !list.length) return undefined;
713
+ const body = norm(sent);
714
+ return list.filter((r) => r.parts.every((p) => body.includes(p))).map((r) => r.id);
715
+ };
716
+
717
+ // What the person said to the app: every text value requests carried in their bodies and
718
+ // addresses, long enough to tell apart, the newest kept. A model call's prompt is told apart from
719
+ // them whichever side keeps the conversation. Kept by value, not by request: with trials side by
720
+ // side a trial's opening message sat up to 282 requests back, and a window of the last 32 would
721
+ // have told it as the app's own words on 114 of 132 later turns.
722
+ // Bounded by characters as well as count: a raw body is one value, and 2000 of them at 64 KB would
723
+ // hold about 128 MB inside the app.
724
+ const HEARD_KEPT = 2000, HEARD_CHARS = 2_000_000, HEARD_MAX = 200, SAID_ASCII = 8, SAID_OTHER = 4;
725
+ const heard = new Set();
726
+ let heardChars = 0;
727
+ const heardIn = (ctx) => {
728
+ const out = new Set();
729
+ const add = (s) => {
730
+ const t = typeof s === "string" ? s.trim() : "";
731
+ if (out.size < HEARD_MAX && t.length >= (/[^\x00-\x7F]/.test(t) ? SAID_OTHER : SAID_ASCII) && /\p{L}/u.test(t)) out.add(t);
732
+ };
733
+ const walk = (v, depth) => { if (typeof v === "string") add(v); else if (v && typeof v === "object" && depth < 12) for (const x of Object.values(v)) walk(x, depth + 1); };
734
+ const raw = bodyOf(ctx);
735
+ const json = parse(raw);
736
+ if (json && typeof json === "object") walk(json, 0);
737
+ else if (/^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/.test(raw)) for (const v of new URLSearchParams(raw).values()) add(v);
738
+ else add(raw);
739
+ for (const v of new URLSearchParams(ctx.path.split("?")[1] || "").values()) add(v);
740
+ return out;
741
+ };
742
+ const hear = (ctx) => {
743
+ try {
744
+ for (const w of heardIn(ctx)) {
745
+ if (heard.delete(w)) heardChars -= w.length;
746
+ heard.add(w);
747
+ heardChars += w.length;
748
+ }
749
+ for (const w of heard) {
750
+ if (heard.size <= HEARD_KEPT && heardChars <= HEARD_CHARS) break;
751
+ heard.delete(w);
752
+ heardChars -= w.length;
753
+ }
754
+ } catch { /* nothing heard */ }
755
+ };
756
+ // An exchange is kept when the request makes its first model call, for the first few requests
757
+ // per route, and written once the app has answered. Model calls and tool rows carry the request's
758
+ // id whether kept or not; the command joins them to the exchange it has.
759
+ // The person's requests, ours (a run's turns, a knock) and the canary's each have their own count,
760
+ // so a run that knocked first never takes the places of the requests that prove a door. Every one
761
+ // of the person's is kept: their newest is the sign-in and the proof a run takes, and the command
762
+ // keeps only the newest few per door.
763
+ // ponytail: the trace file grows by one row per request of the person's; a cap per route if a person's own load test fills it.
764
+ const EXCHANGES_PER_ROUTE = 4, CANARY_PER_ROUTE = 30, ROUTES_KEPT = 1000, SENT_MAX = 4, MODEL_WORDS = 4000;
765
+ const slotOf = (turn) => (!turn ? { cls: "", max: Infinity } : /^canary:/.test(turn) ? { cls: "canary ", max: CANARY_PER_ROUTE } : { cls: "ours ", max: EXCHANGES_PER_ROUTE });
766
+ const perRoute = new Map();
767
+ // One route whatever id its path carries: a conversation in the path is a new path every trial.
768
+ const routeKey = (method, path) => `${method} ${path.split("?")[0].split("/").map((s) => (/^\d+$/.test(s) || (s.length >= 8 && /\d/.test(s)) ? "{id}" : s)).join("/")}`;
769
+ let lastExchange = 0;
770
+ const { makeTied, sentencesIn } = require("./tied.cjs");
771
+ const tied = makeTied({ norm, bodyOf, replyOf: (ctx) => (ctx.reply ? ctx.reply.text.slice(0, MAX) : "") });
772
+ const requestCtx = (method, path, headers, turn) => {
773
+ if (turn) onTurn();
774
+ const ctx = { id: `${process.pid}.${++lastExchange}`, at: Date.now(), method, path, headers, chunks: [], size: 0, noted: false, kept: false, sent: [], turn, sites: [], traced: 0 };
775
+ tied.opened(ctx);
776
+ return ctx;
777
+ };
778
+ // The request a model call is pinned to, returned so the call's row carries it: the one it was
779
+ // made inside, else the request that asked for it (lib/tied.cjs), else `alone`, the one request
780
+ // open when a caller outside the app sees that much and no more.
781
+ const note = (ctx, sent, alone) => {
782
+ tied.sent(sent || "");
783
+ if (!ctx) { try { ctx = tied.pinned(String(sent || "")) ?? (alone && tied.inside(alone, String(sent || ""))); } catch { ctx = undefined; } }
784
+ else { try { ctx = tied.inside(ctx, String(sent || "")); } catch { /* its own, as before */ } }
785
+ if (!ctx) return ctx;
786
+ if (!ctx.noted) {
787
+ ctx.noted = true;
788
+ const { cls, max } = slotOf(ctx.turn);
789
+ const key = cls + routeKey(ctx.method, ctx.path);
790
+ const n = perRoute.get(key) || 0;
791
+ if (n < max && (n || perRoute.size < ROUTES_KEPT)) { perRoute.set(key, n + 1); ctx.kept = true; }
792
+ }
793
+ if (ctx.kept && ctx.sent.length < SENT_MAX) ctx.sent.push(fitted(String(sent || "")));
794
+ return ctx;
795
+ };
796
+ const rowOf = (ctx) => {
797
+ const { reply } = ctx;
798
+ return { at: ctx.at, ms: ctx.ended - ctx.at, method: ctx.method, path: ctx.path, body: bodyOf(ctx), status: reply.status, type: String(reply.type || ""), ...(reply.writes ? { writes: reply.writes } : {}), reply: reply.text.slice(0, MAX), ...(reply.cookies.length ? { cookies: reply.cookies } : {}), ...(reply.cut ? { cut: true } : {}) };
799
+ };
800
+ // An answer that came on a second request is written as that request's row under the id of the
801
+ // request that asked, with the requests of its turn in `after` (the one that asked last) and the
802
+ // headers they were sent with: one line, so a reader never sees half a turn.
803
+ const answered = (ctx, steps = []) => {
804
+ const asker = steps.at(-1) || ctx;
805
+ if (!asker.kept || asker.written) return;
806
+ asker.written = true;
807
+ const turn = ctx.turn || asker.turn;
808
+ row({ ex: asker.id, ...rowOf(ctx), headers: Object.assign({}, ...steps.map((s) => s.headers), ctx.headers), ...(turn ? { turn } : {}), sent: asker.sent, ...(steps.length ? { after: steps.map(rowOf) } : {}) });
809
+ };
810
+ // A request answered later by its model is written once its calls have gone quiet, marked
811
+ // `later`, in the time its model took to answer it; a run's turn also gets the model's last words,
812
+ // the answer the command hands the run (lib/replay.mjs). Only an answer settles it: a call that
813
+ // came back 2xx with no words (a model asking for a tool) is a step, and the next call is waited
814
+ // for, as long as the run waits.
815
+ // ponytail: a fixed settle; after a step that answers in words, more than this of the app's own
816
+ // work before the next call cuts the answer at that step. A call whose words match only a request
817
+ // already settled is the evidence: a settle measured per door when one is seen.
818
+ const SETTLE_MS = 5000;
819
+ // The turn's answer is written once the calls are quiet even while a read the hook took for a fetch
820
+ // of it is open (the person's page polling the sender): the answer is the model's words either way.
821
+ // The exchange is written as answered later only when no fetch came to complete it (tied.settled).
822
+ const answeredLater = (ctx) => {
823
+ const said = ctx.said;
824
+ if (ctx.turn && !ctx.turnAnswered) { ctx.turnAnswered = true; row({ answer: { ex: ctx.id, turn: ctx.turn, status: said.status, reply: said.words } }); }
825
+ if (tied.settled(ctx) && ctx.kept) row({ ex: ctx.id, ...rowOf(ctx), ms: said.at - ctx.at, later: true, headers: ctx.headers, ...(ctx.turn ? { turn: ctx.turn } : {}), sent: ctx.sent });
826
+ };
827
+ const settle = (ctx) => {
828
+ clearTimeout(ctx.settling);
829
+ const said = ctx.said;
830
+ if (!said || (!said.words && said.status >= 200 && said.status < 300)) return;
831
+ ctx.settling = setTimeout(() => { try { if (!ctx.callsOpen && !ctx.waiting) answeredLater(ctx); } catch { /* the app goes on */ } }, SETTLE_MS);
832
+ if (ctx.settling.unref) ctx.settling.unref();
833
+ };
834
+ // Once the app has answered and a streamed body has been read whole.
835
+ const ended = (ctx, reply) => {
836
+ if (ctx.ended) return;
837
+ ctx.ended = Date.now();
838
+ ctx.reply = reply;
839
+ const done = () => {
840
+ hear(ctx);
841
+ const steps = tied.closed(ctx);
842
+ onEnded(ctx, steps);
843
+ if (steps) answered(ctx, steps);
844
+ else if (ctx.late) settle(ctx);
845
+ else if (!ctx.callsOpen) answered(ctx);
846
+ // A message taken and answered at once with no model call inside it: its model may answer later,
847
+ // in a process this hook is not in. The command names the endpoint (lib/replay.mjs receipts).
848
+ if (!ctx.noted && !steps && ctx.method !== "GET" && reply.status >= 200 && reply.status < 300 && sentencesIn(bodyOf(ctx)).length) {
849
+ row({ receipt: { ex: ctx.id, at: ctx.at, ms: ctx.ended - ctx.at, method: ctx.method, path: ctx.path, status: reply.status, ...(ctx.turn ? { turn: ctx.turn } : {}) } });
850
+ }
851
+ };
852
+ if (ctx.body) ctx.body.then(done);
853
+ else done();
854
+ };
855
+ const meter = ({ host, path, sent, status, type, body, ctx, t0, caller }) => {
856
+ try {
857
+ const reply = readReply(type, String(body || "").slice(0, REPLY_MAX));
858
+ const rules = rulesIn(sent);
859
+ const provided = providerIn(sent, reply.events);
860
+ const answered = [...(toolsIn(sent) || []), ...provided.answers];
861
+ const tools = answered.length ? answered : undefined;
862
+ const called = calledIn(sent, reply.events, provided.calls);
863
+ const passages = passagesIn(sent);
864
+ const turn = ctx && ctx.turn;
865
+ const asked = parse(sent);
866
+ const told = asked && typeof asked === "object" && !EMBEDDING.test(path) ? toldOf(asked, ctx ? new Set([...heard, ...heardIn(ctx)]) : heard) : {};
867
+ tied.done(ctx);
868
+ if (ctx && ctx.late) {
869
+ if (!EMBEDDING.test(path)) ctx.said = { status, words: replyText(reply.events).slice(0, MODEL_WORDS), at: Date.now() };
870
+ settle(ctx);
871
+ }
872
+ row({ call: { at: Date.now(), ms: Date.now() - t0, host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), ...answeredBy(askedFor(path, sent), reply.model), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(EMBEDDING.test(path) ? { embedding: true } : {}), ...(ctx ? { ex: ctx.id } : {}), ...(ctx && (ctx.kept || ctx.turn) ? { reply: replyText(reply.events).slice(0, MODEL_WORDS) } : {}), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}), ...(reply.limit ? { limit: { cap: capOf(asked), reasoning: reply.reasoning } } : {}), ...told } });
873
+ } catch { /* the command is gone */ }
874
+ };
875
+ return { tied, requestCtx, note, ended, meter };
876
+ }
877
+
878
+ module.exports = {
879
+ MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, MODEL_HOST,
880
+ scrubbed, list, clipped, argsText, turnOf, toolText, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames,
881
+ fitted, makeRecorder, asChat, GATEWAY_MODEL,
882
+ };