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.
- package/lib/pyhook/sitecustomize.py +161 -9
- package/lib/replay.mjs +18 -5
- package/lib/start.mjs +10 -0
- package/lib/trace.cjs +82 -8
- package/lib/verbs.mjs +7 -1
- package/local.mjs +2 -1
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
92
|
-
|
|
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(-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|