cortad 0.1.6 → 0.1.8

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/sample.mjs ADDED
@@ -0,0 +1,218 @@
1
+ // Reading your own material from your own stores, here on this machine.
2
+ //
3
+ // A world's shell cannot do this and must not: it runs inside the OS sandbox, which denies every
4
+ // env file and every host but this one. A reader written into that shell read nothing on a real
5
+ // laptop: no address, no key, no network. The address and the key are in your environment file,
6
+ // which this program already opened to mask what your commands print, so the read happens here and
7
+ // only rows of short fields go back.
8
+ //
9
+ // Nothing runnable arrives from the outside: the plan names store kinds and variable NAMES, and
10
+ // this file decides what is asked and what is kept. Values are never sent; the rows are.
11
+
12
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
+ import { join, relative } from "node:path";
14
+
15
+ const ROAD = /^[\w.:/-]+$/;
16
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
17
+ const KINDS = new Set(["qdrant", "postgres", "files"]);
18
+ const STORES_MAX = 12;
19
+ const PER_STORE = 6;
20
+ const PER_STORE_MAX = 60;
21
+ // A few rows from MANY collections, never many rows from few: a store whose collection names are
22
+ // the product's own grid (jordan_grade-10_sem-2_math) is only read when every cell is read. Forty
23
+ // eight of them, twenty two rows apiece, filled the whole answer with seven of ulaim's cells and
24
+ // left grade 10 semester 2 maths unread, so the depth follows the breadth: how many rows a
25
+ // collection gets is the answer's room divided by how many collections there are.
26
+ const COLLECTIONS = 200;
27
+ // One row of theirs as this store gave it: a page and its short fields, measured on a real store.
28
+ const ROW_BYTES = 800;
29
+ const PER_MIN = 2;
30
+ const ROWS = 200;
31
+ const FIELDS = 12;
32
+ const STRING_MAX = 120;
33
+ // The one long prose field a row carries is the page itself, kept to what the control plane keeps.
34
+ const PAGE_MAX = 700;
35
+ const BUDGET = 540_000;
36
+ const READ_MS = 50_000;
37
+ const ASK_MS = 10_000;
38
+
39
+ const PII = /(^|_)(e?mail|phone|mobile|tel|fax|password|passwd|pwd|secret|token|otp|ssn|iban|card|address|street|zip|postal|birth|dob|salt|hash|ip)(_|$)|(mail|phone|password|token|secret|hash)$/i;
40
+ const CORPUS = /grade|sem|subject|book|lesson|chunk|doc|page|content|unit|chapter|curricul|textbook|knowledge|embed|vector|material|note|article/i;
41
+ const RECORDS = /histor|attempt|session|log|event|message|user|profile|audit|analytic|trace|queue|cache|token|auth|billing|payment|invoice/i;
42
+ // The narrow security floor: names that are credentials, never a corpus in any product.
43
+ const SENSITIVE = /(?:^|[_\-/.])(?:password|passwd|pwd|credential|secret|apikey|api[_-]?key|access[_-]?token|refresh[_-]?token|jwt|session|oauth)s?(?:$|[_\-/.])|^(?:passwords|credentials|api_keys|secrets)$/i;
44
+ const DOC = /\.(?:md|mdx|txt|rst|html?|pdf|docx?|csv|json)$/i;
45
+ const SKIP_DIR = new Set(["node_modules", ".git", "dist", "build", ".venv", "venv", "__pycache__", ".next"]);
46
+
47
+ const store = (plan, status, rows, note = "") => ({ road: plan.road, kind: plan.kind, status, rows, ...(note ? { note } : {}) });
48
+ const reason = (err) => String(err?.cause?.code ?? err?.code ?? err?.message ?? err).slice(0, 120);
49
+ const valueFor = (names, env, pattern) => names.map((n) => (pattern.test(n) ? env[n] : "")).find(Boolean) ?? "";
50
+
51
+ // The short fields of one row: numbers, short strings, and at most one page of prose. The page is
52
+ // looked for in every column, never in the first twelve alone: a row of theirs carries a dozen ids,
53
+ // dates and flags before its text, so a cap that stopped early kept the bookkeeping and dropped the
54
+ // page, and the cell had nothing of theirs to ask about.
55
+ function short(fields) {
56
+ const out = {};
57
+ let page = null;
58
+ for (const [key, value] of Object.entries(fields ?? {})) {
59
+ if (typeof key !== "string" || PII.test(key) || typeof value === "boolean") continue;
60
+ if (typeof value === "string" && value.trim().length > STRING_MAX) {
61
+ const text = value.trim();
62
+ if (!page || text.length > page[1].length) page = [key, text];
63
+ continue;
64
+ }
65
+ if (Object.keys(out).length >= FIELDS) continue;
66
+ if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
67
+ else if (typeof value === "string" && value.trim()) out[key] = value.trim();
68
+ }
69
+ if (page) out[page[0]] = page[1].slice(0, PAGE_MAX);
70
+ return out;
71
+ }
72
+
73
+ const rank = (name) => `${RECORDS.test(name) ? 1 : 0}${CORPUS.test(name) ? 0 : 1}${name}`;
74
+
75
+ // Round robin over the cell a name addresses, which is every segment but the last:
76
+ // jordan_grade-10_sem-2 holds math, physics and the rest. Keyed on the first two segments instead,
77
+ // the semester was never part of the key, the names sorted with every sem-1 collection first, and a
78
+ // cap of forty-eight read semester 1 of everything and semester 2 of nothing.
79
+ export function spread(names) {
80
+ const groups = new Map();
81
+ for (const name of names) {
82
+ const parts = name.split(/[_/.]/);
83
+ const key = parts.length > 1 ? parts.slice(0, -1).join("_") : parts[0];
84
+ groups.set(key, [...(groups.get(key) ?? []), name]);
85
+ }
86
+ const out = [];
87
+ for (let i = 0; out.length < names.length; i += 1) {
88
+ for (const key of [...groups.keys()].sort()) {
89
+ const list = groups.get(key);
90
+ if (list.length > i) out.push(list[i]);
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ async function ask(url, key, body) {
97
+ const res = await fetch(url, {
98
+ method: body ? "POST" : "GET",
99
+ headers: { "content-type": "application/json", ...(key ? { "api-key": key } : {}) },
100
+ ...(body ? { body: JSON.stringify(body) } : {}),
101
+ signal: AbortSignal.timeout(ASK_MS),
102
+ });
103
+ if (!res.ok) throw new Error(`answered ${res.status}`);
104
+ return res.json();
105
+ }
106
+
107
+ async function qdrant(plan, names, env, per, until) {
108
+ let url = valueFor(names, env, /URL|HOST|ENDPOINT/);
109
+ const key = valueFor(names, env, /KEY|TOKEN/);
110
+ if (!url) return store(plan, "no-store", []);
111
+ if (!/^https?:\/\//.test(url)) url = `http://${url}`;
112
+ const base = url.replace(/\/+$/, "");
113
+ let every;
114
+ try {
115
+ every = ((await ask(`${base}/collections`, key)).result?.collections ?? []).map((c) => String(c?.name ?? "")).filter(Boolean);
116
+ } catch (err) {
117
+ return store(plan, "unreachable", [], reason(err));
118
+ }
119
+ const corpus = every.filter((n) => CORPUS.test(n) && !SENSITIVE.test(n));
120
+ const named = (corpus.length ? corpus : every.filter((n) => !SENSITIVE.test(n))).sort((a, b) => rank(a).localeCompare(rank(b)));
121
+ const asking = spread(named).slice(0, COLLECTIONS);
122
+ const each = Math.max(PER_MIN, Math.min(per, Math.floor(BUDGET / ROW_BYTES / Math.max(1, asking.length))));
123
+ const rows = [];
124
+ let read = 0;
125
+ // Where each collection that had rows left off. On ulaim a third of the 177 collections are
126
+ // empty, so the even split left half the answer unspent at three pages a cell.
127
+ const more = new Map();
128
+ const scroll = async (name, limit, offset) => {
129
+ const got = await ask(`${base}/collections/${encodeURIComponent(name)}/points/scroll`, key, { limit, with_payload: true, with_vector: false, ...(offset != null ? { offset } : {}) });
130
+ let kept = 0;
131
+ for (const point of got.result?.points ?? []) {
132
+ const fields = short(point?.payload ?? {});
133
+ if (Object.keys(fields).length) { rows.push({ where: `${name}#${point?.id}`, fields }); kept += 1; }
134
+ }
135
+ if (kept && got.result?.next_page_offset != null) more.set(name, got.result.next_page_offset);
136
+ };
137
+ for (const name of asking) {
138
+ if (Date.now() > until) break;
139
+ try { await scroll(name, each); } catch { continue; }
140
+ read += 1;
141
+ }
142
+ // The second sweep spends what the first left, evenly over the collections that had more.
143
+ const rowBytes = rows.length ? JSON.stringify(rows).length / rows.length : ROW_BYTES;
144
+ const extra = Math.min(PER_STORE_MAX - each, Math.floor((BUDGET - JSON.stringify(rows).length) / rowBytes / Math.max(1, more.size)));
145
+ if (extra > 0) {
146
+ for (const [name, offset] of more) {
147
+ if (Date.now() > until) break;
148
+ try { await scroll(name, extra, offset); } catch { /* the first sweep's rows stand */ }
149
+ }
150
+ }
151
+ return store(plan, rows.length ? "sampled" : "empty", rows, `${read} of ${named.length} collections`);
152
+ }
153
+
154
+ // A document folder their own code ingests from, named by its variable and walked from your root.
155
+ function files(plan, names, env, root) {
156
+ const name = names.find((n) => env[n]);
157
+ const set = name ? env[name] : "";
158
+ const dir = set && !set.startsWith("/") ? join(root, set) : set;
159
+ if (!dir || !existsSync(dir) || !statSync(dir).isDirectory()) return store(plan, "no-store", []);
160
+ const rows = [];
161
+ const walk = (at, depth) => {
162
+ if (rows.length >= ROWS || depth > 8) return;
163
+ let entries;
164
+ try { entries = readdirSync(at, { withFileTypes: true }); } catch { return; }
165
+ for (const entry of entries) {
166
+ if (rows.length >= ROWS) return;
167
+ if (entry.isDirectory()) { if (!SKIP_DIR.has(entry.name) && !entry.name.startsWith(".")) walk(join(at, entry.name), depth + 1); continue; }
168
+ if (!DOC.test(entry.name)) continue;
169
+ rows.push({ where: `${name}#${relative(dir, join(at, entry.name))}`, fields: { file: entry.name, heading: heading(join(at, entry.name)) } });
170
+ }
171
+ };
172
+ walk(dir, 0);
173
+ return store(plan, rows.length ? "sampled" : "empty", rows);
174
+ }
175
+
176
+ // What the file calls itself: its first line that says something.
177
+ function heading(path) {
178
+ try {
179
+ for (const line of readFileSync(path, "utf8").slice(0, 4000).split("\n").slice(0, 60)) {
180
+ const said = line.trim().replace(/^#+/, "").trim();
181
+ if (said) return said.slice(0, STRING_MAX);
182
+ }
183
+ } catch { /* unreadable is no heading */ }
184
+ return "";
185
+ }
186
+
187
+ // Halved until the report fits what one answer carries, pages first and every store together, so a
188
+ // store read late is not the only one cut.
189
+ function fitted(report) {
190
+ while (JSON.stringify(report).length > BUDGET && report.stores.some((s) => s.rows.length > 1)) {
191
+ for (const s of report.stores) s.rows = s.rows.slice(0, Math.max(1, Math.floor(s.rows.length / 2)));
192
+ }
193
+ return report;
194
+ }
195
+
196
+ // ponytail: a database on this machine is not read yet; the table choice needs the read's own list
197
+ // of the tables their AI code names, which the control plane holds and this program does not.
198
+ const NO_DATABASE = "we do not read a database from your own machine yet, so nothing here is written from its rows";
199
+
200
+ export async function sampleHere(plan, env, root) {
201
+ const until = Date.now() + READ_MS;
202
+ const wanted = Number(plan?.perStore);
203
+ const per = Number.isInteger(wanted) && wanted > 0 ? Math.min(wanted, PER_STORE_MAX) : PER_STORE;
204
+ const stores = [];
205
+ for (const asked of (plan?.stores ?? []).slice(0, STORES_MAX)) {
206
+ if (!ROAD.test(String(asked?.road ?? "")) || !KINDS.has(asked?.kind)) continue;
207
+ const named = { road: asked.road, kind: asked.kind };
208
+ const names = (asked.env ?? []).filter((n) => typeof n === "string" && NAME.test(n));
209
+ try {
210
+ if (asked.kind === "qdrant") stores.push(await qdrant(named, names, env, per, until));
211
+ else if (asked.kind === "files") stores.push(files(named, names, env, root));
212
+ else stores.push(store(named, "no-reader", [], NO_DATABASE));
213
+ } catch (err) {
214
+ stores.push(store(named, "unreachable", [], reason(err)));
215
+ }
216
+ }
217
+ return fitted({ stores });
218
+ }
package/lib/start.mjs CHANGED
@@ -10,36 +10,55 @@ const SERVER = /^(?:express|fastify|hono|koa|@nestjs\/core|@strapi\/strapi|next|
10
10
  const PY_MODEL = /^\s*["']?(?:openai|anthropic|langchain|langgraph|litellm|llama[-_]index|google-generativeai|google-genai|groq|cohere|mistralai|ollama|fireworks-ai|together)\b/im;
11
11
  const PY_SERVER = /^\s*["']?(?:fastapi|flask|django|starlette|quart|litestar|sanic|aiohttp|uvicorn|gunicorn)\b/im;
12
12
  const ENTRIES = ["main.py", "app.py", "server.py", "run.py", "api.py", "wsgi.py", "asgi.py", "src/main.py", "app/main.py", "src/app.py", "backend/main.py", "api/main.py"];
13
+ // A twelve-name list is not how apps name their entry file: agent-service-toolkit's is
14
+ // src/run_service.py, and asking a person how their app starts because of a filename is asking
15
+ // them to do our job. Every plausibly named file beside the manifest is a candidate.
16
+ const ENTRY_NAME = /^(?:main|app|server|serve|api|run|start|service|web|backend|wsgi|asgi)[\w-]*\.py$/i;
17
+ const entriesIn = (dir) => ["", "src", "app", "backend", "api"].flatMap((sub) => {
18
+ try { return readdirSync(join(dir, sub)).filter((f) => ENTRY_NAME.test(f)).map((f) => (sub ? `${sub}/${f}` : f)); }
19
+ catch { return []; }
20
+ });
21
+ // What tells an entry file from a script that also runs itself: this one brings a server up.
22
+ const SERVES = /uvicorn\.run|FastAPI\(|Flask\(|Starlette\(|Litestar\(|Quart\(|app\.run\(|run_server|serve\(/;
23
+ // The same question of a package script: does this command put something on a port, or run a task
24
+ // and exit. `crewai run` and `python main.py` are tasks; `next dev` and `nodemon server.js` serve.
25
+ const SERVES_JS = /\b(?:next|nuxt|vite|nest|remix|astro|sveltekit|serve|nodemon|ts-node-dev|uvicorn|gunicorn|rails|strapi)\b/;
26
+ // The manager this repository was installed with, named by its lockfile.
27
+ const manager = (dir) => (existsSync(join(dir, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(dir, "yarn.lock")) ? "yarn" : existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")) ? "bun" : "npm");
13
28
 
14
29
  function nodeStart(dir, onPath) {
15
30
  const pkg = json(join(dir, "package.json"));
16
31
  if (!pkg) return null;
17
32
  const script = ["dev", "develop", "start:dev", "serve", "start"].find((s) => pkg.scripts?.[s]);
18
33
  if (!script) return null;
19
- const locked = existsSync(join(dir, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(dir, "yarn.lock")) ? "yarn" : existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")) ? "bun" : "npm";
34
+ const locked = manager(dir);
35
+ const deps = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
20
36
  // A lockfile names the manager the repository was installed with, not one this machine has.
21
- return `${onPath(locked) ? locked : "npm"} run ${script}`;
37
+ return { cmd: `${onPath(locked) ? locked : "npm"} run ${script}`, serves: deps.some((d) => SERVER.test(d)) || SERVES_JS.test(pkg.scripts[script]) };
22
38
  }
23
39
 
24
40
  function pythonStart(dir, onPath) {
25
41
  if (!["requirements.txt", "pyproject.toml", "manage.py", "Pipfile", "uv.lock"].some((f) => existsSync(join(dir, f)))) return null;
26
42
  const venv = [".venv/bin/python", "venv/bin/python", "env/bin/python"].map((p) => join(dir, p)).find(existsSync);
27
43
  const py = venv ? JSON.stringify(venv) : existsSync(join(dir, "uv.lock")) && onPath("uv") ? "uv run python" : existsSync(join(dir, "poetry.lock")) && onPath("poetry") ? "poetry run python" : "python3";
28
- if (existsSync(join(dir, "manage.py"))) return `${py} manage.py runserver`;
29
- for (const entry of ENTRIES) {
30
- const text = read(join(dir, entry));
31
- if (!text) continue;
44
+ if (existsSync(join(dir, "manage.py"))) return { cmd: `${py} manage.py runserver`, serves: true };
45
+ const named = [...new Set([...ENTRIES, ...entriesIn(dir)])].map((entry) => [entry, read(join(dir, entry))]).filter(([, text]) => text);
46
+ // The file that serves before the file that merely runs: src/run_agent.py runs a conversation in
47
+ // the terminal and src/run_service.py is the app, and they sit in one folder.
48
+ for (const [entry, text] of [...named.filter(([, text]) => SERVES.test(text)), ...named]) {
32
49
  // Their own way of starting it carries their own port and settings.
33
- if (/if\s+__name__\s*==\s*["']__main__["']/.test(text)) return `${py} ${entry}`;
50
+ if (/if\s+__name__\s*==\s*["']__main__["']/.test(text)) return { cmd: `${py} ${entry}`, serves: SERVES.test(text) };
34
51
  const module = entry.replace(/\.py$/, "").split("/").join(".");
35
52
  const fast = /^(\w+)\s*=\s*(?:FastAPI|Starlette|Litestar|Quart)\(/m.exec(text);
36
- if (fast) return `${py} -m uvicorn ${module}:${fast[1]} --host 127.0.0.1 --port 8000`;
53
+ if (fast) return { cmd: `${py} -m uvicorn ${module}:${fast[1]} --host 127.0.0.1 --port 8000`, serves: true };
37
54
  const flask = /^(\w+)\s*=\s*Flask\(/m.exec(text);
38
- if (flask) return `${py} -m flask --app ${module} run --port 5000`;
55
+ if (flask) return { cmd: `${py} -m flask --app ${module} run --port 5000`, serves: true };
39
56
  }
40
57
  return null;
41
58
  }
42
59
 
60
+ const KNOWN = ["package.json", "pyproject.toml", "requirements.txt", "manage.py", "Pipfile", "uv.lock"];
61
+
43
62
  const startOf = (dir, onPath) => nodeStart(dir, onPath) ?? pythonStart(dir, onPath);
44
63
 
45
64
  // How much a folder looks like the thing that serves the AI.
@@ -53,7 +72,7 @@ function weight(dir, name) {
53
72
  }
54
73
 
55
74
  const SKIP = /^(?:node_modules|\.git|dist|build|\.next|coverage|\.venv|venv|__pycache__|\.turbo|\.cache)$/;
56
- function workspaces(root) {
75
+ export function workspaces(root) {
57
76
  const found = [];
58
77
  const visit = (dir, depth) => {
59
78
  let names = [];
@@ -75,11 +94,47 @@ export function startPlan({ root, typed, onPath }) {
75
94
  const pkg = json(join(root, "package.json"));
76
95
  const mono = Boolean(pkg?.workspaces) || existsSync(join(root, "pnpm-workspace.yaml")) || existsSync(join(root, "turbo.json")) || existsSync(join(root, "lerna.json"));
77
96
  const own = mono ? null : startOf(root, onPath);
78
- if (own) return { cmd: own, cwd: root };
79
- const ranked = workspaces(root).map((dir) => ({ dir, cmd: startOf(dir, onPath), weight: weight(dir, relative(root, dir)) })).filter((w) => w.cmd).sort((a, b) => b.weight - a.weight);
97
+ if (own) return { cmd: own.cmd, cwd: root };
98
+ const ranked = workspaces(root).map((dir) => ({ dir, start: startOf(dir, onPath), weight: weight(dir, relative(root, dir)) })).filter((w) => w.start).sort((a, b) => b.weight - a.weight);
80
99
  const best = ranked[0];
81
- if (best && best.weight > 0 && (ranked.length === 1 || best.weight > ranked[1].weight)) return { cmd: best.cmd, cwd: best.dir, within: relative(root, best.dir) };
100
+ const at = (w) => ({ cmd: w.start.cmd, cwd: w.dir, within: relative(root, w.dir) });
101
+ if (best && best.weight > 0 && (ranked.length === 1 || best.weight > ranked[1].weight)) return at(best);
82
102
  // A root that does have a start of its own after all (a monorepo whose root script runs everything).
83
103
  const rootStart = startOf(root, onPath);
84
- return rootStart ? { cmd: rootStart, cwd: root } : best ? { cmd: best.cmd, cwd: best.dir, within: relative(root, best.dir), unsure: true } : null;
104
+ if (rootStart) return { cmd: rootStart.cmd, cwd: root };
105
+ // Nothing at all that we know how to start. A repository written in a language we read, holding
106
+ // no way to serve, is a package rather than an app: camel is one. Anything else is a start
107
+ // command we have not learned.
108
+ if (!best) return KNOWN.some((f) => existsSync(join(root, f))) ? { cmd: null, cwd: root, noServer: true } : null;
109
+ // Nothing here stands out, so the one that serves wins. A file that only runs in a terminal is
110
+ // not an app: camel is a package people import, swarm and the crewai examples are scripts that
111
+ // print to a terminal, and starting one of them puts a chat loop where nothing can knock on it.
112
+ const serving = ranked.find((w) => w.start.serves);
113
+ return serving ? { ...at(serving), unsure: true } : { cmd: null, cwd: root, noServer: true };
114
+ }
115
+
116
+ // What an app says when its dependencies are not installed on this machine, in every runtime we
117
+ // start, and the name of the one it named first.
118
+ const MISSING = /Cannot find module ['"]?([@\w./-]+)|Cannot find package ['"]?([@\w./-]+)|ModuleNotFoundError: No module named ['"]?([\w.]+)|ImportError: No module named ['"]?([\w.]+)|(?:^|[:/ ])([\w.-]+): (?:command )?not found|command not found: ?([\w.-]+)/m;
119
+ export const missingDependency = (said) => MISSING.exec(String(said ?? ""))?.slice(1).find(Boolean) ?? "";
120
+
121
+ // One install, with the manager the project locked. Never a global one: a Python project without an
122
+ // interpreter of its own gets a virtual environment beside its code, so nothing installed here
123
+ // reaches the rest of the machine.
124
+ export function installPlan(dir, onPath) {
125
+ if (json(join(dir, "package.json"))) {
126
+ // The manager the repository locked, fetched for the job when this machine has not got it: npm
127
+ // refuses vercel's ai-chatbot outright over a peer range pnpm resolves without a word.
128
+ const locked = manager(dir);
129
+ if (locked !== "npm") return onPath(locked) ? `${locked} install` : `npx --yes ${locked} install`;
130
+ return existsSync(join(dir, "package-lock.json")) ? "npm ci || npm install --legacy-peer-deps" : "npm install || npm install --legacy-peer-deps";
131
+ }
132
+ if (existsSync(join(dir, "uv.lock")) && onPath("uv")) return "uv sync";
133
+ if (existsSync(join(dir, "poetry.lock")) && onPath("poetry")) return "poetry install";
134
+ const venv = [".venv/bin/python", "venv/bin/python", "env/bin/python"].map((p) => join(dir, p)).find(existsSync);
135
+ const py = venv ? JSON.stringify(venv) : null;
136
+ const into = (what) => (py ? `${py} -m pip install ${what}` : `python3 -m venv .venv && .venv/bin/python -m pip install --upgrade pip && .venv/bin/python -m pip install ${what}`);
137
+ if (existsSync(join(dir, "requirements.txt"))) return into("-r requirements.txt");
138
+ if (existsSync(join(dir, "pyproject.toml"))) return into("-e .");
139
+ return null;
85
140
  }
package/lib/trace.cjs CHANGED
@@ -1,8 +1,10 @@
1
1
  // Loaded into your app by the command that started it (node --require), and only then. It watches
2
- // for one thing: a request to your app during which your app called a model. That request is your
3
- // AI's door, with the exact body it takes and the sign-in it carried, learned from a message you sent
4
- // yourself rather than guessed from code. What it sees is written to a file only you can read, in the
5
- // command's own folder on this machine. Nothing here talks to a network.
2
+ // for two things. First, a request to your app during which your app called a model: that request is
3
+ // your AI's door, with the exact body it takes and the sign-in it carried, learned from a message you
4
+ // sent yourself rather than guessed from code. Second, every model call your app makes: the host, the
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. Reply text is never written down. Both go to a file only you
7
+ // can read, in the command's own folder on this machine. Nothing here talks to a network.
6
8
  "use strict";
7
9
  const FILE = process.env.CORTAD_TRACE_FILE;
8
10
  if (FILE) {
@@ -13,10 +15,15 @@ if (FILE) {
13
15
  const https = require("node:https");
14
16
  const als = new AsyncLocalStorage();
15
17
  // Said once, so the command knows a message sent to this app can be seen arriving.
16
- try { fs.appendFileSync(FILE, JSON.stringify({ hello: "node", pid: process.pid }) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ }
18
+ const row = (value) => { try { fs.appendFileSync(FILE, JSON.stringify(value) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ } };
19
+ const isBun = typeof Bun !== "undefined" && typeof Bun.serve === "function";
20
+ row({ hello: isBun ? "bun" : "node", pid: process.pid });
21
+ // Which port this process serves. Every process the start command spawns loads this file, turbo's
22
+ // own launcher included, so "loaded" is not "watching your app": only a listener on the app's port is.
23
+ const listening = (port) => { if (Number.isInteger(port) && port > 0) row({ listen: port, pid: process.pid }); };
17
24
  const MAX = 65536;
18
25
  // Where models are served, by host, and the paths every OpenAI-shaped or vendor endpoint ends with.
19
- const MODEL_HOST = /(?:^|\.)(?:openai\.com|anthropic\.com|fireworks\.ai|openrouter\.ai|groq\.com|mistral\.ai|together\.xyz|together\.ai|deepseek\.com|cohere\.ai|cohere\.com|perplexity\.ai|x\.ai|googleapis\.com|openai\.azure\.com|cognitiveservices\.azure\.com|amazonaws\.com|replicate\.com|huggingface\.co|cerebras\.ai|deepinfra\.com|novita\.ai|moonshot\.cn|dashscope\.aliyuncs\.com|bigmodel\.cn)$/i;
26
+ const MODEL_HOST = /(?:^|\.)(?:openai\.com|anthropic\.com|fireworks\.ai|openrouter\.ai|groq\.com|mistral\.ai|together\.xyz|together\.ai|deepseek\.com|cohere\.ai|cohere\.com|perplexity\.ai|x\.ai|googleapis\.com|openai\.azure\.com|cognitiveservices\.azure\.com|amazonaws\.com|replicate\.com|huggingface\.co|cerebras\.ai|deepinfra\.com|novita\.ai|moonshot\.cn|dashscope\.aliyuncs\.com|bigmodel\.cn|ai-gateway\.vercel\.sh|gateway\.ai\.cloudflare\.com|helicone\.ai|portkey\.ai)$/i;
20
27
  const MODEL_PATH = /\/(?:chat\/completions|completions|responses|messages|embeddings)$|:(?:generateContent|streamGenerateContent)|\/invoke(?:-with-response-stream)?$|\/api\/(?:chat|generate)$/i;
21
28
  const isModelCall = (host, path) => {
22
29
  const h = String(host || "").replace(/:\d+$/, "");
@@ -32,14 +39,16 @@ if (FILE) {
32
39
  const key = `${ctx.method} ${ctx.path.split("?")[0]}`;
33
40
  if (said.has(key)) return;
34
41
  said.add(key);
35
- const record = { at: Date.now(), method: ctx.method, path: ctx.path, headers: ctx.headers, body: Buffer.concat(ctx.chunks).toString("utf8").slice(0, MAX), sent: String(sent || "").slice(0, MAX) };
36
- try { fs.appendFileSync(FILE, JSON.stringify(record) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ }
42
+ const write = (chunks) => row({ at: Date.now(), method: ctx.method, path: ctx.path, headers: ctx.headers, body: Buffer.concat(chunks).toString("utf8").slice(0, MAX), sent: String(sent || "").slice(0, MAX) });
43
+ if (ctx.body) ctx.body.then((b) => write([b]));
44
+ else write(ctx.chunks);
37
45
  };
38
46
 
39
47
  // Inbound: each request is handled inside its own context, and its body is seen as it arrives
40
48
  // without reading it, so your own body parser is untouched.
41
49
  const emit = http.Server.prototype.emit;
42
50
  http.Server.prototype.emit = function (type, req, ...rest) {
51
+ if (type === "listening") { try { listening(this.address()?.port); } catch { /* not a TCP server */ } }
43
52
  if (type !== "request" || !req || !req.method || /^(?:GET|HEAD|OPTIONS)$/.test(req.method)) return emit.call(this, type, req, ...rest);
44
53
  const ctx = { method: req.method, path: req.url || "/", headers: { ...req.headers }, chunks: [], size: 0, noted: false };
45
54
  const push = req.push;
@@ -67,16 +76,160 @@ if (FILE) {
67
76
  return als.run(ctx, () => emit.call(this, type, req, ...rest));
68
77
  };
69
78
 
79
+ // Bun serves through Bun.serve, never node:http, so the patch above saw no request at all: databuddy's
80
+ // Elysia API on Bun took the customer's messages and nothing was written. Bun.serve is wrapped
81
+ // instead, both the fetch handler and the per-route handlers Bun can dispatch to directly. The body
82
+ // is read from a clone, so the app's own reader is untouched.
83
+ if (isBun) {
84
+ const inside = (handler, self) => function (req, server) {
85
+ if (!req || /^(?:GET|HEAD|OPTIONS)$/.test(req.method)) return handler.call(self, req, server);
86
+ let path = "/";
87
+ try { const u = new URL(req.url); path = u.pathname + u.search; } catch { /* keep "/" */ }
88
+ const ctx = { method: req.method, path, headers: Object.fromEntries(req.headers), chunks: [], size: 0, noted: false };
89
+ // ponytail: a body is copied only when it is small or says it is text; an upload is left alone.
90
+ const size = Number(req.headers.get("content-length") || 0);
91
+ if (size <= MAX || /json|text|form/i.test(req.headers.get("content-type") || "")) {
92
+ try { ctx.body = req.clone().arrayBuffer().then((b) => Buffer.from(b).subarray(0, MAX), () => Buffer.alloc(0)); } catch { /* unread */ }
93
+ }
94
+ return als.run(ctx, () => handler.call(self, req, server));
95
+ };
96
+ const routes = (table) => {
97
+ if (!table || typeof table !== "object") return table;
98
+ const out = {};
99
+ for (const [route, value] of Object.entries(table)) {
100
+ if (typeof value === "function") out[route] = inside(value, table);
101
+ else if (value && typeof value === "object" && !(value instanceof Response)) {
102
+ out[route] = {};
103
+ for (const [verb, fn] of Object.entries(value)) out[route][verb] = typeof fn === "function" ? inside(fn, value) : fn;
104
+ } else out[route] = value;
105
+ }
106
+ return out;
107
+ };
108
+ const serve = Bun.serve;
109
+ Bun.serve = function (options, ...rest) {
110
+ let wrapped = options;
111
+ try {
112
+ if (options && typeof options === "object") {
113
+ wrapped = Object.assign(Object.create(Object.getPrototypeOf(options)), options);
114
+ if (typeof options.fetch === "function") wrapped.fetch = inside(options.fetch, options);
115
+ if (options.routes) wrapped.routes = routes(options.routes);
116
+ }
117
+ } catch { wrapped = options; }
118
+ const server = serve.call(this, wrapped, ...rest);
119
+ try { listening(server && server.port); } catch { /* not ours to fail */ }
120
+ return server;
121
+ };
122
+ }
123
+
124
+ // The meter. One row per model call: host, model id, status, and the provider's own token counts.
125
+ const zlib = require("node:zlib");
126
+ const REPLY_MAX = 8 * 1024 * 1024;
127
+ const decoded = (buf, encoding) => {
128
+ try {
129
+ const e = String(encoding || "").toLowerCase();
130
+ return (e === "gzip" ? zlib.gunzipSync(buf) : e === "br" ? zlib.brotliDecompressSync(buf) : e === "deflate" ? zlib.inflateSync(buf) : buf).toString("utf8");
131
+ } catch { return ""; }
132
+ };
133
+ const parse = (text) => { try { return JSON.parse(text); } catch { return null; } };
134
+ const n = (v) => (Number.isInteger(v) && v > 0 ? v : 0);
135
+ // The shapes the providers answer in. promptTokens is the whole input, cache included, so totals add up.
136
+ const tokensOf = (u) => {
137
+ if (!u || typeof u !== "object") return null;
138
+ if (u.tokens && typeof u.tokens === "object") return tokensOf(u.tokens);
139
+ if ("prompt_tokens" in u) return { promptTokens: n(u.prompt_tokens), cachedTokens: n(u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens), completionTokens: n(u.completion_tokens) };
140
+ if ("input_tokens" in u && u.input_tokens_details) return { promptTokens: n(u.input_tokens), cachedTokens: n(u.input_tokens_details.cached_tokens), completionTokens: n(u.output_tokens) };
141
+ if ("input_tokens" in u) { const read = n(u.cache_read_input_tokens); return { promptTokens: n(u.input_tokens) + read + n(u.cache_creation_input_tokens), cachedTokens: read, completionTokens: n(u.output_tokens) }; }
142
+ if ("inputTokens" in u) { const read = n(u.cacheReadInputTokens); return { promptTokens: n(u.inputTokens) + read + n(u.cacheWriteInputTokens), cachedTokens: read, completionTokens: n(u.outputTokens) }; }
143
+ if ("promptTokenCount" in u) return { promptTokens: n(u.promptTokenCount), cachedTokens: n(u.cachedContentTokenCount), completionTokens: n(u.candidatesTokenCount) };
144
+ return null;
145
+ };
146
+ // A stream spreads its counts over events (Anthropic's input in the first, output in the last);
147
+ // later values win, so the fold ends on the final figures.
148
+ const readReply = (type, text) => {
149
+ const events = /event-stream/i.test(type || "")
150
+ ? text.split("\n").filter((l) => l.startsWith("data:")).map((l) => parse(l.slice(5).trim()))
151
+ : [].concat(parse(text));
152
+ const usage = {};
153
+ let model = null;
154
+ for (const e of events) {
155
+ if (!e || typeof e !== "object") continue;
156
+ const part = e.usage || (e.message && e.message.usage) || (e.response && e.response.usage) || e.usageMetadata;
157
+ if (part && typeof part === "object") Object.assign(usage, part);
158
+ model = e.model || (e.message && e.message.model) || (e.response && e.response.model) || e.modelVersion || model;
159
+ }
160
+ return { tokens: tokensOf(Object.keys(usage).length ? usage : null), model };
161
+ };
162
+ // The model asked for, from what was sent; Gemini and Bedrock put it in the path instead.
163
+ const askedFor = (path, sent) => {
164
+ const body = parse(sent);
165
+ if (body && typeof body.model === "string") return body.model;
166
+ const m = /\/models\/([^/:]+):|\/model\/([^/]+)\/(?:invoke|converse)/.exec(path || "");
167
+ return m ? decodeURIComponent(m[1] || m[2]) : "";
168
+ };
169
+ const meter = ({ host, path, sent, status, type, body }) => {
170
+ try {
171
+ const reply = readReply(type, String(body || "").slice(0, REPLY_MAX));
172
+ 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) };
173
+ fs.appendFileSync(FILE, JSON.stringify({ call: row }) + "\n", { mode: 0o600 });
174
+ } catch { /* the command is gone */ }
175
+ };
176
+
177
+ // Outbound to a service their own settings name (a vector store, a database API, a search
178
+ // host): which setting, which host and whether it answered. No body, no headers, no query. A
179
+ // run that tested ulaim while every curriculum lookup failed inside Node never knew; this is
180
+ // the witness that lets the run say retrieval was not tested instead of grading without it.
181
+ let depHosts = null, depSeen = -1;
182
+ const depOf = (input) => {
183
+ try {
184
+ const keys = Object.keys(process.env);
185
+ if (keys.length !== depSeen) {
186
+ depSeen = keys.length; depHosts = new Map();
187
+ for (const k of keys) {
188
+ const v = process.env[k];
189
+ if (!v || !/^https?:\/\//i.test(v) || /(^|_)(KEY|TOKEN|SECRET|PASSWORD)$/i.test(k)) continue;
190
+ try { depHosts.set(new URL(v).host.replace(/:443$/, ""), k); } catch { /* not a URL */ }
191
+ }
192
+ }
193
+ const u = new URL(typeof input === "string" ? input : input && input.url ? input.url : String(input));
194
+ const name = depHosts.get(u.host.replace(/:443$/, "")) || depHosts.get(u.hostname);
195
+ return name ? { env: name, host: u.hostname } : null;
196
+ } catch { return null; }
197
+ };
198
+ const depRow = (dep, status, code) => {
199
+ try { fs.appendFileSync(FILE, JSON.stringify({ dep: { at: Date.now(), env: dep.env, host: dep.host, status, code: code ? String(code).slice(0, 40) : undefined } }) + "\n", { mode: 0o600 }); } catch { /* the command is gone */ }
200
+ };
201
+
70
202
  // Outbound: the model call your app makes while it handles that request.
71
203
  const bodyText = (body) => (typeof body === "string" ? body : Buffer.isBuffer(body) ? body.toString("utf8") : body instanceof Uint8Array ? Buffer.from(body).toString("utf8") : "");
72
204
  if (typeof globalThis.fetch === "function") {
73
205
  const realFetch = globalThis.fetch;
74
206
  globalThis.fetch = function (input, init) {
207
+ let url = null;
75
208
  try {
76
- const url = new URL(typeof input === "string" ? input : input && input.url ? input.url : String(input));
77
- if (isModelCall(url.host, url.pathname)) note(als.getStore(), bodyText(init && init.body));
209
+ const u = new URL(typeof input === "string" ? input : input && input.url ? input.url : String(input));
210
+ if (isModelCall(u.host, u.pathname)) url = u;
78
211
  } catch { /* not a URL we can read */ }
79
- return realFetch.apply(this, arguments);
212
+ if (!url) {
213
+ const dep = depOf(input);
214
+ if (!dep) return realFetch.apply(this, arguments);
215
+ return realFetch.apply(this, arguments).then(
216
+ (res) => { depRow(dep, res.status); return res; },
217
+ (err) => { depRow(dep, 0, err && err.cause && err.cause.code); throw err; },
218
+ );
219
+ }
220
+ const sent = bodyText(init && init.body);
221
+ note(als.getStore(), sent);
222
+ const call = { host: url.host, path: url.pathname, sent };
223
+ return realFetch.apply(this, arguments).then((res) => {
224
+ try {
225
+ // A clone is a tee: your app's branch gets every byte as fast as it reads, this one is read to the end.
226
+ res.clone().text().then(
227
+ (text) => meter({ ...call, status: res.status, type: res.headers.get("content-type"), body: text }),
228
+ () => meter({ ...call, status: res.status, type: "", body: "" }),
229
+ );
230
+ } catch { meter({ ...call, status: res.status, type: "", body: "" }); }
231
+ return res;
232
+ }, (err) => { meter({ ...call, status: 0, type: "", body: "" }); throw err; });
80
233
  };
81
234
  }
82
235
  for (const mod of [http, https]) {
@@ -90,10 +243,29 @@ if (FILE) {
90
243
  if (isModelCall(host, path)) {
91
244
  const ctx = als.getStore();
92
245
  const parts = [];
246
+ let sent = "";
93
247
  const write = req.write;
94
248
  const end = req.end;
95
249
  req.write = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); return write.call(this, chunk, ...r); };
96
- req.end = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); note(ctx, Buffer.concat(parts).toString("utf8")); return end.call(this, chunk, ...r); };
250
+ req.end = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); sent = Buffer.concat(parts).toString("utf8"); note(ctx, sent); return end.call(this, chunk, ...r); };
251
+ const call = { host: String(host || ""), path: String(path || "").split("?")[0] };
252
+ let done = false;
253
+ const once = (row) => { if (!done) { done = true; meter({ ...call, sent, ...row }); } };
254
+ req.once("error", () => once({ status: 0, type: "", body: "" }));
255
+ req.once("response", (res) => {
256
+ // The reply's bytes as the parser hands them over, without reading the stream: your
257
+ // app's own listeners and pipes see exactly what they would have.
258
+ const got = [];
259
+ let size = 0;
260
+ const push = res.push;
261
+ res.push = function (chunk, encoding) {
262
+ if (chunk && size < REPLY_MAX) { const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding); got.push(b); size += b.length; }
263
+ return push.call(this, chunk, encoding);
264
+ };
265
+ const finish = () => once({ status: res.statusCode || 0, type: res.headers["content-type"] || "", body: decoded(Buffer.concat(got), res.headers["content-encoding"]) });
266
+ res.once("end", finish);
267
+ res.once("close", finish);
268
+ });
97
269
  }
98
270
  } catch { /* leave the request alone */ }
99
271
  return req;