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,392 @@
1
+ /**
2
+ * State Directory Streamlining & Retention (Castor Harness)
3
+ *
4
+ * Provides:
5
+ * - pruneOldSessions: evict old session directories based on age, count, and size gates
6
+ * - cleanStateDir: full state-dir cleanup (sessions + orphan .tmp_* files) with throttling
7
+ *
8
+ * Invariant: NEVER prune active/open sessions or sessions referenced by incomplete tasks.
9
+ *
10
+ * Run: node src/state_pruner.js (self-test mode, optional)
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { QWEN_STATE_DIR, TASK_DIR } from "./config.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Defaults
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const DEFAULT_MAX_AGE_MS = 14 * 86_400_000; // 14 days
22
+ const DEFAULT_MAX_COUNT = 200;
23
+ const DEFAULT_MAX_SIZE_MB = 50;
24
+ const PRUNE_THROTTLE_MS = 24 * 3600_000; // 24 hours
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Returns true when the session's event stream carries a terminal event
32
+ * (session_end or session_error). A session without a terminal event is
33
+ * considered "active/open".
34
+ */
35
+ function sessionHasTerminalEvent(sessionDir) {
36
+ const logFile = path.join(sessionDir, "events.jsonl");
37
+ if (!fs.existsSync(logFile)) return false;
38
+ let content;
39
+ try {
40
+ content = fs.readFileSync(logFile, "utf8");
41
+ } catch {
42
+ return false;
43
+ }
44
+ for (const line of content.split("\n")) {
45
+ if (!line.trim()) continue;
46
+ let ev;
47
+ try {
48
+ ev = JSON.parse(line);
49
+ } catch {
50
+ continue;
51
+ }
52
+ if (ev && (ev.type === "session_end" || ev.type === "session_error")) {
53
+ return true;
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+
59
+ /**
60
+ * Returns the set of session IDs referenced by incomplete (not-done) tasks
61
+ * on disk.
62
+ */
63
+ function getActiveTaskSessionIds() {
64
+ const ids = new Set();
65
+ if (!fs.existsSync(TASK_DIR)) return ids;
66
+ let entries;
67
+ try {
68
+ entries = fs.readdirSync(TASK_DIR);
69
+ } catch {
70
+ return ids;
71
+ }
72
+ for (const f of entries) {
73
+ if (!f.endsWith(".json")) continue;
74
+ let task;
75
+ try {
76
+ task = JSON.parse(fs.readFileSync(path.join(TASK_DIR, f), "utf8"));
77
+ } catch {
78
+ continue;
79
+ }
80
+ if (task && !task.done && task.sessionId) {
81
+ ids.add(task.sessionId);
82
+ }
83
+ }
84
+ return ids;
85
+ }
86
+
87
+ /**
88
+ * Returns the total size in bytes of a directory (recursive).
89
+ */
90
+ function dirSizeBytes(dir) {
91
+ let total = 0;
92
+ let entries;
93
+ try {
94
+ entries = fs.readdirSync(dir, { withFileTypes: true });
95
+ } catch {
96
+ return 0;
97
+ }
98
+ for (const entry of entries) {
99
+ const full = path.join(dir, entry.name);
100
+ if (entry.isDirectory()) {
101
+ total += dirSizeBytes(full);
102
+ } else if (entry.isFile()) {
103
+ try {
104
+ total += fs.statSync(full).size;
105
+ } catch {
106
+ // ignore
107
+ }
108
+ }
109
+ }
110
+ return total;
111
+ }
112
+
113
+ /**
114
+ * Returns the mtime of a session directory (uses events.jsonl mtime if
115
+ * available, else the directory mtime).
116
+ */
117
+ function sessionMtime(sessionDir) {
118
+ const logFile = path.join(sessionDir, "events.jsonl");
119
+ try {
120
+ if (fs.existsSync(logFile)) {
121
+ return fs.statSync(logFile).mtimeMs;
122
+ }
123
+ } catch {}
124
+ try {
125
+ return fs.statSync(sessionDir).mtimeMs;
126
+ } catch {
127
+ return 0;
128
+ }
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // pruneOldSessions
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Prunes old session directories from the state directory.
137
+ *
138
+ * @param {object} [options]
139
+ * @param {string} [options.stateDir] Root state dir (default: QWEN_STATE_DIR)
140
+ * @param {number} [options.maxAgeMs] Max age in ms (default: 14 days)
141
+ * @param {number} [options.maxCount] Max number of sessions to keep (default: 200)
142
+ * @param {number} [options.maxSizeMb] Max total size in MB (default: 50)
143
+ * @param {boolean} [options.dryRun] If true, report without deleting
144
+ * @param {number} [options.now] Override current time (for tests)
145
+ * @returns {{ pruned: number, bytesReclaimed: number, protected: number, skipped: number }}
146
+ */
147
+ export function pruneOldSessions(options = {}) {
148
+ const stateDir = options.stateDir || QWEN_STATE_DIR;
149
+ const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
150
+ const maxCount = options.maxCount ?? DEFAULT_MAX_COUNT;
151
+ const maxSizeMb = options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB;
152
+ const dryRun = Boolean(options.dryRun);
153
+ const now = options.now ?? Date.now();
154
+
155
+ const sessionsDir = path.join(stateDir, "sessions");
156
+ if (!fs.existsSync(sessionsDir)) {
157
+ return { pruned: 0, bytesReclaimed: 0, protected: 0, skipped: 0 };
158
+ }
159
+
160
+ // Collect session directories with metadata.
161
+ let entries;
162
+ try {
163
+ entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
164
+ } catch {
165
+ return { pruned: 0, bytesReclaimed: 0, protected: 0, skipped: 0 };
166
+ }
167
+
168
+ const sessions = [];
169
+ for (const entry of entries) {
170
+ if (!entry.isDirectory()) continue;
171
+ const sessionDir = path.join(sessionsDir, entry.name);
172
+ const mtime = sessionMtime(sessionDir);
173
+ const size = dirSizeBytes(sessionDir);
174
+ const hasTerminal = sessionHasTerminalEvent(sessionDir);
175
+ sessions.push({
176
+ id: entry.name,
177
+ dir: sessionDir,
178
+ mtime,
179
+ size,
180
+ hasTerminal,
181
+ });
182
+ }
183
+
184
+ // Get active task session IDs (invariant: never prune these).
185
+ const activeTaskSessionIds = getActiveTaskSessionIds();
186
+
187
+ // Classify: protected vs candidates.
188
+ const protectedSessions = [];
189
+ const candidates = [];
190
+ for (const s of sessions) {
191
+ // Invariant: never prune active/open sessions.
192
+ if (!s.hasTerminal) {
193
+ protectedSessions.push(s);
194
+ continue;
195
+ }
196
+ // Invariant: never prune sessions referenced by incomplete tasks.
197
+ if (activeTaskSessionIds.has(s.id)) {
198
+ protectedSessions.push(s);
199
+ continue;
200
+ }
201
+ candidates.push(s);
202
+ }
203
+
204
+ // Sort candidates by mtime ascending (oldest first).
205
+ candidates.sort((a, b) => a.mtime - b.mtime);
206
+
207
+ // Determine which candidates to prune.
208
+ const toPrune = new Set();
209
+ const maxSizeBytes = maxSizeMb * 1024 * 1024;
210
+
211
+ // Gate 1: age — prune sessions older than maxAgeMs.
212
+ for (const s of candidates) {
213
+ if (now - s.mtime > maxAgeMs) {
214
+ toPrune.add(s.id);
215
+ }
216
+ }
217
+
218
+ // Gate 2: count — if total sessions (protected + candidates) exceeds maxCount,
219
+ // evict oldest candidates until we're at or below maxCount.
220
+ const totalSessions = sessions.length;
221
+ if (totalSessions > maxCount) {
222
+ let excess = totalSessions - maxCount;
223
+ for (const s of candidates) {
224
+ if (excess <= 0) break;
225
+ if (!toPrune.has(s.id)) {
226
+ toPrune.add(s.id);
227
+ excess--;
228
+ }
229
+ }
230
+ }
231
+
232
+ // Gate 3: size — if total size of all sessions exceeds maxSizeMb,
233
+ // evict oldest candidates until total is at or below the limit.
234
+ let totalSize = 0;
235
+ for (const s of sessions) {
236
+ totalSize += s.size;
237
+ }
238
+ if (totalSize > maxSizeBytes) {
239
+ let remaining = totalSize;
240
+ for (const s of candidates) {
241
+ if (remaining <= maxSizeBytes) break;
242
+ if (!toPrune.has(s.id)) {
243
+ toPrune.add(s.id);
244
+ remaining -= s.size;
245
+ }
246
+ }
247
+ }
248
+
249
+ // Execute (or simulate) the prune.
250
+ let pruned = 0;
251
+ let bytesReclaimed = 0;
252
+ for (const s of candidates) {
253
+ if (!toPrune.has(s.id)) continue;
254
+ if (dryRun) {
255
+ pruned++;
256
+ bytesReclaimed += s.size;
257
+ } else {
258
+ try {
259
+ fs.rmSync(s.dir, { recursive: true, force: true });
260
+ pruned++;
261
+ bytesReclaimed += s.size;
262
+ } catch {
263
+ // Best-effort: skip on failure.
264
+ }
265
+ }
266
+ }
267
+
268
+ return {
269
+ pruned,
270
+ bytesReclaimed,
271
+ protected: protectedSessions.length,
272
+ skipped: candidates.length - pruned,
273
+ };
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // cleanStateDir
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Full state-directory cleanup: prunes old sessions and removes orphan
282
+ * .tmp_* files in the tasks directory.
283
+ *
284
+ * @param {object} [options]
285
+ * @param {string} [options.stateDir] Root state dir (default: QWEN_STATE_DIR)
286
+ * @param {number} [options.maxAgeMs] Max age in ms (default: 14 days)
287
+ * @param {number} [options.maxCount] Max number of sessions (default: 200)
288
+ * @param {number} [options.maxSizeMb] Max total size in MB (default: 50)
289
+ * @param {boolean} [options.dryRun] If true, report without deleting
290
+ * @param {boolean} [options.throttled] If true, skip if last prune was < 24h ago
291
+ * @param {number} [options.now] Override current time (for tests)
292
+ * @returns {{
293
+ * sessionsPruned: number,
294
+ * bytesReclaimed: number,
295
+ * tmpFilesCleaned: number,
296
+ * tmpBytesReclaimed: number,
297
+ * skipped: boolean,
298
+ * protected: number,
299
+ * }}
300
+ */
301
+ export function cleanStateDir(options = {}) {
302
+ const stateDir = options.stateDir || QWEN_STATE_DIR;
303
+ const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
304
+ const maxCount = options.maxCount ?? DEFAULT_MAX_COUNT;
305
+ const maxSizeMb = options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB;
306
+ const dryRun = Boolean(options.dryRun);
307
+ const throttled = Boolean(options.throttled);
308
+ const now = options.now ?? Date.now();
309
+
310
+ // Throttle: check .last_prune timestamp.
311
+ if (throttled) {
312
+ const lastPruneFile = path.join(stateDir, ".last_prune");
313
+ try {
314
+ if (fs.existsSync(lastPruneFile)) {
315
+ const lastPrune = parseInt(
316
+ fs.readFileSync(lastPruneFile, "utf8").trim(),
317
+ 10
318
+ );
319
+ if (Number.isFinite(lastPrune) && now - lastPrune < PRUNE_THROTTLE_MS) {
320
+ return {
321
+ sessionsPruned: 0,
322
+ bytesReclaimed: 0,
323
+ tmpFilesCleaned: 0,
324
+ tmpBytesReclaimed: 0,
325
+ skipped: true,
326
+ protected: 0,
327
+ };
328
+ }
329
+ }
330
+ } catch {
331
+ // Corrupt or unreadable .last_prune: proceed with cleanup.
332
+ }
333
+ }
334
+
335
+ // Prune sessions.
336
+ const sessionResult = pruneOldSessions({
337
+ stateDir,
338
+ maxAgeMs,
339
+ maxCount,
340
+ maxSizeMb,
341
+ dryRun,
342
+ now,
343
+ });
344
+
345
+ // Clean orphan .tmp_* files in the tasks directory.
346
+ let tmpFilesCleaned = 0;
347
+ let tmpBytesReclaimed = 0;
348
+ if (fs.existsSync(TASK_DIR)) {
349
+ let entries;
350
+ try {
351
+ entries = fs.readdirSync(TASK_DIR);
352
+ } catch {
353
+ entries = [];
354
+ }
355
+ for (const f of entries) {
356
+ if (!f.includes(".tmp_")) continue;
357
+ const tmpPath = path.join(TASK_DIR, f);
358
+ try {
359
+ const st = fs.statSync(tmpPath);
360
+ if (dryRun) {
361
+ tmpFilesCleaned++;
362
+ tmpBytesReclaimed += st.size;
363
+ } else {
364
+ fs.unlinkSync(tmpPath);
365
+ tmpFilesCleaned++;
366
+ tmpBytesReclaimed += st.size;
367
+ }
368
+ } catch {
369
+ // Best-effort.
370
+ }
371
+ }
372
+ }
373
+
374
+ // Write .last_prune timestamp (only when not dryRun).
375
+ if (!dryRun) {
376
+ try {
377
+ fs.mkdirSync(stateDir, { recursive: true });
378
+ fs.writeFileSync(path.join(stateDir, ".last_prune"), String(now), "utf8");
379
+ } catch {
380
+ // Best-effort.
381
+ }
382
+ }
383
+
384
+ return {
385
+ sessionsPruned: sessionResult.pruned,
386
+ bytesReclaimed: sessionResult.bytesReclaimed,
387
+ tmpFilesCleaned,
388
+ tmpBytesReclaimed,
389
+ skipped: false,
390
+ protected: sessionResult.protected,
391
+ };
392
+ }