cortad 0.3.0-rc.3 → 0.3.0-rc.5

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.
@@ -7,6 +7,7 @@
7
7
  import os
8
8
 
9
9
  _FILE = os.environ.get("CORTAD_TRACE_FILE")
10
+ _RULES = os.environ.get("CORTAD_RULES_FILE")
10
11
 
11
12
 
12
13
  def _install():
@@ -25,6 +26,115 @@ def _install():
25
26
  model_host = re.compile(r"(?:^|\.)(?: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|openai\.azure\.com|cognitiveservices\.azure\.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)$", re.I)
26
27
  model_path = re.compile(r"/(?:chat/completions|completions|responses|messages|embeddings)$|:(?:generateContent|streamGenerateContent)|/invoke(?:-with-response-stream)?$|/api/(?:chat|generate)$", re.I)
27
28
 
29
+ # The turn a message came in under, when the run tagged it (one opaque id per request), so a
30
+ # model call and its prompt can be pinned to the reply they produced while turns overlap.
31
+ turn_ok = re.compile(r"^[A-Za-z0-9:_.-]{1,80}$")
32
+
33
+ def turn_now():
34
+ req = ctx.get()
35
+ return req.get("turn") if req else None
36
+
37
+ # The customer's own rule sentences, written beside the trace by the run, so each model call can
38
+ # say which of them its prompt carried. Absent file, nothing is claimed. Reloaded on change.
39
+ rules_at = [-1.0]
40
+ rules = [None]
41
+ slot = re.compile(r"\{[^}]*\}|\$\{[^}]*\}|%[sd]|<[^>]{1,40}>")
42
+
43
+ def norm(s):
44
+ s = str(s or "")
45
+ s = re.sub(r"\\u([0-9a-fA-F]{4})", lambda m: chr(int(m.group(1), 16)), s)
46
+ s = re.sub(r"\\[nrt]", " ", s).replace('\\"', '"').replace("\\\\", "\\")
47
+ return re.sub(r"\s+", " ", s.lower()).strip()
48
+
49
+ def rules_now():
50
+ if not _RULES:
51
+ return None
52
+ try:
53
+ at = os.stat(_RULES).st_mtime
54
+ if at != rules_at[0]:
55
+ rules_at[0] = at
56
+ with open(_RULES, encoding="utf-8") as f:
57
+ raw = json.load(f)
58
+ out = []
59
+ for r in raw if isinstance(raw, list) else []:
60
+ if not isinstance(r, dict) or not isinstance(r.get("id"), str) or not isinstance(r.get("text"), str):
61
+ continue
62
+ parts = [p for p in (norm(x) for x in slot.split(r["text"])) if len(p) >= 12]
63
+ if parts:
64
+ out.append((r["id"], parts))
65
+ rules[0] = out
66
+ except Exception:
67
+ pass
68
+ return rules[0]
69
+
70
+ # What the app's tools answered, as the prompt of the next model call carries them (chat tool
71
+ # messages, responses function outputs, Anthropic tool_result blocks, Gemini functionResponse
72
+ # parts): the material a reply's facts rest on. Bounded per call.
73
+ tool_text, tools_max = 3000, 12
74
+
75
+ def text_of(v):
76
+ if isinstance(v, str):
77
+ return v
78
+ if isinstance(v, list):
79
+ return "\n".join(p for p in ((x.get("text") or x.get("content") or "") if isinstance(x, dict) else str(x or "") for x in v) if p)
80
+ if isinstance(v, dict):
81
+ try:
82
+ return json.dumps(v, ensure_ascii=False)
83
+ except (TypeError, ValueError):
84
+ return ""
85
+ return ""
86
+
87
+ def tools_in(sent):
88
+ body = parsed(sent) if isinstance(sent, str) else None
89
+ if not isinstance(body, dict):
90
+ return None
91
+ out, names = [], {}
92
+
93
+ def add(name, text):
94
+ t = text_of(text)[:tool_text]
95
+ if t.strip() and len(out) < tools_max and all(o["text"] != t for o in out):
96
+ out.append({"name": str(name or "")[:80], "text": t})
97
+
98
+ messages = body.get("messages") if isinstance(body.get("messages"), list) else []
99
+ for m in messages:
100
+ if not isinstance(m, dict):
101
+ continue
102
+ for c in m.get("tool_calls") or []:
103
+ if isinstance(c, dict) and c.get("id") and isinstance(c.get("function"), dict):
104
+ names[c["id"]] = c["function"].get("name")
105
+ if isinstance(m.get("content"), list):
106
+ for c in m["content"]:
107
+ if isinstance(c, dict) and c.get("type") == "tool_use" and c.get("id"):
108
+ names[c["id"]] = c.get("name")
109
+ for m in messages:
110
+ if not isinstance(m, dict):
111
+ continue
112
+ if m.get("role") in ("tool", "function"):
113
+ add(m.get("name") or names.get(m.get("tool_call_id")), m.get("content"))
114
+ if isinstance(m.get("content"), list):
115
+ for c in m["content"]:
116
+ if isinstance(c, dict) and c.get("type") == "tool_result":
117
+ add(names.get(c.get("tool_use_id")), c.get("content"))
118
+ items = body.get("input") if isinstance(body.get("input"), list) else []
119
+ for it in items:
120
+ if isinstance(it, dict) and it.get("type") == "function_call" and it.get("call_id"):
121
+ names[it["call_id"]] = it.get("name")
122
+ for it in items:
123
+ if isinstance(it, dict) and it.get("type") == "function_call_output":
124
+ add(names.get(it.get("call_id")), it.get("output"))
125
+ for c in body.get("contents") if isinstance(body.get("contents"), list) else []:
126
+ for p in c.get("parts") if isinstance(c, dict) and isinstance(c.get("parts"), list) else []:
127
+ if isinstance(p, dict) and isinstance(p.get("functionResponse"), dict):
128
+ add(p["functionResponse"].get("name"), p["functionResponse"].get("response"))
129
+ return out or None
130
+
131
+ def rules_in(sent):
132
+ found = rules_now()
133
+ if found is None:
134
+ return None
135
+ body = norm(sent)
136
+ return [rid for rid, parts in found if all(p in body for p in parts)]
137
+
28
138
  def write(row):
29
139
  try:
30
140
  fd = os.open(_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
@@ -33,6 +143,17 @@ def _install():
33
143
  except OSError:
34
144
  pass
35
145
 
146
+ # Every other outbound call: the host and what it answered, never a byte of it. A search
147
+ # provider over its limit for a whole run was invisible until this row existed.
148
+ def dep(url, status, code=None):
149
+ try:
150
+ host = (urlsplit(str(url)).hostname or "").lower()
151
+ if not host or host in ("localhost", "127.0.0.1", "::1"):
152
+ return
153
+ turn = turn_now()
154
+ write({"dep": {"at": int(time.time() * 1000), "host": host[:253], "status": int(status or 0), **({"code": str(code)[:40]} if code else {}), **({"turn": turn} if turn else {})}})
155
+ except Exception:
156
+ pass
36
157
  def is_model_call(url):
37
158
  try:
38
159
  parts = urlsplit(str(url))
@@ -141,19 +262,31 @@ def _install():
141
262
  except Exception:
142
263
  return ""
143
264
 
144
- def meter(url, sent, status, kind, raw):
265
+ def meter(url, sent, status, kind, raw, turn=None):
145
266
  try:
146
267
  tokens, model = read_reply(kind, (raw or "")[:reply_max])
147
268
  parts = urlsplit(str(url))
148
269
  row = {"at": int(time.time() * 1000), "host": parts.netloc.replace(":443", ""), "model": str(asked_for(url, sent) or model or "")[:160],
149
270
  "status": int(status or 0), "usage": tokens is not None}
150
271
  row.update(tokens or {"promptTokens": 0, "cachedTokens": 0, "completionTokens": 0})
272
+ turn = turn or turn_now()
273
+ if turn:
274
+ row["turn"] = turn
275
+ as_text = sent if isinstance(sent, str) else text(sent)
276
+ found = rules_in(as_text)
277
+ if found is not None:
278
+ row["rules"] = found
279
+ tools = tools_in(as_text)
280
+ if tools:
281
+ row["tools"] = tools
151
282
  write({"call": row})
152
283
  except Exception:
153
284
  pass
154
285
 
155
286
  def started(method, path, headers):
156
- return {"method": method, "path": path, "headers": headers, "chunks": [], "size": 0, "noted": False}
287
+ turn = headers.pop("x-cortad-turn", None)
288
+ return {"method": method, "path": path, "headers": headers, "chunks": [], "size": 0, "noted": False,
289
+ "turn": turn if isinstance(turn, str) and turn_ok.match(turn) else None}
157
290
 
158
291
  def keep(req, chunk):
159
292
  if chunk and req["size"] < limit:
@@ -330,6 +463,7 @@ def _install():
330
463
 
331
464
  def watch(request, response, sent):
332
465
  if not is_model_call(request.url):
466
+ dep(request.url, response.status_code)
333
467
  return response
334
468
  kind = response.headers.get("content-type", "")
335
469
  if getattr(response, "_content", None) is not None:
@@ -356,9 +490,11 @@ def _install():
356
490
  pass
357
491
  try:
358
492
  response = _send(self, request, *a, **k)
359
- except Exception:
493
+ except Exception as err:
360
494
  if is_model_call(request.url):
361
495
  meter(request.url, sent, 0, "", "")
496
+ else:
497
+ dep(request.url, 0, type(err).__name__)
362
498
  raise
363
499
  try:
364
500
  return watch(request, response, sent)
@@ -374,9 +510,11 @@ def _install():
374
510
  pass
375
511
  try:
376
512
  response = await _send(self, request, *a, **k)
377
- except Exception:
513
+ except Exception as err:
378
514
  if is_model_call(request.url):
379
515
  meter(request.url, sent, 0, "", "")
516
+ else:
517
+ dep(request.url, 0, type(err).__name__)
380
518
  raise
381
519
  try:
382
520
  return watch(request, response, sent)
@@ -392,8 +530,15 @@ def _install():
392
530
  note(request.url, request.body)
393
531
  except Exception:
394
532
  pass
395
- response = send(self, request, *a, **k)
396
533
  try:
534
+ response = send(self, request, *a, **k)
535
+ except Exception as err:
536
+ if not is_model_call(request.url):
537
+ dep(request.url, 0, type(err).__name__)
538
+ raise
539
+ try:
540
+ if not is_model_call(request.url):
541
+ dep(request.url, response.status_code)
397
542
  if is_model_call(request.url):
398
543
  # A streamed reply is counted as a call whose counts were not read.
399
544
  raw = response.content.decode("utf-8", "replace") if getattr(response, "_content_consumed", False) else ""
@@ -413,9 +558,16 @@ def _install():
413
558
  note(str_or_url, body)
414
559
  except Exception:
415
560
  pass
416
- response = await request(self, method, str_or_url, *a, **k)
561
+ try:
562
+ response = await request(self, method, str_or_url, *a, **k)
563
+ except Exception as err:
564
+ if not is_model_call(str_or_url):
565
+ dep(str_or_url, 0, type(err).__name__)
566
+ raise
417
567
  if is_model_call(str_or_url):
418
- response._cortad = (str_or_url, text(body))
568
+ response._cortad = (str_or_url, text(body), turn_now())
569
+ else:
570
+ dep(str_or_url, response.status)
419
571
  return response
420
572
 
421
573
  # Metered when your app reads the reply, or, for one it streams, when the reply is let go.
@@ -426,14 +578,14 @@ def _install():
426
578
  call = getattr(self, "_cortad", None)
427
579
  if call:
428
580
  self._cortad = None
429
- meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"))
581
+ meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"), call[2])
430
582
  return raw
431
583
 
432
584
  def release_kept(self, *a, **k):
433
585
  call = getattr(self, "_cortad", None)
434
586
  if call:
435
587
  self._cortad = None
436
- meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "")
588
+ meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "", call[2])
437
589
  return release(self, *a, **k)
438
590
 
439
591
  module.ClientSession._request = requested
package/lib/replay.mjs CHANGED
@@ -11,7 +11,7 @@ const HOOK = join(HERE, "trace.cjs");
11
11
  const PYHOOK = join(HERE, "pyhook");
12
12
  export const CAPTURED = "captured";
13
13
  // Never replayed: they describe one connection, not the caller.
14
- const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as)$/i;
14
+ const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as|x-cortad-turn)$/i;
15
15
 
16
16
  export function makeCapture({ work, keepSecret, onDoor }) {
17
17
  const file = join(work, "trace.jsonl");
@@ -29,6 +29,9 @@ export function makeCapture({ work, keepSecret, onDoor }) {
29
29
  // ponytail: Node, Bun and Python. Go, Ruby, Java and PHP apps are asked for their route on the screen.
30
30
  const env = (base) => ({
31
31
  CORTAD_TRACE_FILE: file,
32
+ // The run writes the customer's rule sentences here (the engine's /tmp/rules.json lands in this
33
+ // folder), and the hook reads which of them each model call's prompt carried.
34
+ CORTAD_RULES_FILE: join(work, "rules.json"),
32
35
  NODE_OPTIONS: `${base.NODE_OPTIONS ?? ""} --require ${JSON.stringify(HOOK)}`.trim(),
33
36
  BUN_OPTIONS: `${base.BUN_OPTIONS ?? ""} --preload=${bunHook}`.trim(),
34
37
  PYTHONPATH: [PYHOOK, base.PYTHONPATH].filter(Boolean).join(":"),
@@ -72,6 +75,15 @@ export function makeCapture({ work, keepSecret, onDoor }) {
72
75
 
73
76
  // Every model call the hook wrote down, kept here: totals per host and model, and the newest rows.
74
77
  const ROWS = 5000;
78
+ // The turn a row was pinned to by the run's own tag, and the rule ids the call's prompt carried.
79
+ const TURN = /^[A-Za-z0-9:_.-]{1,80}$/;
80
+ const RULE_ID = /^[\w:-]{1,64}$/;
81
+ const turnOf = (v) => (typeof v === "string" && TURN.test(v) ? { turn: v } : {});
82
+ const rulesOf = (v) => (Array.isArray(v) ? { rules: v.filter((id) => typeof id === "string" && RULE_ID.test(id)).slice(0, 300) } : {});
83
+ // What the app's tools answered behind a call, as the hook read them off the next prompt.
84
+ const toolsOf = (v) => (Array.isArray(v) && v.length
85
+ ? { tools: 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) })) }
86
+ : {});
75
87
  const meter = (() => {
76
88
  const rows = [];
77
89
  const deps = [];
@@ -82,14 +94,15 @@ const meter = (() => {
82
94
  rows.push({
83
95
  at: count(call.at), host: call.host.slice(0, 253), model: String(call.model ?? "").slice(0, 160), status: count(call.status),
84
96
  promptTokens: count(call.promptTokens), cachedTokens: count(call.cachedTokens), completionTokens: count(call.completionTokens),
85
- usage: call.usage === true,
97
+ usage: call.usage === true, ...turnOf(call.turn), ...rulesOf(call.rules), ...toolsOf(call.tools),
86
98
  });
87
99
  if (rows.length > ROWS) rows.splice(0, rows.length - ROWS);
88
100
  },
89
101
  // A service their settings name: only its setting, host and status travel, never a byte of it.
90
102
  dep(d) {
91
- if (!d || typeof d.env !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(d.env) || typeof d.host !== "string") return;
92
- deps.push({ at: count(d.at), env: d.env, host: d.host.slice(0, 253), status: count(d.status), ...(typeof d.code === "string" ? { code: d.code.slice(0, 40) } : {}) });
103
+ if (!d || typeof d.host !== "string" || !d.host) return;
104
+ const env = typeof d.env === "string" && /^[A-Z_][A-Z0-9_]*$/.test(d.env) ? d.env : undefined;
105
+ 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) });
93
106
  if (deps.length > ROWS) deps.splice(0, deps.length - ROWS);
94
107
  },
95
108
  report() {
@@ -107,7 +120,7 @@ const meter = (() => {
107
120
  }
108
121
  return {
109
122
  totals: [...totals.values()].sort((a, b) => b.calls - a.calls),
110
- rows: rows.slice(-200).reverse().map(({ at, host, model, status }) => ({ at, host, model, status })),
123
+ rows: rows.slice(-400).reverse().map(({ at, host, model, status, turn, rules, tools }) => ({ at, host, model, status, ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}) })),
111
124
  deps: deps.slice(-400).reverse(),
112
125
  };
113
126
  },
package/lib/start.mjs CHANGED
@@ -55,6 +55,7 @@ function pythonStart(dir, onPath) {
55
55
  const py = venv ? JSON.stringify(venv) : existsSync(join(dir, "uv.lock")) && onPath("uv") ? "uv run python" : existsSync(join(dir, "poetry.lock")) && onPath("poetry") ? "poetry run python" : "python3";
56
56
  if (existsSync(join(dir, "manage.py"))) return { cmd: `${py} manage.py runserver`, serves: true };
57
57
  const named = [...new Set([...ENTRIES, ...entriesIn(dir)])].map((entry) => [entry, read(join(dir, entry))]).filter(([, text]) => text);
58
+ const manifest = `${read(join(dir, "requirements.txt"))}\n${read(join(dir, "pyproject.toml"))}\n${read(join(dir, "Pipfile"))}`;
58
59
  // The file that serves before the file that merely runs: src/run_agent.py runs a conversation in
59
60
  // the terminal and src/run_service.py is the app, and they sit in one folder.
60
61
  for (const [entry, text] of [...named.filter(([, text]) => SERVES.test(text)), ...named]) {
@@ -65,10 +66,19 @@ function pythonStart(dir, onPath) {
65
66
  if (fast) return { cmd: `${py} -m uvicorn ${module}:${fast[1]} --host 127.0.0.1 --port 8000`, serves: true };
66
67
  const flask = /^(\w+)\s*=\s*Flask\(/m.exec(text);
67
68
  if (flask) return { cmd: `${py} -m flask --app ${module} run --port 5000`, serves: true };
69
+ // The app object built elsewhere and only bound or re-exported here: resumeforge's
70
+ // backend/app/main.py is `from .application import (app, ...)`, and the server the manifest
71
+ // names says what serves it.
72
+ const bound = BOUND_APP.exec(text)?.[1] ?? IMPORTED_APP.exec(text)?.slice(1).find(Boolean);
73
+ if (bound && /\b(?:fastapi|starlette|litestar|quart)\b/i.test(manifest)) return { cmd: `${py} -m uvicorn ${module}:${bound} --host 127.0.0.1 --port 8000`, serves: true };
74
+ if (bound && /\bflask\b/i.test(manifest)) return { cmd: `${py} -m flask --app ${module}:${bound} run --port 5000`, serves: true };
68
75
  }
69
76
  return null;
70
77
  }
71
78
 
79
+ // `app = create_app()` at the top of the file, or `from .application import app`.
80
+ const BOUND_APP = /^(app|application)\s*=\s*[\w.]+\(/m;
81
+ const IMPORTED_APP = /^from\s+[\w.]+\s+import\s+(?:\(\s*[^)]*?\b(app|application)\b[^)]*\)|[^\n(]*\b(app|application)\b)/m;
72
82
  const KNOWN = ["package.json", "pyproject.toml", "requirements.txt", "manage.py", "Pipfile", "uv.lock"];
73
83
 
74
84
  const startOf = (dir, onPath) => nodeStart(dir, onPath) ?? pythonStart(dir, onPath);
package/lib/trace.cjs CHANGED
@@ -22,6 +22,73 @@ if (FILE) {
22
22
  // own launcher included, so "loaded" is not "watching your app": only a listener on the app's port is.
23
23
  const listening = (port) => { if (Number.isInteger(port) && port > 0) row({ listen: port, pid: process.pid }); };
24
24
  const MAX = 65536;
25
+ // The turn a message came in under, when the run tagged it: one opaque id per request, so a
26
+ // model call and its prompt can be pinned to the reply they produced even while five turns are
27
+ // in flight. It is read here and never shown to your app's own code path beyond the header.
28
+ const TURN = /^[A-Za-z0-9:_.-]{1,80}$/;
29
+ const turnOf = (headers) => {
30
+ const t = headers && (typeof headers.get === "function" ? headers.get("x-cortad-turn") : headers["x-cortad-turn"]);
31
+ return typeof t === "string" && TURN.test(t) ? t : undefined;
32
+ };
33
+ // The customer's own rule sentences, written beside the trace by the run, so each model call can
34
+ // say which of them its prompt carried: a rule is then asked only of a reply whose call was told
35
+ // it. Absent file, nothing is claimed either way. Reloaded when the file changes.
36
+ const RULES = process.env.CORTAD_RULES_FILE;
37
+ let rules = null, rulesAt = -1;
38
+ const norm = (s) => String(s || "")
39
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
40
+ .replace(/\\[nrt]/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\")
41
+ .toLowerCase().replace(/\s+/g, " ").trim();
42
+ // A sentence with a slot in it ("answer in {language}") is matched by its literal parts.
43
+ const partsOf = (text) => text.split(/\{[^}]*\}|\$\{[^}]*\}|%[sd]|<[^>]{1,40}>/).map(norm).filter((p) => p.length >= 12);
44
+ const rulesNow = () => {
45
+ if (!RULES) return null;
46
+ try {
47
+ const at = fs.statSync(RULES).mtimeMs;
48
+ if (at !== rulesAt) {
49
+ rulesAt = at;
50
+ const list = JSON.parse(fs.readFileSync(RULES, "utf8"));
51
+ rules = Array.isArray(list)
52
+ ? 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)
53
+ : [];
54
+ }
55
+ } catch { /* not written yet, or gone */ }
56
+ return rules;
57
+ };
58
+ // What the app's tools answered, as the prompt of the next model call carries them: the tool
59
+ // messages of a chat body, the function outputs of a responses body, the tool_result blocks of
60
+ // an Anthropic one, the functionResponse parts of a Gemini one. They are the material a reply's
61
+ // facts rest on, and without them a real ticket id read as invented. Bounded per call.
62
+ const TOOL_TEXT = 3000, TOOLS_MAX = 12;
63
+ 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) : "");
64
+ const toolsIn = (sent) => {
65
+ let body; try { body = JSON.parse(sent); } catch { return undefined; }
66
+ if (!body || typeof body !== "object") return undefined;
67
+ const out = [];
68
+ const names = new Map();
69
+ 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 }); };
70
+ for (const m of Array.isArray(body.messages) ? body.messages : []) {
71
+ if (!m || typeof m !== "object") continue;
72
+ 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);
73
+ 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);
74
+ }
75
+ for (const m of Array.isArray(body.messages) ? body.messages : []) {
76
+ if (!m || typeof m !== "object") continue;
77
+ if (m.role === "tool" || m.role === "function") add(m.name || names.get(m.tool_call_id), m.content);
78
+ 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);
79
+ }
80
+ const items = Array.isArray(body.input) ? body.input : [];
81
+ for (const it of items) if (it && it.type === "function_call" && it.call_id) names.set(it.call_id, it.name);
82
+ for (const it of items) if (it && it.type === "function_call_output") add(names.get(it.call_id), it.output);
83
+ 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
+ return out.length ? out : undefined;
85
+ };
86
+ const rulesIn = (sent) => {
87
+ const list = rulesNow();
88
+ if (!list) return undefined;
89
+ const body = norm(sent);
90
+ return list.filter((r) => r.parts.every((p) => body.includes(p))).map((r) => r.id);
91
+ };
25
92
  // Where models are served, by host, and the paths every OpenAI-shaped or vendor endpoint ends with.
26
93
  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
94
  const MODEL_PATH = /\/(?:chat\/completions|completions|responses|messages|embeddings)$|:(?:generateContent|streamGenerateContent)|\/invoke(?:-with-response-stream)?$|\/api\/(?:chat|generate)$/i;
@@ -50,7 +117,8 @@ if (FILE) {
50
117
  http.Server.prototype.emit = function (type, req, ...rest) {
51
118
  if (type === "listening") { try { listening(this.address()?.port); } catch { /* not a TCP server */ } }
52
119
  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 };
120
+ const { "x-cortad-turn": _turn, ...headers } = req.headers;
121
+ const ctx = { method: req.method, path: req.url || "/", headers, chunks: [], size: 0, noted: false, turn: turnOf(req.headers) };
54
122
  const push = req.push;
55
123
  req.push = function (chunk, encoding) {
56
124
  if (chunk && ctx.size < MAX) { const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding); ctx.chunks.push(b); ctx.size += b.length; }
@@ -85,7 +153,7 @@ if (FILE) {
85
153
  if (!req || /^(?:GET|HEAD|OPTIONS)$/.test(req.method)) return handler.call(self, req, server);
86
154
  let path = "/";
87
155
  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 };
156
+ 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
157
  // ponytail: a body is copied only when it is small or says it is text; an upload is left alone.
90
158
  const size = Number(req.headers.get("content-length") || 0);
91
159
  if (size <= MAX || /json|text|form/i.test(req.headers.get("content-type") || "")) {
@@ -166,10 +234,12 @@ if (FILE) {
166
234
  const m = /\/models\/([^/:]+):|\/model\/([^/]+)\/(?:invoke|converse)/.exec(path || "");
167
235
  return m ? decodeURIComponent(m[1] || m[2]) : "";
168
236
  };
169
- const meter = ({ host, path, sent, status, type, body }) => {
237
+ const meter = ({ host, path, sent, status, type, body, turn }) => {
170
238
  try {
171
239
  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) };
240
+ const rules = rulesIn(sent);
241
+ const tools = toolsIn(sent);
242
+ 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
243
  fs.appendFileSync(FILE, JSON.stringify({ call: row }) + "\n", { mode: 0o600 });
174
244
  } catch { /* the command is gone */ }
175
245
  };
@@ -196,7 +266,8 @@ if (FILE) {
196
266
  } catch { return null; }
197
267
  };
198
268
  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 */ }
269
+ const turn = (als.getStore() || {}).turn;
270
+ 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
271
  };
201
272
 
202
273
  // Outbound: the model call your app makes while it handles that request.
@@ -210,7 +281,10 @@ if (FILE) {
210
281
  if (isModelCall(u.host, u.pathname)) url = u;
211
282
  } catch { /* not a URL we can read */ }
212
283
  if (!url) {
213
- const dep = depOf(input);
284
+ // Every outbound host, named by the setting that points at it when one does, and by the
285
+ // host alone otherwise: the search provider behind TAVILY_API_KEY has no URL setting.
286
+ let dep = depOf(input);
287
+ 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
288
  if (!dep) return realFetch.apply(this, arguments);
215
289
  return realFetch.apply(this, arguments).then(
216
290
  (res) => { depRow(dep, res.status); return res; },
@@ -219,7 +293,7 @@ if (FILE) {
219
293
  }
220
294
  const sent = bodyText(init && init.body);
221
295
  note(als.getStore(), sent);
222
- const call = { host: url.host, path: url.pathname, sent };
296
+ const call = { host: url.host, path: url.pathname, sent, turn: (als.getStore() || {}).turn };
223
297
  return realFetch.apply(this, arguments).then((res) => {
224
298
  try {
225
299
  // 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 +322,7 @@ if (FILE) {
248
322
  const end = req.end;
249
323
  req.write = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); return write.call(this, chunk, ...r); };
250
324
  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] };
325
+ const call = { host: String(host || ""), path: String(path || "").split("?")[0], turn: ctx && ctx.turn };
252
326
  let done = false;
253
327
  const once = (row) => { if (!done) { done = true; meter({ ...call, sent, ...row }); } };
254
328
  req.once("error", () => once({ status: 0, type: "", body: "" }));
package/lib/verbs.mjs CHANGED
@@ -127,7 +127,13 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
127
127
  const more = await call("GET", path(page, d.runId ?? jobId));
128
128
  if (!more.ok) return more;
129
129
  d.findings = [...(d.findings ?? []), ...(more.data.findings ?? [])];
130
- if (more.data.byLine) d.byLine = [...(d.byLine ?? []), ...more.data.byLine];
130
+ // Every page carries the whole byLine; merged by line with the ids joined, or the first
131
+ // page's groups print twice.
132
+ for (const g of more.data.byLine ?? []) {
133
+ const held = (d.byLine ?? []).find((h) => h.path === g.path && h.line === g.line);
134
+ if (held) held.findingIds = [...new Set([...held.findingIds, ...g.findingIds])];
135
+ else d.byLine = [...(d.byLine ?? []), g];
136
+ }
131
137
  }
132
138
  return first;
133
139
  };
package/local.mjs CHANGED
@@ -434,7 +434,8 @@ async function verb(job) {
434
434
  case "lock": return { locked: Array.isArray(b.hosts) ? b.hosts.length : 0 };
435
435
  // What your app spent on its providers, from the hook inside it. An app this command did not
436
436
  // start has no hook, and the empty answer says the meter is absent rather than that nothing was spent.
437
- case "usage": return capture?.usage() ?? {};
437
+ // Masked like every other reply: a tool's answer can carry a value from their env files.
438
+ case "usage": return capture ? JSON.parse(mask(JSON.stringify(capture.usage() ?? {}))) : {};
438
439
  // A world is ended from this terminal, never from the cloud.
439
440
  case "destroy": return { ok: true };
440
441
  default: return { ok: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.0-rc.3",
3
+ "version": "0.3.0-rc.5",
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
@@ -50,7 +50,7 @@ Take the person through them worst first, each with its file and line. A long li
50
50
  ## Fixing one finding
51
51
 
52
52
  1. One change, in the file and near the line the finding names.
53
- 2. `verify <findingId>`. It answers within a second, and `run_status` follows it.
53
+ 2. `verify <findingId>`. It answers within a second, and `run_status` follows it. Cortad starts the app again itself before the replay, so the saved edit is what plays.
54
54
  3. Read the move on the `Visible trials:` and `Held-out trials:` lines:
55
55
  - `improved` without `inside the noise`, with the held-out line improved too or `no pair`: the behavior moved. The change stays.
56
56
  - `inside the noise`, `no change` or `unsettled`: the trials cannot tell the change from chance. The file goes back to how it was.