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.
- package/lib/contact-text.mjs +6 -2
- package/lib/proof.mjs +28 -3
- package/lib/stores.mjs +5 -1
- package/lib/text.mjs +2 -0
- package/local.mjs +47 -3
- package/package.json +1 -1
package/lib/contact-text.mjs
CHANGED
|
@@ -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
|
}
|
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-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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())
|
|
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.
|
|
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"
|