cortad 0.3.9 → 0.3.11
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/README.md +3 -0
- package/lib/cli.mjs +4 -1
- package/lib/commits.mjs +202 -0
- package/lib/contact-text.mjs +13 -33
- package/lib/container.mjs +60 -0
- package/lib/data.mjs +15 -9
- package/lib/elastic.mjs +88 -0
- package/lib/env-files.mjs +124 -0
- package/lib/env-secrets.mjs +12 -4
- package/lib/intel-text.mjs +47 -0
- package/lib/keeping.mjs +33 -26
- package/lib/keys.mjs +10 -6
- package/lib/lock.mjs +7 -6
- package/lib/mcp.mjs +37 -4
- package/lib/mint.mjs +27 -11
- package/lib/mongo.mjs +64 -3
- package/lib/proof.mjs +8 -1
- package/lib/read-text.mjs +15 -15
- package/lib/stores.mjs +118 -63
- package/lib/text.mjs +17 -16
- package/lib/verbs.mjs +57 -2
- package/local.mjs +170 -86
- package/package.json +6 -1
- package/skill/SKILL.md +28 -11
- package/skill/references/results.md +17 -17
package/lib/text.mjs
CHANGED
|
@@ -4,8 +4,8 @@ import { failedLines, heldLines } from "./failed-text.mjs";
|
|
|
4
4
|
import { readLines } from "./read-text.mjs";
|
|
5
5
|
import { at, clip, has, num, PAGE_CHARS, pageOf, plural, SIDE, upper } from "./words.mjs";
|
|
6
6
|
|
|
7
|
-
// What each verb prints. A result is data for the coding agent
|
|
8
|
-
//
|
|
7
|
+
// What each verb prints. A result is data for the coding agent, in plain sentences it can hand the
|
|
8
|
+
// person as they are, and a run_status result ends with the next call. A field the API leaves
|
|
9
9
|
// out prints nothing, so the text follows the API as it grows.
|
|
10
10
|
|
|
11
11
|
const DONE = new Set(["succeeded", "failed", "canceled"]);
|
|
@@ -39,8 +39,7 @@ export function statusText(d) {
|
|
|
39
39
|
lines.push(...contactLines(d.contact, d.app?.data, receipts));
|
|
40
40
|
// While the read runs its lists are zeros ("Journeys (0)", "Questions: 0; 0 asked..."): it is said
|
|
41
41
|
// once, and the lists come when it lands (the server's news says so).
|
|
42
|
-
|
|
43
|
-
if (d.read?.complete === false) { if (!d.contact?.ready?.reading || !d.contact.ready.trials) lines.push("Your code is still being read."); }
|
|
42
|
+
if (d.read?.complete === false) lines.push("Your code is still being read.");
|
|
44
43
|
else if (d.read) lines.push(...readLines(d.read));
|
|
45
44
|
if (d.run) lines.push(...runLines(d.run, "latest "));
|
|
46
45
|
// Trials the read wrote after this run chose its own: the one count that is not the run's.
|
|
@@ -49,7 +48,7 @@ export function statusText(d) {
|
|
|
49
48
|
// Production comes up after the first run (skill: The second run and production).
|
|
50
49
|
if (d.field && d.run) lines.push(`Production: ${d.field.connected ? "connected" : "not connected"}.`);
|
|
51
50
|
if (d.next) lines.push(d.next);
|
|
52
|
-
if (d.links?.lab && (d.read || d.run)) lines.push(`
|
|
51
|
+
if (d.links?.lab && (d.read || d.run)) lines.push(`The person sees this at ${d.links.lab}`);
|
|
53
52
|
if (d.run && !finished(d.run)) lines.push(`next: run_status ${d.run.jobId}`);
|
|
54
53
|
return lines.join("\n");
|
|
55
54
|
}
|
|
@@ -61,8 +60,8 @@ export const unseenText = (door) => `A request to ${door} was answered in words
|
|
|
61
60
|
// to it, or there is no AI behind it. `followed`: a model call came after it that carried none of its
|
|
62
61
|
// words.
|
|
63
62
|
export const receiptText = (door, followed) => (followed
|
|
64
|
-
? `${door} answered your request at once, with no model call inside it; a model call came after it, but its prompt carried none of the words your request sent, so Cortad cannot tell it was this request's answer. That gap is on Cortad's side, and a run leaves this endpoint out
|
|
65
|
-
: `${door} answered your request at once with no model call inside it, and no model call followed in any process Cortad started. If a worker in another process writes its answer, Cortad does not see that worker, so a run leaves this endpoint out for now; that gap is on Cortad's side. If this endpoint has no AI behind it, nothing is missing
|
|
63
|
+
? `${door} answered your request at once, with no model call inside it; a model call came after it, but its prompt carried none of the words your request sent, so Cortad cannot tell it was this request's answer. That gap is on Cortad's side, and a run leaves this endpoint out.`
|
|
64
|
+
: `${door} answered your request at once with no model call inside it, and no model call followed in any process Cortad started. If a worker in another process writes its answer, Cortad does not see that worker, so a run leaves this endpoint out for now; that gap is on Cortad's side. If this endpoint has no AI behind it, nothing is missing.`);
|
|
66
65
|
// Only a door the read lists as one no request has reached, and no request has proven since: a sign-up
|
|
67
66
|
// or a guard's reply to a request a later one proved is no worker's answer.
|
|
68
67
|
const receiptsSaid = (receipts = [], c) => {
|
|
@@ -185,7 +184,9 @@ function runLines(d, prefix = "") {
|
|
|
185
184
|
for (const f of d.faults ?? []) lines.push(`Fault${SIDE[f.side] ? ` ${SIDE[f.side]}` : ""}: ${f.what}${f.fix ? ` ${f.fix}` : ""}`);
|
|
186
185
|
for (const s of d.data ?? []) lines.push(`Data: ${s}`);
|
|
187
186
|
// A run the stop line already explains is not said twice as an error.
|
|
188
|
-
|
|
187
|
+
// A failed run with no stop line of its own was stopped by Cortad, never by their app: said as that, with
|
|
188
|
+
// our error after it for a note to the team. "Error: timeout exceeded when trying to connect" read as the app's.
|
|
189
|
+
if (d.error && !(d.stopped && d.error.startsWith(d.stopped.why))) lines.push(d.status === "failed" && !d.stopped ? `The run stopped on Cortad's side, not your app's. Start another run. (Cortad's error: ${d.error})` : `Error: ${d.error}`);
|
|
189
190
|
if (d.verify) lines.push(...verifyLines(d.verify));
|
|
190
191
|
return lines;
|
|
191
192
|
}
|
|
@@ -247,10 +248,10 @@ export function nextCall(d) {
|
|
|
247
248
|
return d.kind === "verify" || d.findings > 0 ? "findings" : "status";
|
|
248
249
|
}
|
|
249
250
|
|
|
250
|
-
// One line
|
|
251
|
+
// One line to hand the person while a run plays: conversations done of the total, whose side any failed
|
|
251
252
|
// reply is on, and the minutes left at the pace measured so far. Cost is measured only at the end.
|
|
252
253
|
export function progressLine(d, now = Date.now()) {
|
|
253
|
-
if (!d.of) return "
|
|
254
|
+
if (!d.of) return "The run has started. No conversation has played yet.";
|
|
254
255
|
const parts = [`${num(d.played ?? 0)} of ${plural(d.of, "conversation")} done.`];
|
|
255
256
|
const total = d.replies?.total ?? d.waited?.of;
|
|
256
257
|
const crashed = d.replies?.crashed ?? 0;
|
|
@@ -262,7 +263,7 @@ export function progressLine(d, now = Date.now()) {
|
|
|
262
263
|
if (total > 0 && ours) parts.push(`${num(ours)} of ${plural(total, "reply", "replies")} so far failed on Cortad's side, not your app's.`);
|
|
263
264
|
const left = Date.parse(d.pace?.endsAt ?? "") - now;
|
|
264
265
|
if (left > 0) parts.push(`About ${plural(Math.ceil(left / 60_000), "minute")} left at the pace your app has answered so far.`);
|
|
265
|
-
return
|
|
266
|
+
return parts.join(" ");
|
|
266
267
|
}
|
|
267
268
|
|
|
268
269
|
export function runText(d) {
|
|
@@ -273,7 +274,7 @@ export function runText(d) {
|
|
|
273
274
|
if (plan) lines.push(plan);
|
|
274
275
|
const offer = productionOffer(d);
|
|
275
276
|
if (offer) lines.push(offer);
|
|
276
|
-
if (finished(d) && d.url) lines.push(`
|
|
277
|
+
if (finished(d) && d.url) lines.push(`The report is at ${d.url}`);
|
|
277
278
|
lines.push(`next: ${nextCall(d)}`);
|
|
278
279
|
return verifyOnceAPage(lines.join("\n"));
|
|
279
280
|
}
|
|
@@ -283,7 +284,7 @@ export function runText(d) {
|
|
|
283
284
|
function productionOffer(d) {
|
|
284
285
|
const v = d.verify;
|
|
285
286
|
if (d.kind !== "verify" || !finished(d) || v?.verdict !== "gone" || !["gone", "less often"].includes(v.heldOut?.moved) || d.field?.connected !== false) return null;
|
|
286
|
-
return `
|
|
287
|
+
return `The failure in ${v.findingId} is gone on its replays. Connecting production shows what real users are doing as it happens; field_connect has the steps.`;
|
|
287
288
|
}
|
|
288
289
|
|
|
289
290
|
export function startedText(d, kind, findingId, said = []) {
|
|
@@ -311,7 +312,7 @@ export function waitingText(p, now, limitMs, runner, app) {
|
|
|
311
312
|
const kind = p.kind === "verify" ? "verify" : "run";
|
|
312
313
|
const waited = `${num((now - Date.parse(p.startedAt)) / 1000)} of up to ${num(limitMs / 1000)} seconds`;
|
|
313
314
|
const head = runner?.state === "up" ? `${answeringText(runner, app)}\nThe ${kind} has waited ${waited}.` : `Your app is still starting for the ${kind}: ${waited}.`;
|
|
314
|
-
return `${head}\
|
|
315
|
+
return `${head}\nnext: run_status pending`;
|
|
315
316
|
}
|
|
316
317
|
|
|
317
318
|
// A spent plan: what the last run found, what was fixed since, what the next run would play.
|
|
@@ -322,7 +323,7 @@ export function refusedText(d) {
|
|
|
322
323
|
if (d.ledger) lines.push(...ledgerLines(d.ledger));
|
|
323
324
|
if (!d.ledger?.plan && d.plans?.length) lines.push(`Plans: ${d.plans.map((p) => `${p.name} $${p.monthlyUsd} a month`).join(", ")}.`);
|
|
324
325
|
lines.push("Nothing ran.");
|
|
325
|
-
if (d.checkout) lines.push(`
|
|
326
|
+
if (d.checkout) lines.push(`Plans and checkout are at ${d.checkout}`);
|
|
326
327
|
return lines.join("\n");
|
|
327
328
|
}
|
|
328
329
|
|
|
@@ -536,7 +537,7 @@ function alsoLines(f) {
|
|
|
536
537
|
export function fieldText(d) {
|
|
537
538
|
const t = d.totals;
|
|
538
539
|
const pct = (k, n) => (n ? `${Math.round((100 * k) / n)}%` : "n/a");
|
|
539
|
-
const link = d.url ? [`
|
|
540
|
+
const link = d.url ? [`The person sees this at ${d.url}`] : [];
|
|
540
541
|
if (!t || !t.n) return [`No production conversations read in the last ${plural(d.days, "day")}.`, ...link].join("\n");
|
|
541
542
|
const lines = [
|
|
542
543
|
`Production, last ${plural(d.days, "day")}: ${plural(t.n, "conversation")}, ${num(t.read)} read.`,
|
package/lib/verbs.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { askReach } from "./auto-reach.mjs";
|
|
|
6
6
|
import { homeOf, projectOf } from "./home.mjs";
|
|
7
7
|
import { reach as reachApp } from "./reach.mjs";
|
|
8
8
|
import { fieldConnectText, fieldText, findingsText, finished, linesOf, notStartedText, refusedText, restartedText, runText, STARTING, startedText, statusText, testedLine, upWaitingText, waitingText } from "./text.mjs";
|
|
9
|
+
import { incidentsText, intelligenceText, outcomesText, queryText } from "./intel-text.mjs";
|
|
9
10
|
|
|
10
11
|
// The ten things a coding agent may ask of Cortad, each a call to the API with this machine's key
|
|
11
12
|
// and a rendering from lib/text.mjs; `reach` also sends to the app on this machine. The MCP tools
|
|
@@ -16,7 +17,7 @@ import { fieldConnectText, fieldText, findingsText, finished, linesOf, notStarte
|
|
|
16
17
|
// the run before them has its numbers in and its job is still ending: the server holds them until it
|
|
17
18
|
// has, up to the same 45 seconds.
|
|
18
19
|
|
|
19
|
-
const NOT_CONNECTED = "This folder is not connected to Cortad. Run npx cortad in this folder as a background command: it prints a link for the person to approve
|
|
20
|
+
const NOT_CONNECTED = "This folder is not connected to Cortad. Run npx cortad in this folder as a background command: it prints a link for the person to approve in a browser where they are signed in to Cortad.";
|
|
20
21
|
const HOLD_S = 45;
|
|
21
22
|
// `wait`: how often it asks whether a run has started, how long the connect command may be gone
|
|
22
23
|
// before no run can start, and how long it waits for one at all.
|
|
@@ -286,11 +287,65 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
|
|
|
286
287
|
return res.ok ? { text: fieldConnectText(res.data), data: res.data } : failed(res);
|
|
287
288
|
};
|
|
288
289
|
|
|
290
|
+
// A world for an AI that is not a chat (the server's world/run.ts): built on this machine from the
|
|
291
|
+
// app's own seed, planted with problems whose answers are known, discovered by the app's own code.
|
|
292
|
+
// `ask`: what the agent built or wants tested, kept with the report. Answers at once with the id.
|
|
293
|
+
const world = async ({ ask } = {}) => {
|
|
294
|
+
const res = await call("POST", "/mcp/world", ask ? { ask: String(ask) } : {});
|
|
295
|
+
if (!res.ok) return failed(res);
|
|
296
|
+
if (res.data?.joined) return { text: `A world is already being tested: ${res.data.jobId}.\nnext: world_status ${res.data.jobId}`, data: res.data };
|
|
297
|
+
if (!res.data?.started) return { text: `${res.data?.why ?? "No world started."}\nNothing ran.`, data: res.data, isError: true, code: "world_refused" };
|
|
298
|
+
const planted = Array.isArray(res.data.situations) && res.data.situations.length ? `Planted: ${res.data.situations.join("; ")}.` : `${res.data.worlds ?? ""} worlds.`;
|
|
299
|
+
return { text: `World started: ${res.data.jobId}. ${planted} Each is tested ${res.data.repeats} times. The person sees it at ${res.data.url}\nnext: world_status ${res.data.jobId}`, data: res.data };
|
|
300
|
+
};
|
|
301
|
+
// The one door: the agent says what it built or wants tested, and Cortad decides what kind of test
|
|
302
|
+
// that is. A world where the app has one and the ask names something it can plant; a run of the
|
|
303
|
+
// app's chat surfaces otherwise. The answer says which, and what to follow it with.
|
|
304
|
+
const test = async ({ ask } = {}) => {
|
|
305
|
+
const res = await call("POST", "/mcp/world", { ...(ask ? { ask: String(ask) } : {}), only: "matched" });
|
|
306
|
+
if (res.ok && res.data?.started) return { text: `This is tested in a world. World started: ${res.data.jobId}. Planted: ${(res.data.situations ?? []).join("; ")}. Each is tested ${res.data.repeats} times. The person sees it at ${res.data.url}\nnext: world_status ${res.data.jobId}`, data: res.data };
|
|
307
|
+
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 };
|
|
308
|
+
const why = res.ok ? (res.data?.unmatched ? "Nothing a world can plant is what that is about, so it is tested by a run of the app's chat surfaces." : "No world is written for this app yet, so it is tested by a run of the app's chat surfaces.") : "The world could not be asked, so it is tested by a run of the app's chat surfaces.";
|
|
309
|
+
const ran = await run({});
|
|
310
|
+
return { ...ran, text: `${why}\n${ran.text}` };
|
|
311
|
+
};
|
|
312
|
+
const worldStatus = async ({ jobId } = {}) => {
|
|
313
|
+
if (!jobId) return { text: "Name the world: world_status <id>.", isError: true, code: "no_id" };
|
|
314
|
+
const res = await call("GET", `/mcp/world/${encodeURIComponent(String(jobId))}`);
|
|
315
|
+
if (!res.ok) return failed(res);
|
|
316
|
+
const running = res.data.status === "queued" || res.data.status === "running";
|
|
317
|
+
return { text: `${res.data.text}\nThe person sees this at ${res.data.url}\nnext: ${running ? `world_status ${jobId}` : "status"}`, data: res.data };
|
|
318
|
+
};
|
|
319
|
+
|
|
289
320
|
const field = async ({ days } = {}) => {
|
|
290
321
|
const res = await call("GET", `/mcp/field${days ? `?days=${Number(days)}` : ""}`);
|
|
291
322
|
return res.ok ? { text: fieldText(res.data), data: res.data } : failed(res);
|
|
292
323
|
};
|
|
293
324
|
|
|
325
|
+
// The Intelligence Layer: the board in numbers, what is wrong, what counts as an outcome, a
|
|
326
|
+
// subscription to incidents, and a question to the warehouse.
|
|
327
|
+
const intelligence = async ({ days } = {}) => {
|
|
328
|
+
const res = await call("GET", `/mcp/intelligence${days ? `?days=${Number(days)}` : ""}`);
|
|
329
|
+
return res.ok ? { text: intelligenceText(res.data), data: res.data } : failed(res);
|
|
330
|
+
};
|
|
331
|
+
const incidents = async () => {
|
|
332
|
+
const res = await call("GET", "/mcp/incidents");
|
|
333
|
+
return res.ok ? { text: incidentsText(res.data), data: res.data } : failed(res);
|
|
334
|
+
};
|
|
335
|
+
const outcomes = async ({ proposals } = {}) => {
|
|
336
|
+
const res = proposals ? await call("POST", "/mcp/outcomes", { proposals }) : await call("GET", "/mcp/outcomes");
|
|
337
|
+
return res.ok ? { text: outcomesText(res.data), data: res.data } : failed(res);
|
|
338
|
+
};
|
|
339
|
+
const subscribe = async ({ url }) => {
|
|
340
|
+
const res = await call("POST", "/mcp/subscribe", { url });
|
|
341
|
+
return res.ok ? { text: `Subscribed. ${res.data.said}
|
|
342
|
+
Secret, shown once: ${res.data.secret}`, data: res.data } : failed(res);
|
|
343
|
+
};
|
|
344
|
+
const query = async (ask) => {
|
|
345
|
+
const res = await call("POST", "/mcp/query", ask);
|
|
346
|
+
return res.ok ? { text: queryText(res.data), data: res.data } : { text: queryText(res.data ?? { error: res.text }), data: res.data };
|
|
347
|
+
};
|
|
348
|
+
|
|
294
349
|
// `status --changed`: the files Cortad cites that changed since the latest run started, with the
|
|
295
350
|
// findings at them. Empty when there is nothing to say, or nothing could be asked. The code review's
|
|
296
351
|
// files are those of the review shown: the read's misses, or the lines Cortad's team's review cites.
|
|
@@ -312,7 +367,7 @@ export function makeVerbs({ api, token, runner = () => null, fetchImpl = fetch,
|
|
|
312
367
|
};
|
|
313
368
|
|
|
314
369
|
// `post` and `changed` serve lib/cli.mjs; the MCP answers only the names in TOOLS.
|
|
315
|
-
return { status, reach, run, run_status: runStatus, wait, findings, verify, dispute, feedback, field_connect: fieldConnect, field, post, changed };
|
|
370
|
+
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 };
|
|
316
371
|
}
|
|
317
372
|
|
|
318
373
|
const sizeOf = (path) => { try { return statSync(path).size; } catch { return 0; } };
|