cortad 0.3.8 → 0.3.9

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.
@@ -19,15 +19,53 @@ const keyOf = (m) => `${m.method} ${m.path}`;
19
19
  // `asked()`: true once the agent's ask waits, so a round stops between two sends to take it.
20
20
  // `report(answers)`: what each test request met, for the server (POST /local/:box/reached). Each round
21
21
  // opens with an empty list, which says this command sends them, so status stops asking the agent to;
22
- // the caller posts it once per connection.
23
- export function makeAutoReach({ status, runner, waiting, origin, signIn, stampOf, asked = () => false, report = async () => {}, say, fetchImpl = fetch }) {
22
+ // the caller posts it once per connection. Each answer is reported as it lands: reported only when
23
+ // its round ended, one endpoint that took 55 seconds held every faster one's answer back with it.
24
+ // `report` answering false, or failing, leaves its answers kept here and said again later: the server
25
+ // hears them late, and the app is never asked again for the sake of a report that did not arrive.
26
+ // What is kept is each endpoint's latest answer, so it is never more than the endpoints the app has
27
+ // and nothing is dropped to make room. One report is out at a time; the first that fails stops the
28
+ // rest, and the next is tried RETRY_MS later, twice as long after each failure up to RETRY_MAX_MS:
29
+ // 250 answers behind a server that was not answering were 250 reports of 15 seconds each, one after
30
+ // the other, and the fifty oldest were thrown away with their endpoints already marked as tested.
31
+ // `roundMs`: past it a round starts no further request; the rows left are sent in the next round. It
32
+ // bounds what a round starts, not a request already out (REACH_MS each) nor the connection as a whole.
33
+ export const ROUND_MS = 90_000;
34
+ const RETRY_MS = 5_000;
35
+ const RETRY_MAX_MS = 60_000;
36
+ const BATCH = 100;
37
+ export function makeAutoReach({ status, runner, waiting, origin, signIn, stampOf, asked = () => false, report = async () => {}, say, fetchImpl = fetch, roundMs = ROUND_MS, now = Date.now }) {
24
38
  const sent = new Map();
25
39
  let n = 0;
26
40
  const next = () => ++n;
41
+ const kept = new Map();
42
+ let draining = null, retryAt = 0, backoff = 0;
43
+ const drain = async () => {
44
+ while (kept.size && now() >= retryAt) {
45
+ const batch = [...kept.values()].slice(0, BATCH);
46
+ const ok = await Promise.resolve().then(() => report(batch)).then((r) => r !== false, () => false);
47
+ if (!ok) {
48
+ // Said once per outage, so a card that is not moving has its reason in the terminal.
49
+ if (!backoff) say(`Cortad did not take what ${kept.size === 1 ? "1 test request" : `${kept.size} test requests`} met. They are kept here and sent again; your app is not asked again.`);
50
+ backoff = Math.min(backoff ? backoff * 2 : RETRY_MS, RETRY_MAX_MS);
51
+ retryAt = now() + backoff;
52
+ return;
53
+ }
54
+ backoff = 0;
55
+ // An endpoint answered again while its last answer was out keeps the newer one.
56
+ for (const a of batch) if (kept.get(a.door) === a) kept.delete(a.door);
57
+ }
58
+ };
59
+ const tell = (met = []) => {
60
+ for (const a of met) kept.set(a.door, a);
61
+ draining ??= drain().finally(() => { draining = null; });
62
+ return draining;
63
+ };
27
64
  // `ask`: the agent's call, { paths }: sent whatever was sent before, and answered with each line.
28
65
  // `wait`: when to look again: soon while the code is still being read, rarely once it is.
29
66
  return async function round(ask = null) {
30
- await report([]).catch(() => {});
67
+ await Promise.resolve().then(() => report([])).catch(() => {});
68
+ await tell();
31
69
  const here = runner();
32
70
  const down = notUp(here);
33
71
  if (down) return { text: `${down} Nothing was sent.\nnext: status`, wait: 5_000 };
@@ -37,14 +75,22 @@ export function makeAutoReach({ status, runner, waiting, origin, signIn, stampOf
37
75
  if (!d) return { text: "Cortad did not answer with the list of endpoints. Nothing was sent.\nnext: status", wait: 30_000 };
38
76
  const pick = picked(d.contact, here.receipts, ask?.paths);
39
77
  const due = pick.sent.filter((m) => !m.excluded && (ask || sent.get(keyOf(m)) !== stampOf(m)));
40
- const answers = due.length ? await sendOurs(due, { runner: here, origin: origin(), signIn, next, stop: () => !ask && asked(), fetchImpl }) : [];
41
- // An app restarting under a request is no answer of the endpoint's: it is sent again next round.
78
+ const until = now() + roundMs;
79
+ let cut = false;
80
+ const answers = due.length ? await sendOurs(due, {
81
+ runner: here, origin: origin(), signIn, next, stop: () => (!ask && asked()) || (cut = now() >= until), fetchImpl,
82
+ onAnswer: (a, i) => { const met = outcomes([due[i]], [a]); if (met.length) void tell(met); },
83
+ }) : [];
84
+ await tell();
85
+ // A request that never reached the app (refused, or a GET cut off) is sent again next round. One
86
+ // that may have reached it, a POST reset or timed out, is not: sending it twice could do twice
87
+ // whatever it does. The agent's `reach` or an edit to its file sends it again.
42
88
  answers.forEach((a, i) => { if (!a.down) sent.set(keyOf(due[i]), stampOf(due[i])); });
43
- const met = outcomes(due, answers);
44
- for (let i = 0; i < met.length; i += 100) await report(met.slice(i, i + 100)).catch(() => {});
45
89
  const line = testedLine(answers);
46
90
  if (line) say(line);
47
- return { text: reachText({ ...pick, sent: due.slice(0, answers.length) }, answers, `${here.port}, one at a time`), wait: d.read?.complete === true ? 60_000 : 5_000 };
91
+ // Rows the round's time left unsent are due in seconds, never a minute on. An ask of the agent's
92
+ // that stopped the round is taken within a second whatever this says.
93
+ return { text: reachText({ ...pick, sent: due.slice(0, answers.length) }, answers, `${here.port}, up to four at a time`), wait: !cut && !kept.size && d.read?.complete === true ? 60_000 : 5_000 };
48
94
  };
49
95
  }
50
96
 
package/lib/calls.mjs CHANGED
@@ -1,23 +1,35 @@
1
1
  // What Cortad counts about its own tools: which one an agent called, from which client, how long it
2
2
  // took, how much text came back and, when it failed, under which code. The arguments and the answer
3
- // stay on this machine. Sent with the machine's own key, and a count that is lost is never retried:
4
- // it must not cost the tool call it describes.
3
+ // stay on this machine. Sent with the machine's own key. A count that is lost is not sent again: it
4
+ // must not cost the tool call it describes. One kind of call is: the agent's own test request
5
+ // (`reach`), which Cortad counts as a try at fixing whatever holds Run. Lost, that try would go
6
+ // uncounted, so its report is sent again, a few times and no more, always under the call's own id
7
+ // (`callId`, made when the call arrived): the server takes an id once however often it arrives.
5
8
  import { randomUUID } from "node:crypto";
6
9
 
7
10
  const text = (v, max) => (typeof v === "string" && v ? v.slice(0, max) : undefined);
8
11
 
9
- export function makeReport({ api, token, version, fetchImpl = fetch, sessionId = randomUUID() }) {
10
- return async ({ tool, durationMs, outputChars, errorCode, error, clientName, clientVersion }) => {
12
+ const RETRIES = 3;
13
+ const RETRY_MS = 400;
14
+
15
+ export function makeReport({ api, token, version, fetchImpl = fetch, sessionId = randomUUID(), wait = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
16
+ return async ({ tool, callId, durationMs, outputChars, errorCode, error, clientName, clientVersion }) => {
11
17
  const key = token();
12
18
  if (!key) return;
13
- const call = { tool, durationMs, outputChars, errorCode, error: text(error?.split("\n")[0], 512), clientName: text(clientName, 80), clientVersion: text(clientVersion, 40), serverVersion: version, sessionId };
14
- try {
15
- await fetchImpl(`${api}/mcp/calls`, {
16
- method: "POST",
17
- headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
18
- body: JSON.stringify([call]),
19
- signal: AbortSignal.timeout(3000),
20
- });
21
- } catch { /* lost */ }
19
+ const call = { tool, ...(callId ? { callId } : {}), durationMs, outputChars, errorCode, error: text(error?.split("\n")[0], 512), clientName: text(clientName, 80), clientVersion: text(clientVersion, 40), serverVersion: version, sessionId };
20
+ const tries = tool === "reach" && callId ? RETRIES : 1;
21
+ for (let n = 1; n <= tries; n++) {
22
+ try {
23
+ const res = await fetchImpl(`${api}/mcp/calls`, {
24
+ method: "POST",
25
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
26
+ body: JSON.stringify([call]),
27
+ signal: AbortSignal.timeout(3000),
28
+ });
29
+ // Taken, or refused for good: either way it is not sent again.
30
+ if (!res || res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429)) return;
31
+ } catch { /* lost */ }
32
+ if (n < tries) await wait(n * RETRY_MS);
33
+ }
22
34
  };
23
35
  }
@@ -130,9 +130,11 @@ function readyLine(r) {
130
130
 
131
131
  // The endpoints no request has reached, as status lists them and reach sends them. `receipts`: doors
132
132
  // the App section says answered at once with their answer out of Cortad's sight, so not said here.
133
- export const unreached = (c, receipts = []) => {
133
+ // `retests`: the doors whose test failed, listed again to be sent once more: reach takes them, the
134
+ // list of endpoints no request reached does not.
135
+ export const unreached = (c, receipts = [], { retests = false } = {}) => {
134
136
  const answered = new Set(receipts.map((x) => x.door));
135
- return (c?.notCalled ?? []).filter((m) => !answered.has(`${m.method} ${m.path}`));
137
+ return (c?.notCalled ?? []).filter((m) => !answered.has(`${m.method} ${m.path}`) && (retests || !m.retest));
136
138
  };
137
139
 
138
140
  // One the server has no request for, or one listed on a field's name alone, is the agent's to send.
@@ -155,8 +157,12 @@ export function contactLines(c, data = [], receipts = []) {
155
157
  const said = data.map((s) => `Data: ${s}`);
156
158
  if (!c) return said;
157
159
  const proven = c.proven ?? [];
158
- // No request yet is said by the App line and by the next step, not a third time here.
159
- const lines = proven.length ? [`Endpoints that answered (${num(proven.length)}):`, ...proven.flatMap(doorLines)] : [];
160
+ // No request yet is said by the App line and by the next step, not a third time here. Endpoints
161
+ // that all answered only Cortad's own test request say so once, over the list: sixteen of them
162
+ // each said it under their own line, and the agent asked for a status it could read.
163
+ const alike = proven.length > 1 && proven.every((v) => v.standing === "fallback") && new Set(proven.map((v) => v.why)).size === 1;
164
+ const head = alike ? `Endpoints that answered (${num(proven.length)}), each only Cortad's own test request so far: ${proven[0].why}` : `Endpoints that answered (${num(proven.length)}):`;
165
+ const lines = proven.length ? [head, ...proven.flatMap((v) => doorLines(alike ? { ...v, standing: null } : v))] : [];
160
166
  const missing = unreached(c, receipts);
161
167
  if (missing.length) lines.push(`Endpoints found in your code that have not answered yet (${num(missing.length)}):`, ...missing.map(unreachedLine), ...[reachLine(missing)].filter(Boolean));
162
168
  const ready = readyLine(c.ready);
package/lib/keeping.mjs CHANGED
@@ -38,7 +38,7 @@ function blockedRows(blocked) {
38
38
  // that command gives the app: a legal research API typed ENV=staging and was started on copies of
39
39
  // the test databases its default settings file names, where its library is empty.
40
40
  const AS_TYPED = "Your app is started with your own command as it is, on the databases that command gives it. Nothing is copied: a run writes into them once you say yes to each on the card.";
41
- export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, hold = () => {}, typed = false }) {
41
+ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, copies = () => [], hold = () => {}, typed = false }) {
42
42
  let pending = null;
43
43
  let now = null;
44
44
  let backing = null;
@@ -50,7 +50,9 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
50
50
  async function keepAll(started) {
51
51
  if (typed) {
52
52
  const none = async () => ({ env: {}, risks: [], copies: [], blocked: [], pass: [] });
53
- now = { env: {}, risks: [], notes: [AS_TYPED], blocked: [], pass: [], stores: [], keeper: { keep: none, drop: async () => {} }, values: {}, started };
53
+ // The app's own command may still reach a Qdrant: the copies the hook makes there are dropped too.
54
+ const keeper = storeKeeper({ ledgerFile, values: {}, dirs: [] });
55
+ now = { env: {}, risks: [], notes: [AS_TYPED], blocked: [], pass: [], stores: [], keeper: { keep: none, drop: async () => {}, dropCopies: (c, s) => keeper.dropCopies(c, s) }, values: {}, started };
54
56
  tell(now.notes);
55
57
  return now;
56
58
  }
@@ -157,12 +159,15 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
157
159
  const k = await pending;
158
160
  pending = null;
159
161
  now = null;
162
+ await k?.keeper.dropCopies(copies(), k.stores).catch(() => {});
160
163
  await k?.keeper.drop();
161
164
  },
162
165
  // Once the app has stopped: a copy is dropped when nothing holds it open, and a store is stopped
163
166
  // only after its copy is gone.
164
167
  async close() {
165
- await (await pending?.catch(() => null))?.keeper.drop().catch(() => {});
168
+ const k = await pending?.catch(() => null);
169
+ await k?.keeper.dropCopies(copies(), k.stores).catch(() => {});
170
+ await k?.keeper.drop().catch(() => {});
166
171
  await backing?.stop().catch(() => {});
167
172
  },
168
173
  };
package/lib/mcp.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // dependency. stdout carries protocol messages and nothing else; everything a person might read
3
3
  // goes to stderr. The tools are lib/verbs.mjs, one to one.
4
4
 
5
+ import { randomUUID } from "node:crypto";
5
6
  import { SHOW } from "./read-text.mjs";
6
7
 
7
8
  export const PROTOCOL = "2025-06-18";
@@ -10,7 +11,7 @@ export const PROTOCOL = "2025-06-18";
10
11
  // every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
11
12
  export const instructions = (spec = "cortad") => [
12
13
  "Cortad tests the AI in this repository: simulated users written from the app's code talk to the app running on this machine, and every reply is checked. Cortad sends each AI endpoint a test request itself. status shows the app's port, what the reviews of the prompts and of the code around the model found, which endpoints answered and which failed at what file and line, and what a run would play and cost. The first run starts by itself; later runs when the person presses Run or asks.",
13
- `Tell the person what status found in a few plain lines. Before you stop, start \`npx -y ${spec} wait\` as a background command: it returns when the next run ends, with what it found. After a run, call findings and fix the worst problem, then verify it; the person asked for these fixes when they connected Cortad. findings has each failure whole and findings numbers every rate. verify replays the failing conversations and says whether the failure is gone. field_connect gives the steps to connect production. dispute records a check that reads this app wrong.`,
14
+ `Tell the person what status found in a few plain lines. Before you stop, start \`npx -y ${spec} wait\` as a background command: it returns when the next run or verify ends, with what it found. After a run, call findings and tell the person what it found and what you would change, worst first; the person then says which fix to make, and nothing is edited before their go. On their go, one fix at a time: verify replays the failing conversations and says whether the failure is gone, and a further round plays only when verify is called again. findings has each failure whole and findings numbers every rate. field_connect gives the steps to connect production. dispute records a check that reads this app wrong.`,
14
15
  ].join("\n\n");
15
16
  export const INSTRUCTIONS = instructions();
16
17
 
@@ -69,8 +70,9 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
69
70
  return new Promise((resolve) => {
70
71
  let client = {};
71
72
  const sending = new Set();
72
- const count = (tool, began, text, failure) => {
73
- const sent = Promise.resolve(report({ tool, durationMs: Date.now() - began, outputChars: text.length, ...failure, ...client })).catch(() => {}).finally(() => sending.delete(sent));
73
+ // `callId`: this call's own name, made when it arrives and sent with every report of it (lib/calls.mjs).
74
+ const count = (tool, began, text, failure, callId) => {
75
+ const sent = Promise.resolve(report({ tool, callId, durationMs: Date.now() - began, outputChars: text.length, ...failure, ...client })).catch(() => {}).finally(() => sending.delete(sent));
74
76
  sending.add(sent);
75
77
  };
76
78
  let closed = false;
@@ -97,6 +99,7 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
97
99
  const { id, method, params } = msg;
98
100
  const notification = id === undefined || id === null;
99
101
  const began = Date.now();
102
+ const callId = randomUUID();
100
103
  try {
101
104
  switch (method) {
102
105
  case "initialize":
@@ -113,11 +116,11 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
113
116
  const name = params?.name;
114
117
  const verb = NAMES.has(name) && Object.hasOwn(verbs, name) ? verbs[name] : null;
115
118
  if (!verb) {
116
- count("unknown", began, "", { errorCode: "unknown_tool" });
119
+ count("unknown", began, "", { errorCode: "unknown_tool" }, callId);
117
120
  return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
118
121
  }
119
122
  const out = await verb(params?.arguments ?? {});
120
- count(name, began, out.text, out.isError ? { errorCode: out.code ?? "refused", error: out.why } : {});
123
+ count(name, began, out.text, out.isError ? { errorCode: out.code ?? "refused", error: out.why } : {}, callId);
121
124
  const structured = out.structured && typeof out.structured === "object" && !Array.isArray(out.structured) ? { structuredContent: out.structured } : {};
122
125
  return send(result(id, { content: [{ type: "text", text: out.text }], ...structured, ...(out.isError ? { isError: true } : {}) }));
123
126
  }
@@ -126,7 +129,7 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
126
129
  }
127
130
  } catch (err) {
128
131
  log(`mcp ${method}: ${err?.stack ?? err}`);
129
- if (method === "tools/call") count(NAMES.has(params?.name) ? params.name : "unknown", began, "", { errorCode: "internal" });
132
+ if (method === "tools/call") count(NAMES.has(params?.name) ? params.name : "unknown", began, "", { errorCode: "internal" }, callId);
130
133
  if (!notification) send(result(id, { content: [{ type: "text", text: `Cortad could not answer: ${String(err?.message ?? err)}` }], isError: true }));
131
134
  }
132
135
  }
@@ -118,7 +118,7 @@ def _install():
118
118
  import re
119
119
  import sys
120
120
  import time
121
- from urllib.parse import urlsplit
121
+ from urllib.parse import quote, unquote, urlsplit, urlunsplit
122
122
 
123
123
  ctx = contextvars.ContextVar("cortad_request", default=None)
124
124
  limit = 65536
@@ -882,6 +882,31 @@ def _install():
882
882
  return False
883
883
  return bool(retrieval_host.search(parts.hostname or "")) or bool(retrieval_path.search(parts.path or ""))
884
884
 
885
+ # A call to one Qdrant collection, by Qdrant's own REST shape (lib/wire.cjs qdrantCall): every
886
+ # PUT, PATCH and DELETE writes, and a POST does unless it is one of Qdrant's reads spelled as a
887
+ # POST. /collections/aliases names no collection: a write of the server itself.
888
+ qdrant_collection = re.compile(r"^/collections/([^/]+)(/.*)?$")
889
+ qdrant_read_post = re.compile(r"^/points(?:/(?:search|query|recommend|discover)(?:/(?:batch|groups))?|/scroll|/count|/facet|/search/matrix/(?:pairs|offsets))?$")
890
+ qdrant_points = re.compile(r"^/points(?:/(?:delete|payload|payload/delete|payload/clear|vectors|vectors/delete|batch))?$")
891
+
892
+ def qdrant_call(url, method):
893
+ try:
894
+ parts = urlsplit(str(url))
895
+ except ValueError:
896
+ return None
897
+ if parts.scheme not in ("http", "https"):
898
+ return None
899
+ m = qdrant_collection.match(parts.path or "")
900
+ if not m:
901
+ return None
902
+ name, rest = m.group(1), m.group(2) or ""
903
+ verb = str(method or "GET").upper()
904
+ write = verb in ("PUT", "PATCH", "DELETE") or (verb == "POST" and not qdrant_read_post.match(rest))
905
+ # "points" changes what the collection holds and a copy stands in for it; "create" makes it;
906
+ # "schema" (an index, an alias, a snapshot, its settings or its deletion) is held, never copied.
907
+ kind = None if not write else "create" if rest == "" and verb == "PUT" else "points" if qdrant_points.match(rest) and verb != "DELETE" else "schema"
908
+ return {"collection": None if name == "aliases" else unquote(name), "rest": rest, "write": write, "kind": kind, "parts": parts}
909
+
885
910
  def passages_from(raw):
886
911
  out, nodes = [], [0]
887
912
 
@@ -1344,6 +1369,11 @@ def _install():
1344
1369
  return False
1345
1370
  elif run_seen["at"] is None or time.monotonic() - run_seen["at"] > run_live_s:
1346
1371
  return False
1372
+ # A read of a Qdrant collection, however its server is named, and any call to a copy this
1373
+ # hook made (copy_of below), go out.
1374
+ q = qdrant_call(url, method)
1375
+ if q and (not q["write"] or (q["collection"] and our_copy.search(q["collection"]))):
1376
+ return False
1347
1377
  try:
1348
1378
  parts = urlsplit(str(url))
1349
1379
  host = (parts.hostname or "").lower()
@@ -1353,6 +1383,150 @@ def _install():
1353
1383
  except Exception:
1354
1384
  return True
1355
1385
 
1386
+ # A write to a Qdrant collection made inside a run goes to a copy of that collection on the same
1387
+ # server, made at the first such write: the collection is read page by page and written under
1388
+ # `<name>_cortad_<pid>`, and from then on every call a run makes to that collection, reads too,
1389
+ # goes to the copy, so the app finds what it wrote. The original is never written. A copy that
1390
+ # cannot be made (over the size a run copies, a server that refused) leaves the write held like
1391
+ # any other. Each copy is reported as a `copy` row and the command deletes them when it ends.
1392
+ # The person's own calls, outside a run, go where they were sent (lib/trace.cjs does the same).
1393
+ import threading
1394
+ our_copy = re.compile(r"_cortad_\d+$")
1395
+ copies = {}
1396
+ copies_lock = threading.Lock()
1397
+ qdrant_cap = 200000
1398
+ # What one process copies at most: collections, and points across them; past it a write is held.
1399
+ copies_max = 8
1400
+ points_max = 300000
1401
+ copied = {"collections": 0, "points": 0}
1402
+ copy_said = set()
1403
+
1404
+ def copy_say(host, collection, why):
1405
+ if (host, collection) in copy_said:
1406
+ return
1407
+ copy_said.add((host, collection))
1408
+ try:
1409
+ sys.stderr.write("cortad: a request Cortad sent made this app write into the Qdrant collection %s at %s. Cortad could not copy that collection on its server (%s), so the write was kept on this machine and answered with a stand-in success.\n" % (collection, host, why))
1410
+ except Exception:
1411
+ pass
1412
+
1413
+ def in_run():
1414
+ req = ctx.get()
1415
+ if req:
1416
+ return bool(req.get("turn"))
1417
+ return run_seen["at"] is not None and time.monotonic() - run_seen["at"] <= run_live_s
1418
+
1419
+ # None to leave the call alone; hold for a write of the server itself; else the collection's
1420
+ # copy when there is one, or none yet for a write, which makes it.
1421
+ def guarded(url, method):
1422
+ call = qdrant_call(url, method)
1423
+ if not call:
1424
+ return None
1425
+ if not call["collection"]:
1426
+ return {"hold": True} if call["write"] and in_run() else None
1427
+ if our_copy.search(call["collection"]) or not in_run():
1428
+ return None
1429
+ key = "%s|%s" % (call["parts"].netloc, call["collection"])
1430
+ copy = copies.get(key)
1431
+ if copy:
1432
+ return {"key": key, "call": call, "copy": copy}
1433
+ if not call["write"]:
1434
+ return None
1435
+ if call["kind"] == "schema":
1436
+ return {"hold": True}
1437
+ if copied["collections"] >= copies_max or copied["points"] >= points_max:
1438
+ copy_say(call["parts"].netloc, call["collection"], "this run already copied %d collections, %d points" % (copied["collections"], copied["points"]))
1439
+ return {"hold": True}
1440
+ return {"key": key, "call": call, "copy": None}
1441
+
1442
+ # Qdrant's own API over urllib, which this file leaves unpatched: never through the app's clients.
1443
+ def qdrant_http(base, key, method, path, body=None):
1444
+ import urllib.error
1445
+ import urllib.request
1446
+ data = json.dumps(body).encode("utf-8") if body is not None else None
1447
+ headers = {"content-type": "application/json"}
1448
+ if key:
1449
+ headers["api-key"] = key
1450
+ req = urllib.request.Request(base + path, data=data, method=method, headers=headers)
1451
+ try:
1452
+ with urllib.request.urlopen(req, timeout=30) as r:
1453
+ return json.loads(r.read().decode("utf-8", "replace") or "null"), r.status
1454
+ except urllib.error.HTTPError as e:
1455
+ return None, e.code
1456
+
1457
+ # The copy made: "empty" when the server holds no such collection yet (the app makes it, under
1458
+ # the copy's name), "points" once every point is across, None when none could be made.
1459
+ def clone_qdrant(base, key, source, clone):
1460
+ at = lambda name: "/collections/" + quote(name, safe="")
1461
+ info, status = qdrant_http(base, key, "GET", at(source))
1462
+ if status == 404:
1463
+ return ("empty", 0)
1464
+ result = (info or {}).get("result") if status == 200 else None
1465
+ if not result:
1466
+ return None
1467
+ if (result.get("points_count") or 0) > qdrant_cap:
1468
+ return None
1469
+ params = (result.get("config") or {}).get("params") or {}
1470
+ made = {"vectors": params.get("vectors")}
1471
+ if params.get("sparse_vectors"):
1472
+ made["sparse_vectors"] = params["sparse_vectors"]
1473
+ if "on_disk_payload" in params:
1474
+ made["on_disk_payload"] = params["on_disk_payload"]
1475
+ if qdrant_http(base, key, "PUT", at(clone), made)[1] != 200:
1476
+ return None
1477
+ try:
1478
+ for field, schema in (result.get("payload_schema") or {}).items():
1479
+ qdrant_http(base, key, "PUT", at(clone) + "/index?wait=true", {"field_name": field, "field_schema": schema.get("params") or schema.get("data_type")})
1480
+ offset = None
1481
+ while True:
1482
+ page, status = qdrant_http(base, key, "POST", at(source) + "/points/scroll", {"limit": 256, "with_payload": True, "with_vector": True, **({"offset": offset} if offset is not None else {})})
1483
+ if status != 200:
1484
+ raise RuntimeError(status)
1485
+ got = (page or {}).get("result") or {}
1486
+ points = got.get("points") or []
1487
+ if points and qdrant_http(base, key, "PUT", at(clone) + "/points?wait=true", {"points": [{"id": p.get("id"), "vector": p.get("vector"), "payload": p.get("payload")} for p in points]})[1] != 200:
1488
+ raise RuntimeError("upsert")
1489
+ offset = got.get("next_page_offset")
1490
+ if offset is None:
1491
+ return ("points", result.get("points_count") or 0)
1492
+ except Exception:
1493
+ qdrant_http(base, key, "DELETE", at(clone))
1494
+ return None
1495
+
1496
+ # The copy's name, made now when none exists; None when none could be made. The key the app
1497
+ # sent is the key the copy is made with, and never leaves this process.
1498
+ def copy_of(g, header_of):
1499
+ with copies_lock:
1500
+ have = copies.get(g["key"])
1501
+ if have:
1502
+ return have
1503
+ parts = g["call"]["parts"]
1504
+ base = "%s://%s" % (parts.scheme, parts.netloc)
1505
+ auth = str(header_of("authorization") or "")
1506
+ bearer = re.match(r"^Bearer\s+(\S+)", auth, re.I)
1507
+ key = str(header_of("api-key") or "") or (bearer.group(1) if bearer else "")
1508
+ clone = "%s_cortad_%d" % (g["call"]["collection"][:40], os.getpid())
1509
+ made = clone_qdrant(base, key, g["call"]["collection"], clone)
1510
+ if not made:
1511
+ copy_say(parts.netloc, g["call"]["collection"], "the copy did not go through")
1512
+ return None
1513
+ copied["collections"] += 1
1514
+ copied["points"] += made[1]
1515
+ copies[g["key"]] = clone
1516
+ write({"copy": {"host": parts.hostname or "", "base": base, "collection": g["call"]["collection"], "clone": clone}})
1517
+ return clone
1518
+
1519
+ def to_copy(url, clone, call):
1520
+ parts = call["parts"]
1521
+ return urlunsplit((parts.scheme, parts.netloc, "/collections/" + quote(clone, safe="") + call["rest"], parts.query, parts.fragment))
1522
+
1523
+ # The copy a call goes to, waited for off the event loop: None to hold the call instead.
1524
+ async def copy_for(g, header_of):
1525
+ import asyncio
1526
+ if g.get("hold"):
1527
+ return None
1528
+ return g["copy"] or await asyncio.get_running_loop().run_in_executor(None, copy_of, g, header_of)
1529
+
1356
1530
  # A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (SQL over HTTP,
1357
1531
  # where a SELECT can write through a function) is held.
1358
1532
  # ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
@@ -2468,19 +2642,34 @@ def _install():
2468
2642
  dep(request.url, response.status_code, req=req, caller=caller)
2469
2643
  return response
2470
2644
 
2645
+ def held_answer(request, sent):
2646
+ kind = request.headers.get("content-type", "")
2647
+ held_row(request.url, request.method, sent, kind, ctx.get())
2648
+ return module.Response(200, headers={"content-type": "application/json"}, content=held_reply(sent, kind), request=request)
2649
+
2471
2650
  # A held call that cannot be answered fails in the app; it is never sent instead.
2472
2651
  def held_httpx(request, sent):
2473
2652
  if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent, request.url, request.headers.get):
2474
2653
  return None
2475
- kind = request.headers.get("content-type", "")
2476
- held_row(request.url, request.method, sent, kind, ctx.get())
2477
- return module.Response(200, headers={"content-type": "application/json"}, content=held_reply(sent, kind), request=request)
2654
+ return held_answer(request, sent)
2655
+
2656
+ # A call to a Qdrant collection goes to the run's copy of it, `clone`; with none, it is held.
2657
+ def routed_httpx(request, g, clone, sent):
2658
+ if clone is None:
2659
+ return held_answer(request, sent)
2660
+ request.url = module.URL(to_copy(request.url, clone, g["call"]))
2661
+ return None
2478
2662
 
2479
2663
  for cls in (module.Client, module.AsyncClient):
2480
2664
  send = cls.send
2481
2665
  if cls is module.Client:
2482
2666
  def sync_send(self, request, *a, _send=send, **k):
2483
2667
  sent = sent_of(request)
2668
+ g = guarded(request.url, request.method)
2669
+ if g is not None:
2670
+ answered = routed_httpx(request, g, None if g.get("hold") else (g["copy"] or copy_of(g, request.headers.get)), sent)
2671
+ if answered is not None:
2672
+ return answered
2484
2673
  held = held_httpx(request, sent)
2485
2674
  if held is not None:
2486
2675
  return held
@@ -2504,6 +2693,11 @@ def _install():
2504
2693
  else:
2505
2694
  async def async_send(self, request, *a, _send=send, **k):
2506
2695
  sent = sent_of(request)
2696
+ g = guarded(request.url, request.method)
2697
+ if g is not None:
2698
+ answered = routed_httpx(request, g, await copy_for(g, request.headers.get), sent)
2699
+ if answered is not None:
2700
+ return answered
2507
2701
  held = held_httpx(request, sent)
2508
2702
  if held is not None:
2509
2703
  return held
@@ -2535,6 +2729,9 @@ def _install():
2535
2729
  raw = text(request.body, reply_max)
2536
2730
  if reads_only(raw, request.url, request.headers.get):
2537
2731
  return None
2732
+ return held_answer(request, raw)
2733
+
2734
+ def held_answer(request, raw):
2538
2735
  import datetime
2539
2736
  models, structures = sys.modules["requests.models"], sys.modules["requests.structures"]
2540
2737
  kind = request.headers.get("content-type", "")
@@ -2546,6 +2743,12 @@ def _install():
2546
2743
  return response
2547
2744
 
2548
2745
  def sent(self, request, *a, **k):
2746
+ g = guarded(request.url, request.method)
2747
+ if g is not None:
2748
+ clone = None if g.get("hold") else (g["copy"] or copy_of(g, request.headers.get))
2749
+ if clone is None:
2750
+ return held_answer(request, text(request.body, reply_max))
2751
+ request.url = to_copy(request.url, clone, g["call"])
2549
2752
  answered = held(request)
2550
2753
  if answered is not None:
2551
2754
  return answered
@@ -2601,7 +2804,18 @@ def _install():
2601
2804
  async def requested(self, method, str_or_url, *a, **k):
2602
2805
  body = k.get("json") if k.get("json") is not None else k.get("data")
2603
2806
  url = whole(self, str_or_url)
2604
- if held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max), url, lambda name: header_of(self, k, name)):
2807
+ g = guarded(url, method)
2808
+ hold = False
2809
+ if g is not None:
2810
+ clone = await copy_for(g, lambda name: header_of(self, k, name))
2811
+ if clone is None:
2812
+ hold = True
2813
+ else:
2814
+ parts = g["call"]["parts"]
2815
+ url = to_copy(url, clone, g["call"])
2816
+ # A session with a base address takes the path alone.
2817
+ str_or_url = url if getattr(self, "_base_url", None) is None else url[len("%s://%s" % (parts.scheme, parts.netloc)):]
2818
+ if hold or (held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max), url, lambda name: header_of(self, k, name))):
2605
2819
  port = held_port()
2606
2820
  if port is None:
2607
2821
  raise module.ClientConnectionError("this write was kept from leaving and could not be answered")
package/lib/qdrant.mjs CHANGED
@@ -5,8 +5,9 @@ export const QDRANT_CAP = 200_000;
5
5
  const BATCH = 256;
6
6
  const at = (name) => `/collections/${encodeURIComponent(name)}`;
7
7
 
8
+ // `conn.fetch`: the fetch to use, when the caller's global one is wrapped (the hook in the app).
8
9
  async function call(conn, method, path, body) {
9
- const res = await fetch(`${conn.base}${path}`, {
10
+ const res = await (conn.fetch ?? fetch)(`${conn.base}${path}`, {
10
11
  method,
11
12
  headers: { "content-type": "application/json", ...(conn.apiKey ? { "api-key": conn.apiKey } : {}) },
12
13
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -34,7 +35,7 @@ export async function cloneQdrant(conn, source, clone) {
34
35
  const page = await call(conn, "POST", `${at(source)}/points/scroll`, { limit: BATCH, with_payload: true, with_vector: true, ...(offset === undefined ? {} : { offset }) });
35
36
  if (page.points.length) await call(conn, "PUT", `${at(clone)}/points?wait=true`, { points: page.points.map(({ id, vector, payload }) => ({ id, vector, payload })) });
36
37
  offset = page.next_page_offset;
37
- if (offset === null || offset === undefined) return { how: "points" };
38
+ if (offset === null || offset === undefined) return { how: "points", points: info.points_count ?? 0 };
38
39
  }
39
40
  } catch (e) {
40
41
  if (made) await dropQdrant(conn, clone);
package/lib/reach.mjs CHANGED
@@ -4,7 +4,7 @@ import { plural, upper } from "./words.mjs";
4
4
 
5
5
  // One request to each endpoint status lists as not reached, from this machine to the app on its port.
6
6
  // Each body is the one Cortad read that endpoint's client sending (the server's `request`).
7
- // The connect command sends these by itself, one at a time, each tagged x-cortad-turn: reach:auto:<n>
7
+ // The connect command sends these by itself, up to four at a time, each tagged x-cortad-turn: reach:auto:<n>
8
8
  // (sendOurs), signed in only as a test account this machine made. Headers are never the agent's: they
9
9
  // go from here to the app on this machine and nowhere else, and only their names are printed. These
10
10
  // are real requests with real effects.
@@ -31,7 +31,7 @@ export function notUp(runner) {
31
31
  // The rows a call sends and the ones it leaves: the named paths, else every row with a ready request
32
32
  // that is not marked by hand.
33
33
  export function picked(contact, receipts, paths = []) {
34
- const rows = unreached(contact, receipts);
34
+ const rows = unreached(contact, receipts, { retests: true });
35
35
  const named = new Set(Array.isArray(paths) ? paths : []);
36
36
  const chosen = named.size ? rows.filter((m) => named.has(m.path) || named.has(keyOf(m))) : rows.filter((m) => !m.unsure);
37
37
  const sent = chosen.filter((m) => m.request);
@@ -72,15 +72,15 @@ export function reachText({ rows, sent, left, unknown }, answers, how) {
72
72
  ].filter(Boolean).join("\n");
73
73
  }
74
74
 
75
- // Cortad's own test requests, sent by the connect command: one at a time, so what the app prints
76
- // belongs to one of them, each with the app's own origin where one is known. An endpoint that answers
75
+ // Cortad's own test requests, sent by the connect command, each with the app's own origin where one is known. An endpoint that answers
77
76
  // 401 or 403 is asked once more as the test account this machine signed in, when there is one
78
77
  // (`signIn(row)` gives { name, value } or null); the token never leaves this machine.
79
78
  // `stop()`: true when the rest should wait, for the agent's ask; the answers so far come back.
80
79
  // Four at a time: one each in turn left a 22-endpoint app waiting nearly two minutes behind its two
81
80
  // slowest replies (ulaim, 2026-10-06). Answers keep the rows' order; a row is not sent once stop() says so.
81
+ // `onAnswer(answer, i)`: called as each row's answer lands, so one slow endpoint holds back no other's.
82
82
  const AT_ONCE = 4;
83
- export async function sendOurs(rows, { runner, origin, signIn = async () => null, next, stop = () => false, fetchImpl = fetch, atOnce = AT_ONCE }) {
83
+ export async function sendOurs(rows, { runner, origin, signIn = async () => null, next, stop = () => false, onAnswer = () => {}, fetchImpl = fetch, atOnce = AT_ONCE }) {
84
84
  const app = appUrl(runner);
85
85
  const base = () => ({ ...(origin ? { origin, referer: `${origin}/` } : {}), [RUN_TURN]: `reach:auto:${next()}` });
86
86
  const answers = [];
@@ -101,6 +101,7 @@ export async function sendOurs(rows, { runner, origin, signIn = async () => null
101
101
  if (stopped || stop()) { stopped = true; return; }
102
102
  const i = at++;
103
103
  answers[i] = await one(rows[i]);
104
+ try { onAnswer(answers[i], i); } catch { /* the answer stands whatever its listener did */ }
104
105
  }
105
106
  };
106
107
  await Promise.all(Array.from({ length: Math.min(atOnce, rows.length) }, lane));
@@ -179,7 +180,13 @@ export async function sendOne(m, app, headers, fetchImpl, secrets = []) {
179
180
  res = await fetchImpl(url, { method, headers: json && !typed ? { "content-type": "application/json", ...headers } : headers,
180
181
  ...(json ? { body: JSON.stringify(body) } : {}), redirect: "manual", signal: AbortSignal.timeout(REACH_MS) });
181
182
  } catch (err) {
182
- return { line: `${label}: ${failedSaid(err, app.port || 80)}`, ok: false, status: 0, down: /^(?:ECONNREFUSED|ECONNRESET)$/.test(err?.cause?.code ?? err?.code ?? "") };
183
+ const code = err?.cause?.code ?? err?.code ?? "";
184
+ // Refused, nothing reached the app, and a GET cut off changes nothing: both are sent again
185
+ // (`down`). A POST whose connection was reset may have been taken by the app before it went, so
186
+ // it is an answer nobody saw (`unknown`) and is never sent again by itself.
187
+ const unknown = code === "ECONNRESET" && method !== "GET";
188
+ return { line: `${label}: ${failedSaid(err, app.port || 80)}${unknown ? " It may have reached your app, so Cortad does not send it again by itself." : ""}`, ok: false, status: 0,
189
+ down: !unknown && /^(?:ECONNREFUSED|ECONNRESET)$/.test(code), ...(unknown ? { unknown: true } : {}) };
183
190
  }
184
191
  let text;
185
192
  try { text = await res.text(); } catch { return { line: `${label}: ${res.status}, and the answer had not finished after ${REACH_MS / 1000} seconds.`, ok: false, status: res.status }; }
package/lib/replay.mjs CHANGED
@@ -37,6 +37,8 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
37
37
  let newestTable = null;
38
38
  // Each server a hooked process opened a connection to, by host and port.
39
39
  const conns = new Map();
40
+ // The Qdrant collections the hook copied for a run's writes, by server and name (lib/stores.mjs drops them).
41
+ const copies = new Map();
40
42
  // The model's last words and status for each of a run's turns on an endpoint the app answers
41
43
  // later, by turn (lib/wire.cjs answeredLater).
42
44
  const answers = new Map();
@@ -48,7 +50,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
48
50
  // requires, to one without.
49
51
  const bunHook = /\s/.test(HOOK) ? (() => {
50
52
  const dir = mkdtempSync(join(tmpdir(), "cortad-"));
51
- for (const name of ["trace.cjs", "wire.cjs", "tied.cjs"]) copyFileSync(join(HERE, name), join(dir, name));
53
+ for (const name of ["trace.cjs", "wire.cjs", "tied.cjs", "qdrant.mjs"]) copyFileSync(join(HERE, name), join(dir, name));
52
54
  return join(dir, "trace.cjs");
53
55
  })() : HOOK;
54
56
  // The hooks are handed to whatever is started: a Node app loads the first, a Bun app the same file
@@ -96,6 +98,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
96
98
  if (Number.isInteger(row.listen)) { ports.add(row.listen); continue; }
97
99
  if (row.routes) { const t = routeTable(row.routes); if (t) { tables.set(t.port, t); newestTable = t; } continue; }
98
100
  if (row.conn) { const { host, port } = row.conn; if (typeof host === "string" && Number.isInteger(port)) conns.set(`${host}:${port}`, { host, port }); continue; }
101
+ if (row.copy) { const { base, collection, clone } = row.copy; if ([base, collection, clone].every((v) => typeof v === "string" && v)) copies.set(`${base}|${collection}`, { base, collection, clone }); continue; }
99
102
  if (row.answer) {
100
103
  const { turn, status, reply } = row.answer;
101
104
  if (TURN.test(turn ?? "")) answers.set(turn, { status: Number.isInteger(status) ? status : 0, reply: typeof reply === "string" ? reply : "" });
@@ -158,6 +161,8 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
158
161
  registry: (port) => { poll(); return tables.get(port) ?? newestTable ?? {}; },
159
162
  // The servers the app has connected to so far (lib/stores.mjs names the ones no setting does).
160
163
  connections: () => { poll(); return [...conns.values()]; },
164
+ // The collections the hook copied on a Qdrant server for a run's writes, to be deleted at the end.
165
+ copies: () => { poll(); return [...copies.values()]; },
161
166
  // The model's last words for a run's turn on an endpoint the app answers later, once the hook
162
167
  // has written them; null at `until`, or once `signal` aborts. `forget` drops a turn sent again.
163
168
  answered: async (turn, until, signal) => {
package/lib/spec.mjs CHANGED
@@ -11,10 +11,20 @@ export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import
11
11
  export const SPEC = "(?:cortad(?:@[A-Za-z0-9._-]+)?|/[A-Za-z0-9._/-]+)";
12
12
  const SAFE = new RegExp(`^${SPEC}$`);
13
13
 
14
+ // A pin older than this very version loses to it: a connect run as cortad@0.3.8 wrote a skill and an
15
+ // MCP server pinned to 0.3.7, and the agent asked whether the two were meant to mix.
14
16
  export function cliSpec({ env = process.env, version = VERSION } = {}) {
15
- if (env.CORTAD_CLI_SPEC && SAFE.test(env.CORTAD_CLI_SPEC)) return env.CORTAD_CLI_SPEC;
16
- return `cortad@${version}`;
17
+ const pinned = env.CORTAD_CLI_SPEC && SAFE.test(env.CORTAD_CLI_SPEC) ? env.CORTAD_CLI_SPEC : null;
18
+ if (!pinned) return `cortad@${version}`;
19
+ const released = /^cortad@(\d+\.\d+\.\d+)$/.exec(pinned)?.[1];
20
+ return released && newer(version, released) ? `cortad@${version}` : pinned;
17
21
  }
22
+ const newer = (a, b) => {
23
+ if (!/^\d+\.\d+\.\d+$/.test(a)) return false;
24
+ const [x, y] = [a, b].map((v) => v.split(".").map(Number));
25
+ for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return x[i] > y[i];
26
+ return false;
27
+ };
18
28
 
19
29
  // npx needs -y to fetch a package without asking; a local folder is not fetched.
20
30
  export const npxArgs = (spec) => (isAbsolute(spec) ? [spec] : ["-y", spec]);
package/lib/stores.mjs CHANGED
@@ -249,6 +249,12 @@ const heldWhy = (s, why, tool) => {
249
249
  const [reason, fix] = (HELD[why] ?? HELD.other)(s, tool);
250
250
  return `${listed(s.names)} ${s.names.length === 1 ? "points" : "point"} at ${s.engine === "redis" || s.db ? "the" : "a"} ${whatOf(s)} off this machine, and ${reason}, so the run would write into it as it is. ${fix}, then run the connect command again.`;
251
251
  };
252
+ // A Qdrant server the app writes into is kept by the hook in the app instead of by a copy made here:
253
+ // the first write a run makes into a collection copies that collection on the same server and the
254
+ // app's later calls go to the copy (lib/trace.cjs, lib/pyhook/sitecustomize.py). So a hosted Qdrant
255
+ // holds nothing, and a local one whose collection no setting names is not a risk either.
256
+ export const GUARDED_ENGINES = new Set(["qdrant"]);
257
+ const guardedKept = (s) => `Your app's ${ENGINE[s.engine]} collections at ${listed(s.names)} are not written into by a run: the first time a run writes into one, that collection is copied on the same server and the app's calls go to the copy, which is deleted when this command ends.`;
252
258
  const hostedKept = (s, got) => (got.how === "neon"
253
259
  ? `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 and made again from the original at the next connect.`
254
260
  : got.how === "supabase"
@@ -259,7 +265,7 @@ export const storeId = (s) => createHash("sha256").update(s.key).digest("hex").s
259
265
  // A store off this machine the app was pointed at a copy of and reached anyway (dotenv's override):
260
266
  // trials write into the original, so it holds Run. `blocked`: the rows already holding it.
261
267
  export const bypassedOf = (connections, stores, blocked) => stores
262
- .filter((s) => !onThisMachine(s.host) && !blocked.some((b) => b.id === storeId(s)) && connections.some((c) => hostKey(c.host) === hostKey(s.host)))
268
+ .filter((s) => !onThisMachine(s.host) && !GUARDED_ENGINES.has(s.engine) && !blocked.some((b) => b.id === storeId(s)) && connections.some((c) => hostKey(c.host) === hostKey(s.host)))
263
269
  .map((s) => ({ id: storeId(s), names: s.names, why: heldWhy(s, "bypassed") }));
264
270
 
265
271
  // A copy that throws is one that did not go through, said as such; it never stops the app starting.
@@ -292,6 +298,12 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
292
298
  const pass = [];
293
299
  for (const s of stores) {
294
300
  if (done.has(s.key)) continue;
301
+ if (GUARDED_ENGINES.has(s.engine) && (!onThisMachine(s.host) || !s.points.length || !s.db)) {
302
+ if (await down(s)) continue;
303
+ done.add(s.key);
304
+ copies.push(guardedKept(s));
305
+ continue;
306
+ }
295
307
  if (!onThisMachine(s.host)) {
296
308
  done.add(s.key);
297
309
  const got = started ? await hosted.keep(s).catch(() => ({ why: ["other"] })) : { why: ["started"] };
@@ -329,6 +341,21 @@ export function storeKeeper({ ledgerFile, values = {}, dirs = [], fetchFn, local
329
341
  for (const { s, row } of made.splice(0)) if (await drop(s, row.made, row.marker)) book.remove(row);
330
342
  await hosted.drop();
331
343
  },
344
+ // The collections the hook in the app copied for a run's writes (lib/trace.cjs copyOf), deleted
345
+ // with the key the settings hold for that server. Each is in the ledger from the moment it is
346
+ // seen, so one a killed session left is deleted by the next.
347
+ // ponytail: a server no setting names a key for is asked without one; a copy that refuses stays
348
+ // under its _cortad_ name for the person to see. The key the app sent never leaves the app.
349
+ async dropCopies(copies, stores) {
350
+ const rows = copies.map((c) => ({ kind: "qdrant-copy", key: c.base, made: c.clone }));
351
+ for (const row of rows) book.add(row);
352
+ const hostOf = (base) => { try { return new URL(base).hostname; } catch { return base; } };
353
+ for (const row of [...rows, ...book.orphans("qdrant-copy")]) {
354
+ // The hook names the server with its port and QDRANT_URL may not: the key is the host's.
355
+ const conn = { base: row.key, apiKey: stores.find((s) => s.engine === "qdrant" && s.conn?.base && hostOf(s.conn.base) === hostOf(row.key))?.conn.apiKey };
356
+ if (await dropQdrant(conn, row.made)) book.remove(row);
357
+ }
358
+ },
332
359
  };
333
360
  }
334
361
 
@@ -341,7 +368,7 @@ export function unnamedHeld(connections, stores, blocked) {
341
368
  for (const { host, port } of connections) {
342
369
  const local = onThisMachine(host);
343
370
  const engine = local ? ENGINE_AT[port] : HOSTED.find(([, re]) => re.test(String(host).toLowerCase()))?.[0] ?? ENGINE_AT[port];
344
- if (!engine || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === `localhost:${port}` : s.engine === engine && !onThisMachine(s.host)))) continue;
371
+ if (!engine || GUARDED_ENGINES.has(engine) || stores.some((s) => (local ? `${hostKey(s.host)}:${s.port}` === `localhost:${port}` : s.engine === engine && !onThisMachine(s.host)))) continue;
345
372
  const id = createHash("sha256").update(`${engine}|${local ? "localhost" : String(host).toLowerCase()}|${port}`).digest("hex").slice(0, 16);
346
373
  if ([...blocked, ...rows].some((b) => b.id === id)) continue;
347
374
  // Named by its engine where a setting's name would stand: the API takes a name, never an address.
package/lib/text.mjs CHANGED
@@ -100,8 +100,16 @@ const runsSaid = (left, allowed) => (left > 0 ? `${num(left)} of ${plural(allowe
100
100
  // After the findings: what was measured of the app's own promises, in its own words, then the checks
101
101
  // measured and why the rest were not, then how many trials kept every promise. Counts, never a score.
102
102
  // The replies that did not count sit before the count: a clean count beside 62 error replies read as a clean bill.
103
+ // Their own eval files, as the run played them: one line for all, one per file.
104
+ function evalLines(evals) {
105
+ const t = evals?.total;
106
+ if (!t || !t.played) return [];
107
+ 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` : ""}`;
108
+ return [`Your own eval files: ${said(t)}.`, ...(evals.files ?? []).slice(0, 12).map((f) => ` ${f.file}: ${said(f)}.`)];
109
+ }
110
+
103
111
  function measuredLines(d) {
104
- const lines = [...promiseLines(d.promises), ...failedLines(d.failedReplies)];
112
+ const lines = [...promiseLines(d.promises), ...evalLines(d.evals), ...failedLines(d.failedReplies)];
105
113
  const of = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0;
106
114
  if (of) lines.push(`${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured.`);
107
115
  // The dissection says what was not measured in its own line.
@@ -392,7 +400,14 @@ function findingsTail(d) {
392
400
  function findingsPages(d, budget = PAGE_CHARS - 60) {
393
401
  // Unrecorded is not unused (dissection-text.mjs): no tool is said never to have run.
394
402
  const unseen = (d.tools ?? d.dissection?.tools)?.recorded === false;
395
- const blocks = [...(d.findings ?? []).map((f) => findingBlock(f, unseen)), findingsTail(d)].filter(Boolean);
403
+ // The findings from their own eval files are headed as theirs, once, where the first one stands.
404
+ let headed = false;
405
+ const blocks = [...(d.findings ?? []).map((f) => {
406
+ const block = findingBlock(f, unseen);
407
+ if (f.group !== "eval" || headed) return block;
408
+ headed = true;
409
+ return `From your own eval files:\n${block}`;
410
+ }), findingsTail(d)].filter(Boolean);
396
411
  const pages = [];
397
412
  let page = findingsHead(d);
398
413
  let filled = false;
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
@@ -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
@@ -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.
@@ -947,6 +948,7 @@ const keeping = makeKeeping({
947
948
  root, work, ledgerFile: join(homeOf(project), "made.json"), onPath, say, hold, typed: Boolean(flag("--start")),
948
949
  launch: (plan) => sidecar(plan.cmd, plan.cwd),
949
950
  connections: () => capture?.connections() ?? [],
951
+ copies: () => capture?.copies() ?? [],
950
952
  settings: () => {
951
953
  const from = {};
952
954
  for (const file of envFiles.filter((f) => !/\.(example|sample)$/.test(f))) {
@@ -1262,7 +1264,8 @@ const firstStart = startApp();
1262
1264
  firstStart.catch(() => { /* appLife reads the failure */ });
1263
1265
  const archive = join(work, "tree.tgz");
1264
1266
  step("connecting");
1265
- await exec("tar", ["-czf", archive, "-C", root, "-T", list]);
1267
+ // COPYFILE_DISABLE: a Mac's tar adds no "._name" entries.
1268
+ await exec("tar", ["-czf", archive, "-C", root, "-T", list], { env: { ...process.env, COPYFILE_DISABLE: "1" } });
1266
1269
  const bytes = readFileSync(archive);
1267
1270
  const PART = 1_500_000;
1268
1271
  let resumed = false;
@@ -1516,9 +1519,12 @@ const autoReach = makeAutoReach({
1516
1519
  stampOf: (m) => { try { return m.file ? String(statSync(join(root, m.file)).mtimeMs) : ""; } catch { return ""; } },
1517
1520
  asked: () => existsSync(join(homeOf(project), ASK)),
1518
1521
  report: async (answers) => {
1519
- if (!answers.length && reachedBox === box) return;
1522
+ if (!answers.length && reachedBox === box) return true;
1520
1523
  const to = box;
1521
- if ((await call("POST", `/local/${to}/reached`, { answers }, { timeoutMs: 15_000 })).ok) reachedBox = to;
1524
+ // False where the server did not take it: the answers are kept and said again (lib/auto-reach.mjs).
1525
+ const ok = Boolean((await call("POST", `/local/${to}/reached`, { answers }, { timeoutMs: 15_000 }).catch(() => null))?.ok);
1526
+ if (ok) reachedBox = to;
1527
+ return ok;
1522
1528
  },
1523
1529
  say,
1524
1530
  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.9",
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"
package/skill/SKILL.md CHANGED
@@ -34,22 +34,23 @@ The connect command prints "Go back to the browser" once it is connected. Cortad
34
34
  - each `For the person:` line, as printed.
35
35
  2. When `status` lists an endpoint as `Needs a signed-in user`, Cortad could not make a sign-in for it on this machine. Tell the person; when they ask for it, sign in a test account through the app's own sign-in on this machine, send that endpoint one request in that session, then call `status` again. That is the only sign-in this needs, and only for the endpoints listed that way.
36
36
  3. A failure on the app's side that every conversation would repeat, such as a model provider refusing, a retrieval with no answer or a tool returning an error: say what you would change, change it when the person agrees, then call `reach` to send Cortad's test request to that endpoint again.
37
- 4. The first run starts by itself once every endpoint has answered or failed; the person can also press Run on the card, or ask you, and then you call `run`. Before you stop, start `npx -y {{cortad}} wait` as a background command (in Claude Code: Bash with run_in_background). It returns when the next run ends, with what it found, and its ending wakes you. Then follow "After a run".
37
+ 4. The first run starts when the person presses Run on the card, or asks you, and then you call `run`. Before you stop, start `npx -y {{cortad}} wait` as a background command (in Claude Code: Bash with run_in_background). It returns when the next run or verify ends, with what it found, and its ending wakes you. Then follow "After a run".
38
38
 
39
39
  `status` with `show` lists one section in full: `rules`, `standards`, `journeys`, `endpoints`, `trials`, `records` or `reviews` (in a shell, `npx {{cortad}} status rules`). `status` with show `machine` says what Cortad does on this machine; hand the person its `For the person:` lines when they ask.
40
40
 
41
41
  ## After a run
42
42
 
43
- The person asked for this when they connected Cortad: fix what a run found and check each fix.
43
+ The run is Cortad's; the code is the person's. You report first, and you change nothing until they say so.
44
44
 
45
- 1. Tell the person in three lines: how many conversations played; the run's "All N conversations" line as printed, how many completed their request fully or partly with its rate and range; the two worst problems at their file and line.
46
- 2. Call `findings` and fix the worst problem: one change, in the file and near the line it names. Then `verify <findingId>` and follow it with `run_status` until it decides.
47
- 3. Go on to the next problem until each one is fixed or needs a decision only the person can make; tell them that decision in one line.
48
- 4. After the last verify, tell the person what each verify decided, then start `npx -y {{cortad}} wait` in the background again.
45
+ 1. Call `findings`. Tell the person, in plain lines: how many conversations played and how many completed their request, with the rate and range as printed; each problem worth their time, worst first, with its file and line and what you would change there in one line; which findings read their app wrong (see "A check that reads wrong"); and which problems need a decision only they can make.
46
+ 2. Then stop, and let them answer. Until their go, nothing is edited, no verify starts and no run starts. They choose which problems to fix, in what order, and whether the change you propose fits their product.
47
+ 3. On their go, one problem at a time: one change, in the file and near the line the finding names, then `verify <findingId>`. Its first line says how many conversations the round replays and what that counts against this month; start `npx -y {{cortad}} wait` in the background, and it wakes you when the verify ends, so `run_status` is called once at most.
48
+ 4. Tell them what the verify decided, in its own words; the next change comes after their next go. A verify that cannot tell offers another round in its last line; it plays only when you call `verify` again, and only when the person wants it.
49
+ 5. When they stop, say what changed, what each verify decided, and what is still open, then start `npx -y {{cortad}} wait` in the background again.
49
50
 
50
51
  ## Following a run
51
52
 
52
- `run` answers within a second; when the app is still starting it says so, and `run_status` holds until the run has an id. `run_status` follows the run this machine started last, or the latest run. Each call holds up to 45 seconds, returns as soon as the count moves, and ends with the next call; call it again rather than a shell loop. Hand the person its `For the person:` line at most once every two minutes. When the plan is spent, `run` says so and nothing runs.
53
+ `run` answers within a second; when the app is still starting it says so, and `run_status` holds until the run has an id. `run_status` follows the run this machine started last, or the latest run. Each call holds up to 45 seconds, returns as soon as the count moves, and ends with the next call; `npx -y {{cortad}} wait` in the background does the following for you and wakes you at the end of a run or a verify. Hand the person its `For the person:` line at most once every two minutes. When the plan is spent, `run` says so and nothing runs.
53
54
 
54
55
  ## The first run: the baseline
55
56
 
@@ -65,8 +66,8 @@ The first finished run on a repository prints, in order: what ran; the simulated
65
66
 
66
67
  ## Fixing one finding
67
68
 
68
- 1. One change, in the file and near the line the finding names.
69
- 2. `verify <findingId>` replays the conversations the finding failed on, word for word, against the saved edit; Cortad starts the app again first when its code changed. Undecided after a round, it plays another by itself, and `run_status` follows every round under the same id. A `crash:N` finding replays the requests that were out when the app stopped.
69
+ 1. One change, in the file and near the line the finding names, after the person's go.
70
+ 2. `verify <findingId>` replays the conversations the finding failed on, word for word, against the saved edit; Cortad starts the app again first when its code changed. Its first line says how many conversations the round replays and what they count against. Undecided after a round, it says what another round would play; that round starts only when `verify` is called again. A `crash:N` finding replays the requests that were out when the app stopped.
70
71
  3. Read the verdict. Replies the app refused or failed on the replay come first.
71
72
  - Worse on the conversations saved to test the fix: undo the change, and aim the next one at the behavior the question asks about.
72
73
  - Gone on the saved conversations while its own still fail: keep the fix; a reply that changed is another problem to fix next.
@@ -98,7 +98,7 @@ Rules in the code: 16, in 5 files.
98
98
  Sends Cortad's test request again, from the connect command on this machine, after a fix:
99
99
 
100
100
  ```
101
- Sent 1 request to your app on port 3100, one at a time:
101
+ Sent 1 request to your app on port 3100, up to four at a time:
102
102
  POST /api/homework/explain: 200 in 2.4 seconds.
103
103
  status shows which of them reached your model.
104
104
  next: status