cortad 0.3.1-rc.14 → 0.3.1-rc.16

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.
@@ -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
- const cost = has(r.usd) && `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" : ""}`;
101
- const parts = [fit, has(r.replies) && `about ${plural(r.replies, "reply", "replies")}`, time, cost].filter(Boolean);
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
  }
package/lib/proof.mjs CHANGED
@@ -411,6 +411,25 @@ function carrierOf(earlier, later, ask, door) {
411
411
  return { carrier: "none", key: null };
412
412
  }
413
413
 
414
+ // Body fields a later request sent back with a value the reply to an earlier one handed out, which
415
+ // that earlier request had not sent there: AI Answers signs each conversation's history and refuses a
416
+ // follow-up without the signature its last reply carried. A trial sends back what its own last reply
417
+ // handed out. The conversation's own carrier is the session's, not one of these.
418
+ function echoesOf(exchanges, ask, session) {
419
+ const out = new Set();
420
+ for (let i = 1; i < exchanges.length; i++) {
421
+ for (const l of leaves(bodyOf(exchanges[i]) ?? {})) {
422
+ const at = l.at.join(".");
423
+ if (inList(l.at) || fieldOf(l.at) === ask?.field || (session.carrier === "body" && at === session.key)) continue;
424
+ if (!/^\S{6,}$/.test(l.value) || !(/\d/.test(l.value) || l.value.length >= 16)) continue;
425
+ const handed = exchanges.slice(0, i).some((earlier) => norm(earlier.reply).includes(norm(l.value))
426
+ && leaves(bodyOf(earlier) ?? {}).find((e) => e.at.join(".") === at)?.value !== l.value);
427
+ if (handed) out.add(at);
428
+ }
429
+ }
430
+ return [...out];
431
+ }
432
+
414
433
  // Held when a later prompt carried an earlier exchange's words. A body that carried them in a field
415
434
  // other than its own ask holds the conversation itself; otherwise what stayed the same carries it.
416
435
  // `tried`: a later request went on from an earlier one (it sent back what that one was handed, or
@@ -660,9 +679,14 @@ export function proofOf(exchanges, door, { find = () => null, base = "" } = {})
660
679
  // A reply cut short, by either side, is not the reply: the latest whole one stands.
661
680
  const uncut = exchanges.findLastIndex((ex) => !ex.cut);
662
681
  const shown = uncut < 0 ? exchanges.length - 1 : uncut;
663
- // The person's own request is the shape trials take; ours only when there is none.
664
- const shape = latest;
665
682
  const session = sessionOf(exchanges, ask, door);
683
+ // The person's own request is the shape trials take; ours only when there is none. Where the history
684
+ // carries the conversation, one whose history holds turns: a first message's empty list says nothing
685
+ // of how the app writes a turn, and AI Answers' follow-ups went out as {role:"assistant"} where it
686
+ // writes and signs {sender:"ai"}, refused 403 every one.
687
+ const turnsIn = (ex) => { const list = String(session.key ?? "").split(".").reduce((v, k) => v?.[k], bodyOf(ex)); return Array.isArray(list) && list.length > 0; };
688
+ const shape = session.carrier === "history" ? exchanges.findLast(turnsIn) ?? latest : latest;
689
+ const echoes = echoesOf(exchanges, ask, session);
666
690
  const metered = exchanges.filter((ex) => generation(ex).some((c) => c.usage));
667
691
  const tokens = (key) => median(metered.map((ex) => generation(ex).reduce((n, c) => n + (Number(c[key]) || 0), 0)));
668
692
  // The model that answered names the endpoint's model; the one asked for only when no other answered.
@@ -688,6 +712,7 @@ export function proofOf(exchanges, door, { find = () => null, base = "" } = {})
688
712
  askField: ask?.field ?? null,
689
713
  ...(ask?.appFields.length ? { appFields: ask.appFields } : {}),
690
714
  session,
715
+ ...(echoes.length ? { echoes } : {}),
691
716
  replyPath: replies[shown].path,
692
717
  stream: replies[shown].stream,
693
718
  modelCalls: median(exchanges.map((ex) => generation(ex).length)) ?? 0,
@@ -745,7 +770,7 @@ export function canaryOf(exchanges, proof, found = null, own = []) {
745
770
  if (!sent) return null;
746
771
  // The fact went nowhere in the request: the endpoint has no place for a person's words.
747
772
  const nonce = NONCE.exec(norm(sent.a1.body))?.[0];
748
- if (!nonce) return { n: Number(n), passed: false, problems: [{ kind: "ask-not-in-turn", side: "ours", said: "This endpoint's request has no place for a person's message, so a trial's words would reach nothing." }] };
773
+ if (!nonce) return { n: Number(n), passed: false, problems: [{ kind: "ask-nowhere", side: "ours", said: "This endpoint's request has no place for a person's message, so a trial's words would reach nothing." }] };
749
774
  const { a1, a2, b1 } = sent;
750
775
  const problems = [];
751
776
  const add = (kind, side, said) => problems.push({ kind, side, said });
package/lib/stores.mjs CHANGED
@@ -237,9 +237,13 @@ const HELD = {
237
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"],
238
238
  other: (s) => ["the copy did not go through", `Point ${s.names[0]} at a database on this machine`],
239
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).
240
243
  const heldWhy = (s, why, tool) => {
241
244
  const [reason, fix] = (HELD[why] ?? HELD.other)(s, tool);
242
- 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.`;
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}`;
243
247
  };
244
248
  const hostedKept = (s, got) => (got.how === "neon"
245
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));
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";
@@ -253,6 +254,8 @@ let secrets = [];
253
254
  const LATER_HEADER = "x-cortad-answer", LATER_MISSED = "x-cortad-later", LATER_MARGIN_MS = 5_000;
254
255
  // The cookies each trial's own turns were handed, newest trials kept.
255
256
  const trialJars = new Map();
257
+ // Endpoints that refused a new visitor: their trials carry the person's own session.
258
+ const ownSession = new Set();
256
259
  const TRIAL_JARS = 500;
257
260
  let identities = null;
258
261
  let capture = null;
@@ -411,9 +414,25 @@ async function verb(job) {
411
414
  // a reply is waited on for three of them. A run that says nothing gets the old 170 seconds.
412
415
  const holdMs = Math.min(Math.max(Number(b.waitMs) || 170_000, 1_000), 630_000);
413
416
  const began = Date.now();
417
+ // The trial's own cookies take the place of the captured ones of the same name: a session your
418
+ // app keys its conversation on is this trial's, never the one every trial was handed at capture.
419
+ const cookieAt = Object.keys(headers).find((k) => k.toLowerCase() === "cookie");
420
+ const { [cookieAt ?? ""]: given = "", ...cookieless } = headers;
421
+ const cookie = () => [...new Map([...given.split(/;\s*/), ...jar.split(/;\s*/)].filter(Boolean).map((pair) => [pair.split("=")[0].trim(), pair])).values()].join("; ");
422
+ // A cookie your app hands any new visitor is that visitor's session: each trial asks for its own,
423
+ // so one trial's answer in progress never holds another's (AI Answers answers one at a time per
424
+ // visitor, and every trial carried the agent's one session). An endpoint that refuses a new
425
+ // visitor keeps the person's session for this trial and every later one.
426
+ const door = `${method} ${path.split("?")[0]}`;
427
+ const fresh = Boolean(trial && given && !trialJars.has(trial) && !ownSession.has(door));
428
+ const visitor = async () => {
429
+ const res = await fetch(url, { method: "GET", headers: cookieless, redirect: "manual", signal: AbortSignal.any([letGo, AbortSignal.timeout(15_000)]) }).catch(() => null);
430
+ await res?.arrayBuffer().catch(() => null);
431
+ return (res?.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).filter(Boolean).join("; ");
432
+ };
414
433
  const sent = (at, over = {}) => fetch(at, {
415
434
  ...init, ...over,
416
- headers: { ...headers, ...(over.headers ?? {}), ...(jar ? { cookie: [headers.cookie, jar].filter(Boolean).join("; ") } : {}) },
435
+ headers: { ...(jar ? cookieless : headers), ...(over.headers ?? {}), ...(jar ? { cookie: cookie() } : {}) },
417
436
  signal: AbortSignal.any([letGo, AbortSignal.timeout(holdMs)]),
418
437
  });
419
438
  const keep = (res) => {
@@ -433,8 +452,17 @@ async function verb(job) {
433
452
  }
434
453
  };
435
454
  const ask = async () => {
455
+ if (fresh) { jar = await visitor(); if (jar) trialJars.set(trial, jar); }
436
456
  let res = await sent(url);
437
457
  keep(res);
458
+ if (fresh && jar && (res.status === 401 || res.status === 403)) {
459
+ ownSession.add(door);
460
+ jar = "";
461
+ trialJars.delete(trial);
462
+ await res.arrayBuffer().catch(() => null);
463
+ res = await sent(url);
464
+ keep(res);
465
+ }
438
466
  if (method !== "GET" && sentBack(res)) { await warm(); if (jar) res = await sent(url); }
439
467
  const buf = Buffer.from(await res.arrayBuffer());
440
468
  // An OpenAPI document can run to megabytes; the caller asks for it whole.
@@ -508,6 +536,8 @@ async function verb(job) {
508
536
  }
509
537
  // The person changed what they said yes to while this command runs.
510
538
  case "consent": await allowOutbound(b); return { ok: true };
539
+ // A line for the agent reading this terminal: the read of the code landing, said the moment it does.
540
+ case "say": { const line = String(b.line ?? "").replace(/\s+/g, " ").trim().slice(0, 400); if (line) say(line); return { ok: true }; }
511
541
  case "restart": return restartApp();
512
542
  case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
513
543
  // Your own pages, read here rather than in a world's shell: that shell is sealed away from
@@ -861,7 +891,10 @@ async function start(waitMs, tries = 3) {
861
891
  try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
862
892
  // Taken before the app reads a file: a save after this moment is a change it has not loaded.
863
893
  const note = { own: true, at: Date.now(), reloads: RELOADER.test(commandText(cmd, appDir)), answered: false };
864
- note.files = sourceOf(root, files);
894
+ // The settings files count as what it loaded: an env file is rarely tracked, and an agent's edit to
895
+ // one left the app on the old settings until the command was started again (ulaim, 2026-10-01).
896
+ note.files = { ...sourceOf(root, files), ...sourceOf(root, envFiles.filter((f) => !/\.(example|sample)$/.test(f)).map((f) => relative(root, f))) };
897
+ storesAt ??= storesNamed();
865
898
  loaded = note;
866
899
  const copies = await keeping.env(true);
867
900
  await allowOutbound();
@@ -957,10 +990,21 @@ const savedSinceStart = () => Boolean(loaded?.files) && changesOf({
957
990
  // Every save that changes what your app runs starts it again, when it does not reload by itself: an
958
991
  // agent that saved a fix and asked the app eight seconds later was answered by the old code. Looked
959
992
  // at again after each restart, for a save made while it was under way.
993
+ // The stores the settings name, as copies are made of them: a change here is a change of what the app
994
+ // writes into, so its copies and holds are made again for the new settings, and said again.
995
+ 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 ""; } };
996
+ let storesAt = null;
960
997
  async function restartOnSave() {
961
998
  while (!closing) {
962
999
  await sourceChanged();
963
- while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) await restartApp();
1000
+ while (!closing && launched && child && !appGone && !loaded?.reloads && savedSinceStart()) {
1001
+ const named = storesNamed();
1002
+ const moved = storesAt !== null && named !== storesAt;
1003
+ storesAt = named;
1004
+ if (moved) await keeping.forget().catch(() => {});
1005
+ await restartApp(moved ? "starting your app again on the settings you changed, with its data copied again for them" : undefined);
1006
+ if (moved) await announce().catch(() => null);
1007
+ }
964
1008
  }
965
1009
  }
966
1010
  async function restartNow() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.1-rc.14",
3
+ "version": "0.3.1-rc.16",
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"