cortad 0.3.1-rc.7 → 0.3.1-rc.9
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/cli.mjs +5 -0
- package/lib/data.mjs +1 -1
- package/lib/fresh.mjs +108 -0
- package/lib/home.mjs +7 -4
- package/lib/mcp.mjs +1 -1
- package/lib/pyhook/sitecustomize.py +126 -21
- package/lib/replay.mjs +5 -2
- package/lib/text.mjs +49 -10
- package/lib/trace.cjs +63 -16
- package/lib/verbs.mjs +26 -11
- package/lib/writes.mjs +11 -5
- package/local.mjs +52 -13
- package/package.json +2 -1
- package/skill/SKILL.md +5 -4
- package/skill/references/results.md +21 -1
package/lib/cli.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { closeSync, openSync } from "node:fs";
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { makeCode } from "./fresh.mjs";
|
|
5
6
|
import { homeOf, projectOf, readPending, readRunner, readToken, writePending, writeRunner } from "./home.mjs";
|
|
6
7
|
import { markRun, putBack, writesDirOf } from "./writes.mjs";
|
|
7
8
|
import { serveMcp } from "./mcp.mjs";
|
|
@@ -18,6 +19,9 @@ import { makeVerbs, READY_MS } from "./verbs.mjs";
|
|
|
18
19
|
const LOCAL = join(dirname(fileURLToPath(import.meta.url)), "..", "local.mjs");
|
|
19
20
|
const OURS = ["cortad.com", "brainsless.com", "brainsless-frontend.pages.dev"];
|
|
20
21
|
const WAITER = "run-when-up";
|
|
22
|
+
// How long `run` and `verify` wait for an app started again for a change. An MCP call is cut at
|
|
23
|
+
// about 60 seconds by Codex, Cursor CLI and Copilot CLI; a shell command is not.
|
|
24
|
+
const RESTART_MS = { mcp: 45_000, shell: 150_000 };
|
|
21
25
|
export const VERBS = ["status", "run", "run_status", "findings", "verify", "dispute", "field_connect", "field"];
|
|
22
26
|
export const COMMANDS = new Set(["mcp", ...VERBS, "run-status", "field-connect", "stick", "unstick", WAITER]);
|
|
23
27
|
|
|
@@ -44,6 +48,7 @@ export async function main(argv, { root = process.cwd(), env = process.env, stdo
|
|
|
44
48
|
token: () => readToken(project),
|
|
45
49
|
pending,
|
|
46
50
|
startApp: (args, kind) => startApp({ root, project, env, keep: mcp, children, args, kind }),
|
|
51
|
+
code: makeCode({ project, root, waitMs: mcp ? RESTART_MS.mcp : RESTART_MS.shell }),
|
|
47
52
|
});
|
|
48
53
|
|
|
49
54
|
if (command === WAITER) {
|
package/lib/data.mjs
CHANGED
|
@@ -58,7 +58,7 @@ const DEFAULTED = /(?:os\.(?:environ\.get|getenv)|\bgetenv)\(\s*['"]([A-Za-z_]\w
|
|
|
58
58
|
const OPENED = /(?:sqlite3\.connect|aiosqlite\.connect|duckdb\.connect|new\s+(?:sqlite3\.)?Database|create_(?:async_)?engine)\(\s*['"]([^'"\n]+)['"]/g;
|
|
59
59
|
const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|php)$/;
|
|
60
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$/;
|
|
61
|
+
export const TEST = /(?:^|\/)(?:tests?|__tests__|spec|e2e|fixtures?)\/|[._-](?:test|spec)\.[^/]+$|(?:^|\/)(?:test_[^/]+|conftest)\.py$/;
|
|
62
62
|
// A folder bigger than this is not copied: said instead, so the person decides.
|
|
63
63
|
const FOLDER_CAP = 1024 ** 3;
|
|
64
64
|
|
package/lib/fresh.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Which code a run plays. The command holding the app up (local.mjs) notes in app.json, each time
|
|
2
|
+
// the app starts, when it started, whether it started it, whether it reloads on save, and what each
|
|
3
|
+
// source file said then. Before a run is asked for, the verbs compare the files as they stand with
|
|
4
|
+
// that note: an app without a reloader is started again first, and the run's text says which code
|
|
5
|
+
// it played. yunqiao's `python run.py` served the old routes through a verify its agent had just
|
|
6
|
+
// fixed them for.
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { TEST } from "./data.mjs";
|
|
11
|
+
import { homeOf, readApp, readRunner } from "./home.mjs";
|
|
12
|
+
import { writesDirOf, writtenPaths } from "./writes.mjs";
|
|
13
|
+
|
|
14
|
+
// A command, or an app's own output, that says it picks a saved file up by itself. Not a compiler
|
|
15
|
+
// in watch mode (tsc --watch rebuilds, the server it feeds keeps running), and not vite, which
|
|
16
|
+
// reloads the page and never the server behind it.
|
|
17
|
+
export const RELOADER = new RegExp([
|
|
18
|
+
String.raw`--reload\b`, String.raw`\bnodemon\b`, String.raw`\bts-node-dev\b`, String.raw`\btsx\s+watch\b`,
|
|
19
|
+
String.raw`\b(?:node|tsx|bun|deno|nest)\b[^&|;\n]*\s--(?:watch|hot)\b`,
|
|
20
|
+
String.raw`\b(?:next|nuxt|astro|remix|fastapi)\s+dev\b`, String.raw`\brails\s+s(?:erver)?\b`,
|
|
21
|
+
String.raw`\bmanage\.py\s+runserver\b(?![^&|;\n]*--noreload)`, String.raw`\bflask\b[^&|;\n]*\s--(?:debug|reload)\b`,
|
|
22
|
+
String.raw`(?:^|[;&|\n]\s*)air\b`, String.raw`\bwatchexec\b`, String.raw`\bcargo[- ]watch\b`,
|
|
23
|
+
"Will watch for changes", "Started reloader process", "watching for file changes", String.raw`Restarting with (?:stat|watchdog|inotify|fsevents)`,
|
|
24
|
+
String.raw`\[nodemon\]`, "Restarting '", "WatchFiles detected changes", String.raw`Reloading\.\.\.`, "changed, reloading",
|
|
25
|
+
].join("|"), "i");
|
|
26
|
+
|
|
27
|
+
// `npm run dev` says nothing by itself; the script it names does, and the one that names.
|
|
28
|
+
export function commandText(cmd, dir) {
|
|
29
|
+
let scripts = {};
|
|
30
|
+
try { scripts = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).scripts ?? {}; } catch { /* not a Node app */ }
|
|
31
|
+
let text = String(cmd ?? "");
|
|
32
|
+
for (let i = 0, at = text; i < 3; i++) {
|
|
33
|
+
const name = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:.-]+)/.exec(at)?.[1];
|
|
34
|
+
if (!name || typeof scripts[name] !== "string") break;
|
|
35
|
+
at = scripts[name];
|
|
36
|
+
text += `\n${at}`;
|
|
37
|
+
}
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ps's etime, [[dd-]hh:]mm:ss, in milliseconds.
|
|
42
|
+
export function elapsedMs(etime) {
|
|
43
|
+
const [days, clock] = String(etime).includes("-") ? String(etime).split("-") : [0, String(etime)];
|
|
44
|
+
const [s = 0, m = 0, h = 0] = clock.split(":").map(Number).reverse();
|
|
45
|
+
return (((Number(days) * 24 + h) * 60 + m) * 60 + s) * 1000;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Not the app's code: this command's own line and hooks, the coding agents' folders, and tests.
|
|
49
|
+
const NOT_APP = /^(?:AGENTS|CLAUDE)\.md$|^\.github\/copilot-instructions\.md$|^\.(?:claude|codex|cursor|agents)\//;
|
|
50
|
+
const digestOf = (file) => { try { return createHash("sha1").update(readFileSync(file)).digest("hex"); } catch { return ""; } };
|
|
51
|
+
|
|
52
|
+
// What each source file says now. Names alone for an app this command did not start: what it
|
|
53
|
+
// loaded is unknown, so only a file's time can be compared.
|
|
54
|
+
export const sourceOf = (root, files, hash = true) =>
|
|
55
|
+
Object.fromEntries(files.filter((rel) => !NOT_APP.test(rel) && !TEST.test(rel)).map((rel) => [rel, hash ? digestOf(join(root, rel)) : ""]));
|
|
56
|
+
|
|
57
|
+
// The files that differ from what the app started with, newest first, and the newest change of
|
|
58
|
+
// all. A file rewritten with the bytes it had (a run's writes put back) has not changed.
|
|
59
|
+
export function changesOf({ root, app, skip = new Set() }) {
|
|
60
|
+
const since = Date.parse(app.startedAt);
|
|
61
|
+
const changed = [];
|
|
62
|
+
let last = null;
|
|
63
|
+
for (const [rel, was] of Object.entries(app.files ?? {})) {
|
|
64
|
+
if (skip.has(rel)) continue;
|
|
65
|
+
let at;
|
|
66
|
+
try { at = statSync(join(root, rel)).mtimeMs; } catch { continue; }
|
|
67
|
+
if (at > since && was && digestOf(join(root, rel)) === was) continue;
|
|
68
|
+
if (at > since) changed.push({ path: rel, at });
|
|
69
|
+
if (!last || at > last.at) last = { path: rel, at };
|
|
70
|
+
}
|
|
71
|
+
return { changed: changed.sort((a, b) => b.at - a.at), last };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// What the run's text says about the code it played (lib/text.mjs testedLine).
|
|
75
|
+
export const testedOf = (app, { changed, last }) => ({
|
|
76
|
+
startedAt: app.startedAt,
|
|
77
|
+
...(last ? { change: { path: last.path, at: new Date(last.at).toISOString() } } : {}),
|
|
78
|
+
...(app.reloads ? { reloads: true } : changed.length && !app.own ? { stale: true } : {}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// The verbs' side. `fresh` starts the app again through the process holding it up when its code
|
|
82
|
+
// changed and nothing reloaded it, and waits until it answers on the new code; `tested` is what
|
|
83
|
+
// the run is about to play.
|
|
84
|
+
export function makeCode({ project, root, waitMs, base, signal = (pid) => process.kill(pid, "SIGUSR2"), now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
85
|
+
const current = () => {
|
|
86
|
+
const app = readApp(project, base);
|
|
87
|
+
return app?.startedAt && app.runner === readRunner(project, base)?.pid ? app : null;
|
|
88
|
+
};
|
|
89
|
+
const changes = (app) => changesOf({ root, app, skip: writtenPaths(writesDirOf(homeOf(project, base))) });
|
|
90
|
+
const tested = () => { const app = current(); return app ? testedOf(app, changes(app)) : null; };
|
|
91
|
+
|
|
92
|
+
const fresh = async () => {
|
|
93
|
+
const app = current();
|
|
94
|
+
const { changed } = app ? changes(app) : { changed: [] };
|
|
95
|
+
if (!changed.length || !app.own || app.reloads) return {};
|
|
96
|
+
const asked = now();
|
|
97
|
+
try { signal(app.runner); } catch { return { error: "The process holding your app up could not be asked to start it again for your change." }; }
|
|
98
|
+
while (now() - asked < waitMs) {
|
|
99
|
+
await sleep(500);
|
|
100
|
+
const next = readApp(project, base);
|
|
101
|
+
if (next?.failedAt && Date.parse(next.failedAt) >= asked) return { error: `Your app was started again to play your change and did not come back. ${next.error ?? ""}`.trim() };
|
|
102
|
+
if (next?.startedAt && Date.parse(next.startedAt) >= asked) return { restarted: changed.map((c) => c.path) };
|
|
103
|
+
if (!readRunner(project, base)) return { error: "The process holding your app up ended while starting it again for your change." };
|
|
104
|
+
}
|
|
105
|
+
return { error: `Your app was started again to play your change and has not answered after ${Math.round(waitMs / 1000)} seconds.` };
|
|
106
|
+
};
|
|
107
|
+
return { fresh, tested };
|
|
108
|
+
}
|
package/lib/home.mjs
CHANGED
|
@@ -11,6 +11,7 @@ export const projectOf = (root) => createHash("sha256").update(realpathSync(root
|
|
|
11
11
|
export const homeOf = (project, base = join(homedir(), ".cortad")) => join(base, project);
|
|
12
12
|
|
|
13
13
|
const read = (file) => { try { return readFileSync(file, "utf8").trim() || null; } catch { return null; } };
|
|
14
|
+
const readJson = (file) => { const raw = read(file); try { return raw ? JSON.parse(raw) : null; } catch { return null; } };
|
|
14
15
|
const write = (dir, name, text) => { mkdirSync(dir, { recursive: true, mode: 0o700 }); writeFileSync(join(dir, name), text, { mode: 0o600 }); };
|
|
15
16
|
|
|
16
17
|
export const readToken = (project, base) => read(join(homeOf(project, base), "token"));
|
|
@@ -32,10 +33,12 @@ export function readRunner(project, base) {
|
|
|
32
33
|
export const writeRunner = (project, runner, base) => write(homeOf(project, base), "runner.json", JSON.stringify(runner));
|
|
33
34
|
export const clearRunner = (project, base) => { try { rmSync(join(homeOf(project, base), "runner.json")); } catch { /* gone */ } };
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
// The app that runner holds up, as it noted it last (lib/fresh.mjs): when it started, whether that
|
|
37
|
+
// runner started it, and what its source files said then.
|
|
38
|
+
export const readApp = (project, base) => readJson(join(homeOf(project, base), "app.json"));
|
|
39
|
+
export const writeApp = (project, app, base) => write(homeOf(project, base), "app.json", JSON.stringify(app));
|
|
40
|
+
|
|
41
|
+
export const readPending = (project, base) => readJson(join(homeOf(project, base), "pending.json"));
|
|
39
42
|
export const writePending = (project, pending, base) => write(homeOf(project, base), "pending.json", JSON.stringify(pending));
|
|
40
43
|
|
|
41
44
|
export const hasHome = (base = join(homedir(), ".cortad")) => existsSync(base);
|
package/lib/mcp.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export const TOOLS = [
|
|
|
30
30
|
description: "The failures of a run, worst first and grouped by file and line: the question, the replies it held in with the interval, quotes, the trials a verify replays and whether a verify of it can leave the noise. Applies once a run has finished. A long list comes in pages.",
|
|
31
31
|
inputSchema: { type: "object", properties: { jobId: str("A run id. Omit for the latest."), page: { type: "integer", minimum: 1, description: "The page to read. Default 1." } }, additionalProperties: false } },
|
|
32
32
|
{ name: "verify", title: "Verify a fix",
|
|
33
|
-
description: "Replays every trial the finding's question was read on, with the same seeds, after a change: what came back before and now, the move visible and held-out apart, and whether the evidence supports keeping the fix. Answers within a second; run_status follows it. Uses verify trials from the plan, not a run.",
|
|
33
|
+
description: "Replays every trial the finding's question was read on, with the same seeds, after a change, and when those read it too few times for a rate, plays up to 12 more trials in the finding's situation: what came back before and now, the move visible and held-out apart (reply by reply, or the question's failure rate on the same trials where the replies cannot be paired), and whether the evidence supports keeping the fix. Answers within a second and says how many new trials it plays; run_status follows it. Uses verify trials from the plan, not a run.",
|
|
34
34
|
inputSchema: { type: "object", properties: { findingId: str("The finding id from findings."), jobId: str("The run the finding came from. Omit for the latest.") }, required: ["findingId"], additionalProperties: false } },
|
|
35
35
|
{ name: "dispute", title: "Dispute a check",
|
|
36
36
|
description: "Records that a finding's check reads this app wrong, with the reason and an optional better wording. The finding and its rate stay as they are; the note goes to the owner.",
|
|
@@ -579,7 +579,7 @@ def _install():
|
|
|
579
579
|
# person's latest message on, and Anthropic document and search_result blocks. The app's own
|
|
580
580
|
# instructions are not passages: only what sits under such a label is.
|
|
581
581
|
passage_text, passages_max = 3000, 6
|
|
582
|
-
material = re.compile(r"\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq)\b
|
|
582
|
+
material = re.compile(r"\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq|relevant)\b|检索|知识|参考资料|资料|上下文|文档|背景|相关", re.I)
|
|
583
583
|
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*$")
|
|
584
584
|
# An inline label is a noun phrase that ends in the material word ("context:", "background:",
|
|
585
585
|
# "retrieved passages:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
|
|
@@ -587,6 +587,36 @@ def _install():
|
|
|
587
587
|
named = re.compile(r"(?:" + material.pattern + r")\s*$", re.I)
|
|
588
588
|
tagged = re.compile(r"<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)</\1>")
|
|
589
589
|
|
|
590
|
+
# A label's block runs on to the next heading of its own kind: "资料:" over retrieved chunks
|
|
591
|
+
# that open on markdown headings holds every chunk, where stopping at its first paragraph kept
|
|
592
|
+
# one chunk of five and the reader called the knowledge base's own 1% fee made up. A markdown
|
|
593
|
+
# label holds deeper headings. A closing paragraph that opens on a label of its own ("问题:...",
|
|
594
|
+
# "Question: ...") is the prompt's ask, not material. A long block is several passages, cut
|
|
595
|
+
# between its paragraphs.
|
|
596
|
+
markdown = re.compile(r"^\s*(#{1,6})\s")
|
|
597
|
+
|
|
598
|
+
def ends_block(label, line):
|
|
599
|
+
if not heading.match(line):
|
|
600
|
+
return False
|
|
601
|
+
outer, inner = markdown.match(label), markdown.match(line)
|
|
602
|
+
return not inner or bool(outer and len(inner.group(1)) <= len(outer.group(1)))
|
|
603
|
+
|
|
604
|
+
def asks(para):
|
|
605
|
+
m = inline.match(para.split("\n")[0])
|
|
606
|
+
return bool(m and not named.search(m.group(1)))
|
|
607
|
+
|
|
608
|
+
# A heading inside a fenced block is the material's own text: a README chunk's "## Features".
|
|
609
|
+
fence = re.compile(r"^\s*(?:```|~~~)", re.M)
|
|
610
|
+
|
|
611
|
+
def add_block(add, name, paras):
|
|
612
|
+
text = ""
|
|
613
|
+
for p in paras:
|
|
614
|
+
if text and len(text) + len(p) + 2 > passage_text:
|
|
615
|
+
add(name, text)
|
|
616
|
+
text = ""
|
|
617
|
+
text += ("\n\n" if text else "") + p
|
|
618
|
+
add(name, text)
|
|
619
|
+
|
|
590
620
|
def labelled(text, add):
|
|
591
621
|
def lift(m):
|
|
592
622
|
if material.search(m.group(1).replace("_", " ")):
|
|
@@ -599,13 +629,14 @@ def _install():
|
|
|
599
629
|
lines = paras[i].split("\n")
|
|
600
630
|
head = lines[0]
|
|
601
631
|
if heading.match(head) and material.search(head):
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
632
|
+
block = ["\n".join(lines[1:])]
|
|
633
|
+
fenced = len(fence.findall(block[0])) % 2 == 1
|
|
634
|
+
while i + 1 < len(paras) and (fenced or (not ends_block(head, paras[i + 1].split("\n")[0]) and not (i + 2 == len(paras) and asks(paras[i + 1])))):
|
|
635
|
+
i += 1
|
|
636
|
+
block.append(paras[i])
|
|
637
|
+
if len(fence.findall(paras[i])) % 2 == 1:
|
|
638
|
+
fenced = not fenced
|
|
639
|
+
add_block(add, re.sub(r"[#::\[\]=]", "", head).strip(), block)
|
|
609
640
|
else:
|
|
610
641
|
label = next((m for m in (inline.match(l) for l in lines) if m and named.search(m.group(1))), None)
|
|
611
642
|
if label:
|
|
@@ -855,7 +886,76 @@ def _install():
|
|
|
855
886
|
except Exception:
|
|
856
887
|
return ""
|
|
857
888
|
|
|
858
|
-
|
|
889
|
+
# Which of the app's own lines made a model call, nearest first: the run names the code path that
|
|
890
|
+
# ran by it, where the read could only name the door. Its frames on this stack outside installed
|
|
891
|
+
# packages, then, when the call runs in a task another task awaits (LangChain runs each step of a
|
|
892
|
+
# chain as its own task), the awaiting task's. Relative to the folder the app was started in.
|
|
893
|
+
root = os.getcwd().replace(os.sep, "/").rstrip("/") + "/"
|
|
894
|
+
installed = re.compile(r"/(?:site-packages|dist-packages|\.venv|venv|__pypackages__)/|/lib/python\d")
|
|
895
|
+
|
|
896
|
+
def ours(frame, out):
|
|
897
|
+
f = frame.f_code.co_filename.replace(os.sep, "/")
|
|
898
|
+
if f.startswith(root) and not installed.search(f[len(root) - 1:]):
|
|
899
|
+
at = "%s:%d" % (f[len(root):], frame.f_lineno)
|
|
900
|
+
if at not in out:
|
|
901
|
+
out.append(at)
|
|
902
|
+
return len(out) >= 3
|
|
903
|
+
|
|
904
|
+
# The task that waits on this one: its wake-up is among this one's callbacks, directly, or behind
|
|
905
|
+
# the future an asyncio.gather callback closes over.
|
|
906
|
+
def awaiting(fut, depth=0):
|
|
907
|
+
for cb in getattr(fut, "_callbacks", None) or ():
|
|
908
|
+
fn = cb[0] if isinstance(cb, tuple) else cb
|
|
909
|
+
owner = getattr(fn, "__self__", None)
|
|
910
|
+
if owner is not None and owner is not fut and hasattr(owner, "get_coro"):
|
|
911
|
+
return owner
|
|
912
|
+
for cell in (getattr(fn, "__closure__", None) or ()) if depth < 2 else ():
|
|
913
|
+
try:
|
|
914
|
+
inner = cell.cell_contents
|
|
915
|
+
except ValueError:
|
|
916
|
+
continue
|
|
917
|
+
found = awaiting(inner, depth + 1) if inner is not fut and hasattr(inner, "_callbacks") else None
|
|
918
|
+
if found is not None:
|
|
919
|
+
return found
|
|
920
|
+
return None
|
|
921
|
+
|
|
922
|
+
# An embedding call answers nobody, so where it was made names no path a reply came down.
|
|
923
|
+
embedding = re.compile(r"/embeddings$", re.I)
|
|
924
|
+
|
|
925
|
+
def callers_now(url):
|
|
926
|
+
if embedding.search(urlsplit(str(url)).path or ""):
|
|
927
|
+
return None
|
|
928
|
+
out = []
|
|
929
|
+
try:
|
|
930
|
+
# Below the event loop's own frames is whatever started the loop, not this call's path.
|
|
931
|
+
frame = sys._getframe(1)
|
|
932
|
+
while frame is not None and not frame.f_code.co_filename.replace(os.sep, "/").endswith("/asyncio/events.py"):
|
|
933
|
+
if ours(frame, out):
|
|
934
|
+
return out
|
|
935
|
+
frame = frame.f_back
|
|
936
|
+
aio = sys.modules.get("asyncio")
|
|
937
|
+
try:
|
|
938
|
+
task = aio.current_task() if aio else None
|
|
939
|
+
except RuntimeError:
|
|
940
|
+
task = None
|
|
941
|
+
for _ in range(8):
|
|
942
|
+
task = awaiting(task) if task is not None else None
|
|
943
|
+
if task is None:
|
|
944
|
+
break
|
|
945
|
+
chain, coro = [], task.get_coro()
|
|
946
|
+
while coro is not None and len(chain) < 64:
|
|
947
|
+
f = getattr(coro, "cr_frame", None) or getattr(coro, "gi_frame", None) or getattr(coro, "ag_frame", None)
|
|
948
|
+
if f is not None:
|
|
949
|
+
chain.append(f)
|
|
950
|
+
coro = getattr(coro, "cr_await", None) or getattr(coro, "gi_yieldfrom", None) or getattr(coro, "ag_await", None)
|
|
951
|
+
for f in reversed(chain):
|
|
952
|
+
if ours(f, out):
|
|
953
|
+
return out
|
|
954
|
+
except Exception:
|
|
955
|
+
pass
|
|
956
|
+
return out or None
|
|
957
|
+
|
|
958
|
+
def meter(url, sent, status, kind, raw, turn=None, caller=None):
|
|
859
959
|
try:
|
|
860
960
|
tokens, model, events = read_reply(kind, (raw or "")[:reply_max])
|
|
861
961
|
parts = urlsplit(str(url))
|
|
@@ -878,6 +978,8 @@ def _install():
|
|
|
878
978
|
passages = passages_in(as_text)
|
|
879
979
|
if passages:
|
|
880
980
|
row["passages"] = passages
|
|
981
|
+
if caller:
|
|
982
|
+
row["caller"] = caller
|
|
881
983
|
write({"call": row})
|
|
882
984
|
except Exception:
|
|
883
985
|
pass
|
|
@@ -1425,7 +1527,7 @@ def _install():
|
|
|
1425
1527
|
finally:
|
|
1426
1528
|
self.finish()
|
|
1427
1529
|
|
|
1428
|
-
def watch(request, response, sent):
|
|
1530
|
+
def watch(request, response, sent, caller):
|
|
1429
1531
|
model = is_model_call(request.url)
|
|
1430
1532
|
if not model and not is_retrieval(request.url):
|
|
1431
1533
|
dep(request.url, response.status_code)
|
|
@@ -1433,13 +1535,13 @@ def _install():
|
|
|
1433
1535
|
kind = response.headers.get("content-type", "")
|
|
1434
1536
|
if getattr(response, "_content", None) is not None:
|
|
1435
1537
|
if model:
|
|
1436
|
-
meter(request.url, sent, response.status_code, kind, response.content.decode("utf-8", "replace"))
|
|
1538
|
+
meter(request.url, sent, response.status_code, kind, response.content.decode("utf-8", "replace"), caller=caller)
|
|
1437
1539
|
else:
|
|
1438
1540
|
dep(request.url, response.status_code, passages=passages_from(response.content.decode("utf-8", "replace")))
|
|
1439
1541
|
return response
|
|
1440
1542
|
turn = turn_now()
|
|
1441
1543
|
if model:
|
|
1442
|
-
done = lambda raw: meter(request.url, sent, response.status_code, kind, unpacked(raw, response.headers.get("content-encoding")), turn)
|
|
1544
|
+
done = lambda raw: meter(request.url, sent, response.status_code, kind, unpacked(raw, response.headers.get("content-encoding")), turn, caller)
|
|
1443
1545
|
else:
|
|
1444
1546
|
done = lambda raw: dep(request.url, response.status_code, passages=passages_from(unpacked(raw, response.headers.get("content-encoding"))), turn=turn)
|
|
1445
1547
|
stream = response.stream
|
|
@@ -1448,7 +1550,7 @@ def _install():
|
|
|
1448
1550
|
elif hasattr(stream, "__iter__"):
|
|
1449
1551
|
response.stream = SyncKept(stream, done)
|
|
1450
1552
|
elif model:
|
|
1451
|
-
meter(request.url, sent, response.status_code, kind, "")
|
|
1553
|
+
meter(request.url, sent, response.status_code, kind, "", caller=caller)
|
|
1452
1554
|
else:
|
|
1453
1555
|
dep(request.url, response.status_code)
|
|
1454
1556
|
return response
|
|
@@ -1458,6 +1560,7 @@ def _install():
|
|
|
1458
1560
|
if cls is module.Client:
|
|
1459
1561
|
def sync_send(self, request, *a, _send=send, **k):
|
|
1460
1562
|
sent = sent_of(request)
|
|
1563
|
+
caller = callers_now(request.url) if is_model_call(request.url) else None
|
|
1461
1564
|
try:
|
|
1462
1565
|
note(request.url, sent)
|
|
1463
1566
|
except Exception:
|
|
@@ -1466,18 +1569,19 @@ def _install():
|
|
|
1466
1569
|
response = _send(self, request, *a, **k)
|
|
1467
1570
|
except Exception as err:
|
|
1468
1571
|
if is_model_call(request.url):
|
|
1469
|
-
meter(request.url, sent, 0, "", "")
|
|
1572
|
+
meter(request.url, sent, 0, "", "", caller=caller)
|
|
1470
1573
|
else:
|
|
1471
1574
|
dep(request.url, 0, type(err).__name__)
|
|
1472
1575
|
raise
|
|
1473
1576
|
try:
|
|
1474
|
-
return watch(request, response, sent)
|
|
1577
|
+
return watch(request, response, sent, caller)
|
|
1475
1578
|
except Exception:
|
|
1476
1579
|
return response
|
|
1477
1580
|
cls.send = sync_send
|
|
1478
1581
|
else:
|
|
1479
1582
|
async def async_send(self, request, *a, _send=send, **k):
|
|
1480
1583
|
sent = sent_of(request)
|
|
1584
|
+
caller = callers_now(request.url) if is_model_call(request.url) else None
|
|
1481
1585
|
try:
|
|
1482
1586
|
note(request.url, sent)
|
|
1483
1587
|
except Exception:
|
|
@@ -1486,12 +1590,12 @@ def _install():
|
|
|
1486
1590
|
response = await _send(self, request, *a, **k)
|
|
1487
1591
|
except Exception as err:
|
|
1488
1592
|
if is_model_call(request.url):
|
|
1489
|
-
meter(request.url, sent, 0, "", "")
|
|
1593
|
+
meter(request.url, sent, 0, "", "", caller=caller)
|
|
1490
1594
|
else:
|
|
1491
1595
|
dep(request.url, 0, type(err).__name__)
|
|
1492
1596
|
raise
|
|
1493
1597
|
try:
|
|
1494
|
-
return watch(request, response, sent)
|
|
1598
|
+
return watch(request, response, sent, caller)
|
|
1495
1599
|
except Exception:
|
|
1496
1600
|
return response
|
|
1497
1601
|
cls.send = async_send
|
|
@@ -1500,6 +1604,7 @@ def _install():
|
|
|
1500
1604
|
send = module.Session.send
|
|
1501
1605
|
|
|
1502
1606
|
def sent(self, request, *a, **k):
|
|
1607
|
+
caller = callers_now(request.url) if is_model_call(request.url) else None
|
|
1503
1608
|
try:
|
|
1504
1609
|
note(request.url, request.body)
|
|
1505
1610
|
except Exception:
|
|
@@ -1517,7 +1622,7 @@ def _install():
|
|
|
1517
1622
|
if is_model_call(request.url):
|
|
1518
1623
|
# A streamed reply is counted as a call whose counts were not read.
|
|
1519
1624
|
raw = response.content.decode("utf-8", "replace") if getattr(response, "_content_consumed", False) else ""
|
|
1520
|
-
meter(request.url, text(request.body), response.status_code, response.headers.get("content-type", ""), raw)
|
|
1625
|
+
meter(request.url, text(request.body), response.status_code, response.headers.get("content-type", ""), raw, caller=caller)
|
|
1521
1626
|
except Exception:
|
|
1522
1627
|
pass
|
|
1523
1628
|
return response
|
|
@@ -1540,7 +1645,7 @@ def _install():
|
|
|
1540
1645
|
dep(str_or_url, 0, type(err).__name__)
|
|
1541
1646
|
raise
|
|
1542
1647
|
if is_model_call(str_or_url):
|
|
1543
|
-
response._cortad = (str_or_url, text(body), turn_now())
|
|
1648
|
+
response._cortad = (str_or_url, text(body), turn_now(), callers_now(str_or_url))
|
|
1544
1649
|
elif is_retrieval(str_or_url):
|
|
1545
1650
|
response._cortad_dep = (str_or_url, turn_now())
|
|
1546
1651
|
else:
|
|
@@ -1559,7 +1664,7 @@ def _install():
|
|
|
1559
1664
|
call = getattr(self, "_cortad", None)
|
|
1560
1665
|
if call:
|
|
1561
1666
|
self._cortad = None
|
|
1562
|
-
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"), call[2])
|
|
1667
|
+
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), (raw or b"").decode("utf-8", "replace"), call[2], call[3])
|
|
1563
1668
|
return raw
|
|
1564
1669
|
|
|
1565
1670
|
def release_kept(self, *a, **k):
|
|
@@ -1570,7 +1675,7 @@ def _install():
|
|
|
1570
1675
|
call = getattr(self, "_cortad", None)
|
|
1571
1676
|
if call:
|
|
1572
1677
|
self._cortad = None
|
|
1573
|
-
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "", call[2])
|
|
1678
|
+
meter(call[0], call[1], self.status, self.headers.get("content-type", ""), "", call[2], call[3])
|
|
1574
1679
|
return release(self, *a, **k)
|
|
1575
1680
|
|
|
1576
1681
|
module.ClientSession._request = requested
|
package/lib/replay.mjs
CHANGED
|
@@ -121,6 +121,9 @@ const calledOf = (v) => {
|
|
|
121
121
|
return called.length ? { called } : {};
|
|
122
122
|
};
|
|
123
123
|
const passagesOf = (v) => (texts(v).length ? { passages: texts(v).slice(0, 6) } : {});
|
|
124
|
+
// Which of the app's own lines made the call, nearest first, as "path:line".
|
|
125
|
+
const AT = /^[^\n\r\t]{1,300}:\d{1,7}$/;
|
|
126
|
+
const callerOf = (v) => { const caller = Array.isArray(v) ? v.filter((c) => typeof c === "string" && AT.test(c)).slice(0, 3) : []; return caller.length ? { caller } : {}; };
|
|
124
127
|
const meter = (() => {
|
|
125
128
|
const rows = [];
|
|
126
129
|
const deps = [];
|
|
@@ -131,7 +134,7 @@ const meter = (() => {
|
|
|
131
134
|
rows.push({
|
|
132
135
|
at: count(call.at), host: call.host.slice(0, 253), model: String(call.model ?? "").slice(0, 160), status: count(call.status),
|
|
133
136
|
promptTokens: count(call.promptTokens), cachedTokens: count(call.cachedTokens), completionTokens: count(call.completionTokens),
|
|
134
|
-
usage: call.usage === true, ...turnOf(call.turn), ...rulesOf(call.rules), ...toolsOf(call.tools), ...calledOf(call.called), ...passagesOf(call.passages),
|
|
137
|
+
usage: call.usage === true, ...turnOf(call.turn), ...rulesOf(call.rules), ...toolsOf(call.tools), ...calledOf(call.called), ...passagesOf(call.passages), ...callerOf(call.caller),
|
|
135
138
|
});
|
|
136
139
|
if (rows.length > ROWS) rows.splice(0, rows.length - ROWS);
|
|
137
140
|
},
|
|
@@ -159,7 +162,7 @@ const meter = (() => {
|
|
|
159
162
|
}
|
|
160
163
|
return {
|
|
161
164
|
totals: [...totals.values()].sort((a, b) => b.calls - a.calls),
|
|
162
|
-
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 } : {}) })),
|
|
165
|
+
rows: rows.slice(-400).reverse().map(({ at, host, model, status, turn, rules, tools, called, passages, caller }) => ({ at, host, model, status, ...(turn ? { turn } : {}), ...(rules ? { rules } : {}), ...(tools ? { tools } : {}), ...(called ? { called } : {}), ...(passages ? { passages } : {}), ...(caller ? { caller } : {}) })),
|
|
163
166
|
deps: deps.slice(-400).reverse(),
|
|
164
167
|
};
|
|
165
168
|
},
|
package/lib/text.mjs
CHANGED
|
@@ -68,7 +68,10 @@ function scoreText(d, of) {
|
|
|
68
68
|
const heldBackLine = (h) => h.why === "not testable here"
|
|
69
69
|
? `Held back: ${plural(h.trials, "trial")} cannot be played here: ${clip(h.surface, 200).replace(/\.$/, "")}.`
|
|
70
70
|
: `Held back: ${plural(h.trials, "trial")} on ${h.surface}${h.why ? `, ${clip(h.why, 200)}` : ""}.`;
|
|
71
|
-
const haltLine = (h) => `${h.side === "ours" ?
|
|
71
|
+
const haltLine = (h) => `${h.side === "ours" ? `Stopped ${SIDE.ours}` : "Your app stopped answering"} after ${plural(h.after, "turn")}${h.why ? `: ${clip(h.why, 240)}` : ""}. The score covers the turns before that.`;
|
|
72
|
+
// The pace a slow app was played at, and the replies our own wait cut off: both said as ours. A
|
|
73
|
+
// verify says the second in its own paired line.
|
|
74
|
+
const waitLines = (d) => [d.pace?.said, d.kind === "verify" ? null : d.waited?.said].filter(Boolean);
|
|
72
75
|
const LAYER_WORDS = {
|
|
73
76
|
retrieval: "what the store returned behind the reply, not how the reply was written",
|
|
74
77
|
tools: "the tool call behind the reply",
|
|
@@ -82,8 +85,10 @@ function runLines(d, prefix = "") {
|
|
|
82
85
|
const done = finished(d);
|
|
83
86
|
const played = d.of ? `${num(d.played ?? 0)} of ${plural(d.of, "trial")} played` : "no trial played yet";
|
|
84
87
|
const lines = [`${upper(`${prefix}${d.kind === "verify" ? "verify" : "run"}`)} ${d.jobId}: ${d.status === "succeeded" || !d.status ? (done ? "finished" : "running") : d.status}, ${played}.`];
|
|
88
|
+
if (d.tested) lines.push(testedLine(d.tested));
|
|
85
89
|
// Between two counts, what the run is doing now: four minutes of "0 of 15" read as nothing happening.
|
|
86
90
|
if (!done && d.now?.doing) lines.push(d.now.doing);
|
|
91
|
+
lines.push(...waitLines(d));
|
|
87
92
|
// A verify is a move, not a score: "100 of 100" on a replay whose run before decided nothing read
|
|
88
93
|
// as a clean bill to bloom's agent, beside "zero verified fixes".
|
|
89
94
|
if (done && d.kind !== "verify") lines.push(...measuredLines(d));
|
|
@@ -141,24 +146,40 @@ function stoppedLine(d) {
|
|
|
141
146
|
|
|
142
147
|
const moveText = (m) => `held ${kn(m.before)} readings before, ${kn(m.after)} after; ${m.moved}, ${signed(m.point)} points${interval(m)}${m.insideNoise ? ", inside the noise" : ""}.`;
|
|
143
148
|
|
|
149
|
+
// Where the replies did not pair, the question's rate on the same trials before and after, with the
|
|
150
|
+
// trials the verify played new in the situation and the readings nobody could settle said apart.
|
|
151
|
+
function rateText(r) {
|
|
152
|
+
const unclear = [r.before.unclear && `${num(r.before.unclear)} unclear before`, r.after.unclear && `${num(r.after.unclear)} unclear after`].filter(Boolean).map((u) => `, ${u}`).join("");
|
|
153
|
+
const over = r.trials?.widened ? `, over ${plural(r.trials.replayed, "trial")} replayed and ${num(r.trials.widened)} new` : "";
|
|
154
|
+
const verdict = r.thin ? `too few to state a rate (${num(r.after.settled)} of the 22 it needs)` : `${r.moved}, ${signed(r.point)} points${interval(r)}${r.insideNoise ? ", inside the noise" : ""}`;
|
|
155
|
+
return `failed on ${num(r.before.failed)} of ${num(r.before.settled)} settled readings before, ${num(r.after.failed)} of ${num(r.after.settled)} after${unclear}${over}; ${verdict}.`;
|
|
156
|
+
}
|
|
157
|
+
const sideText = (m) => (m.rate ? rateText(m.rate) : m.unavailable ? null : moveText(m));
|
|
158
|
+
|
|
144
159
|
// Over zero pairs there is no move to print: the line says verification is unavailable and why,
|
|
145
160
|
// never "no change, 0 points" measured on nothing. The visible side's reason is the said line.
|
|
146
161
|
function verifyLines(v) {
|
|
147
|
-
// An access verify is a re-knock, not a trial move: the routes side by side, then the count.
|
|
162
|
+
// An access verify is a re-knock, not a trial move: the routes side by side, then the count. Only
|
|
163
|
+
// a route knocked and answered both times is compared; one the app no longer has or did not answer
|
|
164
|
+
// is named as such, never read as a closed gate.
|
|
148
165
|
if (v.access) {
|
|
149
166
|
const lines = [`Verify of ${v.findingId}.`];
|
|
150
167
|
for (const r of v.routes ?? []) {
|
|
151
|
-
const then = r.wasOpen ? `answered ${r.before} with no sign-in` : `refused (${r.before
|
|
152
|
-
const now =
|
|
153
|
-
|
|
168
|
+
const then = r.wasOpen ? `answered ${r.before} with no sign-in` : `refused (${r.before})`;
|
|
169
|
+
const now =
|
|
170
|
+
r.state === "answered" ? (r.nowOpen ? `now answers ${r.after} with no sign-in` : `now refuses (${r.after})`)
|
|
171
|
+
: r.state === "gone" ? "your app no longer has this route"
|
|
172
|
+
: r.state === "no-answer" ? "your app did not answer this time"
|
|
173
|
+
: "was not knocked again";
|
|
174
|
+
lines.push(` ${r.method} ${r.route}: ${then}, ${now}.`);
|
|
154
175
|
}
|
|
155
176
|
if (v.said) lines.push(v.said);
|
|
156
177
|
return lines;
|
|
157
178
|
}
|
|
158
179
|
const lines = [`Verify of ${v.findingId}${v.path ? ` at ${at(v.path, v.line)}` : ""}.`];
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
|
|
180
|
+
const visible = v.visible && sideText(v.visible);
|
|
181
|
+
if (visible) lines.push(`Visible trials: ${visible}`);
|
|
182
|
+
if (v.holdout) lines.push(`Held-out trials: ${sideText(v.holdout) ?? `verification unavailable: ${v.holdout.unavailable}.`}`);
|
|
162
183
|
else if (v.holdout === null) lines.push("Held-out trials: no pair for this question.");
|
|
163
184
|
if (v.overfit) lines.push("Overfit: the visible trials moved and the held-out trials did not.");
|
|
164
185
|
if (v.said) lines.push(v.said);
|
|
@@ -177,10 +198,23 @@ export function runText(d) {
|
|
|
177
198
|
return lines.join("\n");
|
|
178
199
|
}
|
|
179
200
|
|
|
180
|
-
export function startedText(d, kind, findingId) {
|
|
201
|
+
export function startedText(d, kind, findingId, said = []) {
|
|
181
202
|
const verify = (d.kind ?? kind) === "verify";
|
|
182
203
|
const what = verify ? `Verify${findingId && !d.joined ? ` of ${findingId}` : ""}` : "Run";
|
|
183
|
-
return `${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}
|
|
204
|
+
return [`${what} ${d.joined ? "already playing" : "started"}: ${d.jobId}.`, ...said, d.note, `next: run_status ${d.jobId}`].filter(Boolean).join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const restartedText = (paths) =>
|
|
208
|
+
`Your app was started again so this run plays your change (${plural(paths.length, "file")} changed since it started: ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ", ..." : ""}).`;
|
|
209
|
+
|
|
210
|
+
// Which code the run played: the app's start against the last change to its source (lib/fresh.mjs).
|
|
211
|
+
const clock = (iso) => new Date(iso).toTimeString().slice(0, 8);
|
|
212
|
+
export function testedLine(t) {
|
|
213
|
+
const started = clock(t.startedAt);
|
|
214
|
+
if (t.stale) return `Your app was already running when this command started and has been running since ${started}; ${t.change.path} changed at ${clock(t.change.at)}, so this run may have played the old code. Stop your app and run the command again.`;
|
|
215
|
+
if (!t.change) return `App started ${started}.`;
|
|
216
|
+
if (Date.parse(t.change.at) <= Date.parse(t.startedAt)) return `App started ${started}, after your last change at ${clock(t.change.at)}.`;
|
|
217
|
+
return `App started ${started}${t.reloads ? " and reloads itself on save" : ""}; your last change was at ${clock(t.change.at)}.`;
|
|
184
218
|
}
|
|
185
219
|
|
|
186
220
|
export const waitingText = (p, now, limitMs) =>
|
|
@@ -237,9 +271,11 @@ export const findingsText = (d, page = 1) => pageOf(findingsPages(d), page, (n)
|
|
|
237
271
|
function findingsHead(d) {
|
|
238
272
|
if (!d.runId) return ["No run has finished on this repository yet."];
|
|
239
273
|
const lines = [`Run ${d.runId}: ${plural(d.findings?.length ?? 0, "finding")}.`];
|
|
274
|
+
if (d.tested) lines.push(testedLine(d.tested));
|
|
240
275
|
lines.push(...measuredLines(d));
|
|
241
276
|
if (d.blocked) lines.push(d.blocked);
|
|
242
277
|
if (d.halted) lines.push(haltLine(d.halted));
|
|
278
|
+
lines.push(...waitLines(d));
|
|
243
279
|
const reads = readingsLine(d);
|
|
244
280
|
if (reads) lines.push(reads);
|
|
245
281
|
if (d.baseRunId) lines.push(`Compared with run ${d.baseRunId}.`);
|
|
@@ -325,6 +361,9 @@ function findingBlock(f, placed) {
|
|
|
325
361
|
// investigation itself.
|
|
326
362
|
if (q.asked) lines.push(` Asked: "${clip(q.asked, 300)}"`);
|
|
327
363
|
lines.push(` Reply ${q.reply}: "${clip(q.quote, 400)}"${about ? ` (${about})` : ""}`);
|
|
364
|
+
// What the reader checked the reply against: what their app retrieved for it, the start of each.
|
|
365
|
+
if (q.checked) lines.push(` ${q.checked}`);
|
|
366
|
+
for (const p of (q.passages ?? []).slice(0, 3)) lines.push(` Passage${p.source ? ` (${clip(p.source, 60)})` : ""}: "${clip(p.text, 240)}"`);
|
|
328
367
|
// What a developer reproduces it from: the request as sent, the body back, the app's own log.
|
|
329
368
|
if (q.request) lines.push(` Request: ${q.request.method} ${q.request.path}${q.request.body ? ` ${clip(q.request.body, 600)}` : ""}`);
|
|
330
369
|
if (has(q.response)) lines.push(` Response: ${q.response ? clip(q.response, 600) : "(empty body)"}`);
|
package/lib/trace.cjs
CHANGED
|
@@ -119,15 +119,33 @@ if (FILE) {
|
|
|
119
119
|
.replace(/\*(\w+)/g, "{$1}");
|
|
120
120
|
const joined = (a, b) => ("/" + [a, b].map((s) => String(s).replace(/^\/+|\/+$/g, "")).filter(Boolean).join("/"));
|
|
121
121
|
const nodePath = require("node:path");
|
|
122
|
+
// The frames of the stack it is called from, nearest first, outside this hook and node_modules,
|
|
123
|
+
// relative to the folder the app was started in. An awaited call keeps its awaiting frames in
|
|
124
|
+
// the stack, and an SDK is deeper than the default ten frames.
|
|
125
|
+
const stackFrames = () => {
|
|
126
|
+
const limit = Error.stackTraceLimit;
|
|
127
|
+
Error.stackTraceLimit = 60;
|
|
128
|
+
const stack = String(new Error().stack);
|
|
129
|
+
Error.stackTraceLimit = limit;
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const line of stack.split("\n")) {
|
|
132
|
+
const m = /\(?(?:file:\/\/)?(\/[^()]+?):(\d+):\d+\)?$/.exec(line.trim());
|
|
133
|
+
if (m && m[1] !== __filename && !m[1].includes("/node_modules/")) out.push({ file: nodePath.relative(process.cwd(), decodeURI(m[1])), line: Number(m[2]) });
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
};
|
|
122
137
|
// The file that registered a route: the first frame outside this hook and outside node_modules.
|
|
123
|
-
const callerFile = () => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
138
|
+
const callerFile = () => { const f = stackFrames()[0]; return f && !f.file.startsWith("..") ? f.file : ""; };
|
|
139
|
+
// Which of the app's own lines made a model call, nearest first: the run names the code path that
|
|
140
|
+
// ran by it, where the read could only name the door.
|
|
141
|
+
const callers = () => {
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const f of stackFrames()) {
|
|
144
|
+
const at = `${f.file}:${f.line}`;
|
|
145
|
+
if (!f.file.startsWith("..") && !out.includes(at)) out.push(at);
|
|
146
|
+
if (out.length === 3) break;
|
|
129
147
|
}
|
|
130
|
-
return
|
|
148
|
+
return out.length ? out : undefined;
|
|
131
149
|
};
|
|
132
150
|
const seen = new Set();
|
|
133
151
|
let nest = false;
|
|
@@ -561,13 +579,36 @@ if (FILE) {
|
|
|
561
579
|
// person's latest message on, and Anthropic document and search_result blocks. The app's own
|
|
562
580
|
// instructions are not passages: only what sits under such a label is.
|
|
563
581
|
const PASSAGE_TEXT = 3000, PASSAGES_MAX = 6;
|
|
564
|
-
const MATERIAL = /\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq)\b
|
|
582
|
+
const MATERIAL = /\b(?:retriev\w*|context|knowledge|documents?|sources?|search[ _-]?results?|references?|passages?|excerpts?|snippets?|chunks?|background|faq|relevant)\b|检索|知识|参考资料|资料|上下文|文档|背景|相关/i;
|
|
565
583
|
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*$/;
|
|
566
584
|
// An inline label is a noun phrase that ends in the material word ("context:", "background:",
|
|
567
585
|
// "retrieved passages:"): "4. Knowledge:" in a numbered instruction and a JSON key are not.
|
|
568
586
|
const INLINE = /^\s*(?:[-*]\s+)?([A-Za-z\u4e00-\u9fff][A-Za-z\u4e00-\u9fff '’_-]{0,29})[::]\s*\S/;
|
|
569
587
|
const NAMED = new RegExp(`(?:${MATERIAL.source})\\s*$`, "i");
|
|
570
588
|
const TAGGED = /<([A-Za-z][\w-]*)[^>]*>([\s\S]*?)<\/\1>/g;
|
|
589
|
+
// A label's block runs on to the next heading of its own kind: "资料:" over retrieved chunks
|
|
590
|
+
// that open on markdown headings holds every chunk, where stopping at its first paragraph kept
|
|
591
|
+
// one chunk of five and the reader called the knowledge base's own 1% fee made up. A markdown
|
|
592
|
+
// label holds deeper headings. A closing paragraph that opens on a label of its own ("问题:...",
|
|
593
|
+
// "Question: ...") is the prompt's ask, not material. A long block is several passages, cut
|
|
594
|
+
// between its paragraphs.
|
|
595
|
+
const MARKDOWN = /^\s*(#{1,6})\s/;
|
|
596
|
+
const endsBlock = (label, line) => {
|
|
597
|
+
if (!HEADING.test(line)) return false;
|
|
598
|
+
const outer = MARKDOWN.exec(label), inner = MARKDOWN.exec(line);
|
|
599
|
+
return !inner || Boolean(outer && inner[1].length <= outer[1].length);
|
|
600
|
+
};
|
|
601
|
+
const asks = (para) => { const m = INLINE.exec(para.split("\n")[0]); return Boolean(m && !NAMED.test(m[1])); };
|
|
602
|
+
// A heading inside a fenced block is the material's own text: a README chunk's "## Features".
|
|
603
|
+
const fences = (para) => (para.match(/^\s*(?:```|~~~)/gm) || []).length;
|
|
604
|
+
const addBlock = (add, name, paras) => {
|
|
605
|
+
let text = "";
|
|
606
|
+
for (const p of paras) {
|
|
607
|
+
if (text && text.length + p.length + 2 > PASSAGE_TEXT) { add(name, text); text = ""; }
|
|
608
|
+
text += (text ? "\n\n" : "") + p;
|
|
609
|
+
}
|
|
610
|
+
add(name, text);
|
|
611
|
+
};
|
|
571
612
|
const labelled = (text, add) => {
|
|
572
613
|
const rest = String(text || "").replace(TAGGED, (all, tag, inner) => { if (MATERIAL.test(tag.replace(/_/g, " "))) { add(tag, inner); return ""; } return all; });
|
|
573
614
|
const paras = rest.split(/\n\s*\n/);
|
|
@@ -575,10 +616,13 @@ if (FILE) {
|
|
|
575
616
|
const lines = paras[i].split("\n");
|
|
576
617
|
const head = lines[0];
|
|
577
618
|
if (HEADING.test(head) && MATERIAL.test(head)) {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
619
|
+
const block = [lines.slice(1).join("\n")];
|
|
620
|
+
let fenced = fences(block[0]) % 2 === 1;
|
|
621
|
+
while (i + 1 < paras.length && (fenced || (!endsBlock(head, paras[i + 1].split("\n")[0]) && !(i + 2 === paras.length && asks(paras[i + 1]))))) {
|
|
622
|
+
block.push(paras[++i]);
|
|
623
|
+
if (fences(paras[i]) % 2 === 1) fenced = !fenced;
|
|
624
|
+
}
|
|
625
|
+
addBlock(add, head.replace(/[#::[\]=]/g, "").trim(), block);
|
|
582
626
|
continue;
|
|
583
627
|
}
|
|
584
628
|
const inline = lines.map((l) => INLINE.exec(l)).find((m) => m && NAMED.test(m[1]));
|
|
@@ -636,6 +680,8 @@ if (FILE) {
|
|
|
636
680
|
// Where models are served, by host, and the paths every OpenAI-shaped or vendor endpoint ends with.
|
|
637
681
|
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;
|
|
638
682
|
const MODEL_PATH = /\/(?:chat\/completions|completions|responses|messages|embeddings)$|:(?:generateContent|streamGenerateContent)|\/invoke(?:-with-response-stream)?$|\/api\/(?:chat|generate)$/i;
|
|
683
|
+
// An embedding call answers nobody, so where it was made names no path a reply came down.
|
|
684
|
+
const EMBEDDING = /\/embeddings$/i;
|
|
639
685
|
const isModelCall = (host, path) => {
|
|
640
686
|
const h = String(host || "").replace(/:\d+$/, "");
|
|
641
687
|
const p = String(path || "").split("?")[0];
|
|
@@ -790,14 +836,14 @@ if (FILE) {
|
|
|
790
836
|
const m = /\/models\/([^/:]+):|\/model\/([^/]+)\/(?:invoke|converse)/.exec(path || "");
|
|
791
837
|
return m ? decodeURIComponent(m[1] || m[2]) : "";
|
|
792
838
|
};
|
|
793
|
-
const meter = ({ host, path, sent, status, type, body, turn }) => {
|
|
839
|
+
const meter = ({ host, path, sent, status, type, body, turn, caller }) => {
|
|
794
840
|
try {
|
|
795
841
|
const reply = readReply(type, String(body || "").slice(0, REPLY_MAX));
|
|
796
842
|
const rules = rulesIn(sent);
|
|
797
843
|
const tools = toolsIn(sent);
|
|
798
844
|
const called = calledIn(sent, reply.events);
|
|
799
845
|
const passages = passagesIn(sent);
|
|
800
|
-
row({ call: { 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 } : {}) } });
|
|
846
|
+
row({ call: { 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 } : {}), ...(caller ? { caller } : {}) } });
|
|
801
847
|
} catch { /* the command is gone */ }
|
|
802
848
|
};
|
|
803
849
|
|
|
@@ -1014,7 +1060,7 @@ if (FILE) {
|
|
|
1014
1060
|
}
|
|
1015
1061
|
const sent = bodyText(init && init.body);
|
|
1016
1062
|
note(als.getStore(), sent);
|
|
1017
|
-
const call = { host: url.host, path: url.pathname, sent, turn: (als.getStore() || {}).turn };
|
|
1063
|
+
const call = { host: url.host, path: url.pathname, sent, turn: (als.getStore() || {}).turn, caller: EMBEDDING.test(url.pathname) ? undefined : callers() };
|
|
1018
1064
|
return realFetch.apply(this, arguments).then((res) => {
|
|
1019
1065
|
try {
|
|
1020
1066
|
// A clone is a tee: your app's branch gets every byte as fast as it reads, this one is read to the end.
|
|
@@ -1043,7 +1089,8 @@ if (FILE) {
|
|
|
1043
1089
|
const end = req.end;
|
|
1044
1090
|
req.write = function (chunk, ...r) { if (chunk && typeof chunk !== "function") parts.push(Buffer.from(chunk)); return write.call(this, chunk, ...r); };
|
|
1045
1091
|
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); };
|
|
1046
|
-
const
|
|
1092
|
+
const bare = String(path || "").split("?")[0];
|
|
1093
|
+
const call = { host: String(host || ""), path: bare, turn: ctx && ctx.turn, caller: EMBEDDING.test(bare) ? undefined : callers() };
|
|
1047
1094
|
let done = false;
|
|
1048
1095
|
const once = (row) => { if (!done) { done = true; meter({ ...call, sent, ...row }); } };
|
|
1049
1096
|
req.once("error", () => once({ status: 0, type: "", body: "" }));
|
package/lib/verbs.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { resolve as resolvePath, sep } from "node:path";
|
|
3
3
|
import { SHOW, showText } from "./read-text.mjs";
|
|
4
|
-
import { fieldConnectText, fieldText, findingsText, finished, linesOf, refusedText, runText, STARTING, startedText, statusText, waitingText } from "./text.mjs";
|
|
4
|
+
import { fieldConnectText, fieldText, findingsText, finished, linesOf, refusedText, restartedText, runText, STARTING, startedText, statusText, testedLine, waitingText } from "./text.mjs";
|
|
5
5
|
|
|
6
6
|
// The eight things a coding agent may ask of Cortad, each a call to the API with this machine's key
|
|
7
7
|
// and a rendering from lib/text.mjs. The MCP tools and the `npx cortad <verb>` commands are the same
|
|
@@ -17,8 +17,9 @@ export const READY_MS = 240_000;
|
|
|
17
17
|
|
|
18
18
|
// `pending` keeps the run this machine asked for in ~/.cortad/<project>/pending.json: { kind,
|
|
19
19
|
// startedAt, after } while the app comes up, then its jobId, or the error that ended it. `after` is
|
|
20
|
-
// the latest run at the time, so a newer run on the server outranks a stale file.
|
|
21
|
-
|
|
20
|
+
// the latest run at the time, so a newer run on the server outranks a stale file. A run this machine
|
|
21
|
+
// posted also keeps `tested`, which code it played (lib/fresh.mjs), for its run_status and findings.
|
|
22
|
+
export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, root, writes = null, code = null, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
22
23
|
const call = async (method, path, body, timeoutMs = 30_000) => {
|
|
23
24
|
const key = typeof token === "function" ? token() : token;
|
|
24
25
|
if (!key) return { status: 0, ok: false, data: { error: NOT_CONNECTED } };
|
|
@@ -40,6 +41,10 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
|
|
|
40
41
|
};
|
|
41
42
|
const failed = (res) => ({ text: res.data?.why ?? res.data?.error ?? `Cortad answered ${res.status}.`, data: res.data, isError: true });
|
|
42
43
|
const iso = () => new Date(now()).toISOString();
|
|
44
|
+
const withTested = (d, id) => {
|
|
45
|
+
const kept = pending.read();
|
|
46
|
+
return kept?.tested && kept.jobId === id ? { ...d, tested: kept.tested } : d;
|
|
47
|
+
};
|
|
43
48
|
|
|
44
49
|
// With show, one section of the read in full, paged; a name that is not a section is the plain status.
|
|
45
50
|
const status = async ({ show, page } = {}) => {
|
|
@@ -50,24 +55,33 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
|
|
|
50
55
|
};
|
|
51
56
|
|
|
52
57
|
// Asks the server for the run and keeps its id here, so run_status finds it without one.
|
|
53
|
-
const post = async (args, kind) => {
|
|
58
|
+
const post = async (args, kind, restarted = []) => {
|
|
59
|
+
const tested = code?.tested() ?? null;
|
|
54
60
|
const res = await call("POST", "/mcp/run", args);
|
|
55
61
|
if (res.status === 202 || (res.ok && res.data?.joined)) {
|
|
56
|
-
|
|
62
|
+
const mine = res.status === 202 ? tested : null;
|
|
63
|
+
pending.write({ jobId: res.data.jobId, kind: res.data.kind ?? kind, startedAt: iso(), ...(mine ? { tested: mine } : {}) });
|
|
57
64
|
try { writes?.mark(res.data.jobId); } catch { /* the app's writes go unrecorded this run */ }
|
|
58
|
-
|
|
65
|
+
const said = [restarted.length ? restartedText(restarted) : "", mine?.stale ? testedLine(mine) : ""];
|
|
66
|
+
return { text: startedText(res.data, kind, args.findingId, said), data: res.data };
|
|
59
67
|
}
|
|
60
68
|
if (res.status === 402) return { text: refusedText(res.data), data: res.data, isError: true };
|
|
61
69
|
if (res.status === 409) return { text: `${res.data?.why ?? "The run could not start."}\nNothing ran.`, data: res.data, isError: true };
|
|
62
70
|
return failed(res);
|
|
63
71
|
};
|
|
64
72
|
|
|
65
|
-
// An app that is up gets the run now
|
|
66
|
-
// is
|
|
73
|
+
// An app that is up gets the run now, started again first when its code changed and nothing
|
|
74
|
+
// reloaded it; never under a run already playing, which is joined. One that is not up is started
|
|
75
|
+
// in the background, and the run is posted from there once it answers.
|
|
67
76
|
const start = async (args, kind) => {
|
|
68
77
|
const before = await call("GET", "/mcp/status");
|
|
69
78
|
if (!before.ok) return failed(before);
|
|
70
|
-
if (before.data.app?.state === "ready-to-test")
|
|
79
|
+
if (before.data.app?.state === "ready-to-test") {
|
|
80
|
+
const idle = !before.data.run || finished(before.data.run);
|
|
81
|
+
const got = code && idle ? await code.fresh() : {};
|
|
82
|
+
if (got.error) return { text: `${got.error}\nNothing ran.`, isError: true };
|
|
83
|
+
return post(args, kind, got.restarted);
|
|
84
|
+
}
|
|
71
85
|
pending.write({ kind, startedAt: iso(), after: before.data.run?.jobId ?? null });
|
|
72
86
|
const began = await startApp(args, kind);
|
|
73
87
|
if (!began.ok) {
|
|
@@ -115,7 +129,8 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
|
|
|
115
129
|
// The run is over: what the app wrote into its folder during it is put back, said before the next step.
|
|
116
130
|
let back = "";
|
|
117
131
|
if (writes && finished(res.data)) { try { back = writes.putBack(); } catch { back = ""; } }
|
|
118
|
-
|
|
132
|
+
const text = runText(withTested(res.data, id));
|
|
133
|
+
return { text: back ? withBack(text, back) : text, data: res.data };
|
|
119
134
|
};
|
|
120
135
|
|
|
121
136
|
// Every server page of a run's findings, so the pages the agent reads are cut by size here.
|
|
@@ -144,7 +159,7 @@ export function makeVerbs({ api, token, fetchImpl = fetch, startApp, pending, ro
|
|
|
144
159
|
|
|
145
160
|
const findings = async ({ jobId, page } = {}) => {
|
|
146
161
|
const res = await gather(jobId);
|
|
147
|
-
return res.ok ? { text: findingsText(res.data, page), data: res.data } : failed(res);
|
|
162
|
+
return res.ok ? { text: findingsText(withTested(res.data, res.data.runId), page), data: res.data } : failed(res);
|
|
148
163
|
};
|
|
149
164
|
|
|
150
165
|
const dispute = async ({ findingId, why, question, jobId }) => {
|
package/lib/writes.mjs
CHANGED
|
@@ -23,11 +23,7 @@ export function markRun(dir, jobId, { keep = false } = {}) {
|
|
|
23
23
|
// server's side, so no verb marks it) and again at every run a verb starts, so what a person wrote
|
|
24
24
|
// between two runs is dropped from the record rather than undone.
|
|
25
25
|
export function putBack(dir, root) {
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
rows = readFileSync(join(dir, "written.jsonl"), "utf8").split("\n").filter(Boolean)
|
|
29
|
-
.map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
30
|
-
} catch { /* the run wrote nothing */ }
|
|
26
|
+
const rows = rowsOf(dir);
|
|
31
27
|
const base = resolve(root);
|
|
32
28
|
const done = [];
|
|
33
29
|
for (const row of rows) {
|
|
@@ -44,6 +40,16 @@ export function putBack(dir, root) {
|
|
|
44
40
|
return saidOf(done);
|
|
45
41
|
}
|
|
46
42
|
|
|
43
|
+
function rowsOf(dir) {
|
|
44
|
+
try {
|
|
45
|
+
return readFileSync(join(dir, "written.jsonl"), "utf8").split("\n").filter(Boolean)
|
|
46
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
|
|
47
|
+
} catch { return []; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The files the app itself wrote since the record began: its data, never a change to its code.
|
|
51
|
+
export const writtenPaths = (dir) => new Set(rowsOf(dir).map((row) => row.path).filter((p) => typeof p === "string"));
|
|
52
|
+
|
|
47
53
|
export function saidOf(paths) {
|
|
48
54
|
if (!paths.length) return "";
|
|
49
55
|
const named = paths.slice(0, 3).join(", ") + (paths.length > 3 ? ` and ${paths.length - 3} more` : "");
|
package/local.mjs
CHANGED
|
@@ -21,7 +21,8 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
|
21
21
|
import { createInterface } from "node:readline";
|
|
22
22
|
import { promisify } from "node:util";
|
|
23
23
|
import { COMMANDS, main as face } from "./lib/cli.mjs";
|
|
24
|
-
import { clearRunner, homeOf, projectOf, readToken, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
|
|
24
|
+
import { clearRunner, homeOf, projectOf, readToken, writeApp, writeDigest, writeRunner, writeToken } from "./lib/home.mjs";
|
|
25
|
+
import { commandText, elapsedMs, RELOADER, sourceOf } from "./lib/fresh.mjs";
|
|
25
26
|
import { registerAll } from "./lib/register.mjs";
|
|
26
27
|
import { finished } from "./lib/text.mjs";
|
|
27
28
|
import { lockHolds, makeLock } from "./lib/lock.mjs";
|
|
@@ -375,10 +376,13 @@ async function verb(job) {
|
|
|
375
376
|
const url = hostUrl(port, path);
|
|
376
377
|
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; } };
|
|
377
378
|
let jar = "";
|
|
379
|
+
// Held as long as the run waits for this reply: a course generator that takes ninety seconds
|
|
380
|
+
// a reply is waited on for three of them. A run that says nothing gets the old 170 seconds.
|
|
381
|
+
const holdMs = Math.min(Math.max(Number(b.waitMs) || 170_000, 1_000), 630_000);
|
|
378
382
|
const sent = (at, over = {}) => fetch(at, {
|
|
379
383
|
...init, ...over,
|
|
380
384
|
headers: { ...headers, ...(over.headers ?? {}), ...(jar ? { cookie: [headers.cookie, jar].filter(Boolean).join("; ") } : {}) },
|
|
381
|
-
signal: AbortSignal.timeout(
|
|
385
|
+
signal: AbortSignal.timeout(holdMs),
|
|
382
386
|
});
|
|
383
387
|
const keep = (res) => { const set = res.headers.getSetCookie?.() ?? []; if (set.length) jar = [jar, ...set.map((c) => c.split(";")[0])].filter(Boolean).join("; "); };
|
|
384
388
|
const sentBack = (res) => (res.status >= 300 && res.status < 400 ? yours(res.headers.get("location") ?? "") : null);
|
|
@@ -646,16 +650,32 @@ async function listenerOn(port) {
|
|
|
646
650
|
// The top of the chain that runs the app: nodemon, npm, the sh -c under it. Climbs from the listener
|
|
647
651
|
// while the parent is a runner, never into the person's own shell or terminal.
|
|
648
652
|
const RUNNER = /^(?:\S*\/)?(?:node|npm|npx|pnpm|yarn|bun|deno|python[\d.]*|uvicorn|gunicorn|flask|tsx|ts-node|nodemon|concurrently|pm2)(?:\s|$)|^(?:\/bin\/)?sh -c\b/;
|
|
649
|
-
async function
|
|
650
|
-
const rows = (await exec("ps", ["-axo", "pid=,ppid=,args="])).stdout.trim().split("\n").map((l) => l.trim());
|
|
651
|
-
const table = new Map(rows.map((l) => { const m = /^(\d+)\s+(\d+)\s+(.*)$/.exec(l); return m ? [Number(m[1]), { ppid: Number(m[2]),
|
|
652
|
-
|
|
653
|
-
for (let i = 0; i < 8; i++) {
|
|
654
|
-
const parent = table.get(
|
|
653
|
+
async function chainOf(pid) {
|
|
654
|
+
const rows = (await exec("ps", ["-axo", "pid=,ppid=,etime=,args="])).stdout.trim().split("\n").map((l) => l.trim());
|
|
655
|
+
const table = new Map(rows.map((l) => { const m = /^(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(l); return m ? [Number(m[1]), { pid: Number(m[1]), ppid: Number(m[2]), etime: m[3], args: m[4] }] : [0, null]; }));
|
|
656
|
+
const chain = table.get(pid) ? [table.get(pid)] : [];
|
|
657
|
+
for (let i = 0; i < 8 && chain.length; i++) {
|
|
658
|
+
const parent = table.get(chain.at(-1).ppid);
|
|
655
659
|
if (!parent || !RUNNER.test(parent.args)) break;
|
|
656
|
-
|
|
660
|
+
chain.push(parent);
|
|
657
661
|
}
|
|
658
|
-
return
|
|
662
|
+
return chain;
|
|
663
|
+
}
|
|
664
|
+
const supervisorOf = async (pid) => (await chainOf(pid)).at(-1)?.pid ?? pid;
|
|
665
|
+
|
|
666
|
+
// What the running app was started from, noted in app.json for the verbs, which run in another
|
|
667
|
+
// process (lib/fresh.mjs): when it started, whether this command started it, whether it reloads on
|
|
668
|
+
// save, and what each source file said then. An app found already running is noted by its own
|
|
669
|
+
// process's age and command line, with the file names alone.
|
|
670
|
+
let loaded = null;
|
|
671
|
+
const noteApp = () => writeApp(project, loaded?.at
|
|
672
|
+
? { runner: process.pid, own: loaded.own, startedAt: new Date(loaded.at).toISOString(), reloads: loaded.reloads, files: loaded.files }
|
|
673
|
+
: { runner: process.pid });
|
|
674
|
+
async function noteAttached(port) {
|
|
675
|
+
const pid = await listenerOn(port);
|
|
676
|
+
const chain = pid ? await chainOf(pid).catch(() => []) : [];
|
|
677
|
+
loaded = { own: false, at: chain.length ? Date.now() - elapsedMs(chain[0].etime) : null, reloads: RELOADER.test(chain.map((p) => p.args).join("\n")), files: sourceOf(root, files, false) };
|
|
678
|
+
noteApp();
|
|
659
679
|
}
|
|
660
680
|
|
|
661
681
|
// Your app, started the way you start it, and watched until one of its own ports answers.
|
|
@@ -772,10 +792,17 @@ async function start(waitMs) {
|
|
|
772
792
|
// The record of what the app writes opens with the app: the first run starts itself on the
|
|
773
793
|
// server's side, with no verb here to mark it, and a restart mid-run keeps the record open.
|
|
774
794
|
try { markRun(writesDirOf(homeOf(projectOf(root))), "session", { keep: true }); } catch { /* the app's writes go unrecorded */ }
|
|
795
|
+
// Taken before the app reads a file: a save after this moment is a change it has not loaded.
|
|
796
|
+
const note = { own: true, at: Date.now(), reloads: RELOADER.test(commandText(cmd, appDir)), answered: false };
|
|
797
|
+
note.files = sourceOf(root, files);
|
|
798
|
+
loaded = note;
|
|
775
799
|
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 });
|
|
776
800
|
const mine = child;
|
|
777
801
|
let seen = "";
|
|
778
|
-
const onData = (d) => {
|
|
802
|
+
const onData = (d) => {
|
|
803
|
+
const s = d.toString(); appendFileSync(bootLog, s); seen = (seen + s).slice(-20_000); lastSaid = seen; lastOutputAt = Date.now(); if (verbose) process.stdout.write(s);
|
|
804
|
+
if (!note.reloads && RELOADER.test(s)) { note.reloads = true; if (note.answered && loaded === note) noteApp(); }
|
|
805
|
+
};
|
|
779
806
|
mine.stdout.on("data", onData);
|
|
780
807
|
mine.stderr.on("data", onData);
|
|
781
808
|
let exited = null;
|
|
@@ -807,7 +834,11 @@ async function start(waitMs) {
|
|
|
807
834
|
const plain = seen.replace(/\x1b\[[0-9;]*m/g, "");
|
|
808
835
|
const said = [...plain.matchAll(/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{2,5})\b|\bport\s*[:=]?\s*(\d{4,5})\b/gi)].map((m) => Number(m[1] || m[2]));
|
|
809
836
|
for (const port of [...new Set([...said.reverse().filter((p) => ports.includes(p)), ...ports])]) {
|
|
810
|
-
if (await answers(port))
|
|
837
|
+
if (await answers(port)) {
|
|
838
|
+
note.answered = true;
|
|
839
|
+
if (loaded === note) noteApp();
|
|
840
|
+
return { port, exited: null, tail: "" };
|
|
841
|
+
}
|
|
811
842
|
}
|
|
812
843
|
await new Promise((r) => setTimeout(r, 1000));
|
|
813
844
|
}
|
|
@@ -1024,7 +1055,7 @@ const announce = () => call("POST", `/local/${box}/app`, { port: app.port, cmd:
|
|
|
1024
1055
|
async function appLife() {
|
|
1025
1056
|
for (let first = true; ; first = false) {
|
|
1026
1057
|
const got = await startApp();
|
|
1027
|
-
if (got.port) { app = got; break; }
|
|
1058
|
+
if (got.port) { app = got; if (!launched) await noteAttached(app.port); break; }
|
|
1028
1059
|
await call("POST", `/local/${box}/stopped`, { said: mask(got.said || got.why).slice(-2000) }).catch(() => {});
|
|
1029
1060
|
say(got.noStart ? got.why : `${got.why} Fix it and save: it is started again by itself.`);
|
|
1030
1061
|
// Said with the same words the agent prompt waits for, so an agent holding the terminal reports
|
|
@@ -1086,6 +1117,7 @@ async function appLife() {
|
|
|
1086
1117
|
say("saw your change, starting your app again");
|
|
1087
1118
|
back = launched ? await launch(180_000) : await startApp();
|
|
1088
1119
|
}
|
|
1120
|
+
if (back.port && !launched) await noteAttached(back.port);
|
|
1089
1121
|
if (back.port) { app = { ...app, ...(back.cmd !== undefined ? back : {}), port: back.port }; up = true; downSince = 0; toldDown = false; appTold = Boolean((await announce().catch(() => null))?.ok); say(`your app is answering again on port ${app.port}`); }
|
|
1090
1122
|
} finally { restarting = false; }
|
|
1091
1123
|
}
|
|
@@ -1119,6 +1151,13 @@ async function close(code = 0) {
|
|
|
1119
1151
|
}
|
|
1120
1152
|
process.on("SIGINT", () => close(0));
|
|
1121
1153
|
process.on("SIGTERM", () => close(0));
|
|
1154
|
+
// A verb in another process asks for the app to be started again before a run, because its code
|
|
1155
|
+
// changed and nothing reloaded it (lib/fresh.mjs). The new start is noted as the app answers; one
|
|
1156
|
+
// that fails is noted with its reason. A restart already under way answers the same way.
|
|
1157
|
+
process.on("SIGUSR2", () => {
|
|
1158
|
+
if (restarting || !app) return;
|
|
1159
|
+
void restartApp().then((out) => { if (out.error) writeApp(project, { runner: process.pid, failedAt: new Date().toISOString(), error: mask(out.error).slice(-600) }); });
|
|
1160
|
+
});
|
|
1122
1161
|
|
|
1123
1162
|
let quiet = 0;
|
|
1124
1163
|
// Each poll names the jobs the last one brought. A poll's answer can die on the way (the network
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cortad",
|
|
3
|
-
"version": "0.3.1-rc.
|
|
3
|
+
"version": "0.3.1-rc.9",
|
|
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"
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"README.md",
|
|
11
11
|
"lib/cli.mjs",
|
|
12
12
|
"lib/data.mjs",
|
|
13
|
+
"lib/fresh.mjs",
|
|
13
14
|
"lib/home.mjs",
|
|
14
15
|
"lib/identities.mjs",
|
|
15
16
|
"lib/keys.mjs",
|
package/skill/SKILL.md
CHANGED
|
@@ -49,14 +49,15 @@ Take the person through them worst first, each with its file and line. A long li
|
|
|
49
49
|
|
|
50
50
|
## Fixing one finding
|
|
51
51
|
|
|
52
|
-
Each finding ends with what a verify of it can show. When
|
|
52
|
+
Each finding ends with what a verify of it can show. When the trials it replays read the question too few times for a rate, the verify plays up to 12 more trials in that situation itself, counted as verify trials, and the finding says how many before one is spent.
|
|
53
53
|
|
|
54
54
|
1. One change, in the file and near the line the finding names.
|
|
55
|
-
2. `verify <findingId>`. It answers within a second, and `run_status` follows it. Cortad starts the app again
|
|
55
|
+
2. `verify <findingId>`. It answers within a second, and `run_status` follows it. When the app's code changed since the app started and nothing reloaded it, Cortad starts the app again first and the answer says so, so the saved edit is what plays; `run_status` names when the app started against the last change. When it also plays new trials in the finding's situation, the answer says how many.
|
|
56
56
|
3. Read the verify's text. It opens with what came back on the replayed trials before and now (errors, refusals, replies that got no answer), so a fix that broke the endpoint shows first. It closes on whether the evidence supports keeping the fix:
|
|
57
57
|
- `supports keeping the fix`: the behavior moved outside the noise, with the held-out line moving too or `no pair`. The change stays.
|
|
58
|
-
- `does not support keeping the fix`: the pairs could have shown a fix and nothing moved, or it moved the wrong way. The file goes back to how it was.
|
|
59
|
-
- `cannot tell`: not a verdict against the change, and the sentence says why and what would
|
|
58
|
+
- `does not support keeping the fix`: the pairs, or the rate on the same trials, could have shown a fix and nothing moved, or it moved the wrong way. The file goes back to how it was.
|
|
59
|
+
- `cannot tell`: not a verdict against the change, and the sentence says why and what would; most often the reader could not settle enough of the replies, which is on Cortad's side. A change the app's own tests support may stay, said as not yet verified by Cortad.
|
|
60
|
+
- Where the replies cannot be paired reply for reply, the verify compares the question's failure rate on the same trials before and now, with the new trials it played in the situation, and says both. Readings the reader could not settle are named and count on neither side.
|
|
60
61
|
- A reply that failed before and no longer meets the question's condition is counted as gone on its own, not as a pass.
|
|
61
62
|
- A verify that played no trial says so and compares nothing; the file stays as it was left.
|
|
62
63
|
- An `Overfit:` line: the visible trials moved and the held-out trials stayed where they were, so the change fits the trials it could see. The file goes back, and the next change aims at the behavior the question asks about.
|
|
@@ -162,7 +162,19 @@ A verify with no pair: the two runs read the finding's question on no common tri
|
|
|
162
162
|
Verify 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a: finished, 1 of 1 trial played.
|
|
163
163
|
Verify of finding:2 at backend/tools/refunds.py.
|
|
164
164
|
Held-out trials: no pair for this question.
|
|
165
|
-
On the 1 trial replayed, 0 of 3 replies were errors before and 0 of 3 now. No reading of this question pairs between the two runs. Not paired: 1 trial (t-5d10): the replay took a different turn at reply 2, and the question was not asked on the replay because its condition did not hold (the customer has reached this step of the journey: Agent states amount, asks consent). The evidence cannot tell whether to keep the fix: no reading of this question pairs between the two runs. This finding rests on 2 readings, 2 of them failures, so a
|
|
165
|
+
On the 1 trial replayed, 0 of 3 replies were errors before and 0 of 3 now. No reading of this question pairs between the two runs. Not paired: 1 trial (t-5d10): the replay took a different turn at reply 2, and the question was not asked on the replay because its condition did not hold (the customer has reached this step of the journey: Agent states amount, asks consent). The evidence cannot tell whether to keep the fix: no reading of this question pairs between the two runs. This finding rests on 2 readings, 2 of them failures, so a replay of its trials alone cannot leave the noise: even if both failures hold on the replay, 2 readings cannot tell that apart from chance. A verify of it plays 10 more trials in this situation to reach about 22 readings.
|
|
166
|
+
For the person: the report is at https://cortad.com/lab
|
|
167
|
+
next: findings
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
A verify whose replies did not pair: the replay took other turns and the reader settled other replies, so the question's failure rate on the same trials is compared instead, with the 10 new trials the verify played in the finding's situation. Readings the reader could not settle count on neither side.
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
Verify 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a: finished, 11 of 11 trials played.
|
|
174
|
+
Verify of finding:2 at backend/tools/refunds.py.
|
|
175
|
+
Visible trials: failed on 2 of 2 settled readings before, 1 of 29 after, 4 unclear after, over 1 trial replayed and 10 new; improved, +97 points, interval 29 to 99.
|
|
176
|
+
Held-out trials: failed on 2 of 16 settled readings before, 0 of 15 after, 1 unclear after; too few to state a rate (15 of the 22 it needs).
|
|
177
|
+
On the 1 trial replayed, 0 of 3 replies were errors before and 0 of 4 now. On the 10 more trials played in this situation, 0 of 30 replies were errors. Reply for reply, nothing could be paired: the reader could not settle any reply of this question that both runs read. Across the 1 trial replayed in this situation and the 10 more this verify played there, this question failed on 2 of 2 settled readings before and 1 of 29 now; 4 readings now could not be settled either way and count on neither side. As two separate samples the failure rate fell 97 points, and the real fall is between 29 and 99 points. That is outside the 18 points that come back different on temperature alone. The held-out trials have too few readings to say (15 of the 22 each rate needs). The evidence supports keeping the fix: this question failed on 2 of 2 settled readings before and 1 of 29 now, 97 points fewer.
|
|
166
178
|
For the person: the report is at https://cortad.com/lab
|
|
167
179
|
next: findings
|
|
168
180
|
```
|
|
@@ -221,6 +233,14 @@ Verify of finding:1 started: 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a.
|
|
|
221
233
|
next: run_status 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a
|
|
222
234
|
```
|
|
223
235
|
|
|
236
|
+
When the trials it replays read the finding's question too few times for a rate, it says before they play how many new trials it adds in the finding's situation. They are verify trials from the plan.
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
Verify of finding:2 started: 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a.
|
|
240
|
+
Playing 10 more trials in this situation to reach about 22 readings of this question; the run before read it 2 times here.
|
|
241
|
+
next: run_status 7c31e0aa-1b2c-4d3e-8f4a-5b6c7d8e9f0a
|
|
242
|
+
```
|
|
243
|
+
|
|
224
244
|
## field_connect
|
|
225
245
|
|
|
226
246
|
```
|