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/skills.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/skills.js - Packaged skills library with keyword auto-injection.
|
|
3
|
+
*
|
|
4
|
+
* A "skill" is a reusable workflow recipe stored at:
|
|
5
|
+
*
|
|
6
|
+
* <repo>/skills/<name>/SKILL.md
|
|
7
|
+
*
|
|
8
|
+
* Each SKILL.md has a tiny YAML-ish frontmatter block (the lines between the
|
|
9
|
+
* first pair of `---` markers) followed by a markdown workflow body:
|
|
10
|
+
*
|
|
11
|
+
* ---
|
|
12
|
+
* name: my-skill
|
|
13
|
+
* description: One-line summary
|
|
14
|
+
* keywords: [traceback, stack trace, error digest]
|
|
15
|
+
* ---
|
|
16
|
+
* <workflow body in markdown>
|
|
17
|
+
*
|
|
18
|
+
* The parser is intentionally tiny and dependency-free:
|
|
19
|
+
* - frontmatter = lines between the first `---` and the next `---`
|
|
20
|
+
* - `key: value` lines; `keywords` may be a JSON-ish list `[a, b, c]`
|
|
21
|
+
* or a plain comma-separated string
|
|
22
|
+
* - the body is everything after the closing `---`
|
|
23
|
+
*
|
|
24
|
+
* `matchSkills({ prompt, cwd })` returns the skills whose keywords appear in
|
|
25
|
+
* the prompt (case-insensitive substring / word match) OR in the cwd path
|
|
26
|
+
* string (so a repo named "evo" can trigger an evo skill). Results are
|
|
27
|
+
* additive and budget-capped: at most 3 skills, each body capped at ~2000
|
|
28
|
+
* chars, and a total injected budget of ~6000 chars.
|
|
29
|
+
*
|
|
30
|
+
* `injectSkills(prompt, cwd)` appends a clearly-delimited block to the prompt
|
|
31
|
+
* ONLY when there are matches; otherwise it returns the prompt unchanged.
|
|
32
|
+
*
|
|
33
|
+
* Never-throw discipline: unreadable or malformed skills are skipped with a
|
|
34
|
+
* stderr note so a bad skill can never take down a dispatch.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Budgets
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Max number of skills injected per dispatch. */
|
|
46
|
+
export const MAX_SKILLS = 3;
|
|
47
|
+
/** Max characters of a single skill body before it is truncated. */
|
|
48
|
+
export const MAX_BODY_CHARS = 2000;
|
|
49
|
+
/** Max total characters across all injected skill bodies. */
|
|
50
|
+
export const MAX_TOTAL_CHARS = 6000;
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Skills directory resolution (mirrors streamProxyPath in platform.js)
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the repo-root `skills/` directory from this module's own location
|
|
58
|
+
* so it is correct regardless of process.cwd(). This file lives at
|
|
59
|
+
* `<repo>/src/skills.js`, so the repo root is two levels up.
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function skillsDir() {
|
|
63
|
+
const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
64
|
+
return path.join(repoRoot, "skills");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Frontmatter parsing
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Split a SKILL.md document into { frontmatter, body }.
|
|
73
|
+
*
|
|
74
|
+
* The frontmatter is the block between the FIRST `---` line and the NEXT
|
|
75
|
+
* `---` line. If the document does not open with `---` (or has no closing
|
|
76
|
+
* `---`), the whole document is treated as the body with empty frontmatter.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} text
|
|
79
|
+
* @returns {{ frontmatter: string, body: string }}
|
|
80
|
+
*/
|
|
81
|
+
export function splitFrontmatter(text) {
|
|
82
|
+
// Normalize line endings so the split is stable across CRLF/LF files.
|
|
83
|
+
const normalized = String(text).replace(/\r\n/g, "\n");
|
|
84
|
+
const lines = normalized.split("\n");
|
|
85
|
+
|
|
86
|
+
// The first non-empty line must be exactly `---` to open frontmatter.
|
|
87
|
+
let openIdx = -1;
|
|
88
|
+
for (let i = 0; i < lines.length; i++) {
|
|
89
|
+
if (lines[i].trim() === "") continue;
|
|
90
|
+
if (lines[i].trim() === "---") openIdx = i;
|
|
91
|
+
break; // only the first non-empty line can open it
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (openIdx === -1) {
|
|
95
|
+
return { frontmatter: "", body: normalized };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let closeIdx = -1;
|
|
99
|
+
for (let i = openIdx + 1; i < lines.length; i++) {
|
|
100
|
+
if (lines[i].trim() === "---") {
|
|
101
|
+
closeIdx = i;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (closeIdx === -1) {
|
|
107
|
+
// Opened but never closed: treat the whole thing as body (malformed).
|
|
108
|
+
return { frontmatter: "", body: normalized };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const frontmatter = lines.slice(openIdx + 1, closeIdx).join("\n");
|
|
112
|
+
const body = lines.slice(closeIdx + 1).join("\n").trim();
|
|
113
|
+
return { frontmatter, body };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse a `keywords` value that may be a JSON-ish list `[a, b, c]` or a
|
|
118
|
+
* plain comma-separated string `a, b, c`.
|
|
119
|
+
* @param {string} raw
|
|
120
|
+
* @returns {string[]}
|
|
121
|
+
*/
|
|
122
|
+
function parseKeywords(raw) {
|
|
123
|
+
if (raw == null) return [];
|
|
124
|
+
let s = String(raw).trim();
|
|
125
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
126
|
+
s = s.slice(1, -1);
|
|
127
|
+
}
|
|
128
|
+
return s
|
|
129
|
+
.split(",")
|
|
130
|
+
.map((k) => k.trim().replace(/^["']|["']$/g, ""))
|
|
131
|
+
.filter((k) => k.length > 0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse a frontmatter block into a plain object. Only `key: value` lines are
|
|
136
|
+
* honored; anything else is ignored. Values are trimmed and de-quoted.
|
|
137
|
+
* @param {string} frontmatter
|
|
138
|
+
* @returns {Record<string, string>}
|
|
139
|
+
*/
|
|
140
|
+
export function parseFrontmatter(frontmatter) {
|
|
141
|
+
const out = {};
|
|
142
|
+
if (!frontmatter) return out;
|
|
143
|
+
for (const line of frontmatter.split("\n")) {
|
|
144
|
+
const trimmed = line.trim();
|
|
145
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
146
|
+
const colon = trimmed.indexOf(":");
|
|
147
|
+
if (colon === -1) continue;
|
|
148
|
+
const key = trimmed.slice(0, colon).trim();
|
|
149
|
+
let value = trimmed.slice(colon + 1).trim();
|
|
150
|
+
// Strip a single pair of surrounding quotes.
|
|
151
|
+
if (
|
|
152
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
153
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
154
|
+
) {
|
|
155
|
+
value = value.slice(1, -1);
|
|
156
|
+
}
|
|
157
|
+
if (key) out[key] = value;
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Parse a single SKILL.md file into a skill record.
|
|
164
|
+
* @param {string} text
|
|
165
|
+
* @param {string} dirName
|
|
166
|
+
* @returns {{ name: string, description: string, keywords: string[], body: string }|null}
|
|
167
|
+
*/
|
|
168
|
+
export function parseSkill(text, dirName) {
|
|
169
|
+
const { frontmatter, body } = splitFrontmatter(text);
|
|
170
|
+
const fm = parseFrontmatter(frontmatter);
|
|
171
|
+
const name = (fm.name || dirName).trim() || dirName;
|
|
172
|
+
const description = (fm.description || "").trim();
|
|
173
|
+
const keywords = parseKeywords(fm.keywords);
|
|
174
|
+
return { name, description, keywords, body };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Loading
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Load and parse every skill under a skills directory.
|
|
183
|
+
* Unreadable or malformed entries are skipped with a stderr note.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} [dir] skills directory (defaults to the repo-root skills/)
|
|
186
|
+
* @returns {Array<{ name: string, description: string, keywords: string[], body: string }>}
|
|
187
|
+
*/
|
|
188
|
+
export function loadSkills(dir = skillsDir()) {
|
|
189
|
+
const out = [];
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
193
|
+
} catch {
|
|
194
|
+
// No skills/ directory at all: that is fine, just no skills.
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
if (!entry.isDirectory()) continue;
|
|
200
|
+
const skillFile = path.join(dir, entry.name, "SKILL.md");
|
|
201
|
+
let text;
|
|
202
|
+
try {
|
|
203
|
+
text = fs.readFileSync(skillFile, "utf8");
|
|
204
|
+
} catch {
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
`[skills] skipping '${entry.name}': SKILL.md not found or unreadable\n`
|
|
207
|
+
);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const skill = parseSkill(text, entry.name);
|
|
212
|
+
if (!skill || !skill.body) {
|
|
213
|
+
process.stderr.write(
|
|
214
|
+
`[skills] skipping '${entry.name}': empty or malformed body\n`
|
|
215
|
+
);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
out.push(skill);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
process.stderr.write(
|
|
221
|
+
`[skills] skipping '${entry.name}': ${err.message}\n`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Introspection helper: list all loaded skills (name + description + keywords).
|
|
230
|
+
* @param {string} [dir] skills directory override (defaults to repo-root skills/)
|
|
231
|
+
* @returns {Array<{ name: string, description: string, keywords: string[] }>}
|
|
232
|
+
*/
|
|
233
|
+
export function listSkills(dir) {
|
|
234
|
+
return loadSkills(dir).map(({ name, description, keywords }) => ({
|
|
235
|
+
name,
|
|
236
|
+
description,
|
|
237
|
+
keywords,
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Matching
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Case-insensitive substring / word match of a keyword against a haystack.
|
|
247
|
+
* @param {string} keyword
|
|
248
|
+
* @param {string} haystack
|
|
249
|
+
* @returns {boolean}
|
|
250
|
+
*/
|
|
251
|
+
function keywordMatches(keyword, haystack) {
|
|
252
|
+
if (!keyword || !haystack) return false;
|
|
253
|
+
return haystack.toLowerCase().includes(keyword.toLowerCase());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Extracts terms following negative intent keywords (e.g. purge, remove, eliminate, delete, avoid, without).
|
|
258
|
+
* Strips common stop-words like 'all', 'the', 'any'.
|
|
259
|
+
* @param {string} text
|
|
260
|
+
* @returns {Set<string>}
|
|
261
|
+
*/
|
|
262
|
+
export function extractNegatedKeywords(text) {
|
|
263
|
+
const negated = new Set();
|
|
264
|
+
if (!text) return negated;
|
|
265
|
+
const re = /\b(?:purge|eliminate|remove|delete|deprecate|scrub|do\s+not\s+use|avoid|without)\b(?:\s+(?:all|the|any))?\s+([a-zA-Z0-9_\-]+)/gi;
|
|
266
|
+
let m;
|
|
267
|
+
while ((m = re.exec(text)) !== null) {
|
|
268
|
+
if (m[1]) negated.add(m[1].toLowerCase());
|
|
269
|
+
}
|
|
270
|
+
return negated;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Checks whether a skill's name or any of its keywords match any extracted negated term.
|
|
275
|
+
* @param {{ name: string, keywords: string[] }} skill
|
|
276
|
+
* @param {Set<string>} negatedKeywords
|
|
277
|
+
* @returns {boolean}
|
|
278
|
+
*/
|
|
279
|
+
export function isSkillNegated(skill, negatedKeywords) {
|
|
280
|
+
if (!skill || !negatedKeywords || negatedKeywords.size === 0) return false;
|
|
281
|
+
const nameLower = (skill.name || "").toLowerCase();
|
|
282
|
+
for (const neg of negatedKeywords) {
|
|
283
|
+
if (nameLower === neg || nameLower.includes(neg)) return true;
|
|
284
|
+
if (Array.isArray(skill.keywords)) {
|
|
285
|
+
for (const kw of skill.keywords) {
|
|
286
|
+
const kwLower = String(kw || "").toLowerCase();
|
|
287
|
+
if (kwLower === neg || kwLower.includes(neg)) return true;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Find skills whose keywords match the prompt text OR the cwd path string.
|
|
296
|
+
* Results are additive and budget-capped (MAX_SKILLS, MAX_BODY_CHARS,
|
|
297
|
+
* MAX_TOTAL_CHARS).
|
|
298
|
+
*
|
|
299
|
+
* If `explicitSkills` is provided as an array, keyword matching is bypassed and
|
|
300
|
+
* exactly those skills are returned (or an Error is thrown if an explicit skill
|
|
301
|
+
* does not exist).
|
|
302
|
+
*
|
|
303
|
+
* Excludes any skill whose name or keywords match terms following negative
|
|
304
|
+
* directives (e.g., "purge avo" blacklists avo skills to prevent contradictory
|
|
305
|
+
* instruction loops).
|
|
306
|
+
*
|
|
307
|
+
* @param {{ prompt?: string, cwd?: string, dir?: string, explicitSkills?: string[] }} params
|
|
308
|
+
* @returns {Array<{ name: string, description: string, body: string }>}
|
|
309
|
+
*/
|
|
310
|
+
export function matchSkills({ prompt = "", cwd = "", dir, explicitSkills } = {}) {
|
|
311
|
+
const all = loadSkills(dir);
|
|
312
|
+
|
|
313
|
+
let candidateSkills = [];
|
|
314
|
+
|
|
315
|
+
if (Array.isArray(explicitSkills) && explicitSkills.length > 0) {
|
|
316
|
+
const skillMap = new Map(all.map((s) => [s.name.toLowerCase(), s]));
|
|
317
|
+
for (const name of explicitSkills) {
|
|
318
|
+
const found = skillMap.get(String(name).toLowerCase());
|
|
319
|
+
if (!found) {
|
|
320
|
+
throw new Error(`Explicit skill '${name}' requested but not found`);
|
|
321
|
+
}
|
|
322
|
+
candidateSkills.push(found);
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
const promptStr = String(prompt || "");
|
|
326
|
+
const cwdStr = String(cwd || "");
|
|
327
|
+
const negatedKeywords = extractNegatedKeywords(promptStr);
|
|
328
|
+
|
|
329
|
+
for (const skill of all) {
|
|
330
|
+
if (!Array.isArray(skill.keywords) || skill.keywords.length === 0) continue;
|
|
331
|
+
if (isSkillNegated(skill, negatedKeywords)) continue;
|
|
332
|
+
const hit = skill.keywords.some(
|
|
333
|
+
(kw) => keywordMatches(kw, promptStr) || keywordMatches(kw, cwdStr)
|
|
334
|
+
);
|
|
335
|
+
if (hit) candidateSkills.push(skill);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Cap the number of skills first.
|
|
340
|
+
const capped = candidateSkills.slice(0, MAX_SKILLS);
|
|
341
|
+
|
|
342
|
+
// Enforce the per-body and total budgets.
|
|
343
|
+
const out = [];
|
|
344
|
+
let total = 0;
|
|
345
|
+
for (const skill of capped) {
|
|
346
|
+
let body = skill.body;
|
|
347
|
+
if (body.length > MAX_BODY_CHARS) {
|
|
348
|
+
body = body.slice(0, MAX_BODY_CHARS) + "\n...[truncated]";
|
|
349
|
+
}
|
|
350
|
+
if (total + body.length > MAX_TOTAL_CHARS) {
|
|
351
|
+
// Truncate to fit the remaining budget; drop if nothing fits.
|
|
352
|
+
const remaining = MAX_TOTAL_CHARS - total;
|
|
353
|
+
if (remaining <= 0) break;
|
|
354
|
+
body = body.slice(0, remaining) + "\n...[truncated]";
|
|
355
|
+
}
|
|
356
|
+
total += body.length;
|
|
357
|
+
out.push({ name: skill.name, description: skill.description, body });
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Injection
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Append a clearly-delimited skills block to the prompt ONLY when there are
|
|
368
|
+
* matching skills. Returns the prompt unchanged when nothing matches.
|
|
369
|
+
*
|
|
370
|
+
* @param {string} prompt
|
|
371
|
+
* @param {string} [cwd]
|
|
372
|
+
* @param {string} [dir] skills directory override (defaults to repo-root skills/)
|
|
373
|
+
* @param {string[]} [explicitSkills]
|
|
374
|
+
* @returns {string}
|
|
375
|
+
*/
|
|
376
|
+
export function injectSkills(prompt, cwd, dir, explicitSkills) {
|
|
377
|
+
if (typeof prompt !== "string") return prompt;
|
|
378
|
+
if (prompt.includes("--- Matching skills (auto-injected from skills/) ---")) {
|
|
379
|
+
return prompt;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const matches = matchSkills({ prompt, cwd, dir, explicitSkills });
|
|
383
|
+
if (matches.length === 0) return prompt;
|
|
384
|
+
|
|
385
|
+
const parts = [
|
|
386
|
+
"",
|
|
387
|
+
"",
|
|
388
|
+
"--- Matching skills (auto-injected from skills/) ---",
|
|
389
|
+
];
|
|
390
|
+
const seen = new Set();
|
|
391
|
+
for (const s of matches) {
|
|
392
|
+
if (seen.has(s.name)) continue;
|
|
393
|
+
seen.add(s.name);
|
|
394
|
+
parts.push(`### ${s.name}`);
|
|
395
|
+
parts.push(s.body);
|
|
396
|
+
parts.push("");
|
|
397
|
+
}
|
|
398
|
+
return `${prompt}\n${parts.join("\n")}`.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
399
|
+
}
|
|
400
|
+
|