cortad 0.3.1 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",