cortad 0.3.0-rc.4 → 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 +129 -6
- package/lib/replay.mjs +16 -4
- package/lib/start.mjs +10 -0
- package/lib/trace.cjs +78 -7
- 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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(-
|
|
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
|
|
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.
|
|
@@ -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
|
-
|
|
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.
|