cortad 0.4.1 → 0.4.3

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.
@@ -33,6 +33,12 @@ export function secretValues(envFiles) {
33
33
 
34
34
  export const maskWith = (text, values) => { let s = text; for (const v of values) s = s.split(v).join("[masked]"); return s; };
35
35
 
36
+ // Whether a value reads as a credential rather than configuration. A database or project name is a
37
+ // route word too: masking it inside the app's route table turns the path into one no request can
38
+ // replay, and every trial of that door fails. A plain word under 24 characters is configuration;
39
+ // anything key-like, mixed with digits or symbols, or long stays masked everywhere.
40
+ export const credentialShaped = (v) => KEY_LIKE.test(v) || !/^[A-Za-z ._-]+$/.test(v) || v.length >= 24;
41
+
36
42
  // A process's output as it may be kept: masked with `values()`, read at each chunk since values are
37
43
  // learned while the app runs, and never cut inside one. A line goes to `write` once it ends; a line
38
44
  // longer than `max` goes on early, all but its last characters, as many as the longest value less
package/lib/proof.mjs CHANGED
@@ -185,7 +185,7 @@ export const replyOf = once((ex) => {
185
185
  .sort((a, b) => b.score - a.score || b.l.value.length - a.l.value.length)[0];
186
186
  // The leaf names the field the run reads; the reply is the document it sits in, when it sits in one.
187
187
  const text = best ? documentAt(whole, best.l.at) ?? readable(best.l.value) : "";
188
- return { text, path: best ? best.l.at.join(".") : null, stream: false };
188
+ return { text, path: best ? newestReply(whole, best.l.at).join(".") : null, stream: false };
189
189
  }
190
190
  if (!stream) return { text: readable(typeof whole === "string" ? whole : ex.reply), path: null, stream: false };
191
191
  const { events, loose } = eventsOf(ex.reply);
@@ -249,6 +249,15 @@ const at = (v, path) => path.reduce((o, k) => (o === null || typeof o !== "objec
249
249
  // A concrete path with the last entry of a list of turns written -1, so it still names the newest
250
250
  // turn when the list holds more of them. A list of anything else is positional and keeps its index.
251
251
  const newest = (v, path) => path.map((k, i) => { const list = at(v, path.slice(0, i)); return typeof k === "number" && isTurns(list) && k === list.length - 1 ? -1 : k; });
252
+ // A reply's place in a thread the app answers with is its newest turn, also where the turns carry no
253
+ // side: Nitham's {turns: [{n, kind, request, markdown}]} was read at turns.0.markdown, the first answer,
254
+ // on every later turn. Only a reply's path; a request's history keeps the role rule.
255
+ const TURNS_NAME = /^(?:turns|messages|history|exchanges|replies)$/i;
256
+ const newestReply = (v, path) => path.map((k, i) => {
257
+ const list = at(v, path.slice(0, i));
258
+ const named = i > 0 && TURNS_NAME.test(String(path[i - 1])) && Array.isArray(list) && list.every((e) => e && typeof e === "object" && !Array.isArray(e));
259
+ return typeof k === "number" && (named || isTurns(list)) && k === list.length - 1 ? -1 : k;
260
+ });
252
261
 
253
262
  // A stream whose events carry the whole thread (a queue's completed event holds every turn so far)
254
263
  // has the reply at one entry of a list, not spread over the events: the leaf the model's words match
@@ -757,9 +766,20 @@ function problemsIn(ex, reply, find, frame, printedLine) {
757
766
  placed("leaked-markup", [...tags][0], `The reply carried markup from the prompt your app sent the model: ${listed(tags)}.`, written ?? call,
758
767
  written ? "" : " None of it is written in your code; this is the line that made the model call.");
759
768
  }
769
+ // A SLOT IS ONLY A SLOT WHERE A LINE OF THEIR CODE WRITES IT.
770
+ //
771
+ // Both branches now need `find(p)`. The first one asked only whether the same braces appear in the
772
+ // runtime prompt, which on a maths tutor they always do: LaTeX. `\text{kg}` in a reply and the same
773
+ // in the prompt reported an unfilled placeholder and addressed it to the line that made the model
774
+ // call, while holding the proof that no code of theirs writes the token. Measured on one customer's
775
+ // 64 replies: 17 of them carry a brace group this pattern matches, and `\begin{bmatrix}`,
776
+ // `\frac{dy}`, `{dx}` and `{T_y}` are not slots. Every `placeholder` problem this rule has ever
777
+ // recorded, 12 of them across six days, was that same false reading, and none was ever real.
760
778
  for (const [p] of said.matchAll(PLACEHOLDER)) {
761
- if (prompt.includes(norm(p))) placed("placeholder", p, `The reply carried "${p}", a placeholder still unfilled in the prompt sent to the model.`, find(p) ?? call);
762
- else if (!words.includes(norm(p)) && find(p)) add("placeholder", `The reply carried "${p}", a placeholder your code never filled.`, find(p));
779
+ const written = find(p);
780
+ if (!written) continue;
781
+ if (prompt.includes(norm(p))) placed("placeholder", p, `The reply carried "${p}", a placeholder still unfilled in the prompt sent to the model.`, written);
782
+ else if (!words.includes(norm(p))) add("placeholder", `The reply carried "${p}", a placeholder your code never filled.`, written);
763
783
  }
764
784
  const stopped = endedOnTool(ex);
765
785
  if (stopped) add("ended-on-tool", `The request ended right after ${stopped.join(", ")} ran: no model call came after it, so the reply holds no answer written from the tool's result.`, frame(generation(ex).at(-1)?.caller) ?? call);
@@ -1315,10 +1315,31 @@ def _install():
1315
1315
  # `later`, in the time its model took to answer it; a run's turn also gets the model's last words,
1316
1316
  # the answer the command hands the run. Only an answer settles it: a call that came back 2xx with
1317
1317
  # no words (a model asking for a tool) is a step, and the next call is waited for (lib/trace.cjs).
1318
- # ponytail: a fixed settle; after a step that answers in words, more than this of the app's own
1319
- # work before the next call cuts the answer at that step. A call whose words match only a request
1320
- # already settled is the evidence: a settle measured per door when one is seen.
1318
+ # ponytail: a fixed settle where the work runs in threads; after a step that answers in words, more
1319
+ # than this of the app's own work before the next call cuts the answer at that step. On asyncio the
1320
+ # task that made the calls says when the work is done (working, below).
1321
1321
  settle_s = 5.0
1322
+ # How long a task that made a request's calls may stay open after its last words before the turn is
1323
+ # answered anyway: a worker the request started and never ends holds no run for longer.
1324
+ open_task_s = 120.0
1325
+
1326
+ # The asyncio task a request's model call ran in, kept so the turn is answered when that work ends:
1327
+ # Nitham's quick answer searches between its first reading and its check, past the settle, and the
1328
+ # first reading was handed back while the check still ran. Only a task the request itself started,
1329
+ # which carries its context: a worker started at boot serves every request and never ends.
1330
+ def working(req):
1331
+ aio = sys.modules.get("asyncio")
1332
+ try:
1333
+ task = aio.current_task() if aio else None
1334
+ except RuntimeError:
1335
+ task = None
1336
+ if task is None or ctx.get() is not req or any(t is task for t in req.setdefault("tasks", [])):
1337
+ return
1338
+ req["tasks"].append(task)
1339
+ task.add_done_callback(lambda _t: settle(req))
1340
+
1341
+ def open_tasks(req):
1342
+ return [t for t in req.get("tasks", ()) if not t.done()]
1322
1343
 
1323
1344
  # The turn's answer is written once the calls are quiet even while a read the hook took for a fetch
1324
1345
  # of it is open (the person's page polling the sender): the answer is the model's words either way.
@@ -1337,10 +1358,20 @@ def _install():
1337
1358
  def settle(req):
1338
1359
  import threading
1339
1360
 
1361
+ def arm(seconds):
1362
+ req["settling"] = threading.Timer(seconds, quiet)
1363
+ req["settling"].daemon = True
1364
+ req["settling"].start()
1365
+
1340
1366
  def quiet():
1341
1367
  try:
1342
- if not req.get("calls_open") and not req.get("waiting"):
1343
- answered_later(req)
1368
+ if req.get("calls_open") or req.get("waiting"):
1369
+ return
1370
+ left = open_task_s - (time.time() * 1000 - req["said"][2]) / 1000
1371
+ if open_tasks(req) and left > 0:
1372
+ arm(left)
1373
+ return
1374
+ answered_later(req)
1344
1375
  except Exception:
1345
1376
  pass
1346
1377
  was = req.get("settling")
@@ -1349,9 +1380,7 @@ def _install():
1349
1380
  said = req.get("said")
1350
1381
  if not said or (not said[1] and 200 <= said[0] < 300):
1351
1382
  return
1352
- req["settling"] = threading.Timer(settle_s, quiet)
1353
- req["settling"].daemon = True
1354
- req["settling"].start()
1383
+ arm(0.5 if req.get("tasks") and not open_tasks(req) else settle_s)
1355
1384
 
1356
1385
  # Where the app's own code wrote the parts of this reply, under the id its exchange is written by.
1357
1386
  def written_at(req, steps):
@@ -1583,6 +1612,7 @@ def _install():
1583
1612
  if not row.get("embedding"):
1584
1613
  req["said"] = (row["status"], reply_text(events)[:model_words], now)
1585
1614
  req["said_all"] = (req.get("said_all", []) + [req["said"][1]])[-12:]
1615
+ working(req)
1586
1616
  settle(req)
1587
1617
  row.update(inside_of(req))
1588
1618
  if req and (req.get("kept") or req.get("turn")):
package/lib/text.mjs CHANGED
@@ -99,12 +99,23 @@ const runsSaid = (left, allowed) => (left > 0 ? `${num(left)} of ${plural(allowe
99
99
  // After the findings: what was measured of the app's own promises, in its own words, then the checks
100
100
  // measured and why the rest were not, then how many trials kept every promise. Counts, never a score.
101
101
  // The replies that did not count sit before the count: a clean count beside 62 error replies read as a clean bill.
102
- // Their own eval files, as the run played them: one line for all, one per file.
102
+ // Their own eval files, as the run played them: one line for all, one per file. `open` are questions
103
+ // a file asks with nothing expected of the reply, played and read against the app's own rules; they
104
+ // are said apart because the file made no claim to pass or fail. `left` is what never became a
105
+ // conversation, by the reason, so a count of what was taken never stands alone.
103
106
  function evalLines(evals) {
104
107
  const t = evals?.total;
105
- if (!t || !t.played) return [];
106
- const said = (x) => `${num(x.passed)} of ${plural(x.played, "case")} passed${x.failed ? `, ${num(x.failed)} failed` : ""}${x.unclear ? `, ${num(x.unclear)} not settled` : ""}${x.cases > x.played ? `, ${num(x.cases - x.played)} not played` : ""}`;
107
- return [`Your own eval files: ${said(t)}.`, ...(evals.files ?? []).slice(0, 12).map((f) => ` ${f.file}: ${said(f)}.`)];
108
+ if (!t || (!t.played && !t.open)) return [];
109
+ const open = (x) => (x.open ? `${x.played ? ", and " : ""}${num(x.open)} more ${x.open === 1 ? "question it asks" : "questions it asks"} with no answer of its own, read against your rules` : "");
110
+ const said = (x) => (x.played
111
+ ? `${num(x.passed)} of ${plural(x.played, "case")} passed${x.failed ? `, ${num(x.failed)} failed` : ""}${x.unclear ? `, ${num(x.unclear)} not settled` : ""}${x.cases > x.played ? `, ${num(x.cases - x.played)} not played` : ""}${open(x)}`
112
+ : `${num(x.open)} ${x.open === 1 ? "question" : "questions"} played, read against your rules, since the ${x.open === 1 ? "file expects no answer of its own" : "files expect no answers of their own"}`);
113
+ const left = Object.entries(evals.left ?? {}).sort((a, b) => b[1] - a[1]);
114
+ return [
115
+ `Your own eval files: ${said(t)}.`,
116
+ ...(evals.files ?? []).slice(0, 12).map((f) => ` ${f.file}: ${said(f)}.`),
117
+ ...left.map(([why, n]) => ` ${num(n)} ${n === 1 ? "case" : "cases"} of yours was left out: ${why}.`.replace("cases of yours was", "cases of yours were")),
118
+ ];
108
119
  }
109
120
 
110
121
  function measuredLines(d) {
@@ -118,6 +129,10 @@ function measuredLines(d) {
118
129
  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.`);
119
130
  // The server's own sentence already says the count.
120
131
  if (d.promises && !d.promises.said && has(d.promises.decided)) lines.push(promiseLine(d));
132
+ // What the run decided, and what nothing breaking is worth on that many. The server writes the
133
+ // sentence (simulate/report-pack.ts sensitivityOf) because the arithmetic belongs beside the
134
+ // numbers it is made of; printing it here is what stops a count standing with no denominator.
135
+ if (d.sensitivity?.said) lines.push(clip(d.sensitivity.said, 400));
121
136
  return lines;
122
137
  }
123
138
 
@@ -440,6 +455,13 @@ function findingBlock(f, unseen = false) {
440
455
  const lines = [`${f.id} ${f.asks}`, shareLine(f)].filter(Boolean);
441
456
  if (f.sentAway?.said) lines.push(` Of them, ${f.sentAway.said}.`);
442
457
  if (f.baseline) lines.push(` Played again: ${plural(f.baseline.trials, "new conversation")} with the same ask in other words; it failed again in ${num(f.baseline.failed)}.`);
458
+ // The same conversation, played again from nothing. A failure that did not come back is one roll of
459
+ // the app's dice, and it was listed beside one that came back twice with nothing to tell them apart.
460
+ if (f.cameBack) {
461
+ lines.push(f.cameBack.again === 0
462
+ ? ` Played over again: ${plural(f.cameBack.played, "conversation")} of its own, word for word from nothing, and it did not fail again in any of them. Worth less of your time than one that came back.`
463
+ : ` Played over again: ${plural(f.cameBack.played, "conversation")} of its own, word for word from nothing, and it failed again in ${num(f.cameBack.again)}.`);
464
+ }
443
465
  // Chased because production broke it first: real people met this one.
444
466
  if (f.production) lines.push(` In production: ${plural(f.production, "conversation")} with real users broke this in the last 30 days.`);
445
467
  const logs = { shown: new Set(), room: 30 };
package/local.mjs CHANGED
@@ -32,7 +32,7 @@ import { claimRunner, readRunner, releaseRunner, replaceRunner, startedAtOf, wri
32
32
  import { registerAll } from "./lib/register.mjs";
33
33
  import { connectedText } from "./lib/read-text.mjs";
34
34
  import { VERSION } from "./lib/spec.mjs";
35
- import { maskedLines, maskWith, secretValues } from "./lib/env-secrets.mjs";
35
+ import { credentialShaped, maskedLines, maskWith, secretValues } from "./lib/env-secrets.mjs";
36
36
  import { finished, unseenText } from "./lib/text.mjs";
37
37
  import { lockHolds, makeLock } from "./lib/lock.mjs";
38
38
  import { AS_HEADER, CAPTURED, makeCapture } from "./lib/replay.mjs";
@@ -226,6 +226,12 @@ let secrets = [];
226
226
  // with how long the run waits for this reply; a reply with no answer says in the second why none came.
227
227
  // The wait here ends a few seconds before the run's own and the wire's hold, so the reason reaches it.
228
228
  const LATER_HEADER = "x-cortad-answer", LATER_MISSED = "x-cortad-later", LATER_MARGIN_MS = 5_000;
229
+ // The app's own reply to a message it answers later, when it is a small JSON object: a receipt.
230
+ const RECEIPT_MAX = 16_384;
231
+ const receiptOf = (got) => {
232
+ if (got.truncated || !got.body || got.body.length > RECEIPT_MAX) return null;
233
+ try { const v = JSON.parse(got.body); return v && typeof v === "object" && !Array.isArray(v) ? v : null; } catch { return null; }
234
+ };
229
235
  // The cookies each trial's own turns were handed, newest trials kept.
230
236
  const trialJars = new Map();
231
237
  // Endpoints that refused a new visitor: their trials carry the person's own session.
@@ -261,6 +267,9 @@ let gate = null;
261
267
  const hold = () => { if (!gate) { let open; gate = new Promise((r) => { open = r; }); gate.open = () => { gate = null; open(); }; } };
262
268
  const release = () => gate?.open();
263
269
  const mask = (text) => maskWith(String(text ?? ""), secrets);
270
+ // The route table is replayed, so a configuration word that is also a route word stays readable;
271
+ // anything credential-shaped is masked here the same as everywhere.
272
+ const maskRoutes = (text) => maskWith(String(text ?? ""), secrets.filter(credentialShaped));
264
273
 
265
274
  // ---- the wire
266
275
  let box = "";
@@ -494,7 +503,10 @@ async function verb(job) {
494
503
  const said = capture ? await capture.answered(turn || headers["x-cortad-turn"], until, letGo) : null;
495
504
  if (letGo.aborted) return { error: "cancelled by the run" };
496
505
  if (!said?.reply.trim()) return { ...got, headers: { ...got.headers, [LATER_MISSED]: JSON.stringify(said ? { model: said.status } : { waited: Math.round((Date.now() - began) / 1000) }) } };
497
- return { ...got, headers: { "content-type": "application/json" }, body: mask(JSON.stringify({ reply: said.reply })), truncated: false };
506
+ // The app's own reply rides beside the answer: the thread it opened is named there and nowhere
507
+ // else, and a run's next message on that conversation sends it back.
508
+ const own = receiptOf(got);
509
+ return { ...got, headers: { "content-type": "application/json" }, body: mask(JSON.stringify({ reply: said.reply, ...(own ? { receipt: own } : {}) })), truncated: false };
498
510
  };
499
511
  try { return await answered(await counted()); }
500
512
  catch (e) {
@@ -574,7 +586,7 @@ async function verb(job) {
574
586
  // was not seen; a run says "not recorded", never that none ran.
575
587
  case "usage": return capture ? { ...JSON.parse(mask(JSON.stringify(capture.usage(b.turn, b.totals === true) ?? {}))), ...(launched && toolsKnown() ? { toolsRecorded: toolsRecorded() } : {}) } : {};
576
588
  // Every route your app holds, read by the hook off the app itself, masked like the meter.
577
- case "routes": return capture ? JSON.parse(mask(JSON.stringify(capture.registry(app?.port)))) : {};
589
+ case "routes": return capture ? JSON.parse(maskRoutes(JSON.stringify(capture.registry(app?.port)))) : {};
578
590
  // A world is ended from this terminal, never from the cloud.
579
591
  case "destroy": return { ok: true };
580
592
  default: return { ok: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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"