mcp-castor 2026.3.0

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.
Files changed (42) hide show
  1. package/README.md +487 -0
  2. package/bin/castor.js +706 -0
  3. package/index.js +206 -0
  4. package/package.json +97 -0
  5. package/skills/canary-test-staging/SKILL.md +24 -0
  6. package/skills/evo-mutation-rollback/SKILL.md +29 -0
  7. package/skills/hypothesis-generation/SKILL.md +26 -0
  8. package/skills/traceback-condensing/SKILL.md +26 -0
  9. package/src/castor_runner.js +469 -0
  10. package/src/config.js +1204 -0
  11. package/src/env.js +10 -0
  12. package/src/evo_engine.js +214 -0
  13. package/src/harness/core/events.js +75 -0
  14. package/src/harness/core/kernel.js +209 -0
  15. package/src/harness/evo/evaluator.js +156 -0
  16. package/src/harness/evo/evo_operator.js +550 -0
  17. package/src/harness/evo/lineage_dag.js +383 -0
  18. package/src/harness/evo/trace_repair.js +173 -0
  19. package/src/harness/evo/watchdog.js +72 -0
  20. package/src/harness/loop_detector.js +135 -0
  21. package/src/harness/runner.js +1216 -0
  22. package/src/harness/services/ast_service.js +1813 -0
  23. package/src/harness/services/event_logger.js +275 -0
  24. package/src/harness/services/mcp_bridge.js +408 -0
  25. package/src/harness/services/provider_vllm.js +728 -0
  26. package/src/harness/services/sandbox_fs.js +1238 -0
  27. package/src/harness/services/searxng_lifecycle.js +254 -0
  28. package/src/harness/services/shell_executor.js +264 -0
  29. package/src/harness/services/shell_validator.js +506 -0
  30. package/src/harness/services/web_service.js +828 -0
  31. package/src/platform.js +344 -0
  32. package/src/repetition_detector.js +139 -0
  33. package/src/semaphore.js +373 -0
  34. package/src/server_lifecycle.js +781 -0
  35. package/src/skills.js +400 -0
  36. package/src/state_pruner.js +392 -0
  37. package/src/task_registry.js +1357 -0
  38. package/src/telemetry.js +638 -0
  39. package/src/tools.js +997 -0
  40. package/src/wsl_bridge.js +629 -0
  41. package/src/wsl_env.js +171 -0
  42. package/stream_proxy.js +453 -0
@@ -0,0 +1,344 @@
1
+ /**
2
+ * src/platform.js — Single resolver for host-specific (machine) values.
3
+ *
4
+ * Every hardcoded machine path / distro / user in the codebase is routed
5
+ * through this module so there is exactly one place that knows about the
6
+ * host layout. All resolvers are:
7
+ * - env-overridable (QWEN_*),
8
+ * - lazily resolved and cached,
9
+ * - safe (never throw; fall back to a sane default).
10
+ *
11
+ * The Windows<->POSIX path translators are re-exported from wsl_bridge.js
12
+ * (NOT reimplemented here).
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { IS_WINDOWS } from "./env.js";
19
+ import {
20
+ wslDistro,
21
+ wslUser,
22
+ wslHome,
23
+ winHomeWsl,
24
+ setWslUserProbe,
25
+ _resetWslUserCache,
26
+ } from "./wsl_env.js";
27
+ import {
28
+ isWslLocation,
29
+ toPosixWslPath,
30
+ toWindowsPath,
31
+ toMsys2Path,
32
+ normalizeWorkspacePath,
33
+ } from "./wsl_bridge.js";
34
+
35
+ // Re-export the Windows<->POSIX path translators (do NOT reimplement them).
36
+ export { isWslLocation, toPosixWslPath, toWindowsPath, toMsys2Path, normalizeWorkspacePath };
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // WSL distro / user / home — re-exported from the leaf wsl_env.js.
40
+ //
41
+ // These resolvers (and their whoami probe cache + test seams) were extracted
42
+ // into wsl_env.js via dependency inversion so config.js can import winHomeWsl
43
+ // without importing platform.js (breaking the cycle that caused the Linux CI
44
+ // TDZ). They are re-exported here so existing consumers are unchanged.
45
+ // ---------------------------------------------------------------------------
46
+ export {
47
+ wslDistro,
48
+ wslUser,
49
+ wslHome,
50
+ winHomeWsl,
51
+ setWslUserProbe,
52
+ _resetWslUserCache,
53
+ };
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Windows home (host-side user home, e.g. C:\Users\<user>)
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /** Windows host home directory (e.g. C:\Users\<user>). Env QWEN_WIN_HOME; default os.homedir(). */
60
+ export function winHome() {
61
+ if (process.env.QWEN_WIN_HOME) return process.env.QWEN_WIN_HOME;
62
+ if (IS_WINDOWS) return os.homedir();
63
+ const wslWin = winHomeWsl();
64
+ if (wslWin) return toWindowsPath(wslWin);
65
+ return os.homedir();
66
+ }
67
+
68
+ // winHomeWsl() is re-exported from wsl_env.js (see the re-export block above);
69
+ // its body now lives in the leaf module so config.js can import it without
70
+ // importing platform.js.
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Bare-command resolution (P8 bridge: spawnable absolute paths)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const _cmdPathCache = new Map();
77
+
78
+ /**
79
+ * Resolve a bare command name to a spawnable absolute path via which/where.
80
+ * Node's spawn does NOT resolve Windows .cmd shims (npx, uvx) without a
81
+ * shell — and shell mode would break argv-array purity — so callers that
82
+ * must spawn bare package runners on Windows resolve first. Absolute or
83
+ * path-bearing commands pass through untouched. Returns null when the
84
+ * command cannot be found. Lazily probed and cached; never throws.
85
+ * @param {string} command
86
+ * @returns {string|null}
87
+ */
88
+ export function resolveCommandPath(command) {
89
+ if (!command || typeof command !== "string") return null;
90
+ if (command.includes("/") || command.includes("\\") || path.isAbsolute(command)) {
91
+ return command;
92
+ }
93
+ if (_cmdPathCache.has(command)) return _cmdPathCache.get(command);
94
+ let resolved = null;
95
+ try {
96
+ const probe = IS_WINDOWS ? "where.exe" : "which";
97
+ const out = execFileSync(probe, [command], {
98
+ timeout: 5000,
99
+ windowsHide: true,
100
+ stdio: ["ignore", "pipe", "ignore"],
101
+ });
102
+ const first = out
103
+ .toString()
104
+ .split(/\r?\n/)
105
+ .map((s) => s.trim())
106
+ .find(Boolean);
107
+ if (first) resolved = first;
108
+ } catch {}
109
+ _cmdPathCache.set(command, resolved);
110
+ return resolved;
111
+ }
112
+
113
+ /** Clear the resolved-command cache so the next call re-probes. Test-only. */
114
+ export function _resetCommandPathCache() {
115
+ _cmdPathCache.clear();
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // POSIX shell (bash-compatible) resolver
120
+ // ---------------------------------------------------------------------------
121
+
122
+ let _posixShell = null;
123
+ let _posixShellResolved = false;
124
+
125
+ /**
126
+ * Cheap probe: run `<candidate> -c "echo __ok__"` and check the output.
127
+ * Returns true if the candidate is a working bash-compatible shell.
128
+ * Never throws.
129
+ */
130
+ function _probeShellCandidate(candidate) {
131
+ if (!candidate) return false;
132
+ try {
133
+ const out = execFileSync(candidate, ["-c", "echo __ok__"], {
134
+ timeout: 3_000,
135
+ stdio: ["ignore", "pipe", "ignore"],
136
+ });
137
+ return out.toString().includes("__ok__");
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ // Test seam: lets offline tests inject a fake candidate probe (mirrors the
144
+ // setWslUserProbe seam). Pass null to restore the real probe.
145
+ let _probeCandidate = _probeShellCandidate;
146
+ export function setPosixShellProbeCandidate(fn) {
147
+ _probeCandidate = typeof fn === "function" ? fn : _probeShellCandidate;
148
+ }
149
+
150
+ /** Find `bash` (or `bash.exe`) on PATH. Returns the first match or null. */
151
+ function _findBashOnPath() {
152
+ try {
153
+ const cmd = IS_WINDOWS ? "where.exe" : "which";
154
+ const target = IS_WINDOWS ? "bash.exe" : "bash";
155
+ const out = execFileSync(cmd, [target], {
156
+ timeout: 3_000,
157
+ stdio: ["ignore", "pipe", "ignore"],
158
+ });
159
+ const first = out.toString().split(/\r?\n/)[0].trim();
160
+ return first || null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Probe the POSIX shell candidates in resolution order:
168
+ * 1. QWEN_POSIX_SHELL env override
169
+ * 2. Git Bash standard locations
170
+ * 3. bash on PATH (last resort, might be the WSL System32 stub)
171
+ * Returns the first candidate that passes the probe, or null.
172
+ */
173
+ function _probePosixShell() {
174
+ const candidates = [];
175
+ if (process.env.QWEN_POSIX_SHELL) {
176
+ candidates.push(process.env.QWEN_POSIX_SHELL);
177
+ }
178
+ if (IS_WINDOWS) {
179
+ candidates.push(
180
+ "C:\\Program Files\\Git\\bin\\bash.exe",
181
+ "C:\\Program Files (x86)\\Git\\bin\\bash.exe",
182
+ );
183
+ const localAppData = process.env.LOCALAPPDATA;
184
+ if (localAppData) {
185
+ candidates.push(path.join(localAppData, "Programs", "Git", "bin", "bash.exe"));
186
+ }
187
+ }
188
+ const onPath = _findBashOnPath();
189
+ if (onPath) candidates.push(onPath);
190
+
191
+ for (const c of candidates) {
192
+ if (_probeCandidate(c)) return c;
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /**
198
+ * POSIX shell (bash-compatible) resolver.
199
+ * Env QWEN_POSIX_SHELL overrides; otherwise Git Bash standard locations;
200
+ * otherwise bash on PATH. Lazily resolved and cached. Never throws.
201
+ * Returns null if no working POSIX shell is found.
202
+ */
203
+ export function posixShell() {
204
+ if (_posixShellResolved) return _posixShell;
205
+ _posixShell = _probePosixShell();
206
+ _posixShellResolved = true;
207
+ return _posixShell;
208
+ }
209
+
210
+ /** Clear the cached POSIX shell so the next call re-probes. Test-only. */
211
+ export function _resetPosixShellCache() {
212
+ _posixShell = null;
213
+ _posixShellResolved = false;
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Stream proxy path (WSL-side)
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * WSL-side path to the repo's stream_proxy.js, derived from this module's own
222
+ * location (import.meta.url) so it is correct regardless of process.cwd().
223
+ * This file lives at <repo>/src/platform.js; the proxy at <repo>/stream_proxy.js.
224
+ * Env QWEN_STREAM_PROXY_PATH overrides.
225
+ */
226
+ export function streamProxyPath() {
227
+ if (process.env.QWEN_STREAM_PROXY_PATH) return process.env.QWEN_STREAM_PROXY_PATH;
228
+ const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
229
+ return `${toPosixWslPath(repoRoot)}/stream_proxy.js`;
230
+ }
231
+
232
+ /**
233
+ * WSL-side path to the launcher script (scripts/wsl/start_huge.sh).
234
+ * Sourced directly from the repository so it does not rely on brittle symlinks.
235
+ * Env QWEN_LAUNCHER_PATH overrides.
236
+ */
237
+ export function launcherScriptPath() {
238
+ if (process.env.QWEN_LAUNCHER_PATH) return process.env.QWEN_LAUNCHER_PATH;
239
+ const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
240
+ const ecosystemRoot = path.dirname(repoRoot);
241
+ return `${toPosixWslPath(ecosystemRoot)}/scripts/wsl/start_huge.sh`;
242
+ }
243
+
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // API-key candidate paths
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * Candidate paths for the vLLM API-key file, routed through the resolvers.
251
+ * (Previously a hardcoded list inside wsl_bridge.getApiKeySync.)
252
+ *
253
+ * Environment variables QWEN_API_KEY / OPENAI_API_KEY are checked by the
254
+ * caller (wsl_bridge.getApiKeySync) BEFORE this function is invoked, so
255
+ * this function only returns file-based candidates.
256
+ */
257
+ export function apiKeyCandidates() {
258
+ const distro = wslDistro();
259
+ const wslHomePath = wslHome();
260
+ // WSL home "/home/<user>" -> "home\<user>" (strip the leading slash so the
261
+ // UNC path stays single-backslash: \\wsl.localhost\<distro>\home\<user>\...).
262
+ const wslHomeWin = wslHomePath.replace(/^\//, "").replace(/\//g, "\\");
263
+ if (IS_WINDOWS) {
264
+ return [
265
+ `\\\\wsl.localhost\\${distro}\\${wslHomeWin}\\qwen-serving\\api_key.txt`,
266
+ `\\\\wsl$\\${distro}\\${wslHomeWin}\\qwen-serving\\api_key.txt`,
267
+ path.join(winHome(), "qwen-serving", "api_key.txt"),
268
+ ];
269
+ }
270
+ return [
271
+ `${wslHomePath}/qwen-serving/api_key.txt`,
272
+ path.join(process.env.HOME || "/root", "qwen-serving", "api_key.txt"),
273
+ ];
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Spawn-profile builder
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Build the exact spawn options for a given mode. ONE function replaces the
282
+ * duplicated Windows/WSL branch logic scattered across the codebase.
283
+ *
284
+ * mode "windows": spawn the command directly (cwd passed through as-is).
285
+ * mode "wsl": wrap with `wsl.exe -d <distro> [--user <user>]
286
+ * [--cd <posixCwd>] --exec <command> <args...>`.
287
+ *
288
+ * Returns { command, args, options }. This function ONLY spawns — it never
289
+ * kills (kill semantics remain the sole responsibility of wsl_bridge.js).
290
+ *
291
+ * @param {object} spec
292
+ * @param {string} spec.command executable to run (inside the target env)
293
+ * @param {string[]} spec.args its arguments
294
+ * @param {string} [spec.cwd] working dir (Windows or WSL path)
295
+ * @param {object} [spec.env] extra env vars (merged over process.env)
296
+ * @param {"windows"|"wsl"} [spec.mode="windows"]
297
+ * @param {Array|string} [spec.stdio] defaults to ["ignore","pipe","pipe"]
298
+ * @param {string} [spec.user] if set, adds `--user <user>` in wsl mode
299
+ * @param {boolean} [spec.useCd=true] whether to add `--cd` in wsl mode
300
+ * @param {boolean} [spec.detached] override the default detached flag
301
+ */
302
+ export function buildSpawnProfile({
303
+ command,
304
+ args = [],
305
+ cwd,
306
+ env,
307
+ mode = "windows",
308
+ stdio,
309
+ user,
310
+ useCd = true,
311
+ detached,
312
+ } = {}) {
313
+ const stdioFinal = stdio || ["ignore", "pipe", "pipe"];
314
+ const mergedEnv = { ...process.env, ...(env || {}) };
315
+
316
+ if (mode === "wsl") {
317
+ const distro = wslDistro();
318
+ const wslArgs = ["-d", distro];
319
+ if (user) wslArgs.push("--user", user);
320
+ if (useCd && cwd) wslArgs.push("--cd", toPosixWslPath(cwd));
321
+ wslArgs.push("--exec", command, ...args);
322
+ return {
323
+ command: "wsl.exe",
324
+ args: wslArgs,
325
+ options: {
326
+ env: mergedEnv,
327
+ stdio: stdioFinal,
328
+ detached: false,
329
+ },
330
+ };
331
+ }
332
+
333
+ // "windows" mode: spawn directly (cwd passed through as-is).
334
+ return {
335
+ command,
336
+ args,
337
+ options: {
338
+ cwd: cwd || undefined,
339
+ env: mergedEnv,
340
+ stdio: stdioFinal,
341
+ detached: detached !== undefined ? detached : !IS_WINDOWS,
342
+ },
343
+ };
344
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Stateful degenerate-repetition guard for SSE streams.
3
+ *
4
+ * Detects two classes of runaway repetition:
5
+ * - Single-character runs, governed by tiered limits:
6
+ * CODE_LIMIT (1000) for code/diff-significant characters, DIVIDER_LIMIT
7
+ * (250) for whitespace/markdown dividers, and DEFAULT_LIMIT (100) for
8
+ * everything else.
9
+ * - Multi-character pattern loops (a unit of 4-32 characters repeated
10
+ * PATTERN_REPEAT_COUNT times), excluding "pure" runs made entirely of
11
+ * code-significant or divider characters (those are covered by the
12
+ * single-character tiered limits).
13
+ */
14
+
15
+ /**
16
+ * Stable prefix of the stream-proxy guard marker. The stream proxy appends
17
+ * this marker (with the detected repetition type and pattern interpolated)
18
+ * to a stream it has circuit-broken for runaway repetition; the Castor runner
19
+ * (src/harness/runner.js) matches on this prefix to detect guard-truncated
20
+ * degenerate finals and strips the full marker when measuring the
21
+ * substantive remainder.
22
+ * @type {string}
23
+ */
24
+ export const GUARD_MARKER_PREFIX =
25
+ "[StreamProxy Guard: Runaway repetition loop (";
26
+
27
+ /**
28
+ * Template for the full stream-proxy guard marker. The `${type}` and
29
+ * `${pattern}` placeholders are interpolated by the breaker with the detected
30
+ * repetition type (e.g. "character" / "pattern") and the repeated unit.
31
+ * @type {string}
32
+ */
33
+ export const GUARD_MARKER_TEMPLATE =
34
+ "\n\n[StreamProxy Guard: Runaway repetition loop (${type}: ${pattern}) detected and safely truncated]\n\n";
35
+
36
+ // Characters that appear in long consecutive runs in model output (git-diff
37
+ // '+' hunks, code, URLs, JSON, math).
38
+ const CODE_REPEAT_CHARS = new Set(
39
+ "+./\\<>|:;()[]{}'\"!?,~^&%$@".split("")
40
+ );
41
+
42
+ // Whitespace / standard markdown divider characters (governed by
43
+ // DIVIDER_LIMIT).
44
+ const DIVIDER_CHARS = new Set(["-", "=", "*", "#", " ", "\t", "\n", "_"]);
45
+
46
+ // Union of code-significant and divider/whitespace characters. A repeating
47
+ // unit made entirely of these is a "pure" run (e.g. a git-diff '+' hunk, a
48
+ // '====' divider, a '////' comment) and is governed by the single-character
49
+ // tiered limits; the block-level detector skips it. Multi-character phrase
50
+ // loops (e.g. "the the the") contain letters outside this set and are caught
51
+ // by the block detector.
52
+ const PURE_RUN_CHARS = new Set([...CODE_REPEAT_CHARS, ...DIVIDER_CHARS]);
53
+
54
+ const CODE_LIMIT = 1000;
55
+ const DIVIDER_LIMIT = 250;
56
+ const DEFAULT_LIMIT = 100;
57
+ const PATTERN_REPEAT_COUNT = 40;
58
+
59
+ /**
60
+ * Stateful detector for runaway repetition in a streamed text stream. Feed
61
+ * chunks via {@link RepetitionDetector#feed}; it tracks the trailing
62
+ * single-character run and a rolling window of recent text for
63
+ * multi-character pattern loops.
64
+ */
65
+ export class RepetitionDetector {
66
+ constructor() {
67
+ this.lastChar = "";
68
+ this.charRepeatCount = 0;
69
+ this.rolling = "";
70
+ }
71
+
72
+ /**
73
+ * Feeds a chunk of streamed text into the detector.
74
+ * @param {string} text - The next chunk of streamed text.
75
+ * @returns {{type: string, pattern: string, count: number}|null} A
76
+ * detection object (`type` is "character" or "pattern", `pattern` is the
77
+ * repeated unit, `count` is the run length) when degenerate repetition is
78
+ * detected, or null otherwise.
79
+ */
80
+ feed(text) {
81
+ if (!text || typeof text !== "string") return null;
82
+
83
+ // 1. Single character consecutive repetition (e.g. "!!!!!!!!!!!!!!!!...")
84
+ for (let i = 0; i < text.length; i++) {
85
+ const ch = text[i];
86
+ if (ch === this.lastChar) {
87
+ this.charRepeatCount++;
88
+ let limit;
89
+ if (CODE_REPEAT_CHARS.has(ch)) {
90
+ limit = CODE_LIMIT;
91
+ } else if (DIVIDER_CHARS.has(ch)) {
92
+ limit = DIVIDER_LIMIT;
93
+ } else {
94
+ limit = DEFAULT_LIMIT;
95
+ }
96
+ if (this.charRepeatCount >= limit) {
97
+ return { type: "character", pattern: ch, count: this.charRepeatCount };
98
+ }
99
+ } else {
100
+ this.lastChar = ch;
101
+ this.charRepeatCount = 1;
102
+ }
103
+ }
104
+
105
+ // 2. Multi-character pattern repetition (e.g. repeating phrases or tokens)
106
+ this.rolling = (this.rolling + text).slice(-1500);
107
+ const len = this.rolling.length;
108
+ for (let unitLen = 4; unitLen <= 32; unitLen++) {
109
+ const neededLen = unitLen * PATTERN_REPEAT_COUNT;
110
+ if (len < neededLen) continue;
111
+ const unit = this.rolling.slice(-unitLen);
112
+ // Skip "pure" runs: units made entirely of code-significant or
113
+ // divider/whitespace characters (e.g. "++", "====", "////", "|---|"). These are
114
+ // governed by the single-character tiered limits above, so the
115
+ // block-level detector must not also trip on them.
116
+ let isPureRun = true;
117
+ for (let k = 0; k < unit.length; k++) {
118
+ if (!PURE_RUN_CHARS.has(unit[k])) {
119
+ isPureRun = false;
120
+ break;
121
+ }
122
+ }
123
+ if (isPureRun) continue;
124
+ let isRep = true;
125
+ for (let r = 1; r < PATTERN_REPEAT_COUNT; r++) {
126
+ const seg = this.rolling.slice(len - (r + 1) * unitLen, len - r * unitLen);
127
+ if (seg !== unit) {
128
+ isRep = false;
129
+ break;
130
+ }
131
+ }
132
+ if (isRep) {
133
+ return { type: "pattern", pattern: unit, count: PATTERN_REPEAT_COUNT };
134
+ }
135
+ }
136
+
137
+ return null;
138
+ }
139
+ }