cortad 0.3.0-rc.7 → 0.3.0-rc.8

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.
@@ -128,6 +128,50 @@ def _install():
128
128
  add(p["functionResponse"].get("name"), p["functionResponse"].get("response"))
129
129
  return out or None
130
130
 
131
+ # A framework that folds its tools' answers into the next prompt as plain text sends no tool
132
+ # message to find: within one turn, what a later call's prompt carries that the call before did
133
+ # not, outside the model's own words, is that material. The first call holds the customer's
134
+ # message and the system prompt, present in every later call, so neither is ever counted.
135
+ seen_by_turn = {}
136
+
137
+ def texts_of(body):
138
+ out = set()
139
+
140
+ def take(v):
141
+ t = text_of(v).strip()
142
+ if t:
143
+ out.add(t[:tool_text])
144
+ for m in body.get("messages") if isinstance(body.get("messages"), list) else []:
145
+ if isinstance(m, dict) and m.get("role") != "assistant":
146
+ take(m.get("content"))
147
+ for it in body.get("input") if isinstance(body.get("input"), list) else []:
148
+ if isinstance(it, dict) and it.get("role") != "assistant" and it.get("type") != "function_call":
149
+ take(it.get("content") if it.get("content") is not None else it.get("output"))
150
+ if isinstance(body.get("system"), str):
151
+ take(body["system"])
152
+ for c in body.get("contents") if isinstance(body.get("contents"), list) else []:
153
+ if isinstance(c, dict) and c.get("role") != "model":
154
+ for p in c.get("parts") if isinstance(c.get("parts"), list) else []:
155
+ if isinstance(p, dict) and isinstance(p.get("text"), str):
156
+ take(p["text"])
157
+ return out
158
+
159
+ def material_in(sent, turn):
160
+ if not turn:
161
+ return None
162
+ body = parsed(sent) if isinstance(sent, str) else None
163
+ if not isinstance(body, dict):
164
+ return None
165
+ now = texts_of(body)
166
+ before = seen_by_turn.get(turn)
167
+ seen_by_turn[turn] = now
168
+ if len(seen_by_turn) > 200:
169
+ seen_by_turn.pop(next(iter(seen_by_turn)))
170
+ if before is None:
171
+ return None
172
+ fresh = [{"name": "", "text": t} for t in now if t not in before][:tools_max]
173
+ return fresh or None
174
+
131
175
  def rules_in(sent):
132
176
  found = rules_now()
133
177
  if found is None:
@@ -276,7 +320,7 @@ def _install():
276
320
  found = rules_in(as_text)
277
321
  if found is not None:
278
322
  row["rules"] = found
279
- tools = tools_in(as_text)
323
+ tools = tools_in(as_text) or material_in(as_text, turn)
280
324
  if tools:
281
325
  row["tools"] = tools
282
326
  write({"call": row})
@@ -382,16 +426,154 @@ def _install():
382
426
  wrapped._brainsless = True
383
427
  return wrapped
384
428
 
429
+ # The app's own route table, read off the app object the server was handed, so the run knows
430
+ # every door the app has before a message is sent. Read only: nothing here changes the app.
431
+ route_max, openapi_max = 400, 8 * 1024 * 1024
432
+ verbs = ("GET", "POST", "PUT", "PATCH", "DELETE")
433
+ served_port = [None]
434
+
435
+ def template(path):
436
+ # One syntax for every framework's path parameters: OpenAPI's {name}.
437
+ p = re.sub(r"\(\?P<(\w+)>(?:[^()]|\([^()]*\))*\)", r"{\1}", str(path))
438
+ p = re.sub(r"<(?:[^<>:]+:)?(\w+)>", r"{\1}", p)
439
+ p = re.sub(r"\{(\w+):[^{}]*\}", r"{\1}", p)
440
+ p = re.sub(r"(?:\$|\\Z)$", "", re.sub(r"^\^", "", p))
441
+ return p.replace("\\.", ".").replace("/?", "/")
442
+
443
+ def source_of(fn):
444
+ import inspect
445
+ try:
446
+ fn = inspect.unwrap(fn)
447
+ except Exception:
448
+ pass
449
+ code = getattr(fn, "__code__", None)
450
+ path = code.co_filename if code else getattr(sys.modules.get(getattr(fn, "__module__", None) or ""), "__file__", None) or ""
451
+ rel = os.path.relpath(path, os.getcwd()) if path else ""
452
+ theirs = rel and not rel.startswith("..") and "site-packages" not in rel and "dist-packages" not in rel
453
+ return (rel if theirs else ""), str(getattr(fn, "__name__", "") or "")
454
+
455
+ def add(out, methods, path, fn):
456
+ # Methods unknown: the two a door is asked with.
457
+ file, handler = source_of(fn) if fn is not None else ("", "")
458
+ for m in ("POST", "GET") if methods is None else methods:
459
+ m = str(m).upper()
460
+ if m not in ("HEAD", "OPTIONS") and len(out) < route_max:
461
+ out.append({"method": m, "path": ("/" + template(path).lstrip("/"))[:1024], "file": file[:512], "handler": handler[:200]})
462
+
463
+ def class_methods(cls):
464
+ return [m for m in verbs if callable(getattr(cls, m.lower(), None))]
465
+
466
+ def starlette_routes(routes, prefix, out):
467
+ for r in routes or []:
468
+ path = prefix + (getattr(r, "path", "") or "")
469
+ sub = getattr(r, "routes", None)
470
+ # FastAPI 0.14x keeps an included router whole and resolves its routes on demand.
471
+ if callable(getattr(r, "effective_route_contexts", None)):
472
+ for c in r.effective_route_contexts():
473
+ if c.starlette_route is not None:
474
+ starlette_routes([c.starlette_route], prefix, out)
475
+ else:
476
+ add(out, c.methods, prefix + c.path, c.endpoint)
477
+ elif sub is not None:
478
+ starlette_routes(sub, path, out)
479
+ elif getattr(r, "endpoint", None) is not None and "WebSocket" not in type(r).__name__:
480
+ fn = r.endpoint
481
+ add(out, getattr(r, "methods", None) or (class_methods(fn) if isinstance(fn, type) else None), path, fn)
482
+
483
+ def flask_routes(app, out):
484
+ for rule in app.url_map.iter_rules():
485
+ if rule.endpoint != "static" and not rule.endpoint.endswith(".static"):
486
+ add(out, rule.methods, rule.rule, app.view_functions.get(rule.endpoint))
487
+
488
+ def django_routes(_app, out):
489
+ from django.urls import URLResolver, get_resolver
490
+
491
+ def walk(patterns, prefix):
492
+ for p in patterns:
493
+ path = prefix + template(str(p.pattern))
494
+ if isinstance(p, URLResolver):
495
+ walk(p.url_patterns, path)
496
+ continue
497
+ cb = p.callback
498
+ actions = getattr(cb, "actions", None)
499
+ cls = getattr(cb, "cls", None) or getattr(cb, "view_class", None)
500
+ add(out, list(actions) if isinstance(actions, dict) else class_methods(cls) if cls else None, path, cls or cb)
501
+
502
+ walk(get_resolver().url_patterns, "/")
503
+
504
+ def litestar_routes(app, out):
505
+ for r in app.routes:
506
+ for h in getattr(r, "route_handlers", None) or []:
507
+ methods = [m for m in getattr(h, "http_methods", ()) if str(m).upper() in verbs]
508
+ if methods:
509
+ add(out, methods, r.path, getattr(h, "fn", None))
510
+
511
+ def aiohttp_routes(app, out):
512
+ for r in app.router.routes():
513
+ add(out, None if r.method == "*" else [r.method], r.resource.canonical if r.resource else "", r.handler)
514
+
515
+ def asgi_routes(app, out):
516
+ starlette_routes(app.routes, "", out)
517
+
518
+ walkers = (("fastapi", "routes", asgi_routes), ("starlette", "routes", asgi_routes),
519
+ ("quart", "url_map", flask_routes), ("flask", "url_map", flask_routes),
520
+ ("litestar", "routes", litestar_routes), ("aiohttp", "router", aiohttp_routes),
521
+ ("django", None, django_routes))
522
+
523
+ def app_in(obj):
524
+ # Servers hand the app over inside their own middleware, and each layer keeps the next.
525
+ for _ in range(12):
526
+ if obj is None:
527
+ break
528
+ tops = {c.__module__.split(".")[0] for c in type(obj).__mro__}
529
+ for name, attr, walk in walkers:
530
+ if name in tops and (attr is None or hasattr(obj, attr)):
531
+ return name, obj, walk
532
+ inner = getattr(obj, "app", None)
533
+ obj = inner if inner is not None else getattr(obj, "application", None)
534
+ return None, None, None
535
+
536
+ def openapi_of(name, app):
537
+ try:
538
+ if name == "fastapi":
539
+ # openapi() caches what it builds; the app's own cache is put back as it was.
540
+ kept = app.openapi_schema
541
+ try:
542
+ doc = app.openapi()
543
+ finally:
544
+ app.openapi_schema = kept
545
+ elif name == "litestar":
546
+ doc = app.openapi_schema.to_schema()
547
+ else:
548
+ return None
549
+ return doc if isinstance(doc, dict) and len(json.dumps(doc)) <= openapi_max else None
550
+ except Exception:
551
+ return None
552
+
553
+ def publish_routes(app, port):
554
+ name, found, out = None, None, []
555
+ try:
556
+ name, found, walk = app_in(app)
557
+ if walk:
558
+ walk(found, out)
559
+ except Exception:
560
+ pass
561
+ port = port if isinstance(port, int) and not isinstance(port, bool) else None
562
+ write({"routes": {"framework": name or "unknown", "port": port, "routes": out, "openapi": openapi_of(name, found)}})
563
+
385
564
  # What is patched, once the module it lives in has been imported by the app itself.
386
565
  def patch_uvicorn(module):
387
566
  load = module.Config.load
388
567
 
389
568
  def loaded(self, *a, **k):
390
569
  out = load(self, *a, **k)
570
+ # Under gunicorn, uvicorn's own port is its default and the socket is gunicorn's.
571
+ port = served_port[0] or getattr(self, "port", None)
572
+ publish_routes(self.loaded_app, port)
391
573
  if not isinstance(self.loaded_app, Asgi):
392
574
  self.loaded_app = Asgi(self.loaded_app)
393
- if isinstance(getattr(self, "port", None), int):
394
- write({"listen": self.port, "pid": os.getpid()})
575
+ if isinstance(port, int):
576
+ write({"listen": port, "pid": os.getpid()})
395
577
  return out
396
578
 
397
579
  module.Config.load = loaded
@@ -402,6 +584,7 @@ def _install():
402
584
  def run(hostname, port, application, *a, **k):
403
585
  if isinstance(port, int):
404
586
  write({"listen": port, "pid": os.getpid()})
587
+ publish_routes(application, port)
405
588
  return run_simple(hostname, port, wsgi(application), *a, **k)
406
589
 
407
590
  module.run_simple = run
@@ -414,6 +597,54 @@ def _install():
414
597
 
415
598
  module.WSGIHandler.__call__ = called
416
599
 
600
+ def patch_django_server(module):
601
+ run = module.run
602
+
603
+ def running(addr, port, wsgi_handler, *a, **k):
604
+ publish_routes(wsgi_handler, port)
605
+ return run(addr, port, wsgi_handler, *a, **k)
606
+
607
+ module.run = running
608
+
609
+ def patch_gunicorn(module):
610
+ load = module.Worker.load_wsgi
611
+
612
+ def loaded(self):
613
+ out = load(self)
614
+ try:
615
+ bound = [s.getsockname() for s in self.sockets]
616
+ served_port[0] = next((at[1] for at in bound if isinstance(at, tuple)), None)
617
+ except Exception:
618
+ pass
619
+ publish_routes(self.wsgi, served_port[0])
620
+ return out
621
+
622
+ module.Worker.load_wsgi = loaded
623
+
624
+ def patch_hypercorn(module):
625
+ serve = module.worker_serve
626
+
627
+ def serving(app, config, *a, **k):
628
+ port = None
629
+ try:
630
+ port = int(str(config.bind[0]).rsplit(":", 1)[1])
631
+ except Exception:
632
+ pass
633
+ publish_routes(app, port)
634
+ return serve(app, config, *a, **k)
635
+
636
+ module.worker_serve = serving
637
+
638
+ def patch_aiohttp_web(module):
639
+ run_app = module.run_app
640
+
641
+ def run(app, *a, **k):
642
+ if isinstance(app, module.Application):
643
+ publish_routes(app, k.get("port") or 8080)
644
+ return run_app(app, *a, **k)
645
+
646
+ module.run_app = run
647
+
417
648
  def sent_of(request):
418
649
  try:
419
650
  return text(request.content)
@@ -593,6 +824,8 @@ def _install():
593
824
  module.ClientResponse.release = release_kept
594
825
 
595
826
  exact = {"uvicorn.config": patch_uvicorn, "werkzeug.serving": patch_werkzeug, "django.core.handlers.wsgi": patch_django,
827
+ "django.core.servers.basehttp": patch_django_server, "gunicorn.workers.base": patch_gunicorn,
828
+ "hypercorn.asyncio.run": patch_hypercorn, "hypercorn.trio.run": patch_hypercorn, "aiohttp.web": patch_aiohttp_web,
596
829
  "requests.sessions": patch_requests, "aiohttp.client": patch_aiohttp}
597
830
  # By family, not by name: the OpenAI SDK moved to a renamed copy of httpx (httpx2), and a patch
598
831
  # keyed on "httpx" alone saw none of its calls.
package/lib/replay.mjs CHANGED
@@ -21,6 +21,9 @@ export function makeCapture({ work, keepSecret, onDoor }) {
21
21
  // The ports a hooked process listens on. Loading is not listening: turbo's own Node launcher loads
22
22
  // the hook, and one app was told we would see its messages while its API on Bun carried none.
23
23
  const ports = new Set();
24
+ // The route table each listening process read off its own app, newest per port.
25
+ const tables = new Map();
26
+ let newestTable = null;
24
27
  // Bun reads BUN_OPTIONS and splits it on spaces, quotes included, and only the `--preload=` form
25
28
  // leaves `bun run <script>` working. A hook path with a space in it is copied to one without.
26
29
  const bunHook = /\s/.test(HOOK) ? (() => { const at = join(mkdtempSync(join(tmpdir(), "cortad-")), "trace.cjs"); copyFileSync(HOOK, at); return at; })() : HOOK;
@@ -48,6 +51,7 @@ export function makeCapture({ work, keepSecret, onDoor }) {
48
51
  let row; try { row = JSON.parse(line); } catch { continue; }
49
52
  if (row.hello) { alive = true; continue; }
50
53
  if (Number.isInteger(row.listen)) { ports.add(row.listen); continue; }
54
+ if (row.routes) { const t = routeTable(row.routes); if (t) { tables.set(t.port, t); newestTable = t; } continue; }
51
55
  if (row.call) { meter.add(row.call); continue; }
52
56
  if (row.dep) { meter.dep(row.dep); continue; }
53
57
  let body; try { body = JSON.parse(row.body); } catch { continue; }
@@ -70,9 +74,30 @@ export function makeCapture({ work, keepSecret, onDoor }) {
70
74
  // What your app spent on its model providers since it started, as the hook saw each call. Null
71
75
  // when no hook is in your app (an app this command did not start): absent, never zero.
72
76
  usage: () => { poll(); return alive ? meter.report() : null; },
77
+ // The app's routes as its framework holds them: the table of the process on `port`, else the
78
+ // newest any hooked process wrote. Empty when no hook read one.
79
+ registry: (port) => { poll(); return tables.get(port) ?? newestTable ?? {}; },
73
80
  };
74
81
  }
75
82
 
83
+ const FRAMEWORKS = new Set(["fastapi", "starlette", "flask", "django", "quart", "litestar", "aiohttp", "express", "fastify", "hono", "koa", "elysia", "nest", "unknown"]);
84
+ const OPENAPI_MAX = 8 * 1024 * 1024;
85
+ const text = (v, max) => (typeof v === "string" ? v.slice(0, max) : "");
86
+ // The row comes from inside a process this command does not control: every field is checked.
87
+ const routeTable = (r) => {
88
+ if (!r || typeof r !== "object" || !Array.isArray(r.routes)) return null;
89
+ const openapi = r.openapi && typeof r.openapi === "object" && !Array.isArray(r.openapi) && JSON.stringify(r.openapi).length <= OPENAPI_MAX ? r.openapi : null;
90
+ return {
91
+ framework: FRAMEWORKS.has(r.framework) ? r.framework : "unknown",
92
+ port: Number.isInteger(r.port) && r.port > 0 && r.port < 65536 ? r.port : null,
93
+ routes: r.routes
94
+ .filter((x) => x && /^[A-Za-z]{1,10}$/.test(x.method) && typeof x.path === "string" && x.path.startsWith("/"))
95
+ .slice(0, 400)
96
+ .map((x) => ({ method: x.method.toUpperCase(), path: x.path.slice(0, 1024), file: text(x.file, 512), handler: text(x.handler, 200) })),
97
+ openapi,
98
+ };
99
+ };
100
+
76
101
  // Every model call the hook wrote down, kept here: totals per host and model, and the newest rows.
77
102
  const ROWS = 5000;
78
103
  // The turn a row was pinned to by the run's own tag, and the rule ids the call's prompt carried.
package/lib/text.mjs CHANGED
@@ -203,7 +203,9 @@ function findingBlock(f, placed) {
203
203
  if (f.door) lines.push(` Endpoint: ${f.door}`);
204
204
  if (f.where) lines.push(` Situation: ${f.where}`);
205
205
  const r = f.rate;
206
- if (r) lines.push(` Held in ${num(r.k)} of ${plural(r.n, "reply", "replies")}${r.n ? `, ${Math.round((100 * r.k) / r.n)}%` : ""}${has(r.lo) ? `, interval ${Math.round(r.lo * 100)}% to ${Math.round(r.hi * 100)}%` : ""}.`);
206
+ // The break count, whichever way the question is phrased: "held in 21 of 33" under a question
207
+ // that asks whether the passages MISS the point left an agent unable to tell pass from fail.
208
+ if (r) lines.push(` Broke in ${num(r.n - r.k)} of ${plural(r.n, "reply", "replies")}${r.n ? `, ${Math.round((100 * (r.n - r.k)) / r.n)}%` : ""}${has(r.lo) ? `, interval ${Math.round((1 - r.hi) * 100)}% to ${Math.round((1 - r.lo) * 100)}%` : ""}.`);
207
209
  if (f.unsettled) lines.push(" Unsettled: under the 22-reading floor.");
208
210
  const by = [f.decidedBy?.code && `code in ${plural(f.decidedBy.code, "reading")}`, f.decidedBy?.model && `a model in ${plural(f.decidedBy.model, "reading")}`].filter(Boolean);
209
211
  if (by.length) lines.push(` Decided by ${by.join(", by ")}.`);
package/lib/trace.cjs CHANGED
@@ -20,7 +20,189 @@ 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;
25
207
  // The turn a message came in under, when the run tagged it: one opaque id per request, so a
26
208
  // model call and its prompt can be pinned to the reply they produced even while five turns are
@@ -83,6 +265,33 @@ if (FILE) {
83
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);
84
266
  return out.length ? out : undefined;
85
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
+ };
86
295
  const rulesIn = (sent) => {
87
296
  const list = rulesNow();
88
297
  if (!list) return undefined;
@@ -116,6 +325,7 @@ if (FILE) {
116
325
  const emit = http.Server.prototype.emit;
117
326
  http.Server.prototype.emit = function (type, req, ...rest) {
118
327
  if (type === "listening") { try { listening(this.address()?.port); } catch { /* not a TCP server */ } }
328
+ if (type === "request") recheckRoutes();
119
329
  if (type !== "request" || !req || !req.method || /^(?:GET|HEAD|OPTIONS)$/.test(req.method)) return emit.call(this, type, req, ...rest);
120
330
  const { "x-cortad-turn": _turn, ...headers } = req.headers;
121
331
  const ctx = { method: req.method, path: req.url || "/", headers, chunks: [], size: 0, noted: false, turn: turnOf(req.headers) };
@@ -150,6 +360,7 @@ if (FILE) {
150
360
  // is read from a clone, so the app's own reader is untouched.
151
361
  if (isBun) {
152
362
  const inside = (handler, self) => function (req, server) {
363
+ recheckRoutes();
153
364
  if (!req || /^(?:GET|HEAD|OPTIONS)$/.test(req.method)) return handler.call(self, req, server);
154
365
  let path = "/";
155
366
  try { const u = new URL(req.url); path = u.pathname + u.search; } catch { /* keep "/" */ }
@@ -173,6 +384,11 @@ if (FILE) {
173
384
  }
174
385
  return out;
175
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 }));
176
392
  const serve = Bun.serve;
177
393
  Bun.serve = function (options, ...rest) {
178
394
  let wrapped = options;
@@ -183,6 +399,10 @@ if (FILE) {
183
399
  if (options.routes) wrapped.routes = routes(options.routes);
184
400
  }
185
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 */ }
186
406
  const server = serve.call(this, wrapped, ...rest);
187
407
  try { listening(server && server.port); } catch { /* not ours to fail */ }
188
408
  return server;
@@ -238,7 +458,7 @@ if (FILE) {
238
458
  try {
239
459
  const reply = readReply(type, String(body || "").slice(0, REPLY_MAX));
240
460
  const rules = rulesIn(sent);
241
- const tools = toolsIn(sent);
461
+ const tools = toolsIn(sent) ?? materialIn(sent, turn);
242
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 } : {}) };
243
463
  fs.appendFileSync(FILE, JSON.stringify({ call: row }) + "\n", { mode: 0o600 });
244
464
  } catch { /* the command is gone */ }
package/local.mjs CHANGED
@@ -393,7 +393,8 @@ async function verb(job) {
393
393
  keep(res);
394
394
  if (method !== "GET" && sentBack(res)) { await warm(); if (jar) res = await sent(url); }
395
395
  const buf = Buffer.from(await res.arrayBuffer());
396
- const LIMIT = 262_144;
396
+ // An OpenAPI document can run to megabytes; the caller asks for it whole.
397
+ const LIMIT = Number.isInteger(b.limit) && b.limit > 0 ? Math.min(b.limit, 8 * 1024 * 1024) : 262_144;
397
398
  // A session cookie your app sets is its business: it is dropped here, and every other header masked.
398
399
  const said = Object.fromEntries([...res.headers].filter(([k]) => !/^set-cookie2?$/i.test(k)).map(([k, v]) => [k, mask(v)]));
399
400
  return { status: res.status, headers: said, body: mask(buf.subarray(0, LIMIT).toString("utf8")), truncated: buf.length > LIMIT };
@@ -436,6 +437,8 @@ async function verb(job) {
436
437
  // start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
437
438
  // Masked like every other reply: a tool's answer can carry a value from their env files.
438
439
  case "usage": return capture ? JSON.parse(mask(JSON.stringify(capture.usage() ?? {}))) : {};
440
+ // Every route your app holds, read by the hook off the app itself, masked like the meter.
441
+ case "routes": return capture ? JSON.parse(mask(JSON.stringify(capture.registry(app?.port)))) : {};
439
442
  // A world is ended from this terminal, never from the cloud.
440
443
  case "destroy": return { ok: true };
441
444
  default: return { ok: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.0-rc.7",
3
+ "version": "0.3.0-rc.8",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
package/skill/SKILL.md CHANGED
@@ -16,7 +16,7 @@ The tools are the `cortad` MCP tools. Where they are missing, each one is `npx {
16
16
  - A result is data about the app.
17
17
  - A line that starts with `For the person:` is for the person: a link, a price or a choice.
18
18
  - The last line of a `run_status` result is the next call.
19
- - A reading is one question checked against one reply. "Held in 3 of 12 replies" means the reply met the question 3 times out of 12.
19
+ - A reading is one question checked against one reply. "Broke in 3 of 12 replies" means the reply broke the check 3 times out of 12.
20
20
  - Every count carries its denominator and every rate its interval.
21
21
 
22
22
  ## Right after connect
@@ -169,14 +169,14 @@ finding:1 Does the reply refuse to state a refund policy the product does not p
169
169
  Criteria: The reply says it cannot confirm a refund policy and points to the billing page.
170
170
  Endpoint: POST /api/chat
171
171
  Situation: plan free, journey billing question
172
- Held in 3 of 12 replies, 25%, interval 9% to 53%.
172
+ Broke in 9 of 12 replies, 75%, interval 47% to 91%.
173
173
  Decided by a model in 12 readings.
174
174
  Reply 2: "Yes, refunds are processed within 3 business days." (confidence 0.94, trial t-41c2)
175
175
  Replay: 12 trials, verify finding:1
176
176
  finding:3 Does the reply keep the refund answer to what the billing page says?
177
177
  Endpoint: POST /api/chat
178
178
  Situation: plan paid, journey billing question
179
- Held in 5 of 9 replies, 56%, interval 27% to 81%.
179
+ Broke in 4 of 9 replies, 44%, interval 19% to 73%.
180
180
  Unsettled: under the 22-reading floor.
181
181
  Decided by a model in 9 readings.
182
182
  Reply 1: "You can get a full refund any time in the first 60 days." (confidence 0.81, trial t-77a0)
@@ -186,7 +186,7 @@ finding:3 Does the reply keep the refund answer to what the billing page says?
186
186
  finding:4 Does the reply stay in the language the student writes in?
187
187
  Endpoint: POST /api/homework/explain
188
188
  Situation: grade 9, journey homework help
189
- Held in 6 of 10 replies, 60%, interval 31% to 83%.
189
+ Broke in 4 of 10 replies, 40%, interval 17% to 69%.
190
190
  Decided by code in 10 readings.
191
191
  Reply 1: "Sure! Let's solve this together." (confidence 1.00, trial t-0b19)
192
192
  Log: the student wrote in Spanish