cortad 0.2.2 → 0.3.0-rc.10

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/text.mjs ADDED
@@ -0,0 +1,268 @@
1
+ import { readLines } from "./read-text.mjs";
2
+ import { at, clip, has, num, PAGE_CHARS, pageOf, plural, SIDE, upper } from "./words.mjs";
3
+
4
+ // What each verb prints. A result is data for the coding agent: a line meant for the person starts
5
+ // with "For the person:", and a run_status result ends with the next call. A field the API leaves
6
+ // out prints nothing, so the text follows the API as it grows.
7
+
8
+ const DONE = new Set(["succeeded", "failed", "canceled"]);
9
+ const UNIT = { sweeps: ["run", "runs"], runs: ["run", "runs"], trials: ["verify trial", "verify trials"] };
10
+ export const STARTING = "Starting your app for the run. Call run_status; it answers as soon as the run has an id.";
11
+
12
+ const signed = (x) => (Math.round(x) > 0 ? `+${num(x)}` : num(x));
13
+ const units = (n, unit) => { const [one, many] = UNIT[unit] ?? [unit, unit]; return `${num(n)} ${n === 1 ? one : many}`; };
14
+ const kn = (x) => (has(x?.k) ? `${num(x.k)} of ${num(x.n)}` : num(x));
15
+ const interval = (ci) => (ci && has(ci.low) ? `, interval ${num(ci.low)} to ${num(ci.high)}` : "");
16
+
17
+ export const finished = (run) => Boolean(run) && (run.finished ?? DONE.has(run.status));
18
+
19
+ export function statusText(d) {
20
+ const lines = [];
21
+ if (d.repository?.name) lines.push(`Cortad · ${d.repository.name}`);
22
+ const plan = planLine(d.plan);
23
+ if (plan) lines.push(plan);
24
+ if (d.app?.said) lines.push(`App: ${d.app.said}`);
25
+ if (d.read?.complete === false) lines.push("The read of your repository is still running; the lists below fill in as it finishes.");
26
+ if (d.read) lines.push(...readLines(d.read));
27
+ if (d.run === null) lines.push("No run yet.");
28
+ else if (d.run) lines.push(...runLines(d.run, "latest "));
29
+ if (d.field) lines.push(`Production: ${d.field.connected ? "connected" : "not connected"}.`);
30
+ if (d.links?.lab && (d.read || d.run)) lines.push(`For the person: ${d.links.lab} shows this in the browser.`);
31
+ if (d.run && !finished(d.run)) lines.push(`next: run_status ${d.run.jobId}`);
32
+ return lines.join("\n");
33
+ }
34
+
35
+ function planLine(p) {
36
+ if (!p?.name) return null;
37
+ const parts = [];
38
+ if (has(p.runsLeft) && has(p.runsAllowed)) parts.push(`${num(p.runsLeft)} of ${plural(p.runsAllowed, "run")} left this month`);
39
+ if (has(p.verifyTrialsLeft) && has(p.verifyTrialsAllowed)) parts.push(`${num(p.verifyTrialsLeft)} of ${plural(p.verifyTrialsAllowed, "verify trial")} left`);
40
+ return parts.length ? `${p.name}: ${parts.join(", ")}.` : `Plan: ${p.name}.`;
41
+ }
42
+
43
+ // The number with what it stands on: a 100 beside an app that stopped, or from five readings, read
44
+ // as a verdict to two agents. Under the 22-reading floor it is provisional and says so.
45
+ function coverageLine(d) {
46
+ const never = d.questionsOf - d.questionsAsked;
47
+ const share = Math.round((100 * d.questionsAsked) / d.questionsOf);
48
+ return `Measured ${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} (${share}%)${never > 0 ? `; ${num(never)} never came up in the situations played, so nothing below speaks for them` : ""}.`;
49
+ }
50
+
51
+ function scoreLine(d) {
52
+ const floor = has(d.decided) && d.decided < 22;
53
+ return `${floor ? "Provisional score" : "Score"} ${num(d.score)} of 100${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}.`;
54
+ }
55
+ const haltLine = (h) => `${h.side === "ours" ? "We lost your app" : "Your app stopped answering"} after ${plural(h.after, "turn")}${h.why ? `: ${clip(h.why, 200)}` : ""}. The score covers the turns before that.`;
56
+ const LAYER_WORDS = {
57
+ retrieval: "what the store returned behind the reply, not how the reply was written",
58
+ tools: "the tool call behind the reply",
59
+ memory: "what the app kept between turns",
60
+ application: "an error in the app around the model",
61
+ instructions: "the model against its own instructions",
62
+ upsell: "what the reply offered beyond the ask",
63
+ };
64
+
65
+ function runLines(d, prefix = "") {
66
+ const done = finished(d);
67
+ const played = d.of ? `${num(d.played ?? 0)} of ${plural(d.of, "trial")} played` : "no trial played yet";
68
+ const lines = [`${upper(`${prefix}${d.kind === "verify" ? "verify" : "run"}`)} ${d.jobId}: ${d.status === "succeeded" || !d.status ? (done ? "finished" : "running") : d.status}, ${played}.`];
69
+ // Coverage before the number: a 97 beside 59 of 80 checks that never fired read as production-ready
70
+ // to every agent that got it. What was measured leads, then the rate on what was measured.
71
+ if (done && has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0) lines.push(coverageLine(d));
72
+ if (done && typeof d.score === "number") lines.push(scoreLine(d));
73
+ if (done && d.halted) lines.push(haltLine(d.halted));
74
+ if (done && has(d.readingsHeld) && has(d.readings) && d.readings > 0) lines.push(`The question readings held in ${num(d.readingsHeld)} of ${num(d.readings)}, ${Math.round((100 * d.readingsHeld) / d.readings)}%; the score is the case checks' pass rate on the checks measured.`);
75
+ if (done && has(d.findings)) lines.push(`${plural(d.findings, "finding")}.`);
76
+ for (const h of (d.heldBack ?? []).slice(0, 6)) lines.push(`Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`);
77
+ const reads = readingsLine(d);
78
+ if (reads) lines.push(reads);
79
+ if (d.stopped) lines.push(stoppedLine(d));
80
+ for (const f of d.faults ?? []) lines.push(`Fault${SIDE[f.side] ? ` ${SIDE[f.side]}` : ""}: ${f.what}${f.fix ? ` ${f.fix}` : ""}`);
81
+ if (d.error) lines.push(`Error: ${d.error}`);
82
+ if (d.verify) lines.push(...verifyLines(d.verify));
83
+ return lines;
84
+ }
85
+
86
+ // A reading is one question checked against one reply.
87
+ function readingsLine(d) {
88
+ if (!has(d.readings) && !has(d.questionsAsked)) return null;
89
+ const r = has(d.readings) ? plural(d.readings, "reading") : null;
90
+ const q = has(d.questionsAsked) ? `${plural(d.questionsAsked, "question")}${has(d.questionsOf) && d.questionsOf > d.questionsAsked ? ` (${num(d.questionsOf - d.questionsAsked)} of the ${num(d.questionsOf)} in the set never came up)` : ""}` : null;
91
+ const split = [has(d.decided) && `${num(d.decided)} decided`, has(d.unclear) && `${num(d.unclear)} unclear`].filter(Boolean).join(", ");
92
+ return `${r && q ? `${r} of ${q}` : r ?? `${q} asked`}${split ? `: ${split}` : ""}.`;
93
+ }
94
+
95
+ // A run that stopped before its last trial says where; one that stopped after it is a note.
96
+ function stoppedLine(d) {
97
+ const s = d.stopped;
98
+ const turn = has(s.after) ? ` at turn ${s.after}` : "";
99
+ const fix = s.fix ? ` ${s.fix}` : "";
100
+ if (d.of && d.played < d.of) return `Stopped at ${num(d.played)} of ${plural(d.of, "trial")}${SIDE[s.side] ? `, ${SIDE[s.side]}` : ""}: ${s.why}${turn}.${fix}`;
101
+ return `${upper(s.why)}${turn}${SIDE[s.side] ? `, ${SIDE[s.side]}` : ""}.${fix}`;
102
+ }
103
+
104
+ const moveText = (m) => `held ${kn(m.before)} readings before, ${kn(m.after)} after; ${m.moved}, ${signed(m.point)} points${interval(m)}${m.insideNoise ? ", inside the noise" : ""}.`;
105
+
106
+ function verifyLines(v) {
107
+ const lines = [`Verify of ${v.findingId}${v.path ? ` at ${at(v.path, v.line)}` : ""}.`];
108
+ if (v.visible) lines.push(`Visible trials: ${moveText(v.visible)}`);
109
+ if (v.holdout) lines.push(`Held-out trials: ${moveText(v.holdout)}`);
110
+ else if (v.holdout === null) lines.push("Held-out trials: no pair for this question.");
111
+ if (v.overfit) lines.push("Overfit: the visible trials moved and the held-out trials did not.");
112
+ if (v.said) lines.push(v.said);
113
+ return lines;
114
+ }
115
+
116
+ export function nextCall(d) {
117
+ if (!finished(d)) return `run_status ${d.jobId}`;
118
+ return d.kind === "verify" || d.findings > 0 ? "findings" : "status";
119
+ }
120
+
121
+ export function runText(d) {
122
+ const lines = runLines(d);
123
+ if (finished(d) && d.url) lines.push(`For the person: the report is at ${d.url}`);
124
+ lines.push(`next: ${nextCall(d)}`);
125
+ return lines.join("\n");
126
+ }
127
+
128
+ export function startedText(d, kind, findingId) {
129
+ const verify = (d.kind ?? kind) === "verify";
130
+ const what = verify ? `Verify${findingId && !d.joined ? ` of ${findingId}` : ""}` : "Run";
131
+ return `${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}.\nnext: run_status ${d.jobId}`;
132
+ }
133
+
134
+ export const waitingText = (p, now, limitMs) =>
135
+ `Your app is still starting for the ${p.kind === "verify" ? "verify" : "run"}: ${num((now - Date.parse(p.startedAt)) / 1000)} of up to ${num(limitMs / 1000)} seconds.\nnext: run_status pending`;
136
+
137
+ // A spent plan: what the last run found, what was fixed since, what the next run would play.
138
+ export function refusedText(d) {
139
+ const lines = [];
140
+ if (has(d.used) && has(d.allowed) && d.unit) lines.push(`Refused: ${num(d.used)} of ${units(d.allowed, d.unit)} used${d.plan?.name ? ` on the ${d.plan.name} plan` : ""}.`);
141
+ else if (d.why) lines.push(`Refused: ${d.why}`);
142
+ if (d.ledger) lines.push(...ledgerLines(d.ledger));
143
+ if (!d.ledger?.plan && d.plans?.length) lines.push(`Plans: ${d.plans.map((p) => `${p.name} $${p.monthlyUsd} a month`).join(", ")}.`);
144
+ lines.push("Nothing ran.");
145
+ if (d.checkout) lines.push(`For the person: plans and checkout at ${d.checkout}`);
146
+ return lines.join("\n");
147
+ }
148
+
149
+ function ledgerLines(l) {
150
+ const lines = [];
151
+ const r = l.lastRun;
152
+ if (r) {
153
+ const parts = [has(r.score) && `score ${num(r.score)} of 100${interval(r.ci)}`, has(r.findings) && plural(r.findings, "finding"), r.of && `${num(r.played ?? 0)} of ${plural(r.of, "trial")} played`].filter(Boolean);
154
+ lines.push(`Last run ${r.jobId}: ${parts.join(", ")}.`);
155
+ }
156
+ if (l.fixed) {
157
+ lines.push(`${plural(l.fixed.length, "fix", "fixes")} verified since that run${l.fixed.length ? ":" : "."}`);
158
+ for (const f of l.fixed) lines.push(` ${at(f.path, f.line)} (${f.findingId}): held ${kn(f.before)} readings before, ${kn(f.after)} after; ${f.moved}, ${signed(f.move)} points${interval(f)}.`);
159
+ }
160
+ const n = l.nextRun;
161
+ 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(", ")}.`);
162
+ const p = l.plan;
163
+ if (p) lines.push(`The ${p.name} plan, $${p.monthlyUsd} a month${has(p.allowed) ? `, includes ${units(p.allowed, p.unit)}` : ""}.`);
164
+ if (l.field === null) lines.push("Production: not connected.");
165
+ else if (l.field) lines.push(`Production, last ${plural(l.field.days, "day")}: ${plural(l.field.conversations, "conversation")}${has(l.field.brokenRate) ? `, ${Math.round(l.field.brokenRate * 100)}% of them broke a rule` : ""}.`);
166
+ return lines;
167
+ }
168
+
169
+ // Findings grouped by the line they live at: the server's byLine, or the findings' own file and
170
+ // line when it sends none. The same line from two server pages is one group.
171
+ export function linesOf(d) {
172
+ const rows = d.byLine ?? (d.findings ?? []).filter((f) => f.file).map((f) => ({ path: f.file, line: f.line, findingIds: [f.id] }));
173
+ const merged = new Map();
174
+ for (const r of rows) {
175
+ const key = at(r.path, r.line);
176
+ if (merged.has(key)) merged.get(key).findingIds.push(...r.findingIds);
177
+ else merged.set(key, { path: r.path, line: r.line, findingIds: [...r.findingIds] });
178
+ }
179
+ return [...merged.values()];
180
+ }
181
+
182
+ export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n) => `findings with page ${n}`);
183
+
184
+ function findingsHead(d) {
185
+ const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
186
+ if (typeof d.score === "number") lines.push(scoreLine(d));
187
+ if (d.halted) lines.push(haltLine(d.halted));
188
+ const reads = readingsLine(d);
189
+ if (reads) lines.push(reads);
190
+ if (d.baseRunId) lines.push(`Compared with run ${d.baseRunId}.`);
191
+ for (const s of [d.read, d.heldBack, d.findings?.length ? null : d.why]) if (s) lines.push(s);
192
+ return lines.join("\n");
193
+ }
194
+
195
+ // Whole findings, worst first as the server orders them, packed into pages that stay under
196
+ // PAGE_CHARS. A group that crosses a page carries its header again.
197
+ function findingsPages(d, budget = PAGE_CHARS - 60) {
198
+ const head = findingsHead(d);
199
+ if (!d.findings?.length) return [head];
200
+ const byId = new Map(d.findings.map((f) => [f.id, f]));
201
+ const groups = linesOf(d).map((g) => ({ label: `${plural(g.findingIds.length, "finding")} at ${at(g.path, g.line)}`, placed: true, items: g.findingIds.map((id) => byId.get(id)).filter(Boolean) }));
202
+ const grouped = new Set(groups.flatMap((g) => g.items.map((f) => f.id)));
203
+ const rest = d.findings.filter((f) => !grouped.has(f.id));
204
+ if (rest.length) groups.push({ label: `${plural(rest.length, "finding")} without a line`, placed: false, items: rest });
205
+
206
+ const pages = [];
207
+ let page = head;
208
+ let filled = false;
209
+ let open = null;
210
+ for (const g of groups) {
211
+ g.items.forEach((f, i) => {
212
+ const block = findingBlock(f, g.placed);
213
+ const label = () => (open === g ? "" : `\n\n${i === 0 ? g.label : `${g.label}, continued`}`);
214
+ if (filled && page.length + label().length + block.length + 1 > budget) {
215
+ pages.push(page);
216
+ page = `Run ${d.runId}, findings continued.`;
217
+ filled = false;
218
+ open = null;
219
+ }
220
+ page += `${label()}\n${block}`;
221
+ filled = true;
222
+ open = g;
223
+ });
224
+ }
225
+ pages.push(page);
226
+ return pages;
227
+ }
228
+
229
+ function findingBlock(f, placed) {
230
+ const lines = [`${f.id} ${f.asks}`];
231
+ if (!placed && f.file) lines.push(` At ${at(f.file, f.line)}`);
232
+ if (f.criteria) lines.push(` Criteria: ${clip(f.criteria, 600)}`);
233
+ if (f.door) lines.push(` Endpoint: ${f.door}`);
234
+ else if (f.doors?.length) lines.push(` Endpoints: ${f.doors.join(", ")}`);
235
+ if (f.where) lines.push(` Situation: ${f.where}`);
236
+ if (f.layer && LAYER_WORDS[f.layer]) lines.push(` Layer: ${f.layer}, ${LAYER_WORDS[f.layer]}.`);
237
+ const r = f.rate;
238
+ // The break count, whichever way the question is phrased: "held in 21 of 33" under a question
239
+ // that asks whether the passages MISS the point left an agent unable to tell pass from fail.
240
+ if (r) lines.push(` 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)}%` : ""}.`);
241
+ if (f.unsettled) lines.push(" Unsettled: under the 22-reading floor.");
242
+ const by = [f.decidedBy?.code && `code in ${plural(f.decidedBy.code, "reading")}`, f.decidedBy?.model && `a model in ${plural(f.decidedBy.model, "reading")}`].filter(Boolean);
243
+ if (by.length) lines.push(` Decided by ${by.join(", by ")}.`);
244
+ for (const q of (f.quotes ?? []).slice(0, 3)) {
245
+ const about = [typeof q.p === "number" && `confidence ${q.p.toFixed(2)}`, q.trialId && `trial ${q.trialId}`].filter(Boolean).join(", ");
246
+ lines.push(` Reply ${q.reply}: "${clip(q.quote, 400)}"${about ? ` (${about})` : ""}`);
247
+ }
248
+ for (const line of (f.log ?? []).slice(0, 8)) lines.push(` Log: ${clip(line, 300)}`);
249
+ if (f.replay) lines.push(` Replay: ${plural(f.replay.trials, "trial")}, verify ${f.id}`);
250
+ return lines.join("\n");
251
+ }
252
+
253
+ export function fieldText(d) {
254
+ const t = d.totals;
255
+ const pct = (k, n) => (n ? `${Math.round((100 * k) / n)}%` : "n/a");
256
+ const link = d.url ? [`For the person: ${d.url}`] : [];
257
+ if (!t || !t.n) return [`No production conversations read in the last ${plural(d.days, "day")}.`, ...link].join("\n");
258
+ const lines = [
259
+ `Production, last ${plural(d.days, "day")}: ${plural(t.n, "conversation")}, ${num(t.read)} read.`,
260
+ `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)}.`,
261
+ ];
262
+ const broke = (d.rules ?? []).filter((r) => r.broke > 0).sort((a, b) => b.broke - a.broke).slice(0, 5);
263
+ if (broke.length) lines.push(`Rules broken most: ${broke.map((r) => `${r.id} (${num(r.broke)})`).join(", ")}.`);
264
+ 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("; ")}.`);
265
+ return [...lines, ...link].join("\n");
266
+ }
267
+
268
+ export const fieldConnectText = (d) => [d.connected ? "Production is connected." : "Production is not connected.", ...(d.steps ?? []).map((s, i) => `${i + 1}. ${s}`)].join("\n");