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/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: a line meant for the person starts
8
- // with "For the person:", and a run_status result ends with the next call. A field the API leaves
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
- // The quote says it when it is said (it names the read with what the run does about it).
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(`For the person: ${d.links.lab} shows this in the browser.`);
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.\nFor the person: ${door} answers later in a way Cortad cannot match to the message that asked, so a run leaves it out for now; that gap is on Cortad's side.`
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.\nFor the person: ${door} answered with no AI call Cortad could see; if its AI runs in a separate worker, a run leaves it out for now, and that gap is on Cortad's side.`);
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
- if (d.error && !(d.stopped && d.error.startsWith(d.stopped.why))) lines.push(`Error: ${d.error}`);
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 for the person while a run plays: conversations done of the total, whose side any failed
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 "For the person: the run has started and is choosing its conversations; none has played yet.";
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 `For the person: ${parts.join(" ")}`;
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(`For the person: the report is at ${d.url}`);
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 `For the person: the failure in ${v.findingId} is gone on its replays. Connect production to see what your real users are doing as it happens and where your AI still lets them down; field_connect has the steps.`;
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}\nFor the person: the run has not started yet, after ${waited}.\nnext: run_status pending`;
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(`For the person: plans and checkout at ${d.checkout}`);
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 ? [`For the person: ${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.\nFor the person: approve the link npx cortad prints, in a browser where you are signed in to Cortad.";
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; } };