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/auto-reach.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
//
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
}
|
package/lib/contact-text.mjs
CHANGED
|
@@ -93,46 +93,29 @@ function doorLines(v) {
|
|
|
93
93
|
return [...lines, ...inner.filter(Boolean).map((l) => ` ${l}`)];
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
// What a run would play, take and cost,
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
96
|
+
// What a run would play, take and cost, in one short line the agent hands the person: how many
|
|
97
|
+
// simulated users, about how long, and one price on their own key. A first run's price is what it is
|
|
98
|
+
// expected to cost (`expected`, from factors measured on recorded first runs); the most it could cost,
|
|
99
|
+
// the follow-ups and the pace at one request at a time were three more numbers nobody acted on.
|
|
100
100
|
function readyLine(r) {
|
|
101
101
|
if (!r || !r.trials) return null;
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
// a range when that is slower, and where fewer conversations fit at that pace, how many is said.
|
|
108
|
-
const upTo = has(r.atOne?.minutes) && r.atOne.minutes > r.minutes ? r.atOne.minutes : null;
|
|
109
|
-
const atOne = has(r.atOne?.trials) && r.atOne.trials < r.trials ? ` If your app answers one request at a time, about ${num(r.atOne.trials)} of them fit in that time.` : "";
|
|
110
|
-
const minutes = has(r.minutes) && `in about ${upTo ? `${num(r.minutes)} to ${plural(upTo, "minute")}` : plural(r.minutes, "minute")}`;
|
|
111
|
-
const size = [has(r.replies) && `about ${plural(r.replies, "reply", "replies")}`, minutes].filter(Boolean).join(" ");
|
|
112
|
-
const usd = (x) => `$${x.toFixed(2)}`;
|
|
113
|
-
// Some endpoints had no published rate or token counts: the price covers the others and says so.
|
|
114
|
-
// A first run is priced at what it is expected to cost (`expected`, from factors measured on recorded
|
|
115
|
-
// first runs) and the most it costs (`upTo`); a server from before `expected` sends the most alone.
|
|
116
|
-
// After a run of the app's own, as what that run measured. An older server sends a range (`usdHigh`).
|
|
117
|
-
// Where some endpoints had no price, on how many.
|
|
118
|
-
const amount = () => `${r.upTo ? "up to" : "about"} ${usd(r.usd)}${has(r.usdHigh) ? ` to ${usd(r.usdHigh)}` : ""}`;
|
|
119
|
-
const some = r.pricedOn ? `, on the ${num(r.pricedOn.endpoints)} of ${plural(r.pricedOn.of, "endpoint")} Cortad could price` : "";
|
|
120
|
-
const onKey = `on your ${r.provider ? `${r.provider} ` : ""}key${r.model ? ` for ${r.model}` : ""}`;
|
|
121
|
-
const both = r.upTo && has(r.expected);
|
|
122
|
-
// No price is said, as the card says it (guide.ts quoteLine), never left out.
|
|
123
|
-
const cost = has(r.usd)
|
|
124
|
-
? ` ${both ? `It is expected to cost ${usd(r.expected)}${soFar} ${onKey}, and the most it costs is ${usd(r.usd)}` : `It costs ${amount()}${soFar} ${onKey}`}${some}${r.estimated ? ", estimated from the length of the text since your provider sent no token counts" : ""}.`
|
|
125
|
-
: " It runs on your app's own model keys; Cortad could not price it from what your app's model calls reported.";
|
|
126
|
-
// No rules is a fact of a finished read: said while the code is still being read, it was false a minute later.
|
|
127
|
-
const rules = !has(r.rules) || (r.reading && r.rules === 0) ? "" : r.rules === 0 ? " No rules of your own were read from the code, so replies are checked against general standards only." : ` Every reply is checked against the ${plural(r.rules, "rule")} read from your code.`;
|
|
128
|
-
return `For the person: a run plays ${plays}${followUps}${size ? `: ${size}` : ""}.${atOne}${cost}${rules}${r.reading ? " Your code is still being read; the run waits for it and plays every conversation it adds." : ""}`;
|
|
102
|
+
const plays = `${plural(r.trials, "simulated user")}${r.reading ? " so far" : ""}`;
|
|
103
|
+
const minutes = has(r.minutes) ? ` in about ${plural(r.minutes, "minute")}` : "";
|
|
104
|
+
const key = `your ${r.provider ? `${r.provider} ` : ""}key`;
|
|
105
|
+
const price = has(r.usd) ? `About $${(r.upTo && has(r.expected) ? r.expected : r.usd).toFixed(2)} on ${key}.` : `It runs on ${key}.`;
|
|
106
|
+
return `A run plays ${plays}${minutes}. ${price}`;
|
|
129
107
|
}
|
|
108
|
+
// No rules is a fact of a finished read, said before a run is spent on general standards alone: said
|
|
109
|
+
// while the code is still being read, it was false a minute later.
|
|
110
|
+
const noRulesLine = (r) => (r?.trials && r.rules === 0 && !r.reading ? "No rules of your own were read from the code, so replies are checked against general standards only." : null);
|
|
130
111
|
|
|
131
112
|
// The endpoints no request has reached, as status lists them and reach sends them. `receipts`: doors
|
|
132
113
|
// the App section says answered at once with their answer out of Cortad's sight, so not said here.
|
|
133
|
-
|
|
114
|
+
// `retests`: the doors whose test failed, listed again to be sent once more: reach takes them, the
|
|
115
|
+
// list of endpoints no request reached does not.
|
|
116
|
+
export const unreached = (c, receipts = [], { retests = false } = {}) => {
|
|
134
117
|
const answered = new Set(receipts.map((x) => x.door));
|
|
135
|
-
return (c?.notCalled ?? []).filter((m) => !answered.has(`${m.method} ${m.path}`));
|
|
118
|
+
return (c?.notCalled ?? []).filter((m) => !answered.has(`${m.method} ${m.path}`) && (retests || !m.retest));
|
|
136
119
|
};
|
|
137
120
|
|
|
138
121
|
// One the server has no request for, or one listed on a field's name alone, is the agent's to send.
|
|
@@ -155,12 +138,15 @@ export function contactLines(c, data = [], receipts = []) {
|
|
|
155
138
|
const said = data.map((s) => `Data: ${s}`);
|
|
156
139
|
if (!c) return said;
|
|
157
140
|
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
|
-
|
|
141
|
+
// No request yet is said by the App line and by the next step, not a third time here. Endpoints
|
|
142
|
+
// that all answered only Cortad's own test request say so once, over the list: sixteen of them
|
|
143
|
+
// each said it under their own line, and the agent asked for a status it could read.
|
|
144
|
+
const alike = proven.length > 1 && proven.every((v) => v.standing === "fallback") && new Set(proven.map((v) => v.why)).size === 1;
|
|
145
|
+
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)}):`;
|
|
146
|
+
const lines = proven.length ? [head, ...proven.flatMap((v) => doorLines(alike ? { ...v, standing: null } : v))] : [];
|
|
160
147
|
const missing = unreached(c, receipts);
|
|
161
148
|
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
|
-
const
|
|
163
|
-
if (ready) lines.push(ready);
|
|
149
|
+
for (const line of [readyLine(c.ready), noRulesLine(c.ready)]) if (line) lines.push(line);
|
|
164
150
|
// What holds Run is the server's next line (src/local/contact.ts heldLine), said once: printed here
|
|
165
151
|
// as well, ulaim's hold on its Qdrant was read twice in one status.
|
|
166
152
|
return [...lines, ...said];
|
package/lib/keeping.mjs
CHANGED
|
@@ -38,19 +38,21 @@ 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;
|
|
45
45
|
let backedUp = false;
|
|
46
46
|
let seen = 0;
|
|
47
47
|
const tell = (lines) => { for (const line of lines) say(lower(line)); };
|
|
48
|
-
const held = (blocked) => tell(blocked.map((b) => `
|
|
48
|
+
const held = (blocked) => tell(blocked.map((b) => `One question for the person on the card in the browser: ${lower(b.why)}`));
|
|
49
49
|
|
|
50
50
|
async function keepAll(started) {
|
|
51
51
|
if (typed) {
|
|
52
52
|
const none = async () => ({ env: {}, risks: [], copies: [], blocked: [], pass: [] });
|
|
53
|
-
|
|
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
|
-
|
|
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";
|
|
@@ -9,8 +10,8 @@ export const PROTOCOL = "2025-06-18";
|
|
|
9
10
|
// Codex keeps only the first 512 characters self-contained and Claude Code shares about 4 KB across
|
|
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
|
-
"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
|
|
13
|
-
`Tell the person what status found in
|
|
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 says whether the app is up, what Cortad's audit of the AI code found, and what a run would play and cost. A run starts when the person presses Run in the browser or asks you.",
|
|
14
|
+
`Tell the person what status found in three or four short, plain lines, leaving out file paths, endpoints, status codes and setting names unless they ask. 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 their users ran into and what you would change, worst first, three problems at most; 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
|
|
|
@@ -22,7 +23,7 @@ const ABOUT = ["status", "reach", "run", "run_status", "findings", "verify", "di
|
|
|
22
23
|
|
|
23
24
|
export const TOOLS = [
|
|
24
25
|
{ name: "status", title: "Cortad status", annotations: readOnly,
|
|
25
|
-
description: "What Cortad found in this repository: whether the app is up and on which port,
|
|
26
|
+
description: "What Cortad found in this repository: whether the app is up and on which port, its audit of the prompts and of the code around the model, each endpoint's answer to Cortad's test request or the file and line it failed at, the plan with runs left, and the latest run. Applies at the start of a session, right after a connect, and before a commit that changes prompts, tools, models or retrieval. With show, one section in full, in pages.",
|
|
26
27
|
inputSchema: { type: "object", properties: { show: { type: "string", enum: SHOW, description: "A section to list in full: rules, standards, journeys, endpoints, trials, records or reviews; or machine, what Cortad does on this machine." }, page: { type: "integer", minimum: 1, description: "The page of that list. Default 1." } }, additionalProperties: false } },
|
|
27
28
|
{ name: "reach", title: "Send Cortad's test requests again",
|
|
28
29
|
description: "Sends Cortad's test request again to the endpoints status lists as failed or not yet answered, or to the paths named, from this machine to the app on its port. Each endpoint comes back with its HTTP status, its time and, when it is not 2xx, the start of what the app answered.",
|
|
@@ -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
|
-
|
|
73
|
-
|
|
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
|
}
|