cortad 0.3.0 → 0.3.1-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/data.mjs +143 -0
- package/lib/pyhook/sitecustomize.py +300 -35
- package/lib/read-text.mjs +23 -7
- package/lib/replay.mjs +12 -6
- package/lib/text.mjs +47 -4
- package/lib/trace.cjs +189 -20
- package/lib/words.mjs +18 -1
- package/local.mjs +39 -4
- package/package.json +2 -1
- package/skill/references/results.md +2 -0
package/lib/data.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Your app's own database, kept out of the run's way. A run creates people, conversations and
|
|
2
|
+
// records by the hundred; written into the file your app keeps its real data in, they stay there
|
|
3
|
+
// after the run. So an app started here runs against a copy of its database file, made in this
|
|
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.
|
|
7
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
const FILE_DB = /\.(?:db|sqlite3?|duckdb)$/i;
|
|
11
|
+
// sqlite:///x.db (SQLAlchemy, relative), sqlite:////abs/x.db, sqlite+aiosqlite:///x.db, file:./dev.db (Prisma, libsql).
|
|
12
|
+
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
|
+
|
|
16
|
+
// What one value names: a database file (with how to write the value again for another path), a
|
|
17
|
+
// database server, or nothing.
|
|
18
|
+
export function databaseOf(value) {
|
|
19
|
+
const v = String(value ?? "").trim();
|
|
20
|
+
if (!v) return null;
|
|
21
|
+
const server = SERVER_URL.exec(v);
|
|
22
|
+
if (server) return { server: ENGINE[server[1].toLowerCase()] ?? server[1] };
|
|
23
|
+
const url = FILE_URL.exec(v);
|
|
24
|
+
if (url) {
|
|
25
|
+
const path = decodeURIComponent(url[2]);
|
|
26
|
+
if (!path || path === ":memory:" || path.startsWith(":memory")) return null;
|
|
27
|
+
return { path, write: (to) => `${url[1]}${to}${url[3] ?? ""}`, prisma: /^file:/i.test(url[1]) };
|
|
28
|
+
}
|
|
29
|
+
if (!v.includes("://") && FILE_DB.test(v)) return { path: v, write: (to) => to };
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A folder the app keeps its data in, named by a variable (DATA_DIR=./data): a database file sits
|
|
34
|
+
// directly inside it. The whole folder is the database then, uploads and backups beside it.
|
|
35
|
+
const FOLDER_NAME = /DATA|DB|DATABASE|STORAGE|STORE|PERSIST|SQLITE|DUCK/i;
|
|
36
|
+
function dataFolder(name, value, dirs, appDir, root) {
|
|
37
|
+
const v = String(value ?? "").trim();
|
|
38
|
+
if (!FOLDER_NAME.test(name) || !v || v.includes("://") || FILE_DB.test(v) || !/^[.~/\w]/.test(v)) return null;
|
|
39
|
+
for (const place of [appDir, ...dirs].map((d) => resolve(d, v))) {
|
|
40
|
+
if (place === root || place === appDir || !place.startsWith(root + "/")) continue;
|
|
41
|
+
let names;
|
|
42
|
+
try { if (!statSync(place).isDirectory()) continue; names = readdirSync(place); } catch { continue; }
|
|
43
|
+
if (names.some((n) => FILE_DB.test(n))) return place;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const sizeOf = (dir, cap) => {
|
|
48
|
+
let total = 0;
|
|
49
|
+
const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { if (total > cap) return; const p = join(d, e.name); if (e.isDirectory()) walk(p); else if (e.isFile()) total += statSync(p).size; } };
|
|
50
|
+
try { walk(dir); } catch { /* counted what it could */ }
|
|
51
|
+
return total;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// A name the code reads with a default written beside it: os.getenv("DATABASE_URL", "sqlite:///./app.db"),
|
|
55
|
+
// process.env.DB_PATH ?? "./data.db". With nothing in the environment, the default is the database.
|
|
56
|
+
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
|
+
// A database file opened by a path written in the code, with no variable in front of it.
|
|
58
|
+
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)$/;
|
|
60
|
+
// A test opens its own scratch file; that is not where the app keeps its data.
|
|
61
|
+
const TEST = /(?:^|\/)(?:tests?|__tests__|spec|e2e|fixtures?)\/|[._-](?:test|spec)\.[^/]+$|(?:^|\/)(?:test_[^/]+|conftest)\.py$/;
|
|
62
|
+
// A folder bigger than this is not copied: said instead, so the person decides.
|
|
63
|
+
const FOLDER_CAP = 1024 ** 3;
|
|
64
|
+
|
|
65
|
+
function scan(sources) {
|
|
66
|
+
const defaults = {};
|
|
67
|
+
const opened = [];
|
|
68
|
+
for (const file of sources) {
|
|
69
|
+
if (!CODE.test(file) || TEST.test(file)) continue;
|
|
70
|
+
let text = "";
|
|
71
|
+
try { if (statSync(file).size > 512_000) continue; text = readFileSync(file, "utf8"); } catch { continue; }
|
|
72
|
+
for (const m of text.matchAll(DEFAULTED)) {
|
|
73
|
+
const name = m[1] ?? m[3] ?? m[4];
|
|
74
|
+
const value = m[2] ?? m[5];
|
|
75
|
+
if (name && !(name in defaults) && databaseOf(value)) defaults[name] = value;
|
|
76
|
+
}
|
|
77
|
+
for (const m of text.matchAll(OPENED)) { const db = databaseOf(m[1]); if (db?.path) opened.push({ file, path: db.path }); }
|
|
78
|
+
}
|
|
79
|
+
return { defaults, opened };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Where a relative path lands. The app's own folder first, which is where it runs; then the folder
|
|
83
|
+
// of the env file that named it, which some settings loaders resolve against; for Prisma, the folder
|
|
84
|
+
// its schema lives in. The first place the file exists is the one; none, and it is the app's folder.
|
|
85
|
+
function located(db, dirs, appDir) {
|
|
86
|
+
if (isAbsolute(db.path)) return db.path;
|
|
87
|
+
const prisma = db.prisma ? [join(appDir, "prisma")] : [];
|
|
88
|
+
const places = [...prisma, appDir, ...dirs].map((d) => resolve(d, db.path));
|
|
89
|
+
return places.find((p) => existsSync(p)) ?? places[0];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 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.
|
|
94
|
+
export function keepData({ values, from = {}, appDir, root = appDir, work, sources = [], started = true }) {
|
|
95
|
+
const { defaults, opened } = scan(sources);
|
|
96
|
+
const all = { ...defaults, ...values };
|
|
97
|
+
const env = {};
|
|
98
|
+
const copied = [];
|
|
99
|
+
const said = [];
|
|
100
|
+
const moved = new Set();
|
|
101
|
+
const rel = (p) => relative(root, p) || basename(p);
|
|
102
|
+
for (const [name, value] of Object.entries(all)) {
|
|
103
|
+
const db = databaseOf(value);
|
|
104
|
+
const folder = db ? null : dataFolder(name, value, from[name] ? [dirname(from[name])] : [], appDir, root);
|
|
105
|
+
if (folder) {
|
|
106
|
+
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; }
|
|
109
|
+
const to = join(work, "data", String(copied.length), basename(folder));
|
|
110
|
+
cpSync(folder, to, { recursive: true });
|
|
111
|
+
env[name] = to;
|
|
112
|
+
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.`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
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
|
+
const original = located(db, from[name] ? [dirname(from[name])] : [], appDir);
|
|
122
|
+
moved.add(original);
|
|
123
|
+
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.`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const to = join(work, "data", String(copied.length), basename(original));
|
|
128
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
129
|
+
// The file and the journal beside it: SQLite keeps recent writes in -wal until a checkpoint.
|
|
130
|
+
if (existsSync(original)) for (const tail of ["", "-wal", "-shm", "-journal"]) if (existsSync(original + tail)) copyFileSync(original + tail, to + tail);
|
|
131
|
+
env[name] = db.write(to);
|
|
132
|
+
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.`);
|
|
134
|
+
}
|
|
135
|
+
for (const o of opened) {
|
|
136
|
+
const path = resolve(dirname(o.file), o.path);
|
|
137
|
+
const cwdPath = resolve(appDir, o.path);
|
|
138
|
+
if ([path, cwdPath].some((p) => moved.has(p) || [...moved].some((m) => p.startsWith(m + "/")))) continue;
|
|
139
|
+
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.`);
|
|
141
|
+
}
|
|
142
|
+
return { env, copied, said };
|
|
143
|
+
}
|
|
@@ -84,6 +84,24 @@ def _install():
|
|
|
84
84
|
return ""
|
|
85
85
|
return ""
|
|
86
86
|
|
|
87
|
+
def items(v):
|
|
88
|
+
return [x for x in v if isinstance(x, dict)] if isinstance(v, list) else []
|
|
89
|
+
|
|
90
|
+
# Only what came after the person's latest message belongs to this turn: a thread the app
|
|
91
|
+
# resends whole carries every earlier turn's tool answers too.
|
|
92
|
+
def since(seq, is_person):
|
|
93
|
+
at = -1
|
|
94
|
+
for i, m in enumerate(seq):
|
|
95
|
+
if is_person(m):
|
|
96
|
+
at = i
|
|
97
|
+
return seq[at + 1:]
|
|
98
|
+
|
|
99
|
+
def person_said(m):
|
|
100
|
+
return m.get("role") == "user" and not any(isinstance(c, dict) and c.get("type") == "tool_result" for c in (m.get("content") if isinstance(m.get("content"), list) else []))
|
|
101
|
+
|
|
102
|
+
def person_asked(c):
|
|
103
|
+
return c.get("role") == "user" and not any(p.get("functionResponse") for p in items(c.get("parts")))
|
|
104
|
+
|
|
87
105
|
def tools_in(sent):
|
|
88
106
|
body = parsed(sent) if isinstance(sent, str) else None
|
|
89
107
|
if not isinstance(body, dict):
|
|
@@ -95,39 +113,258 @@ def _install():
|
|
|
95
113
|
if t.strip() and len(out) < tools_max and all(o["text"] != t for o in out):
|
|
96
114
|
out.append({"name": str(name or "")[:80], "text": t})
|
|
97
115
|
|
|
98
|
-
messages =
|
|
116
|
+
messages = items(body.get("messages"))
|
|
99
117
|
for m in messages:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
for c in m.get("tool_calls") or []:
|
|
103
|
-
if isinstance(c, dict) and c.get("id") and isinstance(c.get("function"), dict):
|
|
118
|
+
for c in items(m.get("tool_calls")):
|
|
119
|
+
if c.get("id") and isinstance(c.get("function"), dict):
|
|
104
120
|
names[c["id"]] = c["function"].get("name")
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
for m in messages:
|
|
110
|
-
if not isinstance(m, dict):
|
|
111
|
-
continue
|
|
121
|
+
for c in items(m.get("content")):
|
|
122
|
+
if c.get("type") == "tool_use" and c.get("id"):
|
|
123
|
+
names[c["id"]] = c.get("name")
|
|
124
|
+
for m in since(messages, person_said):
|
|
112
125
|
if m.get("role") in ("tool", "function"):
|
|
113
126
|
add(m.get("name") or names.get(m.get("tool_call_id")), m.get("content"))
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if isinstance(it, dict) and it.get("type") == "function_call" and it.get("call_id"):
|
|
127
|
+
for c in items(m.get("content")):
|
|
128
|
+
if c.get("type") == "tool_result":
|
|
129
|
+
add(names.get(c.get("tool_use_id")), c.get("content"))
|
|
130
|
+
inputs = items(body.get("input"))
|
|
131
|
+
for it in inputs:
|
|
132
|
+
if it.get("type") == "function_call" and it.get("call_id"):
|
|
121
133
|
names[it["call_id"]] = it.get("name")
|
|
122
|
-
for it in
|
|
123
|
-
if
|
|
134
|
+
for it in since(inputs, lambda x: x.get("role") == "user"):
|
|
135
|
+
if it.get("type") == "function_call_output":
|
|
124
136
|
add(names.get(it.get("call_id")), it.get("output"))
|
|
125
|
-
for c in
|
|
126
|
-
for p in
|
|
127
|
-
if isinstance(p
|
|
137
|
+
for c in since(items(body.get("contents")), person_asked):
|
|
138
|
+
for p in items(c.get("parts")):
|
|
139
|
+
if isinstance(p.get("functionResponse"), dict):
|
|
128
140
|
add(p["functionResponse"].get("name"), p["functionResponse"].get("response"))
|
|
129
141
|
return out or None
|
|
130
142
|
|
|
143
|
+
# What the model asked the app to run: chat tool_calls (a stream's pieces joined by position), a
|
|
144
|
+
# legacy function_call, a responses function_call item, an Anthropic or Bedrock tool use, a
|
|
145
|
+
# Gemini functionCall. Read off the model's reply, and off this turn's earlier calls as the
|
|
146
|
+
# prompt resends them. Names and arguments only: every value clipped, a secret never written.
|
|
147
|
+
calls_max, value_max, args_max = 12, 200, 1200
|
|
148
|
+
secret_key = re.compile(r"(?:^|_)(?:pass(?:word|phrase)?|secret|token|api_?key|authorization|cookie|session(?:_id)?|credentials?|private_key)$")
|
|
149
|
+
secret_value = re.compile(r"^(?:Bearer\s|Basic\s|sk-|pk_|rk_|ghp_|gho_|github_pat_|xox[abpr]-|AKIA|AIza|eyJ[\w-]{10,}\.)")
|
|
150
|
+
|
|
151
|
+
def clipped(v, depth=0):
|
|
152
|
+
if isinstance(v, str):
|
|
153
|
+
return "[secret]" if secret_value.match(v) else (v[:value_max] + "…" if len(v) > value_max else v)
|
|
154
|
+
if isinstance(v, list):
|
|
155
|
+
return [] if depth > 4 else [clipped(x, depth + 1) for x in v[:20]]
|
|
156
|
+
if isinstance(v, dict):
|
|
157
|
+
if depth > 4:
|
|
158
|
+
return {}
|
|
159
|
+
return {k: ("[secret]" if secret_key.search(re.sub(r"([a-z])([A-Z])", r"\1_\2", str(k)).lower()) else clipped(x, depth + 1)) for k, x in list(v.items())[:40]}
|
|
160
|
+
return v
|
|
161
|
+
|
|
162
|
+
def args_text(raw):
|
|
163
|
+
v = raw
|
|
164
|
+
if isinstance(raw, str):
|
|
165
|
+
v = parsed(raw) if raw.strip() else {}
|
|
166
|
+
if v is None:
|
|
167
|
+
v = raw
|
|
168
|
+
return json.dumps(clipped({} if v is None else v), ensure_ascii=False, separators=(",", ":"))[:args_max]
|
|
169
|
+
|
|
170
|
+
def calls_of(events):
|
|
171
|
+
whole, parts, n = {}, {}, [0]
|
|
172
|
+
|
|
173
|
+
def put(key, name, args):
|
|
174
|
+
if name:
|
|
175
|
+
if not key:
|
|
176
|
+
n[0] += 1
|
|
177
|
+
key = "w%d" % n[0]
|
|
178
|
+
whole[key] = (name, args)
|
|
179
|
+
|
|
180
|
+
def piece(key, name, args):
|
|
181
|
+
p = parts.setdefault(key, ["", ""])
|
|
182
|
+
if name and not p[0]:
|
|
183
|
+
p[0] = name
|
|
184
|
+
if isinstance(args, str):
|
|
185
|
+
p[1] += args
|
|
186
|
+
|
|
187
|
+
def blocks(content):
|
|
188
|
+
for b in items(content):
|
|
189
|
+
if b.get("type") == "tool_use":
|
|
190
|
+
put(b.get("id"), b.get("name"), b.get("input"))
|
|
191
|
+
if isinstance(b.get("toolUse"), dict):
|
|
192
|
+
put(b["toolUse"].get("toolUseId"), b["toolUse"].get("name"), b["toolUse"].get("input"))
|
|
193
|
+
|
|
194
|
+
for e in events:
|
|
195
|
+
if not isinstance(e, dict):
|
|
196
|
+
continue
|
|
197
|
+
for c in items(e.get("choices")):
|
|
198
|
+
m = c.get("message") if isinstance(c.get("message"), dict) else None
|
|
199
|
+
if m:
|
|
200
|
+
for t in items(m.get("tool_calls")):
|
|
201
|
+
f = t.get("function") if isinstance(t.get("function"), dict) else None
|
|
202
|
+
if f:
|
|
203
|
+
put(t.get("id"), f.get("name"), f.get("arguments"))
|
|
204
|
+
if isinstance(m.get("function_call"), dict):
|
|
205
|
+
put(None, m["function_call"].get("name"), m["function_call"].get("arguments"))
|
|
206
|
+
d = c.get("delta") if isinstance(c.get("delta"), dict) else None
|
|
207
|
+
if d:
|
|
208
|
+
for t in items(d.get("tool_calls")):
|
|
209
|
+
f = t.get("function") if isinstance(t.get("function"), dict) else {}
|
|
210
|
+
piece("c%s.%s" % (c.get("index") or 0, t.get("index", t.get("id"))), f.get("name"), f.get("arguments"))
|
|
211
|
+
if isinstance(d.get("function_call"), dict):
|
|
212
|
+
piece("f%s" % (c.get("index") or 0), d["function_call"].get("name"), d["function_call"].get("arguments"))
|
|
213
|
+
resp = e.get("response") if isinstance(e.get("response"), dict) else {}
|
|
214
|
+
for it in items(e.get("output")) + items([e.get("item")]) + items(resp.get("output")):
|
|
215
|
+
if it.get("type") == "function_call":
|
|
216
|
+
put(it.get("call_id") or it.get("id"), it.get("name"), it.get("arguments"))
|
|
217
|
+
blocks(e.get("content"))
|
|
218
|
+
if isinstance(e.get("message"), dict):
|
|
219
|
+
blocks(e["message"].get("content"))
|
|
220
|
+
if isinstance(e.get("output"), dict) and isinstance(e["output"].get("message"), dict):
|
|
221
|
+
blocks(e["output"]["message"].get("content"))
|
|
222
|
+
cb = e.get("content_block") if isinstance(e.get("content_block"), dict) else {}
|
|
223
|
+
if e.get("type") == "content_block_start" and cb.get("type") == "tool_use":
|
|
224
|
+
piece("a%s" % e.get("index"), cb.get("name"), "")
|
|
225
|
+
delta = e.get("delta") if isinstance(e.get("delta"), dict) else {}
|
|
226
|
+
if e.get("type") == "content_block_delta" and delta.get("type") == "input_json_delta":
|
|
227
|
+
piece("a%s" % e.get("index"), "", delta.get("partial_json"))
|
|
228
|
+
for c in items(e.get("candidates")):
|
|
229
|
+
for p in items((c.get("content") or {}).get("parts") if isinstance(c.get("content"), dict) else None):
|
|
230
|
+
if isinstance(p.get("functionCall"), dict):
|
|
231
|
+
put(None, p["functionCall"].get("name"), p["functionCall"].get("args"))
|
|
232
|
+
found = list(whole.values()) + [tuple(p) for p in parts.values()]
|
|
233
|
+
return [{"name": str(name)[:80], "arguments": args_text(args)} for name, args in found if name]
|
|
234
|
+
|
|
235
|
+
# This turn's earlier calls, as the prompt resends them after the person's latest message.
|
|
236
|
+
def called_before(body):
|
|
237
|
+
events = []
|
|
238
|
+
for m in since(items(body.get("messages")), person_said):
|
|
239
|
+
if m.get("role") == "assistant":
|
|
240
|
+
events.append({"choices": [{"message": m}], "content": m.get("content")})
|
|
241
|
+
events.append({"output": since(items(body.get("input")), lambda x: x.get("role") == "user")})
|
|
242
|
+
for c in since(items(body.get("contents")), person_asked):
|
|
243
|
+
if c.get("role") == "model":
|
|
244
|
+
events.append({"candidates": [{"content": c}]})
|
|
245
|
+
return calls_of(events)
|
|
246
|
+
|
|
247
|
+
def called_in(sent, events):
|
|
248
|
+
body = parsed(sent) if isinstance(sent, str) else None
|
|
249
|
+
out = []
|
|
250
|
+
for c in (called_before(body) if isinstance(body, dict) else []) + calls_of(events):
|
|
251
|
+
if len(out) < calls_max and not any(o["name"] == c["name"] and o["arguments"] == c["arguments"] for o in out):
|
|
252
|
+
out.append(c)
|
|
253
|
+
return out or None
|
|
254
|
+
|
|
255
|
+
# The passages the prompt carried as retrieved context: a block the app itself labels as
|
|
256
|
+
# context, documents, knowledge, sources or search results, in its system prompt or from the
|
|
257
|
+
# person's latest message on, and Anthropic document and search_result blocks. The app's own
|
|
258
|
+
# instructions are not passages: only what sits under such a label is.
|
|
259
|
+
passage_text, passages_max = 3000, 6
|
|
260
|
+
material = re.compile(r"\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq)\b|检索|知识|参考资料|资料|上下文|文档|背景", re.I)
|
|
261
|
+
heading = re.compile(r"^\s*(?:#{1,6}\s+[^\n]{1,80}|[^\n]{1,80}[::]\s*(?:\([^\n)]*\))?|\[[^\n\]]{1,80}\]|={2,}\s*[^\n]{1,80}?\s*={2,})\s*$")
|
|
262
|
+
# An inline label is a noun phrase that ends in the material word ("context:", "background:",
|
|
263
|
+
# "retrieved passages:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
|
|
264
|
+
inline = re.compile(r"^\s*(?:[-*]\s+)?([A-Za-z\u4e00-\u9fff][A-Za-z\u4e00-\u9fff '’_-]{0,29})[::]\s*\S")
|
|
265
|
+
named = re.compile(r"(?:" + material.pattern + r")\s*$", re.I)
|
|
266
|
+
tagged = re.compile(r"<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)</\1>")
|
|
267
|
+
|
|
268
|
+
def labelled(text, add):
|
|
269
|
+
def lift(m):
|
|
270
|
+
if material.search(m.group(1).replace("_", " ")):
|
|
271
|
+
add(m.group(1), m.group(2))
|
|
272
|
+
return ""
|
|
273
|
+
return m.group(0)
|
|
274
|
+
paras = re.split(r"\n\s*\n", tagged.sub(lift, str(text or "")))
|
|
275
|
+
i = 0
|
|
276
|
+
while i < len(paras):
|
|
277
|
+
lines = paras[i].split("\n")
|
|
278
|
+
head = lines[0]
|
|
279
|
+
if heading.match(head) and material.search(head):
|
|
280
|
+
body = "\n".join(lines[1:])
|
|
281
|
+
# A heading alone on its paragraph labels what follows, up to the next heading.
|
|
282
|
+
if not body.strip():
|
|
283
|
+
while i + 1 < len(paras) and len(body) < passage_text and not heading.match(paras[i + 1].split("\n")[0]):
|
|
284
|
+
i += 1
|
|
285
|
+
body += ("\n\n" if body else "") + paras[i]
|
|
286
|
+
add(re.sub(r"[#::\[\]=]", "", head).strip(), body)
|
|
287
|
+
else:
|
|
288
|
+
label = next((m for m in (inline.match(l) for l in lines) if m and named.search(m.group(1))), None)
|
|
289
|
+
if label:
|
|
290
|
+
add(label.group(1).strip(), paras[i])
|
|
291
|
+
i += 1
|
|
292
|
+
|
|
293
|
+
def passages_in(sent):
|
|
294
|
+
body = parsed(sent) if isinstance(sent, str) else None
|
|
295
|
+
if not isinstance(body, dict):
|
|
296
|
+
return None
|
|
297
|
+
out = []
|
|
298
|
+
|
|
299
|
+
def add(name, text):
|
|
300
|
+
t = str(text or "").strip()[:passage_text]
|
|
301
|
+
if len(t) >= 20 and len(out) < passages_max and all(o["text"] != t for o in out):
|
|
302
|
+
out.append({"name": str(name or "")[:80], "text": t})
|
|
303
|
+
|
|
304
|
+
def scan(content):
|
|
305
|
+
if isinstance(content, str):
|
|
306
|
+
return labelled(content, add)
|
|
307
|
+
for b in items(content):
|
|
308
|
+
if b.get("type") == "document":
|
|
309
|
+
src = b.get("source") if isinstance(b.get("source"), dict) else None
|
|
310
|
+
add(b.get("title") or "document", text_of(src.get("data") if src and src.get("data") is not None else (src or {}).get("content")) if src else text_of(b.get("content")))
|
|
311
|
+
elif b.get("type") == "search_result":
|
|
312
|
+
add(b.get("title") or b.get("source") or "search result", text_of(b.get("content")))
|
|
313
|
+
elif isinstance(b.get("text"), str):
|
|
314
|
+
labelled(b["text"], add)
|
|
315
|
+
|
|
316
|
+
messages = items(body.get("messages"))
|
|
317
|
+
for m in messages:
|
|
318
|
+
if m.get("role") in ("system", "developer"):
|
|
319
|
+
scan(m.get("content"))
|
|
320
|
+
last = max([i for i, m in enumerate(messages) if person_said(m)] or [0])
|
|
321
|
+
for m in messages[last:]:
|
|
322
|
+
if m.get("role") == "user":
|
|
323
|
+
scan(m.get("content"))
|
|
324
|
+
scan(body.get("system"))
|
|
325
|
+
scan(body.get("instructions"))
|
|
326
|
+
if isinstance(body.get("systemInstruction"), dict):
|
|
327
|
+
scan(body["systemInstruction"].get("parts"))
|
|
328
|
+
inputs = [{"role": "user", "content": body["input"]}] if isinstance(body.get("input"), str) else items(body.get("input"))
|
|
329
|
+
last = max([i for i, it in enumerate(inputs) if it.get("role") == "user"] or [0])
|
|
330
|
+
for it in inputs[last:]:
|
|
331
|
+
if it.get("role") in ("user", "system", "developer"):
|
|
332
|
+
scan(it.get("content"))
|
|
333
|
+
return out or None
|
|
334
|
+
|
|
335
|
+
# The passages a retrieval call answered with: a vector store, a search index or a web search,
|
|
336
|
+
# read off its reply when it is JSON. Text fields only, bounded like the rest.
|
|
337
|
+
retrieval_host = re.compile(r"pinecone\.io|qdrant|weaviate|chroma|zilliz|milvus|turbopuffer|upstash\.io|algolia|typesense|meilisearch|elastic|opensearch|vespa|tavily\.com|exa\.ai|serper\.dev|serpapi\.com|search\.brave\.com|bing\.microsoft\.com|jina\.ai", re.I)
|
|
338
|
+
retrieval_path = re.compile(r"/(?:query|search|_search|retrieve|similarity_search|rerank|hybrid)(?:/|$)|/points/(?:search|query)|/rpc/match\w*", re.I)
|
|
339
|
+
passage_key = re.compile(r"^(?:text|content|page_?content|pageContent|chunk|snippet|passage|body|document|documents|description|answer|raw_content|highlights?|excerpt)$", re.I)
|
|
340
|
+
|
|
341
|
+
def is_retrieval(url):
|
|
342
|
+
try:
|
|
343
|
+
parts = urlsplit(str(url))
|
|
344
|
+
except ValueError:
|
|
345
|
+
return False
|
|
346
|
+
return bool(retrieval_host.search(parts.hostname or "")) or bool(retrieval_path.search(parts.path or ""))
|
|
347
|
+
|
|
348
|
+
def passages_from(raw):
|
|
349
|
+
out, nodes = [], [0]
|
|
350
|
+
|
|
351
|
+
def walk(v, key, depth):
|
|
352
|
+
nodes[0] += 1
|
|
353
|
+
if len(out) >= passages_max or depth > 7 or nodes[0] > 4000:
|
|
354
|
+
return
|
|
355
|
+
if isinstance(v, str):
|
|
356
|
+
t = v.strip()[:passage_text]
|
|
357
|
+
if passage_key.match(key) and len(t) >= 20 and all(o["text"] != t for o in out):
|
|
358
|
+
out.append({"name": key, "text": t})
|
|
359
|
+
elif isinstance(v, list):
|
|
360
|
+
for x in v:
|
|
361
|
+
walk(x, key, depth + 1)
|
|
362
|
+
elif isinstance(v, dict):
|
|
363
|
+
for k, x in v.items():
|
|
364
|
+
walk(x, str(k), depth + 1)
|
|
365
|
+
walk(parsed(raw) if isinstance(raw, str) else None, "", 0)
|
|
366
|
+
return out or None
|
|
367
|
+
|
|
131
368
|
# A framework that folds its tools' answers into the next prompt as plain text sends no tool
|
|
132
369
|
# message to find: within one turn, what a later call's prompt carries that the call before did
|
|
133
370
|
# not, outside the model's own words, is that material. The first call holds the customer's
|
|
@@ -189,13 +426,14 @@ def _install():
|
|
|
189
426
|
|
|
190
427
|
# Every other outbound call: the host and what it answered, never a byte of it. A search
|
|
191
428
|
# provider over its limit for a whole run was invisible until this row existed.
|
|
192
|
-
|
|
429
|
+
# A store on this machine (a local Qdrant or Chroma) is said too when it answered with passages.
|
|
430
|
+
def dep(url, status, code=None, passages=None, turn=None):
|
|
193
431
|
try:
|
|
194
432
|
host = (urlsplit(str(url)).hostname or "").lower()
|
|
195
|
-
if not host or host in ("localhost", "127.0.0.1", "::1"):
|
|
433
|
+
if not host or (host in ("localhost", "127.0.0.1", "::1") and not passages):
|
|
196
434
|
return
|
|
197
|
-
turn = turn_now()
|
|
198
|
-
write({"dep": {"at": int(time.time() * 1000), "host": host[:253], "status": int(status or 0), **({"code": str(code)[:40]} if code else {}), **({"turn": turn} if turn else {})}})
|
|
435
|
+
turn = turn or turn_now()
|
|
436
|
+
write({"dep": {"at": int(time.time() * 1000), "host": host[:253], "status": int(status or 0), **({"code": str(code)[:40]} if code else {}), **({"turn": turn} if turn else {}), **({"passages": passages} if passages else {})}})
|
|
199
437
|
except Exception:
|
|
200
438
|
pass
|
|
201
439
|
def is_model_call(url):
|
|
@@ -283,7 +521,7 @@ def _install():
|
|
|
283
521
|
if isinstance(part, dict):
|
|
284
522
|
usage.update(part)
|
|
285
523
|
model = e.get("model") or msg.get("model") or resp.get("model") or e.get("modelVersion") or model
|
|
286
|
-
return tokens_of(usage or None), model
|
|
524
|
+
return tokens_of(usage or None), model, events
|
|
287
525
|
|
|
288
526
|
def asked_for(url, sent):
|
|
289
527
|
body = parsed(sent) if isinstance(sent, str) else None
|
|
@@ -308,7 +546,7 @@ def _install():
|
|
|
308
546
|
|
|
309
547
|
def meter(url, sent, status, kind, raw, turn=None):
|
|
310
548
|
try:
|
|
311
|
-
tokens, model = read_reply(kind, (raw or "")[:reply_max])
|
|
549
|
+
tokens, model, events = read_reply(kind, (raw or "")[:reply_max])
|
|
312
550
|
parts = urlsplit(str(url))
|
|
313
551
|
row = {"at": int(time.time() * 1000), "host": parts.netloc.replace(":443", ""), "model": str(asked_for(url, sent) or model or "")[:160],
|
|
314
552
|
"status": int(status or 0), "usage": tokens is not None}
|
|
@@ -323,6 +561,12 @@ def _install():
|
|
|
323
561
|
tools = tools_in(as_text) or material_in(as_text, turn)
|
|
324
562
|
if tools:
|
|
325
563
|
row["tools"] = tools
|
|
564
|
+
called = called_in(as_text, events)
|
|
565
|
+
if called:
|
|
566
|
+
row["called"] = called
|
|
567
|
+
passages = passages_in(as_text)
|
|
568
|
+
if passages:
|
|
569
|
+
row["passages"] = passages
|
|
326
570
|
write({"call": row})
|
|
327
571
|
except Exception:
|
|
328
572
|
pass
|
|
@@ -693,21 +937,31 @@ def _install():
|
|
|
693
937
|
self.finish()
|
|
694
938
|
|
|
695
939
|
def watch(request, response, sent):
|
|
696
|
-
|
|
940
|
+
model = is_model_call(request.url)
|
|
941
|
+
if not model and not is_retrieval(request.url):
|
|
697
942
|
dep(request.url, response.status_code)
|
|
698
943
|
return response
|
|
699
944
|
kind = response.headers.get("content-type", "")
|
|
700
945
|
if getattr(response, "_content", None) is not None:
|
|
701
|
-
|
|
946
|
+
if model:
|
|
947
|
+
meter(request.url, sent, response.status_code, kind, response.content.decode("utf-8", "replace"))
|
|
948
|
+
else:
|
|
949
|
+
dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")))
|
|
702
950
|
return response
|
|
703
|
-
|
|
951
|
+
turn = turn_now()
|
|
952
|
+
if model:
|
|
953
|
+
done = lambda raw: meter(request.url, sent, response.status_code, kind, unpacked(raw, response.headers.get("content-encoding")), turn)
|
|
954
|
+
else:
|
|
955
|
+
done = lambda raw: dep(request.url, response.status_code, passages=passages_from(unpacked(raw, response.headers.get("content-encoding"))), turn=turn)
|
|
704
956
|
stream = response.stream
|
|
705
957
|
if hasattr(stream, "__aiter__") and hasattr(stream, "aclose"):
|
|
706
958
|
response.stream = AsyncKept(stream, done)
|
|
707
959
|
elif hasattr(stream, "__iter__"):
|
|
708
960
|
response.stream = SyncKept(stream, done)
|
|
709
|
-
|
|
961
|
+
elif model:
|
|
710
962
|
meter(request.url, sent, response.status_code, kind, "")
|
|
963
|
+
else:
|
|
964
|
+
dep(request.url, response.status_code)
|
|
711
965
|
return response
|
|
712
966
|
|
|
713
967
|
for cls in (module.Client, module.AsyncClient):
|
|
@@ -769,7 +1023,8 @@ def _install():
|
|
|
769
1023
|
raise
|
|
770
1024
|
try:
|
|
771
1025
|
if not is_model_call(request.url):
|
|
772
|
-
|
|
1026
|
+
read = is_retrieval(request.url) and not k.get("stream")
|
|
1027
|
+
dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")) if read else None)
|
|
773
1028
|
if is_model_call(request.url):
|
|
774
1029
|
# A streamed reply is counted as a call whose counts were not read.
|
|
775
1030
|
raw = response.content.decode("utf-8", "replace") if getattr(response, "_content_consumed", False) else ""
|
|
@@ -797,6 +1052,8 @@ def _install():
|
|
|
797
1052
|
raise
|
|
798
1053
|
if is_model_call(str_or_url):
|
|
799
1054
|
response._cortad = (str_or_url, text(body), turn_now())
|
|
1055
|
+
elif is_retrieval(str_or_url):
|
|
1056
|
+
response._cortad_dep = (str_or_url, turn_now())
|
|
800
1057
|
else:
|
|
801
1058
|
dep(str_or_url, response.status)
|
|
802
1059
|
return response
|
|
@@ -806,6 +1063,10 @@ def _install():
|
|
|
806
1063
|
|
|
807
1064
|
async def read_kept(self, *a, **k):
|
|
808
1065
|
raw = await read(self, *a, **k)
|
|
1066
|
+
store = getattr(self, "_cortad_dep", None)
|
|
1067
|
+
if store:
|
|
1068
|
+
self._cortad_dep = None
|
|
1069
|
+
dep(store[0], self.status, passages=passages_from((raw or b"").decode("utf-8", "replace")), turn=store[1])
|
|
809
1070
|
call = getattr(self, "_cortad", None)
|
|
810
1071
|
if call:
|
|
811
1072
|
self._cortad = None
|
|
@@ -813,6 +1074,10 @@ def _install():
|
|
|
813
1074
|
return raw
|
|
814
1075
|
|
|
815
1076
|
def release_kept(self, *a, **k):
|
|
1077
|
+
store = getattr(self, "_cortad_dep", None)
|
|
1078
|
+
if store:
|
|
1079
|
+
self._cortad_dep = None
|
|
1080
|
+
dep(store[0], self.status, turn=store[1])
|
|
816
1081
|
call = getattr(self, "_cortad", None)
|
|
817
1082
|
if call:
|
|
818
1083
|
self._cortad = None
|
package/lib/read-text.mjs
CHANGED
|
@@ -12,10 +12,16 @@ const rulesHead = (rules) => {
|
|
|
12
12
|
const files = Array.isArray(rules.files) ? rules.files.length : rules.files;
|
|
13
13
|
return `Rules in the code: ${num(rules.count)}${has(files) ? `, in ${plural(files, "file")}` : ""}.`;
|
|
14
14
|
};
|
|
15
|
-
|
|
15
|
+
// What the last run found about the rule beside it; a rule it never asked is not a rule that held.
|
|
16
|
+
const measuredOf = (m) => (m === null ? "; not measured yet" : m ? `; asked of ${plural(m.asked, "reply", "replies")}, held in ${num(m.held)}${m.asked > m.held ? `, broke in ${num(m.asked - m.held)}` : ""}` : "");
|
|
17
|
+
const ruleLine = (e) => ` ${e.path ? `${at(e.path, e.line)} ` : ""}"${clip(e.text, 300)}"${"measured" in e ? measuredOf(e.measured) : ""}`;
|
|
16
18
|
const headOf = (label, parts) => (parts.some(Boolean) ? `${label}: ${parts.filter(Boolean).join(", ")}.` : `${label}:`);
|
|
17
|
-
const standardsHead = (m) => headOf("Engineering standards", [has(m.decided) && `${num(m.decided)} decided`, has(m.met) && `${num(m.met)} met`, has(m.missed) && `${num(m.missed)} missed`]);
|
|
18
|
-
const
|
|
19
|
+
const standardsHead = (m) => headOf("Engineering standards", [has(m.decided) && `${num(m.decided)} decided`, has(m.met) && `${num(m.met)} met`, has(m.missed) && `${num(m.missed)} missed${has(m.problems) ? `, from ${plural(m.problems, "problem")}` : ""}`]);
|
|
20
|
+
const decided = (x) => (x.decidedBy ? ` (decided by ${x.decidedBy === "code" ? "code" : "a model"})` : "");
|
|
21
|
+
// A miss is one problem with the standards it fails beneath it; an older API sent one standard a row.
|
|
22
|
+
const standardLine = (x) => Array.isArray(x.standards)
|
|
23
|
+
? ` ${x.path ? `${at(x.path, x.line)} ` : ""}${clip(x.problem, 240)}${decided(x)}\n ${x.standards.length === 1 ? "Fails" : `Fails ${num(x.standards.length)}`}: ${x.standards.join("; ")}`
|
|
24
|
+
: ` ${x.path ? `${at(x.path, x.line)} ` : ""}${x.standard}${x.detail ? `: ${clip(x.detail, 240)}` : ""}${decided(x)}`;
|
|
19
25
|
// Which trials can be played is decided when a run knocks: a read that said "21 playable, none held
|
|
20
26
|
// back" was followed by a run that held 28 back, and three agents called the counts contradictory.
|
|
21
27
|
const trialsHead = (t) => headOf("Trials", [has(t.written) && `${num(t.written)} written`]);
|
|
@@ -30,6 +36,14 @@ const named = (label, group, max = 12) => {
|
|
|
30
36
|
// "Journeys (6):" when every one is here, "Journeys (6), 4 listed:" when not.
|
|
31
37
|
const counted = (label, count, n) => `${label} (${num(count ?? n)})${has(count) && n < count ? `, ${num(n)} listed` : ""}:`;
|
|
32
38
|
|
|
39
|
+
// The records the app already holds, read from its own data files, that a trial may name: an agent
|
|
40
|
+
// that sees a trial ask about SO20260810001 can tell it is a real order, not an invented one.
|
|
41
|
+
const recordsLine = (records) => {
|
|
42
|
+
const collections = (records?.collections ?? []).filter((c) => c?.name && c.examples?.length);
|
|
43
|
+
if (!collections.length) return null;
|
|
44
|
+
return `Records the trials can name: ${collections.map((c) => `${c.name} (${c.examples.slice(0, 3).map((e) => clip(e, 40)).join(", ")})`).join(", ")}.`;
|
|
45
|
+
};
|
|
46
|
+
|
|
33
47
|
// The walkthrough, in the order the person is walked through it.
|
|
34
48
|
export function readLines(r) {
|
|
35
49
|
const lines = ["What Cortad read:"];
|
|
@@ -37,12 +51,14 @@ export function readLines(r) {
|
|
|
37
51
|
const examples = (r.rules.examples ?? []).slice(0, 3);
|
|
38
52
|
lines.push(`${rulesHead(r.rules)}${examples.length ? ` ${examples.length} of them:` : ""}`, ...examples.map(ruleLine));
|
|
39
53
|
}
|
|
40
|
-
for (const line of [named("Journeys", r.journeys), named("Simulated users", r.profiles), named("Endpoints", r.doors)]) if (line) lines.push(line);
|
|
54
|
+
for (const line of [named("Journeys", r.journeys), named("Simulated users", r.profiles), named("Endpoints", r.doors), recordsLine(r.records)]) if (line) lines.push(line);
|
|
41
55
|
const m = r.machine;
|
|
42
56
|
if (m) {
|
|
43
57
|
const misses = m.misses ?? [];
|
|
44
|
-
|
|
45
|
-
|
|
58
|
+
// "3 missed" was read off a list of three under a head that said thirteen: the list says it is a part.
|
|
59
|
+
lines.push(standardsHead(m), ...(misses.length > 3 && has(m.missed) && m.missed > 3 ? [` 3 of the ${num(m.missed)} missed:`] : []), ...misses.slice(0, 3).map(standardLine));
|
|
60
|
+
const more = (m.problems ?? misses.length) - Math.min(misses.length, 3);
|
|
61
|
+
if (more > 0) lines.push(` and ${plural(more, "more problem")}; status with show standards lists them all.`);
|
|
46
62
|
}
|
|
47
63
|
const q = r.questions;
|
|
48
64
|
if (q) lines.push(`Questions: ${num(q.total)}${has(q.everywhere) ? `; ${num(q.everywhere)} asked in every conversation` : ""}${has(q.placed) ? `, ${num(q.placed)} placed in the situations they fit` : ""}.`);
|
|
@@ -70,7 +86,7 @@ function sectionOf(r, show) {
|
|
|
70
86
|
const mets = m.mets ?? [];
|
|
71
87
|
return {
|
|
72
88
|
head: standardsHead(m),
|
|
73
|
-
lines: [misses.length && counted("Missed", m.missed, misses.length), ...misses.map(standardLine), mets.length && counted("Met", m.met, mets.length), ...mets.map(standardLine)].filter(Boolean),
|
|
89
|
+
lines: [misses.length && (has(m.problems) ? counted("Problems", m.problems, misses.length) : counted("Missed", m.missed, misses.length)), ...misses.map(standardLine), mets.length && counted("Met", m.met, mets.length), ...mets.map(standardLine)].filter(Boolean),
|
|
74
90
|
continued: "Engineering standards, continued.",
|
|
75
91
|
};
|
|
76
92
|
}
|
package/lib/replay.mjs
CHANGED
|
@@ -106,9 +106,15 @@ const RULE_ID = /^[\w:-]{1,64}$/;
|
|
|
106
106
|
const turnOf = (v) => (typeof v === "string" && TURN.test(v) ? { turn: v } : {});
|
|
107
107
|
const rulesOf = (v) => (Array.isArray(v) ? { rules: v.filter((id) => typeof id === "string" && RULE_ID.test(id)).slice(0, 300) } : {});
|
|
108
108
|
// What the app's tools answered behind a call, as the hook read them off the next prompt.
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
const texts = (v) => (Array.isArray(v) ? v.filter((t) => t && typeof t.text === "string" && t.text.trim()).slice(0, 12).map((t) => ({ name: String(t.name ?? "").slice(0, 80), text: t.text.slice(0, 3000) })) : []);
|
|
110
|
+
const toolsOf = (v) => (texts(v).length ? { tools: texts(v) } : {});
|
|
111
|
+
// The tools the model asked for behind a call, names and clipped arguments, and the passages its
|
|
112
|
+
// prompt was handed as retrieved context, or a retrieval call answered with.
|
|
113
|
+
const calledOf = (v) => {
|
|
114
|
+
const called = Array.isArray(v) ? v.filter((c) => c && typeof c.name === "string" && c.name && typeof c.arguments === "string").slice(0, 12).map((c) => ({ name: c.name.slice(0, 80), arguments: c.arguments.slice(0, 1200) })) : [];
|
|
115
|
+
return called.length ? { called } : {};
|
|
116
|
+
};
|
|
117
|
+
const passagesOf = (v) => (texts(v).length ? { passages: texts(v).slice(0, 6) } : {});
|
|
112
118
|
const meter = (() => {
|
|
113
119
|
const rows = [];
|
|
114
120
|
const deps = [];
|
|
@@ -119,7 +125,7 @@ const meter = (() => {
|
|
|
119
125
|
rows.push({
|
|
120
126
|
at: count(call.at), host: call.host.slice(0, 253), model: String(call.model ?? "").slice(0, 160), status: count(call.status),
|
|
121
127
|
promptTokens: count(call.promptTokens), cachedTokens: count(call.cachedTokens), completionTokens: count(call.completionTokens),
|
|
122
|
-
usage: call.usage === true, ...turnOf(call.turn), ...rulesOf(call.rules), ...toolsOf(call.tools),
|
|
128
|
+
usage: call.usage === true, ...turnOf(call.turn), ...rulesOf(call.rules), ...toolsOf(call.tools), ...calledOf(call.called), ...passagesOf(call.passages),
|
|
123
129
|
});
|
|
124
130
|
if (rows.length > ROWS) rows.splice(0, rows.length - ROWS);
|
|
125
131
|
},
|
|
@@ -127,7 +133,7 @@ const meter = (() => {
|
|
|
127
133
|
dep(d) {
|
|
128
134
|
if (!d || typeof d.host !== "string" || !d.host) return;
|
|
129
135
|
const env = typeof d.env === "string" && /^[A-Z_][A-Z0-9_]*$/.test(d.env) ? d.env : undefined;
|
|
130
|
-
deps.push({ at: count(d.at), ...(env ? { env } : {}), host: d.host.slice(0, 253), status: count(d.status), ...(typeof d.code === "string" ? { code: d.code.slice(0, 40) } : {}), ...turnOf(d.turn) });
|
|
136
|
+
deps.push({ at: count(d.at), ...(env ? { env } : {}), host: d.host.slice(0, 253), status: count(d.status), ...(typeof d.code === "string" ? { code: d.code.slice(0, 40) } : {}), ...turnOf(d.turn), ...passagesOf(d.passages) });
|
|
131
137
|
if (deps.length > ROWS) deps.splice(0, deps.length - ROWS);
|
|
132
138
|
},
|
|
133
139
|
report() {
|
|
@@ -145,7 +151,7 @@ const meter = (() => {
|
|
|
145
151
|
}
|
|
146
152
|
return {
|
|
147
153
|
totals: [...totals.values()].sort((a, b) => b.calls - a.calls),
|
|
148
|
-
rows: rows.slice(-400).reverse().map(({ at, host, model, status, turn, rules, tools }) => ({ at, host, model, status, ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}) })),
|
|
154
|
+
rows: rows.slice(-400).reverse().map(({ at, host, model, status, turn, rules, tools, called, passages }) => ({ at, host, model, status, ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}) })),
|
|
149
155
|
deps: deps.slice(-400).reverse(),
|
|
150
156
|
};
|
|
151
157
|
},
|
package/lib/text.mjs
CHANGED
|
@@ -22,10 +22,14 @@ export function statusText(d) {
|
|
|
22
22
|
const plan = planLine(d.plan);
|
|
23
23
|
if (plan) lines.push(plan);
|
|
24
24
|
if (d.app?.said) lines.push(`App: ${d.app.said}`);
|
|
25
|
+
for (const s of d.app?.data ?? []) lines.push(`Data: ${s}`);
|
|
25
26
|
if (d.read?.complete === false) lines.push("The read of your repository is still running; the lists below fill in as it finishes.");
|
|
26
27
|
if (d.read) lines.push(...readLines(d.read));
|
|
27
28
|
if (d.run === null) lines.push("No run yet.");
|
|
28
29
|
else if (d.run) lines.push(...runLines(d.run, "latest "));
|
|
30
|
+
// Trials the read wrote after this run chose its own: the one count that is not the run's.
|
|
31
|
+
const since = (d.read?.trials?.written ?? 0) - (d.run?.trials?.written ?? Infinity);
|
|
32
|
+
if (since > 0 && d.run?.kind !== "verify") lines.push(`${plural(since, "more trial")} written since this run; the next run chooses from them too.`);
|
|
29
33
|
if (d.field) lines.push(`Production: ${d.field.connected ? "connected" : "not connected"}.`);
|
|
30
34
|
if (d.links?.lab && (d.read || d.run)) lines.push(`For the person: ${d.links.lab} shows this in the browser.`);
|
|
31
35
|
if (d.run && !finished(d.run)) lines.push(`next: run_status ${d.run.jobId}`);
|
|
@@ -53,11 +57,14 @@ function scoreLine(d) {
|
|
|
53
57
|
// The number never stands without its denominator: "93 of 100" read as broad assurance to four
|
|
54
58
|
// agents whose runs had measured a quarter of the checks.
|
|
55
59
|
const on = has(d.questionsAsked) && has(d.questionsOf) && d.questionsOf > 0 ? ` on the ${num(d.questionsAsked)} of ${plural(d.questionsOf, "check")} measured` : "";
|
|
56
|
-
|
|
60
|
+
// The replies that failed stand beside the number: a 100 over the replies that answered, with 62 of
|
|
61
|
+
// 89 error replies unsaid, read as a clean bill.
|
|
62
|
+
const crashed = d.replies && d.replies.crashed > 0 ? `; ${num(d.replies.crashed)} of ${plural(d.replies.total, "reply", "replies")} were failures and are not in it` : "";
|
|
63
|
+
return `${floor ? "Provisional score" : "Score"} ${num(d.score)} of 100${on}${interval(d.ci)}${floor ? `, from ${plural(d.decided, "decided reading")}: under the 22-reading floor, so not a verdict yet` : ""}${crashed}.`;
|
|
57
64
|
}
|
|
58
65
|
// A row the run held back names a door and why, or, when no door could take it, only the reason.
|
|
59
66
|
const heldBackLine = (h) => h.why === "not testable here"
|
|
60
|
-
? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200)}.`
|
|
67
|
+
? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200).replace(/\.$/, "")}.`
|
|
61
68
|
: `Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
|
|
62
69
|
const haltLine = (h) => `${h.side === "ours" ? "We lost your app" : "Your app stopped answering"} after ${plural(h.after, "turn")}${h.why ? `: ${clip(h.why, 200)}` : ""}. The score covers the turns before that.`;
|
|
63
70
|
const LAYER_WORDS = {
|
|
@@ -84,16 +91,33 @@ function runLines(d, prefix = "") {
|
|
|
84
91
|
if (done && d.halted) lines.push(haltLine(d.halted));
|
|
85
92
|
if (done && has(d.readingsHeld) && has(d.readings) && d.readings > 0) lines.push(`The question readings held in ${num(d.readingsHeld)} of ${num(d.readings)}, ${Math.round((100 * d.readingsHeld) / d.readings)}%; the score is the case checks' pass rate on the checks measured.`);
|
|
86
93
|
if (done && has(d.findings)) lines.push(`${plural(d.findings, "finding")}.`);
|
|
87
|
-
|
|
94
|
+
const trials = trialsLine(d);
|
|
95
|
+
if (trials) lines.push(trials);
|
|
96
|
+
// Every row, or six and the rest in one line, so the rows always sum to the held-back count.
|
|
97
|
+
const heldRows = (d.heldBack ?? []).filter((h) => h.trials > 0);
|
|
98
|
+
for (const h of heldRows.slice(0, 6)) lines.push(heldBackLine(h));
|
|
99
|
+
if (heldRows.length > 6) lines.push(`Held back: ${plural(heldRows.slice(6).reduce((n, h) => n + h.trials, 0), "more trial")} for ${plural(heldRows.length - 6, "other reason")}.`);
|
|
88
100
|
const reads = readingsLine(d);
|
|
89
101
|
if (reads) lines.push(reads);
|
|
90
102
|
if (d.stopped) lines.push(stoppedLine(d));
|
|
91
103
|
for (const f of d.faults ?? []) lines.push(`Fault${SIDE[f.side] ? ` ${SIDE[f.side]}` : ""}: ${f.what}${f.fix ? ` ${f.fix}` : ""}`);
|
|
104
|
+
for (const s of d.data ?? []) lines.push(`Data: ${s}`);
|
|
92
105
|
if (d.error) lines.push(`Error: ${d.error}`);
|
|
93
106
|
if (d.verify) lines.push(...verifyLines(d.verify));
|
|
94
107
|
return lines;
|
|
95
108
|
}
|
|
96
109
|
|
|
110
|
+
// The run's trials in one sentence that adds up: written = chosen + held back + not planned, and
|
|
111
|
+
// chosen = played + not played. Three counts from three places never met before.
|
|
112
|
+
function trialsLine(d) {
|
|
113
|
+
const t = d.trials;
|
|
114
|
+
// A verify replays the trials it names, so it has no written count of its own to add up.
|
|
115
|
+
if (!t || !has(t.written) || d.kind === "verify") return null;
|
|
116
|
+
const parts = [`${num(t.chosen)} chosen for this run`, t.heldBack && `${num(t.heldBack)} held back`, t.notPlanned && `${num(t.notPlanned)} not planned`].filter(Boolean);
|
|
117
|
+
const rest = t.notPlayed ? `; ${num(t.played)} played, ${num(t.notPlayed)} ${finished(d) ? "not played" : "still to play"}` : "";
|
|
118
|
+
return `Trials: ${num(t.written)} written: ${parts.join(", ")}${rest}.`;
|
|
119
|
+
}
|
|
120
|
+
|
|
97
121
|
// A reading is one question checked against one reply.
|
|
98
122
|
function readingsLine(d) {
|
|
99
123
|
if (!has(d.readings) && !has(d.questionsAsked)) return null;
|
|
@@ -180,7 +204,7 @@ function ledgerLines(l) {
|
|
|
180
204
|
// Findings grouped by the line they live at: the server's byLine, or the findings' own file and
|
|
181
205
|
// line when it sends none. The same line from two server pages is one group.
|
|
182
206
|
export function linesOf(d) {
|
|
183
|
-
const rows = d.byLine ?? (d.findings ?? []).
|
|
207
|
+
const rows = (d.byLine ?? (d.findings ?? []).map((f) => ({ path: f.file, line: f.line, findingIds: [f.id] }))).filter((r) => r.path);
|
|
184
208
|
const merged = new Map();
|
|
185
209
|
for (const r of rows) {
|
|
186
210
|
const key = at(r.path, r.line);
|
|
@@ -246,6 +270,13 @@ function findingBlock(f, placed) {
|
|
|
246
270
|
if (f.door) lines.push(` Endpoint: ${f.door}`);
|
|
247
271
|
else if (f.doors?.length) lines.push(` Endpoints: ${f.doors.join(", ")}`);
|
|
248
272
|
if (f.where) lines.push(` Situation: ${f.where}`);
|
|
273
|
+
if (f.fix) lines.push(` Fix: ${f.fix}`);
|
|
274
|
+
// One cause, one finding: the same sentence broke these too, and they are not findings of their own.
|
|
275
|
+
if (f.alsoIn?.length) {
|
|
276
|
+
lines.push(` Same cause in ${plural(f.alsoIn.length, "more place")}, the same reply sentence in each:`);
|
|
277
|
+
for (const a of f.alsoIn.slice(0, 8)) lines.push(` ${a.where}: ${clip(a.asks, 160)}, broke in ${num(a.rate.n - a.rate.k)} of ${plural(a.rate.n, "reply", "replies")}`);
|
|
278
|
+
if (f.alsoIn.length > 8) lines.push(` and ${num(f.alsoIn.length - 8)} more.`);
|
|
279
|
+
}
|
|
249
280
|
if (f.layer && LAYER_WORDS[f.layer]) lines.push(` Layer: ${f.layer}, ${LAYER_WORDS[f.layer]}.`);
|
|
250
281
|
const r = f.rate;
|
|
251
282
|
// The break count, whichever way the question is phrased: "held in 21 of 33" under a question
|
|
@@ -254,12 +285,24 @@ function findingBlock(f, placed) {
|
|
|
254
285
|
if (f.unsettled) lines.push(" Unsettled: under the 22-reading floor.");
|
|
255
286
|
const by = [f.decidedBy?.code && `code in ${plural(f.decidedBy.code, "reading")}`, f.decidedBy?.model && `a model in ${plural(f.decidedBy.model, "reading")}`].filter(Boolean);
|
|
256
287
|
if (by.length) lines.push(` Decided by ${by.join(", by ")}.`);
|
|
288
|
+
const logsShown = new Set();
|
|
289
|
+
let logRoom = 30;
|
|
257
290
|
for (const q of (f.quotes ?? []).slice(0, 3)) {
|
|
258
291
|
const about = [typeof q.p === "number" && `confidence ${q.p.toFixed(2)}`, q.trialId && `trial ${q.trialId}`, q.door && `at ${q.door}`].filter(Boolean).join(", ");
|
|
259
292
|
// The request beside the reply: an agent handed "HTTP 502 happened" did the rest of the
|
|
260
293
|
// investigation itself.
|
|
261
294
|
if (q.asked) lines.push(` Asked: "${clip(q.asked, 300)}"`);
|
|
262
295
|
lines.push(` Reply ${q.reply}: "${clip(q.quote, 400)}"${about ? ` (${about})` : ""}`);
|
|
296
|
+
// What a developer reproduces it from: the request as sent, the body back, the app's own log.
|
|
297
|
+
if (q.request) lines.push(` Request: ${q.request.method} ${q.request.path}${q.request.body ? ` ${clip(q.request.body, 600)}` : ""}`);
|
|
298
|
+
if (has(q.response)) lines.push(` Response: ${q.response ? clip(q.response, 600) : "(empty body)"}`);
|
|
299
|
+
// A page stays under what a client keeps: the same log twice is said once, and a finding
|
|
300
|
+
// prints thirty log lines at most.
|
|
301
|
+
const log = (q.log ?? []).join("\n");
|
|
302
|
+
if (log && logsShown.has(log)) lines.push(" Log: the same lines as the reply above.");
|
|
303
|
+
else for (const line of (q.log ?? []).slice(0, logRoom)) { lines.push(` Log: ${clip(line, 240)}`); logRoom -= 1; }
|
|
304
|
+
if (log) logsShown.add(log);
|
|
305
|
+
if (q.at && q.at !== at(f.file, f.line)) lines.push(` At ${q.at}`);
|
|
263
306
|
}
|
|
264
307
|
for (const line of (f.log ?? []).slice(0, 8)) lines.push(` Log: ${clip(line, 300)}`);
|
|
265
308
|
if (f.replay) lines.push(` Replay: ${plural(f.replay.trials, "trial")}, verify ${f.id}`);
|
package/lib/trace.cjs
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// your AI's door, with the exact body it takes and the sign-in it carried, learned from a message you
|
|
4
4
|
// sent yourself rather than guessed from code. Second, every model call your app makes: the host, the
|
|
5
5
|
// model it asked for, the status and the token counts the provider sent back, so a run can say which
|
|
6
|
-
// model answered and what it cost you
|
|
7
|
-
//
|
|
6
|
+
// model answered and what it cost you, which tools it asked for (names and clipped arguments, never a
|
|
7
|
+
// secret) and the passages its prompt was handed as retrieved context. The words of a reply are never
|
|
8
|
+
// written down. Both go to a file only you can read, in the command's own folder on this machine.
|
|
9
|
+
// Nothing here talks to a network.
|
|
8
10
|
"use strict";
|
|
9
11
|
const FILE = process.env.CORTAD_TRACE_FILE;
|
|
10
12
|
if (FILE) {
|
|
@@ -243,26 +245,181 @@ if (FILE) {
|
|
|
243
245
|
// facts rest on, and without them a real ticket id read as invented. Bounded per call.
|
|
244
246
|
const TOOL_TEXT = 3000, TOOLS_MAX = 12;
|
|
245
247
|
const textOf = (v) => (typeof v === "string" ? v : Array.isArray(v) ? v.map((p) => (p && typeof p === "object" ? (p.text || p.content || "") : String(p || ""))).filter(Boolean).join("\n") : v && typeof v === "object" ? JSON.stringify(v) : "");
|
|
248
|
+
// Only what came after the person's latest message belongs to this turn: a thread the app
|
|
249
|
+
// resends whole carries every earlier turn's tool answers too.
|
|
250
|
+
const list = (v) => (Array.isArray(v) ? v.filter((x) => x && typeof x === "object") : []);
|
|
251
|
+
const since = (items, isPerson) => { let at = -1; items.forEach((m, i) => { if (isPerson(m)) at = i; }); return items.slice(at + 1); };
|
|
252
|
+
const personSaid = (m) => m.role === "user" && !(Array.isArray(m.content) && m.content.some((c) => c && c.type === "tool_result"));
|
|
253
|
+
const personAsked = (c) => c.role === "user" && !list(c.parts).some((p) => p.functionResponse);
|
|
246
254
|
const toolsIn = (sent) => {
|
|
247
255
|
let body; try { body = JSON.parse(sent); } catch { return undefined; }
|
|
248
256
|
if (!body || typeof body !== "object") return undefined;
|
|
249
257
|
const out = [];
|
|
250
258
|
const names = new Map();
|
|
251
259
|
const add = (name, text) => { const t = textOf(text).slice(0, TOOL_TEXT); if (t.trim() && out.length < TOOLS_MAX && !out.some((o) => o.text === t)) out.push({ name: String(name || "").slice(0, 80), text: t }); };
|
|
252
|
-
for (const m of
|
|
253
|
-
|
|
254
|
-
for (const c of
|
|
255
|
-
if (Array.isArray(m.content)) for (const c of m.content) if (c && c.type === "tool_use" && c.id) names.set(c.id, c.name);
|
|
260
|
+
for (const m of list(body.messages)) {
|
|
261
|
+
for (const c of list(m.tool_calls)) if (c.id && c.function) names.set(c.id, c.function.name);
|
|
262
|
+
for (const c of list(m.content)) if (c.type === "tool_use" && c.id) names.set(c.id, c.name);
|
|
256
263
|
}
|
|
257
|
-
for (const m of
|
|
258
|
-
if (!m || typeof m !== "object") continue;
|
|
264
|
+
for (const m of since(list(body.messages), personSaid)) {
|
|
259
265
|
if (m.role === "tool" || m.role === "function") add(m.name || names.get(m.tool_call_id), m.content);
|
|
260
|
-
|
|
266
|
+
for (const c of list(m.content)) if (c.type === "tool_result") add(names.get(c.tool_use_id), c.content);
|
|
261
267
|
}
|
|
262
|
-
const items =
|
|
263
|
-
for (const it of items) if (it
|
|
264
|
-
for (const it of items) if (it
|
|
265
|
-
for (const c of
|
|
268
|
+
const items = list(body.input);
|
|
269
|
+
for (const it of items) if (it.type === "function_call" && it.call_id) names.set(it.call_id, it.name);
|
|
270
|
+
for (const it of since(items, (x) => x.role === "user")) if (it.type === "function_call_output") add(names.get(it.call_id), it.output);
|
|
271
|
+
for (const c of since(list(body.contents), personAsked)) for (const p of list(c.parts)) if (p.functionResponse) add(p.functionResponse.name, p.functionResponse.response);
|
|
272
|
+
return out.length ? out : undefined;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// What the model asked the app to run: chat tool_calls (a stream's pieces joined by position), a
|
|
276
|
+
// legacy function_call, a responses function_call item, an Anthropic or Bedrock tool use, a
|
|
277
|
+
// Gemini functionCall. Read off the model's reply, and off this turn's earlier calls as the
|
|
278
|
+
// prompt resends them. Names and arguments only: every value clipped, a secret never written.
|
|
279
|
+
const CALLS_MAX = 12, VALUE_MAX = 200, ARGS_MAX = 1200;
|
|
280
|
+
const SECRET_KEY = /(?:^|_)(?:pass(?:word|phrase)?|secret|token|api_?key|authorization|cookie|session(?:_id)?|credentials?|private_key)$/;
|
|
281
|
+
const SECRET_VALUE = /^(?:Bearer\s|Basic\s|sk-|pk_|rk_|ghp_|gho_|github_pat_|xox[abpr]-|AKIA|AIza|eyJ[\w-]{10,}\.)/;
|
|
282
|
+
const clipped = (v, depth) => {
|
|
283
|
+
if (typeof v === "string") return SECRET_VALUE.test(v) ? "[secret]" : v.length > VALUE_MAX ? v.slice(0, VALUE_MAX) + "…" : v;
|
|
284
|
+
if (Array.isArray(v)) return depth > 4 ? [] : v.slice(0, 20).map((x) => clipped(x, depth + 1));
|
|
285
|
+
if (!v || typeof v !== "object") return v;
|
|
286
|
+
const o = {};
|
|
287
|
+
if (depth > 4) return o;
|
|
288
|
+
for (const [k, x] of Object.entries(v).slice(0, 40)) o[k] = SECRET_KEY.test(k.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()) ? "[secret]" : clipped(x, depth + 1);
|
|
289
|
+
return o;
|
|
290
|
+
};
|
|
291
|
+
const argsText = (raw) => {
|
|
292
|
+
let v = raw;
|
|
293
|
+
if (typeof raw === "string") { try { v = raw.trim() ? JSON.parse(raw) : {}; } catch { v = raw; } }
|
|
294
|
+
return JSON.stringify(clipped(v === undefined || v === null ? {} : v, 0)).slice(0, ARGS_MAX);
|
|
295
|
+
};
|
|
296
|
+
const callsOf = (events) => {
|
|
297
|
+
const whole = new Map();
|
|
298
|
+
const parts = new Map();
|
|
299
|
+
let n = 0;
|
|
300
|
+
const put = (key, name, args) => { if (name) whole.set(key || `w${n++}`, { name, args }); };
|
|
301
|
+
const piece = (key, name, args) => {
|
|
302
|
+
const p = parts.get(key) || { name: "", args: "" };
|
|
303
|
+
if (name && !p.name) p.name = name;
|
|
304
|
+
if (typeof args === "string") p.args += args;
|
|
305
|
+
parts.set(key, p);
|
|
306
|
+
};
|
|
307
|
+
const blocks = (content) => {
|
|
308
|
+
for (const b of list(content)) {
|
|
309
|
+
if (b.type === "tool_use") put(b.id, b.name, b.input);
|
|
310
|
+
if (b.toolUse) put(b.toolUse.toolUseId, b.toolUse.name, b.toolUse.input);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
for (const e of events) {
|
|
314
|
+
if (!e || typeof e !== "object") continue;
|
|
315
|
+
for (const c of list(e.choices)) {
|
|
316
|
+
const m = c.message;
|
|
317
|
+
if (m) {
|
|
318
|
+
for (const t of list(m.tool_calls)) if (t.function) put(t.id, t.function.name, t.function.arguments);
|
|
319
|
+
if (m.function_call) put(null, m.function_call.name, m.function_call.arguments);
|
|
320
|
+
}
|
|
321
|
+
const d = c.delta;
|
|
322
|
+
if (d) {
|
|
323
|
+
for (const t of list(d.tool_calls)) piece(`c${c.index || 0}.${t.index ?? t.id}`, t.function && t.function.name, t.function && t.function.arguments);
|
|
324
|
+
if (d.function_call) piece(`f${c.index || 0}`, d.function_call.name, d.function_call.arguments);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
for (const it of [...list(e.output), ...list(e.item ? [e.item] : []), ...list(e.response && e.response.output)]) {
|
|
328
|
+
if (it.type === "function_call") put(it.call_id || it.id, it.name, it.arguments);
|
|
329
|
+
}
|
|
330
|
+
blocks(e.content);
|
|
331
|
+
blocks(e.message && e.message.content);
|
|
332
|
+
blocks(e.output && e.output.message && e.output.message.content);
|
|
333
|
+
if (e.type === "content_block_start" && e.content_block && e.content_block.type === "tool_use") piece(`a${e.index}`, e.content_block.name, "");
|
|
334
|
+
if (e.type === "content_block_delta" && e.delta && e.delta.type === "input_json_delta") piece(`a${e.index}`, "", e.delta.partial_json);
|
|
335
|
+
for (const c of list(e.candidates)) for (const p of list(c.content && c.content.parts)) if (p.functionCall) put(null, p.functionCall.name, p.functionCall.args);
|
|
336
|
+
}
|
|
337
|
+
return [...whole.values(), ...parts.values()].filter((p) => p.name).map((p) => ({ name: String(p.name).slice(0, 80), arguments: argsText(p.args) }));
|
|
338
|
+
};
|
|
339
|
+
// This turn's earlier calls, as the prompt resends them after the person's latest message.
|
|
340
|
+
const calledBefore = (body) => callsOf([
|
|
341
|
+
...since(list(body.messages), personSaid).filter((m) => m.role === "assistant").map((m) => ({ choices: [{ message: m }], content: m.content })),
|
|
342
|
+
{ output: since(list(body.input), (x) => x.role === "user") },
|
|
343
|
+
...since(list(body.contents), personAsked).filter((c) => c.role === "model").map((c) => ({ candidates: [{ content: c }] })),
|
|
344
|
+
]);
|
|
345
|
+
const calledIn = (sent, events) => {
|
|
346
|
+
const body = parse(sent);
|
|
347
|
+
const out = [];
|
|
348
|
+
for (const c of [...(body && typeof body === "object" ? calledBefore(body) : []), ...callsOf(events)]) {
|
|
349
|
+
if (out.length < CALLS_MAX && !out.some((o) => o.name === c.name && o.arguments === c.arguments)) out.push(c);
|
|
350
|
+
}
|
|
351
|
+
return out.length ? out : undefined;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// The passages the prompt carried as retrieved context: a block the app itself labels as
|
|
355
|
+
// context, documents, knowledge, sources or search results, in its system prompt or from the
|
|
356
|
+
// person's latest message on, and Anthropic document and search_result blocks. The app's own
|
|
357
|
+
// instructions are not passages: only what sits under such a label is.
|
|
358
|
+
const PASSAGE_TEXT = 3000, PASSAGES_MAX = 6;
|
|
359
|
+
const MATERIAL = /\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq)\b|检索|知识|参考资料|资料|上下文|文档|背景/i;
|
|
360
|
+
const HEADING = /^\s*(?:#{1,6}\s+[^\n]{1,80}|[^\n]{1,80}[::]\s*(?:\([^\n)]*\))?|\[[^\n\]]{1,80}\]|={2,}\s*[^\n]{1,80}?\s*={2,})\s*$/;
|
|
361
|
+
// An inline label is a noun phrase that ends in the material word ("context:", "background:",
|
|
362
|
+
// "retrieved passages:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
|
|
363
|
+
const INLINE = /^\s*(?:[-*]\s+)?([A-Za-z\u4e00-\u9fff][A-Za-z\u4e00-\u9fff '’_-]{0,29})[::]\s*\S/;
|
|
364
|
+
const NAMED = new RegExp(`(?:${MATERIAL.source})\\s*$`, "i");
|
|
365
|
+
const TAGGED = /<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)<\/\1>/g;
|
|
366
|
+
const labelled = (text, add) => {
|
|
367
|
+
const rest = String(text || "").replace(TAGGED, (all, tag, inner) => { if (MATERIAL.test(tag.replace(/_/g, " "))) { add(tag, inner); return ""; } return all; });
|
|
368
|
+
const paras = rest.split(/\n\s*\n/);
|
|
369
|
+
for (let i = 0; i < paras.length; i++) {
|
|
370
|
+
const lines = paras[i].split("\n");
|
|
371
|
+
const head = lines[0];
|
|
372
|
+
if (HEADING.test(head) && MATERIAL.test(head)) {
|
|
373
|
+
let body = lines.slice(1).join("\n");
|
|
374
|
+
// A heading alone on its paragraph labels what follows, up to the next heading.
|
|
375
|
+
if (!body.trim()) while (i + 1 < paras.length && body.length < PASSAGE_TEXT && !HEADING.test(paras[i + 1].split("\n")[0])) body += (body ? "\n\n" : "") + paras[++i];
|
|
376
|
+
add(head.replace(/[#::[\]=]/g, "").trim(), body);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const inline = lines.map((l) => INLINE.exec(l)).find((m) => m && NAMED.test(m[1]));
|
|
380
|
+
if (inline) add(inline[1].trim(), paras[i]);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
const passagesIn = (sent) => {
|
|
384
|
+
const body = parse(sent);
|
|
385
|
+
if (!body || typeof body !== "object") return undefined;
|
|
386
|
+
const out = [];
|
|
387
|
+
const add = (name, text) => { const t = String(text || "").trim().slice(0, PASSAGE_TEXT); if (t.length >= 20 && out.length < PASSAGES_MAX && !out.some((o) => o.text === t)) out.push({ name: String(name || "").slice(0, 80), text: t }); };
|
|
388
|
+
const scan = (content) => {
|
|
389
|
+
if (typeof content === "string") return labelled(content, add);
|
|
390
|
+
for (const b of list(content)) {
|
|
391
|
+
if (b.type === "document") add(b.title || "document", b.source ? textOf(b.source.data ?? b.source.content) : textOf(b.content));
|
|
392
|
+
else if (b.type === "search_result") add(b.title || b.source || "search result", textOf(b.content));
|
|
393
|
+
else if (typeof b.text === "string") labelled(b.text, add);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
const messages = list(body.messages);
|
|
397
|
+
for (const m of messages) if (m.role === "system" || m.role === "developer") scan(m.content);
|
|
398
|
+
for (const m of messages.slice(Math.max(0, messages.findLastIndex(personSaid)))) if (m.role === "user") scan(m.content);
|
|
399
|
+
scan(body.system);
|
|
400
|
+
scan(body.instructions);
|
|
401
|
+
scan(body.systemInstruction && body.systemInstruction.parts);
|
|
402
|
+
const input = typeof body.input === "string" ? [{ role: "user", content: body.input }] : list(body.input);
|
|
403
|
+
for (const it of input.slice(Math.max(0, input.findLastIndex((x) => x.role === "user")))) if (it.role === "user" || it.role === "system" || it.role === "developer") scan(it.content);
|
|
404
|
+
return out.length ? out : undefined;
|
|
405
|
+
};
|
|
406
|
+
// The passages a retrieval call answered with: a vector store, a search index or a web search,
|
|
407
|
+
// read off its reply when it is JSON. Text fields only, bounded like the rest.
|
|
408
|
+
const RETRIEVAL_HOST = /pinecone\.io|qdrant|weaviate|chroma|zilliz|milvus|turbopuffer|upstash\.io|algolia|typesense|meilisearch|elastic|opensearch|vespa|tavily\.com|exa\.ai|serper\.dev|serpapi\.com|search\.brave\.com|bing\.microsoft\.com|jina\.ai/i;
|
|
409
|
+
const RETRIEVAL_PATH = /\/(?:query|search|_search|retrieve|similarity_search|rerank|hybrid)(?:\/|$)|\/points\/(?:search|query)|\/rpc\/match\w*/i;
|
|
410
|
+
const isRetrieval = (u) => RETRIEVAL_HOST.test(u.hostname) || RETRIEVAL_PATH.test(u.pathname);
|
|
411
|
+
const PASSAGE_KEY = /^(?:text|content|page_?content|pageContent|chunk|snippet|passage|body|document|documents|description|answer|raw_content|highlights?|excerpt)$/i;
|
|
412
|
+
const passagesFrom = (raw) => {
|
|
413
|
+
const out = [];
|
|
414
|
+
let nodes = 0;
|
|
415
|
+
const walk = (v, key, depth) => {
|
|
416
|
+
if (out.length >= PASSAGES_MAX || depth > 7 || ++nodes > 4000) return;
|
|
417
|
+
const t = typeof v === "string" ? v.trim().slice(0, PASSAGE_TEXT) : "";
|
|
418
|
+
if (t) { if (PASSAGE_KEY.test(key) && t.length >= 20 && !out.some((o) => o.text === t)) out.push({ name: key, text: t }); return; }
|
|
419
|
+
if (Array.isArray(v)) { for (const x of v) walk(x, key, depth + 1); return; }
|
|
420
|
+
if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) walk(x, k, depth + 1);
|
|
421
|
+
};
|
|
422
|
+
walk(parse(raw), "", 0);
|
|
266
423
|
return out.length ? out : undefined;
|
|
267
424
|
};
|
|
268
425
|
// A framework that folds its tools' answers into the next prompt as plain text (an agent loop
|
|
@@ -445,7 +602,7 @@ if (FILE) {
|
|
|
445
602
|
if (part && typeof part === "object") Object.assign(usage, part);
|
|
446
603
|
model = e.model || (e.message && e.message.model) || (e.response && e.response.model) || e.modelVersion || model;
|
|
447
604
|
}
|
|
448
|
-
return { tokens: tokensOf(Object.keys(usage).length ? usage : null), model };
|
|
605
|
+
return { tokens: tokensOf(Object.keys(usage).length ? usage : null), model, events };
|
|
449
606
|
};
|
|
450
607
|
// The model asked for, from what was sent; Gemini and Bedrock put it in the path instead.
|
|
451
608
|
const askedFor = (path, sent) => {
|
|
@@ -459,7 +616,9 @@ if (FILE) {
|
|
|
459
616
|
const reply = readReply(type, String(body || "").slice(0, REPLY_MAX));
|
|
460
617
|
const rules = rulesIn(sent);
|
|
461
618
|
const tools = toolsIn(sent) ?? materialIn(sent, turn);
|
|
462
|
-
const
|
|
619
|
+
const called = calledIn(sent, reply.events);
|
|
620
|
+
const passages = passagesIn(sent);
|
|
621
|
+
const row = { at: Date.now(), host: String(host).replace(/:443$/, ""), model: String(askedFor(path, sent) || reply.model || "").slice(0, 160), status, ...(reply.tokens || { promptTokens: 0, cachedTokens: 0, completionTokens: 0 }), usage: Boolean(reply.tokens), ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}) };
|
|
463
622
|
fs.appendFileSync(FILE, JSON.stringify({ call: row }) + "\n", { mode: 0o600 });
|
|
464
623
|
} catch { /* the command is gone */ }
|
|
465
624
|
};
|
|
@@ -485,9 +644,8 @@ if (FILE) {
|
|
|
485
644
|
return name ? { env: name, host: u.hostname } : null;
|
|
486
645
|
} catch { return null; }
|
|
487
646
|
};
|
|
488
|
-
const depRow = (dep, status, code) => {
|
|
489
|
-
|
|
490
|
-
try { fs.appendFileSync(FILE, JSON.stringify({ dep: { at: Date.now(), ...(dep.env ? { env: dep.env } : {}), host: dep.host, status, code: code ? String(code).slice(0, 40) : undefined, ...(turn ? { turn } : {}) } }) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ }
|
|
647
|
+
const depRow = (dep, status, code, passages, turn = (als.getStore() || {}).turn) => {
|
|
648
|
+
try { fs.appendFileSync(FILE, JSON.stringify({ dep: { at: Date.now(), ...(dep.env ? { env: dep.env } : {}), host: dep.host, status, code: code ? String(code).slice(0, 40) : undefined, ...(turn ? { turn } : {}), ...(passages ? { passages } : {}) } }) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ }
|
|
491
649
|
};
|
|
492
650
|
|
|
493
651
|
// Outbound: the model call your app makes while it handles that request.
|
|
@@ -504,10 +662,21 @@ if (FILE) {
|
|
|
504
662
|
// Every outbound host, named by the setting that points at it when one does, and by the
|
|
505
663
|
// host alone otherwise: the search provider behind TAVILY_API_KEY has no URL setting.
|
|
506
664
|
let dep = depOf(input);
|
|
507
|
-
|
|
665
|
+
let retrieval = false;
|
|
666
|
+
try {
|
|
667
|
+
const u = new URL(typeof input === "string" ? input : input && input.url ? input.url : String(input));
|
|
668
|
+
// A store on this machine (a local Qdrant or Chroma) is still where the passages came from.
|
|
669
|
+
retrieval = /^https?:$/.test(u.protocol) && isRetrieval(u);
|
|
670
|
+
if (!dep && /^https?:$/.test(u.protocol) && (retrieval || !/^(localhost|127\.0\.0\.1|\[::1\])$/.test(u.hostname))) dep = { host: u.hostname };
|
|
671
|
+
} catch { /* not a URL */ }
|
|
508
672
|
if (!dep) return realFetch.apply(this, arguments);
|
|
673
|
+
const turn = (als.getStore() || {}).turn;
|
|
509
674
|
return realFetch.apply(this, arguments).then(
|
|
510
|
-
(res) => {
|
|
675
|
+
(res) => {
|
|
676
|
+
if (!retrieval) { depRow(dep, res.status, undefined, undefined, turn); return res; }
|
|
677
|
+
try { res.clone().text().then((t) => depRow(dep, res.status, undefined, passagesFrom(t), turn), () => depRow(dep, res.status, undefined, undefined, turn)); } catch { depRow(dep, res.status, undefined, undefined, turn); }
|
|
678
|
+
return res;
|
|
679
|
+
},
|
|
511
680
|
(err) => { depRow(dep, 0, err && err.cause && err.cause.code); throw err; },
|
|
512
681
|
);
|
|
513
682
|
}
|
package/lib/words.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve, sep } from "node:path";
|
|
3
|
+
|
|
1
4
|
// The small pieces every rendering shares: numbers with their units, file and line, whose side a
|
|
2
5
|
// stop is on, and the pages a long answer is cut into.
|
|
3
6
|
|
|
@@ -9,7 +12,21 @@ export const has = (v) => v !== undefined && v !== null;
|
|
|
9
12
|
export const num = (x) => (typeof x === "number" ? Math.round(x).toLocaleString("en-US") : String(x));
|
|
10
13
|
export const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
|
|
11
14
|
export const upper = (s) => `${s.charAt(0).toUpperCase()}${s.slice(1)}`;
|
|
12
|
-
|
|
15
|
+
// A line past the end of the file as it stands on this machine is not an address: the file moved
|
|
16
|
+
// on since it was read, and an agent was sent to line 204 of a 172-line file. The file stays.
|
|
17
|
+
// Read each time, never kept: the agent edits these files while this server runs. Only a path
|
|
18
|
+
// inside the project is opened, and only its length is used.
|
|
19
|
+
const linesIn = (path) => {
|
|
20
|
+
const root = process.cwd();
|
|
21
|
+
const file = resolve(root, path);
|
|
22
|
+
if (file !== root && !file.startsWith(`${root}${sep}`)) return null;
|
|
23
|
+
try { return readFileSync(file, "utf8").split("\n").length; } catch { return null; }
|
|
24
|
+
};
|
|
25
|
+
export const at = (path, line) => {
|
|
26
|
+
const n = has(line) && line > 0 && path ? linesIn(path) : null;
|
|
27
|
+
// A line of 0 is no line: "finding at :0" read to an agent as an address that pointed nowhere.
|
|
28
|
+
return `${path}${has(line) && line > 0 && !(n !== null && line > n) ? `:${line}` : ""}`;
|
|
29
|
+
};
|
|
13
30
|
export const clip = (s, max) => (String(s).length > max ? `${String(s).slice(0, max - 3)}...` : String(s));
|
|
14
31
|
|
|
15
32
|
// One page of many, with the call that reads the next one: `next(2)` gives "findings with page 2".
|
package/local.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { mintAcross, originFor, waitForPort } from "./lib/service.mjs";
|
|
|
32
32
|
import { listingUrl } from "./lib/listing.mjs";
|
|
33
33
|
import { installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
|
|
34
34
|
import { openSwitches } from "./lib/switches.mjs";
|
|
35
|
+
import { keepData } from "./lib/data.mjs";
|
|
35
36
|
|
|
36
37
|
const argv = process.argv.slice(2);
|
|
37
38
|
// The two faces a coding agent uses after the first connect (lib/cli.mjs): the MCP server the
|
|
@@ -427,6 +428,18 @@ async function verb(job) {
|
|
|
427
428
|
mint: (recipes, port, origin) => identities.mint({ ...b, recipes, headers: { ...(b.headers ?? {}), ...(origin ? { origin, referer: `${origin}/` } : {}) } }, port),
|
|
428
429
|
originFor: (port) => originFor(port, envOrigins(envFiles)),
|
|
429
430
|
})))) : { identities: [] };
|
|
431
|
+
// The last of what your app printed, for a reply it failed: the traceback a developer reads.
|
|
432
|
+
// Masked like everything else, and the folder named so its paths can be read as your files.
|
|
433
|
+
case "log": {
|
|
434
|
+
const want = Math.min(Math.max(Number(b.bytes) || 16_000, 1), 200_000);
|
|
435
|
+
try {
|
|
436
|
+
const all = readFileSync(bootLog);
|
|
437
|
+
const text = mask(all.subarray(Math.max(0, all.length - want)).toString("utf8").replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""));
|
|
438
|
+
// A JVM stack names a class, not a path: the source files it could be are sent with it.
|
|
439
|
+
const jvm = /\bat\s+[\w$.]+\([\w$]+\.(?:java|kt|scala|groovy):\d+\)/.test(text);
|
|
440
|
+
return { text, root, size: all.length, ...(jvm ? { files: files.filter((f) => /\.(?:java|kt|scala|groovy)$/.test(f)).slice(0, 20_000) } : {}) };
|
|
441
|
+
} catch { return { text: "", root, size: 0 }; }
|
|
442
|
+
}
|
|
430
443
|
case "restart": return restartApp();
|
|
431
444
|
case "inventory": return inventoryOf(b.probe && typeof b.probe === "object" ? b.probe : {});
|
|
432
445
|
// Your own pages, read here rather than in a world's shell: that shell is sealed away from
|
|
@@ -575,7 +588,7 @@ async function startApp() {
|
|
|
575
588
|
const ports = [...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1]));
|
|
576
589
|
for (const port of new Set(ports)) {
|
|
577
590
|
if (await answers(port)) {
|
|
578
|
-
launched = null; child = null;
|
|
591
|
+
launched = null; child = null; data = null;
|
|
579
592
|
const took = await takeOver(port);
|
|
580
593
|
if (took) return took;
|
|
581
594
|
say(`your app is already running on port ${port}, so that one is used; its request limits stay as they are`);
|
|
@@ -728,9 +741,27 @@ async function freed(cmd, lifted) {
|
|
|
728
741
|
return m ? { cmd: cmd.replace(m[0], `${m[1]}${port}`), lifted } : { cmd, lifted: { ...lifted, PORT: String(port) } };
|
|
729
742
|
}
|
|
730
743
|
|
|
744
|
+
// Your app's database, read once per session: a copy made before the first start, and every
|
|
745
|
+
// restart after it runs against the same copy, so a crash mid-run does not lose what the run made.
|
|
746
|
+
// Told in the terminal here and to the run with the app (announce), so status and the report say it.
|
|
747
|
+
let data = null;
|
|
748
|
+
function dataOnce(started) {
|
|
749
|
+
if (data) return data;
|
|
750
|
+
const values = { ...envExports(envFiles), ...process.env };
|
|
751
|
+
const from = {};
|
|
752
|
+
for (const file of envFiles.filter((f) => !/\.(example|sample)$/.test(f))) {
|
|
753
|
+
let text = "";
|
|
754
|
+
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
755
|
+
for (const line of text.split("\n")) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line); if (m && !(m[1] in process.env)) from[m[1]] = file; }
|
|
756
|
+
}
|
|
757
|
+
data = keepData({ values, from, appDir, root, work, sources: files.map((f) => join(root, f)), started });
|
|
758
|
+
for (const line of data.said) say(line.charAt(0).toLowerCase() + line.slice(1));
|
|
759
|
+
return data;
|
|
760
|
+
}
|
|
761
|
+
|
|
731
762
|
async function start(waitMs) {
|
|
732
763
|
const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
|
|
733
|
-
child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...envExports(envFiles), ...process.env, ...lifted, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
764
|
+
child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...envExports(envFiles), ...process.env, ...lifted, ...dataOnce(true).env, ...(capture ? capture.env(process.env) : {}), FORCE_COLOR: "0", ...(pinned.bin ? { PATH: `${pinned.bin}:${process.env.PATH ?? ""}` } : {}) }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
734
765
|
const mine = child;
|
|
735
766
|
let seen = "";
|
|
736
767
|
const onData = (d) => { const s = d.toString(); appendFileSync(bootLog, s); seen = (seen + s).slice(-20_000); lastSaid = seen; lastOutputAt = Date.now(); if (verbose) process.stdout.write(s); };
|
|
@@ -847,6 +878,7 @@ if (explain) {
|
|
|
847
878
|
`would raise ${flag("--port") ? "nothing: your app's own request limits stay as they are" : `${Object.keys(liftedLimits(envFiles, files.map((f) => join(root, f)))).join(", ") || "no request limits found"} (for this session only)`}`,
|
|
848
879
|
`loads into app lib/trace.cjs (Node, Bun) or lib/pyhook/sitecustomize.py (Python): records the one request during which your app calls a model`,
|
|
849
880
|
`your files never written by this program; your own coding agent edits them`,
|
|
881
|
+
`your database a database file your env or code names is copied to this program's temp folder and your app is started on the copy; a database server is named, not copied`,
|
|
850
882
|
`test shell confined by the OS: your project and toolchains only, writes to temp and build folders, localhost only`,
|
|
851
883
|
`for your agent an MCP entry and a skill in each coding agent's own home folder (Claude Code, Codex, Cursor), and a key in ~/.cortad for later runs`,
|
|
852
884
|
``,
|
|
@@ -963,7 +995,7 @@ function sourceChanged() {
|
|
|
963
995
|
}
|
|
964
996
|
// `watching` says whether a message sent to this app can be seen arriving: only an app this command
|
|
965
997
|
// started carries the hook, and only a runtime the hook exists for.
|
|
966
|
-
const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], watching: Boolean(launched && capture?.watching(app.port)), metered: Boolean(launched && capture?.watching(app.port)) });
|
|
998
|
+
const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], data: dataOnce(Boolean(launched)).said.slice(0, 8), watching: Boolean(launched && capture?.watching(app.port)), metered: Boolean(launched && capture?.watching(app.port)) });
|
|
967
999
|
|
|
968
1000
|
// Your app's life beside this connection. It is started; if it stops, or never comes up, this stays
|
|
969
1001
|
// and starts it again the moment you save a fix, and the browser is told each time it answers, so a
|
|
@@ -1010,7 +1042,10 @@ async function appLife() {
|
|
|
1010
1042
|
const busy = launched && !appGone && !crashed && Date.now() - lastOutputAt < 15_000;
|
|
1011
1043
|
if (busy || downFor < (crashed ? 4_000 : 10_000)) continue;
|
|
1012
1044
|
// Down for real. The screen is told, so it never shows an app that is not there.
|
|
1013
|
-
|
|
1045
|
+
// `exited`: the process this command started is gone or its watcher said it crashed, not a process
|
|
1046
|
+
// running and not answering. The run tells the two apart: one is their crash, the other may be our
|
|
1047
|
+
// load. An app it only attached to is not its to watch, so nothing is said either way.
|
|
1048
|
+
if (!toldDown) toldDown = Boolean((await call("POST", `/local/${box}/stopped`, { said: mask(lastSaid || "your app stopped answering").slice(-2000), ...(launched ? { exited: Boolean(appGone || crashed) } : {}) }).catch(() => null))?.ok);
|
|
1014
1049
|
// An app that went quiet or exited did not stop because of a save (it was killed, it ran out
|
|
1015
1050
|
// of memory, it crashed on a request), so waiting for a save would wait forever. It is started
|
|
1016
1051
|
// here, whoever started it first: an app this command only attached to (yours, from another
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1-rc.2",
|
|
4
4
|
"description": "Connects the AI app on your machine to Cortad for test conversations, and gives your coding agent the MCP and skill to run them. No dependencies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"cortad": "local.mjs"
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"README.md",
|
|
11
11
|
"lib/cli.mjs",
|
|
12
|
+
"lib/data.mjs",
|
|
12
13
|
"lib/home.mjs",
|
|
13
14
|
"lib/identities.mjs",
|
|
14
15
|
"lib/keys.mjs",
|
|
@@ -129,6 +129,8 @@ For the person: the report is at https://cortad.com/lab
|
|
|
129
129
|
next: findings
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
+
Your app stopped during the run and was started again: that is a finding of its own, `crash:1`, with the error your app printed and the turn it happened at. Fix the error it quotes. It has no trials to replay, so `verify` refuses it; the next run that finishes without it is the proof. A `Data:` line says whether the run wrote to a copy of your app's database or into the real one.
|
|
133
|
+
|
|
132
134
|
A verify that holds: both intervals are above zero and the held-out trials moved with the visible ones.
|
|
133
135
|
|
|
134
136
|
```
|