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.
- package/README.md +487 -0
- package/bin/castor.js +706 -0
- package/index.js +206 -0
- package/package.json +97 -0
- package/skills/canary-test-staging/SKILL.md +24 -0
- package/skills/evo-mutation-rollback/SKILL.md +29 -0
- package/skills/hypothesis-generation/SKILL.md +26 -0
- package/skills/traceback-condensing/SKILL.md +26 -0
- package/src/castor_runner.js +469 -0
- package/src/config.js +1204 -0
- package/src/env.js +10 -0
- package/src/evo_engine.js +214 -0
- package/src/harness/core/events.js +75 -0
- package/src/harness/core/kernel.js +209 -0
- package/src/harness/evo/evaluator.js +156 -0
- package/src/harness/evo/evo_operator.js +550 -0
- package/src/harness/evo/lineage_dag.js +383 -0
- package/src/harness/evo/trace_repair.js +173 -0
- package/src/harness/evo/watchdog.js +72 -0
- package/src/harness/loop_detector.js +135 -0
- package/src/harness/runner.js +1216 -0
- package/src/harness/services/ast_service.js +1813 -0
- package/src/harness/services/event_logger.js +275 -0
- package/src/harness/services/mcp_bridge.js +408 -0
- package/src/harness/services/provider_vllm.js +728 -0
- package/src/harness/services/sandbox_fs.js +1238 -0
- package/src/harness/services/searxng_lifecycle.js +254 -0
- package/src/harness/services/shell_executor.js +264 -0
- package/src/harness/services/shell_validator.js +506 -0
- package/src/harness/services/web_service.js +828 -0
- package/src/platform.js +344 -0
- package/src/repetition_detector.js +139 -0
- package/src/semaphore.js +373 -0
- package/src/server_lifecycle.js +781 -0
- package/src/skills.js +400 -0
- package/src/state_pruner.js +392 -0
- package/src/task_registry.js +1357 -0
- package/src/telemetry.js +638 -0
- package/src/tools.js +997 -0
- package/src/wsl_bridge.js +629 -0
- package/src/wsl_env.js +171 -0
- package/stream_proxy.js +453 -0
package/src/platform.js
ADDED
|
@@ -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
|
+
}
|