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/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
|
|
3
|
-
// cloud
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
29
|
-
|
|
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
|
-
//
|
|
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.
|
|
62
|
-
if (row.
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
118
|
-
// prompt was handed as retrieved context, or
|
|
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
|
|
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
|
-
|
|
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(-
|
|
166
|
-
|
|
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);
|