cortad 0.3.1-rc.8 → 0.3.1

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.
Files changed (56) hide show
  1. package/README.md +8 -5
  2. package/lib/app-port.mjs +9 -0
  3. package/lib/auto-reach.mjs +88 -0
  4. package/lib/backing.mjs +230 -0
  5. package/lib/cli.mjs +113 -30
  6. package/lib/contact-text.mjs +167 -0
  7. package/lib/data.mjs +16 -22
  8. package/lib/dissection-text.mjs +232 -0
  9. package/lib/document.mjs +75 -0
  10. package/lib/env-secrets.mjs +48 -0
  11. package/lib/failed-text.mjs +36 -0
  12. package/lib/fresh.mjs +33 -12
  13. package/lib/health.mjs +61 -0
  14. package/lib/held.mjs +67 -0
  15. package/lib/home.mjs +55 -17
  16. package/lib/hosted.mjs +201 -0
  17. package/lib/identities.mjs +1 -1
  18. package/lib/keeping.mjs +162 -0
  19. package/lib/ledger.mjs +20 -0
  20. package/lib/lock.mjs +6 -1
  21. package/lib/mcp.mjs +33 -15
  22. package/lib/mint.mjs +179 -38
  23. package/lib/mongo.mjs +74 -0
  24. package/lib/numbers-text.mjs +68 -0
  25. package/lib/pg.mjs +118 -0
  26. package/lib/proc.mjs +100 -0
  27. package/lib/proof.mjs +1014 -0
  28. package/lib/proxy.mjs +184 -0
  29. package/lib/pyhook/cortad_sites.py +218 -0
  30. package/lib/pyhook/cortad_tied.py +291 -0
  31. package/lib/pyhook/sitecustomize.py +1267 -153
  32. package/lib/qdrant.mjs +47 -0
  33. package/lib/reach.mjs +223 -0
  34. package/lib/read-text.mjs +78 -24
  35. package/lib/redis.mjs +130 -0
  36. package/lib/register.mjs +36 -27
  37. package/lib/replay.mjs +467 -34
  38. package/lib/runner.mjs +85 -0
  39. package/lib/service.mjs +25 -0
  40. package/lib/spec.mjs +7 -4
  41. package/lib/start.mjs +33 -2
  42. package/lib/stick.mjs +65 -25
  43. package/lib/stores.mjs +347 -0
  44. package/lib/switches.mjs +36 -0
  45. package/lib/tether.mjs +9 -0
  46. package/lib/text.mjs +321 -177
  47. package/lib/tied.cjs +217 -0
  48. package/lib/trace.cjs +399 -480
  49. package/lib/verbs.mjs +142 -31
  50. package/lib/wire.cjs +882 -0
  51. package/lib/words.mjs +2 -0
  52. package/lib/writes.mjs +2 -2
  53. package/local.mjs +661 -297
  54. package/package.json +30 -1
  55. package/skill/SKILL.md +49 -42
  56. package/skill/references/results.md +279 -102
@@ -0,0 +1,167 @@
1
+ import { at, clip, has, num, plural } from "./words.mjs";
2
+
3
+ // What `status` says between connect and a run: each endpoint a real request proved, with what the
4
+ // hook saw inside those requests; each endpoint the read found that no request has reached; what a
5
+ // run on the proven ones would be, and what it would write into. Every figure is one the agent can
6
+ // check against its own request or a line of the code. A field the API leaves out prints nothing.
7
+
8
+ const CARRIER = {
9
+ body: (k) => `the body field "${k}"`,
10
+ cookie: (k) => `the cookie "${k}"`,
11
+ header: (k) => `the header "${k}"`,
12
+ path: (k) => `the path segment {${k}}`,
13
+ history: (k) => `the earlier messages the client sends in "${k}"`,
14
+ };
15
+ const where = (x) => (x?.file || x?.path ? `${at(x.file ?? x.path, x.line)}: ` : "");
16
+ const TIED = { sent: "the first request sent", got: "the first request got back" };
17
+
18
+ // A door whose answer came on a second request: that request, what tied the two, and what the client
19
+ // sent first in each turn, each as the agent's own requests showed it.
20
+ function secondStepLine(s) {
21
+ if (!s?.request) return null;
22
+ const tie = s.tie?.key && TIED[s.tie.from] ? `, tied by the "${s.tie.key}" ${TIED[s.tie.from]}` : "";
23
+ const before = s.before?.length ? `; each turn first sent ${s.before.join(", ")}` : "";
24
+ return `The answer came on a second request, ${s.request}${tie}${before}.`;
25
+ }
26
+
27
+ function sessionLine(v) {
28
+ const s = v.session;
29
+ if (!s || !(v.requests > 1)) return null;
30
+ if (!s.held) return "No later request's prompt carried an earlier one.";
31
+ const by = CARRIER[s.carrier] && s.key ? CARRIER[s.carrier](s.key) : "nothing the request sent, so every caller shares what the app keeps";
32
+ return `A later request's prompt carried an earlier one, tied by ${by}.`;
33
+ }
34
+
35
+ // A prompt with none of the rules is said with the file its call is made from, and whether Cortad
36
+ // read any rule there: "carried none" could not tell a gap in Cortad's read from one in the app.
37
+ // `inFile`, the rules read from that file, is said when the server sends it.
38
+ function rulesLine(r) {
39
+ if (!r || !has(r.of) || r.of === 0) return null;
40
+ if (!r.carried && r.at?.path && r.readThere === false) return `None of the ${plural(r.of, "rule")} Cortad read from your code are in the prompt this endpoint sent, and Cortad read no rule from ${r.at.path}, where the call is made; the prompt at ${at(r.at.path, r.at.line)} shows whether Cortad's read missed rules written there or your app sends this prompt without any.`;
41
+ if (!r.carried && r.at?.path && r.readThere === true) return `The prompt this endpoint sent from ${at(r.at.path, r.at.line)} holds none of the ${has(r.inFile) ? `${plural(r.inFile, "rule")} ` : "rules "}Cortad read from that file.`;
42
+ // A server from before `at` and `readThere` keeps its own words.
43
+ const reader = "at" in r || "readThere" in r ? "Cortad read" : "read";
44
+ const head = `The prompts carried ${r.carried ? num(r.carried) : "none"} of the ${plural(r.of, "rule")} ${reader} from your code`;
45
+ const example = (r.examples ?? [])[0];
46
+ return `${head}${example ? `, for example ${at(example.path, example.line)} "${clip(example.text, 160)}"` : ""}.`;
47
+ }
48
+
49
+ // Whose it is, as a sentence of its own: "Run waits, on the app's side:" read as a fault of the app's.
50
+ const WHOSE = { ours: "That is on Cortad's side, not your app's.", theirs: "That is in your app." };
51
+ // A door runs do not use yet, or one only Cortad's requests reached, and why.
52
+ function standingLine(v) {
53
+ if (v.standing === "excluded") return `Runs leave this endpoint out: ${v.why}${WHOSE[v.side] ? ` ${WHOSE[v.side]}` : ""}`;
54
+ if (v.standing === "fallback") return `Answered only Cortad's own requests: ${v.why}`;
55
+ return null;
56
+ }
57
+
58
+ // Who the conversations through a signed-in endpoint act as: the customers the agent's requests signed in as.
59
+ const accountsLine = (n) => (!has(n) ? null : n > 1 ? `Conversations are split across ${num(n)} accounts, one for each customer your requests signed in as.` : "All conversations act as one account.");
60
+
61
+ // Where a trial takes each value the app's own client sets for its model.
62
+ const appFieldLine = (f) => (f.from === "app" ? `"${f.field}" is set by your app${f.path ? ` at ${f.path}` : ""}.` : `"${f.field}" is only in your agent's request.`);
63
+
64
+ // The message Cortad took from the last request, so a wrong field shows; the reply is the agent's
65
+ // own, already read, and ten of them were the longest part of ulaim's status. One with no message
66
+ // found keeps a short reply to know it by.
67
+ function sampleLine(s) {
68
+ if (!s?.reply) return null;
69
+ const cut = s.cut ? "; the reply stopped before it finished" : "";
70
+ return s.ask ? `Last message: "${clip(s.ask, 160)}"${cut}.` : `Last reply: "${clip(s.reply, 80)}"${cut}.`;
71
+ }
72
+
73
+ function doorLines(v) {
74
+ const reached = `${plural(v.requests, "request")} reached ${v.model ?? "the model"}`;
75
+ const calls = has(v.modelCallsPerRequest) ? `, ${plural(v.modelCallsPerRequest, "model call")} each` : "";
76
+ const seconds = has(v.replySeconds) ? `, ${v.replySeconds} seconds a reply` : "";
77
+ const lines = [` ${v.door}: ${reached}${calls}${seconds}.`];
78
+ const inner = [
79
+ standingLine(v),
80
+ v.askField ? `The message goes in "${v.askField}".` : null,
81
+ ...(v.appFields ?? []).map(appFieldLine),
82
+ secondStepLine(v.secondStep),
83
+ v.later ? "Your app takes the message at once and its model answers afterwards; Cortad grades the model's last answer to each message." : null,
84
+ sessionLine(v),
85
+ accountsLine(v.accounts),
86
+ ...(v.checks ?? []).map((said) => `Cortad's test conversation here: ${said}`),
87
+ rulesLine(v.rules),
88
+ v.tools?.length ? `Tools that ran: ${v.tools.join(", ")}.` : null,
89
+ v.passages ? `Passages handed to the model: ${num(v.passages)}.` : null,
90
+ ...(v.problems ?? []).map((p) => `Problem${where(p) ? ` at ${where(p)}` : ": "}${p.said}`),
91
+ sampleLine(v.sample),
92
+ ];
93
+ return [...lines, ...inner.filter(Boolean).map((l) => ` ${l}`)];
94
+ }
95
+
96
+ // What a run would play, take and cost, said to the person: the conversations it starts with (of
97
+ // those planned, the ones that fit in one run's time), the follow-ups apart, the time at the widest it
98
+ // goes and what it costs on their own keys, or that no price could be read. "Written" was said of
99
+ // conversations the run writes as it starts: "44 of the 79 trials written" beside "Trials: 39 written".
100
+ function readyLine(r) {
101
+ if (!r || !r.trials) return null;
102
+ const soFar = r.reading ? " so far" : "";
103
+ const plays = has(r.planned) && r.planned > r.trials ? `${num(r.trials)} of the ${plural(r.planned, "conversation")} planned${soFar}, as many as fit in one run's time` : `${plural(r.trials, "conversation")}${soFar}`;
104
+ // Follow-ups are played only where something breaks: said apart, never in the count above.
105
+ const followUps = r.followUps ? `, and up to ${num(r.followUps)} more where something breaks` : "";
106
+ // The run widens only while replies keep pace, and a one-at-a-time app holds it to one: the time is
107
+ // a range when that is slower, and where fewer conversations fit at that pace, how many is said.
108
+ const upTo = has(r.atOne?.minutes) && r.atOne.minutes > r.minutes ? r.atOne.minutes : null;
109
+ const atOne = has(r.atOne?.trials) && r.atOne.trials < r.trials ? ` If your app answers one request at a time, about ${num(r.atOne.trials)} of them fit in that time.` : "";
110
+ const minutes = has(r.minutes) && `in about ${upTo ? `${num(r.minutes)} to ${plural(upTo, "minute")}` : plural(r.minutes, "minute")}`;
111
+ const size = [has(r.replies) && `about ${plural(r.replies, "reply", "replies")}`, minutes].filter(Boolean).join(" ");
112
+ const usd = (x) => `$${x.toFixed(2)}`;
113
+ // Some endpoints had no published rate or token counts: the price covers the others and says so.
114
+ // A first run is priced at what it is expected to cost (`expected`, from factors measured on recorded
115
+ // first runs) and the most it costs (`upTo`); a server from before `expected` sends the most alone.
116
+ // After a run of the app's own, as what that run measured. An older server sends a range (`usdHigh`).
117
+ // Where some endpoints had no price, on how many.
118
+ const amount = () => `${r.upTo ? "up to" : "about"} ${usd(r.usd)}${has(r.usdHigh) ? ` to ${usd(r.usdHigh)}` : ""}`;
119
+ const some = r.pricedOn ? `, on the ${num(r.pricedOn.endpoints)} of ${plural(r.pricedOn.of, "endpoint")} Cortad could price` : "";
120
+ const onKey = `on your ${r.provider ? `${r.provider} ` : ""}key${r.model ? ` for ${r.model}` : ""}`;
121
+ const both = r.upTo && has(r.expected);
122
+ // No price is said, as the card says it (guide.ts quoteLine), never left out.
123
+ const cost = has(r.usd)
124
+ ? ` ${both ? `It is expected to cost ${usd(r.expected)}${soFar} ${onKey}, and the most it costs is ${usd(r.usd)}` : `It costs ${amount()}${soFar} ${onKey}`}${some}${r.estimated ? ", estimated from the length of the text since your provider sent no token counts" : ""}.`
125
+ : " It runs on your app's own model keys; Cortad could not price it from what your app's model calls reported.";
126
+ // No rules is a fact of a finished read: said while the code is still being read, it was false a minute later.
127
+ const rules = !has(r.rules) || (r.reading && r.rules === 0) ? "" : r.rules === 0 ? " No rules of your own were read from the code, so replies are checked against general standards only." : ` Every reply is checked against the ${plural(r.rules, "rule")} read from your code.`;
128
+ return `For the person: a run plays ${plays}${followUps}${size ? `: ${size}` : ""}.${atOne}${cost}${rules}${r.reading ? " Your code is still being read; the run waits for it and plays every conversation it adds." : ""}`;
129
+ }
130
+
131
+ // The endpoints no request has reached, as status lists them and reach sends them. `receipts`: doors
132
+ // the App section says answered at once with their answer out of Cortad's sight, so not said here.
133
+ export const unreached = (c, receipts = []) => {
134
+ const answered = new Set(receipts.map((x) => x.door));
135
+ return (c?.notCalled ?? []).filter((m) => !answered.has(`${m.method} ${m.path}`));
136
+ };
137
+
138
+ // One the server has no request for, or one listed on a field's name alone, is the agent's to send.
139
+ // `signIn` and `failed`: what Cortad's own test request met there, said as the server words it.
140
+ function unreachedLine(m) {
141
+ const said = [m.excluded, m.signIn && `${m.signIn}.`, m.failed, !m.excluded && (m.unsure || m.byHand) ? "By hand." : "", m.byHand, m.why].filter(Boolean).join(" ");
142
+ return ` ${m.method} ${m.path}${m.file ? ` ${at(m.file, m.line)}` : ""}${said ? ` ${said}` : ""}`;
143
+ }
144
+
145
+ // Cortad sends the rest itself, said only where the server has a request ready for one of them.
146
+ function reachLine(missing) {
147
+ if (!missing.some((m) => m.request && !m.unsure)) return null;
148
+ const except = [missing.some((m) => m.unsure || m.byHand) && "marked by hand", missing.some((m) => m.excluded) && "left out of runs"].filter(Boolean);
149
+ return `Cortad sends each of them a test request${except.length ? `, except any ${except.join(" or ")}` : ""}.`;
150
+ }
151
+
152
+ // `data`: what the command did with the app's stores and services, said right under the run it is
153
+ // about, so what a run writes into for real is read before Run is pressed.
154
+ export function contactLines(c, data = [], receipts = []) {
155
+ const said = data.map((s) => `Data: ${s}`);
156
+ if (!c) return said;
157
+ const proven = c.proven ?? [];
158
+ // No request yet is said by the App line and by the next step, not a third time here.
159
+ const lines = proven.length ? [`Endpoints that answered (${num(proven.length)}):`, ...proven.flatMap(doorLines)] : [];
160
+ const missing = unreached(c, receipts);
161
+ if (missing.length) lines.push(`Endpoints found in your code that have not answered yet (${num(missing.length)}):`, ...missing.map(unreachedLine), ...[reachLine(missing)].filter(Boolean));
162
+ const ready = readyLine(c.ready);
163
+ if (ready) lines.push(ready);
164
+ // What holds Run is the server's next line (src/local/contact.ts heldLine), said once: printed here
165
+ // as well, ulaim's hold on its Qdrant was read twice in one status.
166
+ return [...lines, ...said];
167
+ }
package/lib/data.mjs CHANGED
@@ -2,24 +2,21 @@
2
2
  // records by the hundred; written into the file your app keeps its real data in, they stay there
3
3
  // after the run. So an app started here runs against a copy of its database file, made in this
4
4
  // command's own temp folder and pointed at through the same variable, and the original is never
5
- // opened. What cannot be copied (a database server, a file your code names with no variable in
6
- // front of it, an app that was already running) is said before the first run, in one sentence.
5
+ // opened. What cannot be copied (a file your code names with no variable in front of it, an app that
6
+ // was already running) is said before the first run, in one sentence. A database server is copied
7
+ // on its own server (lib/stores.mjs).
7
8
  import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
8
9
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
10
+ import { storesOf } from "./stores.mjs";
9
11
 
10
12
  const FILE_DB = /\.(?:db|sqlite3?|duckdb)$/i;
11
13
  // sqlite:///x.db (SQLAlchemy, relative), sqlite:////abs/x.db, sqlite+aiosqlite:///x.db, file:./dev.db (Prisma, libsql).
12
14
  const FILE_URL = /^(sqlite(?:\+\w+)?:\/\/\/|file:(?:\/\/(?=\/))?)([^?]*)(\?.*)?$/i;
13
- const SERVER_URL = /^(postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|mssql|sqlserver|cockroachdb)(?:\+\w+)?:\/\//i;
14
- const ENGINE = { postgres: "Postgres", postgresql: "Postgres", cockroachdb: "CockroachDB", mysql: "MySQL", mariadb: "MariaDB", mongodb: "MongoDB", "mongodb+srv": "MongoDB", mssql: "SQL Server", sqlserver: "SQL Server" };
15
15
 
16
- // What one value names: a database file (with how to write the value again for another path), a
17
- // database server, or nothing.
16
+ // What one value names: a database file, with how to write the value again for another path, or nothing.
18
17
  export function databaseOf(value) {
19
18
  const v = String(value ?? "").trim();
20
19
  if (!v) return null;
21
- const server = SERVER_URL.exec(v);
22
- if (server) return { server: ENGINE[server[1].toLowerCase()] ?? server[1] };
23
20
  const url = FILE_URL.exec(v);
24
21
  if (url) {
25
22
  const path = decodeURIComponent(url[2]);
@@ -56,7 +53,7 @@ const sizeOf = (dir, cap) => {
56
53
  const DEFAULTED = /(?:os\.(?:environ\.get|getenv)|\bgetenv)\(\s*['"]([A-Za-z_]\w*)['"]\s*,\s*['"]([^'"\n]+)['"]|process\.env(?:\.([A-Za-z_]\w*)|\[\s*['"]([A-Za-z_]\w*)['"]\s*\])\s*(?:\?\?|\|\|)\s*['"]([^'"\n]+)['"]/g;
57
54
  // A database file opened by a path written in the code, with no variable in front of it.
58
55
  const OPENED = /(?:sqlite3\.connect|aiosqlite\.connect|duckdb\.connect|new\s+(?:sqlite3\.)?Database|create_(?:async_)?engine)\(\s*['"]([^'"\n]+)['"]/g;
59
- const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|php)$/;
56
+ export const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|php)$/;
60
57
  // A test opens its own scratch file; that is not where the app keeps its data.
61
58
  export const TEST = /(?:^|\/)(?:tests?|__tests__|spec|e2e|fixtures?)\/|[._-](?:test|spec)\.[^/]+$|(?:^|\/)(?:test_[^/]+|conftest)\.py$/;
62
59
  // A folder bigger than this is not copied: said instead, so the person decides.
@@ -72,7 +69,7 @@ function scan(sources) {
72
69
  for (const m of text.matchAll(DEFAULTED)) {
73
70
  const name = m[1] ?? m[3] ?? m[4];
74
71
  const value = m[2] ?? m[5];
75
- if (name && !(name in defaults) && databaseOf(value)) defaults[name] = value;
72
+ if (name && !(name in defaults) && (databaseOf(value) || storesOf({ [name]: value }).length)) defaults[name] = value;
76
73
  }
77
74
  for (const m of text.matchAll(OPENED)) { const db = databaseOf(m[1]); if (db?.path) opened.push({ file, path: db.path }); }
78
75
  }
@@ -90,7 +87,8 @@ function located(db, dirs, appDir) {
90
87
  }
91
88
 
92
89
  // values: the environment the app will start with, names to values. from: the env file each name
93
- // was read from, when there is one. Returns the variables to start the app with, and what to say.
90
+ // was read from, when there is one. Returns the variables to start the app with, what to say, and
91
+ // the settings with the code's own defaults beside them, which the stores are read from.
94
92
  export function keepData({ values, from = {}, appDir, root = appDir, work, sources = [], started = true }) {
95
93
  const { defaults, opened } = scan(sources);
96
94
  const all = { ...defaults, ...values };
@@ -104,24 +102,20 @@ export function keepData({ values, from = {}, appDir, root = appDir, work, sourc
104
102
  const folder = db ? null : dataFolder(name, value, from[name] ? [dirname(from[name])] : [], appDir, root);
105
103
  if (folder) {
106
104
  moved.add(folder);
107
- if (!started) { said.push(`Your app was already running when this command started, so it writes to its own data folder, ${rel(folder)}/: trials write into it. Stop your app and run the command again to run against a copy.`); continue; }
108
- if (sizeOf(folder, FOLDER_CAP) > FOLDER_CAP) { said.push(`Your data folder ${rel(folder)}/ (${name}) is over 1 GB, so it is not copied: trials write into it.`); continue; }
105
+ if (!started) { said.push(`Your app was already running when this command started, so it writes to its own data folder, ${rel(folder)}/: test conversations write into it. Stop your app and run the command again to run against a copy.`); continue; }
106
+ if (sizeOf(folder, FOLDER_CAP) > FOLDER_CAP) { said.push(`Your data folder ${rel(folder)}/ (${name}) is over 1 GB, so it is not copied: test conversations write into it.`); continue; }
109
107
  const to = join(work, "data", String(copied.length), basename(folder));
110
108
  cpSync(folder, to, { recursive: true });
111
109
  env[name] = to;
112
110
  copied.push({ name, from: folder, to });
113
- said.push(`Your app runs against a copy of its data folder ${rel(folder)}/, through ${name}; the original is not touched.`);
111
+ said.push(`Your app runs against a copy of its data folder ${rel(folder)}/, through ${name}; the original is not touched, and the copy is deleted when this command ends and made again from the original at the next connect.`);
114
112
  continue;
115
113
  }
116
114
  if (!db) continue;
117
- if (db.server) {
118
- said.push(`Trials write into the ${db.server} database ${name} names: a database server cannot be copied here, so what a run creates stays in it. Point ${name} at a scratch database before a run if that one holds real data.`);
119
- continue;
120
- }
121
115
  const original = located(db, from[name] ? [dirname(from[name])] : [], appDir);
122
116
  moved.add(original);
123
117
  if (!started) {
124
- said.push(`Your app was already running when this command started, so it writes to its own database, ${rel(original)}: trials write into it. Stop your app and run the command again to run against a copy.`);
118
+ said.push(`Your app was already running when this command started, so it writes to its own database, ${rel(original)}: test conversations write into it. Stop your app and run the command again to run against a copy.`);
125
119
  continue;
126
120
  }
127
121
  const to = join(work, "data", String(copied.length), basename(original));
@@ -130,14 +124,14 @@ export function keepData({ values, from = {}, appDir, root = appDir, work, sourc
130
124
  if (existsSync(original)) for (const tail of ["", "-wal", "-shm", "-journal"]) if (existsSync(original + tail)) copyFileSync(original + tail, to + tail);
131
125
  env[name] = db.write(to);
132
126
  copied.push({ name, from: original, to });
133
- said.push(`Your app runs against a copy of ${rel(original)}, through ${name}; the original is not touched.`);
127
+ said.push(`Your app runs against a copy of ${rel(original)}, through ${name}; the original is not touched, and the copy is deleted when this command ends and made again from the original at the next connect.`);
134
128
  }
135
129
  for (const o of opened) {
136
130
  const path = resolve(dirname(o.file), o.path);
137
131
  const cwdPath = resolve(appDir, o.path);
138
132
  if ([path, cwdPath].some((p) => moved.has(p) || [...moved].some((m) => p.startsWith(m + "/")))) continue;
139
133
  moved.add(cwdPath);
140
- said.push(`Your app opens ${o.path} by a path written in ${rel(o.file)}, with no variable to point it at a copy: trials write into that file.`);
134
+ said.push(`Your app opens ${o.path} by a path written in ${rel(o.file)}, with no variable to point it at a copy: test conversations write into that file.`);
141
135
  }
142
- return { env, copied, said };
136
+ return { env, copied, said, values: all };
143
137
  }
@@ -0,0 +1,232 @@
1
+ import { clip, closeToCall, has, num, plural, upper } from "./words.mjs";
2
+
3
+ // A finished run's dissection, as the server cut it (world-agent/dissection.ts): the run in a line,
4
+ // what only running the app showed, what a code review would also have found, the gates a knock
5
+ // found open, then what was held, not measured, and what the next run is paired against.
6
+
7
+ // The findings printed whole, in the server's order, leaders first; the rest get a line each, so the
8
+ // plan line and the next call at the end stay inside the page a client shows (PAGE_CHARS).
9
+ const WHOLE_CHARS = 4500;
10
+ // A quoted reply can hold a block of many lines; a finding's lines stay one line each.
11
+ const flat = (s, n) => clip(String(s).replace(/\s+/g, " ").trim(), n);
12
+
13
+ const pct = (x) => `${Math.round(x * 100)}%`;
14
+
15
+ const LAYER_NAMES = { prompt: "prompt", flow: "flow around the model", tool: "tool", routing: "routing", limit: "model's output cap", service: "service your app depends on" };
16
+
17
+ // "9 min 40 s", "45 s".
18
+ export const durationSaid = (s) => (s >= 60 ? `${Math.floor(s / 60)} min ${Math.round(s % 60)} s` : `${Math.round(s)} s`);
19
+
20
+ // What the run cost on their key and how long it took, in one sentence; empty when neither was measured.
21
+ export function costSaid(d) {
22
+ const parts = [has(d.durationS) && `took ${durationSaid(d.durationS)}`, d.spent && spentSaid(d.spent)].filter(Boolean);
23
+ return parts.length ? `The run ${parts.join(" and ")}.` : "";
24
+ }
25
+
26
+ const spentSaid = (s) => (has(s.usd)
27
+ ? `spent $${s.usd.toFixed(2)} on your key over ${plural(s.calls, "model call")}`
28
+ : `made ${plural(s.calls, "model call")} on your key, not all of them priced`);
29
+
30
+ function headLines(d) {
31
+ const r = d.run;
32
+ // The trials played are the run's own first line; this says what they ran on and what they cost.
33
+ const head = [r.model && `Your app answered on ${r.model}.`, costSaid(r)].filter(Boolean).join(" ");
34
+ const lines = head ? [head] : [];
35
+ if (r.replies?.n && has(r.replies.medianS)) lines.push(`Replies took ${r.replies.medianS} s at the median and ${r.replies.slowestS} s at the slowest, over ${plural(r.replies.n, "reply", "replies")}.`);
36
+ const t = d.tools;
37
+ // Unrecorded is not unused: the app started before the list of its tools reached it (usage's toolsRecorded).
38
+ if (t?.recorded === false) lines.push(`Calls to the ${plural(t.offered, "tool")} your app offers its model were not recorded in this run.`);
39
+ else if (t) lines.push(`${num(t.ran)} of ${plural(t.offered, "tool")} your app offers its model ran${t.neverRan.length ? `; never ran: ${t.neverRan.join(", ")}` : ""}.`);
40
+ return lines;
41
+ }
42
+
43
+ // `unseen`: the run did not record its tools, so none is said never to have run.
44
+ function partLines(p, unseen) {
45
+ const lines = [`${p.id} ${p.asks}`, ` ${upper(p.reach)}.`];
46
+ if (p.harm) lines.push(` Harm: ${p.harm}.`);
47
+ if (p.at) lines.push(` At ${p.at}`);
48
+ if (p.step) lines.push(` Written by: ${p.step.replace(/^written by /, "")}.`);
49
+ if (p.tools) lines.push(` Tools that ran behind these replies: ${p.tools.length ? p.tools.join(", ") : "none"}.`);
50
+ if (p.neverRan?.length && !unseen) lines.push(` Needed by these asks and never run: ${p.neverRan.join(", ")}.`);
51
+ if (p.sameCause) lines.push(` Same cause as ${p.sameCause}: one fix covers both. Verify ${p.sameCause} after the fix; the next run rechecks this one.`);
52
+ else if (p.layer) lines.push(` Fix in the ${LAYER_NAMES[p.layer.name] ?? p.layer.name}: ${p.layer.said}.`);
53
+ const e = p.exchange;
54
+ if (e?.sent) lines.push(` Sent: "${flat(e.sent, 200)}"`);
55
+ if (e?.broke) lines.push(` Broke on: "${flat(e.broke, 240)}"`);
56
+ if (e?.after) lines.push(` Then: "${flat(e.after, 200)}"`);
57
+ if (e?.note) lines.push(` ${e.note}`);
58
+ // Where a fix belongs, not read: said after the exchange, never as a fix.
59
+ if (p.note) lines.push(` ${p.note}`);
60
+ if (p.verify && !p.sameCause) lines.push(` ${p.verify}`);
61
+ return lines;
62
+ }
63
+
64
+ // A run after a change leads with what moved, the way the landing page's demo says it, then what
65
+ // it played and why, each moved behavior with the conversation that turned, the rest in one line,
66
+ // and what the new conversations found.
67
+ function changeLines(c, parts) {
68
+ const lines = [c.lead, ...(c.scope ? [c.scope] : [])];
69
+ for (const m of c.moved ?? []) {
70
+ lines.push(`${m.worse ? "Worse" : "Better"}: "${flat(m.asks, 160)}" ${m.said}.`);
71
+ if (m.at) lines.push(` At ${m.at}`);
72
+ const p = m.proof;
73
+ if (p) {
74
+ lines.push(` Conversation ${p.trial}${p.sent ? `, sent: "${flat(p.sent, 160)}"` : ""}`);
75
+ if (p.before) lines.push(` Before: "${flat(p.before, 200)}"`);
76
+ if (p.now) lines.push(` Now: "${flat(p.now, 200)}"`);
77
+ }
78
+ }
79
+ if (c.steady) lines.push(c.steady);
80
+ if (c.fresh?.length) lines.push("New findings on the conversations written for this change:", ...parts(c.fresh));
81
+ return lines;
82
+ }
83
+
84
+ export function dissectionLines(d) {
85
+ if (!d?.run) return [];
86
+ if (d.baseline && !d.unmeasured && !d.change) return firstRunLines(d);
87
+ const room = { left: WHOLE_CHARS, short: 0, unseen: d.tools?.recorded === false };
88
+ const parts = (ps) => ps.flatMap((p) => partOrLine(p, room));
89
+ const lines = [...(d.change ? changeLines(d.change, parts) : []), ...headLines(d)];
90
+ if (d.unmeasured) lines.push(`This run measured nothing. ${d.unmeasured} It is not counted against your plan; run again once that is fixed.`);
91
+ else lines.push(...brokeLines(d, room, "Found only by running your app:"));
92
+ lines.push(...inCodeLines(d, room), ...gateLines(d, room), ...shortLines(room));
93
+ for (const s of [d.held, d.notMeasured, d.next]) if (s) lines.push(s);
94
+ return lines;
95
+ }
96
+
97
+ function brokeLines(d, room, head) {
98
+ const lines = d.runOnly?.length ? [head, ...d.runOnly.flatMap((p) => partOrLine(p, room))] : ["Nothing broke that only running your app could show."];
99
+ return [...lines, ...(d.also ?? [])];
100
+ }
101
+
102
+ const section = (head, parts, room) => {
103
+ const lines = (parts ?? []).flatMap((p) => partOrLine(p, room));
104
+ return lines.length ? [head, ...lines] : [];
105
+ };
106
+ const inCodeLines = (d, room) => section("Also in the code; a code review would also find these:", d.inCode, room);
107
+ const gateLines = (d, room) => section("Open gates, found by sending requests to your app's routes:", d.gates, room);
108
+ const shortLines = (room) => [
109
+ ...(room.leaders?.length ? [`Also to act on, each shown in full by findings: ${room.leaders.join(", ")}.`] : []),
110
+ ...(room.short ? [`findings shows the last ${plural(room.short, "finding")} in full${room.unlisted ? `; ${num(room.unlisted)} of them are not listed here` : ""}.`] : []),
111
+ ];
112
+
113
+ // The first run on a repository is the baseline: who the simulated users were, what they asked for,
114
+ // how the app did on each journey with its interval, what held everywhere, then what broke. The
115
+ // findings get the room the picture leaves, so the whole stays under a page.
116
+ const FIRST_CHARS = 6000;
117
+ const FIRST_LINES = 1600;
118
+ const PEOPLE_SHOWN = 6;
119
+ const JOURNEYS_SHOWN = 6;
120
+
121
+ function firstRunLines(d) {
122
+ const b = d.baseline;
123
+ const said = { interval: false };
124
+ const picture = [baselineHead(b), ...peopleLines(b, said), ...journeyLines(b, said), ...heldEverywhereLines(b)];
125
+ const room = { left: Math.max(1200, FIRST_CHARS - picture.join("\n").length), short: 0, lines: FIRST_LINES, unseen: d.tools?.recorded === false };
126
+ return [
127
+ ...picture,
128
+ ...brokeLines(d, room, "What broke, found only by running your app:"),
129
+ ...gateLines(d, room),
130
+ ...inCodeLines(d, room),
131
+ ...shortLines(room),
132
+ ...notMeasuredLines(d, b),
133
+ ...headLines(d),
134
+ b.production
135
+ ? "Production is connected: field shows what your real users are doing and where your AI lets them down."
136
+ : "Connect production to see what your real users are doing as it happens and where your AI lets them down; field_connect has the steps.",
137
+ ];
138
+ }
139
+
140
+ function baselineHead(b) {
141
+ const head = `This was the first run, the baseline: ${plural(b.conversations, "conversation")} written from your code.`;
142
+ if (!b.writtenFor) return head;
143
+ const users = `${plural(b.users, "simulated user")} below`;
144
+ return b.writtenFor === b.conversations ? `${head} All of them came from the ${users}.`
145
+ : `${head} The ${users} had ${num(b.writtenFor)} of them; the other ${num(b.conversations - b.writtenFor)} were not written for any one user.`;
146
+ }
147
+
148
+ // "87 of 96 completed fully or partly (80 fully): 91% (83% to 95%)", saying once what the range is.
149
+ function gotSaid(g, said) {
150
+ const done = g.full < g.k ? `completed fully or partly (${num(g.full)} fully)` : "completed fully";
151
+ const clause = said.interval ? "" : ", the likely range";
152
+ said.interval = true;
153
+ return `${num(g.k)} of ${num(g.of)} ${done}: ${pct(g.of ? g.k / g.of : 0)} (${pct(g.lo)} to ${pct(g.hi)}${clause})`;
154
+ }
155
+
156
+ function peopleLines(b, said) {
157
+ if (!b.people?.length) return [];
158
+ const line = (p) => {
159
+ const played = has(p.conversations) ? ` ${plural(p.conversations, "conversation")}${p.got ? `; ${gotSaid(p.got, said)}` : ""}.` : "";
160
+ return ` ${flat(p.who, 60)}: ${flat(p.wants, 80)}. Writes ${flat(p.writes, 50)}.${played}`;
161
+ };
162
+ const more = b.people.length - PEOPLE_SHOWN;
163
+ return ["The simulated users, as Cortad found them in your code:", ...b.people.slice(0, PEOPLE_SHOWN).map(line), ...(more > 0 ? [` and ${plural(more, "more user")}.`] : [])];
164
+ }
165
+
166
+ function journeyLines(b, said) {
167
+ const j = b.journeys ?? { read: 0, rows: [], unplayed: [] };
168
+ const on = j.rows.reduce((n, r) => n + r.conversations, 0);
169
+ const head = j.read
170
+ ? `Cortad found ${plural(j.read, "journey")} in your code${on < b.conversations ? `; ${num(on)} of the ${plural(b.conversations, "conversation")} followed one of them` : ""}.`
171
+ : "Cortad found no journeys in your code, so the conversations are counted together.";
172
+ const lines = [head, "How the conversations ended, judged on each one's last reply:"];
173
+ const a = b.all;
174
+ if (a?.got || a?.frustrated || a?.askedForPerson) {
175
+ const parts = [a.got && gotSaid(a.got, said), a.unsure && closeToCall(a.unsure, said), a.frustrated && `${num(a.frustrated.k)} grew frustrated`, a.askedForPerson && `${num(a.askedForPerson.k)} asked for a person midway`].filter(Boolean);
176
+ // The line counts the conversations its own fraction is of: a conversation our simulated
177
+ // customer cut short still had a reply read, and "All 89 conversations: 67 of 90" said two counts.
178
+ lines.push(` All ${plural(a.got?.of ?? b.conversations, "conversation")}: ${parts.join("; ")}.`);
179
+ }
180
+ for (const r of j.rows.slice(0, JOURNEYS_SHOWN)) {
181
+ const went = r.got ? `${gotSaid(r.got, said)}.` : `${plural(r.conversations, "conversation")}; where they ended is not known.`;
182
+ const most = r.failedMost.map((f) => `"${flat(f.asks, 100)}" in ${num(f.failed)} of ${num(f.of)}`).join("; ");
183
+ lines.push(` ${flat(r.name, 40)}: ${went}${most ? ` Failed most: ${most}.` : ""}`);
184
+ }
185
+ const more = j.rows.length - JOURNEYS_SHOWN;
186
+ if (more > 0) lines.push(` and ${plural(more, "more journey")}; findings numbers has each one.`);
187
+ return lines;
188
+ }
189
+
190
+ function heldEverywhereLines(b) {
191
+ if (!b.heldEverywhere?.length) return [];
192
+ return [
193
+ "Checks that passed in every conversation, each tested often enough to show it fails less than one time in five:",
194
+ ...b.heldEverywhere.map((h) => ` Passed in all ${plural(h.of, "conversation")} it was tested in, so at most ${pct(h.hi)} would fail: "${flat(h.asks, 100)}"`),
195
+ ...(b.heldEverywhereMore ? [` and ${plural(b.heldEverywhereMore, "more check")}; findings numbers lists every check.`] : []),
196
+ ];
197
+ }
198
+
199
+ function notMeasuredLines(d, b) {
200
+ const unplayed = b.journeys?.unplayed ?? [];
201
+ const lines = [d.held, d.notMeasured, unplayed.length && `No conversation reached ${unplayed.length === 1 ? "this journey" : `these ${num(unplayed.length)} journeys`}: ${flat(unplayed.join(", "), 200)}.`].filter(Boolean);
202
+ return lines.length ? ["What the numbers leave out, and why:", ...lines.map((l) => ` ${l}`)] : [];
203
+ }
204
+
205
+ // `room.lines`: what the one-line findings may take; past it a finding that may lead, an open gate or
206
+ // a crash among them, is named by its id alone, and any other is counted, not listed.
207
+ function partOrLine(p, room) {
208
+ const whole = partLines(p, room.unseen);
209
+ const size = whole.join("\n").length;
210
+ if (!room.short && size <= room.left) {
211
+ room.left -= size;
212
+ return whole;
213
+ }
214
+ room.short += 1;
215
+ const line = `${p.id} ${flat(p.asks, 140)} ${upper(p.reach)}.`;
216
+ if (line.length > (room.lines ?? Infinity)) {
217
+ if (p.leads) (room.leaders ??= []).push(p.id);
218
+ else room.unlisted = (room.unlisted ?? 0) + 1;
219
+ return [];
220
+ }
221
+ if (has(room.lines)) room.lines -= line.length;
222
+ return [line];
223
+ }
224
+
225
+ // A verify's plan says the same clauses on every finding; a page says each once.
226
+ const BOILER = [/ until it can decide, up to [\d,]+ replays/g, / Only replays it can score count toward this month's verify conversations, at least one a round\./g];
227
+ export function verifyOnceAPage(page) {
228
+ return BOILER.reduce((text, clause) => {
229
+ let first = true;
230
+ return text.replace(clause, (all) => (first ? ((first = false), all) : ""));
231
+ }, page);
232
+ }
@@ -0,0 +1,75 @@
1
+ // A reply that is a document, not a sentence: flashcards, a quiz, a mindmap, notes with sections.
2
+ // What the person sees is every item with its fields, so that is the reply. One rule with the
3
+ // backend's src/customer/document.ts: ulaim's five flashcards were read as one card's back.
4
+
5
+ const PROSE = /\S\s+\S/;
6
+ // Fields the app keeps for itself: ids, flags, times. Never part of what the person reads.
7
+ const META = /^(?:id|ID|uuid|_id|\w+_id|[a-z]\w*Id|success|ok|timestamp|createdAt|updatedAt|created_at|updated_at)$/;
8
+ const REPLY_WORD = /^(?:reply|message|response|text|content|answer|output|result|chunk|token|delta|value)$/;
9
+ const FENCED = /^\s*```(?:json)?\s*\n?([\s\S]*?)\n?\s*```\s*$/i;
10
+ // A turn of a conversation names its side under one of these, or as its own type ({"type":"ai"}).
11
+ const ROLE_KEY = /^(?:role|sender|from|author|speaker|who|side|type)$/i;
12
+ // The backend's turns.ts sides: the person's, the app's, and neither.
13
+ const SIDE = /^(?:user|human|learner|student|customer|client|person|visitor|guest|player|candidate|patient|member|caller|me|you|question|assistant|ai|bot|model|agent|npc|coach|tutor|teacher|mentor|advisor|expert|character|answer|gpt|llm|robot|system|developer|tool|function|event|log|meta|hint|error)$/i;
14
+
15
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
16
+ const isScalar = (v) => typeof v === "string" || typeof v === "number";
17
+ const kept = (v) => v !== null && v !== undefined && typeof v !== "boolean" && v !== "" && !(Array.isArray(v) && !v.length) && !(isObject(v) && !Object.keys(v).length);
18
+ const fields = (row) => Object.entries(row).filter(([k, v]) => !META.test(k) && kept(v));
19
+ const isThread = (list) => list.length > 0 && list.every((t) => isObject(t) && Object.entries(t).some(([k, v]) => ROLE_KEY.test(k) && typeof v === "string" && SIDE.test(v)));
20
+ const readAt = (v, keys) => keys.reduce((o, k) => (Array.isArray(o) ? o[Number(k) < 0 ? o.length + Number(k) : Number(k)] : isObject(o) ? o[k] : undefined), v);
21
+
22
+ function proseLeaves(value) {
23
+ if (typeof value === "string") return PROSE.test(value) ? 1 : 0;
24
+ // A conversation is not a document: its reply is its newest turn.
25
+ if (Array.isArray(value)) return isThread(value) ? 0 : value.reduce((n, v) => n + proseLeaves(v), 0);
26
+ if (isObject(value)) return fields(value).reduce((n, [, v]) => n + proseLeaves(v), 0);
27
+ return 0;
28
+ }
29
+
30
+ export const isDocument = (value) => (Array.isArray(value) || isObject(value)) && proseLeaves(value) >= 2;
31
+
32
+ const hang = (label, lines) => lines.map((line, i) => (i === 0 ? `${label} ${line}` : `${" ".repeat(label.length + 1)}${line}`));
33
+ function lines(value) {
34
+ if (Array.isArray(value)) {
35
+ const items = value.filter(kept);
36
+ return items.every(isScalar) ? items.map((v) => `- ${v}`) : items.flatMap((v, i) => hang(`${i + 1}.`, lines(v)));
37
+ }
38
+ if (isObject(value)) return fields(value).flatMap(([k, v]) => (isScalar(v) ? [`${k}: ${v}`] : [`${k}:`, ...lines(v).map((l) => ` ${l}`)]));
39
+ return [String(value)];
40
+ }
41
+
42
+ // Every item on its own lines with its field names, in order.
43
+ export const documentText = (value) => lines(value).join("\n");
44
+
45
+ // A text that is a JSON document, bare or fenced as a model writes it, read whole; any other as it is.
46
+ export function readable(text) {
47
+ const body = FENCED.exec(text)?.[1] ?? text;
48
+ if (!/^\s*[[{]/.test(body)) return text;
49
+ try {
50
+ const value = JSON.parse(body);
51
+ return isDocument(value) ? documentText(value) : text;
52
+ } catch {
53
+ return text;
54
+ }
55
+ }
56
+
57
+ // The document a path (a list of keys) into `value` lands in, read whole, or null when the path names
58
+ // a reply of its own: a path through an entry of a list of records, a field beside other things said
59
+ // that is not a chat envelope's answer field, or a list or object itself.
60
+ export function documentAt(value, keys) {
61
+ if (!keys.length) return null;
62
+ const at = readAt(value, keys);
63
+ if ((Array.isArray(at) || isObject(at)) && isDocument(at)) return documentText(at);
64
+ for (let i = 0; i < keys.length; i++) {
65
+ const list = readAt(value, keys.slice(0, i));
66
+ if (!Array.isArray(list) || !/^-?\d+$/.test(String(keys[i]))) continue;
67
+ if (Number(keys[i]) < 0 || !list.some(isObject) || isThread(list)) return null;
68
+ const holder = i > 0 ? readAt(value, keys.slice(0, i - 1)) : list;
69
+ const whole = isObject(holder) && isDocument(holder) ? holder : list;
70
+ return isDocument(whole) ? documentText(whole) : null;
71
+ }
72
+ if (REPLY_WORD.test(String(keys.at(-1)))) return null;
73
+ const parent = readAt(value, keys.slice(0, -1));
74
+ return isObject(parent) && isDocument(parent) ? documentText(parent) : null;
75
+ }
@@ -0,0 +1,48 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { StringDecoder } from "node:string_decoder";
3
+
4
+ // A model's or provider's name is how the app is configured, not a credential: status names it and
5
+ // prices the run by it. A value under such a name that looks like a key stays masked all the same.
6
+ const NAMES_A_MODEL = /(?:^|_)(?:MODEL|MODELS|MODEL_NAME|MODEL_ID|PROVIDER)$/i;
7
+ const KEY_LIKE = /[A-Za-z0-9]{24,}/;
8
+
9
+ // Values from your env files, read here and only here, so nothing a command prints can carry one.
10
+ export function secretValues(envFiles) {
11
+ const values = new Set();
12
+ for (const file of envFiles) {
13
+ let text = "";
14
+ try { text = readFileSync(file, "utf8"); } catch { continue; }
15
+ for (const line of text.split("\n")) {
16
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
17
+ if (!m) continue;
18
+ const v = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
19
+ if (NAMES_A_MODEL.test(m[1]) && !KEY_LIKE.test(v)) continue;
20
+ if (v.length >= 8 && !/^(true|false|localhost|development|production|\d+)$/i.test(v)) values.add(v);
21
+ }
22
+ }
23
+ return [...values].sort((a, b) => b.length - a.length);
24
+ }
25
+
26
+ export const maskWith = (text, values) => { let s = text; for (const v of values) s = s.split(v).join("[masked]"); return s; };
27
+
28
+ // A process's output as it may be kept: masked with `values()`, read at each chunk since values are
29
+ // learned while the app runs, and never cut inside one. A line goes to `write` once it ends; a line
30
+ // longer than `max` goes on early, all but its last characters, as many as the longest value less
31
+ // one, so a value the chunk ended inside is masked whole with the next. Nothing is added to it.
32
+ export function maskedLines(values, write, max = 16_000) {
33
+ const decoder = new StringDecoder("utf8");
34
+ let carry = "";
35
+ return (chunk) => {
36
+ const text = carry + decoder.write(chunk);
37
+ const end = text.lastIndexOf("\n") + 1;
38
+ let out = maskWith(text.slice(0, end), values());
39
+ carry = text.slice(end);
40
+ if (carry.length > max) {
41
+ const masked = maskWith(carry, values());
42
+ const cut = Math.max(0, masked.length - Math.max(0, ...values().map((v) => v.length - 1)));
43
+ out += masked.slice(0, cut);
44
+ carry = masked.slice(cut);
45
+ }
46
+ if (out) write(out);
47
+ };
48
+ }