cortad 0.3.1 → 0.3.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/calls.mjs +23 -0
- package/lib/cli.mjs +4 -2
- package/lib/mcp.mjs +20 -4
- package/lib/verbs.mjs +15 -9
- package/local.mjs +6 -12
- package/package.json +2 -1
package/lib/calls.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// What Cortad counts about its own tools: which one an agent called, from which client, how long it
|
|
2
|
+
// took, how much text came back and, when it failed, under which code. The arguments and the answer
|
|
3
|
+
// stay on this machine. Sent with the machine's own key, and a count that is lost is never retried:
|
|
4
|
+
// it must not cost the tool call it describes.
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
const text = (v, max) => (typeof v === "string" && v ? v.slice(0, max) : undefined);
|
|
8
|
+
|
|
9
|
+
export function makeReport({ api, token, version, fetchImpl = fetch, sessionId = randomUUID() }) {
|
|
10
|
+
return async ({ tool, durationMs, outputChars, errorCode, error, clientName, clientVersion }) => {
|
|
11
|
+
const key = token();
|
|
12
|
+
if (!key) return;
|
|
13
|
+
const call = { tool, durationMs, outputChars, errorCode, error: text(error?.split("\n")[0], 512), clientName: text(clientName, 80), clientVersion: text(clientVersion, 40), serverVersion: version, sessionId };
|
|
14
|
+
try {
|
|
15
|
+
await fetchImpl(`${api}/mcp/calls`, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
18
|
+
body: JSON.stringify([call]),
|
|
19
|
+
signal: AbortSignal.timeout(3000),
|
|
20
|
+
});
|
|
21
|
+
} catch { /* lost */ }
|
|
22
|
+
};
|
|
23
|
+
}
|
package/lib/cli.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { makeCode } from "./fresh.mjs";
|
|
|
6
6
|
import { dropNews, homeOf, wasHeard, projectOf, readPending, readToken, takeNews, writePending } from "./home.mjs";
|
|
7
7
|
import { readRunner } from "./runner.mjs";
|
|
8
8
|
import { markRun, putBack, writesDirOf } from "./writes.mjs";
|
|
9
|
+
import { makeReport } from "./calls.mjs";
|
|
9
10
|
import { serveMcp, TOOLS } from "./mcp.mjs";
|
|
10
11
|
import { SHOW } from "./read-text.mjs";
|
|
11
12
|
import { answeringText, notStartedText } from "./text.mjs";
|
|
@@ -66,7 +67,8 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
|
|
|
66
67
|
}
|
|
67
68
|
if (mcp) {
|
|
68
69
|
const atRunner = asRunner(verbs, { runnerVersion: () => readRunner(project)?.version, runAt: (version, name, args) => runAt(version, name, args, { root, env }) });
|
|
69
|
-
const code = await serveMcp({ verbs: atRunner, version: VERSION, spec: npxName(cliSpec({ env })), log: (line) => stderr.write(`${line}\n`)
|
|
70
|
+
const code = await serveMcp({ verbs: atRunner, version: VERSION, spec: npxName(cliSpec({ env })), log: (line) => stderr.write(`${line}\n`),
|
|
71
|
+
report: makeReport({ api: `${origin.origin}/api`, token: () => readToken(project), version: VERSION }) });
|
|
70
72
|
for (const child of children) try { child.kill("SIGTERM"); } catch { /* gone */ }
|
|
71
73
|
return code;
|
|
72
74
|
}
|
|
@@ -88,7 +90,7 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
|
|
|
88
90
|
// `--json '<arguments>'`: the MCP server of another version asking this one to run its tool.
|
|
89
91
|
const json = rest[0] === "--json";
|
|
90
92
|
const out = await verbs[command.replace(/-/g, "_")](json ? jsonArgs(rest[1]) : argsOf(command, rest));
|
|
91
|
-
stdout.write(`${json ? JSON.stringify({ text: out.text, ...(out.isError ? { isError: true } : {}), ...(out.structured ? { structured: out.structured } : {}) }) : out.text}\n`);
|
|
93
|
+
stdout.write(`${json ? JSON.stringify({ text: out.text, ...(out.isError ? { isError: true, code: out.code, why: out.why } : {}), ...(out.structured ? { structured: out.structured } : {}) }) : out.text}\n`);
|
|
92
94
|
return out.isError ? 1 : 0;
|
|
93
95
|
}
|
|
94
96
|
|
package/lib/mcp.mjs
CHANGED
|
@@ -64,8 +64,17 @@ const error = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, messa
|
|
|
64
64
|
const result = (id, value) => ({ jsonrpc: "2.0", id, result: value });
|
|
65
65
|
|
|
66
66
|
// Serves until stdin closes. `verbs` is lib/verbs.mjs's object; `version` is the package's.
|
|
67
|
-
|
|
67
|
+
// `report` is lib/calls.mjs: handed each tool call once it has answered, and awaited before the exit.
|
|
68
|
+
export function serveMcp({ verbs, version, spec, input = process.stdin, output = process.stdout, log = () => {}, report = async () => {} }) {
|
|
68
69
|
return new Promise((resolve) => {
|
|
70
|
+
let client = {};
|
|
71
|
+
const sending = new Set();
|
|
72
|
+
const count = (tool, began, text, failure) => {
|
|
73
|
+
const sent = Promise.resolve(report({ tool, durationMs: Date.now() - began, outputChars: text.length, ...failure, ...client })).catch(() => {}).finally(() => sending.delete(sent));
|
|
74
|
+
sending.add(sent);
|
|
75
|
+
};
|
|
76
|
+
let closed = false;
|
|
77
|
+
const close = () => { if (!closed) { closed = true; void Promise.all(sending).then(() => resolve(0)); } };
|
|
69
78
|
const send = (msg) => output.write(`${JSON.stringify(msg)}\n`);
|
|
70
79
|
let buffer = "";
|
|
71
80
|
input.setEncoding("utf8");
|
|
@@ -78,8 +87,8 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
|
|
|
78
87
|
if (line) void handle(line);
|
|
79
88
|
}
|
|
80
89
|
});
|
|
81
|
-
input.on("end",
|
|
82
|
-
input.on("close",
|
|
90
|
+
input.on("end", close);
|
|
91
|
+
input.on("close", close);
|
|
83
92
|
|
|
84
93
|
async function handle(line) {
|
|
85
94
|
let msg;
|
|
@@ -87,9 +96,11 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
|
|
|
87
96
|
if (!msg || typeof msg !== "object" || Array.isArray(msg)) { send(error(null, -32600, "invalid request")); return; }
|
|
88
97
|
const { id, method, params } = msg;
|
|
89
98
|
const notification = id === undefined || id === null;
|
|
99
|
+
const began = Date.now();
|
|
90
100
|
try {
|
|
91
101
|
switch (method) {
|
|
92
102
|
case "initialize":
|
|
103
|
+
client = { clientName: params?.clientInfo?.name, clientVersion: params?.clientInfo?.version };
|
|
93
104
|
return send(result(id, { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: "cortad", version }, instructions: instructions(spec) }));
|
|
94
105
|
case "notifications/initialized":
|
|
95
106
|
case "notifications/cancelled":
|
|
@@ -101,8 +112,12 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
|
|
|
101
112
|
case "tools/call": {
|
|
102
113
|
const name = params?.name;
|
|
103
114
|
const verb = NAMES.has(name) && Object.hasOwn(verbs, name) ? verbs[name] : null;
|
|
104
|
-
if (!verb)
|
|
115
|
+
if (!verb) {
|
|
116
|
+
count("unknown", began, "", { errorCode: "unknown_tool" });
|
|
117
|
+
return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
|
|
118
|
+
}
|
|
105
119
|
const out = await verb(params?.arguments ?? {});
|
|
120
|
+
count(name, began, out.text, out.isError ? { errorCode: out.code ?? "refused", error: out.why } : {});
|
|
106
121
|
const structured = out.structured && typeof out.structured === "object" && !Array.isArray(out.structured) ? { structuredContent: out.structured } : {};
|
|
107
122
|
return send(result(id, { content: [{ type: "text", text: out.text }], ...structured, ...(out.isError ? { isError: true } : {}) }));
|
|
108
123
|
}
|
|
@@ -111,6 +126,7 @@ export function serveMcp({ verbs, version, spec, input = process.stdin, output =
|
|
|
111
126
|
}
|
|
112
127
|
} catch (err) {
|
|
113
128
|
log(`mcp ${method}: ${err?.stack ?? err}`);
|
|
129
|
+
if (method === "tools/call") count(NAMES.has(params?.name) ? params.name : "unknown", began, "", { errorCode: "internal" });
|
|
114
130
|
if (!notification) send(result(id, { content: [{ type: "text", text: `Cortad could not answer: ${String(err?.message ?? err)}` }], isError: true }));
|
|
115
131
|
}
|
|
116
132
|
}
|
package/lib/verbs.mjs
CHANGED
|
@@ -58,7 +58,13 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
|
|
|
58
58
|
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 200) }; }
|
|
59
59
|
return { status: res.status, ok: res.ok, data };
|
|
60
60
|
};
|
|
61
|
-
|
|
61
|
+
// `code` and `why` are what lib/calls.mjs counts a failure under. `why` is set only where the
|
|
62
|
+
// sentence is Cortad's own: a failure that quotes the app's output carries a code and no words.
|
|
63
|
+
const failed = (res) => {
|
|
64
|
+
const text = res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`;
|
|
65
|
+
const code = res.status ? `http_${res.status}` : text === NOT_CONNECTED ? "not_connected" : "unreachable";
|
|
66
|
+
return { text, data: res.data, isError: true, code, why: text };
|
|
67
|
+
};
|
|
62
68
|
const iso = () => new Date(now()).toISOString();
|
|
63
69
|
const heard = (id) => { try { seen(String(id)); } catch { /* said once more by the hook */ } };
|
|
64
70
|
// Empty for an app the connect command found already running.
|
|
@@ -108,8 +114,8 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
|
|
|
108
114
|
const said = [restarted.length ? restartedText(restarted) : "", mine?.stale ? testedLine(mine) : ""];
|
|
109
115
|
return { text: startedText(res.data, kind, args.findingId, said), data: res.data };
|
|
110
116
|
}
|
|
111
|
-
if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
|
|
112
|
-
if (res.status === 409) return { text: `${res.data?.why ?? "The run could not start."}\nNothing ran.`, data: res.data, isError: true };
|
|
117
|
+
if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true, code: "plan_spent" };
|
|
118
|
+
if (res.status === 409) return { text: `${res.data?.why ?? "The run could not start."}\nNothing ran.`, data: res.data, isError: true, code: "run_refused" };
|
|
113
119
|
return failed(res);
|
|
114
120
|
};
|
|
115
121
|
|
|
@@ -119,20 +125,20 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
|
|
|
119
125
|
// if none holds the app, and the run is posted in the background once it can be.
|
|
120
126
|
const start = async (args, kind) => {
|
|
121
127
|
const here = runner();
|
|
122
|
-
if (here?.state === "failed") return { text: notStartedText(here), data: { app: here }, isError: true };
|
|
128
|
+
if (here?.state === "failed") return { text: notStartedText(here), data: { app: here }, isError: true, code: "app_not_started" };
|
|
123
129
|
const before = await call("GET", "/mcp/status?quiet=1");
|
|
124
130
|
if (!before.ok) return failed(before);
|
|
125
131
|
if (here?.state === "up" && before.data.app?.state === "ready-to-test") {
|
|
126
132
|
const idle = !before.data.run || finished(before.data.run);
|
|
127
133
|
const got = code && idle ? await code.fresh() : {};
|
|
128
|
-
if (got.error) return { text: `${got.error}\nNothing ran.`, isError: true };
|
|
134
|
+
if (got.error) return { text: `${got.error}\nNothing ran.`, isError: true, code: "app_not_started" };
|
|
129
135
|
return post(args, kind, got.restarted, before.data);
|
|
130
136
|
}
|
|
131
137
|
pending.write({ kind, startedAt: iso(), after: before.data.run?.jobId ?? null });
|
|
132
138
|
const began = await startApp(args, kind);
|
|
133
139
|
if (!began.ok) {
|
|
134
140
|
pending.write({ kind, startedAt: iso(), error: began.why });
|
|
135
|
-
return { text: began.why, isError: true };
|
|
141
|
+
return { text: began.why, isError: true, code: "app_not_started" };
|
|
136
142
|
}
|
|
137
143
|
return { text: here?.state === "up" ? upWaitingText(here, before.data.app) : STARTING, data: { pending: true } };
|
|
138
144
|
};
|
|
@@ -153,8 +159,8 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
|
|
|
153
159
|
p = pending.read() ?? p;
|
|
154
160
|
}
|
|
155
161
|
if (p.jobId) return { id: p.jobId };
|
|
156
|
-
if (p.error) return { text: `${p.error}\nnext: status`, isError: true };
|
|
157
|
-
if (stale()) return { text: "The process starting your app for the run ended without a result.\nNothing ran.\nnext: status", isError: true };
|
|
162
|
+
if (p.error) return { text: `${p.error}\nnext: status`, isError: true, code: "app_not_started" };
|
|
163
|
+
if (stale()) return { text: "The process starting your app for the run ended without a result.\nNothing ran.\nnext: status", isError: true, code: "app_not_started" };
|
|
158
164
|
return { text: waitingText(p, now(), READY_MS, runner(), latest.data.app) };
|
|
159
165
|
};
|
|
160
166
|
|
|
@@ -192,7 +198,7 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
|
|
|
192
198
|
// verb alone, since an MCP call is cut at about 60 seconds. A run playing when it starts is that run,
|
|
193
199
|
// and so is a finished one the agent has not read: a first run can end before the agent starts this.
|
|
194
200
|
const wait = async () => {
|
|
195
|
-
if (!(typeof token === "function" ? token() : token)) return { text: NOT_CONNECTED, isError: true };
|
|
201
|
+
if (!(typeof token === "function" ? token() : token)) return { text: NOT_CONNECTED, isError: true, code: "not_connected", why: NOT_CONNECTED };
|
|
196
202
|
const began = now();
|
|
197
203
|
let id = null;
|
|
198
204
|
let goneAt = null;
|
package/local.mjs
CHANGED
|
@@ -133,15 +133,10 @@ if (viaToken) await holdProject();
|
|
|
133
133
|
|
|
134
134
|
// ---- what leaves the machine: the source git would commit, and nothing git is told to ignore
|
|
135
135
|
const SKIP_DIR = /^(node_modules|\.git|dist|build|out|coverage|vendor|venv|\.venv|env|target|tmp|\.next|\.nuxt|\.turbo|\.cache|__pycache__|\.terraform|\.wrangler|\.svelte-kit|\.output|\.parcel-cache|\.idea|\.vscode|secrets?|\.secrets?)$/i;
|
|
136
|
-
// Env files by any name (scripts/books.env is one), keys,
|
|
137
|
-
const SKIP_FILE = /^\.env(\..*)?$|^\.envrc$|\.env$|\.(pem|key|p12|pfx|jks|keystore|sqlite|sqlite3|db|log|lock|map|zip|tar|gz|tgz|7z|rar|png|jpe?g|gif|webp|ico|svg|mp3|mp4|wav|mov|pdf|woff2?|ttf|otf|eot|bin|exe|dll|so|dylib|wasm|onnx|pt|pth|safetensors|
|
|
138
|
-
const MAX_FILE =
|
|
139
|
-
|
|
140
|
-
// and datasets are where projects keep what they would never paste into a chat.
|
|
141
|
-
const MAX_DATA = 200_000;
|
|
142
|
-
const DATA_FILE = /\.(json|ya?ml)$/i;
|
|
143
|
-
const MANIFEST_FILE = /^(package|tsconfig|composer|app|manifest)\.json$/i;
|
|
144
|
-
const MAX_TOTAL = 80_000_000;
|
|
136
|
+
// Env files by any name (scripts/books.env is one), keys, credentials, media and compiled output.
|
|
137
|
+
const SKIP_FILE = /^\.env(\..*)?$|^\.envrc$|\.env$|\.(pem|key|p12|pfx|jks|keystore|sqlite|sqlite3|db|log|lock|map|zip|tar|gz|tgz|7z|rar|png|jpe?g|gif|webp|ico|svg|mp3|mp4|wav|mov|pdf|woff2?|ttf|otf|eot|bin|exe|dll|so|dylib|wasm|onnx|pt|pth|safetensors|numbers|DS_Store)$/i;
|
|
138
|
+
const MAX_FILE = 25_000_000;
|
|
139
|
+
const MAX_TOTAL = 256_000_000;
|
|
145
140
|
const ENV_FILE = /^\.env(\.(local|staging|stage|development|dev|test|example|sample))?$/;
|
|
146
141
|
|
|
147
142
|
function walk(dir, depth, out, envs, total) {
|
|
@@ -156,7 +151,6 @@ function walk(dir, depth, out, envs, total) {
|
|
|
156
151
|
let size;
|
|
157
152
|
try { size = statSync(full).size; } catch { continue; }
|
|
158
153
|
if (size > MAX_FILE || total + size > MAX_TOTAL) continue;
|
|
159
|
-
if (size > MAX_DATA && DATA_FILE.test(e.name) && !MANIFEST_FILE.test(e.name)) continue;
|
|
160
154
|
if (!shareable(relative(root, full), false)) continue;
|
|
161
155
|
out.push(relative(root, full));
|
|
162
156
|
total += size;
|
|
@@ -1150,8 +1144,8 @@ if (explain) {
|
|
|
1150
1144
|
`talks to ${origin.origin}, localhost (your app's port only), and your own model providers, to ask each which models your key can use`,
|
|
1151
1145
|
`would send ${files.length} source files, ${Math.round(bytes / 1024)} KB, once${listed ? " (what git would commit)" : ""}`,
|
|
1152
1146
|
...(kept.length ? [`kept here ${kept.length} file${kept.length === 1 ? " that holds" : "s that hold"} keys: ${kept.slice(0, 6).join(", ")}${kept.length > 6 ? ", ..." : ""}`] : []),
|
|
1153
|
-
...(left.length ? [`also not sent ${left.length} tracked
|
|
1154
|
-
`not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files,
|
|
1147
|
+
...(left.length ? [`also not sent ${left.length} tracked media or lock file${left.length === 1 ? "" : "s"}: ${left.slice(0, 6).join(", ")}${left.length > 6 ? ", ..." : ""}`] : []),
|
|
1148
|
+
`not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, node_modules, .git`,
|
|
1155
1149
|
`env files values read here only: to hide them in replies, to sign in a test account, and to ask your providers what your keys reach. Variable names and whether a switch is on or off go up; no value does`,
|
|
1156
1150
|
`would start ${flag("--port") ? `nothing: uses your app on port ${flag("--port")}` : plan?.cmd ? `${plan.cmd} (in ${rel(plan.cwd)})` : plan?.noServer ? "nothing: this repository has no server to run" : "asks you how your app starts"}`,
|
|
1157
1151
|
`would raise ${flag("--port") ? "nothing: your app's own request limits stay as they are" : `${Object.keys(liftedLimits(envFiles, files.map((f) => join(root, f)))).join(", ") || "no request limits found"} (only after your yes, for this session only)`}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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. No dependencies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cortad": "local.mjs"
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"lib/app-port.mjs",
|
|
12
12
|
"lib/auto-reach.mjs",
|
|
13
13
|
"lib/backing.mjs",
|
|
14
|
+
"lib/calls.mjs",
|
|
14
15
|
"lib/cli.mjs",
|
|
15
16
|
"lib/contact-text.mjs",
|
|
16
17
|
"lib/data.mjs",
|