@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/behavior-text.mjs +239 -0
- package/lib/cli.mjs +22 -2
- package/lib/dissection-text.mjs +1 -1
- package/lib/doors.mjs +89 -49
- package/lib/history/contract.mjs +29 -13
- package/lib/history/sources.mjs +11 -0
- package/lib/intelligence-pair.mjs +1 -1
- package/lib/intelligence-text.mjs +41 -18
- package/lib/intelligence.mjs +38 -14
- package/lib/layer.mjs +293 -1026
- package/lib/mcp.mjs +4 -4
- package/lib/message-text.cjs +48 -0
- package/lib/proxy.mjs +25 -0
- package/lib/replay.mjs +3 -1
- package/lib/setup-live-text.mjs +19 -0
- package/lib/setup-live.mjs +35 -0
- package/lib/start.mjs +22 -0
- package/lib/text.mjs +1 -1
- package/lib/trace.cjs +7 -1
- package/lib/verbs.mjs +44 -7
- package/lib/wire.cjs +10 -2
- package/local.mjs +29 -12
- package/package.json +6 -1
- package/skill/SKILL.md +46 -71
- package/skill/references/results.md +54 -8
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
|
|
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
|
|
15
|
-
`status says
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
237
|
-
return { text: `${text}\nnext: ${res.data.verify ? `run_status ${id}` : `
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 ||
|
|
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
|
|
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 &&
|
|
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
|
|
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 (
|
|
760
|
-
|
|
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 =
|
|
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}
|
|
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.
|
|
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",
|