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 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
- export function serveMcp({ verbs, version, spec, input = process.stdin, output = process.stdout, log = () => {} }) {
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", () => resolve(0));
82
- input.on("close", () => resolve(0));
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) return send(result(id, { content: [{ type: "text", text: `No tool ${String(name)}.` }], isError: true }));
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
- const failed = (res) => ({ text: res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`, data: res.data, isError: true });
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, and data rather than code.
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|parquet|csv|tsv|jsonl|ndjson|xlsx?|numbers|DS_Store)$/i;
138
- const MAX_FILE = 1_000_000;
139
- // A JSON or YAML file this large is a dataset (exports, catalogues, fixtures), not code or a prompt,
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 data, media or lock file${left.length === 1 ? "" : "s"}: ${left.slice(0, 6).join(", ")}${left.length > 6 ? ", ..." : ""}`] : []),
1154
- `not sent anything git ignores, env files (${envFiles.length} here: ${envFiles.slice(0, 6).map(rel).join(", ") || "none"}), key files, data files, node_modules, .git`,
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.1",
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",