cortad 0.3.0-rc.4 → 0.3.0-rc.6

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)
@@ -40,7 +150,8 @@ def _install():
40
150
  host = (urlsplit(str(url)).hostname or "").lower()
41
151
  if not host or host in ("localhost", "127.0.0.1", "::1"):
42
152
  return
43
- write({"dep": {"at": int(time.time() * 1000), "host": host[:253], "status": int(status or 0), **({"code": str(code)[:40]} if code else {})}})
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 {})}})
44
155
  except Exception:
45
156
  pass
46
157
  def is_model_call(url):
@@ -151,19 +262,31 @@ def _install():
151
262
  except Exception:
152
263
  return ""
153
264
 
154
- def meter(url, sent, status, kind, raw):
265
+ def meter(url, sent, status, kind, raw, turn=None):
155
266
  try:
156
267
  tokens, model = read_reply(kind, (raw or "")[:reply_max])
157
268
  parts = urlsplit(str(url))
158
269
  row = {"at": int(time.time() * 1000), "host": parts.netloc.replace(":443", ""), "model": str(asked_for(url, sent) or model or "")[:160],
159
270
  "status": int(status or 0), "usage": tokens is not None}
160
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
161
282
  write({"call": row})
162
283
  except Exception:
163
284
  pass
164
285
 
165
286
  def started(method, path, headers):
166
- 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}
167
290
 
168
291
  def keep(req, chunk):
169
292
  if chunk and req["size"] < limit:
@@ -442,7 +565,7 @@ def _install():
442
565
  dep(str_or_url, 0, type(err).__name__)
443
566
  raise
444
567
  if is_model_call(str_or_url):
445
- response._cortad = (str_or_url, text(body))
568
+ response._cortad = (str_or_url, text(body), turn_now())
446
569
  else:
447
570
  dep(str_or_url, response.status)
448
571
  return response
@@ -455,14 +578,14 @@ def _install():
455
578
  call = getattr(self, "_cortad", None)
456
579
  if call:
457
580
  self._cortad = None
458
- 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])
459
582
  return raw
460
583
 
461
584
  def release_kept(self, *a, **k):
462
585
  call = getattr(self, "_cortad", None)
463
586
  if call:
464
587
  self._cortad = None
465
- 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])
466
589
  return release(self, *a, **k)
467
590
 
468
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,7 +94,7 @@ 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
  },
@@ -90,7 +102,7 @@ const meter = (() => {
90
102
  dep(d) {
91
103
  if (!d || typeof d.host !== "string" || !d.host) return;
92
104
  const env = typeof d.env === "string" && /^[A-Z_][A-Z0-9_]*$/.test(d.env) ? d.env : undefined;
93
- 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) } : {}) });
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) });
94
106
  if (deps.length > ROWS) deps.splice(0, deps.length - ROWS);
95
107
  },
96
108
  report() {
@@ -108,7 +120,7 @@ const meter = (() => {
108
120
  }
109
121
  return {
110
122
  totals: [...totals.values()].sort((a, b) => b.calls - a.calls),
111
- 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 } : {}) })),
112
124
  deps: deps.slice(-400).reverse(),
113
125
  };
114
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(), ...(dep.env ? { 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.
@@ -222,7 +293,7 @@ if (FILE) {
222
293
  }
223
294
  const sent = bodyText(init && init.body);
224
295
  note(als.getStore(), sent);
225
- const call = { host: url.host, path: url.pathname, sent };
296
+ const call = { host: url.host, path: url.pathname, sent, turn: (als.getStore() || {}).turn };
226
297
  return realFetch.apply(this, arguments).then((res) => {
227
298
  try {
228
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.
@@ -251,7 +322,7 @@ if (FILE) {
251
322
  const end = req.end;
252
323
  req.write = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); return write.call(this, chunk, ...r); };
253
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); };
254
- 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 };
255
326
  let done = false;
256
327
  const once = (row) => { if (!done) { done = true; meter({ ...call, sent, ...row }); } };
257
328
  req.once("error", () => once({ status: 0, type: "", body: "" }));
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 };
@@ -681,8 +682,22 @@ async function installOnce(said) {
681
682
  return true;
682
683
  }
683
684
 
685
+ // The port their start command names, moved when something else already holds it: two apps on one
686
+ // laptop both said --port 8000, and the second attached to the first's server as its own.
687
+ async function freed(cmd) {
688
+ const m = /(--port[= ]|-p |\bPORT=)(\d{4,5})\b/.exec(cmd);
689
+ if (!m || !(await listenerOn(Number(m[2])))) return cmd;
690
+ for (let port = Number(m[2]) + 1; port < Number(m[2]) + 30; port++) {
691
+ if (await listenerOn(port)) continue;
692
+ say(`port ${m[2]} is taken on this machine, so your app starts on ${port}`);
693
+ return cmd.replace(m[0], `${m[1]}${port}`);
694
+ }
695
+ return cmd;
696
+ }
697
+
684
698
  async function start(waitMs) {
685
- const { cmd, lifted } = launched;
699
+ const { lifted } = launched;
700
+ const cmd = await freed(launched.cmd);
686
701
  child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...process.env, ...lifted, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
687
702
  const mine = child;
688
703
  let seen = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.0-rc.4",
3
+ "version": "0.3.0-rc.6",
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.