@cortad/connector 0.6.1 → 0.6.3

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/mcp.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // The MCP face: newline-delimited JSON-RPC 2.0 on stdin and stdout, protocol 2025-06-18, no
2
2
  // dependency. stdout carries protocol messages and nothing else; everything a person might read
3
- // goes to stderr. The tools are the six doors of lib/doors.mjs; the names they replaced still
3
+ // goes to stderr. The tools are the seven doors of lib/doors.mjs; the names they replaced still
4
4
  // answer, so an agent handed an older skill is told where to go instead of getting nothing.
5
5
 
6
6
  import { randomUUID } from "node:crypto";
@@ -11,12 +11,12 @@ export const PROTOCOL = "2025-06-18";
11
11
  // Codex keeps only the first 512 characters self-contained and Claude Code shares about 4 KB across
12
12
  // every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
13
13
  export const instructions = (spec = "cortad") => [
14
- "Cortad tests the AI in this repository, then watches it in production with the same questions: simulated users written from its code talk to the app on this machine, and each reply is asked its rules, journeys and standards. Connect the app with its start command after `--`; Cortad sends each chosen AI endpoint its test request and learns it from the answer. You send one message only where status says an endpoint needs a signed-in user. A run starts when the person presses Run simulation or asks you.",
15
- `status says what to know right now. 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. test is the one call after a change: say what you built and Cortad decides what kind of test that is. After a run, status gives you what their users ran into; tell the person what you would change, worst first, three problems at most, and nothing is edited before their go. On their go, one fix at a time: verify replays the failing conversations, says whether the failure is gone, and says how long the last fix for it held. status about production is the live app and what is wrong in it; connect connects it. dispute records a question that reads this app wrong, and tell sends the Cortad team a note.`,
14
+ "Cortad tests the AI in this repository and reads how it behaves with its users: simulated users written from its code talk to the app on this machine, and the live app's real conversations are read with the same questions. Connect the app with its start command after `--`; Cortad sends each chosen AI endpoint its test request and learns it from the answer. You send one message only where status says an endpoint needs a signed-in user. A run starts when the person presses Run simulation or asks you.",
15
+ `status says where things stand. 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. test is the one call after a change: say what you built. After a run, behavior says how the AI behaved with their users: tell the person persona by persona, in plain words, who they are, what they want, who achieved it, what the app did well and where it gave way; then what you would change and why that follows. Nothing is edited before their go. On their go, one fix at a time: verify replays the conversations a problem happened in and says whether the behavior moved. behavior with against compares two points and with history is the log; search finds conversations by meaning. connect connects the live app; tell sends a note, or says a question reads this app wrong.`,
16
16
  ].join("\n\n");
17
17
  export const INSTRUCTIONS = instructions();
18
18
 
19
- // Every name tools/call answers: the six doors and the names they replaced.
19
+ // Every name tools/call answers: the seven doors and the names they replaced.
20
20
  const NAMES = new Set([...DOOR_NAMES, ...RETIRED]);
21
21
 
22
22
  const error = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
@@ -0,0 +1,48 @@
1
+ // What one message says, whichever provider's shape it is in: OpenAI chat and Responses parts,
2
+ // Anthropic and Bedrock blocks, Gemini parts. Text stays text; what is not text becomes a plain marker
3
+ // ("[image]", "[file receipt.pdf]", "[tool call lookup_order {...}]") so a reader knows it was there.
4
+ // The same rules as the SDKs' partText and the backend's src/ingest/message-text.ts.
5
+ const ARGS_MAX = 300;
6
+
7
+ const args = (v) => {
8
+ const s = typeof v === "string" ? v : JSON.stringify(v ?? {});
9
+ return s.length > ARGS_MAX ? `${s.slice(0, ARGS_MAX)}...` : s;
10
+ };
11
+ const named = (kind, name) => (typeof name === "string" && name ? `[${kind} ${name}]` : `[${kind}]`);
12
+ const IMAGE = /^(?:image|image_url|input_image|image_file)$/;
13
+ const FILE = /^(?:file|input_file|document)$/;
14
+ const AUDIO = /^(?:audio|input_audio)$/;
15
+ // A model's reasoning and a provider's own tools (a web search it ran, the page it fetched): not what was said.
16
+ const INTERNAL = /^(?:thinking|redacted_thinking|reasoning|server_tool_use|mcp_tool_use|\w+_tool_result|(?:web_search|file_search|code_interpreter|image_generation|computer|mcp)_call)$/;
17
+ const mime = (data) => String(data?.mimeType ?? data?.mime_type ?? "");
18
+
19
+ function partText(part) {
20
+ if (typeof part === "string") return part;
21
+ if (!part || typeof part !== "object") return "";
22
+ const type = typeof part.type === "string" ? part.type : "";
23
+ if (INTERNAL.test(type)) return "";
24
+ if (typeof part.text === "string") return part.text;
25
+ if (typeof part.refusal === "string") return part.refusal;
26
+ if (IMAGE.test(type) || part.image) return "[image]";
27
+ if (AUDIO.test(type) || part.audio) return "[audio]";
28
+ if (FILE.test(type) || part.document) return named("file", part.file?.filename ?? part.filename ?? part.title ?? part.document?.name);
29
+ const inline = part.inlineData ?? part.inline_data ?? part.fileData ?? part.file_data;
30
+ if (inline) return /^image\//.test(mime(inline)) ? "[image]" : /^audio\//.test(mime(inline)) ? "[audio]" : named("file", mime(inline));
31
+ const call = type === "tool_use" || type === "function_call" ? part : part.toolUse ?? part.functionCall;
32
+ if (call) return `[tool call ${call.name ?? "?"} ${args(call.input ?? call.args ?? call.arguments)}]`;
33
+ if (type === "tool_result" || type === "function_call_output") return messageText(part.content ?? part.output);
34
+ const result = part.toolResult ?? part.functionResponse;
35
+ if (result) return messageText(result.content ?? result.response);
36
+ if (type === "message" || (!type && "content" in part)) return messageText(part.content);
37
+ return type ? `[${type}]` : "";
38
+ }
39
+
40
+ function messageText(content) {
41
+ if (content == null) return "";
42
+ if (typeof content === "string") return content;
43
+ if (Array.isArray(content)) return content.map(partText).filter(Boolean).join("\n");
44
+ if (typeof content === "object") return partText(content) || JSON.stringify(content);
45
+ return String(content);
46
+ }
47
+
48
+ module.exports = { messageText };
package/lib/proxy.mjs CHANGED
@@ -28,6 +28,9 @@ const SECRET_SETTING = /KEY|TOKEN|SECRET|PASSWORD/i;
28
28
  const HOP = /^(?:host|connection|keep-alive|transfer-encoding|upgrade|te|trailer|proxy-.*|content-length|accept-encoding)$/i;
29
29
  const REPLY_HOP = /^(?:connection|keep-alive|transfer-encoding|content-length|content-encoding)$/i;
30
30
 
31
+ // A tunnel names its host alone: of Amazon's and Google's, only their model services.
32
+ const modelHost = (h) => (/amazonaws\.com$/i.test(h) ? /^bedrock/i.test(h) : /googleapis\.com$/i.test(h) ? /generativelanguage|aiplatform/i.test(h) : MODEL_HOST.test(h));
33
+
31
34
  // The person's words in a request, a form's fields as much as JSON's.
32
35
  const asked = (raw) => (/^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/.test(raw) ? [...new URLSearchParams(raw).values()].flatMap(tied.sentencesIn) : tied.sentencesIn(raw));
33
36
  // A request whose reply said something of its own, to words the person sent, with no model call begun
@@ -167,6 +170,23 @@ export async function openProxy({ file, rulesFile, target, modelPort: wantedMode
167
170
  };
168
171
  // An app that hangs up while it sends a call ends that call, never this command.
169
172
  const model = createServer((req, res) => { relay(req, res).catch(() => res.destroy()); });
173
+ // HTTPS_PROXY: the app's TLS goes through as a sealed tunnel, so an address written in its code is
174
+ // seen and a signed request (Bedrock's SigV4) reaches its provider byte for byte. What a sealed
175
+ // call said and spent is not known here, so it counts as seen and writes no meter row.
176
+ model.on("connect", (req, socket, head) => {
177
+ const at = /^([^\s:]+|\[[^\]]+\]):(\d{1,5})$/.exec(req.url || "");
178
+ if (!at) { socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); return; }
179
+ const host = at[1].replace(/^\[|\]$/g, "");
180
+ if (modelHost(host) && ++calls === 1) onSeen();
181
+ const up = connect({ host, port: Number(at[2]) }, () => {
182
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
183
+ if (head?.length) up.write(head);
184
+ socket.pipe(up).pipe(socket);
185
+ });
186
+ const end = () => { socket.destroy(); up.destroy(); };
187
+ up.on("error", () => { if (!socket.destroyed) socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); end(); });
188
+ socket.on("error", end);
189
+ });
170
190
 
171
191
  const frontPort = await listen(front);
172
192
  const modelPort = await listen(model, wantedModelPort);
@@ -181,6 +201,11 @@ export async function openProxy({ file, rulesFile, target, modelPort: wantedMode
181
201
  // The app's model settings, pointed here: each SDK's own, and any other of the app's that addresses a model.
182
202
  env(base) {
183
203
  const out = {};
204
+ // A proxy of the app's own stays; loopback is never tunnelled, so the settings above reach this proxy direct.
205
+ if (!base.HTTPS_PROXY && !base.https_proxy) {
206
+ out.HTTPS_PROXY = out.https_proxy = `http://127.0.0.1:${modelPort}`;
207
+ out.NO_PROXY = out.no_proxy = [base.NO_PROXY || base.no_proxy, "localhost,127.0.0.1,::1,host.docker.internal"].filter(Boolean).join(",");
208
+ }
184
209
  for (const [name, fallback] of Object.entries(DEFAULTS)) out[name] = via(base[name] || fallback);
185
210
  for (const [name, value] of Object.entries(base)) {
186
211
  if (name in out || SECRET_SETTING.test(name) || typeof value !== "string") continue;
package/lib/replay.mjs CHANGED
@@ -363,7 +363,9 @@ function tally(totals, call, count) {
363
363
  t.completionTokens += count(call.completionTokens);
364
364
  // An answered call the provider sent no counts for: it was spent, and what it cost is not known.
365
365
  const status = count(call.status);
366
- if (call.usage !== true && status > 0 && status < 400) t.unmetered += 1;
366
+ // Counted from its words (lib/wire.cjs estimateOf): priced, and said as an estimate.
367
+ if (call.estimated === true) t.estimated = (t.estimated ?? 0) + 1;
368
+ else if (call.usage !== true && status > 0 && status < 400) t.unmetered += 1;
367
369
  totals.set(key, t);
368
370
  }
369
371
 
@@ -0,0 +1,19 @@
1
+ // What the waiting coding agent is handed when the person answers the setup card: the three steps,
2
+ // in order, with nothing left to ask them. `origin`: the Cortad this command talks to where it is not
3
+ // cortad.com, said on every step so each one reaches the same place.
4
+ export function askedText({ messages, source }, spec, origin = null) {
5
+ const n = Number(messages).toLocaleString("en-US");
6
+ const npx = `${origin ? `CORTAD_ORIGIN=${origin} ` : ""}npx -y ${spec}`;
7
+ const run = (rest) => `${npx} intelligence${rest ? ` ${rest}` : ""}`;
8
+ const from = source
9
+ ? run(`history --from ${source} --messages ${messages}`)
10
+ : `the export described in the output of step 1, piped to ${run(`history --stdin --messages ${messages}`)}`;
11
+ return [
12
+ `The person asked Cortad, on the setup card, to read their users' newest ${n} messages. They have answered: do not ask them which source or how many.`,
13
+ `1. ${run("")}: it puts Cortad's keys in this folder's env. Skip the question it prints about history.`,
14
+ `2. ${from}`,
15
+ `3. ${run("")} again: it prints the change to make in this code. Make it on a new branch named cortad-intelligence, commit it, then run ${run("")} once more so the card sees the change. Show the person the diff; nothing live changes until they deploy.`,
16
+ `Then start ${npx} wait in the background again.`,
17
+ `next: ${run("")}`,
18
+ ].join("\n");
19
+ }
@@ -0,0 +1,35 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ // What this folder tells the setup card in the browser: which history tool its env names, how far the
4
+ // history send has got, and whether each code change is in the working tree yet. A report that does
5
+ // not land changes nothing here, so it never fails the command it rides on.
6
+
7
+ // Cortad in the backend: the SDK imported in the app's own code, not only installed.
8
+ const SDK = String.raw`from ["']@cortad/sdk["']|require\(["']@cortad/sdk["']\)|from cortad import|^[[:space:]]*import cortad([^_[:alnum:]]|$)`;
9
+ // Cortad's page script, where the app serves its pages.
10
+ const TAG = String.raw`b\.cortad\.com/i/cortad\.js`;
11
+ const NOT_CODE = [":(exclude)*package.json", ":(exclude)*lock*", ":(exclude)*requirements*.txt", ":(exclude)*pyproject.toml", ":(exclude).cortad"];
12
+
13
+ const inTree = (root, pattern) => {
14
+ try {
15
+ execFileSync("git", ["-C", root, "grep", "-q", "-I", "-E", "--untracked", "-e", pattern, "--", ".", ...NOT_CODE], { stdio: "ignore", timeout: 15_000 });
16
+ return true;
17
+ } catch { return false; }
18
+ };
19
+
20
+ // Each change as the working tree holds it now, tracked or not yet committed; a folder that is not a
21
+ // git checkout reads as neither.
22
+ export const changesIn = (root) => ({ sdk: inTree(root, SDK), tag: inTree(root, TAG) });
23
+
24
+ export async function report({ apiBase, token, fetchImpl = fetch }, body) {
25
+ if (!token) return false;
26
+ try {
27
+ const res = await fetchImpl(`${apiBase}/mcp/setup/live`, {
28
+ method: "POST",
29
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
30
+ body: JSON.stringify(body),
31
+ signal: AbortSignal.timeout(10_000),
32
+ });
33
+ return res.ok;
34
+ } catch { return false; }
35
+ }
package/lib/start.mjs CHANGED
@@ -219,3 +219,25 @@ export const SHOWN_COMMAND = 200;
219
219
  export const shownCommand = (cmd, mask = (text) => text) => (cmd == null ? cmd
220
220
  : mask(String(cmd)).replace(/\b([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\S+)/g, (whole, name, value) => (/^[\w.-]{1,24}$/.test(value) ? whole : `${name}=***`)).slice(0, SHOWN_COMMAND));
221
221
 
222
+ // A container: the daemon runs the app, so nothing in this command's process family listens on its
223
+ // port, and 127.0.0.1 inside it is the container itself.
224
+ export const inContainer = (cmd = "") => /^\s*(?:sudo\s+)?(?:docker|podman)(?:-compose)?\b/.test(cmd);
225
+ // The host ports a `docker run -p 3204:8080` publishes: where the app answers on this machine.
226
+ export const publishedPorts = (cmd = "") => [...cmd.matchAll(/(?:^|\s)(?:-p|--publish)(?:\s+|=)(?:[\d.]+:)?(\d{2,5}):\d{2,5}\b/g)].map((m) => Number(m[1]));
227
+ // The host ports a `docker compose up` publishes, read from the files it names with -f, else the ones
228
+ // compose reads by default in `dir`: short syntax ("3000:3000", 127.0.0.1:8080:80) and long (published: 3000).
229
+ const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"];
230
+ export const composePorts = (cmd = "", dir = ".") => {
231
+ if (!/\bcompose\b/.test(cmd)) return [];
232
+ const named = [...cmd.matchAll(/(?:^|\s)(?:-f|--file)(?:\s+|=)(\S+)/g)].map((m) => m[1]);
233
+ const text = (named.length ? named : COMPOSE_FILES).map((f) => read(join(dir, f))).join("\n");
234
+ const short = [...text.matchAll(/^\s*-\s*["']?(?:[\d.]+:)?(\d{2,5}):\d{2,5}(?:\/tcp)?["']?\s*$/gm)];
235
+ const long = [...text.matchAll(/^\s*published:\s*["']?(\d{2,5})["']?\s*$/gm)];
236
+ return [...new Set([...short, ...long].map((m) => Number(m[1])))];
237
+ };
238
+ // The proxy's settings as a container reaches them: Docker Desktop, colima and OrbStack all route
239
+ // host.docker.internal to this machine's loopback.
240
+ export const forContainer = (env) => Object.fromEntries(Object.entries(env).map(([name, value]) => [name, value.replace("//127.0.0.1:", "//host.docker.internal:")]));
241
+ // The settings a container command already hands through from this terminal (`-e NAME`, `--env NAME`):
242
+ // those carry the proxy's address in with no change to the command.
243
+ export const passedThrough = (cmd = "") => new Set([...cmd.matchAll(/(?:^|\s)(?:-e|--env)(?:\s+|=)([A-Za-z_][A-Za-z0-9_]*)(?=\s|$)/g)].map((m) => m[1]));
package/lib/text.mjs CHANGED
@@ -264,7 +264,7 @@ function verifyLines(v) {
264
264
 
265
265
  export function nextCall(d) {
266
266
  if (!finished(d)) return `run_status ${d.jobId}`;
267
- return d.kind === "verify" || d.findings > 0 ? "findings" : "status";
267
+ return d.kind === "verify" || d.status === "failed" || d.status === "canceled" ? "status" : `behavior ${d.jobId}`;
268
268
  }
269
269
 
270
270
  // One line to hand the person while a run plays: conversations done of the total, whose side any failed
package/lib/trace.cjs CHANGED
@@ -744,7 +744,13 @@ if (FILE) {
744
744
  return compile.call(this, source, filename, ...rest);
745
745
  };
746
746
  }
747
- if (TOOLS_FILE && typeof Module.registerHooks === "function") {
747
+ // Node 22 cannot run a sync load hook beside a loader's async one (tsx's `--import`): every ES
748
+ // module load throws "loadSync is not a function" and the app dies at start. Node 26 can. There,
749
+ // an ES module's tools go unwrapped and the sweep below says so in the app's log.
750
+ // ponytail: measured on 22 (fails) and 26 (works) only; 23 to 25 are treated as 22.
751
+ const asyncLoader = [...process.execArgv, ...(process.env.NODE_OPTIONS || "").split(/\s+/)].some((a) => /^--(?:import|(?:experimental-)?loader)\b/.test(a));
752
+ const mixes = Number(process.versions.node.split(".")[0]) >= 26;
753
+ if (TOOLS_FILE && typeof Module.registerHooks === "function" && (mixes || !asyncLoader)) {
748
754
  try {
749
755
  Module.registerHooks({
750
756
  load(url, context, nextLoad) {
package/lib/verbs.mjs CHANGED
@@ -8,6 +8,8 @@ import { intelligence as setUp } from "./intelligence.mjs";
8
8
  import { reach as reachApp } from "./reach.mjs";
9
9
  import { fieldText, findingsText, finished, linesOf, notStartedText, refusedText, restartedText, runText, STARTING, startedText, statusText, testedLine, upWaitingText, waitingText } from "./text.mjs";
10
10
  import { incidentsText, intelligenceText, outcomesText, queryText } from "./intel-text.mjs";
11
+ import { againstText, behaviorText, historyText, searchText } from "./behavior-text.mjs";
12
+ import { askedText } from "./setup-live-text.mjs";
11
13
 
12
14
  // The ten things a coding agent may ask of Cortad, each a call to the API with this machine's key
13
15
  // and a rendering from lib/text.mjs; `reach` also sends to the app on this machine. The MCP tools
@@ -44,7 +46,7 @@ export const cappedAt = (n) => (Number.isSafeInteger(n) && n >= 1 ? { conversati
44
46
  // `spec` is the package the agent calls back, npx -y <spec>.
45
47
  // `appLog` is the file the connect command keeps the app's output in; its path is said here, on this
46
48
  // machine, beside a failure, and never sent.
47
- export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch, startApp, pending, root, writes = null, appLog = null, code = null, seen = () => {}, wasRead = () => false, spec = "cortad", now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
49
+ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch, startApp, pending, root, writes = null, appLog = null, code = null, seen = () => {}, wasRead = () => false, spec = "cortad", origin = null, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
48
50
  const call = async (method, path, body, timeoutMs = 30_000) => {
49
51
  const key = typeof token === "function" ? token() : token;
50
52
  if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
@@ -215,6 +217,11 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
215
217
  return [logSaid(appFailed(d)), back].filter(Boolean).reduce(withBack, runText(withTested(d, id)));
216
218
  };
217
219
 
220
+ const sinceConnect = (r) => {
221
+ const connected = Date.parse(runner()?.startedAt ?? "");
222
+ return !Number.isFinite(connected) || Date.parse(r.startedAt ?? "") >= connected;
223
+ };
224
+
218
225
  // `npx cortad wait`: returns when the next run or verify ends, started from the browser or by the
219
226
  // agent, with what it found. A coding agent starts it in the background and is woken when it exits; it is a shell
220
227
  // verb alone, since an MCP call is cut at about 60 seconds. A run playing when it starts is that run,
@@ -231,15 +238,23 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
231
238
  const res = id ? await call("GET", `/mcp/run/${encodeURIComponent(id)}?wait=${HOLD_S}`, undefined, (HOLD_S + 13) * 1000) : await call("GET", "/mcp/status?quiet=1");
232
239
  // A key that no longer opens this repository, or a run that is gone, does not come back by asking again.
233
240
  if ([401, 403, 404].includes(res.status)) return failed(res);
241
+ // The person answered the setup card: taken once, here, and handed to the agent this wakes.
242
+ if (res.ok && res.data?.setupAsk) {
243
+ const took = await call("POST", "/mcp/setup/live/take");
244
+ if (took.ok && took.data?.taken) return { text: askedText(took.data, spec, origin), data: took.data };
245
+ if (!took.ok) await sleep(WAIT_POLL_MS);
246
+ continue;
247
+ }
234
248
  if (res.ok && id && finished(res.data)) {
235
249
  const text = shown(res.data, id).replace(/\nnext: [^\n]*$/, "");
236
- // A verify's verdict is read by run_status; a run's problems by findings.
237
- return { text: `${text}\nnext: ${res.data.verify ? `run_status ${id}` : `findings ${res.data.jobId ?? id}`}`, data: res.data };
250
+ // A verify's verdict is read by run_status; how a run's users fared by behavior.
251
+ return { text: `${text}\nnext: ${res.data.verify ? `run_status ${id}` : `behavior ${res.data.jobId ?? id}`}`, data: res.data };
238
252
  }
239
253
  const r = res.ok && !id ? res.data?.run : null;
240
254
  // A verify is followed like a run: the agent that started it is woken at its end instead of
241
- // polling run_status twelve times a round.
242
- if (r?.jobId && (!finished(r) || !wasRead(r.jobId))) { id = r.jobId; continue; }
255
+ // polling run_status twelve times a round. A finished one nobody read is news only if it began
256
+ // under the connect command running now: one from before a restart is not the run the agent waits for.
257
+ if (r?.jobId && (!finished(r) || (!wasRead(r.jobId) && sinceConnect(r)))) { id = r.jobId; continue; }
243
258
  // A run being followed holds on the server; a failed call, or no run yet, is asked again later.
244
259
  if (!id || !res.ok) await sleep(WAIT_POLL_MS);
245
260
  }
@@ -336,7 +351,7 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
336
351
  if (res.ok && res.data?.joined) return { text: `A world is already being tested: ${res.data.jobId}.\nnext: world_status ${res.data.jobId}`, data: res.data };
337
352
  // What kind of test an ask gets is said only on an ask: a call that records the person's choice
338
353
  // of features, or allows a test account, starts nothing and is told nothing about worlds.
339
- const why = res.ok ? (res.data?.unmatched ? "Nothing a world can plant is what that is about, so it is tested by a run: simulated users talk to the app on this machine." : "This app has no world, so it is tested by a run: simulated users talk to the app on this machine.") : "The world could not be asked, so it is tested by a run: simulated users talk to the app on this machine.";
354
+ const why = "This is tested by simulated users talking to the app on this machine.";
340
355
  const ran = await run({ conversations, features, account, asked: Boolean(ask) });
341
356
  return { ...ran, text: ask ? `${why}\n${ran.text}` : ran.text };
342
357
  };
@@ -372,6 +387,28 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
372
387
  return res.ok ? { text: `Subscribed. ${res.data.said}
373
388
  Secret, shown once: ${res.data.secret}`, data: res.data } : failed(res);
374
389
  };
390
+ // The people using the live app, for the agent: the map of tables, a ready answer, or its own ask.
391
+ // The answer is the server's JSON as it came, which an agent reads better than any table drawn here.
392
+ const users = async (body) => {
393
+ const res = await call("POST", "/mcp/users", body);
394
+ return res.ok ? { text: JSON.stringify(res.data), data: res.data } : failed(res);
395
+ };
396
+ // How the app behaved with its users at one point, one persona of it, two points against each
397
+ // other, or the log of every point (the server's agent-behavior/read.ts).
398
+ const behavior = async ({ at, persona, against, history } = {}) => {
399
+ const q = new URLSearchParams({ ...(at ? { at: String(at) } : {}), ...(persona ? { persona: String(persona) } : {}), ...(against ? { against: String(against) } : {}), ...(history ? { history: "1" } : {}) }).toString();
400
+ const res = await call("GET", `/mcp/behavior${q ? `?${q}` : ""}`);
401
+ if (!res.ok) { const out = failed(res); return { ...out, text: `${out.text}\nnext: behavior with history: true lists every point there is` }; }
402
+ const d = res.data;
403
+ return { text: d.history ? historyText(d) : d.against ? againstText(d) : behaviorText(d), data: d };
404
+ };
405
+ // Conversations found by what they mean, across simulations and production.
406
+ const search = async ({ query: asked, where, persona, limit } = {}) => {
407
+ if (typeof asked !== "string" || !asked.trim()) return { text: "Say what to look for: search with query, in words, such as \"students asking for a past exam paper\".\nnext: search", isError: true, code: "no_query" };
408
+ const body = { query: asked.trim().slice(0, 500), ...(where ? { where: String(where) } : {}), ...(persona ? { persona: String(persona).slice(0, 200) } : {}), ...(Number.isSafeInteger(limit) && limit >= 1 ? { limit: Math.min(limit, 50) } : {}) };
409
+ const res = await call("POST", "/mcp/search", body);
410
+ return res.ok ? { text: searchText(res.data, body.query), data: res.data } : failed(res);
411
+ };
375
412
  const query = async (ask) => {
376
413
  const res = await call("POST", "/mcp/query", ask);
377
414
  return res.ok ? { text: queryText(res.data), data: res.data } : { text: queryText(res.data ?? { error: res.text }), data: res.data };
@@ -398,7 +435,7 @@ Secret, shown once: ${res.data.secret}`, data: res.data } : failed(res);
398
435
  };
399
436
 
400
437
  // `post` and `changed` serve lib/cli.mjs; the MCP answers only the names in TOOLS.
401
- return { status, reach, run, run_status: runStatus, wait, findings, verify, dispute, feedback, field_connect: fieldConnect, field, world, world_status: worldStatus, test, connect: fieldConnect, intelligence, incidents, outcomes, subscribe, query, post, changed };
438
+ return { status, reach, run, run_status: runStatus, wait, findings, verify, dispute, feedback, field_connect: fieldConnect, field, world, world_status: worldStatus, test, connect: fieldConnect, intelligence, incidents, outcomes, subscribe, query, users, behavior, search, post, changed };
402
439
  }
403
440
 
404
441
  const sizeOf = (path) => { try { return statSync(path).size; } catch { return 0; } };
package/lib/wire.cjs CHANGED
@@ -5,6 +5,7 @@
5
5
  // and one recorder per process. Nothing here talks to a network.
6
6
  "use strict";
7
7
  const fs = require("node:fs");
8
+ const { messageText } = require("./message-text.cjs");
8
9
  // Every value a trace row carries passes one mask, whichever path wrote it: a key inside a value
9
10
  // ("sk-...", a bearer token, a JWT) and what anyone wrote after "password is" or "token:". The
10
11
  // door row's sign-in headers are kept on purpose: the command replays them from this machine.
@@ -60,7 +61,7 @@ const partsOf = (text) => text.split(/\{[^}]*\}|\$\{[^}]*\}|%[sd]|<[^>]{1,40}>/)
60
61
  // the part cut off was graded as made up. A cut answer stands the passage questions down.
61
62
  const TOOL_TEXT = 12000, TOOLS_MAX = 12;
62
63
  const toolText = (name, full, extra = {}) => ({ name: String(name || "").slice(0, 80), text: full.slice(0, TOOL_TEXT), ...extra, ...(full.length > TOOL_TEXT ? { cut: true } : {}) });
63
- const textOf = (v) => (typeof v === "string" ? v : Array.isArray(v) ? v.map((p) => (p && typeof p === "object" ? (p.text || p.content || "") : String(p || ""))).filter(Boolean).join("\n") : v && typeof v === "object" ? JSON.stringify(v) : "");
64
+ const textOf = messageText;
64
65
  // Only what came after the person's latest message belongs to this turn: a thread the app
65
66
  // resends whole carries every earlier turn's tool answers too.
66
67
  const list = (v) => (Array.isArray(v) ? v.filter((x) => x && typeof x === "object") : []);
@@ -653,6 +654,13 @@ const stoppedAtLimit = (e) => {
653
654
  || (e.type === "finish" && (e.finishReason === "length" || Boolean(e.finishReason && e.finishReason.unified === "length")));
654
655
  };
655
656
  const reasoningOf = (u) => n((u.output_tokens_details && u.output_tokens_details.reasoning_tokens) || (u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens) || u.thoughtsTokenCount || (u.outputTokens && u.outputTokens.reasoning));
657
+ // An answered reply the provider sent no counts for (Anthropic's OpenAI compatible stream sends
658
+ // none) is counted from its text at four characters a token, the request whole as it was sent,
659
+ // and marked estimated: a run cap and a price that counted nothing let any spend through.
660
+ const CHARS_PER_TOKEN = 4;
661
+ const estimateOf = (sent, events, status) => (status >= 200 && status < 400
662
+ ? { promptTokens: Math.ceil(String(sent || "").length / CHARS_PER_TOKEN), cachedTokens: 0, completionTokens: Math.ceil(replyText(events).length / CHARS_PER_TOKEN), estimated: true }
663
+ : { promptTokens: 0, cachedTokens: 0, completionTokens: 0 });
656
664
  // The output cap the app's request set, in each provider's field.
657
665
  const capOf = (b) => (b && typeof b === "object" ? n(b.max_tokens) || n(b.max_completion_tokens) || n(b.max_output_tokens) || n(b.maxOutputTokens) || n(b.generationConfig && b.generationConfig.maxOutputTokens) || n(b.inferenceConfig && b.inferenceConfig.maxTokens) || null : null);
658
666
  // A stream spreads its counts over events (Anthropic's input in the first, output in the last);
@@ -896,7 +904,7 @@ function makeRecorder({ row, rulesFile, onTurn = () => {}, onEnded = () => {} })
896
904
  }
897
905
  settle(ctx);
898
906
  }
899
- row({ call: { at: Date.now(), ms: Date.now() - t0, host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), ...answeredBy(askedFor(path, sent), reply.model), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(EMBEDDING.test(path) ? { embedding: true } : {}), ...(ctx ? { ex: ctx.id } : {}), ...(ctx && (ctx.kept || ctx.turn) ? { reply: replyText(reply.events).slice(0, MODEL_WORDS) } : {}), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}), ...(reply.limit ? { limit: { cap: capOf(asked), reasoning: reply.reasoning } } : {}), ...told } });
907
+ row({ call: { at: Date.now(), ms: Date.now() - t0, host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), ...answeredBy(askedFor(path, sent), reply.model), status, ...(reply.tokens || estimateOf(sent, reply.events, status)), usage: Boolean(reply.tokens), ...(EMBEDDING.test(path) ? { embedding: true } : {}), ...(ctx ? { ex: ctx.id } : {}), ...(ctx && (ctx.kept || ctx.turn) ? { reply: replyText(reply.events).slice(0, MODEL_WORDS) } : {}), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}), ...(reply.limit ? { limit: { cap: capOf(asked), reasoning: reply.reasoning } } : {}), ...told } });
900
908
  } catch { /* the command is gone */ }
901
909
  };
902
910
  return { tied, requestCtx, note, ended, meter };
package/local.mjs CHANGED
@@ -38,7 +38,7 @@ import { finished, unseenText } from "./lib/text.mjs";
38
38
  import { lockHolds, makeLock } from "./lib/lock.mjs";
39
39
  import { AS_HEADER, CAPTURED, makeCapture } from "./lib/replay.mjs";
40
40
  import { sampleHere } from "./lib/sample.mjs";
41
- import { hookable, shownCommand, startPlan, workspaces } from "./lib/start.mjs";
41
+ import { composePorts, forContainer, hookable, inContainer, passedThrough, publishedPorts, shownCommand, startPlan, workspaces } from "./lib/start.mjs";
42
42
  import { openProxy } from "./lib/proxy.mjs";
43
43
  import { liftedLimits } from "./lib/switches.mjs";
44
44
  import { downLine, makeHealth } from "./lib/health.mjs";
@@ -49,8 +49,13 @@ import { clientOriginOf, envOf, keysFromEnv } from "./lib/origins.mjs";
49
49
  import { forgetSignIn, keptSignIn, mintJwt, rememberSignIn, signUpAndIn, testAccount } from "./lib/sign-in.mjs";
50
50
  import { clientRequestOf } from "./lib/browser-rung.mjs";
51
51
  import { layerOf, layerSaid } from "./lib/layer.mjs";
52
+ import { sourcesIn } from "./lib/history/sources.mjs";
53
+ import { report as reportLive } from "./lib/setup-live.mjs";
52
54
  import { grpcCall } from "./lib/grpc.mjs";
53
55
 
56
+ // A count with its noun agreeing: 1 prompt, 3 prompts.
57
+ const counted = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`;
58
+
54
59
  const argv = process.argv.slice(2);
55
60
  // The two faces a coding agent uses after the first connect (lib/cli.mjs): the MCP server the
56
61
  // client starts on every session, and the same verbs as shell commands. Neither runs the connect
@@ -106,14 +111,16 @@ if (!trusted) fail(`refusing: ${origin.host} is not Cortad.`);
106
111
  const api = `${origin.origin}/api`;
107
112
 
108
113
  const root = process.cwd();
109
- const MANIFEST = ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "docker-compose.yml", "docker-compose.yaml", "Gemfile", "mix.exs"];
114
+ const MANIFEST = ["package.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml", "docker-compose.yml", "docker-compose.yaml", "Gemfile", "mix.exs", "pom.xml", "build.gradle", "build.gradle.kts", "composer.json", "Dockerfile"];
115
+ // A .NET project is named for itself: Support.csproj, Shop.sln.
116
+ const dotnetHere = () => { try { return readdirSync(root).some((f) => /\.(?:csproj|fsproj|sln)$/.test(f)); } catch { return false; } };
110
117
  // A repository whose root carries no manifest of its own is still a repository: crewai-examples
111
118
  // keeps one project per folder under crews/, and the whole of it was refused at the door. The root
112
119
  // is accepted when a project lives below it, and whenever the person says how the app starts or
113
120
  // where it answers: a single file with no manifest is an app too, and nothing here needs the manifest.
114
121
  const startSaid = argv.includes("--") || argv.includes("--start") || argv.includes("--port") || argv.includes("--token");
115
- if (!startSaid && !MANIFEST.some((f) => existsSync(join(root, f))) && !workspaces(root).length) {
116
- fail(`no project here: this folder has no package.json or pyproject.toml, and neither does any folder in it. Run it from your repository's root, or say how your app starts: npx cortad <code> -- <your start command>`);
122
+ if (!startSaid && !MANIFEST.some((f) => existsSync(join(root, f))) && !dotnetHere() && !workspaces(root).length) {
123
+ fail(`no project here: this folder has no project file (package.json, pyproject.toml, go.mod, pom.xml, Gemfile, composer.json, Cargo.toml, a .csproj), and neither does any folder in it. Run it from your repository's root, or say how your app starts: npx cortad <code> -- <your start command>`);
117
124
  }
118
125
  // Which project this is, as a hash of where it lives: the same folder coming back resumes the same
119
126
  // connection, and the path itself never leaves this machine.
@@ -682,7 +689,7 @@ async function startApp() {
682
689
  if (up.port) {
683
690
  keepStart({ cmd: typed, ...(wantedPort ? { port: wantedPort } : {}) });
684
691
  // A container never saw the proxy's settings: the one line that makes its model calls visible is said here.
685
- if (proxy && IN_CONTAINER.test(typed)) say(containerLine());
692
+ if (proxy && inContainer(typed)) sayIf(containerLine());
686
693
  return { port: up.port, cmd: launched.cmd, lifted: raised };
687
694
  }
688
695
  if (up.tail) console.error(up.tail);
@@ -742,22 +749,26 @@ async function useProxy() {
742
749
  keepStart({ modelPort: proxy.modelPort });
743
750
  say("your app's model calls are seen through a local proxy on this machine, so the line of code that made each call is not known. During a run, its calls to other services go out as they would for a real customer: a conversation that books, charges or emails does it for real");
744
751
  }
752
+ const sayIf = (line) => { if (line) say(line); };
745
753
  // An app run in a container: the proxy's settings never reach it, and 127.0.0.1 inside it is not this machine.
746
754
  const containerLine = () => {
747
755
  // Only the settings the app's own environment names, else the OpenAI one: every SDK's would be a list nobody reads.
748
756
  const hints = modelHints();
749
757
  const handed = proxy.env(hints);
750
758
  const named = Object.keys(handed).filter((name) => name in hints);
751
- const settings = (named.length ? named : ["OPENAI_BASE_URL"]).map((name) => `${name}=${handed[name].replace("127.0.0.1", "host.docker.internal")}`);
759
+ const passed = passedThrough(launched?.cmd);
760
+ // A setting the command already hands the container from this terminal arrives on the proxy as it is.
761
+ const missing = (named.length ? named : ["OPENAI_BASE_URL"]).filter((name) => !passed.has(name));
762
+ if (!missing.length) return "";
763
+ const settings = missing.map((name) => `${name}=${forContainer(handed)[name]}`);
752
764
  return `your app runs in a container, where Cortad's hook cannot load: its replies are seen through the proxy, its model calls are not. To see them, give the container ${settings.length === 1 ? "this setting" : "these settings"} and start it again with this command (the same port on every connect of this folder): ${settings.join(", ")}`;
753
765
  };
754
- const IN_CONTAINER = /^\s*(?:sudo\s+)?(?:docker|podman)(?:-compose)?\b/;
755
766
  // The app answered, and the hook is not in it: it is started again with its model settings on the
756
767
  // proxy. One in a container would come back the same, so it is left up, seen at the front alone.
757
768
  async function startProxied(up, waitMs) {
758
769
  await useProxy();
759
- if (IN_CONTAINER.test(launched?.cmd ?? "")) {
760
- say(containerLine());
770
+ if (inContainer(launched?.cmd)) {
771
+ sayIf(containerLine());
761
772
  return up;
762
773
  }
763
774
  await stopTree(child.pid);
@@ -780,7 +791,7 @@ async function start(waitMs) {
780
791
  // for the session, and the hook. No env file is read into it: its own loader reads its own files, in
781
792
  // its own order, which is the only way a test file's placeholder key never outranks the real one.
782
793
  const base = { ...process.env, ...lifted };
783
- child = spawnTied(cmd, { cwd: appDir, env: { ...base, ...(proxy ? proxy.env(modelHints()) : capture.env(base)), FORCE_COLOR: "0" } });
794
+ child = spawnTied(cmd, { cwd: appDir, env: { ...base, ...(proxy ? (inContainer(cmd) ? forContainer(proxy.env(modelHints())) : proxy.env(modelHints())) : capture.env(base)), FORCE_COLOR: "0" } });
784
795
  const mine = child;
785
796
  become("starting", { app: mine.pid ?? null });
786
797
  let seen = "";
@@ -821,7 +832,7 @@ async function start(waitMs) {
821
832
  // A container publishes its port through the daemon, so nothing in this command's own process
822
833
  // family listens on it and `ports` stays empty however long the wait runs. What the container
823
834
  // said is then the only evidence there is, and `answers` still has to agree before it is used.
824
- const alsoSaid = IN_CONTAINER.test(cmdText) ? said : [];
835
+ const alsoSaid = inContainer(cmdText) ? [...publishedPorts(cmdText), ...composePorts(cmdText, appDir), ...said] : [];
825
836
  for (const port of [...new Set([...(wantedPort ? [wantedPort] : []), ...said.filter((p) => ports.includes(p)), ...ports, ...alsoSaid])]) {
826
837
  if (await answers(port)) {
827
838
  note.answered = true;
@@ -1047,7 +1058,7 @@ if (attach.data.upload === "layer") {
1047
1058
  const sentList = join(homeOf(project), "sent.json");
1048
1059
  try { mkdirSync(homeOf(project), { recursive: true, mode: 0o700 }); writeFileSync(sentList, JSON.stringify({ ...said, files: chosen.files.map((f) => ({ path: f, why: chosen.why.get(f) })), leftOut: chosen.dropped ?? [] }, null, 1), { mode: 0o600 }); } catch { /* the list is for reading; the send does not depend on it */ }
1049
1060
  const left = said.leftOut.files ? ` ${said.leftOut.files} large data and test files were left out to keep the upload under 16 MB; they are named in the list.` : "";
1050
- say(`sending the part of this code that is its AI, so simulated users can be written for it: ${said.prompts} prompts, ${said.modelCalls} model calls, ${said.evals} eval files, the routes that reach the model and the shapes they read. ${layer.set.size} of ${candidates.length} files; the list is at ${sentList}.${left} The rest of this folder is not sent.`);
1061
+ say(`sending the part of this code that is its AI, so simulated users can be written for it: ${counted(said.prompts, 'prompt')}, ${counted(said.modelCalls, 'model call')}, ${counted(said.evals, 'eval file')}, the routes that reach the model and the shapes they read. ${layer.set.size} of ${candidates.length} files; the list is at ${sentList}.${left} The rest of this folder is not sent.`);
1051
1062
  } else {
1052
1063
  say(`sending this folder's code as this project has before: ${files.length} files, env files and keys left out.`);
1053
1064
  }
@@ -1121,6 +1132,12 @@ if (!sent.ok && sent.status !== 401 && sent.status !== 404) {
1121
1132
  sent = await sendTree();
1122
1133
  }
1123
1134
  if (!sent.ok) sent.err ? unreachable(sent.err) : fail(sent.data?.error ?? `upload failed (${sent.status})`);
1135
+ // Which history tool this folder's env names, by name only, so the setup card can say where its users'
1136
+ // messages will come from before the person is asked how many.
1137
+ {
1138
+ const found = sourcesIn({ ...envOf(root), ...process.env }).map((s) => s.id);
1139
+ if (found.length) void reportLive({ apiBase: api, token: readToken(project) }, { found });
1140
+ }
1124
1141
  // Their commit history follows the code: the commits newer than the ones Cortad holds, the whole
1125
1142
  // history the first time, each diff cut to the files above and to lines that carry no key
1126
1143
  // (lib/commits.mjs). It goes on after the connect is reported and never stops it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortad/connector",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "README.md",
11
11
  "lib/app-port.mjs",
12
+ "lib/behavior-text.mjs",
12
13
  "lib/auto-reach.mjs",
13
14
  "lib/browser-rung.mjs",
14
15
  "lib/calls.mjs",
@@ -47,6 +48,8 @@
47
48
  "lib/replay.mjs",
48
49
  "lib/runner.mjs",
49
50
  "lib/sample.mjs",
51
+ "lib/setup-live.mjs",
52
+ "lib/setup-live-text.mjs",
50
53
  "lib/sign-in.mjs",
51
54
  "lib/spec.mjs",
52
55
  "lib/start-cause.mjs",
@@ -59,6 +62,7 @@
59
62
  "lib/trace.cjs",
60
63
  "lib/verbs.mjs",
61
64
  "lib/wire.cjs",
65
+ "lib/message-text.cjs",
62
66
  "lib/words.mjs",
63
67
  "lib/writes.mjs",
64
68
  "local.mjs",
@@ -72,6 +76,7 @@
72
76
  "lib/redact.mjs",
73
77
  "lib/history/contract.mjs",
74
78
  "lib/history/send.mjs",
79
+ "lib/history/sources.mjs",
75
80
  "lib/history/langfuse.mjs",
76
81
  "lib/intelligence-pair.mjs",
77
82
  "lib/history/langsmith.mjs",