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/replay.mjs CHANGED
@@ -1,10 +1,14 @@
1
1
  // The other half of lib/trace.cjs: the command's side. It hands the hook to the app it starts,
2
- // reads what the hook wrote, keeps the sign-in that request carried on this machine, and tells the
3
- // cloud only what it needs to ask again: the route, the method and the body.
4
- import { copyFileSync, mkdtempSync, readFileSync, statSync } from "node:fs";
2
+ // reads what the hook wrote, keeps the sign-in each request carried on this machine, and tells the
3
+ // cloud what each door is proven to do (lib/proof.mjs): the route, the method, the body, the header
4
+ // names and what the requests did inside the app. Header values never leave.
5
+ import { copyFileSync, existsSync, mkdtempSync, readFileSync, statSync } from "node:fs";
6
+ import { SourceMap } from "node:module";
5
7
  import { tmpdir } from "node:os";
6
- import { dirname, join } from "node:path";
8
+ import { dirname, isAbsolute, join, relative } from "node:path";
7
9
  import { fileURLToPath } from "node:url";
10
+ import { CODE, TEST } from "./data.mjs";
11
+ import { canaryOf, doorOf, doorsOf, exchangesOf, isCanary, proofOf, provingOf, sitesOf } from "./proof.mjs";
8
12
 
9
13
  const HERE = dirname(fileURLToPath(import.meta.url));
10
14
  const HOOK = join(HERE, "trace.cjs");
@@ -13,23 +17,41 @@ export const CAPTURED = "captured";
13
17
  // Never replayed: they describe one connection, not the caller.
14
18
  const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as|x-cortad-turn)$/i;
15
19
 
16
- export function makeCapture({ work, keepSecret, onDoor, writes = null, root = null }) {
20
+ // `files` lists the repository's source, relative to `root`, for placing a problem at its line;
21
+ // `appFolder` is the folder the app runs in, relative to `root`. `onProof` gets one payload per door,
22
+ // again whenever what it proves changes. `printed(from, to)` is what the app printed between two times.
23
+ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = null, root = null, files = () => [], appFolder = () => "", printed = () => "" }) {
17
24
  const file = join(work, "trace.jsonl");
18
25
  let read = 0;
19
- let held = null;
26
+ const accounts = makeAccounts();
20
27
  let alive = false;
21
28
  // The ports a hooked process listens on. Loading is not listening: turbo's own Node launcher loads
22
29
  // the hook, and one app was told we would see its messages while its API on Bun carried none.
23
30
  const ports = new Set();
31
+ // The processes the hook said hello from.
32
+ const hellos = new Set();
24
33
  // The route table each listening process read off its own app, newest per port.
25
34
  const tables = new Map();
26
35
  let newestTable = null;
36
+ // Each server a hooked process opened a connection to, by host and port.
37
+ const conns = new Map();
38
+ // The model's last words and status for each of a run's turns on an endpoint the app answers
39
+ // later, by turn (lib/wire.cjs answeredLater).
40
+ const answers = new Map();
41
+ // The person's messages the app took and answered at once with no model call inside them, by door,
42
+ // newest kept: { ex, at, followed }, `followed` once a model call tied to no request came after.
43
+ const taken = new Map();
27
44
  // Bun reads BUN_OPTIONS and splits it on spaces, quotes included, and only the `--preload=` form
28
- // leaves `bun run <script>` working. A hook path with a space in it is copied to one without.
29
- const bunHook = /\s/.test(HOOK) ? (() => { const at = join(mkdtempSync(join(tmpdir(), "cortad-")), "trace.cjs"); copyFileSync(HOOK, at); return at; })() : HOOK;
45
+ // leaves `bun run <script>` working. A hook path with a space in it is copied, with the file it
46
+ // requires, to one without.
47
+ const bunHook = /\s/.test(HOOK) ? (() => {
48
+ const dir = mkdtempSync(join(tmpdir(), "cortad-"));
49
+ for (const name of ["trace.cjs", "wire.cjs", "tied.cjs"]) copyFileSync(join(HERE, name), join(dir, name));
50
+ return join(dir, "trace.cjs");
51
+ })() : HOOK;
30
52
  // The hooks are handed to whatever is started: a Node app loads the first, a Bun app the same file
31
53
  // through its own variable, a Python app the second, and each ignores the others' variables.
32
- // ponytail: Node, Bun and Python. Go, Ruby, Java and PHP apps are asked for their route on the screen.
54
+ // Node, Bun and Python. Any other app is seen through lib/proxy.mjs instead.
33
55
  const env = (base) => ({
34
56
  CORTAD_TRACE_FILE: file,
35
57
  // The run writes the customer's rule sentences here (the engine's /tmp/rules.json lands in this
@@ -38,6 +60,9 @@ export function makeCapture({ work, keepSecret, onDoor, writes = null, root = nu
38
60
  // The read's tools by name, file and function, written by the run the same way: the hook wraps
39
61
  // those functions so a tool your own code runs is seen even when no model named it.
40
62
  CORTAD_TOOLS_FILE: join(work, "tools.json"),
63
+ // The hosts a trial's outbound writes may reach; every other write made inside a trial's turn
64
+ // is held in the app and answered there (the person's yes, written by the command).
65
+ CORTAD_OUTBOUND_FILE: join(work, "outbound.json"),
41
66
  // While a run is on, the hook records each file the app writes under its own folder, with a
42
67
  // copy from before the first write, and the run's end puts them back (lib/writes.mjs).
43
68
  ...(writes && root ? { CORTAD_WRITES_DIR: writes, CORTAD_APP_ROOT: root } : {}),
@@ -46,6 +71,16 @@ export function makeCapture({ work, keepSecret, onDoor, writes = null, root = nu
46
71
  PYTHONPATH: [PYHOOK, base.PYTHONPATH].filter(Boolean).join(":"),
47
72
  });
48
73
 
74
+ // The cookies and headers that carry a proven door's conversation. Speaking as the person never
75
+ // sends them: a trial that did would join the agent's own conversation instead of opening its own.
76
+ const carriers = new Map();
77
+ const sent = (payload) => {
78
+ const s = payload?.proof?.session;
79
+ if (s?.held && (s.carrier === "cookie" || s.carrier === "header") && s.key) carriers.set(`${payload.door.method} ${payload.door.path}`, { carrier: s.carrier, key: String(s.key).toLowerCase() });
80
+ onProof(payload);
81
+ };
82
+ const find = root ? sourceFinder(root, files) : null;
83
+ const contact = makeContact({ onProof: sent, appFolder, printed, accounts: accounts.kept, find: find ?? (() => null), found: find && ((literal) => Boolean(find(literal, [], true))), routes: () => [...tables.values()].flatMap((t) => t.routes) });
49
84
  function poll() {
50
85
  let size = 0;
51
86
  try { size = statSync(file).size; } catch { return; }
@@ -55,34 +90,194 @@ export function makeCapture({ work, keepSecret, onDoor, writes = null, root = nu
55
90
  read = size;
56
91
  for (const line of fresh.split("\n").filter(Boolean)) {
57
92
  let row; try { row = JSON.parse(line); } catch { continue; }
58
- if (row.hello) { alive = true; continue; }
93
+ if (row.hello) { alive = true; if (Number.isInteger(row.pid)) hellos.add(row.pid); continue; }
59
94
  if (Number.isInteger(row.listen)) { ports.add(row.listen); continue; }
60
95
  if (row.routes) { const t = routeTable(row.routes); if (t) { tables.set(t.port, t); newestTable = t; } continue; }
61
- if (row.call) { meter.add(row.call); continue; }
62
- if (row.dep) { meter.dep(row.dep); continue; }
63
- let body; try { body = JSON.parse(row.body); } catch { continue; }
64
- if (!body || typeof body !== "object" || typeof row.path !== "string" || !row.path.startsWith("/")) continue;
96
+ if (row.conn) { const { host, port } = row.conn; if (typeof host === "string" && Number.isInteger(port)) conns.set(`${host}:${port}`, { host, port }); continue; }
97
+ if (row.answer) {
98
+ const { turn, status, reply } = row.answer;
99
+ if (TURN.test(turn ?? "")) answers.set(turn, { status: Number.isInteger(status) ? status : 0, reply: typeof reply === "string" ? reply : "" });
100
+ if (answers.size > ANSWERS_KEPT) answers.delete(answers.keys().next().value);
101
+ continue;
102
+ }
103
+ if (row.receipt) {
104
+ const r = row.receipt;
105
+ if (typeof r.ex === "string" && typeof r.method === "string" && typeof r.path === "string" && r.path.startsWith("/") && r.turn === undefined) {
106
+ const door = doorOf({ method: r.method.toUpperCase(), path: r.path }, newestTable?.routes ?? []).key;
107
+ taken.delete(door);
108
+ taken.set(door, { ex: r.ex, at: Number(r.at) || 0, followed: false });
109
+ if (taken.size > RECEIPTS_KEPT) taken.delete(taken.keys().next().value);
110
+ }
111
+ continue;
112
+ }
113
+ // A call tied to a taken message proves it the usual way; one tied to none may be its worker's.
114
+ if (row.call) for (const [door, r] of taken) if (row.call.ex === r.ex) taken.delete(door); else if (typeof row.call.ex !== "string" && (Number(row.call.at) || 0) > r.at) r.followed = true;
115
+ if (row.call) { row.call.caller = placed(row.call.caller); meter.add(row.call); contact.add(row); continue; }
116
+ if (row.dep) { row.dep.caller = placed(row.dep.caller); meter.dep(row.dep); contact.add(row); continue; }
117
+ if (row.sites) { row.sites.list = placedSites(row.sites.list); meter.sites(row.sites); contact.add(row); continue; }
118
+ if (typeof row.ex !== "string" || typeof row.method !== "string" || typeof row.path !== "string" || !row.path.startsWith("/")) continue;
119
+ for (const [door, r] of taken) if (r.ex === row.ex) taken.delete(door);
65
120
  const headers = Object.fromEntries(Object.entries(row.headers ?? {}).filter(([k, v]) => !HOP.test(k) && typeof v === "string"));
66
121
  for (const [k, v] of Object.entries(headers)) if (/authorization|cookie|token|secret|session|csrf|api-?key/i.test(k)) keepSecret(String(v).replace(/^Bearer\s+/i, ""));
67
- held = { headers, at: Date.now() };
68
- onDoor({ method: String(row.method || "POST").toUpperCase(), path: row.path, body, headerNames: Object.keys(headers).sort() });
122
+ // Speaking as the person means their own client's headers, never a trial's. Cortad's own test
123
+ // request (reach:*) carries the page and sign-in this machine found for the app, so a run speaks
124
+ // with them where no request of the person's came: without it every run request lost the Origin
125
+ // an origin guard asks for, and each endpoint was refused 403 (ulaim, 2026-10-06).
126
+ if (typeof row.turn !== "string" || row.turn.startsWith("reach:")) accounts.keep(headers, `${row.method} ${row.path.split("?")[0]}`, row.body);
127
+ contact.add(row);
69
128
  }
129
+ contact.flush();
70
130
  }
71
131
  const timer = setInterval(poll, 700);
72
132
  timer.unref();
73
133
  return {
74
134
  env,
75
- headers: () => held?.headers ?? null,
135
+ // Where the hook writes, and where the run writes the rules it reads: lib/proxy.mjs writes and reads the same.
136
+ file,
137
+ rulesFile: join(work, "rules.json"),
138
+ // The headers a captured account speaks with ("captured", "captured:account2"), or null for any
139
+ // other caller.
140
+ headers: (role = CAPTURED) => { const own = accounts.speak(role); return own ? withoutCarriers(own, [...carriers.values()]) : null; },
141
+ // The body a run sends when it speaks as one of several customers: see makeAccounts.
142
+ bodyAs: (role, door, body) => accounts.bodyAs(role, door, body),
76
143
  // A message sent to the app on `port` can be seen arriving. A hook that has not said where it
77
144
  // listens (Django under runserver) is taken at its hello, as before.
78
145
  watching: (port) => { poll(); return ports.has(port) || (alive && ports.size === 0); },
79
146
  alive: () => { poll(); return alive; },
147
+ // The hook is in the process that answers on `port`, listening there as `pid`: it said it
148
+ // listens there, or that process said hello (a server the hook cannot see bind, Django's).
149
+ hookedAt: (port, pid) => { poll(); return ports.has(port) || hellos.has(pid); },
80
150
  // What your app spent on its model providers since it started, as the hook saw each call. Null
81
- // when no hook is in your app (an app this command did not start): absent, never zero.
82
- usage: () => { poll(); return alive ? meter.report() : null; },
151
+ // when no hook is in your app (an app this command did not start): absent, never zero. With a
152
+ // `turn`, the calls pinned to that turn also say what their model was told.
153
+ usage: (turn) => { poll(); return alive ? meter.report(TURN.test(turn ?? "") ? turn : undefined) : null; },
83
154
  // The app's routes as its framework holds them: the table of the process on `port`, else the
84
155
  // newest any hooked process wrote. Empty when no hook read one.
85
156
  registry: (port) => { poll(); return tables.get(port) ?? newestTable ?? {}; },
157
+ // The servers the app has connected to so far (lib/stores.mjs names the ones no setting does).
158
+ connections: () => { poll(); return [...conns.values()]; },
159
+ // The model's last words for a run's turn on an endpoint the app answers later, once the hook
160
+ // has written them; null at `until`, or once `signal` aborts. `forget` drops a turn sent again.
161
+ answered: async (turn, until, signal) => {
162
+ for (;;) {
163
+ poll();
164
+ if (answers.has(turn) || Date.now() >= until || signal?.aborted) return answers.get(turn) ?? null;
165
+ await new Promise((r) => setTimeout(r, 200));
166
+ }
167
+ },
168
+ forget: (turn) => { answers.delete(turn); },
169
+ // The server restarted and forgot what this terminal proved: every door's proof goes up again,
170
+ // from this process, which still holds the sign-in each request carried.
171
+ proveAgain: () => { contact.again(); contact.flush(); },
172
+ // The doors that took a message and answered at once, whose request no model call has been tied
173
+ // to after a worker's wait: never proven from this, only named.
174
+ receipts: () => { poll(); return [...taken].filter(([, r]) => Date.now() - r.at > RECEIPT_WAIT_MS).map(([door, r]) => ({ door, followed: r.followed })); },
175
+ };
176
+ }
177
+
178
+ // The exchanges seen so far, joined with the model calls and tool rows made inside them, and the
179
+ // payload last sent for each door. A door is sent again only when what it proves changed. Call rows
180
+ // usually land before their exchange's own row, which is written once the app has answered.
181
+ const EXCHANGES_KEPT = 200, LOOSE_KEPT = 500, PER_DOOR = 8, ANSWERS_KEPT = 500, RECEIPTS_KEPT = 50;
182
+ // How long a worker gets to make its model call after the message was taken (lib/proxy.mjs UNSEEN_MS).
183
+ const RECEIPT_WAIT_MS = 3000;
184
+ function makeContact({ onProof, find, found, appFolder, routes, printed, accounts = () => [] }) {
185
+ const kept = new Map();
186
+ let counted = 0;
187
+ const loose = new Map();
188
+ const sent = new Map();
189
+ const dirty = new Set();
190
+ // The first error each of the person's requests printed, kept past the output it was read from.
191
+ const firstErrors = new Map();
192
+ const bound = (map, max) => { while (map.size > max) map.delete(map.keys().next().value); };
193
+ // A run sends hundreds of requests of its own: they go first, so the person's few are still here
194
+ // to prove their doors when it ends.
195
+ const boundKept = () => {
196
+ while (kept.size > EXCHANGES_KEPT) {
197
+ const ours = [...kept].find(([, rows]) => typeof rows[0].turn === "string");
198
+ kept.delete(ours ? ours[0] : kept.keys().next().value);
199
+ }
200
+ };
201
+ return {
202
+ add(row) {
203
+ const id = row.ex ?? row.call?.ex ?? row.dep?.ex ?? row.sites?.ex;
204
+ if (typeof id !== "string") return;
205
+ // An exchange written again (answered later, then fetched on a second request) keeps its calls.
206
+ if (typeof row.ex === "string") { kept.set(id, [row, ...(kept.get(id) ?? []).filter((r) => typeof r.ex !== "string"), ...(loose.get(id) ?? [])]); loose.delete(id); boundKept(); dirty.add(id); }
207
+ else if (kept.has(id)) { kept.get(id).push(row); dirty.add(id); }
208
+ else { loose.set(id, [...(loose.get(id) ?? []), row]); bound(loose, LOOSE_KEPT); }
209
+ },
210
+ again() { sent.clear(); counted = -1; },
211
+ flush() {
212
+ // A customer signed in as for the first time changes what every door's run is played as.
213
+ const signIns = accounts();
214
+ const recount = signIns.length !== counted;
215
+ counted = signIns.length;
216
+ if (!dirty.size && !recount) return;
217
+ const all = exchangesOf([...kept.values()].flat());
218
+ const canaries = doorsOf(all.filter(isCanary), routes());
219
+ for (const [key, { door, exchanges }] of doorsOf(all.filter((ex) => !isCanary(ex)), routes())) {
220
+ const tried = canaries.get(key)?.exchanges ?? [];
221
+ if (!recount && ![...exchanges, ...tried].some((ex) => dirty.has(ex.id))) continue;
222
+ const payload = proofOf(provingOf(exchanges).slice(-PER_DOOR), door, { find, base: appFolder(), printed, firstErrors, others: all });
223
+ const canary = canaryOf(tried, payload.proof, found, provingOf(exchanges));
224
+ if (canary) payload.proof.canary = canary;
225
+ if (signIns.length > 1) payload.proof.accounts = signIns.length;
226
+ const json = JSON.stringify(payload);
227
+ if (sent.get(key) === json) continue;
228
+ sent.set(key, json);
229
+ // What the sign-in on the person's newest request to this door is and when it runs out, never
230
+ // the sign-in itself: a run that outlasts it would be refused halfway. Counted at the post.
231
+ // With several accounts a run plays them all, so the one that ends first is the one said.
232
+ const signedIn = payload.template.headerNames.length ? exchanges.findLast((ex) => !ex.trial) : null;
233
+ const identity = signedIn ? soonest(identityOf(signedIn.headers), signIns) : null;
234
+ try { onProof(identity ? { ...payload, proof: { ...payload.proof, identity } } : payload); } catch { /* sent again with the next change */ }
235
+ }
236
+ bound(firstErrors, EXCHANGES_KEPT);
237
+ dirty.clear();
238
+ },
239
+ };
240
+ }
241
+
242
+ // Where the repository's own code holds a literal, as its file and line. The files the model call's
243
+ // own frames name are searched first; then every other code file, and a literal more than one of them
244
+ // holds names none (the caller falls back to the frame). Docs and tests never answer, and a frame
245
+ // file is read only when it is one of the repository's shareable files. `any`: whether the repository
246
+ // holds it at all, in the first file of any kind but a test, since a prompt is often kept in a text,
247
+ // Markdown, YAML or template file its code reads.
248
+ // ponytail: reads every file per new literal; an index when problems per run grow past a few.
249
+ const DOCS = /(?:^|\/)docs?\//;
250
+ export function sourceFinder(root, files) {
251
+ const found = new Map();
252
+ const lineIn = (rel, literal) => {
253
+ let text;
254
+ try { text = readFileSync(join(root, rel), "utf8"); } catch { return null; }
255
+ const i = text.indexOf(literal);
256
+ return i < 0 ? null : { file: rel, line: text.slice(0, i).split("\n").length };
257
+ };
258
+ const search = (literal, near, any) => {
259
+ const all = files();
260
+ const shared = new Set(all);
261
+ for (const rel of near) {
262
+ const at = shared.has(rel) ? lineIn(rel, literal) : null;
263
+ if (at) return at;
264
+ }
265
+ let only = null;
266
+ for (const rel of all) {
267
+ if ((!any && (!CODE.test(rel) || DOCS.test(rel))) || TEST.test(rel) || near.includes(rel)) continue;
268
+ const at = lineIn(rel, literal);
269
+ if (!at) continue;
270
+ if (any) return at;
271
+ if (only) return null;
272
+ only = at;
273
+ }
274
+ return only;
275
+ };
276
+ return (literal, near = [], any = false) => {
277
+ if (typeof literal !== "string" || literal.trim().length < 3) return null;
278
+ const key = `${any ? "any" : ""}\0${near.join("\n")}\0${literal}`;
279
+ if (!found.has(key)) found.set(key, search(literal, near, any));
280
+ return found.get(key);
86
281
  };
87
282
  }
88
283
 
@@ -105,54 +300,85 @@ const routeTable = (r) => {
105
300
  };
106
301
 
107
302
  // Every model call the hook wrote down, kept here: totals per host and model, and the newest rows.
108
- const ROWS = 5000;
303
+ // What a call's model was told is kept only while its row is among those a report hands on.
304
+ const ROWS = 5000, REPORTED = 400;
109
305
  // The turn a row was pinned to by the run's own tag, and the rule ids the call's prompt carried.
110
306
  const TURN = /^[A-Za-z0-9:_.-]{1,80}$/;
111
307
  const RULE_ID = /^[\w:-]{1,64}$/;
112
308
  const turnOf = (v) => (typeof v === "string" && TURN.test(v) ? { turn: v } : {});
113
309
  const rulesOf = (v) => (Array.isArray(v) ? { rules: v.filter((id) => typeof id === "string" && RULE_ID.test(id)).slice(0, 300) } : {});
114
- // What the app's tools answered behind a call, as the hook read them off the next prompt.
115
- const texts = (v) => (Array.isArray(v) ? v.filter((t) => t && typeof t.text === "string" && t.text.trim()).slice(0, 12).map((t) => ({ name: String(t.name ?? "").slice(0, 80), text: t.text.slice(0, 3000) })) : []);
310
+ // What the app's tools answered behind a call, as the hook read them off the next prompt, and what
311
+ // the tools the model's provider ran answered (`provider`), off the model's reply.
312
+ // `kind: "data"`: the app's own record of the person (a profile, an account), not a retrieval.
313
+ const texts = (v) => (Array.isArray(v) ? v.filter((t) => t && typeof t.text === "string" && t.text.trim()).slice(0, 12).map((t) => ({ name: String(t.name ?? "").slice(0, 80), text: t.text.slice(0, 3000), ...(t.kind === "data" ? { kind: "data" } : {}), ...(t.provider === true ? { provider: true } : {}) })) : []);
116
314
  const toolsOf = (v) => (texts(v).length ? { tools: texts(v) } : {});
117
- // The tools the model asked for behind a call, names and clipped arguments, and the passages its
118
- // prompt was handed as retrieved context, or a retrieval call answered with.
315
+ // The tools the model asked for behind a call, names and clipped arguments, what the tool's declared
316
+ // schema refused in them (`refused`), and the passages its prompt was handed as retrieved context, or
317
+ // a retrieval call answered with.
119
318
  const calledOf = (v) => {
120
- const called = Array.isArray(v) ? v.filter((c) => c && typeof c.name === "string" && c.name && typeof c.arguments === "string").slice(0, 12).map((c) => ({ name: c.name.slice(0, 80), arguments: c.arguments.slice(0, 1200) })) : [];
319
+ const called = Array.isArray(v) ? v.filter((c) => c && typeof c.name === "string" && c.name && typeof c.arguments === "string").slice(0, 12).map((c) => ({ name: c.name.slice(0, 80), arguments: c.arguments.slice(0, 1200), ...(typeof c.refused === "string" && c.refused ? { refused: c.refused.slice(0, 300) } : {}) })) : [];
121
320
  return called.length ? { called } : {};
122
321
  };
123
322
  const passagesOf = (v) => (texts(v).length ? { passages: texts(v).slice(0, 6) } : {});
124
323
  // Which of the app's own lines made the call, nearest first, as "path:line".
125
324
  const AT = /^[^\n\r\t]{1,300}:\d{1,7}$/;
126
- const callerOf = (v) => { const caller = Array.isArray(v) ? v.filter((c) => typeof c === "string" && AT.test(c)).slice(0, 3) : []; return caller.length ? { caller } : {}; };
325
+ const CALLERS_MAX = 5;
326
+ const callerOf = (v) => { const caller = Array.isArray(v) ? v.filter((c) => typeof c === "string" && AT.test(c)).slice(0, CALLERS_MAX) : []; return caller.length ? { caller } : {}; };
327
+ // What the call's model was told, as the hook read it off the prompt: the app's instructions ("" when
328
+ // it carried none), the tools offered by name, and the declared ones as their schemas say.
329
+ const NAME = /^[\w.:-]{1,80}$/;
330
+ const names = (v, max) => (Array.isArray(v) ? v.filter((n) => typeof n === "string" && NAME.test(n)).slice(0, max) : []);
331
+ const toldOf = (call) => {
332
+ const offered = names(call.offered, 60);
333
+ const declared = (Array.isArray(call.declared) ? call.declared : []).filter((d) => d && typeof d.name === "string" && NAME.test(d.name)).slice(0, 60)
334
+ .map((d) => ({ name: d.name, ...(typeof d.does === "string" && d.does.trim() ? { does: d.does.trim().slice(0, 300) } : {}), requires: names(d.requires, 12) }));
335
+ return { ...(typeof call.instructions === "string" ? { instructions: call.instructions.slice(0, 12_000) } : {}), ...(offered.length ? { offered } : {}), ...(declared.length ? { declared } : {}) };
336
+ };
337
+ // The model's words behind a call, and the lines of the app that wrote the reply of the request it
338
+ // was made in, go out with the rows pinned to the turn asked about, as what the model was told does.
339
+ const MODEL_WORDS = 4000, SITES_KEPT = 500;
127
340
  const meter = (() => {
128
341
  const rows = [];
129
342
  const deps = [];
343
+ const sitesOfEx = new Map();
130
344
  const count = (v) => (Number.isInteger(v) && v > 0 ? v : 0);
131
345
  return {
132
346
  add(call) {
133
347
  if (!call || typeof call.host !== "string" || !call.host) return;
134
348
  rows.push({
135
- at: count(call.at), host: call.host.slice(0, 253), model: String(call.model ?? "").slice(0, 160), status: count(call.status),
349
+ at: count(call.at), ms: count(call.ms), host: call.host.slice(0, 253), model: String(call.model ?? "").slice(0, 160), ...(typeof call.answered === "string" && call.answered ? { answered: call.answered.slice(0, 160) } : {}), status: count(call.status),
136
350
  promptTokens: count(call.promptTokens), cachedTokens: count(call.cachedTokens), completionTokens: count(call.completionTokens),
137
351
  usage: call.usage === true, ...turnOf(call.turn), ...rulesOf(call.rules), ...toolsOf(call.tools), ...calledOf(call.called), ...passagesOf(call.passages), ...callerOf(call.caller),
352
+ ...(typeof call.ex === "string" ? { ex: call.ex } : {}), told: toldOf(call), ...(typeof call.reply === "string" ? { said: call.reply.slice(0, MODEL_WORDS) } : {}),
353
+ ...(call.limit && typeof call.limit === "object" ? { limit: { cap: count(call.limit.cap) || null, reasoning: count(call.limit.reasoning) } } : {}),
138
354
  });
355
+ if (rows.length > REPORTED) { const old = rows[rows.length - REPORTED - 1]; delete old.told; delete old.said; }
139
356
  if (rows.length > ROWS) rows.splice(0, rows.length - ROWS);
140
357
  },
141
358
  // A service their settings name: only its setting, host and status travel, never a byte of it.
142
- // A tool function of the app's own ("in-app") carries its call and what it returned, as a
359
+ // A write a trial made that the hook held carries `held` and its clipped call. A tool function of the app's own ("in-app") carries its call and what it returned, as a
143
360
  // model call's row does.
144
361
  dep(d) {
145
362
  if (!d || typeof d.host !== "string" || !d.host) return;
146
363
  const env = typeof d.env === "string" && /^[A-Z_][A-Z0-9_]*$/.test(d.env) ? d.env : undefined;
147
- deps.push({ at: count(d.at), ...(env ? { env } : {}), host: d.host.slice(0, 253), status: count(d.status), ...(typeof d.code === "string" ? { code: d.code.slice(0, 40) } : {}), ...turnOf(d.turn), ...passagesOf(d.passages), ...toolsOf(d.tools), ...calledOf(d.called) });
364
+ deps.push({ at: count(d.at), ...(env ? { env } : {}), host: d.host.slice(0, 253), status: count(d.status), ...(typeof d.code === "string" ? { code: d.code.slice(0, 40) } : {}), ...(d.held === true ? { held: true } : {}), ...turnOf(d.turn), ...passagesOf(d.passages), ...toolsOf(d.tools), ...calledOf(d.called) });
148
365
  if (deps.length > ROWS) deps.splice(0, deps.length - ROWS);
149
366
  },
150
- report() {
367
+ sites(s) {
368
+ if (typeof s?.ex !== "string") return;
369
+ sitesOfEx.set(s.ex, sitesOf(s.list));
370
+ if (sitesOfEx.size > SITES_KEPT) sitesOfEx.delete(sitesOfEx.keys().next().value);
371
+ },
372
+ // `turn`: the rows pinned to it carry what their model was told. The instructions go out with the
373
+ // reply they are read with, never with every call of the run at every turn.
374
+ report(turn) {
151
375
  const totals = new Map();
152
376
  for (const r of rows) {
153
377
  const key = `${r.host}|${r.model}`;
154
- const t = totals.get(key) ?? { host: r.host, model: r.model, calls: 0, promptTokens: 0, cachedTokens: 0, completionTokens: 0, unmetered: 0 };
378
+ const t = totals.get(key) ?? { host: r.host, model: r.model, calls: 0, promptTokens: 0, cachedTokens: 0, completionTokens: 0, unmetered: 0, answered: r.answered };
155
379
  t.calls += 1;
380
+ // The model that answered for this one, when every call of it was answered by one other model.
381
+ if (t.answered !== r.answered) t.answered = undefined;
156
382
  t.promptTokens += r.promptTokens;
157
383
  t.cachedTokens += r.cachedTokens;
158
384
  t.completionTokens += r.completionTokens;
@@ -161,10 +387,214 @@ const meter = (() => {
161
387
  totals.set(key, t);
162
388
  }
163
389
  return {
164
- totals: [...totals.values()].sort((a, b) => b.calls - a.calls),
165
- rows: rows.slice(-400).reverse().map(({ at, host, model, status, turn, rules, tools, called, passages, caller }) => ({ at, host, model, status, ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}) })),
166
- deps: deps.slice(-400).reverse(),
390
+ totals: [...totals.values()].sort((a, b) => b.calls - a.calls).map(({ answered, ...t }) => (answered ? { ...t, answered } : t)),
391
+ rows: rows.slice(-REPORTED).reverse().map(({ at, ms, host, model, status, turn: pinned, rules, tools, called, passages, caller, ex, told, said }) => ({ at, ms, host, model, status, ...(pinned ? { turn: pinned } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}),
392
+ ...(turn !== undefined && pinned === turn ? { ...told, ...(said !== undefined ? { said } : {}), ...(sitesOfEx.get(ex)?.length ? { sites: sitesOfEx.get(ex) } : {}) } : {}) })),
393
+ deps: deps.slice(-REPORTED).reverse(),
167
394
  };
168
395
  },
169
396
  };
170
397
  })();
398
+
399
+ // The person's headers minus what carries a conversation: the header itself, or the one cookie inside
400
+ // the Cookie header.
401
+ export function withoutCarriers(headers, carriers) {
402
+ const out = {};
403
+ for (const [name, value] of Object.entries(headers)) {
404
+ const lower = name.toLowerCase();
405
+ if (carriers.some((c) => c.carrier === "header" && c.key === lower)) continue;
406
+ if (lower === "cookie") {
407
+ const kept = String(value).split(/;\s*/).filter((pair) => !carriers.some((c) => c.carrier === "cookie" && pair.split("=")[0].trim().toLowerCase() === c.key));
408
+ if (kept.length) out[name] = kept.join("; ");
409
+ continue;
410
+ }
411
+ out[name] = value;
412
+ }
413
+ return out;
414
+ }
415
+
416
+ // What the person's sign-in is and how many seconds it has left, read on this machine: a bearer
417
+ // token or a cookie that is a JWT carries its own expiry. Null seconds when none says.
418
+ // ponytail: JWT `exp` only; a session a server keeps (an opaque cookie) has no expiry we can read.
419
+ const JWT = /^[\w-]{8,}\.([\w-]{8,})\.[\w-]{8,}$/;
420
+ const expOf = (token) => {
421
+ const payload = JWT.exec(token)?.[1];
422
+ if (!payload) return null;
423
+ try { const exp = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))?.exp; return Number.isFinite(exp) ? exp : null; } catch { return null; }
424
+ };
425
+ // The door's own sign-in, or the account of several whose sign-in ends first, numbered from one.
426
+ function soonest(own, signIns) {
427
+ if (own.kind === "none" || signIns.length < 2) return own;
428
+ const ends = signIns.map((headers) => identityOf(headers).expiresInS);
429
+ const i = ends.reduce((best, s, at) => (s !== null && (ends[best] === null || s < ends[best]) ? at : best), 0);
430
+ if (ends[i] === null || (own.expiresInS !== null && own.expiresInS < ends[i])) return own;
431
+ return { ...own, expiresInS: ends[i], ...(i > 0 ? { account: i + 1 } : {}) };
432
+ }
433
+
434
+ // Each customer the person's own requests signed in as, up to four, in the order first seen, with the
435
+ // headers of that customer's newest request. The same customer signed in again (a JWT renewed, the
436
+ // same subject) is the same account. A request with no sign-in is no account; its headers are spoken
437
+ // only while no account is held. An account whose sign-in has ended gives its place to a new one.
438
+ // ponytail: an opaque token renewed reads as a new customer; the app's own word on who is signed in if that shows up.
439
+ const ACCOUNTS_MAX = 4;
440
+ const ACCOUNT_ROLE = /^captured(?::account([2-4]))?$/;
441
+ // What of a request's body may belong to who sent it: the plain values at fixed places (the id of
442
+ // their workspace, their site). Never a list, which is where a conversation lives.
443
+ const LEAVES_MAX = 60, LEAF_MAX = 200, LEAF_DEPTH = 4;
444
+ function leavesOf(body) {
445
+ const out = new Map();
446
+ const walk = (v, path, depth) => {
447
+ if (out.size >= LEAVES_MAX || depth > LEAF_DEPTH) return;
448
+ if (v && typeof v === "object" && !Array.isArray(v)) { for (const [k, x] of Object.entries(v)) walk(x, path ? `${path}.${k}` : k, depth + 1); return; }
449
+ if ((typeof v === "string" && v.length <= LEAF_MAX) || typeof v === "number" || typeof v === "boolean") out.set(path, v);
450
+ };
451
+ try { walk(typeof body === "string" ? JSON.parse(body) : body, "", 0); } catch { /* not JSON: nothing of theirs to read */ }
452
+ return out;
453
+ }
454
+ export function makeAccounts() {
455
+ const kept = [];
456
+ let bare = null;
457
+ const at = (role) => { const m = ACCOUNT_ROLE.exec(role); return m ? kept[Number(m[1] ?? 1) - 1] ?? kept[0] ?? null : null; };
458
+ return {
459
+ // `door` and `body`: the request as that customer sent it, for the values in it that are theirs.
460
+ keep(headers, door, body) {
461
+ const key = signInKey(headers);
462
+ if (!key) { bare = headers; return; }
463
+ let account = kept.find((a) => a.key === key);
464
+ if (account) account.headers = headers;
465
+ else {
466
+ account = { key, headers, bodies: new Map() };
467
+ const ended = kept.findIndex((a) => (identityOf(a.headers).expiresInS ?? 1) <= 0);
468
+ if (kept.length < ACCOUNTS_MAX) kept.push(account);
469
+ else if (ended >= 0) kept[ended] = account;
470
+ else return;
471
+ }
472
+ if (door) account.bodies.set(door, leavesOf(body));
473
+ },
474
+ kept: () => kept.map((a) => a.headers),
475
+ speak(role) {
476
+ const m = ACCOUNT_ROLE.exec(role);
477
+ if (!m) return null;
478
+ return at(role)?.headers ?? bare;
479
+ },
480
+ // A run's request is built from one customer's own request, so it carries that customer's ids.
481
+ // Sent as another customer, each value that is one customer's own becomes this customer's own:
482
+ // Databuddy's chat takes the id of the site asked about, and another customer's site is refused.
483
+ // A value the run itself wrote (the message, a new conversation's id) matches nobody's and stays.
484
+ bodyAs(role, door, body) {
485
+ const me = at(role);
486
+ if (!me || kept.length < 2 || body === undefined || body === null) return body;
487
+ let parsed;
488
+ try { parsed = typeof body === "string" ? JSON.parse(body) : body; } catch { return body; }
489
+ const mine = (path) => me.bodies.get(door)?.get(path) ?? [...me.bodies.values()].map((b) => b.get(path)).find((v) => v !== undefined);
490
+ const others = kept.filter((a) => a !== me);
491
+ let changed = false;
492
+ const walk = (v, path, depth) => {
493
+ if (v && typeof v === "object" && !Array.isArray(v) && depth <= LEAF_DEPTH) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x, path ? `${path}.${k}` : k, depth + 1)]));
494
+ const own = mine(path);
495
+ if (own === undefined || own === v || !others.some((a) => a.bodies.get(door)?.get(path) === v)) return v;
496
+ changed = true;
497
+ return own;
498
+ };
499
+ const out = walk(parsed, "", 0);
500
+ return !changed ? body : typeof body === "string" ? JSON.stringify(out) : out;
501
+ },
502
+ };
503
+ }
504
+
505
+ // Who a request is signed in as, read on this machine: the subject of every JWT it carries, else the
506
+ // sign-in values themselves. "" when it carries none. A CSRF token says nothing about who.
507
+ const SIGN_IN_HEADER = /^(?:authorization|proxy-authorization|cookie)$|api[-_]?key|token|secret|session|auth/i;
508
+ const NOT_WHO = /csrf|xsrf/i;
509
+ function signInKey(headers) {
510
+ const signIns = [];
511
+ for (const [name, value] of Object.entries(headers)) {
512
+ if (!SIGN_IN_HEADER.test(name) || NOT_WHO.test(name)) continue;
513
+ if (name.toLowerCase() !== "cookie") { signIns.push([name, String(value).replace(/^bearer\s+/i, "")]); continue; }
514
+ const pairs = String(value).split(/;\s*/).map((pair) => pair.split("=")).filter(([k, ...v]) => k && v.length && !NOT_WHO.test(k)).map(([k, ...v]) => [k, v.join("=")]);
515
+ const named = pairs.filter(([k]) => SIGN_IN_HEADER.test(k) || /sid|jwt|user|login/i.test(k));
516
+ signIns.push(...(named.length ? named : pairs));
517
+ }
518
+ const subjects = signIns.map(([, v]) => subjectOf(v)).filter(Boolean);
519
+ return (subjects.length ? subjects : signIns.map(([k, v]) => `${k}=${v}`)).sort().join("\n");
520
+ }
521
+ const subjectOf = (token) => {
522
+ const payload = JWT.exec(token)?.[1];
523
+ if (!payload) return null;
524
+ try { const { iss = "", sub } = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); return typeof sub === "string" && sub ? `${iss}|${sub}` : null; } catch { return null; }
525
+ };
526
+
527
+ export function identityOf(headers, now = Date.now()) {
528
+ if (!headers) return null;
529
+ const named = (name) => String(Object.entries(headers).find(([k]) => k.toLowerCase() === name)?.[1] ?? "");
530
+ const auth = named("authorization");
531
+ // A CSRF token can be a JWT too, and says nothing about when the sign-in ends.
532
+ const cookies = named("cookie").split(/;\s*/).map((pair) => pair.split("=")).filter(([name]) => !/csrf|xsrf/i.test(name)).map((kv) => kv.slice(1).join("=")).filter(Boolean);
533
+ const kind = /^bearer\s/i.test(auth) ? "bearer" : auth ? "authorization" : cookies.length ? "cookie" : "none";
534
+ const exps = [auth.replace(/^bearer\s+/i, ""), ...cookies].map(expOf).filter((e) => e !== null);
535
+ return { kind, expiresInS: exps.length ? Math.round(Math.min(...exps) - now / 1000) : null };
536
+ }
537
+
538
+ // A frame the hook found in a build folder comes as "chunk:line:column" under the chunk's absolute
539
+ // path (lib/trace.cjs). It is placed here, in the command, through the chunk's source map, at the
540
+ // app's own source line; a frame the map puts in a dependency, or cannot place, is dropped, and the
541
+ // next frame of the stack is the one kept. Any other frame passes as it came.
542
+ const BUILT = /\/(?:\.next|\.nuxt|\.svelte-kit|\.output|\.turbo|\.vercel|dist|build|out)\//;
543
+ const BUILT_AT = /^(\/[^\n\r\t]{1,1000}):(\d{1,7}):(\d{1,7})$/;
544
+ const MAPS_KEPT = 64, MAP_BYTES = 64 * 1024 * 1024;
545
+ const maps = new Map();
546
+ function mapOf(chunk) {
547
+ let mtime;
548
+ try { mtime = statSync(chunk).mtimeMs; } catch { return null; }
549
+ const had = maps.get(chunk);
550
+ if (had?.mtime === mtime) return had.map;
551
+ let map = null;
552
+ try {
553
+ const text = readFileSync(chunk, "utf8");
554
+ const url = /\/\/[#@] sourceMappingURL=(\S+)\s*$/.exec(text)?.[1];
555
+ const inline = url?.startsWith("data:") ? url.slice(url.indexOf(",") + 1) : null;
556
+ const file = url && !inline ? join(dirname(chunk), decodeURIComponent(url)) : `${chunk}.map`;
557
+ // The map named by the chunk is read only from inside the build folder the chunk sits in: a
558
+ // sourceMappingURL is the bundler's word, never a path out to the rest of the machine.
559
+ if (inline === null && relative(chunk.slice(0, chunk.search(BUILT)), file).startsWith("..")) throw new Error("outside the build");
560
+ if (inline === null && statSync(file).size > MAP_BYTES) throw new Error("too large");
561
+ const payload = inline === null ? readFileSync(file, "utf8") : url.includes(";base64,") ? Buffer.from(inline, "base64").toString("utf8") : decodeURIComponent(inline);
562
+ map = new SourceMap(JSON.parse(payload));
563
+ } catch { /* no map: the frame is not placed */ }
564
+ maps.set(chunk, { mtime, map });
565
+ while (maps.size > MAPS_KEPT) maps.delete(maps.keys().next().value);
566
+ return map;
567
+ }
568
+ // A map's source as a path relative to the folder the build folder sits in: "[project]/src/a.ts"
569
+ // (Turbopack), "webpack://_N_E/./src/a.ts" (webpack) or a file URL. In a workspace Turbopack's
570
+ // project is the workspace root, so the folders above are tried until the file is there.
571
+ function sourcePath(source, root) {
572
+ let s = String(source).replace(/^webpack:\/\/[^/]*\//, "").replace(/^turbopack:\/\/\//, "").replace(/^\[project\]\//, "").replace(/^\.\//, "");
573
+ if (s.startsWith("file://")) { try { s = fileURLToPath(s); } catch { return null; } }
574
+ if (!s || /(?:^|\/)node_modules\//.test(s) || s.startsWith("[")) return null;
575
+ if (isAbsolute(s)) { const rel = existsSync(s) ? relative(root, s) : null; return rel && !rel.startsWith("..") ? rel : null; }
576
+ for (let dir = root, up = 0; up < 5; dir = dirname(dir), up++) {
577
+ if (existsSync(join(dir, s))) { const rel = relative(root, join(dir, s)); return rel.startsWith("..") ? null : rel; }
578
+ if (dirname(dir) === dir) break;
579
+ }
580
+ return null;
581
+ }
582
+ export function placeBuilt(at) {
583
+ const m = BUILT_AT.exec(at);
584
+ if (!m) return at;
585
+ const chunk = m[1];
586
+ const cut = chunk.search(BUILT);
587
+ const map = cut > 0 ? mapOf(chunk) : null;
588
+ if (!map) return null;
589
+ const hit = map.findEntry(Number(m[2]) - 1, Number(m[3]) - 1);
590
+ if (!hit?.originalSource) return null;
591
+ const file = sourcePath(hit.originalSource, chunk.slice(0, cut));
592
+ return file ? `${file}:${hit.originalLine + 1}` : null;
593
+ }
594
+ // Five placed frames are kept, the count a stack of the app's own frames already carries.
595
+ const placed = (list) => (Array.isArray(list) ? [...new Set(list.filter((c) => typeof c === "string").map(placeBuilt).filter(Boolean))].slice(0, 5) : list);
596
+ // A site written in a bundle carries the frames after its write, nearest first: the first placed in
597
+ // the app's own source is where the app put the chunk into the stream.
598
+ const placedSites = (list) => (Array.isArray(list)
599
+ ? list.flatMap((site) => { const at = (Array.isArray(site?.at) ? site.at : [site?.at]).filter((a) => typeof a === "string").map(placeBuilt).find(Boolean); return at ? [{ ...site, at }] : []; })
600
+ : list);