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 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 script = ["dev", "develop", "start:dev", "serve", "start"].find((s) => pkg.scripts?.[s]);
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
- const own = mono ? null : startOf(root, onPath);
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 = `http://127.0.0.1:${port}${path}`;
374
- const yours = (to) => { try { const u = new URL(to, url); return u.hostname === "127.0.0.1" && u.port === String(port) ? u.href : null; } catch { return null; } };
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
- try { await fetch(`http://127.0.0.1:${port}/`, { method: "GET", signal: AbortSignal.timeout(2500), redirect: "manual" }); return true; }
453
- catch { return false; }
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.10",
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"