cortad 0.2.2 → 0.3.0-rc.10

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/lib/trace.cjs CHANGED
@@ -20,8 +20,284 @@ if (FILE) {
20
20
  row({ hello: isBun ? "bun" : "node", pid: process.pid });
21
21
  // Which port this process serves. Every process the start command spawns loads this file, turbo's
22
22
  // own launcher included, so "loaded" is not "watching your app": only a listener on the app's port is.
23
- const listening = (port) => { if (Number.isInteger(port) && port > 0) row({ listen: port, pid: process.pid }); };
23
+ const listening = (port) => { if (Number.isInteger(port) && port > 0) { row({ listen: port, pid: process.pid }); sayRoutes(port); } };
24
+
25
+ // The app's own route table, so the run knows every door it has before a message is sent.
26
+ // Express keeps no mount prefix once a router is built (Express 5 layers keep no path at all),
27
+ // so its registrations are watched as they happen; the others are read off the app.
28
+ const ROUTES_MAX = 400;
29
+ const pathOf = (p) => String(p)
30
+ .replace(/\{(\/?[:*][^{}]*)\}/g, "$1")
31
+ .replace(/:(\w+)(?:\{[^{}]*\})?\??/g, "{$1}")
32
+ .replace(/\*(\w+)/g, "{$1}");
33
+ const joined = (a, b) => ("/" + [a, b].map((s) => String(s).replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/"));
34
+ const nodePath = require("node:path");
35
+ // The file that registered a route: the first frame outside this hook and outside node_modules.
36
+ const callerFile = () => {
37
+ for (const line of String(new Error().stack).split("\n").slice(2)) {
38
+ const m = /\(?(?:file:\/\/)?(\/[^()]+?):\d+:\d+\)?$/.exec(line.trim());
39
+ if (!m || m[1] === __filename || m[1].includes("/node_modules/")) continue;
40
+ const rel = nodePath.relative(process.cwd(), decodeURI(m[1]));
41
+ return rel.startsWith("..") ? "" : rel;
42
+ }
43
+ return "";
44
+ };
45
+ const seen = new Set();
46
+ let nest = false;
47
+
48
+ const expressRoutes = [];
49
+ const expressMounts = [];
50
+ const OWNER = Symbol("cortad.router");
51
+ const isApp = (o) => typeof o === "function" && typeof o.set === "function" && typeof o.handle === "function";
52
+ const isRouter = (o) => typeof o === "function" && Array.isArray(o.stack) && typeof o.handle === "function";
53
+ const useArgs = (args) => {
54
+ let first = args[0];
55
+ while (Array.isArray(first) && first.length) first = first[0];
56
+ const pathed = typeof first !== "function";
57
+ return { paths: pathed ? [].concat(args[0]).filter((p) => typeof p === "string") : ["/"], fns: args.slice(pathed ? 1 : 0).flat(Infinity) };
58
+ };
59
+ const patchExpress = (express) => {
60
+ seen.add("express");
61
+ const routerProto = typeof express.Router.prototype.route === "function" ? express.Router.prototype : express.Router;
62
+ const mounting = (proto, wanted) => {
63
+ const use = proto.use;
64
+ proto.use = function (...args) {
65
+ try {
66
+ const { paths, fns } = useArgs(args);
67
+ for (const fn of fns) if (wanted(fn)) for (const path of paths) expressMounts.push({ parent: this, child: fn, path });
68
+ } catch { /* not ours to fail */ }
69
+ return use.apply(this, args);
70
+ };
71
+ };
72
+ // Express 4 and 5 both pass a plain router through to the router's own use, and hide a
73
+ // mounted sub-app behind a wrapper there, so each level records only what it can see.
74
+ mounting(routerProto, isRouter);
75
+ mounting(express.application, isApp);
76
+ const route = routerProto.route;
77
+ routerProto.route = function (...args) {
78
+ const made = route.apply(this, args);
79
+ try { made[OWNER] = this; } catch { /* frozen */ }
80
+ return made;
81
+ };
82
+ for (const verb of ["get", "post", "put", "patch", "delete", "all"]) {
83
+ const add = express.Route.prototype[verb];
84
+ if (typeof add !== "function") continue;
85
+ express.Route.prototype[verb] = function (...args) {
86
+ try {
87
+ if (expressRoutes.length < ROUTES_MAX * 4) {
88
+ const fns = args.flat(Infinity);
89
+ expressRoutes.push({ owner: this[OWNER], verb, paths: [].concat(this.path).filter((p) => typeof p === "string"), handler: fns[fns.length - 1], file: callerFile() });
90
+ }
91
+ } catch { /* not ours to fail */ }
92
+ return add.apply(this, args);
93
+ };
94
+ }
95
+ };
96
+ const expressTable = (add) => {
97
+ // An app is known by its router once it has one: routes are registered on the router.
98
+ // Express 4's app.router getter throws; its router is _router, made on the first route.
99
+ const own = (o) => {
100
+ if (!isApp(o) || o._router) return (o && o._router) || o;
101
+ try { return o.router || o; } catch { return o; }
102
+ };
103
+ const ups = new Map();
104
+ for (const m of expressMounts) {
105
+ const child = own(m.child);
106
+ if (!ups.has(child)) ups.set(child, []);
107
+ ups.get(child).push({ parent: own(m.parent), path: m.path });
108
+ }
109
+ const prefixes = (router, depth) => (!ups.has(router) || depth > 8 ? [""] : ups.get(router).flatMap((m) => prefixes(m.parent, depth + 1).map((p) => joined(p, m.path))));
110
+ for (const r of expressRoutes) {
111
+ for (const prefix of prefixes(own(r.owner), 0)) for (const path of r.paths) add(r.verb === "all" ? null : [r.verb], joined(prefix, path), r.handler, r.file);
112
+ }
113
+ };
114
+
115
+ // Fastify says each instance it makes on a diagnostics channel, and a root onRoute hook sees
116
+ // every route with its plugin prefix already applied.
117
+ const fastifyRoutes = [];
118
+ try {
119
+ require("node:diagnostics_channel").subscribe("fastify.initialization", ({ fastify }) => {
120
+ seen.add("fastify");
121
+ try {
122
+ fastify.addHook("onRoute", (o) => {
123
+ if (fastifyRoutes.length < ROUTES_MAX * 2) fastifyRoutes.push({ methods: [].concat(o.method), path: o.url, handler: o.handler, file: callerFile() });
124
+ });
125
+ } catch { /* an instance that takes no hooks */ }
126
+ });
127
+ } catch { /* no diagnostics channel */ }
128
+
129
+ // Hono and Koa keep their tables on the app, so their apps are held as they are made. Only
130
+ // what is loaded through require is seen: Hono's ES module build never passes through here.
131
+ const honoApps = new Set();
132
+ const koaApps = new Set();
133
+ const holding = new WeakMap();
134
+ const heldHono = (exported) => {
135
+ const d = Object.getOwnPropertyDescriptor(exported, "Hono");
136
+ if (!d || ("value" in d && !d.writable && !d.configurable)) return exported;
137
+ seen.add("hono");
138
+ const Hono = new Proxy(exported.Hono, { construct(t, a, nt) { const app = Reflect.construct(t, a, nt); if (honoApps.size < 50) honoApps.add(app); return app; } });
139
+ return new Proxy(exported, { get: (t, k, r) => (k === "Hono" ? Hono : Reflect.get(t, k, r)) });
140
+ };
141
+ const patchKoa = (Koa) => {
142
+ seen.add("koa");
143
+ const callback = Koa.prototype.callback;
144
+ Koa.prototype.callback = function (...a) { if (koaApps.size < 50) koaApps.add(this); return callback.apply(this, a); };
145
+ };
146
+ const adopted = (request, exported) => {
147
+ if (!exported || !/(?:^|node_modules\/)(?:express|koa|hono|@nestjs\/core)(?:\/index\.js)?$|\/lib\/(?:express|application)(?:\.js)?$/.test(request)) return exported;
148
+ if (holding.has(exported)) return holding.get(exported);
149
+ let out = exported;
150
+ if (typeof exported === "function" && exported.application && typeof exported.Router === "function" && typeof exported.Route === "function") patchExpress(exported);
151
+ else if (typeof exported === "function" && exported.prototype && typeof exported.prototype.callback === "function" && typeof exported.prototype.createContext === "function") patchKoa(exported);
152
+ else if (typeof exported.Hono === "function") out = heldHono(exported);
153
+ else if (exported.NestFactory) nest = true;
154
+ holding.set(exported, out);
155
+ return out;
156
+ };
157
+ const Module = require("node:module");
158
+ const load = Module._load;
159
+ Module._load = function (request, ...rest) {
160
+ const exported = load.call(this, request, ...rest);
161
+ try { return adopted(String(request), exported); } catch { return exported; }
162
+ };
163
+
164
+ let bunRoutes = [];
165
+ const table = () => {
166
+ const out = new Map();
167
+ const from = new Set();
168
+ const add = (framework) => (methods, path, handler, file) => {
169
+ if (typeof path !== "string") return;
170
+ for (const m of methods || ["POST", "GET"]) {
171
+ const method = String(m).toUpperCase();
172
+ const at = pathOf(path.startsWith("/") ? path : "/" + path);
173
+ if (method === "HEAD" || method === "OPTIONS" || out.size >= ROUTES_MAX || out.has(`${method} ${at}`)) continue;
174
+ from.add(framework);
175
+ out.set(`${method} ${at}`, { method, path: at.slice(0, 1024), file: String(file || "").slice(0, 512), handler: String((handler && handler.name) || "").slice(0, 200) });
176
+ }
177
+ };
178
+ expressTable(add("express"));
179
+ for (const r of fastifyRoutes) add("fastify")(r.methods, r.path, r.handler, r.file);
180
+ // A sub-app's routes are copied into the app that mounts it, so the widest table is the app.
181
+ const hono = [...honoApps].reduce((a, b) => (Array.isArray(b.routes) && b.routes.length > ((a && a.routes.length) || 0) ? b : a), null);
182
+ for (const r of hono ? hono.routes : []) if (r.method !== "ALL" || !r.path.endsWith("*")) add("hono")(r.method === "ALL" ? null : [r.method], r.path, r.handler, "");
183
+ for (const app of koaApps) for (const mw of app.middleware || []) for (const layer of (mw.router && mw.router.stack) || []) {
184
+ if (layer.methods && layer.methods.length) add("koa")(layer.methods, layer.path, layer.stack && layer.stack[layer.stack.length - 1], "");
185
+ }
186
+ for (const r of bunRoutes) add(seen.has("elysia") ? "elysia" : "unknown")(r.methods, r.path, r.handler, "");
187
+ const framework = nest ? "nest" : [...from][0] || [...seen][0] || "unknown";
188
+ return { framework, routes: [...out.values()] };
189
+ };
190
+ let routesSaid = -1, lastPort = null;
191
+ function sayRoutes(port) {
192
+ try {
193
+ lastPort = port;
194
+ const { framework, routes } = table();
195
+ routesSaid = routes.length;
196
+ row({ routes: { framework, port, routes, openapi: null } });
197
+ } catch { /* the table is a courtesy; the app is not */ }
198
+ }
199
+ // Routes an app adds after it starts listening are there by its first request.
200
+ let recheck = true;
201
+ const recheckRoutes = () => {
202
+ if (!recheck || lastPort === null) return;
203
+ recheck = false;
204
+ setImmediate(() => { try { if (table().routes.length !== routesSaid) sayRoutes(lastPort); } catch { /* as above */ } });
205
+ };
24
206
  const MAX = 65536;
207
+ // The turn a message came in under, when the run tagged it: one opaque id per request, so a
208
+ // model call and its prompt can be pinned to the reply they produced even while five turns are
209
+ // in flight. It is read here and never shown to your app's own code path beyond the header.
210
+ const TURN = /^[A-Za-z0-9:_.-]{1,80}$/;
211
+ const turnOf = (headers) => {
212
+ const t = headers && (typeof headers.get === "function" ? headers.get("x-cortad-turn") : headers["x-cortad-turn"]);
213
+ return typeof t === "string" && TURN.test(t) ? t : undefined;
214
+ };
215
+ // The customer's own rule sentences, written beside the trace by the run, so each model call can
216
+ // say which of them its prompt carried: a rule is then asked only of a reply whose call was told
217
+ // it. Absent file, nothing is claimed either way. Reloaded when the file changes.
218
+ const RULES = process.env.CORTAD_RULES_FILE;
219
+ let rules = null, rulesAt = -1;
220
+ const norm = (s) => String(s || "")
221
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
222
+ .replace(/\\[nrt]/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\")
223
+ .toLowerCase().replace(/\s+/g, " ").trim();
224
+ // A sentence with a slot in it ("answer in {language}") is matched by its literal parts.
225
+ const partsOf = (text) => text.split(/\{[^}]*\}|\$\{[^}]*\}|%[sd]|<[^>]{1,40}>/).map(norm).filter((p) => p.length >= 12);
226
+ const rulesNow = () => {
227
+ if (!RULES) return null;
228
+ try {
229
+ const at = fs.statSync(RULES).mtimeMs;
230
+ if (at !== rulesAt) {
231
+ rulesAt = at;
232
+ const list = JSON.parse(fs.readFileSync(RULES, "utf8"));
233
+ rules = Array.isArray(list)
234
+ ? list.filter((r) => r && typeof r.id === "string" && typeof r.text === "string").map((r) => ({ id: r.id, parts: partsOf(r.text) })).filter((r) => r.parts.length)
235
+ : [];
236
+ }
237
+ } catch { /* not written yet, or gone */ }
238
+ return rules;
239
+ };
240
+ // What the app's tools answered, as the prompt of the next model call carries them: the tool
241
+ // messages of a chat body, the function outputs of a responses body, the tool_result blocks of
242
+ // an Anthropic one, the functionResponse parts of a Gemini one. They are the material a reply's
243
+ // facts rest on, and without them a real ticket id read as invented. Bounded per call.
244
+ const TOOL_TEXT = 3000, TOOLS_MAX = 12;
245
+ const textOf = (v) => (typeof v === "string" ? v : Array.isArray(v) ? v.map((p) => (p && typeof p === "object" ? (p.text || p.content || "") : String(p || ""))).filter(Boolean).join("\n") : v && typeof v === "object" ? JSON.stringify(v) : "");
246
+ const toolsIn = (sent) => {
247
+ let body; try { body = JSON.parse(sent); } catch { return undefined; }
248
+ if (!body || typeof body !== "object") return undefined;
249
+ const out = [];
250
+ const names = new Map();
251
+ const add = (name, text) => { const t = textOf(text).slice(0, TOOL_TEXT); if (t.trim() && out.length < TOOLS_MAX && !out.some((o) => o.text === t)) out.push({ name: String(name || "").slice(0, 80), text: t }); };
252
+ for (const m of Array.isArray(body.messages) ? body.messages : []) {
253
+ if (!m || typeof m !== "object") continue;
254
+ for (const c of Array.isArray(m.tool_calls) ? m.tool_calls : []) if (c && c.id && c.function) names.set(c.id, c.function.name);
255
+ if (Array.isArray(m.content)) for (const c of m.content) if (c && c.type === "tool_use" && c.id) names.set(c.id, c.name);
256
+ }
257
+ for (const m of Array.isArray(body.messages) ? body.messages : []) {
258
+ if (!m || typeof m !== "object") continue;
259
+ if (m.role === "tool" || m.role === "function") add(m.name || names.get(m.tool_call_id), m.content);
260
+ if (Array.isArray(m.content)) for (const c of m.content) if (c && c.type === "tool_result") add(names.get(c.tool_use_id), c.content);
261
+ }
262
+ const items = Array.isArray(body.input) ? body.input : [];
263
+ for (const it of items) if (it && it.type === "function_call" && it.call_id) names.set(it.call_id, it.name);
264
+ for (const it of items) if (it && it.type === "function_call_output") add(names.get(it.call_id), it.output);
265
+ for (const c of Array.isArray(body.contents) ? body.contents : []) for (const p of c && Array.isArray(c.parts) ? c.parts : []) if (p && p.functionResponse) add(p.functionResponse.name, p.functionResponse.response);
266
+ return out.length ? out : undefined;
267
+ };
268
+ // A framework that folds its tools' answers into the next prompt as plain text (an agent loop
269
+ // writing "Observation: ..." into a user message) sends no tool message to find. Within one
270
+ // turn, what the second call's prompt carries that the first one did not, outside the model's
271
+ // own words, is that material. The first call of a turn holds the customer's message and the
272
+ // system prompt, and both are in every later call, so neither is ever counted.
273
+ const textsOf = (body) => {
274
+ const out = new Set();
275
+ const take = (v) => { const t = textOf(v).trim(); if (t) out.add(t.slice(0, TOOL_TEXT)); };
276
+ for (const m of Array.isArray(body.messages) ? body.messages : []) if (m && typeof m === "object" && m.role !== "assistant") take(m.content);
277
+ for (const it of Array.isArray(body.input) ? body.input : []) if (it && typeof it === "object" && it.role !== "assistant" && it.type !== "function_call") take(it.content ?? it.output);
278
+ if (typeof body.system === "string") take(body.system);
279
+ for (const c of Array.isArray(body.contents) ? body.contents : []) if (c && c.role !== "model") for (const p of Array.isArray(c.parts) ? c.parts : []) if (p && typeof p.text === "string") take(p.text);
280
+ return out;
281
+ };
282
+ const seenByTurn = new Map();
283
+ const materialIn = (sent, turn) => {
284
+ if (!turn) return undefined;
285
+ let body; try { body = JSON.parse(sent); } catch { return undefined; }
286
+ if (!body || typeof body !== "object") return undefined;
287
+ const now = textsOf(body);
288
+ const before = seenByTurn.get(turn);
289
+ seenByTurn.set(turn, now);
290
+ if (seenByTurn.size > 200) seenByTurn.delete(seenByTurn.keys().next().value);
291
+ if (!before) return undefined;
292
+ const fresh = [...now].filter((t) => !before.has(t)).slice(0, TOOLS_MAX).map((t) => ({ name: "", text: t }));
293
+ return fresh.length ? fresh : undefined;
294
+ };
295
+ const rulesIn = (sent) => {
296
+ const list = rulesNow();
297
+ if (!list) return undefined;
298
+ const body = norm(sent);
299
+ return list.filter((r) => r.parts.every((p) => body.includes(p))).map((r) => r.id);
300
+ };
25
301
  // Where models are served, by host, and the paths every OpenAI-shaped or vendor endpoint ends with.
26
302
  const MODEL_HOST = /(?:^|\.)(?:openai\.com|anthropic\.com|fireworks\.ai|openrouter\.ai|groq\.com|mistral\.ai|together\.xyz|together\.ai|deepseek\.com|cohere\.ai|cohere\.com|perplexity\.ai|x\.ai|googleapis\.com|openai\.azure\.com|cognitiveservices\.azure\.com|amazonaws\.com|replicate\.com|huggingface\.co|cerebras\.ai|deepinfra\.com|novita\.ai|moonshot\.cn|dashscope\.aliyuncs\.com|bigmodel\.cn|ai-gateway\.vercel\.sh|gateway\.ai\.cloudflare\.com|helicone\.ai|portkey\.ai)$/i;
27
303
  const MODEL_PATH = /\/(?:chat\/completions|completions|responses|messages|embeddings)$|:(?:generateContent|streamGenerateContent)|\/invoke(?:-with-response-stream)?$|\/api\/(?:chat|generate)$/i;
@@ -49,8 +325,10 @@ if (FILE) {
49
325
  const emit = http.Server.prototype.emit;
50
326
  http.Server.prototype.emit = function (type, req, ...rest) {
51
327
  if (type === "listening") { try { listening(this.address()?.port); } catch { /* not a TCP server */ } }
328
+ if (type === "request") recheckRoutes();
52
329
  if (type !== "request" || !req || !req.method || /^(?:GET|HEAD|OPTIONS)$/.test(req.method)) return emit.call(this, type, req, ...rest);
53
- const ctx = { method: req.method, path: req.url || "/", headers: { ...req.headers }, chunks: [], size: 0, noted: false };
330
+ const { "x-cortad-turn": _turn, ...headers } = req.headers;
331
+ const ctx = { method: req.method, path: req.url || "/", headers, chunks: [], size: 0, noted: false, turn: turnOf(req.headers) };
54
332
  const push = req.push;
55
333
  req.push = function (chunk, encoding) {
56
334
  if (chunk && ctx.size < MAX) { const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding); ctx.chunks.push(b); ctx.size += b.length; }
@@ -82,10 +360,11 @@ if (FILE) {
82
360
  // is read from a clone, so the app's own reader is untouched.
83
361
  if (isBun) {
84
362
  const inside = (handler, self) => function (req, server) {
363
+ recheckRoutes();
85
364
  if (!req || /^(?:GET|HEAD|OPTIONS)$/.test(req.method)) return handler.call(self, req, server);
86
365
  let path = "/";
87
366
  try { const u = new URL(req.url); path = u.pathname + u.search; } catch { /* keep "/" */ }
88
- const ctx = { method: req.method, path, headers: Object.fromEntries(req.headers), chunks: [], size: 0, noted: false };
367
+ const ctx = { method: req.method, path, headers: Object.fromEntries([...req.headers].filter(([k]) => k !== "x-cortad-turn")), chunks: [], size: 0, noted: false, turn: turnOf(req.headers) };
89
368
  // ponytail: a body is copied only when it is small or says it is text; an upload is left alone.
90
369
  const size = Number(req.headers.get("content-length") || 0);
91
370
  if (size <= MAX || /json|text|form/i.test(req.headers.get("content-type") || "")) {
@@ -105,6 +384,11 @@ if (FILE) {
105
384
  }
106
385
  return out;
107
386
  };
387
+ // Bun's own route table: a handler per path, or a handler per method.
388
+ const bunTable = (table) => Object.entries(table && typeof table === "object" ? table : {}).map(([path, value]) => (
389
+ typeof value === "function" ? { methods: null, path, handler: value }
390
+ : value instanceof Response ? { methods: ["GET"], path, handler: null }
391
+ : { methods: Object.keys(value || {}), path, handler: null }));
108
392
  const serve = Bun.serve;
109
393
  Bun.serve = function (options, ...rest) {
110
394
  let wrapped = options;
@@ -115,6 +399,10 @@ if (FILE) {
115
399
  if (options.routes) wrapped.routes = routes(options.routes);
116
400
  }
117
401
  } catch { wrapped = options; }
402
+ try {
403
+ if (Object.keys(require.cache).some((k) => k.includes("/node_modules/elysia/"))) seen.add("elysia");
404
+ bunRoutes = bunTable(options && options.routes);
405
+ } catch { /* no table */ }
118
406
  const server = serve.call(this, wrapped, ...rest);
119
407
  try { listening(server && server.port); } catch { /* not ours to fail */ }
120
408
  return server;
@@ -166,10 +454,12 @@ if (FILE) {
166
454
  const m = /\/models\/([^/:]+):|\/model\/([^/]+)\/(?:invoke|converse)/.exec(path || "");
167
455
  return m ? decodeURIComponent(m[1] || m[2]) : "";
168
456
  };
169
- const meter = ({ host, path, sent, status, type, body }) => {
457
+ const meter = ({ host, path, sent, status, type, body, turn }) => {
170
458
  try {
171
459
  const reply = readReply(type, String(body || "").slice(0, REPLY_MAX));
172
- const row = { at: Date.now(), host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens) };
460
+ const rules = rulesIn(sent);
461
+ const tools = toolsIn(sent) ?? materialIn(sent, turn);
462
+ const row = { at: Date.now(), host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}) };
173
463
  fs.appendFileSync(FILE, JSON.stringify({ call: row }) + "\n", { mode: 0o600 });
174
464
  } catch { /* the command is gone */ }
175
465
  };
@@ -196,7 +486,8 @@ if (FILE) {
196
486
  } catch { return null; }
197
487
  };
198
488
  const depRow = (dep, status, code) => {
199
- try { fs.appendFileSync(FILE, JSON.stringify({ dep: { at: Date.now(), env: dep.env, host: dep.host, status, code: code ? String(code).slice(0, 40) : undefined } }) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ }
489
+ const turn = (als.getStore() || {}).turn;
490
+ try { fs.appendFileSync(FILE, JSON.stringify({ dep: { at: Date.now(), ...(dep.env ? { env: dep.env } : {}), host: dep.host, status, code: code ? String(code).slice(0, 40) : undefined, ...(turn ? { turn } : {}) } }) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ }
200
491
  };
201
492
 
202
493
  // Outbound: the model call your app makes while it handles that request.
@@ -210,7 +501,10 @@ if (FILE) {
210
501
  if (isModelCall(u.host, u.pathname)) url = u;
211
502
  } catch { /* not a URL we can read */ }
212
503
  if (!url) {
213
- const dep = depOf(input);
504
+ // Every outbound host, named by the setting that points at it when one does, and by the
505
+ // host alone otherwise: the search provider behind TAVILY_API_KEY has no URL setting.
506
+ let dep = depOf(input);
507
+ if (!dep) { try { const u = new URL(typeof input === "string" ? input : input && input.url ? input.url : String(input)); if (/^https?:$/.test(u.protocol) && !/^(localhost|127\.0\.0\.1|\[::1\])$/.test(u.hostname)) dep = { host: u.hostname }; } catch { /* not a URL */ } }
214
508
  if (!dep) return realFetch.apply(this, arguments);
215
509
  return realFetch.apply(this, arguments).then(
216
510
  (res) => { depRow(dep, res.status); return res; },
@@ -219,7 +513,7 @@ if (FILE) {
219
513
  }
220
514
  const sent = bodyText(init && init.body);
221
515
  note(als.getStore(), sent);
222
- const call = { host: url.host, path: url.pathname, sent };
516
+ const call = { host: url.host, path: url.pathname, sent, turn: (als.getStore() || {}).turn };
223
517
  return realFetch.apply(this, arguments).then((res) => {
224
518
  try {
225
519
  // A clone is a tee: your app's branch gets every byte as fast as it reads, this one is read to the end.
@@ -248,7 +542,7 @@ if (FILE) {
248
542
  const end = req.end;
249
543
  req.write = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); return write.call(this, chunk, ...r); };
250
544
  req.end = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); sent = Buffer.concat(parts).toString("utf8"); note(ctx, sent); return end.call(this, chunk, ...r); };
251
- const call = { host: String(host || ""), path: String(path || "").split("?")[0] };
545
+ const call = { host: String(host || ""), path: String(path || "").split("?")[0], turn: ctx && ctx.turn };
252
546
  let done = false;
253
547
  const once = (row) => { if (!done) { done = true; meter({ ...call, sent, ...row }); } };
254
548
  req.once("error", () => once({ status: 0, type: "", body: "" }));