cortad 0.3.1-rc.13 → 0.3.1-rc.15
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/contact-text.mjs +8 -4
- package/lib/dissection-text.mjs +3 -3
- package/lib/failed-text.mjs +3 -3
- package/lib/keeping.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/numbers-text.mjs +5 -6
- package/lib/proof.mjs +5 -1
- package/lib/pyhook/sitecustomize.py +16 -7
- package/lib/read-text.mjs +3 -3
- package/lib/stick.mjs +1 -1
- package/lib/stores.mjs +7 -1
- package/lib/text.mjs +10 -10
- package/lib/trace.cjs +2 -2
- package/lib/wire.cjs +8 -4
- package/lib/words.mjs +0 -2
- package/local.mjs +20 -3
- package/package.json +1 -1
- package/skill/SKILL.md +5 -5
- package/skill/references/results.md +11 -11
package/lib/contact-text.mjs
CHANGED
|
@@ -29,7 +29,7 @@ function sessionLine(v) {
|
|
|
29
29
|
if (!s || !(v.requests > 1)) return null;
|
|
30
30
|
if (!s.held) return "No later request's prompt carried an earlier one.";
|
|
31
31
|
const by = CARRIER[s.carrier] && s.key ? CARRIER[s.carrier](s.key) : "nothing the request sent, so every caller shares what the app keeps";
|
|
32
|
-
return `A later request's prompt carried an earlier one,
|
|
32
|
+
return `A later request's prompt carried an earlier one, held by ${by}.`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function rulesLine(r) {
|
|
@@ -97,8 +97,12 @@ function readyLine(r) {
|
|
|
97
97
|
? `about ${plural(r.minutes, "minute")} if your app answers several requests at once, or up to about ${plural(one.minutes, "minute")}${one.trials < r.trials ? ` and ${plural(one.trials, "trial")}` : ""} if it answers one at a time`
|
|
98
98
|
: `about ${plural(r.minutes, "minute")}`);
|
|
99
99
|
const usd = (x) => `$${x.toFixed(2)}`;
|
|
100
|
-
|
|
101
|
-
const
|
|
100
|
+
// Some endpoints had no published rate or token counts: the price covers the others and says so.
|
|
101
|
+
const some = r.pricedOn ? `at least ${usd(r.usd)}, priced on ${num(r.pricedOn.endpoints)} of ${plural(r.pricedOn.of, "endpoint")}` : null;
|
|
102
|
+
const cost = has(r.usd) && `${some ?? `about ${usd(r.usd)}${has(r.usdHigh) ? ` to ${usd(r.usdHigh)}` : ""}`}${soFar} on your ${r.provider ? `${r.provider} ` : ""}key${r.model ? ` for ${r.model}` : ""}${r.estimated ? ", estimated from the length of the text since your provider sent no token counts" : ""}`;
|
|
103
|
+
// Follow-ups are played only where something breaks: said apart, never in the count above.
|
|
104
|
+
const followUps = r.followUps ? `and up to ${plural(r.followUps, "follow-up")} where something breaks` : null;
|
|
105
|
+
const parts = [fit, followUps, has(r.replies) && `about ${plural(r.replies, "reply", "replies")}`, time, cost].filter(Boolean);
|
|
102
106
|
const rules = !has(r.rules) ? "" : r.rules === 0 ? " No rules of your own were read from the code, so the run checks replies against generic standards." : ` Replies are checked against the ${plural(r.rules, "rule")} read from your code.`;
|
|
103
107
|
return `A run on the proven endpoints: ${parts.join(", ")}.${r.reading ? " The read is still adding trials; the run waits for it and plays them all." : ""}${rules}`;
|
|
104
108
|
}
|
|
@@ -114,6 +118,6 @@ export function contactLines(c, data = []) {
|
|
|
114
118
|
if (missing.length) lines.push(`Endpoints the read found that no request has reached (${num(missing.length)}):`, ...missing.map((m) => ` ${m.method} ${m.path}${m.file ? ` ${at(m.file, m.line)}` : ""}${m.excluded ? ` ${m.excluded}` : ""}`));
|
|
115
119
|
const ready = readyLine(c.ready);
|
|
116
120
|
if (ready) lines.push(ready);
|
|
117
|
-
for (const b of c.ready?.blocked ?? []) lines.push(`Run
|
|
121
|
+
for (const b of c.ready?.blocked ?? []) lines.push(`Run is held${sideOf(b.side)}: ${b.door}: ${b.why}${b.question ? ` ${b.question}` : ""}`);
|
|
118
122
|
return [...lines, ...said];
|
|
119
123
|
}
|
package/lib/dissection-text.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clip,
|
|
1
|
+
import { clip, has, num, plural } from "./words.mjs";
|
|
2
2
|
|
|
3
3
|
// A finished run's dissection, as the server cut it (world-agent/dissection.ts): the run in a line,
|
|
4
4
|
// what only running the app showed, what a code review would also have found, the gates a knock
|
|
@@ -167,7 +167,7 @@ function journeyLines(b, said) {
|
|
|
167
167
|
const lines = [head, "How it went, at each conversation's last reply:"];
|
|
168
168
|
const a = b.all;
|
|
169
169
|
if (a?.got || a?.frustrated || a?.askedForPerson) {
|
|
170
|
-
const parts = [a.got && gotSaid(a.got, said), a.unsure &&
|
|
170
|
+
const parts = [a.got && gotSaid(a.got, said), a.unsure && `${num(a.unsure)} could not be told`, a.frustrated && `${num(a.frustrated.k)} grew frustrated`, a.askedForPerson && `${num(a.askedForPerson.k)} asked for a person midway`].filter(Boolean);
|
|
171
171
|
lines.push(` All ${plural(b.conversations, "conversation")}: ${parts.join("; ")}.`);
|
|
172
172
|
}
|
|
173
173
|
for (const r of j.rows.slice(0, JOURNEYS_SHOWN)) {
|
|
@@ -192,7 +192,7 @@ function heldEverywhereLines(b) {
|
|
|
192
192
|
function notMeasuredLines(d, b) {
|
|
193
193
|
const unplayed = b.journeys?.unplayed ?? [];
|
|
194
194
|
const lines = [d.held, d.notMeasured, unplayed.length && `No conversation reached ${unplayed.length === 1 ? "this journey" : `these ${num(unplayed.length)} journeys`}: ${flat(unplayed.join(", "), 200)}.`].filter(Boolean);
|
|
195
|
-
return lines.length ? ["What
|
|
195
|
+
return lines.length ? ["What was not measured, and why:", ...lines.map((l) => ` ${l}`)] : [];
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
// `room.lines`: what the one-line findings may take; past it a finding that may lead, an open gate or
|
package/lib/failed-text.mjs
CHANGED
|
@@ -7,15 +7,15 @@ import { at, clip, plural } from "./words.mjs";
|
|
|
7
7
|
|
|
8
8
|
// A row the run held back names a door and why, or, when no door could take it, only the reason.
|
|
9
9
|
export const heldBackLine = (h) => h.why === "not testable here"
|
|
10
|
-
? `
|
|
11
|
-
: `
|
|
10
|
+
? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200).replace(/\.$/, "")}.`
|
|
11
|
+
: `Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
|
|
12
12
|
|
|
13
13
|
const HELD_SHOWN = 6;
|
|
14
14
|
|
|
15
15
|
export function heldLines(held) {
|
|
16
16
|
const rows = (held ?? []).filter((h) => h.trials > 0);
|
|
17
17
|
const lines = rows.slice(0, HELD_SHOWN).map(heldBackLine);
|
|
18
|
-
if (rows.length > HELD_SHOWN) lines.push(`
|
|
18
|
+
if (rows.length > HELD_SHOWN) lines.push(`Held back: ${plural(rows.slice(HELD_SHOWN).reduce((n, h) => n + h.trials, 0), "more trial")} for ${plural(rows.length - HELD_SHOWN, "other reason")}.`);
|
|
19
19
|
return lines;
|
|
20
20
|
}
|
|
21
21
|
|
package/lib/keeping.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
|
|
|
42
42
|
let seen = 0;
|
|
43
43
|
const told = new Set();
|
|
44
44
|
const tell = (lines) => { for (const line of lines) say(lower(line)); };
|
|
45
|
-
const held = (blocked) => tell(blocked.map((b) => `Run
|
|
45
|
+
const held = (blocked) => tell(blocked.map((b) => `Run is held until this is fixed: ${lower(b.why)}`));
|
|
46
46
|
|
|
47
47
|
async function keepAll(started) {
|
|
48
48
|
const { values, from, appDir, sources } = settings();
|
package/lib/mcp.mjs
CHANGED
|
@@ -38,7 +38,7 @@ export const TOOLS = [
|
|
|
38
38
|
{ name: "field_connect", title: "Connect production", inputSchema: none,
|
|
39
39
|
description: "How production replies reach Cortad, so real conversations are read with the same checks. The ingest key is created by the owner in the browser." },
|
|
40
40
|
{ name: "field", title: "Production numbers", annotations: readOnly,
|
|
41
|
-
description: "Production in numbers: conversations read, checks
|
|
41
|
+
description: "Production in numbers: conversations read, checks held, resolved, frustrated, asks for a human, and the rules broken most. Message text stays out of the answer.",
|
|
42
42
|
inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 180, description: "Window in days. Default 30." } }, additionalProperties: false } },
|
|
43
43
|
];
|
|
44
44
|
const NAMES = new Set(TOOLS.map((t) => t.name));
|
package/lib/numbers-text.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clip,
|
|
1
|
+
import { clip, has, num, pack, pageOf, plural } from "./words.mjs";
|
|
2
2
|
|
|
3
3
|
// Every number the run measured, whole, one call away (`findings numbers`): each group with its count
|
|
4
4
|
// by class, of how many, the interval on the share that matters, and one exchange from the worst
|
|
@@ -14,9 +14,9 @@ const SPLIT_WORDS = { journey: "By journey", segment: "By persona segment", situ
|
|
|
14
14
|
|
|
15
15
|
// One group as a row under its header: the counts in the header's order, of how many, the average
|
|
16
16
|
// on a scale, then the share named with its interval.
|
|
17
|
-
function row(t, d
|
|
17
|
+
function row(t, d) {
|
|
18
18
|
const of = units(d.of, t.unit);
|
|
19
|
-
const unsure = d.unsure ?
|
|
19
|
+
const unsure = d.unsure ? ` (${num(d.unsure)} unsure)` : "";
|
|
20
20
|
const share = d.bad && d.of ? `, ${interval(d.bad)}` : "";
|
|
21
21
|
if (t.kind === "question") return `${num(d.counts?.broke ?? 0)} of ${of}${share}${unsure}`;
|
|
22
22
|
const counts = t.classes.map((c) => num(d.counts?.[c] ?? 0)).join(", ");
|
|
@@ -30,14 +30,13 @@ const headerOf = (t) => (t.kind === "question"
|
|
|
30
30
|
: ` Counted as: ${isScale(t) ? `levels ${t.classes.join(", ")}` : t.classes.join(", ")} of ${t.unit}${t.badSaid ? `; then how many ${t.badSaid}, with the 95% interval` : ""}.`);
|
|
31
31
|
|
|
32
32
|
function tableBlock(t) {
|
|
33
|
-
const said = {};
|
|
34
33
|
const lines = [`${t.kind === "question" ? `${t.id} ` : ""}${clip(t.title, 400)}${t.from ? ` (${t.from})` : ""}`];
|
|
35
34
|
if (t.legend) lines.push(` ${clip(t.legend, 500)}`);
|
|
36
|
-
lines.push(headerOf(t), ` All: ${row(t, t.all
|
|
35
|
+
lines.push(headerOf(t), ` All: ${row(t, t.all)}`);
|
|
37
36
|
for (const split of ["journey", "segment", "situation", "reply"]) {
|
|
38
37
|
const groups = t.by?.[split] ?? [];
|
|
39
38
|
if (!groups.length || (split !== "reply" && groups.length === 1 && groups[0].of === t.all.of)) continue;
|
|
40
|
-
lines.push(` ${SPLIT_WORDS[split]}:`, ...groups.map((d) => ` ${clip(d.value, 90)}${d.kept ? " (kept back)" : ""}: ${row(t, d
|
|
39
|
+
lines.push(` ${SPLIT_WORDS[split]}:`, ...groups.map((d) => ` ${clip(d.value, 90)}${d.kept ? " (kept back)" : ""}: ${row(t, d)}`));
|
|
41
40
|
}
|
|
42
41
|
const w = t.worst;
|
|
43
42
|
if (w) {
|
package/lib/proof.mjs
CHANGED
|
@@ -547,8 +547,12 @@ const sentence = ({ model, site }) => (model ? " The model said it."
|
|
|
547
547
|
// The tools the request's last model call asked for, when they ran inside this request and no model
|
|
548
548
|
// call came after them: the app closed the request before the model could answer from their result
|
|
549
549
|
// (an AI SDK route left at one step). A tool the page runs itself and sends back is not this.
|
|
550
|
+
// A last call that wrote words answered: its request carries the tool calls before it in its history
|
|
551
|
+
// (MIT Learn AI's answering call resent search_courses), which is not the request stopping there.
|
|
550
552
|
function endedOnTool(ex) {
|
|
551
|
-
const
|
|
553
|
+
const last = generation(ex).at(-1);
|
|
554
|
+
if (str(last?.reply).trim()) return null;
|
|
555
|
+
const asked = [...new Set(arr(last?.called).map((c) => str(c?.name)).filter(Boolean))];
|
|
552
556
|
if (!asked.length) return null;
|
|
553
557
|
const inApp = new Set(ex.deps.flatMap((d) => arr(d.called).map((c) => str(c?.name))));
|
|
554
558
|
const streamed = eventsOf(str(ex.reply)).events.some((e) => e && typeof e === "object" && /tool.*(?:output|result)/i.test(str(e.type)));
|
|
@@ -171,7 +171,14 @@ def _install():
|
|
|
171
171
|
# What the app's tools answered, as the prompt of the next model call carries them (chat tool
|
|
172
172
|
# messages, responses function outputs, Anthropic tool_result blocks, Gemini functionResponse
|
|
173
173
|
# parts): the material a reply's facts rest on. Bounded per call.
|
|
174
|
-
|
|
174
|
+
# A tool's answer is kept to the cap the reader takes, and one longer says it was cut (wire.cjs toolText).
|
|
175
|
+
tool_text, tools_max = 12000, 12
|
|
176
|
+
|
|
177
|
+
def tool_text_row(name, full, **extra):
|
|
178
|
+
out = {"name": str(name or "")[:80], "text": full[:tool_text], **extra}
|
|
179
|
+
if len(full) > tool_text:
|
|
180
|
+
out["cut"] = True
|
|
181
|
+
return out
|
|
175
182
|
|
|
176
183
|
def text_of(v):
|
|
177
184
|
if isinstance(v, str):
|
|
@@ -214,9 +221,10 @@ def _install():
|
|
|
214
221
|
out, names = [], {}
|
|
215
222
|
|
|
216
223
|
def add(name, text):
|
|
217
|
-
|
|
224
|
+
full = text_of(text)
|
|
225
|
+
t = full[:tool_text]
|
|
218
226
|
if t.strip() and len(out) < tools_max and all(o["text"] != t for o in out):
|
|
219
|
-
out.append(
|
|
227
|
+
out.append(tool_text_row(name, full))
|
|
220
228
|
|
|
221
229
|
messages = items(body.get("messages"))
|
|
222
230
|
for m in messages:
|
|
@@ -645,9 +653,10 @@ def _install():
|
|
|
645
653
|
calls, names, answers = {}, {}, []
|
|
646
654
|
|
|
647
655
|
def answer(name, said):
|
|
648
|
-
|
|
656
|
+
full = text_of(said)
|
|
657
|
+
t = full[:tool_text]
|
|
649
658
|
if name and t.strip() and len(answers) < tools_max and not any(a["name"] == name and a["text"] == t for a in answers):
|
|
650
|
-
answers.append(
|
|
659
|
+
answers.append(tool_text_row(name, full, provider=True))
|
|
651
660
|
|
|
652
661
|
for e in events:
|
|
653
662
|
if not isinstance(e, dict):
|
|
@@ -1731,7 +1740,7 @@ def _install():
|
|
|
1731
1740
|
text = "raised %s: %s" % (type(error).__name__, error) if error is not None else returned(value)
|
|
1732
1741
|
row = {"at": int(time.time() * 1000), "host": "in-app", "status": 200,
|
|
1733
1742
|
"called": [{"name": name, "arguments": args_text(plain_of(named))}],
|
|
1734
|
-
"tools": [
|
|
1743
|
+
"tools": [tool_text_row(name, str(text))]}
|
|
1735
1744
|
row.update(inside_of(req))
|
|
1736
1745
|
write({"dep": row})
|
|
1737
1746
|
except Exception:
|
|
@@ -2448,7 +2457,7 @@ def _install():
|
|
|
2448
2457
|
if held_here(url, method, lambda: auth_of(self, k)) and not reads_only(text(body, reply_max)):
|
|
2449
2458
|
port = held_port()
|
|
2450
2459
|
if port is None:
|
|
2451
|
-
raise module.ClientConnectionError("this write was
|
|
2460
|
+
raise module.ClientConnectionError("this write was held and could not be answered")
|
|
2452
2461
|
parts = urlsplit(url)
|
|
2453
2462
|
n = str(next(held_sent))
|
|
2454
2463
|
held_waiting[n] = (url, method, ctx.get())
|
package/lib/read-text.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const rulesHead = (rules) => {
|
|
|
13
13
|
return `Rules in the code: ${num(rules.count)}${has(files) ? `, in ${plural(files, "file")}` : ""}.`;
|
|
14
14
|
};
|
|
15
15
|
// What the last run found about the rule beside it; a rule it never asked is not a rule that held.
|
|
16
|
-
const measuredOf = (m) => (m === null ? ";
|
|
16
|
+
const measuredOf = (m) => (m === null ? "; not measured yet" : m ? `; asked of ${plural(m.asked, "reply", "replies")}, held in ${num(m.held)}${m.asked > m.held ? `, broke in ${num(m.asked - m.held)}` : ""}` : "");
|
|
17
17
|
const ruleLine = (e) => ` ${e.path ? `${at(e.path, e.line)} ` : ""}"${clip(e.text, 300)}"${"measured" in e ? measuredOf(e.measured) : ""}`;
|
|
18
18
|
const headOf = (label, parts) => (parts.some(Boolean) ? `${label}: ${parts.filter(Boolean).join(", ")}.` : `${label}:`);
|
|
19
19
|
const standardsHead = (m) => headOf("Engineering standards", [has(m.decided) && `${num(m.decided)} decided`, has(m.met) && `${num(m.met)} met`, has(m.missed) && `${num(m.missed)} missed${has(m.problems) ? `, from ${plural(m.problems, "problem")}` : ""}`]);
|
|
@@ -65,7 +65,7 @@ export function readLines(r) {
|
|
|
65
65
|
const q = r.questions;
|
|
66
66
|
if (q) lines.push(`Questions: ${num(q.total)}${has(q.everywhere) ? `; ${num(q.everywhere)} asked in every conversation` : ""}${has(q.placed) ? `, ${num(q.placed)} placed in the situations they fit` : ""}.`);
|
|
67
67
|
const t = r.trials;
|
|
68
|
-
if (t) lines.push(trialsHead(t), ...(t.held ?? []).map((h) => ` ${h.surface}: ${plural(h.n, "trial")}
|
|
68
|
+
if (t) lines.push(trialsHead(t), ...(t.held ?? []).map((h) => ` ${h.surface}: ${plural(h.n, "trial")} held back${heldWhy(h)}`));
|
|
69
69
|
return lines;
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -104,7 +104,7 @@ function sectionOf(r, show) {
|
|
|
104
104
|
default: {
|
|
105
105
|
const t = r.trials ?? {};
|
|
106
106
|
const all = t.all ?? (t.held ?? []).map((h) => ({ surface: h.surface, held: h.n, why: h.why, side: h.side }));
|
|
107
|
-
const line = (x) => ` ${x.surface}: ${[has(x.written) && `${num(x.written)} written`, has(x.playable) && `${num(x.playable)} playable`, x.held && `${num(x.held)}
|
|
107
|
+
const line = (x) => ` ${x.surface}: ${[has(x.written) && `${num(x.written)} written`, has(x.playable) && `${num(x.playable)} playable`, x.held && `${num(x.held)} held back`].filter(Boolean).join(", ")}${x.held ? heldWhy(x) : ""}`;
|
|
108
108
|
return { head: trialsHead(t), lines: all.map(line), continued: "Trials, continued." };
|
|
109
109
|
}
|
|
110
110
|
}
|
package/lib/stick.mjs
CHANGED
|
@@ -74,7 +74,7 @@ function write(root, rel, change, done) {
|
|
|
74
74
|
if (next.trim() === "") {
|
|
75
75
|
rmSync(file);
|
|
76
76
|
for (let dir = dirname(file); dir.startsWith(`${resolve(root)}${sep}`) && !readdirSync(dir).length; dir = dirname(dir)) rmdirSync(dir);
|
|
77
|
-
return `${rel}: removed, it
|
|
77
|
+
return `${rel}: removed, it held only what stick wrote`;
|
|
78
78
|
}
|
|
79
79
|
mkdirSync(dirname(file), { recursive: true });
|
|
80
80
|
writeFileSync(file, next);
|
package/lib/stores.mjs
CHANGED
|
@@ -126,6 +126,8 @@ function fromParts(values) {
|
|
|
126
126
|
for (const [p, hostName] of hosts) {
|
|
127
127
|
// A host setting left blank is a store the app is set up not to use (QDRANT_HOST= beside QDRANT_PATH).
|
|
128
128
|
if (hostName && !String(values[hostName] ?? "").trim()) continue;
|
|
129
|
+
// A host setting that holds a whole address (REDIS_HOST=redis://...) is fromUrl's, not a host.
|
|
130
|
+
if (hostName && String(values[hostName]).includes("://")) continue;
|
|
129
131
|
const parts = {};
|
|
130
132
|
for (const name of Object.keys(values)) {
|
|
131
133
|
if (!name.startsWith(p) || name === hostName) continue;
|
|
@@ -235,9 +237,13 @@ const HELD = {
|
|
|
235
237
|
bypassed: () => ["your app reached it anyway, since it loads its settings file over the settings it was started with", "Load that file without overriding what is already set"],
|
|
236
238
|
other: (s) => ["the copy did not go through", `Point ${s.names[0]} at a database on this machine`],
|
|
237
239
|
};
|
|
240
|
+
// The other way out is the owner's yes on the card in the browser, which lets trials write into it as
|
|
241
|
+
// it is: an agent told only to move the store spent ten minutes on a local Qdrant the card had offered
|
|
242
|
+
// to do without (ulaim, 2026-10-01).
|
|
238
243
|
const heldWhy = (s, why, tool) => {
|
|
239
244
|
const [reason, fix] = (HELD[why] ?? HELD.other)(s, tool);
|
|
240
|
-
|
|
245
|
+
const yes = why === "started" ? "" : " Or the owner can say yes to it on the card in the browser, and trials write into it as it is.";
|
|
246
|
+
return `Trials would write into the ${whatOf(s)} that ${listed(s.names)} ${s.names.length === 1 ? "names" : "name"}, on a server off this machine, and ${reason}. ${fix}, then run the command again.${yes}`;
|
|
241
247
|
};
|
|
242
248
|
const hostedKept = (s, got) => (got.how === "neon"
|
|
243
249
|
? `Your app runs against a Neon branch of its ${whatOf(s)}, made for this session through ${listed(s.names)}; the original is not touched, and the branch is deleted when this command ends.`
|
package/lib/text.mjs
CHANGED
|
@@ -30,6 +30,8 @@ export const finished = (run) => Boolean(run) && (run.finished ?? DONE.has(run.s
|
|
|
30
30
|
export function statusText(d) {
|
|
31
31
|
const lines = [];
|
|
32
32
|
if (d.repository?.name) lines.push(`Cortad · ${d.repository.name}`);
|
|
33
|
+
// What changed since the last status call, first: the read landing, endpoints proven, a run.
|
|
34
|
+
if (d.news?.length) lines.push("New since your last call:", ...d.news.map((n) => ` ${n}`));
|
|
33
35
|
const plan = planLine(d.plan);
|
|
34
36
|
if (plan) lines.push(plan);
|
|
35
37
|
lines.push(...appLines(d));
|
|
@@ -86,7 +88,7 @@ function measuredLines(d) {
|
|
|
86
88
|
if (of) lines.push(`${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured.`);
|
|
87
89
|
// The dissection says what was not measured in its own line.
|
|
88
90
|
const groups = d.dissection ? [] : (d.notMeasured?.groups ?? []).filter((g) => g.count > 0 && g.said);
|
|
89
|
-
if (groups.length) lines.push(`${
|
|
91
|
+
if (groups.length) lines.push(`${num(d.notMeasured.total)} not measured: ${groups.map((g) => clip(g.said, 240)).join("; ")}.`);
|
|
90
92
|
// An answer from before failedReplies says its failures here, never folded into a number.
|
|
91
93
|
if (!d.failedReplies && d.replies?.crashed > 0) lines.push(`${num(d.replies.crashed)} of ${plural(d.replies.total, "reply", "replies")} were failures and are not counted.`);
|
|
92
94
|
// The server's own sentence already says the count.
|
|
@@ -171,7 +173,7 @@ function trialsLine(d) {
|
|
|
171
173
|
const t = d.trials;
|
|
172
174
|
// A verify replays the trials it names, so it has no written count of its own to add up.
|
|
173
175
|
if (!t || !has(t.written) || d.kind === "verify") return null;
|
|
174
|
-
const parts = [`${num(t.chosen)} chosen for this run`, t.heldBack && `${num(t.heldBack)}
|
|
176
|
+
const parts = [`${num(t.chosen)} chosen for this run`, t.heldBack && `${num(t.heldBack)} held back`, t.notPlanned && `${num(t.notPlanned)} not planned`].filter(Boolean);
|
|
175
177
|
const rest = t.notPlayed ? `; ${num(t.played)} played, ${num(t.notPlayed)} ${finished(d) ? "not played" : "still to play"}` : "";
|
|
176
178
|
return `Trials: ${num(t.written)} written: ${parts.join(", ")}${rest}.`;
|
|
177
179
|
}
|
|
@@ -299,7 +301,7 @@ function ledgerLines(l) {
|
|
|
299
301
|
for (const f of l.fixed) lines.push(` ${f.path ? `${at(f.path, f.line)} (${f.findingId})` : f.findingId}: ${f.verdict}; ${num(f.after.failed)} of ${plural(f.after.replays, "replay")} failed, against ${num(f.before.failed)} of ${num(f.before.trials)} before${keptSaid(f.heldOut)}.`);
|
|
300
302
|
}
|
|
301
303
|
const n = l.nextRun;
|
|
302
|
-
if (n) lines.push(`The next run would play ${[plural(n.trials, "trial"), has(n.heldOut) && `${num(n.heldOut)}
|
|
304
|
+
if (n) lines.push(`The next run would play ${[plural(n.trials, "trial"), has(n.heldOut) && `${num(n.heldOut)} held out`, has(n.newFromChanges) && `${num(n.newFromChanges)} new from the changes`].filter(Boolean).join(", ")}.`);
|
|
303
305
|
const p = l.plan;
|
|
304
306
|
if (p) lines.push(`The ${p.name} plan, $${p.monthlyUsd} a month${has(p.allowed) ? `, includes ${units(p.allowed, p.unit)}` : ""}.`);
|
|
305
307
|
if (l.field === null) lines.push("Production: not connected.");
|
|
@@ -342,7 +344,7 @@ function findingsTail(d) {
|
|
|
342
344
|
for (const s of [d.read, d.heldBack, d.findings?.length ? null : d.why]) if (s) lines.push(s);
|
|
343
345
|
// A finding in a situation the run keeps back: what failed and where, so the rule can be fixed;
|
|
344
346
|
// its inputs and trials stay unseen, and verify replays it all the same.
|
|
345
|
-
for (const k of d.keptBack ?? []) lines.push(`Kept back ${k.id}: "${clip(k.asks, 160)}"${k.file ? ` at ${at(k.file, k.line)}` : ""}, ${k.trials ? `failed in ${num(k.trials.failed)} of ${plural(k.trials.of, "trial")}` : `
|
|
347
|
+
for (const k of d.keptBack ?? []) lines.push(`Kept back ${k.id}: "${clip(k.asks, 160)}"${k.file ? ` at ${at(k.file, k.line)}` : ""}, ${k.trials ? `failed in ${num(k.trials.failed)} of ${plural(k.trials.of, "trial")}` : `held ${kn(k.rate)}${has(k.reply) ? ` at reply ${num(k.reply)}` : ""}`}${k.quote ? `; the reply said "${clip(k.quote, 120)}"` : ""}. Its inputs are not shown; verify ${k.id} replays them.`);
|
|
346
348
|
return lines.join("\n");
|
|
347
349
|
}
|
|
348
350
|
|
|
@@ -414,10 +416,10 @@ const pct = (x) => `${Math.round(x * 100)}%`;
|
|
|
414
416
|
// whether the passages MISS the point left an agent unable to tell pass from fail.
|
|
415
417
|
function shareLine(f) {
|
|
416
418
|
const t = f.trials;
|
|
417
|
-
if (t) return ` Failed in ${num(t.failed)} of ${plural(t.of, "trial")}, between ${pct(t.lo)} and ${pct(t.hi)} of trials${f.unsettled ? ";
|
|
419
|
+
if (t) return ` Failed in ${num(t.failed)} of ${plural(t.of, "trial")}, between ${pct(t.lo)} and ${pct(t.hi)} of trials${f.unsettled ? "; unsettled: too few trials yet to say it fails in one visit in five" : ""}.`;
|
|
418
420
|
const r = f.rate;
|
|
419
421
|
if (!r) return "";
|
|
420
|
-
return ` Broke in ${num(r.n - r.k)} of ${plural(r.n, "reply", "replies")}${r.n ? `, ${Math.round((100 * (r.n - r.k)) / r.n)}%` : ""}${has(r.lo) ? `, interval ${Math.round((1 - r.hi) * 100)}% to ${Math.round((1 - r.lo) * 100)}%` : ""}.${f.unsettled ? "
|
|
422
|
+
return ` Broke in ${num(r.n - r.k)} of ${plural(r.n, "reply", "replies")}${r.n ? `, ${Math.round((100 * (r.n - r.k)) / r.n)}%` : ""}${has(r.lo) ? `, interval ${Math.round((1 - r.hi) * 100)}% to ${Math.round((1 - r.lo) * 100)}%` : ""}.${f.unsettled ? " Unsettled." : ""}`;
|
|
421
423
|
}
|
|
422
424
|
|
|
423
425
|
// One quoted break: what the trial sent, the reply, the sentence it broke on, what the reply was
|
|
@@ -429,8 +431,6 @@ function quoteLines(q, f, logs) {
|
|
|
429
431
|
if (q.asked) lines.push(` Sent: "${clip(q.asked, 300)}"`);
|
|
430
432
|
lines.push(` Reply ${q.reply}: "${clip(q.answer ?? q.quote, 400)}"${about ? ` (${about})` : ""}`);
|
|
431
433
|
if (q.answer && q.quote && q.quote !== q.answer) lines.push(` It broke on: "${clip(q.quote, 300)}"`);
|
|
432
|
-
// How sure the reading was that this reply broke the check, and who decided it.
|
|
433
|
-
if (has(q.p)) lines.push(` ${num(Math.round(q.p * 100))} in 100 sure${q.decidedBy === "code" ? ", decided by code" : q.decidedBy === "text-judge" ? ", decided by the second judge" : ""}.`);
|
|
434
434
|
if (q.after) lines.push(` Then the customer: "${clip(q.after, 300)}"`);
|
|
435
435
|
if (q.checked) lines.push(` ${q.checked}`);
|
|
436
436
|
for (const p of (q.passages ?? []).slice(0, 3)) lines.push(` Passage${p.source ? ` (${clip(p.source, 60)})` : ""}: "${clip(p.text, 240)}"`);
|
|
@@ -472,11 +472,11 @@ export function fieldText(d) {
|
|
|
472
472
|
if (!t || !t.n) return [`No production conversations read in the last ${plural(d.days, "day")}.`, ...link].join("\n");
|
|
473
473
|
const lines = [
|
|
474
474
|
`Production, last ${plural(d.days, "day")}: ${plural(t.n, "conversation")}, ${num(t.read)} read.`,
|
|
475
|
-
`Rule checks
|
|
475
|
+
`Rule checks held: ${pct(t.rulingsHeld, t.rulings)} of ${num(t.rulings)}${t.rulingsUnsure ? ` (${num(t.rulingsUnsure)} unsure)` : ""}. Resolved ${pct(t.resolved, t.read)}, frustrated ${pct(t.frustrated, t.read)}, asked for a human ${pct(t.wantsHuman, t.read)}, unanswered ${pct(t.unanswered, t.read)}.`,
|
|
476
476
|
];
|
|
477
477
|
const broke = (d.rules ?? []).filter((r) => r.broke > 0).sort((a, b) => b.broke - a.broke).slice(0, 5);
|
|
478
478
|
if (broke.length) lines.push(`Rules broken most: ${broke.map((r) => `${r.id} (${num(r.broke)})`).join(", ")}.`);
|
|
479
|
-
if (d.journeys?.length) lines.push(`By journey: ${d.journeys.slice(0, 6).map((j) => `${j.value} ${plural(j.n, "conversation")}, ${pct(j.rulingsHeld, j.rulings)}
|
|
479
|
+
if (d.journeys?.length) lines.push(`By journey: ${d.journeys.slice(0, 6).map((j) => `${j.value} ${plural(j.n, "conversation")}, ${pct(j.rulingsHeld, j.rulings)} held`).join("; ")}.`);
|
|
480
480
|
return [...lines, ...link].join("\n");
|
|
481
481
|
}
|
|
482
482
|
|
package/lib/trace.cjs
CHANGED
|
@@ -17,7 +17,7 @@ if (FILE) {
|
|
|
17
17
|
const http = require("node:http");
|
|
18
18
|
const https = require("node:https");
|
|
19
19
|
const als = new AsyncLocalStorage();
|
|
20
|
-
const { MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, scrubbed, list, clipped, argsText, turnOf, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames, makeRecorder } = require("./wire.cjs");
|
|
20
|
+
const { MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, toolText, scrubbed, list, clipped, argsText, turnOf, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames, makeRecorder } = require("./wire.cjs");
|
|
21
21
|
const row = (value) => { try { fs.appendFileSync(FILE, JSON.stringify(scrubbed(value)) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ } };
|
|
22
22
|
// What the app writes into its own folder while a run is on: each file copied once before its
|
|
23
23
|
// first write, so the command puts every one back when the run ends (lib/writes.mjs). Trials
|
|
@@ -611,7 +611,7 @@ if (FILE) {
|
|
|
611
611
|
args.forEach((a, i) => { named[IDENT.test(names[i] || "") ? names[i] : String(i)] = plainOf(a); });
|
|
612
612
|
let text;
|
|
613
613
|
try { text = error ? `raised ${(error && error.name) || "an error"}: ${(error && error.message) || String(error)}` : returned(value); } catch { text = "returned a value this hook cannot read"; }
|
|
614
|
-
row({ dep: { at: Date.now(), host: "in-app", status: 200, ...(ctx ? { ex: ctx.id } : {}), ...(turn ? { turn } : {}), called: [{ name, arguments: argsText(named) }], tools: [
|
|
614
|
+
row({ dep: { at: Date.now(), host: "in-app", status: 200, ...(ctx ? { ex: ctx.id } : {}), ...(turn ? { turn } : {}), called: [{ name, arguments: argsText(named) }], tools: [toolText(name, String(text))] } });
|
|
615
615
|
};
|
|
616
616
|
const TOOL = Symbol.for("cortad.wrapped");
|
|
617
617
|
const wrappers = new WeakMap();
|
package/lib/wire.cjs
CHANGED
|
@@ -55,7 +55,11 @@ const partsOf = (text) => text.split(/\{[^}]*\}|\$\{[^}]*\}|%[sd]|<[^>]{1,40}>/)
|
|
|
55
55
|
// messages of a chat body, the function outputs of a responses body, the tool_result blocks of
|
|
56
56
|
// an Anthropic one, the functionResponse parts of a Gemini one. They are the material a reply's
|
|
57
57
|
// facts rest on, and without them a real ticket id read as invented. Bounded per call.
|
|
58
|
-
|
|
58
|
+
// A tool's answer is kept to the cap the reader takes (12,000 characters), and one longer says it was
|
|
59
|
+
// cut: MIT Learn AI's 8,000-character search results were kept at 3,000, and a podcast episode in
|
|
60
|
+
// the part cut off was graded as made up. A cut answer stands the passage questions down.
|
|
61
|
+
const TOOL_TEXT = 12000, TOOLS_MAX = 12;
|
|
62
|
+
const toolText = (name, full, extra = {}) => ({ name: String(name || "").slice(0, 80), text: full.slice(0, TOOL_TEXT), ...extra, ...(full.length > TOOL_TEXT ? { cut: true } : {}) });
|
|
59
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) : "");
|
|
60
64
|
// Only what came after the person's latest message belongs to this turn: a thread the app
|
|
61
65
|
// resends whole carries every earlier turn's tool answers too.
|
|
@@ -71,7 +75,7 @@ const toolsIn = (sent) => {
|
|
|
71
75
|
if (!body || typeof body !== "object") return undefined;
|
|
72
76
|
const out = [];
|
|
73
77
|
const names = new Map();
|
|
74
|
-
const add = (name, text) => { const
|
|
78
|
+
const add = (name, text) => { const full = textOf(text); const t = full.slice(0, TOOL_TEXT); if (t.trim() && out.length < TOOLS_MAX && !out.some((o) => o.text === t)) out.push(toolText(name, full)); };
|
|
75
79
|
for (const m of list(body.messages)) {
|
|
76
80
|
for (const c of list(m.tool_calls)) if (c.id && c.function) names.set(c.id, c.function.name);
|
|
77
81
|
for (const c of list(m.content)) if (c.type === "tool_use" && c.id) names.set(c.id, c.name);
|
|
@@ -323,7 +327,7 @@ const providerIn = (sent, events) => {
|
|
|
323
327
|
const offered = list(body && body.tools).map((t) => t.type).filter((t) => typeof t === "string" && t !== "function" && t !== "custom");
|
|
324
328
|
const named = (kind) => offered.find((t) => t.startsWith(kind)) || kind;
|
|
325
329
|
const calls = new Map(), names = new Map(), answers = [];
|
|
326
|
-
const answer = (name, text) => { const
|
|
330
|
+
const answer = (name, text) => { const full = textOf(text); const t = full.slice(0, TOOL_TEXT); if (name && t.trim() && answers.length < TOOLS_MAX && !answers.some((a) => a.name === name && a.text === t)) answers.push(toolText(name, full, { provider: true })); };
|
|
327
331
|
for (const e of events) {
|
|
328
332
|
if (!e || typeof e !== "object") continue;
|
|
329
333
|
const order = [];
|
|
@@ -813,6 +817,6 @@ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} })
|
|
|
813
817
|
|
|
814
818
|
module.exports = {
|
|
815
819
|
MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, MODEL_HOST,
|
|
816
|
-
scrubbed, list, clipped, argsText, turnOf, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames,
|
|
820
|
+
scrubbed, list, clipped, argsText, turnOf, toolText, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames,
|
|
817
821
|
makeRecorder,
|
|
818
822
|
};
|
package/lib/words.mjs
CHANGED
|
@@ -12,8 +12,6 @@ export const has = (v) => v !== undefined && v !== null;
|
|
|
12
12
|
export const num = (x) => (typeof x === "number" ? Math.round(x).toLocaleString("en-US") : String(x));
|
|
13
13
|
export const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
|
|
14
14
|
export const upper = (s) => `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
|
|
15
|
-
// A reading between 35 and 85 in 100 is too close to call; a block says that band the first time.
|
|
16
|
-
export const closeToCall = (n, said = {}) => { const band = said.close ? "" : " (between 35 and 85 in 100)"; said.close = true; return `${num(n)} too close to call${band}`; };
|
|
17
15
|
// A line past the end of the file as it stands on this machine is not an address: the file moved
|
|
18
16
|
// on since it was read, and an agent was sent to line 204 of a 172-line file. The file stays.
|
|
19
17
|
// Read each time, never kept: the agent edits these files while this server runs. Only a path
|
package/local.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// on with the key this connect leaves in ~/.cortad. Nothing here touches git. Your environment
|
|
13
13
|
// never leaves this machine. Ctrl-C ends everything.
|
|
14
14
|
|
|
15
|
+
import { storesOf } from "./lib/stores.mjs";
|
|
15
16
|
import { notAnApp, withoutStores } from "./lib/app-port.mjs";
|
|
16
17
|
import { holdsKeys, secretEnvValues } from "./lib/keys.mjs";
|
|
17
18
|
import { spawn, execFile, execFileSync } from "node:child_process";
|
|
@@ -508,6 +509,8 @@ async function verb(job) {
|
|
|
508
509
|
}
|
|
509
510
|
// The person changed what they said yes to while this command runs.
|
|
510
511
|
case "consent": await allowOutbound(b); return { ok: true };
|
|
512
|
+
// A line for the agent reading this terminal: the read of the code landing, said the moment it does.
|
|
513
|
+
case "say": { const line = String(b.line ?? "").replace(/\s+/g, " ").trim().slice(0, 400); if (line) say(line); return { ok: true }; }
|
|
511
514
|
case "restart": return restartApp();
|
|
512
515
|
case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
|
|
513
516
|
// Your own pages, read here rather than in a world's shell: that shell is sealed away from
|
|
@@ -661,7 +664,7 @@ const ownFolder = (dir) => { try { const at = realpathSync(dir); return at === r
|
|
|
661
664
|
// carries it to the verbs, and the screen gets the app's own words beside it.
|
|
662
665
|
function whyNot(up) {
|
|
663
666
|
const h = up.held;
|
|
664
|
-
if (h) return `port ${h.port} is
|
|
667
|
+
if (h) return `port ${h.port} is held by another program (pid ${h.pid}${h.command ? `, ${h.command}` : ""}), so your app cannot listen there${up.fixed ? "; your app sets that port in its own code, so it cannot be moved" : ""}. Stop that program, then save a file here: your app is started again by itself.`;
|
|
665
668
|
const wrongNode = pinned.major && !pinned.bin ? ` This project pins Node ${pinned.major} and this shell runs Node ${shellNode}: switch to ${pinned.major}.` : "";
|
|
666
669
|
return `${up.exited !== null ? "your app stopped before it answered" : "your app did not answer within three minutes"}.${wrongNode} Fix it and save a file here: your app is started again by itself.`;
|
|
667
670
|
}
|
|
@@ -861,7 +864,10 @@ async function start(waitMs, tries = 3) {
|
|
|
861
864
|
try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
|
|
862
865
|
// Taken before the app reads a file: a save after this moment is a change it has not loaded.
|
|
863
866
|
const note = { own: true, at: Date.now(), reloads: RELOADER.test(commandText(cmd, appDir)), answered: false };
|
|
864
|
-
|
|
867
|
+
// The settings files count as what it loaded: an env file is rarely tracked, and an agent's edit to
|
|
868
|
+
// one left the app on the old settings until the command was started again (ulaim, 2026-10-01).
|
|
869
|
+
note.files = { ...sourceOf(root, files), ...sourceOf(root, envFiles.filter((f) => !/\.(example|sample)$/.test(f)).map((f) => relative(root, f))) };
|
|
870
|
+
storesAt ??= storesNamed();
|
|
865
871
|
loaded = note;
|
|
866
872
|
const copies = await keeping.env(true);
|
|
867
873
|
await allowOutbound();
|
|
@@ -957,10 +963,21 @@ const savedSinceStart = () => Boolean(loaded?.files) && changesOf({
|
|
|
957
963
|
// Every save that changes what your app runs starts it again, when it does not reload by itself: an
|
|
958
964
|
// agent that saved a fix and asked the app eight seconds later was answered by the old code. Looked
|
|
959
965
|
// at again after each restart, for a save made while it was under way.
|
|
966
|
+
// The stores the settings name, as copies are made of them: a change here is a change of what the app
|
|
967
|
+
// writes into, so its copies and holds are made again for the new settings, and said again.
|
|
968
|
+
const storesNamed = () => { try { return JSON.stringify(storesOf({ ...envExports(envFiles), ...process.env }).map((s) => [s.engine, s.host, s.port, s.db, s.names])); } catch { return ""; } };
|
|
969
|
+
let storesAt = null;
|
|
960
970
|
async function restartOnSave() {
|
|
961
971
|
while (!closing) {
|
|
962
972
|
await sourceChanged();
|
|
963
|
-
while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart())
|
|
973
|
+
while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) {
|
|
974
|
+
const named = storesNamed();
|
|
975
|
+
const moved = storesAt !== null && named !== storesAt;
|
|
976
|
+
storesAt = named;
|
|
977
|
+
if (moved) await keeping.forget().catch(() => {});
|
|
978
|
+
await restartApp(moved ? "starting your app again on the settings you changed, with its data copied again for them" : undefined);
|
|
979
|
+
if (moved) await announce().catch(() => null);
|
|
980
|
+
}
|
|
964
981
|
}
|
|
965
982
|
}
|
|
966
983
|
async function restartNow() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.3.1-rc.
|
|
3
|
+
"version": "0.3.1-rc.15",
|
|
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
|
@@ -25,7 +25,7 @@ The connect command ends with "Go back to the browser".
|
|
|
25
25
|
|
|
26
26
|
1. The connect command says whether it started the app and the port to send requests to. If it says the app did not start, fix what it names and save; it starts the app again by itself. Requests are seen in an app the command started, or at the port it says to send them to.
|
|
27
27
|
2. Send one real request to each endpoint that reaches the model, the way the app's own client does: its route, its body, its sign-in. On a chat, send a second message in the same conversation. When `status` asks for a request signed in as another customer, send it: each customer you sign in as becomes an account of its own in the run.
|
|
28
|
-
3. `status` then shows what Cortad read (rules, journeys, simulated users, endpoints, standards) and each endpoint your requests reached with what it did inside the app: the model and how many calls a request made, the seconds a reply took, which of the rules read from the code the prompts carried, the tools that ran, the passages handed to the model, and problems a line of code decides, at their file and line. Endpoints the read found that no request reached are listed apart. The line `A run on the proven endpoints:` says how many conversations, about how long and about what it costs on the app's own key; any `Run
|
|
28
|
+
3. `status` then shows what Cortad read (rules, journeys, simulated users, endpoints, standards) and each endpoint your requests reached with what it did inside the app: the model and how many calls a request made, the seconds a reply took, which of the rules read from the code the prompts carried, the tools that ran, the passages handed to the model, and problems a line of code decides, at their file and line. Endpoints the read found that no request reached are listed apart. The line `A run on the proven endpoints:` says how many conversations, about how long and about what it costs on the app's own key; any `Run is held` line says whose side it is on.
|
|
29
29
|
4. The first run is free. Before it, read `status` for what would make the run measure the setup instead of the app:
|
|
30
30
|
- a service down: a `Problem` line where a retrieval got no answer, or a tool came back as an error;
|
|
31
31
|
- a request that ended on a tool call: `The request ended right after ... ran: no model call came after it`, which every conversation on that endpoint would repeat;
|
|
@@ -41,7 +41,7 @@ The connect command ends with "Go back to the browser".
|
|
|
41
41
|
|
|
42
42
|
## The first run: the baseline
|
|
43
43
|
|
|
44
|
-
The first finished run on a repository prints its baseline, in this order: one line on what ran; who the users are, as the read found them in the code, with how many of each one's conversations got what they came for; the journeys and how each went, with the checks that failed most there; the checks no conversation broke; what broke, found only by running the app, each with its file and line; what a code review would also find; what
|
|
44
|
+
The first finished run on a repository prints its baseline, in this order: one line on what ran; who the users are, as the read found them in the code, with how many of each one's conversations got what they came for; the journeys and how each went, with the checks that failed most there; the checks no conversation broke; what broke, found only by running the app, each with its file and line; what a code review would also find; what was not measured and why; one line on production.
|
|
45
45
|
|
|
46
46
|
How to read it:
|
|
47
47
|
|
|
@@ -49,8 +49,8 @@ How to read it:
|
|
|
49
49
|
- "Got what they came for" is read at each conversation's last reply, in full or in part.
|
|
50
50
|
- What broke was found by simulated customers who write the way the read says that user writes and answer each reply as it comes. Each finding carries its reach, the exchange (`Sent`, `Broke on`, `Then`) and where the fix belongs.
|
|
51
51
|
- A hand-written script sends the requests someone wrote and checks the strings they expected. The exchange shows whether a finding took a customer who pressed, came back or wrote their own way; say which findings a script would also have caught.
|
|
52
|
-
- What
|
|
53
|
-
- "What
|
|
52
|
+
- What held is measured too: a check no conversation broke, asked often enough that its interval tops out under one in five, leaving out any that names a tool no conversation ran.
|
|
53
|
+
- "What was not measured, and why" is what this run says nothing about.
|
|
54
54
|
- Some conversations are kept back from you; a verify replays them too, so a fix is graded on conversations you have not read.
|
|
55
55
|
|
|
56
56
|
Tell the person what the run found and what it did not, with the numbers as printed.
|
|
@@ -78,7 +78,7 @@ The trials, checks, seeds and the conversations kept back belong to Cortad, and
|
|
|
78
78
|
After the fixes, `run` again. It plays what your changes since the last full run reach: those conversations again, each paired with its earlier self, and 8 to 12 new ones written for the change; it says what it left out. Its text leads with what moved: each behavior's failing count before and now, whether that is beyond chance, and the conversation that turned. Worse comes first.
|
|
79
79
|
|
|
80
80
|
|
|
81
|
-
Production comes up after the first run. A verify whose failure is gone, and gone or less often on the conversations kept back from you, while production is not connected, ends with a "For the person" line that offers it; the plan line after a run names the production replies the plan above reads, and a spent plan's answer says whether production is connected. `field_connect` gives the steps; the owner creates the key in the browser. `field` gives the numbers: conversations read, checks
|
|
81
|
+
Production comes up after the first run. A verify whose failure is gone, and gone or less often on the conversations kept back from you, while production is not connected, ends with a "For the person" line that offers it; the plan line after a run names the production replies the plan above reads, and a spent plan's answer says whether production is connected. `field_connect` gives the steps; the owner creates the key in the browser. `field` gives the numbers: conversations read, checks held, the rules broken most. Message text stays out.
|
|
82
82
|
|
|
83
83
|
## Making it stick
|
|
84
84
|
|
|
@@ -57,7 +57,7 @@ Engineering standards: 38 decided, 35 met, 3 missed.
|
|
|
57
57
|
apps/api/src/tools/search.ts:51 Tool errors reach the model as errors: search returns an empty list when the index is down (decided by a model)
|
|
58
58
|
Questions: 112; 40 asked in every conversation, 72 placed in the situations they fit.
|
|
59
59
|
Trials: 58 written.
|
|
60
|
-
POST /api/homework/upload: 7 trials
|
|
60
|
+
POST /api/homework/upload: 7 trials held back, on the app's side: the route needs a signed file URL, and no test account can make one
|
|
61
61
|
Latest run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: running, 14 of 51 trials played.
|
|
62
62
|
Production: not connected.
|
|
63
63
|
For the person: https://cortad.com/lab shows this in the browser.
|
|
@@ -109,7 +109,7 @@ Last run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: 3 findings, 51 of 51 trials playe
|
|
|
109
109
|
2 fixes verified since that run:
|
|
110
110
|
apps/api/src/agent/prompt.ts:41 (finding:1): gone; 0 of 3 replays failed, against 3 of 3 before; on the conversations kept back from you, 1 of 2 failed before, 0 of 2 after, not beyond chance.
|
|
111
111
|
apps/api/src/agent/system.ts:12 (finding:2): gone; 0 of 7 replays failed, against 4 of 4 before; the fix made the failure worse on the conversations kept back from you (0 of 8 failed before, 6 of 8 after).
|
|
112
|
-
The next run would play 58 trials, 7
|
|
112
|
+
The next run would play 58 trials, 7 held out, 5 new from the changes.
|
|
113
113
|
The Hobby plan, $99 a month, includes 10 runs.
|
|
114
114
|
Production: not connected.
|
|
115
115
|
Nothing ran.
|
|
@@ -118,14 +118,14 @@ For the person: plans and checkout at https://cortad.com/pricing?checkout=ship
|
|
|
118
118
|
|
|
119
119
|
## run_status
|
|
120
120
|
|
|
121
|
-
Playing. The call
|
|
121
|
+
Playing. The call held up to 45 seconds:
|
|
122
122
|
|
|
123
123
|
```
|
|
124
124
|
Run 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b: running, 14 of 51 trials played.
|
|
125
125
|
next: run_status 8f2a1c4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
-
Finished, the first run on a repository: its baseline. One line on what ran, then who the users are as the read found them in the code, with how many of each one's conversations got what they came for, in full or in part, read at the last reply; the journeys the read named and how each went, with the one or two checks that failed most there; the checks no conversation broke, asked often enough that each fails less than one time in five; then what broke, found only by running the app, each finding with its reach in conversations, the line of the app that wrote the breaking reply, the tools behind it, the layer a fix belongs in (prompt, flow around the model, tool or routing) and the exchange it broke on; the gates a knock found open; what a code review would also have found; what
|
|
128
|
+
Finished, the first run on a repository: its baseline. One line on what ran, then who the users are as the read found them in the code, with how many of each one's conversations got what they came for, in full or in part, read at the last reply; the journeys the read named and how each went, with the one or two checks that failed most there; the checks no conversation broke, asked often enough that each fails less than one time in five; then what broke, found only by running the app, each finding with its reach in conversations, the line of the app that wrote the breaking reply, the tools behind it, the layer a fix belongs in (prompt, flow around the model, tool or routing) and the exchange it broke on; the gates a knock found open; what a code review would also have found; what was held back and not measured; the run's model, time and spend; and one line on production. Every rate carries its 95% interval, said in words the first time. The findings take the room the picture leaves, the rest one line each, and findings shows them whole.
|
|
129
129
|
|
|
130
130
|
A finding that sends customers away counts, for one journey or for everything else customers asked at one endpoint, the conversations whose last reply declined them or pointed them somewhere else, or where they asked for a person or said the app could not help and were not handed on; a conversation that ended with the customer served is never counted. It names the line of the prompt, or the tool those asks needed that the call writing the reply was given and never ran, that at least two and at least half of the replies read point at, reading at most 8 of them, and says where the fix belongs is not known when neither holds; it shows what the customer said next, and stays unconfirmed until its conversations were checked a second time. The same cut comes as structured data beside the text:
|
|
131
131
|
|
|
@@ -175,9 +175,9 @@ access:1 GET /api/admin/students answered 200 to a request with no sign-in; it
|
|
|
175
175
|
Harm: data exposure.
|
|
176
176
|
At apps/api/src/routes/admin.ts:14
|
|
177
177
|
verify access:1 knocks the routes again.
|
|
178
|
-
What
|
|
179
|
-
|
|
180
|
-
28 checks
|
|
178
|
+
What was not measured, and why:
|
|
179
|
+
Held: 7 conversations held back (POST /api/homework/upload: the route needs a signed file URL, and no test account can make one).
|
|
180
|
+
28 checks not measured: 16 need a conversation past the first reply; 8 never met their condition (the parent asks what the plan costs); 4 have no conversation yet.
|
|
181
181
|
No conversation reached this journey: account recovery.
|
|
182
182
|
Your app answered on gpt-4o-mini. The run took 7 min 32 s and spent $0.41 on your key over 214 model calls.
|
|
183
183
|
Replies took 3.1 s at the median and 11.4 s at the slowest, over 138 replies.
|
|
@@ -343,7 +343,7 @@ finding:2 Is the reply written in a language other than the one the student wro
|
|
|
343
343
|
A verify replays its 4 failing conversations. 2 clean replays show it gone.
|
|
344
344
|
|
|
345
345
|
finding:3 Does the reply promise a refund the billing page does not offer?
|
|
346
|
-
Failed in 2 of 7 trials, between 8% and 64% of trials;
|
|
346
|
+
Failed in 2 of 7 trials, between 8% and 64% of trials; unsettled: too few trials yet to say it fails in one visit in five.
|
|
347
347
|
Sent: "We are on the paid plan. What happens if we cancel?"
|
|
348
348
|
Reply 1: "You can get a full refund any time in the first 60 days." (trial 77a0c5d1)
|
|
349
349
|
At apps/api/src/agent/prompt.ts:44
|
|
@@ -357,7 +357,7 @@ finding:3 Does the reply promise a refund the billing page does not offer?
|
|
|
357
357
|
"Never state a refund policy the product does not publish." (apps/api/src/agent/prompt.ts:41): broke in 9 of 12 trials.
|
|
358
358
|
"Answer in the language the student writes in." (apps/api/src/agent/system.ts:12): broke in 2 of 26 trials.
|
|
359
359
|
112 of 140 checks measured.
|
|
360
|
-
28
|
|
360
|
+
28 not measured: 16 need a conversation past the first reply; 8 never met their condition (the parent asks what the plan costs); 4 have no conversation yet.
|
|
361
361
|
3 findings stand in the 12 situations you can read.
|
|
362
362
|
2 findings stand in 3 situations kept back from you. You cannot read them, and a fix is graded on those too.
|
|
363
363
|
```
|
|
@@ -408,8 +408,8 @@ Production is not connected.
|
|
|
408
408
|
|
|
409
409
|
```
|
|
410
410
|
Production, last 30 days: 4,812 conversations, 4,790 read.
|
|
411
|
-
Rule checks
|
|
411
|
+
Rule checks held: 93% of 61,204 (1,120 unsure). Resolved 71%, frustrated 6%, asked for a human 2%, unanswered 4%.
|
|
412
412
|
Rules broken most: rule:answer-first (412), rule:language (188), rule:cite-source (97).
|
|
413
|
-
By journey: homework help 3,102 conversations, 94%
|
|
413
|
+
By journey: homework help 3,102 conversations, 94% held; billing question 410 conversations, 88% held.
|
|
414
414
|
For the person: https://cortad.com/lab#field
|
|
415
415
|
```
|