cortad 0.3.1-rc.9 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/lib/app-port.mjs +9 -0
- package/lib/auto-reach.mjs +88 -0
- package/lib/backing.mjs +230 -0
- package/lib/calls.mjs +23 -0
- package/lib/cli.mjs +115 -30
- package/lib/contact-text.mjs +167 -0
- package/lib/data.mjs +16 -22
- package/lib/dissection-text.mjs +232 -0
- package/lib/document.mjs +75 -0
- package/lib/env-secrets.mjs +48 -0
- package/lib/failed-text.mjs +36 -0
- package/lib/fresh.mjs +33 -12
- package/lib/health.mjs +61 -0
- package/lib/held.mjs +67 -0
- package/lib/home.mjs +55 -17
- package/lib/hosted.mjs +201 -0
- package/lib/identities.mjs +1 -1
- package/lib/keeping.mjs +162 -0
- package/lib/ledger.mjs +20 -0
- package/lib/lock.mjs +6 -1
- package/lib/mcp.mjs +52 -18
- package/lib/mint.mjs +179 -38
- package/lib/mongo.mjs +74 -0
- package/lib/numbers-text.mjs +68 -0
- package/lib/pg.mjs +118 -0
- package/lib/proc.mjs +100 -0
- package/lib/proof.mjs +1014 -0
- package/lib/proxy.mjs +184 -0
- package/lib/pyhook/cortad_sites.py +218 -0
- package/lib/pyhook/cortad_tied.py +291 -0
- package/lib/pyhook/sitecustomize.py +1175 -166
- package/lib/qdrant.mjs +47 -0
- package/lib/reach.mjs +223 -0
- package/lib/read-text.mjs +78 -24
- package/lib/redis.mjs +130 -0
- package/lib/register.mjs +36 -27
- package/lib/replay.mjs +464 -34
- package/lib/runner.mjs +85 -0
- package/lib/service.mjs +25 -0
- package/lib/spec.mjs +7 -4
- package/lib/start.mjs +33 -2
- package/lib/stick.mjs +65 -25
- package/lib/stores.mjs +347 -0
- package/lib/switches.mjs +36 -0
- package/lib/tether.mjs +9 -0
- package/lib/text.mjs +314 -179
- package/lib/tied.cjs +217 -0
- package/lib/trace.cjs +386 -514
- package/lib/verbs.mjs +155 -38
- package/lib/wire.cjs +882 -0
- package/lib/words.mjs +2 -0
- package/lib/writes.mjs +2 -2
- package/local.mjs +661 -297
- package/package.json +31 -1
- package/skill/SKILL.md +49 -42
- package/skill/references/results.md +279 -102
package/lib/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
|
+
}
|