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/auto-reach.mjs +54 -8
- package/lib/calls.mjs +25 -13
- package/lib/contact-text.mjs +23 -37
- package/lib/keeping.mjs +9 -4
- package/lib/mcp.mjs +11 -8
- package/lib/pyhook/sitecustomize.py +219 -5
- package/lib/qdrant.mjs +3 -2
- package/lib/reach.mjs +13 -6
- package/lib/read-text.mjs +15 -15
- package/lib/replay.mjs +6 -1
- package/lib/spec.mjs +12 -2
- package/lib/stores.mjs +31 -4
- package/lib/text.mjs +34 -18
- package/lib/trace.cjs +118 -15
- package/lib/verbs.mjs +8 -5
- package/lib/wire.cjs +25 -1
- package/local.mjs +18 -10
- package/package.json +1 -1
- package/skill/SKILL.md +30 -17
- package/skill/references/results.md +18 -18
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
|
|
8
|
-
//
|
|
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
|
-
|
|
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(`
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 "
|
|
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
|
|
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(`
|
|
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 `
|
|
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}\
|
|
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(`
|
|
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
|
-
|
|
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 ? [`
|
|
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
|
-
|
|
882
|
-
// sent,
|
|
883
|
-
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
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
|
-
|
|
953
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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