cortad 0.3.0-rc.10 → 0.3.0-rc.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/start.mjs +46 -3
- package/lib/text.mjs +4 -1
- package/local.mjs +21 -5
- package/package.json +1 -1
package/lib/start.mjs
CHANGED
|
@@ -38,22 +38,61 @@ export function lockRoot(dir, root) {
|
|
|
38
38
|
// The manager this repository was installed with, named by its lockfile.
|
|
39
39
|
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");
|
|
40
40
|
|
|
41
|
+
// A script that only bundles or serves a browser build: the AI is never behind it.
|
|
42
|
+
const BUNDLER = /\b(?:vite|next|nuxt|astro|svelte-kit|vinxi|webpack(?:-dev-server)?|react-scripts|parcel|rsbuild|storybook)\b/;
|
|
43
|
+
// A script that runs a server file, or runs everything at once.
|
|
44
|
+
const RUNS_SERVER = /\b(?:node|nodemon|tsx|ts-node(?:-dev)?|bun run|deno run|concurrently|npm-run-all|run-p|turbo run)\b|\.(?:sh|mjs|cjs|js|ts)\b/;
|
|
45
|
+
const SERVER_SCRIPT = /^(?:dev:(?:full|all|server|api|backend)|server|start:server|backend(?::dev)?|api(?::dev)?|start:api|serve:api|start)$/;
|
|
46
|
+
// The script that starts the AI, not the page: flowviz's dev runs vite and its server runs node
|
|
47
|
+
// server.js with openai behind express; tududi's dev is the frontend and backend:dev the API.
|
|
48
|
+
function serverScript(scripts, deps) {
|
|
49
|
+
const first = ["dev", "develop", "start:dev", "serve", "start"].find((s) => scripts?.[s]);
|
|
50
|
+
if (!first) return null;
|
|
51
|
+
const command = String(scripts[first]);
|
|
52
|
+
const frontendOnly = BUNDLER.test(command) && !RUNS_SERVER.test(command.replace(BUNDLER, ""));
|
|
53
|
+
if (!frontendOnly || !deps.some((d) => SERVER.test(d) || MODEL_SDK.test(d))) return first;
|
|
54
|
+
const better = Object.keys(scripts).filter((s) => SERVER_SCRIPT.test(s) && s !== first && RUNS_SERVER.test(String(scripts[s])) && !(BUNDLER.test(String(scripts[s])) && !/concurrently|npm-run-all|run-p|\.sh\b/.test(String(scripts[s]))));
|
|
55
|
+
const rank = (s) => (/full|all/.test(s) ? 0 : /server|backend|api/.test(s) ? 1 : 2);
|
|
56
|
+
return better.sort((a, b) => rank(a) - rank(b))[0] ?? first;
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
function nodeStart(dir, onPath) {
|
|
42
60
|
const pkg = json(join(dir, "package.json"));
|
|
43
61
|
if (!pkg) return null;
|
|
44
|
-
const
|
|
62
|
+
const deps = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
|
|
63
|
+
const script = serverScript(pkg.scripts, deps);
|
|
45
64
|
if (!script) return null;
|
|
46
65
|
const locked = manager(dir);
|
|
47
|
-
const deps = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
|
|
48
66
|
// A lockfile names the manager the repository was installed with, not one this machine has.
|
|
49
67
|
return { cmd: `${onPath(locked) ? locked : "npm"} run ${script}`, serves: deps.some((d) => SERVER.test(d)) || SERVES_JS.test(pkg.scripts[script]) };
|
|
50
68
|
}
|
|
51
69
|
|
|
70
|
+
// A package that serves through its own console script: pyproject [project.scripts] names it, and
|
|
71
|
+
// its command module defines a serve or run command (typer, click, argparse). RepoWiki starts with
|
|
72
|
+
// `repowiki serve --port N`; no module-level app object exists to hand uvicorn.
|
|
73
|
+
function consoleServe(dir, venv) {
|
|
74
|
+
if (!venv) return null;
|
|
75
|
+
const toml = read(join(dir, "pyproject.toml"));
|
|
76
|
+
const block = /\[project\.scripts\]([^[]*)/.exec(toml)?.[1] ?? "";
|
|
77
|
+
for (const m of block.matchAll(/^\s*"?([\w.-]+)"?\s*=\s*"([\w.]+):(\w+)"/gm)) {
|
|
78
|
+
const [, name, module] = m;
|
|
79
|
+
const file = ["src/", ""].map((base) => join(dir, base, `${module.split(".").join("/")}.py`)).find(existsSync);
|
|
80
|
+
const text = file ? read(file) : "";
|
|
81
|
+
const sub = /(?:def serve\(|@\w+\.command\(\s*(?:name\s*=\s*)?["']serve["']|add_parser\(\s*["']serve["']|["']serve["']\s*:)/.test(text) ? "serve"
|
|
82
|
+
: /(?:def run\(|add_parser\(\s*["']run["']|@\w+\.command\(\s*(?:name\s*=\s*)?["']run["'])/.test(text) ? "run" : null;
|
|
83
|
+
const bin = join(dirname(venv), name);
|
|
84
|
+
if (sub && existsSync(bin)) return { cmd: `${JSON.stringify(bin)} ${sub}${/--port/.test(text) ? " --port 8000" : ""}`, serves: true };
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
52
89
|
function pythonStart(dir, onPath) {
|
|
53
90
|
if (!["requirements.txt", "pyproject.toml", "manage.py", "Pipfile", "uv.lock"].some((f) => existsSync(join(dir, f)))) return null;
|
|
54
91
|
const venv = [".venv/bin/python", "venv/bin/python", "env/bin/python"].map((p) => join(dir, p)).find(existsSync);
|
|
55
92
|
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";
|
|
56
93
|
if (existsSync(join(dir, "manage.py"))) return { cmd: `${py} manage.py runserver`, serves: true };
|
|
94
|
+
const served = consoleServe(dir, venv);
|
|
95
|
+
if (served) return served;
|
|
57
96
|
const named = [...new Set([...ENTRIES, ...entriesIn(dir)])].map((entry) => [entry, read(join(dir, entry))]).filter(([, text]) => text);
|
|
58
97
|
const manifest = `${read(join(dir, "requirements.txt"))}\n${read(join(dir, "pyproject.toml"))}\n${read(join(dir, "Pipfile"))}`;
|
|
59
98
|
// The file that serves before the file that merely runs: src/run_agent.py runs a conversation in
|
|
@@ -115,7 +154,11 @@ export function startPlan({ root, typed, onPath }) {
|
|
|
115
154
|
if (typed) return { cmd: typed, cwd: root };
|
|
116
155
|
const pkg = json(join(root, "package.json"));
|
|
117
156
|
const mono = Boolean(pkg?.workspaces) || existsSync(join(root, "pnpm-workspace.yaml")) || existsSync(join(root, "turbo.json")) || existsSync(join(root, "lerna.json"));
|
|
118
|
-
|
|
157
|
+
// A root with both manifests: the side that names a model SDK is the app; the other is its page.
|
|
158
|
+
const rootPkg = json(join(root, "package.json"));
|
|
159
|
+
const rootPy = `${read(join(root, "requirements.txt"))}\n${read(join(root, "pyproject.toml"))}`;
|
|
160
|
+
const pythonFirst = Boolean(rootPkg) && PY_MODEL.test(rootPy) && !Object.keys({ ...(rootPkg.dependencies ?? {}), ...(rootPkg.devDependencies ?? {}) }).some((d) => MODEL_SDK.test(d));
|
|
161
|
+
const own = mono ? null : pythonFirst ? (pythonStart(root, onPath) ?? nodeStart(root, onPath)) : startOf(root, onPath);
|
|
119
162
|
if (own) return { cmd: own.cmd, cwd: root };
|
|
120
163
|
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);
|
|
121
164
|
const best = ranked[0];
|
package/lib/text.mjs
CHANGED
|
@@ -242,7 +242,10 @@ function findingBlock(f, placed) {
|
|
|
242
242
|
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);
|
|
243
243
|
if (by.length) lines.push(` Decided by ${by.join(", by ")}.`);
|
|
244
244
|
for (const q of (f.quotes ?? []).slice(0, 3)) {
|
|
245
|
-
const about = [typeof q.p === "number" && `confidence ${q.p.toFixed(2)}`, q.trialId && `trial ${q.trialId}`].filter(Boolean).join(", ");
|
|
245
|
+
const about = [typeof q.p === "number" && `confidence ${q.p.toFixed(2)}`, q.trialId && `trial ${q.trialId}`, q.door && `at ${q.door}`].filter(Boolean).join(", ");
|
|
246
|
+
// The request beside the reply: an agent handed "HTTP 502 happened" did the rest of the
|
|
247
|
+
// investigation itself.
|
|
248
|
+
if (q.asked) lines.push(` Asked: "${clip(q.asked, 300)}"`);
|
|
246
249
|
lines.push(` Reply ${q.reply}: "${clip(q.quote, 400)}"${about ? ` (${about})` : ""}`);
|
|
247
250
|
}
|
|
248
251
|
for (const line of (f.log ?? []).slice(0, 8)) lines.push(` Log: ${clip(line, 300)}`);
|
package/local.mjs
CHANGED
|
@@ -370,8 +370,8 @@ async function verb(job) {
|
|
|
370
370
|
// method", which is what your chat looked like from the outside. So the chain is walked the
|
|
371
371
|
// way a browser walks it, one GET with redirects followed, and the request is sent again with
|
|
372
372
|
// the session your app just handed out. The cookie is kept here and never leaves this machine.
|
|
373
|
-
const url =
|
|
374
|
-
const yours = (to) => { try { const u = new URL(to, url); return
|
|
373
|
+
const url = hostUrl(port, path);
|
|
374
|
+
const yours = (to) => { try { const u = new URL(to, url); return ["127.0.0.1", "::1", "[::1]", "localhost"].includes(u.hostname) && u.port === String(port) ? u.href : null; } catch { return null; } };
|
|
375
375
|
let jar = "";
|
|
376
376
|
const sent = (at, over = {}) => fetch(at, {
|
|
377
377
|
...init, ...over,
|
|
@@ -448,10 +448,26 @@ async function verb(job) {
|
|
|
448
448
|
const exec = promisify(execFile);
|
|
449
449
|
|
|
450
450
|
// ---- start or attach to the app
|
|
451
|
+
// The address the app answers on: 127.0.0.1, or [::1] when it bound IPv6 localhost alone, which a
|
|
452
|
+
// Vite dev server does and which a probe of 127.0.0.1 waited three minutes on.
|
|
453
|
+
let appHost = "127.0.0.1";
|
|
454
|
+
const hostUrl = (port, path = "/") => `http://${appHost === "::1" ? "[::1]" : appHost}:${port}${path}`;
|
|
451
455
|
const answers = async (port) => {
|
|
452
|
-
|
|
453
|
-
|
|
456
|
+
for (const host of [appHost, appHost === "::1" ? "127.0.0.1" : "::1"]) {
|
|
457
|
+
try {
|
|
458
|
+
await fetch(`http://${host === "::1" ? "[::1]" : host}:${port}/`, { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" });
|
|
459
|
+
appHost = host;
|
|
460
|
+
return true;
|
|
461
|
+
} catch { /* the other family next */ }
|
|
462
|
+
}
|
|
463
|
+
return false;
|
|
454
464
|
};
|
|
465
|
+
// The values an env file beside the app declares, exported into its process the way `source .env`
|
|
466
|
+
// would: an app with no dotenv loader of its own crashed every route without them. Example files
|
|
467
|
+
// hold placeholders and are left out; the shell's own environment and the lifted limits win.
|
|
468
|
+
function envExports(envFiles) {
|
|
469
|
+
return envValues(envFiles.filter((f) => !/\.(example|sample)$/.test(f)));
|
|
470
|
+
}
|
|
455
471
|
const onPath = (bin) => (process.env.PATH ?? "").split(":").some((dir) => dir && existsSync(join(dir, bin)));
|
|
456
472
|
// Where their app lives inside this repository, and how it starts. Worked out in lib/start.mjs.
|
|
457
473
|
let appDir = root;
|
|
@@ -707,7 +723,7 @@ async function freed(cmd, lifted) {
|
|
|
707
723
|
|
|
708
724
|
async function start(waitMs) {
|
|
709
725
|
const { cmd, lifted } = await freed(launched.cmd, launched.lifted ?? {});
|
|
710
|
-
child = spawn("/bin/sh", ["-c", cmd], { cwd: appDir, env: { ...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 });
|
|
726
|
+
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 });
|
|
711
727
|
const mine = child;
|
|
712
728
|
let seen = "";
|
|
713
729
|
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); };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.3.0-rc.
|
|
3
|
+
"version": "0.3.0-rc.11",
|
|
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"
|