cortad 0.3.10 → 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/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 +32 -25
- package/lib/keys.mjs +10 -6
- package/lib/lock.mjs +7 -6
- package/lib/mcp.mjs +35 -2
- package/lib/mint.mjs +27 -11
- package/lib/mongo.mjs +64 -3
- package/lib/proof.mjs +8 -1
- package/lib/stores.mjs +116 -61
- package/lib/verbs.mjs +56 -1
- package/local.mjs +163 -81
- package/package.json +6 -1
- package/skill/SKILL.md +5 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// What the agent reads back from the Intelligence Layer's tools, as plain lines: numbers, ids and
|
|
2
|
+
// the cause, never a user's words.
|
|
3
|
+
|
|
4
|
+
const n = (v) => (typeof v === "number" ? v.toLocaleString("en-US") : String(v ?? 0));
|
|
5
|
+
const when = (iso) => (iso ? new Date(iso).toISOString().replace("T", " ").slice(0, 16) + "Z" : "");
|
|
6
|
+
|
|
7
|
+
export function incidentLine(i) {
|
|
8
|
+
const where = i.segment ? (i.segment.kind === "country" ? ` from ${i.segment.value}` : ` on release ${i.segment.value}`) : "";
|
|
9
|
+
const cause = i.cause ? `; the change was ${i.cause.kind} ${i.cause.value ?? ""}`.trimEnd() + (i.cause.at ? ` at ${when(i.cause.at)}` : "") : "";
|
|
10
|
+
return `Since ${when(i.since)}, ${i.kind === "reaction" ? `people${where} reacted "${i.key}" after` : `replies${where} broke "${i.key}" in`} ${n(i.now.broke)} of ${n(i.now.of)}; before, ${n(i.before.broke)} of ${n(i.before.of)}${cause}.`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function intelligenceText(d) {
|
|
14
|
+
const lines = [];
|
|
15
|
+
lines.push(d.wrong?.length ? `Wrong now: ${d.wrong.length}.` : "Nothing is wrong right now.");
|
|
16
|
+
for (const i of d.wrong ?? []) lines.push(`- ${incidentLine(i)}`);
|
|
17
|
+
const t = d.conversations ?? {};
|
|
18
|
+
lines.push(`Last ${d.days} days: ${n(t.n)} conversations, ${n(t.read)} read, ${n(t.heldEvery)} kept every rule, ${n(t.got)} got what they came for, ${n(t.frustrated)} frustrated, ${n(t.wantsHuman)} asked for a person.`);
|
|
19
|
+
if (d.visits?.n) {
|
|
20
|
+
const r = d.visits.reactions ?? {};
|
|
21
|
+
lines.push(`Visits: ${n(d.visits.n)}; after answers people ${Object.entries(r).filter(([k]) => k !== "waited").map(([k, v]) => `${k} ${n(v)}`).join(", ") || "reacted to nothing yet"} (of ${n(r.waited)} answers seen).`);
|
|
22
|
+
}
|
|
23
|
+
if (d.checks?.length) lines.push(`Checks broken most: ${d.checks.filter((c) => c.broke).sort((a, b) => b.broke - a.broke).slice(0, 5).map((c) => `${c.id} ${n(c.broke)} of ${n(c.held + c.broke)}`).join("; ") || "none"}.`);
|
|
24
|
+
lines.push(`The page: ${d.url}`);
|
|
25
|
+
return lines.join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function incidentsText(d) {
|
|
29
|
+
if (!d.open?.length) return `${d.said ?? "Nothing is wrong right now."}\n${d.url}`;
|
|
30
|
+
return [...d.open.map((i) => `${incidentLine(i)} Conversations: ${(i.conversations ?? []).join(", ")}`), d.said, d.url].filter(Boolean).join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ruleWords = (r) => r.kind === "path" ? `a page ${r.match} ${r.value}` : r.kind === "click" ? `a click on ${r.text ? `"${r.text}"` : r.selector}` : r.kind === "event" ? `the page's event "${r.name}"` : `the backend's outcome "${r.name}"`;
|
|
34
|
+
|
|
35
|
+
export function outcomesText(d) {
|
|
36
|
+
if (d.proposed !== undefined) return `${n(d.proposed)} proposed. ${d.awaiting}`;
|
|
37
|
+
if (!d.outcomes?.length) return `No outcome is defined yet. Propose what the code treats as a good ending (a signup route, a checkout, a "done" button) with outcomes {proposals: [{name, rules, why}]}; ${d.shape}`;
|
|
38
|
+
return [...d.outcomes.map((o) => `${o.approved ? "Counted" : "Proposed, awaiting the person"}: ${o.name}: ${o.rules.map(ruleWords).join(", or ")}${o.why ? ` (${o.why})` : ""}`), d.url].join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function queryText(d) {
|
|
42
|
+
if (d.error) return `${d.error}${d.sql ? `\n${d.sql}` : ""}`;
|
|
43
|
+
const rows = d.rows ?? [];
|
|
44
|
+
if (rows.length === 0) return `No rows.\n${d.sql}`;
|
|
45
|
+
const keys = Object.keys(rows[0]);
|
|
46
|
+
return [keys.join("\t"), ...rows.slice(0, 200).map((r) => keys.map((k) => String(r[k] ?? "")).join("\t")), rows.length > 200 ? `${rows.length - 200} more rows` : "", d.scannedBytes != null ? `Scanned ${n(d.scannedBytes)} bytes.` : ""].filter(Boolean).join("\n");
|
|
47
|
+
}
|
package/lib/keeping.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What one session keeps out of the run's way and brings up for it: the app's database files and
|
|
2
2
|
// servers, copied before it starts (lib/data.mjs, lib/stores.mjs), and the stores and services it
|
|
3
3
|
// reaches, started when nothing answers there (lib/backing.mjs). Every sentence goes to the terminal
|
|
4
|
-
// and
|
|
4
|
+
// and to the run: the copies made through lines(), and each store with none through blocked().
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { localAddresses, makeBacking, tcpOpen } from "./backing.mjs";
|
|
7
7
|
import { keepData } from "./data.mjs";
|
|
@@ -14,7 +14,7 @@ const addressed = (s) => Boolean(s.host) && !s.host.startsWith("/") && Boolean(s
|
|
|
14
14
|
// One nothing answers at is left for later: trials cannot write into it.
|
|
15
15
|
const isDown = async (s) => addressed(s) && !(await tcpOpen(s.host, s.port));
|
|
16
16
|
// The stores the settings name on this machine, as ports to bring up; a compose image may serve them.
|
|
17
|
-
const localStores = (k) => k.
|
|
17
|
+
const localStores = (k) => k.named.filter((s) => addressed(s) && onThisMachine(s.host)).map((s) => ({ port: s.port, names: s.names, store: true }));
|
|
18
18
|
// The API keeps eight stores that hold Run. Past that, the rest travel as one row, and a yes to that
|
|
19
19
|
// row is a yes to each of them; a row more changes its id, so an old yes no longer covers it.
|
|
20
20
|
const BLOCKED_MAX = 8;
|
|
@@ -37,8 +37,10 @@ function blockedRows(blocked) {
|
|
|
37
37
|
// `typed`: the person gave the start command. It is run as it is, with the settings and the stores
|
|
38
38
|
// that command gives the app: a legal research API typed ENV=staging and was started on copies of
|
|
39
39
|
// the test databases its default settings file names, where its library is empty.
|
|
40
|
+
// `chosen`: the store settings of the file the person chose for the run (--data). Those stores are
|
|
41
|
+
// the ones copied, and the app is handed all of those settings, so it reaches the server they name.
|
|
40
42
|
const AS_TYPED = "Your app is started with your own command as it is, on the databases that command gives it. Nothing is copied: a run writes into them once you say yes to each on the card.";
|
|
41
|
-
export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, copies = () => [], hold = () => {}, typed = false }) {
|
|
43
|
+
export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, launch, connections, copies = () => [], hold = () => {}, typed = false, chosen = {} }) {
|
|
42
44
|
let pending = null;
|
|
43
45
|
let now = null;
|
|
44
46
|
let backing = null;
|
|
@@ -48,37 +50,38 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
|
|
|
48
50
|
const held = (blocked) => tell(blocked.map((b) => `One question for the person on the card in the browser: ${lower(b.why)}`));
|
|
49
51
|
|
|
50
52
|
async function keepAll(started) {
|
|
51
|
-
if (typed) {
|
|
52
|
-
const none = async () => ({ env: {}, risks: [], copies: [], blocked: [], pass: [] });
|
|
53
|
-
// The app's own command may still reach a Qdrant: the copies the hook makes there are dropped too.
|
|
54
|
-
const keeper = storeKeeper({ ledgerFile, values: {}, dirs: [] });
|
|
55
|
-
now = { env: {}, risks: [], notes: [AS_TYPED], blocked: [], pass: [], stores: [], keeper: { keep: none, drop: async () => {}, dropCopies: (c, s) => keeper.dropCopies(c, s) }, values: {}, started };
|
|
56
|
-
tell(now.notes);
|
|
57
|
-
return now;
|
|
58
|
-
}
|
|
59
53
|
const { values, from, appDir, sources } = settings();
|
|
60
|
-
|
|
54
|
+
// Which stores a typed command gives the app is its own: only those chosen for the run are copied,
|
|
55
|
+
// and each other store it reaches holds Run once the hook sees it (watch). A store it reaches for
|
|
56
|
+
// and finds nothing at is still brought up: a session that ended stopped the ones it had started,
|
|
57
|
+
// and the app started next with its own command met "connection refused".
|
|
58
|
+
const disk = typed ? { env: {}, said: Object.keys(chosen).length ? [] : [AS_TYPED], held: [], values: chosen } : keepData({ values, from, appDir, root, work, sources, started });
|
|
61
59
|
const stores = storesOf(disk.values);
|
|
62
60
|
const keeper = storeKeeper({ ledgerFile, values: disk.values, dirs: [...new Set([appDir, root])] });
|
|
63
61
|
const got = await keeper.keep(stores, { started, down: isDown });
|
|
64
|
-
backing ??= makeBacking({ root, appDir, values: disk.values, onPath, ledgerFile, launch, adopt: started });
|
|
65
|
-
now = { env: { ...disk.env, ...got.env },
|
|
66
|
-
tell(
|
|
62
|
+
backing ??= makeBacking({ root, appDir, values: typed ? values : disk.values, onPath, ledgerFile, launch, adopt: started });
|
|
63
|
+
now = { env: { ...chosen, ...disk.env, ...got.env }, notes: [...disk.said, ...got.copies], blocked: [...disk.held, ...got.blocked], pass: got.pass, stores, named: stores, keeper, values: disk.values, started };
|
|
64
|
+
tell(now.notes);
|
|
67
65
|
held(now.blocked);
|
|
68
66
|
return now;
|
|
69
67
|
}
|
|
70
68
|
// A read that failed is tried again on the next ask, never handed back as the same failure.
|
|
71
69
|
const once = (started) => (pending ??= keepAll(started).catch((e) => { pending = null; throw e; }));
|
|
70
|
+
// The store ids the person's yes covers: a yes to the row the rest travel as is a yes to each.
|
|
71
|
+
const yesOf = (stores) => {
|
|
72
|
+
const yes = new Set(stores);
|
|
73
|
+
const rest = now && now.blocked.length > BLOCKED_MAX ? overflow(now.blocked) : [];
|
|
74
|
+
if (rest.length && yes.has(overflowId(rest))) for (const b of rest) yes.add(b.id);
|
|
75
|
+
return yes;
|
|
76
|
+
};
|
|
72
77
|
|
|
73
78
|
// Brings up what is wanted, copies each store that now answers, and says both.
|
|
74
79
|
async function bringUp(wants) {
|
|
75
|
-
if (typed) return { changed: false, came: false, copied: false };
|
|
76
80
|
const k = await once(true);
|
|
77
81
|
hold();
|
|
78
82
|
const up = await backing.up(wants);
|
|
79
83
|
const got = await k.keeper.keep(k.stores, { started: k.started, down: isDown });
|
|
80
84
|
Object.assign(k.env, got.env);
|
|
81
|
-
k.risks.unshift(...got.risks);
|
|
82
85
|
// Said once: every look brings the same lines back, and status printed one hold four times.
|
|
83
86
|
k.notes.push(...[...up.lines, ...got.copies].filter((l) => !k.notes.includes(l)));
|
|
84
87
|
// A service a setting names that answers nothing holds Run until it does; one that answers now
|
|
@@ -89,7 +92,7 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
|
|
|
89
92
|
for (const d of up.down) if (!k.blocked.some((b) => b.down && b.port === d.port)) k.blocked.push({ id: downId(d.port), names: d.names, why: d.why, down: true, port: d.port });
|
|
90
93
|
k.blocked.push(...got.blocked.filter((b) => !k.blocked.some((x) => x.id === b.id)));
|
|
91
94
|
k.pass.push(...got.pass);
|
|
92
|
-
const said = [...
|
|
95
|
+
const said = [...up.lines, ...got.copies];
|
|
93
96
|
tell(said);
|
|
94
97
|
held(got.blocked);
|
|
95
98
|
return { changed: said.length > 0 || got.blocked.length > 0, came: up.ports.length > 0, copied: Object.keys(got.env).length > 0 };
|
|
@@ -103,20 +106,24 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
|
|
|
103
106
|
env: async (started) => (await once(started)).env,
|
|
104
107
|
// The copies so far, for a service of the repository started beside the app.
|
|
105
108
|
envNow: () => now?.env ?? {},
|
|
106
|
-
//
|
|
107
|
-
lines: async (started) =>
|
|
108
|
-
// The stores
|
|
109
|
+
// The copies trials write into; the API keeps eight sentences of 400 characters.
|
|
110
|
+
lines: async (started) => (await once(started)).notes.map((l) => l.slice(0, 400)).slice(0, 8),
|
|
111
|
+
// The stores no copy could be made of: each holds Run until the person says yes.
|
|
109
112
|
blocked: async (started) => blockedRows((await once(started)).blocked),
|
|
110
113
|
// The hosts a trial's writes may reach: the copies made off this machine, and the hosts and
|
|
111
114
|
// stores the person said yes to (setup.consent). Before the copies are made, or after the app
|
|
112
115
|
// was attached to instead, only the hosts.
|
|
113
116
|
outbound: ({ hosts = [], stores = [] } = {}) => {
|
|
114
117
|
if (!now) return [...new Set(hosts)];
|
|
115
|
-
const yes =
|
|
116
|
-
const rest = now.blocked.length > BLOCKED_MAX ? overflow(now.blocked) : [];
|
|
117
|
-
if (rest.length && yes.has(overflowId(rest))) for (const b of rest) yes.add(b.id);
|
|
118
|
+
const yes = yesOf(stores);
|
|
118
119
|
return [...new Set([...hosts, ...now.pass, ...now.stores.filter((s) => yes.has(storeId(s)) && !onThisMachine(s.host)).map((s) => String(s.host).toLowerCase())])];
|
|
119
120
|
},
|
|
121
|
+
// The stores with no copy the person has not said yes to writing into. A service that is down
|
|
122
|
+
// holds Run too, but nothing can be written into it.
|
|
123
|
+
holding: ({ stores = [] } = {}) => {
|
|
124
|
+
const yes = yesOf(stores);
|
|
125
|
+
return now?.blocked.filter((b) => !b.down && !yes.has(b.id)) ?? [];
|
|
126
|
+
},
|
|
120
127
|
// The app stopped before it answered: each store its settings name and each port it reached that
|
|
121
128
|
// is down is brought up, once. True when the app is worth starting again.
|
|
122
129
|
async backUp(appPort) {
|
|
@@ -151,7 +158,7 @@ export function makeKeeping({ root, work, ledgerFile, onPath, say, settings, lau
|
|
|
151
158
|
const bypassed = [...bypassedOf(live, k.stores, k.blocked), ...unnamedHeld(live, k.stores, k.blocked)];
|
|
152
159
|
k.blocked.push(...bypassed);
|
|
153
160
|
held(bypassed);
|
|
154
|
-
const got =
|
|
161
|
+
const got = await bringUp(reached(appPort));
|
|
155
162
|
return { changed: bypassed.length > 0 || got.changed, copied: got.copied };
|
|
156
163
|
},
|
|
157
164
|
// An app attached to instead of started keeps its own connections: its copies are of no use.
|
package/lib/keys.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseEnv } from "./env-files.mjs";
|
|
1
2
|
// A file that carries a key stays on this machine, whatever git thinks of it: a catalogue that keeps
|
|
2
3
|
// an AES key per video is tracked like code and is no more shareable than a .env.
|
|
3
4
|
// The header alone is code that looks for keys; a key has its body after it.
|
|
@@ -21,18 +22,21 @@ export function holdsKeys(text, values = []) {
|
|
|
21
22
|
const SECRET_NAME = /KEY|SECRET|TOKEN|PASSW|PRIVATE|CREDENTIAL/i;
|
|
22
23
|
const EXAMPLE = /\.(example|sample|template|dist)$/i;
|
|
23
24
|
export function secretEnvValues(envFiles, read) {
|
|
24
|
-
const values =
|
|
25
|
+
const values = new Set();
|
|
26
|
+
// A value of few distinct characters is a placeholder or a published test key, not a secret.
|
|
27
|
+
const add = (v) => { if (v.length >= 12 && !/\s/.test(v) && new Set(v).size >= 8) values.add(v); };
|
|
25
28
|
for (const file of envFiles) {
|
|
26
29
|
if (EXAMPLE.test(file)) continue;
|
|
27
30
|
let text = "";
|
|
28
31
|
try { text = read(file); } catch { continue; }
|
|
29
32
|
for (const line of text.split("\n")) {
|
|
30
33
|
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
if (m && SECRET_NAME.test(m[1])) add(m[2].trim().replace(/^(['"])(.*)\1$/, "$2"));
|
|
35
|
+
}
|
|
36
|
+
// As the app's loader reads it too, each line of a value that spans lines on its own.
|
|
37
|
+
for (const [name, v] of Object.entries(parseEnv(text))) {
|
|
38
|
+
if (SECRET_NAME.test(name)) for (const one of v.split("\n")) add(one.trim());
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
|
-
return values;
|
|
41
|
+
return [...values];
|
|
38
42
|
}
|
package/lib/lock.mjs
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// bubblewrap. Inside it a shell line can read your project and run your tests, and it can write
|
|
4
4
|
// only where your own .gitignore already says files are disposable (caches, build output, coverage)
|
|
5
5
|
// and in temp. It cannot write a tracked file, cannot read your keys or environment files, and on a
|
|
6
|
-
// Mac cannot read what other programs left in /tmp or reach the network beyond this machine
|
|
7
|
-
//
|
|
6
|
+
// Mac cannot read what other programs left in /tmp or reach the network beyond this machine, unless
|
|
7
|
+
// it runs as the app (a world's command): then it keeps the app's reach, since it runs the app's own
|
|
8
|
+
// AI, which calls the app's model provider. With neither tool present there is no shell.
|
|
8
9
|
import { execFile, execFileSync } from "node:child_process";
|
|
9
10
|
import { existsSync, realpathSync } from "node:fs";
|
|
10
11
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -58,7 +59,7 @@ export async function makeLock({ root, work }) {
|
|
|
58
59
|
// Later rules win: home is shut, the project and the toolchains are opened again inside it, and
|
|
59
60
|
// what the project ignores and your keys are shut last.
|
|
60
61
|
const hide = ignored.hidden.map(({ path, dir }) => `(${dir ? "subpath" : "literal"} ${q(real(path) ?? path)})`);
|
|
61
|
-
const
|
|
62
|
+
const rules = ({ network }) => [
|
|
62
63
|
"(version 1)(allow default)",
|
|
63
64
|
`(deny file-read-data (subpath ${q(real(home) ?? home)}))`,
|
|
64
65
|
// /tmp holds what other programs on this machine logged; a shell line may write there, never read it.
|
|
@@ -67,17 +68,17 @@ export async function makeLock({ root, work }) {
|
|
|
67
68
|
'(allow file-read-data (regex #"^/private/tmp/sh-thd"))',
|
|
68
69
|
`(allow file-read-data (subpath ${q(project)}) ${[work, ...tools].map((p) => `(subpath ${q(p)})`).join(" ")})`,
|
|
69
70
|
...(hide.length ? [`(deny file-read-data file-write* ${hide.join(" ")})`] : []),
|
|
70
|
-
"(deny network*)",
|
|
71
71
|
// This machine only. Unix sockets stay shut: through one, a request rides out to the internet
|
|
72
72
|
// by name on a system network helper even with every direct connection refused.
|
|
73
|
-
'(allow network-outbound (remote ip "localhost:*"))(allow network-inbound (local ip "localhost:*"))',
|
|
73
|
+
...(network ? [] : ['(deny network*)(allow network-outbound (remote ip "localhost:*"))(allow network-inbound (local ip "localhost:*"))']),
|
|
74
74
|
"(deny file-write*)",
|
|
75
75
|
`(allow file-write* ${writable.map((p) => `(subpath ${q(p)})`).join(" ")} (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr") (literal "/dev/tty") (subpath "/dev/fd"))`,
|
|
76
76
|
// The same operation the project is opened with: a rule naming file-read-data outranks one naming
|
|
77
77
|
// file-read*, so a wildcard deny here let every .env be read again.
|
|
78
78
|
`(deny file-read* file-read-data ${SECRET_DIRS.map((d) => `(subpath ${q(join(home, d))})`).join(" ")} ${SECRET_FILES.map((f) => `(literal ${q(join(home, f))})`).join(" ")} (regex #"\\.env$") (regex #"/\\.env\\.(local|staging|stage|development|dev|test|production|prod)$"))`,
|
|
79
79
|
].join("");
|
|
80
|
-
|
|
80
|
+
const locked = rules({ network: false }), asApp = rules({ network: true });
|
|
81
|
+
return { name: "seatbelt", wrap: (cmd, { network = false } = {}) => ({ file: "/usr/bin/sandbox-exec", args: ["-p", network ? asApp : locked, "/bin/sh", "-c", cmd] }) };
|
|
81
82
|
}
|
|
82
83
|
const bwrap = process.platform === "linux" ? ["/usr/bin/bwrap", "/bin/bwrap"].find(existsSync) : null;
|
|
83
84
|
if (bwrap) {
|
package/lib/mcp.mjs
CHANGED
|
@@ -11,7 +11,7 @@ export const PROTOCOL = "2025-06-18";
|
|
|
11
11
|
// every server, so the first paragraph stands alone and the whole stays under 1,500 characters.
|
|
12
12
|
export const instructions = (spec = "cortad") => [
|
|
13
13
|
"Cortad tests the AI in this repository: simulated users written from the app's code talk to the app running on this machine, and every reply is checked. Cortad sends each AI endpoint a test request itself. status says whether the app is up, what Cortad's audit of the AI code found, and what a run would play and cost. A run starts when the person presses Run in the browser or asks you.",
|
|
14
|
-
`Tell the person what status found in three or four short, plain lines, leaving out file paths, endpoints, status codes and setting names unless they ask. Before you stop, start \`npx -y ${spec} wait\` as a background command: it returns when the next run or verify ends, with what it found. After a run, call findings and tell the person what their users ran into and what you would change, worst first, three problems at most; the person then says which fix to make, and nothing is edited before their go. On their go, one fix at a time: verify replays the failing conversations and says whether the failure is gone, and a further round plays only when verify is called again. findings has each failure whole and findings numbers every rate.
|
|
14
|
+
`Tell the person what status found in three or four short, plain lines, leaving out file paths, endpoints, status codes and setting names unless they ask. Before you stop, start \`npx -y ${spec} wait\` as a background command: it returns when the next run or verify ends, with what it found. After a run, call findings and tell the person what their users ran into and what you would change, worst first, three problems at most; the person then says which fix to make, and nothing is edited before their go. On their go, one fix at a time: verify replays the failing conversations and says whether the failure is gone, and a further round plays only when verify is called again. findings has each failure whole and findings numbers every rate. connect gives the steps to connect the live app. intelligence says what is happening to it and incidents what went wrong and why; outcomes, subscribe and query are the layer's other tools. dispute records a check that reads this app wrong.`,
|
|
15
15
|
].join("\n\n");
|
|
16
16
|
export const INSTRUCTIONS = instructions();
|
|
17
17
|
|
|
@@ -19,7 +19,7 @@ const str = (description, extra = {}) => ({ type: "string", description, ...extr
|
|
|
19
19
|
const none = { type: "object", properties: {}, additionalProperties: false };
|
|
20
20
|
const readOnly = { readOnlyHint: true };
|
|
21
21
|
// What a note to the Cortad team may be about: a tool, the connect command, or the skill text.
|
|
22
|
-
const ABOUT = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field", "connect", "skill"];
|
|
22
|
+
const ABOUT = ["status", "reach", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field", "world", "world_status", "test", "connect", "intelligence", "incidents", "outcomes", "subscribe", "query", "skill"];
|
|
23
23
|
|
|
24
24
|
export const TOOLS = [
|
|
25
25
|
{ name: "status", title: "Cortad status", annotations: readOnly,
|
|
@@ -58,6 +58,39 @@ export const TOOLS = [
|
|
|
58
58
|
{ name: "field", title: "Production numbers", annotations: readOnly,
|
|
59
59
|
description: "Production in numbers: conversations read, checks passed, resolved, frustrated, asks for a human, and the rules broken most. Message text stays out of the answer.",
|
|
60
60
|
inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 180, description: "Window in days. Default 30." } }, additionalProperties: false } },
|
|
61
|
+
{ name: "world", title: "Test the AI in a world",
|
|
62
|
+
description: "For an AI in this app that is not a chat, such as a worker that reads data and writes a verdict: Cortad builds a copy of the product's data on this machine from the app's own seed, plants problems whose answers are known, runs the app's own detection on each, and scores what it raised against the answer key, repeated for a flip rate. Answers within a second with the world's id; world_status follows it. Say what was built or what to test, and the report keeps it.",
|
|
63
|
+
inputSchema: { type: "object", properties: { ask: str("What was built or what to test, in a sentence.", { maxLength: 2000 }) }, additionalProperties: false } },
|
|
64
|
+
{ name: "world_status", title: "Where a world stands", annotations: readOnly,
|
|
65
|
+
description: "Where a world stands: planted problems found, missed, false alarms where nothing was planted, and flips between repeats, with what the app raised for each.",
|
|
66
|
+
inputSchema: { type: "object", properties: { jobId: str("The world's id, from world.") }, required: ["jobId"], additionalProperties: false } },
|
|
67
|
+
{ name: "test", title: "Test what you built",
|
|
68
|
+
description: "The one call after a change: say what you built or want tested, in a sentence, and Cortad decides what kind of test that is. A world, where this app has one and the sentence names something it can plant; a run of the app's chat surfaces otherwise. Answers within a second with which kind started and the call that follows it.",
|
|
69
|
+
inputSchema: { type: "object", properties: { ask: str("What was built or what to test, in a sentence.", { maxLength: 2000 }) }, additionalProperties: false } },
|
|
70
|
+
{ name: "connect", title: "Connect the live app", inputSchema: none,
|
|
71
|
+
description: "The steps to connect the live app, backend and page: a one-time code writes both keys into the env file, the SDK lines for the framework, the page tag, and where each conversation then appears. The same as field_connect." },
|
|
72
|
+
{ name: "intelligence", title: "The live app in numbers", annotations: readOnly,
|
|
73
|
+
description: "What is happening to the live app: what is wrong now (since when, in how many of how many, the change before it), the window's conversations and checks, the visits and what people did after answers. No user's words.",
|
|
74
|
+
inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 90, description: "Window in days. Default 7." } }, additionalProperties: false } },
|
|
75
|
+
{ name: "incidents", title: "What went wrong", annotations: readOnly, inputSchema: none,
|
|
76
|
+
description: "Each open incident: the check or reaction, since when, now against before, the cause the data points at (a release, a changed prompt or model, tool errors, page errors), and the conversations to open on the page. Fix the cause named first, then verify." },
|
|
77
|
+
{ name: "outcomes", title: "What counts as a good ending",
|
|
78
|
+
description: "What the app counts as an outcome and how often it is reached; or, with proposals, new ones read from the code (a signup route, a checkout, a done button), which the person approves on the page before they count.",
|
|
79
|
+
inputSchema: { type: "object", properties: { proposals: { type: "array", maxItems: 10, items: { type: "object", properties: { name: str("A short name, as the person would say it.", { maxLength: 64 }), why: str("What in the code makes this an outcome.", { maxLength: 500 }), rules: { type: "array", minItems: 1, maxItems: 10, items: { type: "object", properties: { kind: { type: "string", enum: ["path", "click", "event", "backend"] }, match: { type: "string", enum: ["exact", "starts", "contains"] }, value: { type: "string" }, text: { type: "string" }, selector: { type: "string" }, name: { type: "string" } }, required: ["kind"], additionalProperties: false } } }, required: ["name", "rules", "why"], additionalProperties: false } } }, additionalProperties: false } },
|
|
80
|
+
{ name: "subscribe", title: "Hear of incidents",
|
|
81
|
+
description: "Registers a public https address that receives each incident as signed JSON (x-cortad-event, x-cortad-signature: sha256=HMAC of the body). Answers with the secret once; keep it where the app keeps its secrets.",
|
|
82
|
+
inputSchema: { type: "object", properties: { url: str("A public https address that takes a POST.", { maxLength: 500 }) }, required: ["url"], additionalProperties: false } },
|
|
83
|
+
{ name: "query", title: "Ask the warehouse", annotations: readOnly,
|
|
84
|
+
description: "A structured question over the live app's events, readings or reactions: a table, a time range of at most 90 days, filters, a grouping by columns or by hour or day, measures (count, count_distinct, sum, avg, min, max, share), a limit. Compiled by Cortad, pinned to this app; no SQL is taken. Rows come back as a table.",
|
|
85
|
+
inputSchema: { type: "object", properties: {
|
|
86
|
+
table: { type: "string", enum: ["events", "readings", "reactions"] },
|
|
87
|
+
from: str("Start, ISO 8601."), to: str("End, ISO 8601, at most 90 days after from."),
|
|
88
|
+
filters: { type: "array", maxItems: 20, items: { type: "object", properties: { column: { type: "string" }, op: { type: "string", enum: ["eq", "ne", "in", "gt", "gte", "lt", "lte", "null", "not_null"] }, value: {} }, required: ["column", "op"], additionalProperties: false } },
|
|
89
|
+
group: { type: "array", maxItems: 4, items: { type: "string" }, description: "Columns, or hour or day." },
|
|
90
|
+
measures: { type: "array", minItems: 1, maxItems: 8, items: { type: "object", properties: { fn: { type: "string", enum: ["count", "count_distinct", "sum", "avg", "min", "max", "share"] }, column: { type: "string" }, equals: {}, as: { type: "string" } }, required: ["fn"], additionalProperties: false } },
|
|
91
|
+
order: { type: "object", properties: { by: { type: "string" }, dir: { type: "string", enum: ["asc", "desc"] } }, required: ["by"], additionalProperties: false },
|
|
92
|
+
limit: { type: "integer", minimum: 1, maximum: 1000 },
|
|
93
|
+
}, required: ["table", "from", "to", "measures"], additionalProperties: false } },
|
|
61
94
|
];
|
|
62
95
|
const NAMES = new Set(TOOLS.map((t) => t.name));
|
|
63
96
|
|
package/lib/mint.mjs
CHANGED
|
@@ -109,7 +109,7 @@ async function send(url, { method = "POST", headers = {}, body } = {}) {
|
|
|
109
109
|
const res = await fetch(url, { method, headers: { "content-type": "application/json", ...headers }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(20_000) });
|
|
110
110
|
return { ok: res.ok, status: res.status, json: await res.json().catch(() => null) };
|
|
111
111
|
}
|
|
112
|
-
const saidBy = (r) => String(r.json?.errors?.[0]?.long_message ?? r.json?.msg ?? r.json?.error_description ?? r.json?.message ?? `status ${r.status}`).slice(0, 120);
|
|
112
|
+
const saidBy = (r) => String(r.json?.errors?.[0]?.long_message ?? r.json?.msg ?? r.json?.error_description ?? r.json?.message ?? r.json?.error?.message ?? `status ${r.status}`).slice(0, 120);
|
|
113
113
|
const refusedBy = (who, r) => ({ status: "refused", note: `${who} refused the test account: ${saidBy(r)}` });
|
|
114
114
|
// An account the service already held may have been given a role since this machine made it.
|
|
115
115
|
const ELEVATED_ACCOUNT = { status: "refused", note: "the test account carries an admin role; only an ordinary account is signed in" };
|
|
@@ -176,13 +176,31 @@ export function supabaseCookie(origin, session) {
|
|
|
176
176
|
return value.match(/.{1,3180}/g).map((part, i) => `${name}.${i}=${part}`).join("; ");
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// The Auth emulator their app verifies against while FIREBASE_AUTH_EMULATOR_HOST is set: it signs up any
|
|
180
|
+
// address under any key. The account is this machine's own test account, made once and signed in after.
|
|
181
|
+
export async function emulatorToken(host, account) {
|
|
182
|
+
// An emulator runs beside the app on this machine, and the test account's password goes to it in
|
|
183
|
+
// plain http: never to another host.
|
|
184
|
+
if (!/^(?:localhost|127\.0\.0\.1|\[::1\]):\d{2,5}$/.test(host)) return { status: "refused", note: "FIREBASE_AUTH_EMULATOR_HOST does not name an emulator on this machine" };
|
|
185
|
+
const at = (verb) => `http://${host}/identitytoolkit.googleapis.com/v1/accounts:${verb}?key=cortad-test`;
|
|
186
|
+
const body = { email: account.email, password: account.password, returnSecureToken: true };
|
|
187
|
+
let got = await send(at("signUp"), { body });
|
|
188
|
+
if (!got.ok && /EMAIL_EXISTS/.test(saidBy(got))) got = await send(at("signInWithPassword"), { body });
|
|
189
|
+
if (!got.ok || typeof got.json?.idToken !== "string") return refusedBy("your Firebase Auth emulator", got);
|
|
190
|
+
return { idToken: got.json.idToken, ttl: Number(got.json.expiresIn) || 3600 };
|
|
191
|
+
}
|
|
192
|
+
|
|
179
193
|
// Each hosted service's sign-in, as the value the role's header carries and how long it holds.
|
|
180
194
|
const SIGN_IN = new Map([
|
|
181
|
-
["firebase", async ({ env, all }) => {
|
|
182
|
-
|
|
195
|
+
["firebase", async ({ env, all, account }) => {
|
|
196
|
+
if (env.FIREBASE_AUTH_EMULATOR_HOST) {
|
|
197
|
+
const got = await emulatorToken(env.FIREBASE_AUTH_EMULATOR_HOST, account);
|
|
198
|
+
return got.idToken ? { value: `Bearer ${got.idToken}`, ttl: got.ttl, door: "your Firebase Auth emulator", said: "made a test account in your Firebase Auth emulator for the run to sign in with" } : got;
|
|
199
|
+
}
|
|
200
|
+
const admin = serviceAccountOf(env);
|
|
183
201
|
const apiKey = webApiKeyOf(all);
|
|
184
|
-
if (!
|
|
185
|
-
const got = await firebaseIdToken(
|
|
202
|
+
if (!admin || !apiKey) return { status: "absent", note: !admin ? "no Firebase admin credential in your environment" : "no Firebase web API key in your environment" };
|
|
203
|
+
const got = await firebaseIdToken(admin, apiKey);
|
|
186
204
|
if (!got.idToken) return { status: "refused", note: `your Firebase project refused the sign-in: ${got.error}` };
|
|
187
205
|
return { value: `Bearer ${got.idToken}`, ttl: 3600, door: "your Firebase project", said: `signed in to your app as a test user it can tell apart (${TEST_UID}, in your own Firebase project)` };
|
|
188
206
|
}],
|
|
@@ -251,13 +269,11 @@ export function makeIdentities({ root, work, envFiles, sourceFiles = () => [], s
|
|
|
251
269
|
rmSync(out, { force: true });
|
|
252
270
|
// ponytail: the program is Python, as it is in the sandbox. A machine without python3 mints
|
|
253
271
|
// nothing and says so; port the program to this file if that turns out to be common.
|
|
254
|
-
// A caller a hosted service verifies is signed in at that service, below
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
// after the world had given up waiting for it.
|
|
272
|
+
// A caller a hosted service verifies is signed in at that service, below, a Firebase one at the
|
|
273
|
+
// Auth emulator when the app's settings name one: the program reads one env file and never saw an
|
|
274
|
+
// emulator named anywhere else, so a Python app verified by the emulator was knocked unsigned.
|
|
258
275
|
const env = merged();
|
|
259
|
-
const
|
|
260
|
-
const signsHere = (r) => SIGN_IN.has(r?.kind) && !(r.kind === "firebase" && emulated);
|
|
276
|
+
const signsHere = (r) => SIGN_IN.has(r?.kind);
|
|
261
277
|
const refused = new Map();
|
|
262
278
|
// A pair the environment names for the app's own account is the one credential the raise is
|
|
263
279
|
// allowed to reuse: the elevation and existing-account guards do not apply to it.
|
package/lib/mongo.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// A MongoDB database copied for a session on the server it lives on, with MongoDB's own tools: a
|
|
2
|
-
// dump of it restored under a name of ours, and dropped after.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// dump of it restored under a name of ours, and dropped after. Or, for an app that names its
|
|
3
|
+
// databases in its code, the whole server copied into a container of ours (serveMongo). The address
|
|
4
|
+
// carries the password, so it reaches mongodump and mongorestore through a config file only its user
|
|
5
|
+
// can read, and mongosh through its environment: never a command line another process can read.
|
|
5
6
|
import { spawn } from "node:child_process";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
6
8
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
9
|
import { tmpdir } from "node:os";
|
|
8
10
|
import { join } from "node:path";
|
|
11
|
+
import { docker, dockerUp, freePort, runCopy, servedBy, until } from "./container.mjs";
|
|
9
12
|
|
|
10
13
|
// A database bigger than this is not copied: said instead, so the person decides.
|
|
11
14
|
export const MONGO_CAP = 1024 ** 3;
|
|
@@ -72,3 +75,61 @@ export async function cloneMongo(conn, source, clone) {
|
|
|
72
75
|
export async function dropMongo(conn, clone, source) {
|
|
73
76
|
return (await shell(withDb(conn.uri, clone, source), `c.getDB(${JSON.stringify(clone)}).dropDatabase()`)).code === 0;
|
|
74
77
|
}
|
|
78
|
+
|
|
79
|
+
// The address with only this host in it, for a reader that talks to the one server it names: a
|
|
80
|
+
// replica set's member list holds names only its own network resolves.
|
|
81
|
+
function directTo(uri, hostPort) {
|
|
82
|
+
const [, head, hosts, rest = "", query = "", tail = ""] = /^([^:]+:\/\/)([^/?#]*)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/.exec(uri);
|
|
83
|
+
const cut = hosts.lastIndexOf("@");
|
|
84
|
+
const params = new URLSearchParams(query.slice(1));
|
|
85
|
+
params.delete("replicaSet");
|
|
86
|
+
params.set("directConnection", "true");
|
|
87
|
+
return `${head}${cut >= 0 ? hosts.slice(0, cut + 1) : ""}${hostPort}${rest || "/"}?${params}${tail}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A replica set that asks for a password needs a key its members share, one member or not: made in
|
|
91
|
+
// the container before mongod starts, readable by mongod alone.
|
|
92
|
+
const KEYED = 'umask 077; head -c 756 /dev/urandom | base64 > /tmp/cortad.key; chown mongodb /tmp/cortad.key; exec docker-entrypoint.sh mongod "$@"';
|
|
93
|
+
|
|
94
|
+
// The whole server copied into a container of ours (lib/container.mjs) with MongoDB's own tools,
|
|
95
|
+
// which the container has whatever this machine lacks: every database, its indexes, and its users
|
|
96
|
+
// and roles, so the app signs in there as it does on the original. A server the app signs in to is
|
|
97
|
+
// copied into one that asks for a password too: the dump is restored by a user of ours, made through
|
|
98
|
+
// MongoDB's localhost exception and dropped after. Answers { port } or { why }.
|
|
99
|
+
export async function serveMongo(conn, name) {
|
|
100
|
+
if (!(await dockerUp())) return { why: "docker" };
|
|
101
|
+
const from = await servedBy(conn.port);
|
|
102
|
+
let image = from?.image;
|
|
103
|
+
if (!image) {
|
|
104
|
+
const v = await shell(conn.uri, "print(c.getDB('admin').version())");
|
|
105
|
+
if (v.code !== 0) return { why: refusal(v) };
|
|
106
|
+
image = `mongo:${v.out.trim()}`;
|
|
107
|
+
}
|
|
108
|
+
const port = await freePort();
|
|
109
|
+
const replSet = new URLSearchParams(/\?([^#]*)/.exec(conn.uri)?.[1] ?? "").get("replicaSet");
|
|
110
|
+
const restorer = conn.user ? { user: `cortad_${randomBytes(4).toString("hex")}`, pwd: randomBytes(24).toString("hex") } : null;
|
|
111
|
+
const mongod = ["--port", String(port), "--bind_ip_all", ...(replSet ? ["--replSet", replSet] : []), ...(restorer ? ["--auth"] : [])];
|
|
112
|
+
const started = restorer && replSet
|
|
113
|
+
? { entrypoint: "bash", args: ["-c", KEYED, "bash", ...mongod, "--keyFile", "/tmp/cortad.key"] }
|
|
114
|
+
: { args: mongod };
|
|
115
|
+
if (!(await runCopy({ name, image, port, network: from?.net, ...started }))) return { why: "container" };
|
|
116
|
+
const inside = (script, env = {}) => docker(["exec", ...Object.keys(env).flatMap((k) => ["-e", k]), name, "mongosh", "--quiet", "--port", String(port), "--eval", script], { env });
|
|
117
|
+
if (!(await until(async () => (await inside("db.runCommand({ ping: 1 }).ok")).out.trim() === "1", 90_000))) return { why: "other" };
|
|
118
|
+
if (replSet) {
|
|
119
|
+
await inside(`rs.initiate({ _id: ${JSON.stringify(replSet)}, members: [{ _id: 0, host: "127.0.0.1:${port}" }] })`);
|
|
120
|
+
if (!(await until(async () => (await inside("db.hello().isWritablePrimary")).out.trim() === "true", 60_000))) return { why: "other" };
|
|
121
|
+
}
|
|
122
|
+
if (restorer && (await inside(`print(db.getSiblingDB("admin").createUser({ user: ${JSON.stringify(restorer.user)}, pwd: process.env.CORTAD_PWD, roles: ["root"] }).ok)`, { CORTAD_PWD: restorer.pwd })).out.trim() !== "1") return { why: "other" };
|
|
123
|
+
const source = directTo(conn.uri, from?.ip ? `${from.ip}:${from.inner}` : `host.docker.internal:${conn.port}`);
|
|
124
|
+
const target = `mongodb://${restorer ? `${restorer.user}:${restorer.pwd}@` : ""}127.0.0.1:${port}/?directConnection=true${restorer ? "&authSource=admin" : ""}`;
|
|
125
|
+
const size = await docker(["exec", "-e", "CORTAD_SOURCE", name, "mongosh", "--nodb", "--quiet", "--eval", "print(new Mongo(process.env.CORTAD_SOURCE).getDB('admin').adminCommand({ listDatabases: 1 }).totalSize)"], { env: { CORTAD_SOURCE: source } });
|
|
126
|
+
if (!size.ok) return { why: refusal({ code: 1, err: size.err + size.out }) };
|
|
127
|
+
if (Number(size.out.trim()) > MONGO_CAP) return { why: "big" };
|
|
128
|
+
// Each address goes into a file only the container's own user reads, never onto a command line.
|
|
129
|
+
const copy = await docker(["exec", "-e", "CORTAD_FROM", "-e", "CORTAD_TO", name, "bash", "-c",
|
|
130
|
+
"set -o pipefail; umask 077; printf '%s\\n' \"$CORTAD_FROM\" > /tmp/from.yaml && printf '%s\\n' \"$CORTAD_TO\" > /tmp/to.yaml && mongodump --config=/tmp/from.yaml --archive --quiet | mongorestore --config=/tmp/to.yaml --archive --quiet --nsExclude='config.*'; s=$?; rm -f /tmp/from.yaml /tmp/to.yaml; exit $s"],
|
|
131
|
+
{ env: { CORTAD_FROM: `uri: ${JSON.stringify(source)}`, CORTAD_TO: `uri: ${JSON.stringify(target)}` }, timeout: 900_000 });
|
|
132
|
+
if (!copy.ok) return { why: refusal({ code: 1, err: copy.err }) };
|
|
133
|
+
if (restorer) await docker(["exec", "-e", "CORTAD_TARGET", name, "mongosh", "--nodb", "--quiet", "--eval", `new Mongo(process.env.CORTAD_TARGET).getDB("admin").dropUser(${JSON.stringify(restorer.user)})`], { env: { CORTAD_TARGET: target } });
|
|
134
|
+
return { port };
|
|
135
|
+
}
|
package/lib/proof.mjs
CHANGED
|
@@ -991,7 +991,14 @@ export function canaryOf(exchanges, proof, found = null, own = []) {
|
|
|
991
991
|
if (carried(b1)) add("shared-history", "theirs", "A new conversation carried an earlier one's words: every conversation on this endpoint shares what your app keeps, so test conversations would read each other's.");
|
|
992
992
|
const met = metIn(a1, own);
|
|
993
993
|
if (met) add("unreadable", "theirs", met);
|
|
994
|
-
else if (!ok(a1.status)
|
|
994
|
+
else if (!ok(a1.status)) add("unreadable", "theirs", `Your app answered ${a1.status} to the first message.`);
|
|
995
|
+
else if (!replyOf(a1).text.trim()) {
|
|
996
|
+
// No words because the app's own model refused it is the app's key, card or model, never our
|
|
997
|
+
// reading: a wrong key in the twin's env file held Run as "could not be read as words", ours.
|
|
998
|
+
const calls = generation(a1);
|
|
999
|
+
const refused = calls.length && calls.every((c) => REFUSED.has(Number(c.status))) ? calls.at(-1) : null;
|
|
1000
|
+
add("unreadable", refused ? "theirs" : "ours", refused ? `The first message reached your app's model, and the ${modelCall(refused)}, so no words came back.` : "The reply to the first message could not be read as words.");
|
|
1001
|
+
}
|
|
995
1002
|
const instructions = str(generation(a1).find((c) => typeof c.instructions === "string")?.instructions).trim();
|
|
996
1003
|
const pieces = piecesOf(instructions);
|
|
997
1004
|
const request = norm(a1.body);
|