cortad 0.3.8 → 0.3.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 CHANGED
@@ -4,8 +4,8 @@ import { failedLines, heldLines } from "./failed-text.mjs";
4
4
  import { readLines } from "./read-text.mjs";
5
5
  import { at, clip, has, num, PAGE_CHARS, pageOf, plural, SIDE, upper } from "./words.mjs";
6
6
 
7
- // What each verb prints. A result is data for the coding agent: a line meant for the person starts
8
- // with "For the person:", and a run_status result ends with the next call. A field the API leaves
7
+ // What each verb prints. A result is data for the coding agent, in plain sentences it can hand the
8
+ // person as they are, and a run_status result ends with the next call. A field the API leaves
9
9
  // out prints nothing, so the text follows the API as it grows.
10
10
 
11
11
  const DONE = new Set(["succeeded", "failed", "canceled"]);
@@ -39,8 +39,7 @@ export function statusText(d) {
39
39
  lines.push(...contactLines(d.contact, d.app?.data, receipts));
40
40
  // While the read runs its lists are zeros ("Journeys (0)", "Questions: 0; 0 asked..."): it is said
41
41
  // once, and the lists come when it lands (the server's news says so).
42
- // The quote says it when it is said (it names the read with what the run does about it).
43
- if (d.read?.complete === false) { if (!d.contact?.ready?.reading || !d.contact.ready.trials) lines.push("Your code is still being read."); }
42
+ if (d.read?.complete === false) lines.push("Your code is still being read.");
44
43
  else if (d.read) lines.push(...readLines(d.read));
45
44
  if (d.run) lines.push(...runLines(d.run, "latest "));
46
45
  // Trials the read wrote after this run chose its own: the one count that is not the run's.
@@ -49,7 +48,7 @@ export function statusText(d) {
49
48
  // Production comes up after the first run (skill: The second run and production).
50
49
  if (d.field && d.run) lines.push(`Production: ${d.field.connected ? "connected" : "not connected"}.`);
51
50
  if (d.next) lines.push(d.next);
52
- if (d.links?.lab && (d.read || d.run)) lines.push(`For the person: ${d.links.lab} shows this in the browser.`);
51
+ if (d.links?.lab && (d.read || d.run)) lines.push(`The person sees this at ${d.links.lab}`);
53
52
  if (d.run && !finished(d.run)) lines.push(`next: run_status ${d.run.jobId}`);
54
53
  return lines.join("\n");
55
54
  }
@@ -61,8 +60,8 @@ export const unseenText = (door) => `A request to ${door} was answered in words
61
60
  // to it, or there is no AI behind it. `followed`: a model call came after it that carried none of its
62
61
  // words.
63
62
  export const receiptText = (door, followed) => (followed
64
- ? `${door} answered your request at once, with no model call inside it; a model call came after it, but its prompt carried none of the words your request sent, so Cortad cannot tell it was this request's answer. That gap is on Cortad's side, and a run leaves this endpoint out.\nFor the person: ${door} answers later in a way Cortad cannot match to the message that asked, so a run leaves it out for now; that gap is on Cortad's side.`
65
- : `${door} answered your request at once with no model call inside it, and no model call followed in any process Cortad started. If a worker in another process writes its answer, Cortad does not see that worker, so a run leaves this endpoint out for now; that gap is on Cortad's side. If this endpoint has no AI behind it, nothing is missing.\nFor the person: ${door} answered with no AI call Cortad could see; if its AI runs in a separate worker, a run leaves it out for now, and that gap is on Cortad's side.`);
63
+ ? `${door} answered your request at once, with no model call inside it; a model call came after it, but its prompt carried none of the words your request sent, so Cortad cannot tell it was this request's answer. That gap is on Cortad's side, and a run leaves this endpoint out.`
64
+ : `${door} answered your request at once with no model call inside it, and no model call followed in any process Cortad started. If a worker in another process writes its answer, Cortad does not see that worker, so a run leaves this endpoint out for now; that gap is on Cortad's side. If this endpoint has no AI behind it, nothing is missing.`);
66
65
  // Only a door the read lists as one no request has reached, and no request has proven since: a sign-up
67
66
  // or a guard's reply to a request a later one proved is no worker's answer.
68
67
  const receiptsSaid = (receipts = [], c) => {
@@ -100,8 +99,16 @@ const runsSaid = (left, allowed) => (left > 0 ? `${num(left)} of ${plural(allowe
100
99
  // After the findings: what was measured of the app's own promises, in its own words, then the checks
101
100
  // measured and why the rest were not, then how many trials kept every promise. Counts, never a score.
102
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.
103
+ function evalLines(evals) {
104
+ 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
+ }
109
+
103
110
  function measuredLines(d) {
104
- const lines = [...promiseLines(d.promises), ...failedLines(d.failedReplies)];
111
+ const lines = [...promiseLines(d.promises), ...evalLines(d.evals), ...failedLines(d.failedReplies)];
105
112
  const of = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0;
106
113
  if (of) lines.push(`${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured.`);
107
114
  // The dissection says what was not measured in its own line.
@@ -177,7 +184,9 @@ function runLines(d, prefix = "") {
177
184
  for (const f of d.faults ?? []) lines.push(`Fault${SIDE[f.side] ? ` ${SIDE[f.side]}` : ""}: ${f.what}${f.fix ? ` ${f.fix}` : ""}`);
178
185
  for (const s of d.data ?? []) lines.push(`Data: ${s}`);
179
186
  // A run the stop line already explains is not said twice as an error.
180
- if (d.error && !(d.stopped && d.error.startsWith(d.stopped.why))) lines.push(`Error: ${d.error}`);
187
+ // A failed run with no stop line of its own was stopped by Cortad, never by their app: said as that, with
188
+ // our error after it for a note to the team. "Error: timeout exceeded when trying to connect" read as the app's.
189
+ if (d.error && !(d.stopped && d.error.startsWith(d.stopped.why))) lines.push(d.status === "failed" && !d.stopped ? `The run stopped on Cortad's side, not your app's. Start another run. (Cortad's error: ${d.error})` : `Error: ${d.error}`);
181
190
  if (d.verify) lines.push(...verifyLines(d.verify));
182
191
  return lines;
183
192
  }
@@ -239,10 +248,10 @@ export function nextCall(d) {
239
248
  return d.kind === "verify" || d.findings > 0 ? "findings" : "status";
240
249
  }
241
250
 
242
- // One line for the person while a run plays: conversations done of the total, whose side any failed
251
+ // One line to hand the person while a run plays: conversations done of the total, whose side any failed
243
252
  // reply is on, and the minutes left at the pace measured so far. Cost is measured only at the end.
244
253
  export function progressLine(d, now = Date.now()) {
245
- if (!d.of) return "For the person: the run has started and is choosing its conversations; none has played yet.";
254
+ if (!d.of) return "The run has started. No conversation has played yet.";
246
255
  const parts = [`${num(d.played ?? 0)} of ${plural(d.of, "conversation")} done.`];
247
256
  const total = d.replies?.total ?? d.waited?.of;
248
257
  const crashed = d.replies?.crashed ?? 0;
@@ -254,7 +263,7 @@ export function progressLine(d, now = Date.now()) {
254
263
  if (total > 0 && ours) parts.push(`${num(ours)} of ${plural(total, "reply", "replies")} so far failed on Cortad's side, not your app's.`);
255
264
  const left = Date.parse(d.pace?.endsAt ?? "") - now;
256
265
  if (left > 0) parts.push(`About ${plural(Math.ceil(left / 60_000), "minute")} left at the pace your app has answered so far.`);
257
- return `For the person: ${parts.join(" ")}`;
266
+ return parts.join(" ");
258
267
  }
259
268
 
260
269
  export function runText(d) {
@@ -265,7 +274,7 @@ export function runText(d) {
265
274
  if (plan) lines.push(plan);
266
275
  const offer = productionOffer(d);
267
276
  if (offer) lines.push(offer);
268
- if (finished(d) && d.url) lines.push(`For the person: the report is at ${d.url}`);
277
+ if (finished(d) && d.url) lines.push(`The report is at ${d.url}`);
269
278
  lines.push(`next: ${nextCall(d)}`);
270
279
  return verifyOnceAPage(lines.join("\n"));
271
280
  }
@@ -275,7 +284,7 @@ export function runText(d) {
275
284
  function productionOffer(d) {
276
285
  const v = d.verify;
277
286
  if (d.kind !== "verify" || !finished(d) || v?.verdict !== "gone" || !["gone", "less often"].includes(v.heldOut?.moved) || d.field?.connected !== false) return null;
278
- return `For the person: the failure in ${v.findingId} is gone on its replays. Connect production to see what your real users are doing as it happens and where your AI still lets them down; field_connect has the steps.`;
287
+ return `The failure in ${v.findingId} is gone on its replays. Connecting production shows what real users are doing as it happens; field_connect has the steps.`;
279
288
  }
280
289
 
281
290
  export function startedText(d, kind, findingId, said = []) {
@@ -303,7 +312,7 @@ export function waitingText(p, now, limitMs, runner, app) {
303
312
  const kind = p.kind === "verify" ? "verify" : "run";
304
313
  const waited = `${num((now - Date.parse(p.startedAt)) / 1000)} of up to ${num(limitMs / 1000)} seconds`;
305
314
  const head = runner?.state === "up" ? `${answeringText(runner, app)}\nThe ${kind} has waited ${waited}.` : `Your app is still starting for the ${kind}: ${waited}.`;
306
- return `${head}\nFor the person: the run has not started yet, after ${waited}.\nnext: run_status pending`;
315
+ return `${head}\nnext: run_status pending`;
307
316
  }
308
317
 
309
318
  // A spent plan: what the last run found, what was fixed since, what the next run would play.
@@ -314,7 +323,7 @@ export function refusedText(d) {
314
323
  if (d.ledger) lines.push(...ledgerLines(d.ledger));
315
324
  if (!d.ledger?.plan && d.plans?.length) lines.push(`Plans: ${d.plans.map((p) => `${p.name} $${p.monthlyUsd} a month`).join(", ")}.`);
316
325
  lines.push("Nothing ran.");
317
- if (d.checkout) lines.push(`For the person: plans and checkout at ${d.checkout}`);
326
+ if (d.checkout) lines.push(`Plans and checkout are at ${d.checkout}`);
318
327
  return lines.join("\n");
319
328
  }
320
329
 
@@ -392,7 +401,14 @@ function findingsTail(d) {
392
401
  function findingsPages(d, budget = PAGE_CHARS - 60) {
393
402
  // Unrecorded is not unused (dissection-text.mjs): no tool is said never to have run.
394
403
  const unseen = (d.tools ?? d.dissection?.tools)?.recorded === false;
395
- const blocks = [...(d.findings ?? []).map((f) => findingBlock(f, unseen)), findingsTail(d)].filter(Boolean);
404
+ // The findings from their own eval files are headed as theirs, once, where the first one stands.
405
+ let headed = false;
406
+ const blocks = [...(d.findings ?? []).map((f) => {
407
+ const block = findingBlock(f, unseen);
408
+ if (f.group !== "eval" || headed) return block;
409
+ headed = true;
410
+ return `From your own eval files:\n${block}`;
411
+ }), findingsTail(d)].filter(Boolean);
396
412
  const pages = [];
397
413
  let page = findingsHead(d);
398
414
  let filled = false;
@@ -521,7 +537,7 @@ function alsoLines(f) {
521
537
  export function fieldText(d) {
522
538
  const t = d.totals;
523
539
  const pct = (k, n) => (n ? `${Math.round((100 * k) / n)}%` : "n/a");
524
- const link = d.url ? [`For the person: ${d.url}`] : [];
540
+ const link = d.url ? [`The person sees this at ${d.url}`] : [];
525
541
  if (!t || !t.n) return [`No production conversations read in the last ${plural(d.days, "day")}.`, ...link].join("\n");
526
542
  const lines = [
527
543
  `Production, last ${plural(d.days, "day")}: ${plural(t.n, "conversation")}, ${num(t.read)} read.`,
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, toolText, scrubbed, list, clipped, argsText, turnOf, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames, makeRecorder, asChat, GATEWAY_MODEL } = require("./wire.cjs");
20
+ const { MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, toolText, scrubbed, list, clipped, argsText, turnOf, isModelCall, isRetrieval, qdrantCall, passagesFrom, decoded, cookieNames, makeRecorder, asChat, GATEWAY_MODEL } = 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
@@ -798,15 +798,87 @@ if (FILE) {
798
798
  // ponytail: while a run is live, a person's own queued write is held too; per-ask pinning of
799
799
  // background writes is the upgrade when that matters.
800
800
  const RUN_LIVE_MS = 10 * 60_000;
801
+ // The copies this hook makes of Qdrant collections carry this suffix (copyOf below).
802
+ const OUR_COPY = /_cortad_\d+$/;
801
803
  const heldHere = (u, method, headerOf) => {
802
804
  const ctx = als.getStore();
803
805
  if (!WRITES.test(method) || (ctx ? !ctx.turn : Date.now() - lastTurnAt > RUN_LIVE_MS)) return false;
806
+ // A read of a Qdrant collection, however its server is named (a bare address holds no word
807
+ // like qdrant), and any call to a copy this hook made, go out.
808
+ const q = qdrantCall(u, method);
809
+ if (q && (!q.write || (q.collection && OUR_COPY.test(q.collection)))) return false;
804
810
  try {
805
811
  const host = targetOf(u, headerOf);
806
812
  if (THIS_MACHINE.test(host) || SANDBOX_HOST.test(host) || isModelCall(u.host, u.pathname) || isRetrieval(u) || TOKEN_PATH.test(u.pathname) || readsByName(method, u.pathname) || KEY_HEADERS.some((name) => testKey(authText(headerOf(name))))) return false;
807
813
  return !passNow().some((p) => host === p || host.endsWith("." + p));
808
814
  } catch { return true; }
809
815
  };
816
+ // A write to a Qdrant collection made inside a run goes to a copy of that collection on the same
817
+ // server, made at the first such write: the collection is read page by page and written under
818
+ // `<name>_cortad_<pid>`, and from then on every call to that collection, reads too, goes to the
819
+ // copy, so the app finds what it wrote. The original is never written. A copy that cannot be
820
+ // made (a collection over the size a run copies, a server that refused) leaves the write held
821
+ // like any other. Each copy is reported as a `copy` row, and the command deletes them when it
822
+ // ends. A copy of ours is never copied again. ulaim's hosted Qdrant held Run for the person's
823
+ // yes for seventeen minutes; this is what the yes stood in for.
824
+ const copies = new Map();
825
+ // What one process copies at most: collections, and points across them. Past it a write is held
826
+ // like any other, and the app's output says so once. A run is a few conversations' writes, never
827
+ // a cluster's.
828
+ const COPIES_MAX = 8;
829
+ const POINTS_MAX = 300_000;
830
+ let copied = 0, pointsCopied = 0;
831
+ const nativeFetch = typeof globalThis.fetch === "function" ? globalThis.fetch : null;
832
+ let qdrantLib = null;
833
+ const qdrantMod = () => (qdrantLib ??= import(require("node:url").pathToFileURL(require("node:path").join(__dirname, "qdrant.mjs")).href));
834
+ const inRun = () => { const ctx = als.getStore(); return ctx ? Boolean(ctx.turn) : Date.now() - lastTurnAt <= RUN_LIVE_MS; };
835
+ // What to do with a call: null to leave it alone; `hold` for a write of the server itself
836
+ // (/collections/aliases); else the collection's copy when there is one, or none yet for a write
837
+ // in a run, which makes it.
838
+ const guarded = (u, method) => {
839
+ if (!/^https?:$/.test(u.protocol)) return null;
840
+ const call = qdrantCall(u, method);
841
+ if (!call) return null;
842
+ if (!call.collection) return call.write && inRun() ? { hold: true } : null;
843
+ // The person's own calls, outside a run, go where they were sent: their writes are theirs to keep.
844
+ if (OUR_COPY.test(call.collection) || !inRun()) return null;
845
+ const key = `${u.host}|${call.collection}`;
846
+ const copy = copies.get(key);
847
+ if (copy) return { key, call, copy };
848
+ if (!call.write) return null;
849
+ // A change to the collection's shape, or a write past this process's copy budget, is held.
850
+ if (call.kind === "schema") return { hold: true };
851
+ if (copied >= COPIES_MAX || pointsCopied >= POINTS_MAX) { copySay(u.host, call.collection, `this run already copied ${copied} collections, ${pointsCopied} points`); return { hold: true }; }
852
+ return { key, call, copy: null };
853
+ };
854
+ // Said once per collection in the app's own output when no copy could be made and the write was
855
+ // kept on this machine instead, with the reason the server gave.
856
+ const copySaid = new Set();
857
+ const copySay = (host, collection, why) => {
858
+ if (copySaid.has(`${host}|${collection}`)) return;
859
+ copySaid.add(`${host}|${collection}`);
860
+ try { process.stderr.write(`cortad: a request Cortad sent made this app write into the Qdrant collection ${collection} at ${host}. Cortad could not copy that collection on its server (${why}), so the write was kept on this machine and answered with a stand-in success.\n`); } catch { /* no stderr */ }
861
+ };
862
+ const toCopy = (u, clone, call) => { const t = new URL(u.href); t.pathname = `/collections/${encodeURIComponent(clone)}${call.rest}`; return t; };
863
+ // The copy's name, made now when none exists; null when none could be made. The key the app sent
864
+ // is the key the copy is made with, and never leaves this process.
865
+ const copyOf = (u, headerOf, g) => {
866
+ const have = copies.get(g.key);
867
+ if (have) return Promise.resolve(have);
868
+ if (!nativeFetch) return Promise.resolve(null);
869
+ const clone = `${g.call.collection.slice(0, 40)}_cortad_${process.pid}`;
870
+ const bearer = /^Bearer\s+(\S+)/i.exec(headerOf("authorization") || "");
871
+ const conn = { base: `${u.protocol}//${u.host}`, apiKey: headerOf("api-key") || (bearer ? bearer[1] : ""), fetch: nativeFetch };
872
+ const made = qdrantMod().then(({ cloneQdrant }) => cloneQdrant(conn, g.call.collection, clone)).then((got) => {
873
+ if (!got || got.why) { copies.delete(g.key); copySay(u.host, g.call.collection, got ? got.why : "nothing"); return null; }
874
+ copied += 1; pointsCopied += got.points ?? 0;
875
+ copies.set(g.key, clone);
876
+ row({ copy: { host: u.hostname, base: conn.base, collection: g.call.collection, clone } });
877
+ return clone;
878
+ }, (e) => { copies.delete(g.key); copySay(u.host, g.call.collection, e && e.message ? e.message : "error"); return null; });
879
+ copies.set(g.key, made);
880
+ return made;
881
+ };
810
882
  // A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (Neon's SQL, where
811
883
  // a SELECT can write through a function) is held.
812
884
  // ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
@@ -878,23 +950,31 @@ if (FILE) {
878
950
  heldWaiting.delete(id);
879
951
  const sent = Buffer.concat(parts);
880
952
  const text = sent.toString("utf8").slice(0, MAX);
881
- // A body that only reads, known now that it is here, goes on to where it was sent, as it was
882
- // sent, and its answer comes back through this server.
883
- if (w && parts.length < 256 && readsOnly(text, w.u, (name) => String(req.headers[name] || ""), w.port)) {
953
+ const headerOf = (name) => String(req.headers[name] || "");
954
+ // The call goes on to where it was sent, as it was sent, or to `to` instead, and its
955
+ // answer comes back through this server.
956
+ const forward = (to) => {
884
957
  const { "x-cortad-held": _held, host: _host, connection: _connection, ...headers } = req.headers;
885
958
  const given = typeof w.args[0] === "string" || w.args[0] instanceof URL;
886
- const options = { ...((given ? w.args[1] : w.args[0]) || {}), headers, signal: undefined, [PASSED]: true };
959
+ const options = { ...((given ? w.args[1] : w.args[0]) || {}), headers, signal: undefined, [PASSED]: true, ...(to && !given ? { path: to.pathname + to.search } : {}) };
887
960
  // Sent inside the request that asked, so the call is recorded behind that reply.
888
- const send = () => (w.secure ? https : http).request(...(given ? [w.args[0], options] : [options]));
961
+ const send = () => (w.secure ? https : http).request(...(given ? [to ? to.href : w.args[0], options] : [options]));
889
962
  const up = w.ctx ? als.run(w.ctx, send) : send();
890
963
  up.on("response", (got) => { res.writeHead(got.statusCode || 502, got.headers); got.pipe(res); });
891
964
  up.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end(); });
892
965
  res.on("close", () => up.destroy());
893
966
  return up.end(sent);
894
- }
895
- if (w) heldRow(w.u, w.method, text, req.headers["content-type"], w.ctx);
896
- res.writeHead(200, { "content-type": "application/json" });
897
- res.end(heldReply(text, req.headers["content-type"]));
967
+ };
968
+ const answer = () => {
969
+ if (w) heldRow(w.u, w.method, text, req.headers["content-type"], w.ctx);
970
+ res.writeHead(200, { "content-type": "application/json" });
971
+ res.end(heldReply(text, req.headers["content-type"]));
972
+ };
973
+ // A body that only reads, known now that it is here, goes on as it was sent.
974
+ if (w && parts.length < 256 && readsOnly(text, w.u, headerOf, w.port)) return forward(null);
975
+ // A call to a Qdrant collection goes to the run's copy of it, made now when this is its first write.
976
+ if (w && w.copy) return copyOf(w.u, headerOf, w.copy).then((clone) => (clone ? forward(toCopy(w.u, clone, w.copy.call)) : answer()), answer);
977
+ answer();
898
978
  });
899
979
  });
900
980
  server[HELD] = true;
@@ -917,12 +997,19 @@ if (FILE) {
917
997
  const method = String(o.method || "GET").toUpperCase();
918
998
  const u = new URL(`${secure ? "https" : "http"}://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}${path.startsWith("/") ? path : "/" + path}`);
919
999
  const headerOf = (name) => headerIn(o.headers, name) || (name === "authorization" && o.auth ? `Basic ${Buffer.from(String(o.auth)).toString("base64")}` : "");
920
- return heldHere(u, method, headerOf) ? { u, method, o, args: args.filter((a) => typeof a !== "function"), port: o.port || (given && given.port) || "", cb: args.find((a) => typeof a === "function") } : null;
1000
+ const port = o.port || (given && given.port) || "";
1001
+ // The server's address whole, port included, names a collection's copy; the hold is decided on
1002
+ // the host alone, as before.
1003
+ const at = new URL(u.href);
1004
+ if (port) at.port = String(port);
1005
+ const g = guarded(at, method);
1006
+ if (!g && !heldHere(u, method, headerOf)) return null;
1007
+ return { u: at, method, o, args: args.filter((a) => typeof a !== "function"), port, cb: args.find((a) => typeof a === "function"), ...(g && !g.hold ? { copy: g } : {}) };
921
1008
  };
922
1009
  const httpRequest = http.request;
923
- const sendHeld = ({ u, method, o, cb, args, port }, secure) => {
1010
+ const sendHeld = ({ u, method, o, cb, args, port, copy }, secure) => {
924
1011
  const id = String(++heldSent);
925
- heldWaiting.set(id, { u, method, ctx: als.getStore(), args, port, secure });
1012
+ heldWaiting.set(id, { u, method, ctx: als.getStore(), args, port, secure, copy });
926
1013
  const headers = { ...(o.headers && typeof o.headers === "object" && !Array.isArray(o.headers) ? o.headers : {}), "x-cortad-held": id };
927
1014
  // Port 1 answers nothing: a write held before the server listens fails instead of leaving.
928
1015
  const req = httpRequest({ host: "127.0.0.1", port: heldPort || 1, method, path: u.pathname + u.search, headers, ...(o.timeout ? { timeout: o.timeout } : {}), ...(o.signal ? { signal: o.signal } : {}) }, cb);
@@ -943,15 +1030,31 @@ if (FILE) {
943
1030
  const bodyText = (body) => (typeof body === "string" ? body : Buffer.isBuffer(body) ? body.toString("utf8") : body instanceof Uint8Array ? Buffer.from(body).toString("utf8") : "");
944
1031
  if (typeof globalThis.fetch === "function") {
945
1032
  const realFetch = globalThis.fetch;
1033
+ // A call to a Qdrant collection, sent again through this same wrapper at the copy's address, so
1034
+ // it is recorded like any retrieval; held when no copy could be made.
1035
+ const guardedFetch = async (self, wrapped, input, init, u, method, g) => {
1036
+ if (g.hold) return heldFetch(realFetch, input, init);
1037
+ const req = new Request(input, init);
1038
+ const clone = await (typeof g.copy === "string" ? g.copy : copyOf(u, (name) => req.headers.get(name) || "", g));
1039
+ if (!clone) return heldFetch(realFetch, input, init);
1040
+ const body = method === "GET" || method === "HEAD" ? undefined : await req.arrayBuffer();
1041
+ return wrapped.call(self, toCopy(u, clone, g.call).href, { method, headers: req.headers, body, signal: req.signal });
1042
+ };
946
1043
  globalThis.fetch = function (input, init) {
947
1044
  let url = null;
948
1045
  let hold = false;
1046
+ let guard = null;
1047
+ let at = null;
1048
+ let method = "GET";
949
1049
  try {
950
1050
  const u = new URL(typeof input === "string" ? input : input && input.url ? input.url : String(input));
1051
+ at = u;
951
1052
  if (isModelCall(u.host, u.pathname)) url = u;
952
- const method = String((init && init.method) || (input && input.method) || "GET").toUpperCase();
953
- hold = !url && heldHere(u, method, (name) => new Headers((init && init.headers) || (input && input.headers) || undefined).get(name));
1053
+ method = String((init && init.method) || (input && input.method) || "GET").toUpperCase();
1054
+ guard = !url ? guarded(u, method) : null;
1055
+ hold = !url && !guard && heldHere(u, method, (name) => new Headers((init && init.headers) || (input && input.headers) || undefined).get(name));
954
1056
  } catch { /* not a URL we can read */ }
1057
+ if (guard) return guardedFetch(this, globalThis.fetch, input, init, at, method, guard);
955
1058
  if (hold) return heldFetch(realFetch, input, init);
956
1059
  if (!url) {
957
1060
  // Every outbound host, named by the setting that points at it when one does, and by the
package/lib/verbs.mjs CHANGED
@@ -16,7 +16,7 @@ import { fieldConnectText, fieldText, findingsText, finished, linesOf, notStarte
16
16
  // the run before them has its numbers in and its job is still ending: the server holds them until it
17
17
  // has, up to the same 45 seconds.
18
18
 
19
- const NOT_CONNECTED = "This folder is not connected to Cortad. Run npx cortad in this folder as a background command: it prints a link for the person to approve.\nFor the person: approve the link npx cortad prints, in a browser where you are signed in to Cortad.";
19
+ const NOT_CONNECTED = "This folder is not connected to Cortad. Run npx cortad in this folder as a background command: it prints a link for the person to approve in a browser where they are signed in to Cortad.";
20
20
  const HOLD_S = 45;
21
21
  // `wait`: how often it asks whether a run has started, how long the connect command may be gone
22
22
  // before no run can start, and how long it waits for one at all.
@@ -193,8 +193,8 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
193
193
  return [logSaid(appFailed(d)), back].filter(Boolean).reduce(withBack, runText(withTested(d, id)));
194
194
  };
195
195
 
196
- // `npx cortad wait`: returns when the next run ends, started from the browser or by the agent, with
197
- // what it found. A coding agent starts it in the background and is woken when it exits; it is a shell
196
+ // `npx cortad wait`: returns when the next run or verify ends, started from the browser or by the
197
+ // agent, with what it found. A coding agent starts it in the background and is woken when it exits; it is a shell
198
198
  // verb alone, since an MCP call is cut at about 60 seconds. A run playing when it starts is that run,
199
199
  // and so is a finished one the agent has not read: a first run can end before the agent starts this.
200
200
  const wait = async () => {
@@ -211,10 +211,13 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
211
211
  if ([401, 403, 404].includes(res.status)) return failed(res);
212
212
  if (res.ok && id && finished(res.data)) {
213
213
  const text = shown(res.data, id).replace(/\nnext: [^\n]*$/, "");
214
- return { text: `${text}\nnext: findings ${res.data.jobId ?? id}`, data: res.data };
214
+ // A verify's verdict is read by run_status; a run's problems by findings.
215
+ return { text: `${text}\nnext: ${res.data.verify ? `run_status ${id}` : `findings ${res.data.jobId ?? id}`}`, data: res.data };
215
216
  }
216
217
  const r = res.ok && !id ? res.data?.run : null;
217
- if (r?.jobId && r.kind !== "verify" && (!finished(r) || !wasRead(r.jobId))) { id = r.jobId; continue; }
218
+ // A verify is followed like a run: the agent that started it is woken at its end instead of
219
+ // polling run_status twelve times a round.
220
+ if (r?.jobId && (!finished(r) || !wasRead(r.jobId))) { id = r.jobId; continue; }
218
221
  // A run being followed holds on the server; a failed call, or no run yet, is asked again later.
219
222
  if (!id || !res.ok) await sleep(WAIT_POLL_MS);
220
223
  }
package/lib/wire.cjs CHANGED
@@ -481,6 +481,30 @@ const passagesIn = (sent) => {
481
481
  const RETRIEVAL_HOST = /pinecone\.io|qdrant|weaviate|chroma|zilliz|milvus|turbopuffer|upstash\.io|algolia|typesense|meilisearch|elastic|opensearch|vespa|tavily\.com|exa\.ai|serper\.dev|serpapi\.com|search\.brave\.com|bing\.microsoft\.com|jina\.ai/i;
482
482
  const RETRIEVAL_PATH = /\/(?:query|search|_search|retrieve|similarity_search|rerank|hybrid)(?:\/|$)|\/points\/(?:search|query)|\/rpc\/match\w*/i;
483
483
  const isRetrieval = (u) => RETRIEVAL_HOST.test(u.hostname) || RETRIEVAL_PATH.test(u.pathname);
484
+ // A call to one Qdrant collection, by Qdrant's own REST shape: /collections/{name}[/...]. `write`:
485
+ // the call changes the collection. Every PUT, PATCH and DELETE does; a POST does unless it is one
486
+ // of Qdrant's reads spelled as a POST (search, query, scroll, recommend, discover, count, facet, a
487
+ // retrieve of points by id at /points itself). A path under /collections that names no collection
488
+ // (/collections/aliases) is a write of the server, not of one collection: `collection` is null.
489
+ // `kind`, on a write: "points" changes what the collection holds (an upsert, a delete, a payload or
490
+ // vector change, a batch) and is what a copy stands in for; "create" makes the collection; anything
491
+ // else ("schema": a payload index, an alias, a snapshot, a shard, the collection's own settings or
492
+ // its deletion) changes the collection's shape and is held, never copied. ulaim creates a payload
493
+ // index on every one of its 300 collections at its first request, and copying each of them for
494
+ // that filled its cluster with copies and its searches with 503s.
495
+ const QDRANT_COLLECTION = /^\/collections\/([^/]+)(\/.*)?$/;
496
+ const QDRANT_READ_POST = /^\/points(?:\/(?:search|query|recommend|discover)(?:\/(?:batch|groups))?|\/scroll|\/count|\/facet|\/search\/matrix\/(?:pairs|offsets))?$/;
497
+ const QDRANT_POINTS = /^\/points(?:\/(?:delete|payload|payload\/delete|payload\/clear|vectors|vectors\/delete|batch))?(?:\?.*)?$/;
498
+ const qdrantCall = (u, method) => {
499
+ const m = QDRANT_COLLECTION.exec(u.pathname);
500
+ if (!m) return null;
501
+ const [, name, rest = ""] = m;
502
+ const collection = name === "aliases" ? null : decodeURIComponent(name);
503
+ const verb = String(method || "GET").toUpperCase();
504
+ const write = verb === "PUT" || verb === "PATCH" || verb === "DELETE" || (verb === "POST" && !QDRANT_READ_POST.test(rest));
505
+ const kind = !write ? null : rest === "" && verb === "PUT" ? "create" : QDRANT_POINTS.test(rest) && verb !== "DELETE" ? "points" : "schema";
506
+ return { collection, rest, write, kind };
507
+ };
484
508
  const PASSAGE_KEY = /^(?:text|content|page_?content|pageContent|chunk|snippet|passage|body|document|documents|description|answer|raw_content|highlights?|excerpt)$/i;
485
509
  const passagesFrom = (raw) => {
486
510
  const out = [];
@@ -880,6 +904,6 @@ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} })
880
904
 
881
905
  module.exports = {
882
906
  MAX, REPLY_MAX, EMBEDDING, TOOL_TEXT, MODEL_HOST,
883
- scrubbed, list, clipped, argsText, turnOf, toolText, isModelCall, isRetrieval, passagesFrom, decoded, cookieNames,
907
+ scrubbed, list, clipped, argsText, turnOf, toolText, isModelCall, isRetrieval, qdrantCall, passagesFrom, decoded, cookieNames,
884
908
  fitted, makeRecorder, asChat, GATEWAY_MODEL,
885
909
  };
package/local.mjs CHANGED
@@ -28,7 +28,7 @@ import { changesOf, commandText, RELOADER, sourceOf } from "./lib/fresh.mjs";
28
28
  import { chainOf, elapsedMs, holderOf, listening, listenerOn, portInError, spawnTied, stopTree, supervisorOf } from "./lib/proc.mjs";
29
29
  import { claimRunner, readRunner, releaseRunner, replaceRunner, startedAtOf, writeRunner } from "./lib/runner.mjs";
30
30
  import { registerAll } from "./lib/register.mjs";
31
- import { machineText } from "./lib/read-text.mjs";
31
+ import { connectedText } from "./lib/read-text.mjs";
32
32
  import { VERSION } from "./lib/spec.mjs";
33
33
  import { maskedLines, maskWith, secretValues } from "./lib/env-secrets.mjs";
34
34
  import { finished, unseenText } from "./lib/text.mjs";
@@ -134,10 +134,11 @@ async function holdProject() {
134
134
  // A verb waits on this record from the moment it starts this process, so it is taken first.
135
135
  if (viaToken) await holdProject();
136
136
 
137
- // ---- what leaves the machine: the source git would commit, and nothing git is told to ignore
137
+ // ---- what leaves the machine
138
138
  const SKIP_DIR = /^(node_modules|\.git|dist|build|out|coverage|vendor|venv|\.venv|env|target|tmp|\.next|\.nuxt|\.turbo|\.cache|__pycache__|\.terraform|\.wrangler|\.svelte-kit|\.output|\.parcel-cache|\.idea|\.vscode|secrets?|\.secrets?)$/i;
139
139
  // Env files by any name (scripts/books.env is one), keys, credentials, media and compiled output.
140
140
  const SKIP_FILE = /^\.env(\..*)?$|^\.envrc$|\.env$|\.(pem|key|p12|pfx|jks|keystore|sqlite|sqlite3|db|log|lock|map|zip|tar|gz|tgz|7z|rar|png|jpe?g|gif|webp|ico|svg|mp3|mp4|wav|mov|pdf|woff2?|ttf|otf|eot|bin|exe|dll|so|dylib|wasm|onnx|pt|pth|safetensors|numbers|DS_Store)$/i;
141
+ const EVALS = /(?:^|\/)(?:evals?|evaluations?|benchmarks?|gold(?:ens?)?|red[-_]?team)\/|(?:^|[\/._-])(?:evals?|evaluations?|gold(?:en)?|benchmarks?|fidelity|red[-_]?team|cases)(?:[._-][^\/]*)?$/i;
141
142
  const MAX_FILE = 25_000_000;
142
143
  const MAX_TOTAL = 256_000_000;
143
144
  const ENV_FILE = /^\.env(\.(local|staging|stage|development|dev|test|example|sample))?$/;
@@ -178,7 +179,7 @@ function gitListed() {
178
179
  function shareable(rel, askGit = true) {
179
180
  const parts = rel.split(sep);
180
181
  if (parts.slice(0, -1).some((d) => SKIP_DIR.test(d)) || SKIP_FILE.test(parts.at(-1))) return false;
181
- if (listed === null || listed.has(rel)) return true;
182
+ if (listed === null || listed.has(rel) || EVALS.test(parts.join("/"))) return true;
182
183
  // Off the list git gave a moment ago: ignored, or inside a nested checkout. The walk takes git's
183
184
  // word for it; asking per file was one process each, two minutes on a repository with a worktree
184
185
  // inside it. A read asked later, of a file made after the list was taken (an agent edit), asks.
@@ -597,7 +598,9 @@ async function verb(job) {
597
598
  } catch { return { text: "", root, size: 0 }; }
598
599
  }
599
600
  // The person changed what they said yes to while this command runs.
600
- case "consent": await allowOutbound(b); return { ok: true };
601
+ // Said in the terminal: an agent that read "one question for the person" here told them the run
602
+ // was still waiting two minutes after they had answered.
603
+ case "consent": await allowOutbound(b); say("the person's answer on the card in the browser arrived"); return { ok: true };
601
604
  // A line for the agent reading this terminal: the read of the code landing, said the moment it does.
602
605
  // One marked news (a run ending) is also kept for the hook, so an agent that was not listening
603
606
  // hears it on its next prompt or edit.
@@ -947,6 +950,7 @@ const keeping = makeKeeping({
947
950
  root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold, typed: Boolean(flag("--start")),
948
951
  launch: (plan) => sidecar(plan.cmd, plan.cwd),
949
952
  connections: () => capture?.connections() ?? [],
953
+ copies: () => capture?.copies() ?? [],
950
954
  settings: () => {
951
955
  const from = {};
952
956
  for (const file of envFiles.filter((f) => !/\.(example|sample)$/.test(f))) {
@@ -1196,7 +1200,7 @@ if (explain) {
1196
1200
  ].join("\n"));
1197
1201
  process.exit(0);
1198
1202
  }
1199
- if (kept.length) say(`not uploaded, left on this machine: ${kept.join(", ")}`);
1203
+ if (kept.length) say(`left on this machine because it holds a key: ${kept.join(", ")}`);
1200
1204
  const keepSecret = (v) => { if (v && v.length >= 12 && !secrets.includes(v)) secrets.push(v); };
1201
1205
  identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, say, keepSecret, appDir: () => appDir });
1202
1206
  // A real request that reached their model proves its door (lib/proof.mjs). What it proved goes up,
@@ -1262,7 +1266,8 @@ const firstStart = startApp();
1262
1266
  firstStart.catch(() => { /* appLife reads the failure */ });
1263
1267
  const archive = join(work, "tree.tgz");
1264
1268
  step("connecting");
1265
- await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
1269
+ // COPYFILE_DISABLE: a Mac's tar adds no "._name" entries.
1270
+ await exec("tar", ["-czf", archive, "-C", root, "-T", list], { env: { ...process.env, COPYFILE_DISABLE: "1" } });
1266
1271
  const bytes = readFileSync(archive);
1267
1272
  const PART = 1_500_000;
1268
1273
  let resumed = false;
@@ -1294,7 +1299,7 @@ stepDone(moved ? "connected: your change is in, and what Cortad read before stil
1294
1299
  if (!viaToken && process.env.CORTAD_NO_REGISTER !== "1") {
1295
1300
  const got = await registerAll().catch((err) => { if (verbose) say(`could not register with your coding agents: ${err?.message ?? err}`); return { clients: [], changed: [] }; });
1296
1301
  const and = (list) => new Intl.ListFormat("en", { type: "conjunction" }).format(list);
1297
- if (got.changed.length) say(`For the person: ${and(got.clients)} can now use Cortad: its MCP server and skill were added to ${got.changed.map((f) => `~/${f}`).join(", ")}. This repository was not changed.`);
1302
+ if (got.changed.length) say(`${and(got.clients)} can now use Cortad: its tools and skill were added to ${got.changed.map((f) => `~/${f}`).join(", ")}. This repository was not changed.`);
1298
1303
  }
1299
1304
 
1300
1305
  lock = await makeLock({ root, work });
@@ -1386,7 +1391,7 @@ async function appLife(firstStart) {
1386
1391
  say(`your app is answering on port ${app.port}${app.cmd ? ` · ${app.cmd}` : ""}`);
1387
1392
  if (launched) say(`your app's output for this session is kept in ${bootLog}, with the values from your env files masked`);
1388
1393
  if (proxy) say(`send requests to port ${proxy.port}: it passes them to your app and Cortad sees each one. Requests sent straight to port ${app.port} are not seen`);
1389
- for (const line of machineText(machineOf())) say(line);
1394
+ for (const line of connectedText(machineOf())) say(line);
1390
1395
  say("leave this open. Go back to the browser; Ctrl-C disconnects.");
1391
1396
  let downSince = 0;
1392
1397
  let toldDown = false;
@@ -1516,9 +1521,12 @@ const autoReach = makeAutoReach({
1516
1521
  stampOf: (m) => { try { return m.file ? String(statSync(join(root, m.file)).mtimeMs) : ""; } catch { return ""; } },
1517
1522
  asked: () => existsSync(join(homeOf(project), ASK)),
1518
1523
  report: async (answers) => {
1519
- if (!answers.length && reachedBox === box) return;
1524
+ if (!answers.length && reachedBox === box) return true;
1520
1525
  const to = box;
1521
- if ((await call("POST", `/local/${to}/reached`, { answers }, { timeoutMs: 15_000 })).ok) reachedBox = to;
1526
+ // False where the server did not take it: the answers are kept and said again (lib/auto-reach.mjs).
1527
+ const ok = Boolean((await call("POST", `/local/${to}/reached`, { answers }, { timeoutMs: 15_000 }).catch(() => null))?.ok);
1528
+ if (ok) reachedBox = to;
1529
+ return ok;
1522
1530
  },
1523
1531
  say,
1524
1532
  fetchImpl: countedFetch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
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"