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 +23 -0
- package/lib/cli.mjs +4 -2
- package/lib/mcp.mjs +20 -4
- package/lib/verbs.mjs +15 -9
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.3.
|
|
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",
|