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/proof.mjs ADDED
@@ -0,0 +1,1014 @@
1
+ // What a door is proven to do, from the exchanges the hook recorded inside the app: requests that
2
+ // reached a model, what the app answered and the model calls on the way. Everything here is read off
3
+ // those rows; nothing is guessed from code. The shape is the backend's DoorProof
4
+ // (src/local/door-proof.ts). Pure: the one lookup into the repository is handed in as `find`.
5
+ import { documentAt, readable } from "./document.mjs";
6
+
7
+ const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as|x-cortad-turn)$/i;
8
+ // Headers every client sends: never what holds a conversation.
9
+ const PLAIN_HEADER = /^(?:accept(?:-.*)?|content-.*|user-agent|origin|referer|cookie|cache-control|pragma|priority|dnt|sec-.*|x-forwarded-.*|x-real-ip|if-.*)$/i;
10
+ // A provider request's settings, as every provider names them: not what the model was told.
11
+ const SETTING = /^(?:model|role|type|id|object|stream|tool_choice|response_format|stop|user|name|tool_call_id|call_id|index|encoding_format|service_tier|reasoning_effort|modalities)$/;
12
+ // The last ask as asked, clipped; the reply whole, to the bound the run keeps a model's words to.
13
+ const SAMPLE = 600, SAMPLE_REPLY = 4000;
14
+ const PROBLEMS_MAX = 12;
15
+
16
+ const str = (v) => (typeof v === "string" ? v : "");
17
+ const arr = (v) => (Array.isArray(v) ? v : []);
18
+ const parse = (s) => { try { return JSON.parse(s); } catch { return undefined; } };
19
+ const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}...` : s);
20
+ // A provider's message quoted to the person, cut at a space before `n` and never inside a link, which is
21
+ // kept whole up to LINK_MAX: OpenAI's quota error ends on the page that says what to do about it.
22
+ const LINK_MAX = 200;
23
+ const clipAtWord = (s, n) => {
24
+ if (s.length <= n) return s;
25
+ const link = [...s.matchAll(/https?:\/\/\S+/g)].find((m) => m.index < n && m.index + m[0].length > n);
26
+ const end = link ? Math.min(link.index + link[0].length, link.index + LINK_MAX) : s.lastIndexOf(" ", n);
27
+ return end >= s.length ? s : `${s.slice(0, end > 0 ? end : n)}...`;
28
+ };
29
+ // Read once per exchange: a prompt can run to a quarter of a megabyte and is asked of many times.
30
+ const once = (fn) => { const seen = new WeakMap(); return (ex) => { if (!seen.has(ex)) seen.set(ex, fn(ex)); return seen.get(ex); }; };
31
+ const median = (xs) => { const s = xs.filter(Number.isFinite).sort((a, b) => a - b); return s.length ? s[Math.floor((s.length - 1) / 2)] : null; };
32
+ // The hook's own normal form: JSON escapes read as the characters they stand for, case and spacing folded.
33
+ export const norm = (s) => String(s ?? "")
34
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
35
+ .replace(/\\[nrt]/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\")
36
+ .toLowerCase().replace(/\s+/g, " ").trim();
37
+ // A value made to name one thing: a number of six digits or more, a uuid, hex, or a run of letters
38
+ // and digits too long to be a word. "gpt-4o-mini" is none of them.
39
+ const SESSION_NAME = /^(?:session|conversation|thread|chat|dialog(?:ue)?|convo)(?:[_-]?(?:id|key|token|uuid))?$|^(?:id|uuid)$/i;
40
+ const idLike = (v) => /^\d{6,}$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-|^[0-9a-f]{12,}$/i.test(v) || v.split(/[-_.:]/).some((s) => s.length >= 12 && /\d/.test(s) && /[a-z]/i.test(s));
41
+
42
+ // Every scalar leaf, with its path: object keys, and list positions as numbers.
43
+ function leaves(v, at = [], out = [], max = 400) {
44
+ if (out.length >= max || at.length > 10) return out;
45
+ if (typeof v === "string" || typeof v === "number") out.push({ at, value: String(v) });
46
+ else if (Array.isArray(v)) v.forEach((x, i) => leaves(x, [...at, i], out, max));
47
+ else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) leaves(x, [...at, k], out, max);
48
+ return out;
49
+ }
50
+ // A leaf's field: its keys up to the first list. A leaf inside a list is part of that list.
51
+ const fieldOf = (at) => { const i = at.findIndex((k) => typeof k === "number"); return (i < 0 ? at : at.slice(0, i)).join("."); };
52
+ const inList = (at) => at.some((k) => typeof k === "number");
53
+
54
+ // ---- rows to exchanges
55
+
56
+ const request = (r) => (r && typeof r === "object" && typeof r.method === "string" && str(r.path).startsWith("/") ? {
57
+ at: Number(r.at) || 0, ms: Number(r.ms) || 0, method: r.method.toUpperCase(), path: r.path, body: str(r.body), status: Number(r.status) || 0,
58
+ reply: str(r.reply), cookies: arr(r.cookies).filter((c) => typeof c === "string"), ...(r.cut === true ? { cut: true } : {}),
59
+ } : null);
60
+
61
+ // An answer that came on a second request is one exchange: the request that asked is the door, the
62
+ // second step carried the reply, and the requests sent just before it in the same turn go first.
63
+ function turnOf(ex, steps) {
64
+ const asked = steps.at(-1);
65
+ if (!asked) return ex;
66
+ return {
67
+ ...ex, at: asked.at, ms: ex.at + ex.ms - asked.at, method: asked.method, path: asked.path, body: asked.body,
68
+ cookies: [...new Set([...steps.flatMap((s) => s.cookies), ...ex.cookies])],
69
+ answer: { method: ex.method, path: ex.path, asked: asked.reply }, before: steps.slice(0, -1),
70
+ };
71
+ }
72
+
73
+ // The app's own lines that wrote a reply ("path:line"), each with the event it sent there, when the
74
+ // reply names its events, and the first bytes it wrote there.
75
+ const AT = /^[^\n\r\t]{1,300}:\d{1,7}$/;
76
+ const EVENT = /^[\w.:-]{1,40}$/;
77
+ export const sitesOf = (v) => arr(v).filter((s) => s && typeof s.at === "string" && AT.test(s.at) && typeof s.text === "string").slice(0, 16)
78
+ .map((s) => ({ at: s.at, ...(typeof s.event === "string" && EVENT.test(s.event) ? { event: s.event } : {}), text: s.text.slice(0, 300) }));
79
+
80
+ // Trace rows come from inside a process this command does not control: every field is checked.
81
+ export function exchangesOf(rows) {
82
+ const byId = new Map();
83
+ const slot = (id) => { if (!byId.has(id)) byId.set(id, { calls: [], deps: [], sites: [] }); return byId.get(id); };
84
+ for (const r of rows) {
85
+ if (!r || typeof r !== "object") continue;
86
+ if (typeof r.ex === "string" && request(r)) slot(r.ex).row = r;
87
+ else if (r.call && typeof r.call.ex === "string") slot(r.call.ex).calls.push(r.call);
88
+ else if (r.dep && typeof r.dep.ex === "string") slot(r.dep.ex).deps.push(r.dep);
89
+ else if (r.sites && typeof r.sites.ex === "string") slot(r.sites.ex).sites = sitesOf(r.sites.list);
90
+ }
91
+ return [...byId.values()].filter((x) => x.row).map(({ row, calls, deps, sites }) => turnOf({
92
+ id: row.ex, ...request(row), headers: row.headers && typeof row.headers === "object" ? row.headers : {}, trial: typeof row.turn === "string", turn: str(row.turn),
93
+ sent: arr(row.sent).filter((s) => typeof s === "string"), type: str(row.type), writes: Number(row.writes) || 0, calls, deps, sites,
94
+ ...(row.later === true ? { later: true } : {}),
95
+ }, arr(row.after).map(request).filter(Boolean))).sort((a, b) => a.at - b.at);
96
+ }
97
+
98
+ // A request Cortad sent carries a turn tag, the person's own never does. A door is proven by the
99
+ // person's requests; ours prove it only when no request of theirs reached it, and the proof says so.
100
+ export const provingOf = (exchanges) => { const own = exchanges.filter((ex) => !ex.trial); return own.length ? own : exchanges; };
101
+ // The three requests Cortad sends through a door after each proof (backend src/local/canary.ts):
102
+ // "a" a conversation of two turns, "b" a new one. Never part of the door's proof.
103
+ const CANARY = /^canary:(\d{1,10}):([ab]):([12])$/;
104
+ export const isCanary = (ex) => CANARY.test(ex.turn ?? "");
105
+ // Cortad's test request to an endpoint before any run (lib/reach.mjs).
106
+ const REACH = /^reach:/;
107
+
108
+ // ---- which door an exchange came through
109
+
110
+ const routeRe = (template) => new RegExp(`^${template.replace(/[.*+?^$()|[\]\\]/g, "\\$&").replace(/\{[^/{}]+\}/g, "([^/]+)")}/?$`);
111
+
112
+ // The app's own route that answered, from its framework's table; without one, the path, with a
113
+ // segment that reads as an id standing for any.
114
+ export function doorOf(ex, routes = []) {
115
+ const bare = ex.path.split("?")[0];
116
+ const fits = routes.filter((r) => r && r.method === ex.method && typeof r.path === "string" && routeRe(r.path).test(bare))
117
+ .sort((a, b) => (a.path.match(/\{/g) ?? []).length - (b.path.match(/\{/g) ?? []).length);
118
+ const path = fits[0]?.path ?? bare.split("/").map((s) => (/^\d+$/.test(s) || idLike(s) ? "{id}" : s)).join("/");
119
+ return { method: ex.method, path, key: `${ex.method} ${path}` };
120
+ }
121
+
122
+ // The value each {name} of the template took in this exchange's path.
123
+ const paramsOf = (template, path) => {
124
+ const names = [...template.matchAll(/\{([^/{}]+)\}/g)].map((m) => m[1]);
125
+ const m = routeRe(template).exec(path.split("?")[0]);
126
+ return m ? Object.fromEntries(names.map((n, i) => [n, m[i + 1]])) : {};
127
+ };
128
+
129
+ // What each {name} of the route took in the person's own request, which every trial sends again.
130
+ // A value that would move the request to another route is left out (the backend refuses it too).
131
+ const PATH_VALUE = /^(?!\.\.?$)[^/?#\s]{1,200}$/;
132
+ const pathValuesOf = (template, path) => {
133
+ const kept = Object.entries(paramsOf(template, path)).filter(([k, v]) => /^[^/{}\s]{1,100}$/.test(k) && PATH_VALUE.test(v ?? ""));
134
+ return kept.length ? { pathValues: Object.fromEntries(kept.slice(0, 20)) } : {};
135
+ };
136
+
137
+ // ---- what went in, what the model was told, what came back
138
+
139
+ const headerOf = (headers, name) => Object.entries(headers).find(([k]) => k.toLowerCase() === name)?.[1];
140
+ const bodyOf = once((ex) => {
141
+ const json = parse(ex.body);
142
+ if (json && typeof json === "object") return json;
143
+ if (/x-www-form-urlencoded/i.test(str(headerOf(ex.headers, "content-type")))) return Object.fromEntries(new URLSearchParams(ex.body));
144
+ return null;
145
+ });
146
+ // Every string the app sent its models in this exchange, settings left out.
147
+ const promptOf = once((ex) => norm(ex.sent.map((s) => {
148
+ const v = parse(s);
149
+ return v && typeof v === "object" ? leaves(v, [], [], 5000).filter((l) => !SETTING.test(String(l.at.at(-1)))).map((l) => l.value).join("\n") : s;
150
+ }).join("\n")));
151
+ const generation = (ex) => ex.calls.filter((c) => !c.embedding);
152
+ const answeredCalls = (ex) => generation(ex).filter((c) => typeof c.reply === "string" && c.reply);
153
+ // The words the model answered with on the call the app's reply carries most of, the last such call
154
+ // on a tie: LearnHouse answers in one call and writes follow-up suggestions in a second, and taking
155
+ // the last call's words showed the suggestions as the reply and missed that the next turn went on
156
+ // from the answer.
157
+ const tokensOf = (s) => str(s).toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) ?? [];
158
+ const modelWords = (ex) => {
159
+ const calls = answeredCalls(ex);
160
+ if (calls.length < 2) return str(calls.at(-1)?.reply);
161
+ const body = new Set(tokensOf(ex.reply));
162
+ const carried = (c) => tokensOf(c.reply).filter((t) => body.has(t)).length;
163
+ return str(calls.reduce((best, c) => (carried(c) >= carried(best) ? c : best)).reply);
164
+ };
165
+ // A stream that carried no counts is priced from its text, four characters to a token, and marked so:
166
+ // yunqiao's quote had no figure at all for a model its provider publishes a price for.
167
+ const estimatedTokens = (exchanges) => {
168
+ const prompt = median(exchanges.map((ex) => Math.ceil(promptOf(ex).length / 4)));
169
+ return prompt ? { prompt, completion: median(exchanges.map((ex) => Math.ceil(modelWords(ex).length / 4))) ?? 0, estimated: true } : null;
170
+ };
171
+ // How much of the pieces the model said, by length.
172
+ const overlap = (pieces, words) => pieces.reduce((n, p) => { const t = norm(p); return n + (t && words.includes(t) ? p.length : 0); }, 0);
173
+
174
+ // The reply read whole: JSON at the leaf the model's words sit in (the longest leaf when they are not
175
+ // known), a stream put back together from its events, text as it is. A request answered later, by
176
+ // its model after its own reply went out, was answered with the model's last words.
177
+ export const replyOf = once((ex) => {
178
+ if (ex.later) return { text: modelWords(ex), path: null, stream: false };
179
+ const words = norm(modelWords(ex));
180
+ const whole = parse(ex.reply);
181
+ const stream = /event-stream|ndjson|jsonl|stream/i.test(ex.type) || (ex.writes > 1 && whole === undefined);
182
+ if (!stream && whole && typeof whole === "object") {
183
+ const best = leaves(whole).filter((l) => /\D/.test(l.value))
184
+ .map((l) => ({ l, score: words ? overlap([l.value], words) : l.value.length }))
185
+ .sort((a, b) => b.score - a.score || b.l.value.length - a.l.value.length)[0];
186
+ // The leaf names the field the run reads; the reply is the document it sits in, when it sits in one.
187
+ const text = best ? documentAt(whole, best.l.at) ?? readable(best.l.value) : "";
188
+ return { text, path: best ? best.l.at.join(".") : null, stream: false };
189
+ }
190
+ if (!stream) return { text: readable(typeof whole === "string" ? whole : ex.reply), path: null, stream: false };
191
+ const { events, loose } = eventsOf(ex.reply);
192
+ // The words of the call the reply carries most of (modelWords): the last call of an agent can be a
193
+ // memory write, and a second call can write follow-up suggestions, neither of them the answer.
194
+ return threaded(events, words) ?? { text: readable(assembled(events, loose, words, ex.reply)), path: null, stream: true };
195
+ });
196
+
197
+ // Ours, for a stream that ended on an error and said nothing: never quoted as the app's words.
198
+ const NO_TEXT = "the reply's stream finished on an error before any text";
199
+ const withError = (error) => (error === NO_TEXT ? "and its reply ended on an error before any text" : `with an error: "${clipAtWord(error, 160)}"`);
200
+ // A stream's events: SSE data lines, JSON lines, or a JSON value behind a short prefix per line.
201
+ // An SSE event named for the model's reasoning carries its thinking, never the answer (yunqiao's
202
+ // stream sends `event: thinking` before its answer). `errors`: what its events say as an error, read
203
+ // from its data lines when it has any, else only when every line is an event, as the run reads a
204
+ // stream (backend src/customer/endpoint.ts streamFrames).
205
+ const THINKING = /reason|think/i;
206
+ function eventsOf(raw) {
207
+ const events = [];
208
+ const loose = [];
209
+ const errors = [];
210
+ const sse = /^data:/m.test(raw);
211
+ let named = "";
212
+ for (const line of raw.split(/\r?\n/)) {
213
+ if (!line.trim()) { named = ""; continue; }
214
+ const event = /^event:\s*(.*)$/.exec(line);
215
+ if (event) { named = event[1].trim(); continue; }
216
+ if (/^(?:id|retry):|^:/.test(line) || THINKING.test(named)) continue;
217
+ const data = line.startsWith("data:") ? line.slice(5).replace(/^ /, "") : line;
218
+ if (data.trim() === "[DONE]") continue;
219
+ let v = parse(data);
220
+ const part = v === undefined ? /^(\w{1,3}):/.exec(data) : null;
221
+ if (part) v = parse(data.slice(part[0].length));
222
+ // The AI SDK data stream says its error as part 3.
223
+ const said = !sse || line.startsWith("data:") ? eventError(v, part?.[1] === "3" ? "error" : named, data) : "";
224
+ if (said) errors.push(said);
225
+ if (v === undefined || v === null || typeof v !== "object" && typeof v !== "string") loose.push(data);
226
+ else events.push(v);
227
+ }
228
+ // A stream that finishes on an error with no word written is the app's own error (one rule with the
229
+ // backend's readError): the AI SDK ends a reply whose model call failed with finishReason "error".
230
+ const is = (v, type) => v && typeof v === "object" && v.type === type;
231
+ if (!errors.length && events.some((v) => is(v, "finish") && v.finishReason === "error") && !events.some((v) => is(v, "text-delta") && str(v.delta).trim())) errors.push(NO_TEXT);
232
+ return { events, loose, errors: sse || !loose.length ? errors : [] };
233
+ }
234
+
235
+ // What one event says as an error, as the run reads it (backend endpoint.ts readError): its error field,
236
+ // a string or {message}; the words of an event typed or named "error"; whatever an event named "error"
237
+ // carries. An event of another type with an error beside it is a note: resumeforge's
238
+ // {"type":"sources","error":"no public sources found"} went on to a full answer.
239
+ function eventError(v, named, data) {
240
+ const o = v && typeof v === "object" ? v : {};
241
+ if (named !== "error" && typeof o.type === "string" && !/error|fail/i.test(o.type)) return "";
242
+ const e = o.error ?? (o.type === "error" || named === "error" ? o.message ?? o.detail ?? o.errorText : undefined);
243
+ const said = (typeof e === "string" ? e : str(e?.message)).trim();
244
+ return said || (named === "error" ? (typeof v === "string" ? v : data).trim() : "");
245
+ }
246
+
247
+ // A path read the way the run reads one: `-1` is a list's last entry.
248
+ const at = (v, path) => path.reduce((o, k) => (o === null || typeof o !== "object" ? undefined : Array.isArray(o) && k === -1 ? o.at(-1) : o[k]), v);
249
+ // A concrete path with the last entry of a list of turns written -1, so it still names the newest
250
+ // turn when the list holds more of them. A list of anything else is positional and keeps its index.
251
+ const newest = (v, path) => path.map((k, i) => { const list = at(v, path.slice(0, i)); return typeof k === "number" && isTurns(list) && k === list.length - 1 ? -1 : k; });
252
+
253
+ // A stream whose events carry the whole thread (a queue's completed event holds every turn so far)
254
+ // has the reply at one entry of a list, not spread over the events: the leaf the model's words match
255
+ // best, named by its path, and read at that path in every event. Null for any other stream.
256
+ function threaded(events, words) {
257
+ if (!words) return null;
258
+ let best = null;
259
+ for (const v of events) {
260
+ if (typeof v !== "object") continue;
261
+ for (const l of leaves(v)) {
262
+ const score = overlap([l.value], words);
263
+ if (score && score >= (best?.score ?? 0)) best = { score, path: newest(v, l.at) };
264
+ }
265
+ }
266
+ // A list of records that is not a conversation (a quiz's questions) is a document, read whole.
267
+ const thread = best?.path.some((k, i) => { const list = arr(at(events.find((v) => at(v, best.path) !== undefined), best.path.slice(0, i))); return typeof k === "number" && list.length > 1 && (k === -1 || isTurns(list)); });
268
+ if (!thread) return null;
269
+ const pieces = events.map((v) => at(v, best.path)).filter((s) => typeof s === "string");
270
+ return { text: pieces.at(-1) ?? "", path: best.path.join("."), stream: true };
271
+ }
272
+
273
+ // Each JSON event's text is grouped by where it sits, and the group the model's words run through is
274
+ // the reply.
275
+ // An event of the app's own stream that carries the model's reasoning or a tool's traffic, never the
276
+ // answer: the AI SDK sends reasoning and text as the same `delta`, told apart only by their type.
277
+ const NOT_ANSWER = /reason|think|tool|^(?:status|progress|ping|heartbeat)$/i;
278
+ // A leaf that names its event and says nothing: the type, an id, how the stream finished. A reply
279
+ // that ran its tools and ended with no words is nothing but these, and joined together they were
280
+ // shown as what Databuddy answered ("startstart-stepfinish-step").
281
+ const LABEL = /^(?:type|event|kind|role|model|name|object|status|finishReason|finish_reason|id|uuid|sequence|index)$|(?:Id|_id)$/;
282
+ function assembled(events, loose, words, raw) {
283
+ const groups = new Map();
284
+ for (const v of events) {
285
+ if (v && typeof v === "object" && NOT_ANSWER.test(str(v.type))) continue;
286
+ for (const l of typeof v === "string" ? [{ at: [], value: v }] : leaves(v)) {
287
+ // An OpenAI-compatible stream passed straight through carries the model's thinking beside its
288
+ // answer, in `delta.reasoning_content`.
289
+ if (l.at.some((k) => typeof k === "string" && THINKING.test(k))) continue;
290
+ if (LABEL.test(String(l.at.at(-1) ?? ""))) continue;
291
+ const key = l.at.map((k) => (typeof k === "number" ? "[]" : k)).join(".");
292
+ if (!groups.has(key)) groups.set(key, []);
293
+ groups.get(key).push(l.value);
294
+ }
295
+ }
296
+ // Without the model's words, the reply is the group that reads as language: ids and event names
297
+ // repeat on every event and outweigh the words by length, but never hold a space or a non-Latin letter.
298
+ const prose = (pieces) => pieces.filter((v) => /\s|[^\x00-\x7F]/.test(v)).join("").length;
299
+ // With nothing that reads as language, a one-word answer still stands; a count beside it does not.
300
+ const best = [...groups.values()]
301
+ .map((pieces) => ({ text: pieces.join(""), score: words ? overlap(pieces, words) : prose(pieces) }))
302
+ .filter((g) => g.score || /\p{L}/u.test(g.text))
303
+ .sort((a, b) => b.score - a.score || b.text.length - a.text.length)[0];
304
+ return (best?.score ? best.text : loose.join(raw.includes("data:") ? "" : "\n")) || best?.text || "";
305
+ }
306
+
307
+ // ---- the ask
308
+
309
+ const SYSTEM_ROLE = /^(?:system|developer)$/;
310
+ // Fewer letters and digits than this, inside a longer instruction, can sit there by chance ("chat",
311
+ // "user"): the hook's own floor for words told, not said (lib/wire.cjs TOLD_MIN).
312
+ const TOLD_MIN = 12;
313
+ const textIn = (v) => norm(typeof v === "string" ? v : leaves(v, [], [], 5000).filter((l) => !SETTING.test(String(l.at.at(-1)))).map((l) => l.value).join(" "));
314
+ // What the model calls were given as the app's instructions and what as the person's turn, apart, in
315
+ // the provider shapes the hook reads (lib/wire.cjs instructionsIn), and the models they asked for.
316
+ const toldApart = once((ex) => {
317
+ const system = [], person = [];
318
+ const add = (side, v) => { const t = textIn(v); if (t) side.push(t); };
319
+ for (const s of ex.sent) {
320
+ const body = parse(s);
321
+ if (!body || typeof body !== "object") continue;
322
+ for (const v of [body.system, body.instructions, body.systemInstruction?.parts]) if (v !== undefined) add(system, v);
323
+ for (const m of [...arr(body.messages), ...arr(body.input)]) {
324
+ if (SYSTEM_ROLE.test(m?.role)) add(system, m.content);
325
+ else if (m?.role === "user") add(person, m.content);
326
+ }
327
+ for (const c of arr(body.contents)) if (c && c.role !== "model") add(person, c.parts);
328
+ for (const v of [body.input, body.prompt]) if (typeof v === "string") add(person, v);
329
+ }
330
+ return { system, person, models: new Set(generation(ex).map((c) => str(c.model)).filter(Boolean)) };
331
+ });
332
+
333
+ // How a field's own name reads: 0 as the person's message, 2 as the model's instructions, 1 either.
334
+ const MESSAGE_NAME = /^(?:user_?)?(?:message|msg|query|question|input|text|content|ask)$/i;
335
+ const INSTRUCTIONS_NAME = /system|instruction|persona|preamble|developer/i;
336
+ const nameRank = (l) => { const key = String(l.at.at(-1)); return INSTRUCTIONS_NAME.test(key) ? 2 : MESSAGE_NAME.test(key) ? 0 : 1; };
337
+ // What the person can be asking in this request: never a turn's side ("user", "assistant"), and in a
338
+ // list of turns only its newest, unless its side names the app, since a history ends on the app's reply.
339
+ // The person's side goes by any word (a "parent", an id), so only the app's own are told apart.
340
+ // One word counts only where the person's turn carried it: a word of the app's instructions matches by
341
+ // chance. ulaim's "the Arabic root" made "language" its message, and its history's sides made
342
+ // "conversationHistory" its message after that.
343
+ const MODEL_SIDE = /^(?:assistant|ai|bot|chatbot|model|system|developer|tool|function|agent)$/i;
344
+ const askable = (body, at) => {
345
+ if (TURN_ROLE.test(String(at.at(-1)))) return false;
346
+ const i = at.findIndex((k) => typeof k === "number");
347
+ const list = i < 0 ? null : at.slice(0, i).reduce((v, k) => v?.[k], body);
348
+ return !isTurns(list) || (at[i] === list.length - 1 && !Object.entries(list[at[i]]).some(([k, v]) => TURN_ROLE.test(k) && MODEL_SIDE.test(String(v))));
349
+ };
350
+ const words = (v) => /\s/.test(v) || (v.match(/[\p{L}\p{N}]/gu) ?? []).length >= TOLD_MIN;
351
+
352
+ // The body leaves the app's own client set for its model: words the model was given as a whole
353
+ // instruction message or only inside one, while another field reached it as the person's turn, and a
354
+ // field that named the model it called. A turn of a list is the person's by the role it carries. Two
355
+ // fields of their own holding the same words, given once as the instructions and once as the turn,
356
+ // are told apart by name, since the words cannot: the one named most like a message is the person's.
357
+ // Each other is named the app's, so no trial sends the agent's words there without the run saying so.
358
+ function appLeaves(ex, found) {
359
+ const { system, person, models } = toldApart(ex);
360
+ const said = (v) => person.some((t) => t.includes(v));
361
+ const toldOnly = (v) => !said(v) && (v.match(/[\p{L}\p{N}]/gu) ?? []).length >= TOLD_MIN;
362
+ const told = found.filter((l) => !inList(l.at)
363
+ && system.some((t) => t === l.v || (toldOnly(l.v) && t.includes(l.v)))
364
+ && found.some((o) => fieldOf(o.at) !== fieldOf(l.at) && said(o.v)));
365
+ const twins = (l) => told.filter((o) => fieldOf(o.at) !== fieldOf(l.at) && o.v === l.v);
366
+ const persons = (l) => twins(l).length > 0 && twins(l).every((o) => nameRank(l) < nameRank(o) || (nameRank(l) === nameRank(o) && told.indexOf(l) < told.indexOf(o)));
367
+ return [...told.filter((l) => !persons(l)), ...leaves(bodyOf(ex)).filter((l) => !inList(l.at) && models.has(l.value))];
368
+ }
369
+
370
+ // The field whose value reached the model as the person's words: never one the app's client set for
371
+ // the model, then in the most exchanges, one that changes between them, a field of its own before a
372
+ // list, then the longest. A list holding the ask is named by its field. `appFields`: the fields the
373
+ // app's client set for its model, which a trial fills with the app's own values, never the person's.
374
+ function askOf(exchanges) {
375
+ const tally = new Map();
376
+ const app = new Set();
377
+ for (const ex of exchanges) {
378
+ const prompt = promptOf(ex);
379
+ const body = bodyOf(ex);
380
+ if (!prompt || !body) continue;
381
+ const { person } = toldApart(ex);
382
+ const found = leaves(body).map((l) => ({ ...l, v: norm(l.value) })).filter((l) => l.v.length >= 3 && /[a-z\u00c0-\uffff]/.test(l.v) && askable(body, l.at) && prompt.includes(l.v)
383
+ && (words(l.v) || person.some((t) => t.includes(l.v))));
384
+ for (const l of appLeaves(ex, found)) app.add(fieldOf(l.at));
385
+ for (const l of found) {
386
+ const field = fieldOf(l.at);
387
+ const t = tally.get(field) ?? { field, list: inList(l.at), hits: new Set(), values: new Set(), longest: 0, rank: nameRank(l), url: true };
388
+ if (!/^https?:\/\//i.test(String(l.value).trim())) t.url = false;
389
+ t.hits.add(ex.id);
390
+ t.values.add(l.v);
391
+ t.longest = Math.max(t.longest, l.v.length);
392
+ tally.set(field, t);
393
+ }
394
+ }
395
+ // An address is never what a person says, and a field named as the message is theirs before a longer
396
+ // one: AI Answers puts the page URL in its prompt verbatim and rewrites the French question, and the
397
+ // URL, longer, was taken for the ask.
398
+ const best = [...tally.values()].sort((a, b) => app.has(a.field) - app.has(b.field) || b.hits.size - a.hits.size || (b.values.size > 1) - (a.values.size > 1) || a.url - b.url || a.rank - b.rank || a.list - b.list || b.longest - a.longest)[0];
399
+ return best?.field ? { field: best.field, list: best.list, appFields: [...app].filter((f) => f !== best.field) } : null;
400
+ }
401
+
402
+ // The ask in one exchange: the field's value, or the list's last entry the model was told.
403
+ function askIn(ex, ask) {
404
+ const body = bodyOf(ex);
405
+ if (!ask || !body) return "";
406
+ const prompt = promptOf(ex);
407
+ return leaves(body).filter((l) => fieldOf(l.at) === ask.field && askable(body, l.at) && (!ask.list || prompt.includes(norm(l.value)))).at(-1)?.value ?? "";
408
+ }
409
+
410
+ // ---- the session
411
+
412
+ // Where the conversation rides in the history the client sends, a conversation id every request of
413
+ // the agent's carried is the agent's own conversation, not a trial's: AI Answers refused 44 of 44
414
+ // trials with "ChatId does not belong to session". It is left out of the template, so each trial
415
+ // opens a conversation of its own, which the app answers (checked: no chatId, 200).
416
+ function withoutConversation(body, session) {
417
+ if (session?.carrier !== "history" || !body || typeof body !== "object") return body;
418
+ const out = structuredClone(body);
419
+ const strip = (v) => {
420
+ if (!v || typeof v !== "object" || Array.isArray(v)) return;
421
+ for (const k of Object.keys(v)) {
422
+ if (SESSION_NAME.test(k) && /^(?:id|uuid)$/i.test(k) === false && typeof v[k] === "string" && idLike(v[k])) delete v[k];
423
+ else strip(v[k]);
424
+ }
425
+ };
426
+ strip(out);
427
+ return out;
428
+ }
429
+
430
+ // The earlier exchange's words the later prompt carried: its ask, the model's reply, the app's reply.
431
+ // Reply words the earlier prompt already held are the prompt's own text (a scripted greeting), and
432
+ // words asked twice are in the later prompt as its own ask: carried, they are there twice.
433
+ function carriedFrom(later, earlier, ask) {
434
+ const prompt = promptOf(later);
435
+ const before = promptOf(earlier);
436
+ const own = norm(askIn(later, ask));
437
+ const replies = [norm(modelWords(earlier)).slice(0, 80), norm(replyOf(earlier).text).slice(0, 80)].filter((t) => !before.includes(t));
438
+ return [norm(askIn(earlier, ask)), ...replies].filter((t) => t.length >= 8 && (own.includes(t) ? prompt.split(t).length > 2 : prompt.includes(t)));
439
+ }
440
+
441
+ const cookiesIn = (ex) => Object.fromEntries(str(headerOf(ex.headers, "cookie")).split(";").map((c) => c.trim().split("=")).filter(([k, v]) => k && v !== undefined).map(([k, ...v]) => [k, v.join("=")]));
442
+
443
+ // What stayed the same from the earlier exchange to the later one and is not the ask: a field the app
444
+ // handed back, a path segment, a field that reads as an id, a cookie the app set, a header, another
445
+ // cookie. The first found carries the session.
446
+ function carrierOf(earlier, later, ask, door) {
447
+ const same = (a, b) => a !== undefined && a !== "" && a === b;
448
+ const before = new Map(leaves(bodyOf(earlier) ?? {}).map((l) => [l.at.join("."), l.value]));
449
+ const own = leaves(bodyOf(later) ?? {}).filter((l) => !inList(l.at) && fieldOf(l.at) !== ask?.field);
450
+ // A value the app handed back in its earlier reply: the first request had none to send.
451
+ const handedBack = norm(earlier.reply);
452
+ // The earlier request must not have sent it already: LearnHouse's activity_uuid is in every request
453
+ // and echoed in every reply, and taken for a handed-back id it was dropped from 40 of 40 trials.
454
+ const named = own.find((l) => /^\S{6,}$/.test(l.value) && (/\d/.test(l.value) || l.value.length >= 16) && handedBack.includes(norm(l.value)) && before.get(l.at.join(".")) !== l.value);
455
+ // `minted` says who opened the conversation: the app handed the value back (a trial opens its own by
456
+ // sending none), or the client made it up (a trial makes up its own).
457
+ if (named) return { carrier: "body", key: named.at.join("."), minted: "app" };
458
+ const fields = own.filter((l) => same(l.value, before.get(l.at.join("."))));
459
+ const params = paramsOf(door.path, later.path);
460
+ const was = paramsOf(door.path, earlier.path);
461
+ const segment = Object.keys(params).find((k) => same(params[k], was[k]));
462
+ if (segment) return { carrier: "path", key: segment, minted: "client" };
463
+ // A constant id is a conversation only when its name says so; a lesson, workspace or agent id the
464
+ // client sends with every message is a fixed field, replayed as captured, never made up per trial.
465
+ const idField = fields.find((l) => idLike(l.value) && SESSION_NAME.test(String(l.at.at(-1))));
466
+ if (idField) return { carrier: "body", key: idField.at.join("."), minted: "client" };
467
+ const jar = cookiesIn(later);
468
+ const jarBefore = cookiesIn(earlier);
469
+ // A cookie the app set on the earlier reply, sent back: the earlier request could not carry it.
470
+ const set = Object.keys(jar).find((k) => earlier.cookies.includes(k));
471
+ if (set) return { carrier: "cookie", key: set, minted: "app" };
472
+ const cookies = Object.keys(jar).filter((k) => same(jar[k], jarBefore[k]));
473
+ const header = Object.keys(later.headers).find((k) => !HOP.test(k) && !PLAIN_HEADER.test(k) && same(later.headers[k], headerOf(earlier.headers, k.toLowerCase())));
474
+ if (header) return { carrier: "header", key: header.toLowerCase(), minted: "client" };
475
+ if (cookies.length) return { carrier: "cookie", key: cookies[0], minted: "client" };
476
+ return { carrier: "none", key: null };
477
+ }
478
+
479
+ // Body fields a later request sent back with a value the reply to an earlier one handed out, which
480
+ // that earlier request had not sent there: AI Answers signs each conversation's history and refuses a
481
+ // follow-up without the signature its last reply carried. A trial sends back what its own last reply
482
+ // handed out. The conversation's own carrier is the session's, not one of these.
483
+ function echoesOf(exchanges, ask, session) {
484
+ const out = new Set();
485
+ for (let i = 1; i < exchanges.length; i++) {
486
+ for (const l of leaves(bodyOf(exchanges[i]) ?? {})) {
487
+ const at = l.at.join(".");
488
+ if (inList(l.at) || fieldOf(l.at) === ask?.field || (session.carrier === "body" && at === session.key)) continue;
489
+ if (!/^\S{6,}$/.test(l.value) || !(/\d/.test(l.value) || l.value.length >= 16)) continue;
490
+ const handed = exchanges.slice(0, i).some((earlier) => norm(earlier.reply).includes(norm(l.value))
491
+ && leaves(bodyOf(earlier) ?? {}).find((e) => e.at.join(".") === at)?.value !== l.value);
492
+ if (handed) out.add(at);
493
+ }
494
+ }
495
+ return [...out];
496
+ }
497
+
498
+ // Held when a later prompt carried an earlier exchange's words. A body that carried them in a field
499
+ // other than its own ask holds the conversation itself; otherwise what stayed the same carries it.
500
+ // `tried`: a later request went on from an earlier one (it sent back what that one was handed, or
501
+ // kept its id, cookie or header) and still the app held nothing; without it, `held: false` is only
502
+ // that no request went on from another, which says nothing about the door.
503
+ function sessionOf(exchanges, ask, door) {
504
+ let tried = false;
505
+ let carried = null;
506
+ for (let i = 1; i < exchanges.length; i++) {
507
+ for (let j = i - 1; j >= 0; j--) {
508
+ const texts = carriedFrom(exchanges[i], exchanges[j], ask);
509
+ if (!texts.length) {
510
+ const went = carrierOf(exchanges[j], exchanges[i], ask, door);
511
+ if (went.carrier !== "none") { tried = true; carried ??= went; }
512
+ continue;
513
+ }
514
+ const own = norm(askIn(exchanges[i], ask));
515
+ const history = leaves(bodyOf(exchanges[i]) ?? {}).find((l) => norm(l.value) !== own && texts.some((t) => norm(l.value).includes(t)));
516
+ if (history) return { held: true, carrier: "history", key: fieldOf(history.at) };
517
+ return { held: true, ...carrierOf(exchanges[j], exchanges[i], ask, door) };
518
+ }
519
+ }
520
+ // Held or not, what carried the request on is named: a conversation id the app handed back is
521
+ // never a constant for trials to replay. wecom-sales-agent keeps a profile of each conversation
522
+ // rather than its words, so its next prompt carried none of them, and all 44 trials were sent the
523
+ // one id; the app answers one request per conversation at a time, and none came back.
524
+ return tried ? { held: false, ...carried, tried: true } : { held: false, carrier: "none", key: null };
525
+ }
526
+
527
+ // ---- an answer that came on a second request
528
+
529
+ // What the hook ties the two requests on (lib/tied.cjs): a value made to name one thing.
530
+ const TIE = /^(?=.*\d)[\w.:-]{6,200}$/;
531
+ const TURN_ROLE = /^(?:role|sender|from|author|speaker)$/i;
532
+ const isTurns = (v) => Array.isArray(v) && v.length > 0 && v.every((e) => e && typeof e === "object" && !Array.isArray(e)) && v.some((e) => Object.keys(e).some((k) => TURN_ROLE.test(k)));
533
+
534
+ // Where one request of the turn holds the person's words and the conversation: the leaf the ask was
535
+ // in, and a list of turns, the one the ask sits in when it sits in one. The run writes each trial's
536
+ // turn at these paths in the recorded body. A leaf in the ask's own field is the one, and a field the
537
+ // app's client set for its model never is, whatever words it shares with the ask.
538
+ function placesIn(body, words, ask) {
539
+ if (!body || typeof body !== "object") return { askPath: null, historyPath: null };
540
+ const own = norm(words);
541
+ const hits = own ? leaves(body, [], [], 5000).filter((l) => norm(l.value) === own && !ask?.appFields.includes(fieldOf(l.at))) : [];
542
+ const hit = hits.filter((l) => fieldOf(l.at) === ask?.field).at(-1) ?? hits.at(-1);
543
+ const lists = [];
544
+ const walk = (v, path) => {
545
+ if (isTurns(v)) lists.push(path);
546
+ if (v && typeof v === "object" && path.length < 12) for (const [k, x] of Object.entries(v)) walk(x, [...path, Array.isArray(v) ? Number(k) : k]);
547
+ };
548
+ walk(body, []);
549
+ const holding = hit && lists.find((p) => p.length < hit.at.length && p.every((k, i) => k === hit.at[i]));
550
+ const history = holding ?? lists[0];
551
+ return { askPath: hit ? newest(body, hit.at).join(".") : null, historyPath: history ? newest(body, history).join(".") : null };
552
+ }
553
+
554
+ // The second step as the run makes it: its method and address with the tying value named by where
555
+ // the asking request had it (`sent` in its body, or `got` back in its reply), where the ask and the
556
+ // turns sit in the asking request, and the requests sent before it in the same turn. Tied by the
557
+ // asking request's own address, the value is the door's own and the address is kept as it was.
558
+ function answerOf(ex, ask) {
559
+ const { method, path, asked } = ex.answer;
560
+ const [where, ...rest] = path.split("?");
561
+ const query = rest.join("?");
562
+ const address = [...where.split("/"), ...new URLSearchParams(query).values()];
563
+ const fields = (v, from) => (v && typeof v === "object" ? leaves(v) : []).filter((l) => TIE.test(l.value) && address.includes(l.value)).map((l) => ({ key: l.at.join("."), value: l.value, from }));
564
+ const tie = [...fields(bodyOf(ex), "sent"), ...fields(parse(asked), "got")][0];
565
+ const slot = tie && `{${tie.key}}`;
566
+ const named = !tie ? path : where.split("/").map((s) => (s === tie.value ? slot : s)).join("/")
567
+ + (query ? `?${query.split("&").map((p) => { const [k, ...v] = p.split("="); return decodeURIComponent(v.join("=")) === tie.value ? `${k}=${slot}` : p; }).join("&")}` : "");
568
+ const words = askIn(ex, ask);
569
+ return {
570
+ method, path: named, tie: tie ? { key: tie.key, from: tie.from } : null, ...placesIn(bodyOf(ex), words, ask),
571
+ before: ex.before.map((s) => { const body = parse(s.body); return { method: s.method, path: s.path, ...(body !== undefined ? { body } : {}), ...placesIn(body, words, ask) }; }),
572
+ };
573
+ }
574
+
575
+ // ---- problems a line of code decides
576
+
577
+ const TAG = /<\|[\w-]{1,40}\|>|<\/?([a-z][\w-]{0,40})(?:\s[^<>]{0,120})?\/?>/gi;
578
+ const FORMATTING = /^(?:a|b|i|u|s|em|strong|br|p|ul|ol|li|code|pre|span|div|table|thead|tbody|tr|td|th|h[1-6]|img|sup|sub|blockquote|hr|small|mark|del|ins|kbd|details|summary|figure|figcaption|caption|section|article|nav|header|footer|main|button|label|input|form)$/i;
579
+ const REASONING = /^(?:think|thinking|reasoning)$/i;
580
+ const PLACEHOLDER = /\{\{\s*[\w.]{1,40}\s*\}\}|\$\{\s*[\w.]{1,40}\s*\}|\{[a-z_][a-z0-9_]{1,40}\}|%\([a-z_]\w{0,40}\)s/gi;
581
+ const ERROR_OPEN = /^\s*(?:error:|exception:|traceback \(most recent call last\)|internal server error|an? (?:unexpected |internal )?error (?:has )?occurred|something went wrong)/i;
582
+ const ok = (status) => status >= 200 && status < 300;
583
+ // Markup a reply shows as code is the answer, not a leak: fenced blocks and inline code are left out.
584
+ const prose = (text) => text.replace(/(```|~~~)[\s\S]*?(?:\1|$)/g, " ").replace(/`[^`\n]*`/g, " ");
585
+
586
+ // An error field's own words: the field, its message or its first entry's, or the message beside a flag
587
+ // ({"error": true, "message": "Your card was declined"} was quoted as "true").
588
+ const errorWords = (body, field) => {
589
+ const v = body[field];
590
+ return [v, v?.message, arr(v)[0]?.message, body.message].find((s) => typeof s === "string" && s.trim()) ?? JSON.stringify(v);
591
+ };
592
+
593
+ // The app's error on a 2xx: an error field in its JSON, an error its stream says (eventsOf), or a reply
594
+ // that opens as an error and is not the model's own words.
595
+ function errorOf(ex, reply) {
596
+ const body = parse(ex.reply);
597
+ const field = body && typeof body === "object" && !Array.isArray(body) ? ["error", "errors"].find((k) => body[k] && (typeof body[k] !== "object" || Object.keys(body[k]).length)) : null;
598
+ if (field) return errorWords(body, field);
599
+ const [said] = eventsOf(ex.reply).errors;
600
+ if (said) return said;
601
+ const words = norm(modelWords(ex));
602
+ return ERROR_OPEN.test(reply.text) && !(words && words.includes(norm(reply.text).slice(0, 60))) ? reply.text.trim() : "";
603
+ }
604
+ // The reply is the app's error, so the model never answered the request.
605
+ const endedInError = (ex, reply = replyOf(ex)) => !ok(ex.status) || Boolean(errorOf(ex, reply));
606
+
607
+ const callFailed = (c) => { const status = Number(c.status) || 0; return status === 0 || status >= 400; };
608
+ const modelCall = (c) => { const status = Number(c.status) || 0; return `model call to ${str(c.host)}${c.model ? ` for ${c.model}` : ""} ${status ? `answered ${status}` : "got no answer"}`; };
609
+ // A refusal every later call meets: a key, a card, a quota plan, a retired model. A timeout, a dropped
610
+ // connection, a limiter and a provider's own failure are for the test message before a run to judge.
611
+ const REFUSED = new Set([401, 402, 403, 404]);
612
+ // Said from one request alone, its refused call and its own error: a door's problems span its last
613
+ // requests, and a key fixed since was quoted beside a newer request's card error.
614
+ function refusalOf(ex, problems) {
615
+ const call = ex.calls.findLast((c) => REFUSED.has(Number(c.status)));
616
+ const error = problems.find((p) => p.kind === "error-status" || p.kind === "error-under-2xx");
617
+ return call ? { refused: [`A ${modelCall(call)}.`, error?.said].filter(Boolean).join(" ") } : {};
618
+ }
619
+
620
+ // A tool behind the request that came back as an error, in the words it came back with: an answer
621
+ // that opens as an error or carries an error field, an exception the app's own tool raised, a call a
622
+ // provider marked failed, or a call whose arguments the tool's declared schema refused. Said literally;
623
+ // whether an error is the tool broken or its way of saying "not found" is the run's judgment.
624
+ const RAISED = /^raised \S/;
625
+ function toolErrorsIn(ex) {
626
+ const out = new Map();
627
+ // "web_search_call failed" is the provider's own status for a call, never an app tool's answer.
628
+ const said = (text, provider) => {
629
+ const t = str(text).trim();
630
+ const body = parse(t);
631
+ const field = body && typeof body === "object" && !Array.isArray(body) ? ["error", "errors"].find((k) => body[k] && (typeof body[k] !== "object" || Object.keys(body[k]).length)) : null;
632
+ return ERROR_OPEN.test(t) || RAISED.test(t) || (provider && /_call failed$/.test(t)) || Boolean(field);
633
+ };
634
+ for (const r of [...ex.calls, ...ex.deps]) {
635
+ for (const t of arr(r.tools)) if (str(t.name) && said(t.text, t.provider === true)) out.set(`${t.name}|${t.text}`, { name: str(t.name), said: str(t.text).trim(), provider: t.provider === true });
636
+ for (const c of arr(r.called)) if (str(c.name) && str(c.refused)) out.set(`${c.name}|${c.refused}`, { name: str(c.name), said: str(c.refused), refused: true });
637
+ }
638
+ return [...out.values()];
639
+ }
640
+
641
+ // Who put `piece` into the reply: the model, when its own words behind the reply hold it, else the line
642
+ // of the app that sent it, the write whose first bytes carry it. Neither unless the hook read the
643
+ // words of every call behind the reply whole, the hooks cutting them at WORDS_CUT.
644
+ const WORDS_CUT = 4000;
645
+ function whoSent(ex, piece, frame) {
646
+ const words = generation(ex).map((c) => c.reply);
647
+ if (!words.join("") || !words.every((w) => typeof w === "string" && w.length < WORDS_CUT)) return {};
648
+ if (norm(words.join("\n")).includes(norm(piece))) return { model: true };
649
+ const site = ex.sites.find((s) => norm(s.text).includes(norm(piece)));
650
+ return site ? { site, at: frame([site.at]) } : {};
651
+ }
652
+ const sentence = ({ model, site }) => (model ? " The model said it."
653
+ : site ? ` The model never said it: your app's own code sent it here${site.event ? `, in its "${site.event}" event` : ""}.` : "");
654
+
655
+ // The tools the request's last model call asked for, when they ran inside this request and no model
656
+ // call came after them: the app closed the request before the model could answer from their result
657
+ // (an AI SDK route left at one step). A tool the page runs itself and sends back is not this.
658
+ // A last call that wrote words answered: its request carries the tool calls before it in its history
659
+ // (MIT Learn AI's answering call resent search_courses), which is not the request stopping there.
660
+ function endedOnTool(ex) {
661
+ const last = generation(ex).at(-1);
662
+ if (str(last?.reply).trim()) return null;
663
+ const asked = [...new Set(arr(last?.called).map((c) => str(c?.name)).filter(Boolean))];
664
+ if (!asked.length) return null;
665
+ const inApp = new Set(ex.deps.flatMap((d) => arr(d.called).map((c) => str(c?.name))));
666
+ const streamed = eventsOf(str(ex.reply)).events.some((e) => e && typeof e === "object" && /tool.*(?:output|result)/i.test(str(e.type)));
667
+ return asked.some((name) => inApp.has(name)) || streamed ? asked : null;
668
+ }
669
+
670
+ // The first error the app printed, with the indented lines under it that carry its cause, as one
671
+ // line: "ERROR: request failed" alone is a banner. A warning, a traceback's opening, or a line the
672
+ // hook printed about a call it held is not one. One rule with the backend's firstErrorOf
673
+ // (src/simulate/failure.ts), tested there on the same outputs.
674
+ const ERROR_LINE = /(?:error|exception)\b|\bunhandled\b|\bfatal\b|\bpanic\b/i;
675
+ const NOT_ERROR = /warn|Traceback \(most recent|\bcortad: /i;
676
+ // The prefix a process manager puts on every line it relays, read past: concurrently's "[api] ",
677
+ // turbo's "@scope/api:dev: ", overmind's "web | ".
678
+ const RELAYED = /^(?:\[[^\]\s]{1,60}\] |(?:@[\w.-]+\/)?[\w.-]+:[\w.-]+: |[a-z][\w.-]*\s*\| )/i;
679
+ // The source a runtime prints around a throw is not the error: Bun numbers its lines
680
+ // ("321 | return new GatewayError({"), Node puts a caret under the one that threw and its file and
681
+ // line above that, and Python prints each frame's line under the frame.
682
+ const FRAME = /^\s*\d+ \| /;
683
+ const CARET = /^\s*\^+\s*$/;
684
+ const AT_LINE = /^\S+:\d+$/;
685
+ const PY_FRAME = /File "[^"]+", line \d+/;
686
+ const sourceAt = (lines, n) => FRAME.test(lines[n]) || CARET.test(lines[n + 1] ?? "") || (AT_LINE.test(lines[n]) && CARET.test(lines[n + 2] ?? ""))
687
+ || PY_FRAME.test(lines[n]) || (PY_FRAME.test(lines[n - 1] ?? "") && /^\s/.test(lines[n]));
688
+ // A record printed as fields under its first line (a wide event) is quoted by its error field alone.
689
+ // The fields are the request's: the first four under databuddy's "ERROR [api] POST /v1/agent/ask"
690
+ // were its request id, how it signed in, and its user's id and email, and the provider's refusal sat
691
+ // twenty-five fields further down.
692
+ const FIELD = /^[\s│├└─]*([\w.]+)\s*[:=]\s*(.*)$/;
693
+ const ERROR_FIELD = /^(?:\w+\.)?err(?:or)?(?:[._]?message)?$|^message$/i;
694
+ function firstError(text) {
695
+ const printed = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").split(/\r?\n/);
696
+ const lines = printed.map((l) => l.replace(RELAYED, ""));
697
+ // A field of a record is not an error line of its own: databuddy's INFO record of a request its
698
+ // client closed carried "└─ rpc_abort_reason: AbortError", and that leaf was quoted as the problem.
699
+ const i = printed.findIndex((l, n) => ERROR_LINE.test(l) && !NOT_ERROR.test(l) && !sourceAt(lines, n) && !/^\s*[│├└]/.test(lines[n]));
700
+ if (i < 0) return "";
701
+ const block = [];
702
+ for (const l of lines.slice(i + 1, i + 200)) { if (!/^\s+\S/.test(l)) break; block.push(l); }
703
+ const fields = block.map((l) => FIELD.exec(l)).filter(Boolean);
704
+ // A record's fields other than its error are never quoted, named error or not: a record whose
705
+ // error sat under "reason" sent its request id, user id and email in its place.
706
+ const record = fields.length > 1;
707
+ const [, key, said] = (record && fields.find(([, name]) => ERROR_FIELD.test(name))) || [];
708
+ const under = key ? [/(?:^|\s)message=(.*?)(?:\s+stack=|$)/.exec(said)?.[1] ?? `${key}: ${said}`] : record ? [] : block.slice(0, 4);
709
+ // Scrubbed over a bounded piece: a logger that prints a whole prompt on its error line handed
710
+ // 250,000 characters to patterns that walk back over every run of letters.
711
+ return clip(scrubbed([lines[i], ...under].join(" ").slice(0, 2_000).replace(/\s+/g, " ").trim()), 300);
712
+ }
713
+ // What a quoted line of the app's own log never carries off this machine: an address that reaches a
714
+ // person, a sign-in, a generated id. The line says what failed, never for whom.
715
+ const scrubbed = (line) => line
716
+ .replace(/(\b[a-z][\w+.-]*:\/\/)[^\s/@:]*:[^\s/@]+@/gi, "$1[hidden]@")
717
+ .replace(/[\w.+-]+@(?:[\w-]+\.)+[a-z]{2,}/gi, "[email]")
718
+ .replace(/\b(Bearer|Basic)\s+[\w.~+/=-]+/gi, "$1 [hidden]")
719
+ .replace(/\b((?:set-)?cookie|session(?:[_-]?(?:id|token))?|token|secret|password|passwd|api[_-]?key|authorization)\b(["']?\s*[:=]\s*)(?!\[hidden\]|Bearer |Basic )("[^"]*"|[^\s;,]+(?:;\s*[^\s;,=]+=[^\s;,]+)*)/gi, "$1$2[hidden]")
720
+ .replace(/(?<![\w-])(?=[\w-]*\d)(?=[\w-]*[a-z])[\w-]{24,}(?![\w-])/gi, "[id]");
721
+
722
+ function problemsIn(ex, reply, find, frame, printedLine) {
723
+ const out = [];
724
+ const add = (kind, said, where) => out.push({ kind, said, file: where?.file ?? null, line: where?.line ?? null });
725
+ const prompt = promptOf(ex);
726
+ const words = norm(modelWords(ex));
727
+ const call = frame(generation(ex)[0]?.caller);
728
+ const said = prose(reply.text);
729
+ // One problem per kind of leak, every tag of that kind named once: a reply that leaked a block of
730
+ // prompt markup is one defect, not one per tag.
731
+ const tokens = new Set(), reasoning = new Set(), fromPrompt = new Map();
732
+ for (const [tag, name] of said.matchAll(TAG)) {
733
+ if (!name) tokens.add(tag);
734
+ else if (REASONING.test(name)) reasoning.add(tag);
735
+ else if (!FORMATTING.test(name) && prompt.includes(`<${name.toLowerCase()}`) && !fromPrompt.has(name.toLowerCase())) fromPrompt.set(name.toLowerCase(), tag);
736
+ }
737
+ const listed = (set) => [...set].map((t) => `"${t}"`).join(", ");
738
+ // Text meant for the model is placed where it was sent from when the model never said it.
739
+ const placed = (kind, piece, text, where, note = "") => {
740
+ const who = whoSent(ex, piece, frame);
741
+ add(kind, `${text}${who.at ? "" : note}${sentence(who)}`, who.at ?? where);
742
+ };
743
+ if (tokens.size) placed("leaked-markup", [...tokens][0], `The reply carried chat template tokens meant for the model: ${listed(tokens)}.`, call);
744
+ if (reasoning.size) placed("leaked-markup", [...reasoning][0], `The reply carried the model's reasoning markup: ${listed(reasoning)}.`, call);
745
+ if (fromPrompt.size) {
746
+ const written = [...fromPrompt.keys()].map((name) => find(`<${name}`)).find(Boolean);
747
+ const tags = new Set(fromPrompt.values());
748
+ placed("leaked-markup", [...tags][0], `The reply carried markup from the prompt your app sent the model: ${listed(tags)}.`, written ?? call,
749
+ written ? "" : " None of it is written in your code; this is the line that made the model call.");
750
+ }
751
+ for (const [p] of said.matchAll(PLACEHOLDER)) {
752
+ if (prompt.includes(norm(p))) placed("placeholder", p, `The reply carried "${p}", a placeholder still unfilled in the prompt sent to the model.`, find(p) ?? call);
753
+ else if (!words.includes(norm(p)) && find(p)) add("placeholder", `The reply carried "${p}", a placeholder your code never filled.`, find(p));
754
+ }
755
+ const stopped = endedOnTool(ex);
756
+ if (stopped) add("ended-on-tool", `The request ended right after ${stopped.join(", ")} ran: no model call came after it, so the reply holds no answer written from the tool's result.`, frame(generation(ex).at(-1)?.caller) ?? call);
757
+ if (ex.status >= 500) add("error-status", `Your app answered ${ex.status} after it called the model.`, null);
758
+ const error = ok(ex.status) ? errorOf(ex, reply) : "";
759
+ if (error) {
760
+ const flat = error.replace(/\s+/g, " ");
761
+ add("error-under-2xx", `Your app answered ${ex.status} ${withError(flat)}.`, find(flat.slice(0, 60)) ?? find(flat.slice(0, 24)) ?? whoSent(ex, flat.slice(0, 60), frame).at ?? call);
762
+ }
763
+ for (const c of ex.calls.filter(callFailed)) add("model-call-failed", `A ${modelCall(c)}.`, frame(c.caller));
764
+ for (const d of ex.deps.filter((x) => x.retrieval && !arr(x.passages).length)) {
765
+ const status = Number(d.status) || 0;
766
+ add("retrieval-empty", `A retrieval from ${str(d.host)} ${ok(status) ? "returned nothing" : status ? `answered ${status} and returned nothing` : "got no answer"}.`, frame(d.caller));
767
+ }
768
+ for (const t of toolErrorsIn(ex)) {
769
+ const flat = clip(t.said.replace(/\s+/g, " "), 200);
770
+ add("tool-failed", t.refused
771
+ ? `The model called the tool ${t.name} with arguments its declared schema refuses: ${flat}.`
772
+ : `The tool ${t.name} came back as an error${t.provider ? " from your model provider, which runs it" : ""}: "${flat}"`, find(t.name) ?? call);
773
+ }
774
+ const told = generation(ex).filter((c) => Array.isArray(c.rules));
775
+ if (told.length && told.every((c) => !c.rules.length)) add("rules-not-carried", "The prompt your app sent the model carried none of the rules read from your code.", frame(told[0].caller));
776
+ // The person's own request or Cortad's test request before a run, never a run's trial: the hook
777
+ // keeps the person's requests over a run's, so a trial's neighbours may be gone. ponytail: a logger
778
+ // that writes after a second's grace (a worker thread flushing late) is missed; the hook's own
779
+ // capture of the app's output if that is ever seen.
780
+ const line = ex.trial && !REACH.test(ex.turn ?? "") ? "" : printedLine(ex);
781
+ if (line) add("app-printed", `Your app printed "${line}" while it answered.`, null);
782
+ return out;
783
+ }
784
+
785
+ // ---- the door's proof
786
+
787
+ // What the requests did inside the app, as ids, names and counts: the provider host, the read's rules
788
+ // any prompt carried (absent when the hook had no rules to look for), the tools that ran, whether the
789
+ // model asked for them or the app's own code did, and the passages the latest request was handed.
790
+ function insideOf(exchanges) {
791
+ const calls = exchanges.flatMap(generation);
792
+ const hosts = calls.map((c) => str(c.host).replace(/:\d+$/, "").toLowerCase()).filter((h) => /^[a-z0-9.-]{1,200}$/.test(h));
793
+ const told = calls.filter((c) => Array.isArray(c.rules));
794
+ const latest = exchanges.at(-1);
795
+ const names = (rows) => rows.flatMap((r) => arr(r.called).map((t) => str(t?.name))).filter(Boolean);
796
+ return {
797
+ ...(hosts.length ? { host: hosts.sort((a, b) => hosts.filter((h) => h === b).length - hosts.filter((h) => h === a).length)[0] } : {}),
798
+ ...(told.length ? { rules: [...new Set(told.flatMap((c) => c.rules.filter((r) => typeof r === "string" && /^[\w:-]{1,64}$/.test(r))))].slice(0, 300) } : {}),
799
+ tools: [...new Set(exchanges.flatMap((ex) => [...names(ex.calls), ...names(ex.deps)]))].slice(0, 40),
800
+ passages: [...latest.calls, ...latest.deps].reduce((n, r) => n + arr(r.passages).length, 0),
801
+ };
802
+ }
803
+
804
+ // `find(literal, near)` says where the repository's own code holds a literal, as { file, line }, or
805
+ // null, searching `near` (the files the exchange's model calls came from) first.
806
+ // `base` is the folder the app runs in, relative to the repository: the hook's frames start there.
807
+ // `printed(from, to)`: what the app printed between two times, on the hook's clock. `firstErrors`
808
+ // keeps the first error each request printed, by its id, once one is found or the second after its
809
+ // answer has passed: what the app printed is held only until newer output rolls it out.
810
+ export function proofOf(exchanges, door, { find = () => null, base = "", printed = () => "", firstErrors = new Map(), others = exchanges } = {}) {
811
+ const at = (f) => {
812
+ const m = /^(.*):(\d+)$/.exec(str(f));
813
+ return m ? { file: base ? `${base.replace(/\/+$/, "")}/${m[1]}` : m[1], line: Number(m[2]) } : null;
814
+ };
815
+ const frame = (frames) => at(arr(frames)[0]);
816
+ const near = (ex) => [...new Set(ex.calls.flatMap((c) => arr(c.caller).map(at)).filter(Boolean).map((p) => p.file))];
817
+ const fallback = !exchanges.some((ex) => !ex.trial);
818
+ exchanges = provingOf(exchanges);
819
+ // Answered later only when no request of the proof answered in its own reply: one that did is the
820
+ // endpoint's shape, and a request answered later beside it is read as the reply it got.
821
+ const later = exchanges.every((ex) => ex.later);
822
+ if (!later) exchanges = exchanges.map(({ later: _later, ...ex }) => ex);
823
+ const ask = askOf(exchanges);
824
+ const replies = exchanges.map(replyOf);
825
+ const latest = exchanges.at(-1);
826
+ // A reply cut short, by either side, is not the reply: the latest whole one stands.
827
+ const uncut = exchanges.findLastIndex((ex) => !ex.cut);
828
+ const shown = uncut < 0 ? exchanges.length - 1 : uncut;
829
+ const session = sessionOf(exchanges, ask, door);
830
+ // The person's own request is the shape trials take; ours only when there is none. Where the history
831
+ // carries the conversation, one whose history holds turns: a first message's empty list says nothing
832
+ // of how the app writes a turn, and AI Answers' follow-ups went out as {role:"assistant"} where it
833
+ // writes and signs {sender:"ai"}, refused 403 every one.
834
+ const turnsIn = (ex) => { const list = String(session.key ?? "").split(".").reduce((v, k) => v?.[k], bodyOf(ex)); return Array.isArray(list) && list.length > 0; };
835
+ const shape = session.carrier === "history" ? exchanges.findLast(turnsIn) ?? latest : latest;
836
+ const echoes = echoesOf(exchanges, ask, session);
837
+ // A failed call is neither the endpoint's model nor part of its price: llama-3.1-8b answered 404,
838
+ // glm-5p3 wrote the reply, and status named the first and quoted the run at its price.
839
+ const served = (ex) => generation(ex).filter((c) => !callFailed(c));
840
+ const metered = exchanges.filter((ex) => served(ex).some((c) => c.usage));
841
+ const tokens = (key) => median(metered.map((ex) => served(ex).reduce((n, c) => n + (Number(c[key]) || 0), 0)));
842
+ // Of the prompt, what the provider served from its cache: the quote prices it at the cache-read rate.
843
+ const cached = tokens("cachedTokens");
844
+ // The call that wrote the reply names the endpoint's model (the backend's wroteIt): the last one that
845
+ // answered with words, by the model that answered; any call that did not fail only when none wrote.
846
+ const wrote = exchanges.map((ex) => answeredCalls(ex).filter((c) => !callFailed(c)).at(-1)).filter(Boolean);
847
+ const models = (wrote.length ? wrote : exchanges.flatMap(served)).map((c) => str(c.answered) || str(c.model)).filter(Boolean);
848
+ const count = (m) => models.filter((x) => x === m).length;
849
+ const seconds = median(exchanges.map((ex) => ex.ms / 1000));
850
+ // A line the app printed belongs to one request or to none: its window closes at the next request's
851
+ // start, and a request another overlapped gets nothing. Six requests sent at once each carried
852
+ // "SALES_JWT_SECRET missing", printed by one of them. `others`: every request the hook kept.
853
+ // ponytail: a request still open when this one's proof is read is not among them yet; the hook's
854
+ // own count of requests in flight if a line is ever pinned on the wrong one that way.
855
+ const printedLine = (ex) => {
856
+ if (firstErrors.has(ex.id)) return firstErrors.get(ex.id);
857
+ const rest = others.filter((o) => o.id !== ex.id);
858
+ const until = Math.min(ex.at + ex.ms + 1000, ...rest.filter((o) => o.at >= ex.at).map((o) => o.at));
859
+ const crossed = rest.some((o) => o.at < ex.at + ex.ms && o.at + o.ms > ex.at);
860
+ const line = crossed ? "" : firstError(str(printed(ex.at, until)));
861
+ if (line || crossed || Date.now() > until) firstErrors.set(ex.id, line);
862
+ return line;
863
+ };
864
+ const seen = new Set();
865
+ // Whether an endpoint answers after a tool is said by its latest request that ran one. An earlier one
866
+ // that stopped there is not the endpoint's shape once a later one went on to answer: on databuddy a
867
+ // provider's rate limit fell between one request's tool and its answer, and Run stayed held through
868
+ // every clean request sent after it, with "send one more request" as the only advice.
869
+ const lastTool = exchanges.findLastIndex((ex) => generation(ex).some((c) => arr(c.called).length));
870
+ const each = exchanges.map((ex, i) => {
871
+ const files = near(ex);
872
+ return problemsIn(ex, replies[i], (literal) => find(literal, files), frame, printedLine).filter((p) => p.kind !== "ended-on-tool" || i === lastTool);
873
+ });
874
+ const problems = each.flat().filter((p) => !seen.has(`${p.kind}|${p.said}`) && seen.add(`${p.kind}|${p.said}`)).slice(0, PROBLEMS_MAX);
875
+ const query = shape.path.includes("?") ? `?${shape.path.split("?").slice(1).join("?")}` : "";
876
+ const answer = shape.answer ? answerOf(shape, ask) : null;
877
+ return {
878
+ door: { method: door.method, path: `${door.path}${query}` },
879
+ template: {
880
+ body: withoutConversation(parse(shape.body) ?? shape.body, session),
881
+ headerNames: Object.keys(shape.headers).filter((k) => !HOP.test(k)).map((k) => k.toLowerCase()).sort(),
882
+ askField: ask?.field ?? null,
883
+ sessionKey: session.key,
884
+ ...pathValuesOf(door.path, shape.path),
885
+ },
886
+ proof: {
887
+ exchanges: exchanges.length,
888
+ askField: ask?.field ?? null,
889
+ ...(ask?.appFields.length ? { appFields: ask.appFields } : {}),
890
+ session,
891
+ ...(echoes.length ? { echoes } : {}),
892
+ replyPath: replies[shown].path,
893
+ stream: replies[shown].stream,
894
+ modelCalls: median(exchanges.map((ex) => generation(ex).length)) ?? 0,
895
+ model: [...new Set(models)].sort((a, b) => count(b) - count(a))[0] ?? null,
896
+ tokens: metered.length ? { prompt: tokens("promptTokens"), completion: tokens("completionTokens"), ...(cached ? { cached } : {}) } : estimatedTokens(exchanges),
897
+ replySeconds: seconds === null ? null : Math.round(seconds * 10) / 10,
898
+ purpose: null,
899
+ at: new Date(latest.at).toISOString(),
900
+ ...insideOf(exchanges),
901
+ ...(answer ? { answer } : {}),
902
+ ...(later ? { later: true } : {}),
903
+ ...(fallback ? { fallback: true } : {}),
904
+ },
905
+ problems,
906
+ // `error`: the reply shown is the app's error, so the model never answered this request.
907
+ // `refused`: its model provider refused that request's call, said from it alone (refusalOf).
908
+ sample: { ask: clip(askIn(exchanges[shown], ask), SAMPLE), reply: clip(replies[shown].text, SAMPLE_REPLY), ...(exchanges[shown].cut ? { cut: true } : {}),
909
+ ...(endedInError(exchanges[shown], replies[shown]) ? { error: true, ...refusalOf(exchanges[shown], each[shown]) } : {}) },
910
+ };
911
+ }
912
+
913
+ // ---- the canary
914
+
915
+ // The fact the canary's first turn states (backend src/local/canary.ts), as norm() reads it.
916
+ const NONCE = /\bzq\d{8}\b/;
917
+ // Pieces of the app's instructions long enough to stand for one place in its code, and how many
918
+ // are looked for: a prompt built around a date or a name is still found by its other lines.
919
+ const PIECE_MIN = 24, PIECES = 5, SHORT_MIN = 8;
920
+ // Four words in a row: what instructions are made of, and an id, a date or a host name is not.
921
+ const WORDING = /\p{L}{2,}(?:\s+\p{L}{2,}){3}/u;
922
+ const piecesOf = (text) => {
923
+ const long = text.split(/[\n\r"'`\\]+/).map((p) => p.trim()).filter((p) => p.length >= PIECE_MIN).slice(0, PIECES);
924
+ return long.length || text.trim().length < SHORT_MIN ? long : [text.trim()];
925
+ };
926
+
927
+ // An app that writes the person's words into its own instructions does it to the person's own
928
+ // requests (`own`) as well: there, the words in the instructions are its design, not our config.
929
+ const asksInInstructions = (own) => {
930
+ const ask = askOf(own);
931
+ return own.some((ex) => { const words = norm(askIn(ex, ask)); return words.length >= 8 && toldApart(ex).system.some((t) => t.includes(words)); });
932
+ };
933
+
934
+ // What the test message met where the model's answer belongs, said as the backend says what a first
935
+ // reply met (src/local/canary.ts refusedOf): every model call failed, or the app answered with its error.
936
+ // Theirs to fix only when the person's own newest request ended in an error too: a provider failing a test
937
+ // message alone is not their fault, and their request that answers again sends the test message again.
938
+ function metIn(ex, own) {
939
+ const mine = own.filter((o) => !o.trial);
940
+ const last = mine.findLast((o) => !o.cut) ?? mine.at(-1);
941
+ if (!ok(ex.status) || !last || !endedInError(last)) return "";
942
+ const calls = generation(ex);
943
+ const refused = calls.length && calls.every(callFailed) ? calls.at(-1) : null;
944
+ const error = errorOf(ex, replyOf(ex)).replace(/\s+/g, " ").trim();
945
+ if (!refused && !error) return "";
946
+ return `A test message sent the way a run sends it was answered with an error${error && error !== NO_TEXT ? `, "${clipAtWord(error, 160)}"` : ""}, as your own request was${refused ? `: a ${modelCall(refused)}` : ""}. Fix that and send one more request.`;
947
+ }
948
+
949
+ // What the newest complete canary through a door shows. Its first turn states a fact, which must reach
950
+ // the model as the person's words and never as the app's instructions, unless the person's own
951
+ // requests show the app puts them there; the second turn of that conversation must carry it when the
952
+ // person's own requests showed the door holds a conversation; a new conversation must not; the reply
953
+ // must read as words; and instructions the request itself carried must be written in the repository
954
+ // (`found`, skipped when there is no repository to look in). Instructions the request did not carry
955
+ // came from the app's own side (a file, a setting, a store) and are never ours to question. Null until
956
+ // all three requests of one canary are recorded.
957
+ export function canaryOf(exchanges, proof, found = null, own = []) {
958
+ const byN = new Map();
959
+ for (const ex of exchanges) {
960
+ const [, n, conversation, turn] = CANARY.exec(ex.turn ?? "") ?? [];
961
+ if (!n) continue;
962
+ if (!byN.has(n)) byN.set(n, {});
963
+ byN.get(n)[`${conversation}${turn}`] = ex;
964
+ }
965
+ const [n, sent] = [...byN].filter(([, s]) => s.a1 && s.a2 && s.b1).sort(([, x], [, y]) => y.b1.at - x.b1.at)[0] ?? [];
966
+ if (!sent) return null;
967
+ // The fact went nowhere in the request: the endpoint has no place for a person's words.
968
+ const nonce = NONCE.exec(norm(sent.a1.body))?.[0];
969
+ if (!nonce) return { n: Number(n), passed: false, problems: [{ kind: "ask-nowhere", side: "ours", said: "This endpoint's request has no place for a person's message, so a test conversation's words would reach nothing." }] };
970
+ const { a1, a2, b1 } = sent;
971
+ const problems = [];
972
+ const add = (kind, side, said) => problems.push({ kind, side, said });
973
+ const told = toldApart(a1);
974
+ const shaped = told.system.length > 0 || told.person.length > 0;
975
+ const inInstructions = told.system.some((t) => t.includes(nonce));
976
+ if (!generation(a1).length) add("no-model-call", "ours", "The first message Cortad sent reached no model call.");
977
+ else if (inInstructions && !asksInInstructions(own)) add("ask-in-instructions", "ours", "A test conversation's words reached the model as your app's instructions, not as the person's turn.");
978
+ else if (!inInstructions && (shaped ? !told.person.some((t) => t.includes(nonce)) : !promptOf(a1).includes(nonce))) add("ask-not-in-turn", "ours", "A test conversation's words never reached the model as the person's turn.");
979
+ const ask = askOf([a1, a2, b1]);
980
+ const carried = (later) => carriedFrom(later, a1, ask).length > 0 || promptOf(later).includes(nonce);
981
+ if (proof?.session?.held && !carried(a2)) add("not-carried", "ours", "The second message of one conversation did not carry the first, though your own requests showed this endpoint holds a conversation.");
982
+ if (carried(b1)) add("shared-history", "theirs", "A new conversation carried an earlier one's words: every conversation on this endpoint shares what your app keeps, so test conversations would read each other's.");
983
+ const met = metIn(a1, own);
984
+ if (met) add("unreadable", "theirs", met);
985
+ else if (!ok(a1.status) || !replyOf(a1).text.trim()) add("unreadable", ok(a1.status) ? "ours" : "theirs", ok(a1.status) ? "The reply to the first message could not be read as words." : `Your app answered ${a1.status} to the first message.`);
986
+ const instructions = str(generation(a1).find((c) => typeof c.instructions === "string")?.instructions).trim();
987
+ const pieces = piecesOf(instructions);
988
+ const request = norm(a1.body);
989
+ // Carried by the request as instructions: wording the request holds. An id, a date or an address
990
+ // the app writes into its own prompt is its design: Databuddy's prompt names the site the request
991
+ // asks about, and that id held every run.
992
+ const carriedIn = pieces.filter((p) => WORDING.test(p) && request.includes(norm(p)));
993
+ if (found && carriedIn.length && !pieces.some(found)) {
994
+ add("instructions-not-in-code", "ours", `The instructions the model was given are not written in your code: "${clip(instructions.replace(/\s+/g, " "), 200)}". They came from the request this endpoint answered.`);
995
+ }
996
+ return { n: Number(n), passed: !problems.length, problems };
997
+ }
998
+
999
+ // The exchanges grouped by the door they came through, keyed by door.
1000
+ export function doorsOf(exchanges, routes = []) {
1001
+ const doors = new Map();
1002
+ for (const ex of exchanges) {
1003
+ const door = doorOf(ex, routes);
1004
+ if (!doors.has(door.key)) doors.set(door.key, { door, exchanges: [] });
1005
+ doors.get(door.key).exchanges.push(ex);
1006
+ }
1007
+ return doors;
1008
+ }
1009
+
1010
+ // Every door the rows' exchanges came through, with its proof, keyed by door.
1011
+ export function proofsOf(rows, { routes = [], ...opts } = {}) {
1012
+ const all = exchangesOf(rows);
1013
+ return new Map([...doorsOf(all.filter((ex) => !isCanary(ex)), routes)].map(([key, { door, exchanges }]) => [key, proofOf(exchanges, door, { others: all, ...opts })]));
1014
+ }