openclaw-sc 5.38.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 (48) hide show
  1. package/.env.example +13 -0
  2. package/INSTALL.md +13 -0
  3. package/LICENSE +233 -0
  4. package/README.md +123 -0
  5. package/SECURITY.md +27 -0
  6. package/index.js +3479 -0
  7. package/lib/code-review-shared.js +164 -0
  8. package/lib/config.js +164 -0
  9. package/lib/constants.js +438 -0
  10. package/lib/decomposer.js +896 -0
  11. package/lib/dialog-recall.js +389 -0
  12. package/lib/env.js +59 -0
  13. package/lib/level-rules.js +72 -0
  14. package/lib/log-manager.js +258 -0
  15. package/lib/preemption.js +278 -0
  16. package/lib/priority-calc.js +134 -0
  17. package/lib/prompt-injection.js +748 -0
  18. package/lib/route-evidence.js +701 -0
  19. package/lib/shared-fs.js +244 -0
  20. package/lib/steward-rules.js +648 -0
  21. package/lib/task-center.js +602 -0
  22. package/lib/task-chain.js +713 -0
  23. package/lib/task-checker.js +317 -0
  24. package/lib/task-profiles.js +255 -0
  25. package/lib/tcell.js +411 -0
  26. package/lib/tool-auto-discover.js +522 -0
  27. package/lib/tool-enrich-subagent.js +375 -0
  28. package/lib/tool-handlers.js +178 -0
  29. package/lib/tool-selector.js +459 -0
  30. package/openclaw.plugin.json +55 -0
  31. package/package.json +63 -0
  32. package/security.js +141 -0
  33. package/tools/bridge.js +3288 -0
  34. package/tools/dashboard/tk-dashboard.py +262 -0
  35. package/tools/hippocampus-multi-search.js +1038 -0
  36. package/tools/hippocampus-sqlite.js +738 -0
  37. package/tools/hippocampus-store.js +262 -0
  38. package/tools/mcp-tools.config.json +653 -0
  39. package/tools/pipeline-engine.js +667 -0
  40. package/tools/sidecar/_check_tools.py +34 -0
  41. package/tools/sidecar/sidecar-server.cjs +1360 -0
  42. package/tools/sidecar/subagent-runner.cjs +1471 -0
  43. package/tools/system-tools.js +619 -0
  44. package/vector/README.md +100 -0
  45. package/vector/usearch-bridge.js +639 -0
  46. package/vector/usearch-http.js +74 -0
  47. package/vector/usearch-serve.js +156 -0
  48. package/workers/worker.js +1419 -0
@@ -0,0 +1,3288 @@
1
+ 
2
+ import { fileURLToPath, pathToFileURL } from 'url';
3
+ import { basename, dirname, join, normalize } from 'path';
4
+ import { cpus, homedir, EOL, freemem, totalmem } from 'os';
5
+ import { validatePath } from '../security.js';
6
+ import http from 'http';
7
+ import { spawn, spawnSync } from 'child_process';
8
+ import { readdir, readFile as rf } from 'fs/promises';
9
+ import { existsSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs';
10
+ import {
11
+ handleCoreStats,
12
+ handleCoreImageBatch,
13
+ } from '../lib/tool-handlers.js';
14
+ import { getEnv } from '../lib/env.js';
15
+ import { MCP_PORT } from '../lib/constants.js';
16
+ import { handleDialogRecall } from '../lib/dialog-recall.js';
17
+ import { fastPathSearch, splitFiles, mergeSearchResults, mergeLogResults, parseSearchArgs, parseLogArgs } from '../lib/code-review-shared.js';
18
+
19
+ import { getToolTiers } from '../lib/steward-rules.js';
20
+ // system-tools.js handleSystemRun — 仅 worker.js 内部使用,bridge 不需要
21
+ // task-chain.js — 已删除,功能由 pipeline 替代
22
+ // task-center.js — 已删除,功能由 pipeline 替代
23
+ // pipeline-engine.js — 仅 index.js 内部使用
24
+ // task-checker.js — 已删除
25
+ // task-profiles.js pickSubagentModel — 仅 index.js 使用
26
+ // tool-selector.js — 已删除
27
+
28
+ import { detectToolInjection } from '../lib/prompt-injection.js';
29
+ import { recordEvent as hippoRecordEvent } from './hippocampus-store.js';
30
+ import { multiPathSearch } from './hippocampus-multi-search.js';
31
+ import { qdrantSearch, qdrantBuild } from '../vector/usearch-http.js';
32
+
33
+ const __filename = fileURLToPath(import.meta.url);
34
+ const __dirname = dirname(__filename);
35
+
36
+ // ====== Path resolution: support from tools/ 和 插件根目录两种调用 ======
37
+ const PLUGIN_DIR = (() => {
38
+ if (basename(__dirname) === 'tools') {
39
+ return join(__dirname, '..');
40
+ }
41
+ return __dirname;
42
+ })();
43
+
44
+ const SC_TASKCARD_GUARD_MODE = (process.env.SC_TASKCARD_GUARD_MODE || 'warn').toLowerCase();
45
+ const SC_PROMPT_MAX_CHARS = Number(process.env.SC_PROMPT_MAX_CHARS || 30000);
46
+ const SC_PIPELINE_MAX_GROUPS = Number(process.env.SC_PIPELINE_MAX_GROUPS || 100);
47
+
48
+ // External CPU instance injection(index.js 主动传入,避免循环依赖)
49
+ let _externalCpuInstance = null;
50
+
51
+ /**
52
+ * 由 index.js 在Start MCP Server 时调用,传入已实例化的 cpu 对象
53
+ * 从此 getCore() 优先返回外部实例,不再动态 import index.js
54
+ */
55
+ export function setCpuInstance(instance) {
56
+ _externalCpuInstance = instance;
57
+ }
58
+
59
+ // Dynamic import, fallback when no external instance
60
+ let cpuInstance = null;
61
+ async function getCore() {
62
+ if (_externalCpuInstance) return _externalCpuInstance;
63
+ if (!cpuInstance) {
64
+ cpuInstance = await import(pathToFileURL(join(PLUGIN_DIR, 'index.js')).href);
65
+ }
66
+ return cpuInstance.default || cpuInstance;
67
+ }
68
+
69
+ // ====== USearch HTTP 查询(NSSM常驻 18793) ======
70
+ // qdrantSearch 由 ../vector/usearch-http.js 导入
71
+
72
+ // ====== CLI help ======
73
+ function showHelp() {
74
+ console.log(`
75
+ sc v5.37.0 — CLI Bridge
76
+
77
+ Usage:
78
+ node bridge.js search <keyword> [files...] Parallel text search
79
+ node bridge.js log <files...> Parallel log analysis
80
+ node bridge.js stats Pool status
81
+ node bridge.js resolve <provider> <modelId> Model config resolve
82
+ node bridge.js route <taskText> Multi-core decision engine
83
+ node bridge.js mcp [--port <port>] Start MCP Server (SSE)
84
+ node bridge.js sync [--quick] Parallel sync to E:+G:
85
+ node bridge.js --help This help
86
+
87
+ Options:
88
+ --priority high|normal|low 任务Priority (default normal)
89
+ --port <port> MCP Server 端口 (default 18790)
90
+
91
+ Examples:
92
+ node bridge.js search "错误" C:\\logs\\*.log C:\\diary\\*.md
93
+ node bridge.js log C:\\logs\\app.log
94
+ node bridge.js resolve deepseek [model-id]
95
+ node bridge.js route "帮我搜索一下配置文件"
96
+ node bridge.js stats
97
+ node bridge.js mcp --port 18790
98
+ `);
99
+ }
100
+
101
+ // ====== CLI entry ======
102
+ async function main() {
103
+ try {
104
+ const args = process.argv.slice(2);
105
+ if (args.length === 0) { showHelp(); process.exit(0); }
106
+
107
+ const cmd = args[0];
108
+ const core = await getCore();
109
+
110
+ switch (cmd) {
111
+ case 'search': {
112
+ const { keyword, files, priority } = parseSearchArgs(args.slice(1));
113
+ if (!keyword) { console.error('Missing search keyword'); process.exit(1); }
114
+ if (!files || files.length === 0) { console.error('Missing file list'); process.exit(1); }
115
+ const poolStats = core.getStats();
116
+ const tasks = splitFiles(files, poolStats.maxWorkers || 4);
117
+ const results = await Promise.all(
118
+ tasks.map(filesChunk =>
119
+ core.pool.exec({ type: 'search-text', keyword, files: filesChunk }, priority)
120
+ )
121
+ );
122
+ const merged = mergeSearchResults(results, poolStats);
123
+ // TODO: 移除调试日志 console.log(JSON.stringify(merged, null, 2));
124
+ break;
125
+ }
126
+
127
+ case 'log': {
128
+ const { files, priority } = parseLogArgs(args.slice(1));
129
+ if (!files || files.length === 0) { console.error('Missing file list'); process.exit(1); }
130
+ const poolStats = core.getStats();
131
+ const tasks = splitFiles(files, poolStats.maxWorkers || 4);
132
+ const results = await Promise.all(
133
+ tasks.map(filesChunk =>
134
+ core.pool.exec({ type: 'process-log', files: filesChunk }, priority)
135
+ )
136
+ );
137
+ const merged = mergeLogResults(results, poolStats);
138
+ // TODO: 移除调试日志 console.log(JSON.stringify(merged, null, 2));
139
+ break;
140
+ }
141
+
142
+ case 'stats': {
143
+ const stats = core.getStats();
144
+ // TODO: 移除调试日志 console.log(JSON.stringify(stats, null, 2));
145
+ break;
146
+ }
147
+
148
+ case 'build': {
149
+ // 🧠 USearch 增量编译
150
+ const buildFiles = args.files || [];
151
+ try {
152
+ const buildResult = await qdrantBuild(buildFiles);
153
+ result = {
154
+ content: [{ type: "text", text: `🧠 USearch 增量完成: +${buildResult.added} 条, 共 ${buildResult.total} 条, ${buildResult.elapsed_ms}ms` }],
155
+ status: 'success',
156
+ action: 'build',
157
+ mode: 'usearch',
158
+ added: buildResult.added,
159
+ total_in_index: buildResult.total,
160
+ elapsed_ms: buildResult.elapsed_ms,
161
+ };
162
+ } catch (e) {
163
+ result = {
164
+ content: [{ type: "text", text: `⚠️ USearch 增量失败: ${e.message}` }],
165
+ status: 'error',
166
+ action: 'build',
167
+ error: e.message,
168
+ };
169
+ }
170
+ break;
171
+ }
172
+
173
+ case 'resolve': {
174
+ const provider = args[1];
175
+ const modelId = args[2];
176
+ if (!provider || !modelId) { console.error('Usage: node bridge.js resolve <provider> <modelId>'); process.exit(1); }
177
+ const result = await core.pool.exec({ type: 'resolve-model', provider, modelId });
178
+ // TODO: 移除调试日志 console.log(JSON.stringify(result, null, 2));
179
+ break;
180
+ }
181
+
182
+ case 'json': {
183
+ const jsonAction = args[1];
184
+ const jsonInput = args.slice(2).filter(a => !a.startsWith('--')).join(' ');
185
+ const jsonIndentIdx = args.indexOf('--indent');
186
+ const jsonIndent = jsonIndentIdx >= 0 ? parseInt(args[jsonIndentIdx + 1], 10) : 2;
187
+ const validJsonActions = ['format', 'validate', 'convert', 'csv2json', 'json2csv'];
188
+ if (!jsonAction || !validJsonActions.includes(jsonAction)) {
189
+ console.error(`Usage: node bridge.js json <${validJsonActions.join('|')}> <input> [--indent N]`);
190
+ process.exit(1);
191
+ }
192
+ if (!jsonInput) { console.error('Missing input'); process.exit(1); }
193
+ try {
194
+ const result = handleJsonTool({ action: jsonAction, input: jsonInput, indent: jsonIndent });
195
+ // TODO: 移除调试日志 console.log(JSON.stringify(result, null, 2));
196
+ } catch (e) {
197
+ console.error(JSON.stringify({ status: 'error', message: e.message }, null, 2));
198
+ process.exit(1);
199
+ }
200
+ break;
201
+ }
202
+
203
+ case 'route': {
204
+ const taskText = args.slice(1).join(' ');
205
+ if (!taskText) { console.error('Usage: node bridge.js route <taskText>'); process.exit(1); }
206
+ const result = await core.pool.exec({ type: 'route-quick', text: taskText }, 'high');
207
+ // TODO: 移除调试日志 console.log(JSON.stringify({
208
+ // task: taskText,
209
+ // quickResult: result,
210
+ // note: result.matched ? `建议工具: ${result.tool} (置信度: ${result.confidence})` : '未匹配到直接工具,需走完整评估',
211
+ // }, null, 2));
212
+ break;
213
+ }
214
+
215
+ case 'sync': {
216
+ const isQuick = args.includes('--quick');
217
+ const pool = core.pool;
218
+ const [eResult, gResult] = await Promise.all([
219
+ pool.exec({ type: 'sync-archive', target: 'E', quick: isQuick }, 'normal').catch(e => ({ error: e.message })),
220
+ pool.exec({ type: 'sync-archive', target: 'G', quick: isQuick }, 'normal').catch(e => ({ error: e.message })),
221
+ ]);
222
+ // TODO: 移除调试日志 console.log(JSON.stringify({ mode: isQuick ? 'quick' : 'full', E: eResult, G: gResult, timestamp: new Date().toISOString() }, null, 2));
223
+ break;
224
+ }
225
+
226
+ case 'mcp': {
227
+ const portIdx = args.indexOf('--port');
228
+ const port = portIdx >= 0 ? parseInt(args[portIdx + 1], 10) : MCP_PORT;
229
+ await startMcpServer(port);
230
+ break;
231
+ }
232
+
233
+ case '--help':
234
+ case '-h':
235
+ showHelp();
236
+ break;
237
+
238
+ default:
239
+ console.error(`Unknown命令: ${cmd}`);
240
+ showHelp();
241
+ process.exit(1);
242
+ }
243
+ } catch (err) {
244
+ console.error('main 执行出错:', err);
245
+ process.exit(1);
246
+ }
247
+ }
248
+
249
+ // ====== 共享工具函数(统一来自 lib/code-review-shared.js)======
250
+ // parseSearchArgs, parseLogArgs, splitFiles, mergeSearchResults, mergeLogResults
251
+ // 由 import 从 ../lib/code-review-shared.js 引入
252
+
253
+ const DEFAULT_SEARCH_CONFIG = {
254
+ backends: ['tavily', 'ddg', 'direct_http'],
255
+ tavily: {
256
+ apiKeyEnv: 'TAVILY_API_KEY',
257
+ timeout: 30000,
258
+ maxRetries: 1,
259
+ searchDepth: 'advanced',
260
+ },
261
+ ddg: {
262
+ baseUrl: 'https://api.duckduckgo.com',
263
+ htmlBaseUrl: 'https://html.duckduckgo.com/html/',
264
+ timeout: 30000,
265
+ },
266
+ searxng: {
267
+ baseUrl: '',
268
+ timeout: 30000,
269
+ },
270
+ direct_http: {
271
+ timeout: 30000,
272
+ maxContentLength: 50000,
273
+ searchUrl: 'https://www.bing.com/search',
274
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) OpenClaw-sc-webSearch/1.0 Safari/537.36',
275
+ },
276
+ };
277
+
278
+ const searchBackendState = {
279
+ tavily: {
280
+ exhaustedMonth: null,
281
+ },
282
+ };
283
+
284
+ function getLocalMonthKey(date = new Date()) {
285
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
286
+ }
287
+
288
+ function getSearchConfig(config) {
289
+ const raw = config?.search || {};
290
+ return {
291
+ backends: normalizeSearchBackends(raw.backends || DEFAULT_SEARCH_CONFIG.backends),
292
+ tavily: { ...DEFAULT_SEARCH_CONFIG.tavily, ...(raw.tavily || {}) },
293
+ ddg: { ...DEFAULT_SEARCH_CONFIG.ddg, ...(raw.ddg || {}) },
294
+ searxng: { ...DEFAULT_SEARCH_CONFIG.searxng, ...(raw.searxng || {}) },
295
+ direct_http: { ...DEFAULT_SEARCH_CONFIG.direct_http, ...(raw.direct_http || {}) },
296
+ };
297
+ }
298
+
299
+ function normalizeSearchBackends(backends) {
300
+ const list = Array.isArray(backends) ? backends : String(backends || '').split(',');
301
+ const seen = new Set();
302
+ return list
303
+ .map(v => String(v || '').trim().toLowerCase())
304
+ .filter(v => v && !seen.has(v) && seen.add(v));
305
+ }
306
+
307
+ function clampMaxResults(value, fallback = 5) {
308
+ const n = Number(value || fallback);
309
+ if (!Number.isFinite(n) || n <= 0) return fallback;
310
+ return Math.min(Math.floor(n), 20);
311
+ }
312
+
313
+ function makeBackendError(backend, message, extra = {}) {
314
+ return Object.assign(new Error(message || 'backend unavailable'), {
315
+ backend,
316
+ code: extra.code || -32603,
317
+ status: extra.status,
318
+ safeMessage: extra.safeMessage || '搜索服务暂时不可用,已切换备用渠道',
319
+ quotaExhausted: Boolean(extra.quotaExhausted),
320
+ });
321
+ }
322
+
323
+ function isTavilyExhausted() {
324
+ return searchBackendState.tavily.exhaustedMonth === getLocalMonthKey();
325
+ }
326
+
327
+ function markTavilyExhausted() {
328
+ searchBackendState.tavily.exhaustedMonth = getLocalMonthKey();
329
+ }
330
+
331
+ function isTavilyQuotaError(status, data, text) {
332
+ const blob = [
333
+ status === 429 ? '429' : '',
334
+ typeof text === 'string' ? text : '',
335
+ data?.detail?.error,
336
+ data?.error,
337
+ data?.message,
338
+ ].filter(Boolean).join(' ').toLowerCase();
339
+ return status === 429 ||
340
+ blob.includes('usage limit') ||
341
+ blob.includes('quota') ||
342
+ blob.includes('rate limit') ||
343
+ blob.includes('too many requests');
344
+ }
345
+
346
+ async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
347
+ const controller = new AbortController();
348
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
349
+ try {
350
+ return await fetch(url, { ...options, signal: controller.signal });
351
+ } catch (e) {
352
+ if (e?.name === 'AbortError') {
353
+ throw new Error(`请求超时(${timeoutMs}ms)`);
354
+ }
355
+ throw e;
356
+ } finally {
357
+ clearTimeout(timer);
358
+ }
359
+ }
360
+
361
+ async function readLimitedResponseText(resp, maxBytes = 50000) {
362
+ if (!resp.body || typeof resp.body.getReader !== 'function') {
363
+ const text = await resp.text();
364
+ return text.slice(0, maxBytes);
365
+ }
366
+
367
+ const reader = resp.body.getReader();
368
+ const chunks = [];
369
+ let total = 0;
370
+ try {
371
+ while (total < maxBytes) {
372
+ const { done, value } = await reader.read();
373
+ if (done) break;
374
+ const chunk = Buffer.from(value);
375
+ const remaining = maxBytes - total;
376
+ chunks.push(chunk.length > remaining ? chunk.subarray(0, remaining) : chunk);
377
+ total += Math.min(chunk.length, remaining);
378
+ if (chunk.length > remaining) {
379
+ try { await reader.cancel(); } catch {}
380
+ break;
381
+ }
382
+ }
383
+ } finally {
384
+ try { reader.releaseLock(); } catch {}
385
+ }
386
+ return Buffer.concat(chunks).toString('utf8');
387
+ }
388
+
389
+ function tryParseJson(text) {
390
+ try { return JSON.parse(text); } catch { return null; }
391
+ }
392
+
393
+ function decodeHtmlEntities(text) {
394
+ const named = {
395
+ amp: '&',
396
+ lt: '<',
397
+ gt: '>',
398
+ quot: '"',
399
+ apos: "'",
400
+ nbsp: ' ',
401
+ };
402
+ return String(text || '').replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (_, ent) => {
403
+ if (ent[0] === '#') {
404
+ const isHex = ent[1]?.toLowerCase() === 'x';
405
+ const code = parseInt(ent.slice(isHex ? 2 : 1), isHex ? 16 : 10);
406
+ return Number.isFinite(code) ? String.fromCodePoint(code) : '';
407
+ }
408
+ return named[ent.toLowerCase()] ?? '';
409
+ });
410
+ }
411
+
412
+ function htmlToText(html) {
413
+ return decodeHtmlEntities(String(html || '')
414
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
415
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
416
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
417
+ .replace(/<!--[\s\S]*?-->/g, ' ')
418
+ .replace(/<\/(p|div|section|article|li|tr|h[1-6]|br)>/gi, '\n')
419
+ .replace(/<[^>]+>/g, ' '))
420
+ .replace(/\r/g, '')
421
+ .replace(/[ \t\f\v]+/g, ' ')
422
+ .replace(/\n[ \t]+/g, '\n')
423
+ .replace(/\n{3,}/g, '\n\n')
424
+ .trim();
425
+ }
426
+
427
+ function extractHtmlTitle(html) {
428
+ const match = String(html || '').match(/<title[^>]*>([\s\S]*?)<\/title>/i);
429
+ return match ? htmlToText(match[1]).slice(0, 200) : '';
430
+ }
431
+
432
+ function isHttpUrl(value) {
433
+ try {
434
+ const url = new URL(String(value || ''));
435
+ return url.protocol === 'http:' || url.protocol === 'https:';
436
+ } catch {
437
+ return false;
438
+ }
439
+ }
440
+
441
+ function unwrapDuckDuckGoUrl(rawUrl) {
442
+ const cleaned = decodeHtmlEntities(rawUrl || '');
443
+ try {
444
+ const withProtocol = cleaned.startsWith('//') ? `https:${cleaned}` : cleaned;
445
+ const url = new URL(withProtocol);
446
+ const uddg = url.searchParams.get('uddg');
447
+ return uddg ? decodeURIComponent(uddg) : withProtocol;
448
+ } catch {
449
+ return cleaned;
450
+ }
451
+ }
452
+
453
+ function normalizeSearchResult(item, backend) {
454
+ const url = item?.url || item?.href || item?.link || '';
455
+ return {
456
+ title: String(item?.title || item?.name || url || '(untitled)').trim(),
457
+ url,
458
+ content: String(item?.content || item?.snippet || item?.body || '').trim(),
459
+ source: backend,
460
+ score: item?.score,
461
+ };
462
+ }
463
+
464
+ function dedupeSearchResults(results, maxResults) {
465
+ const seen = new Set();
466
+ const deduped = [];
467
+ for (const item of results || []) {
468
+ const normalized = normalizeSearchResult(item, item?.source || item?.backend || 'unknown');
469
+ const key = (normalized.url || normalized.title).toLowerCase();
470
+ if (!key || seen.has(key)) continue;
471
+ seen.add(key);
472
+ deduped.push(normalized);
473
+ if (deduped.length >= maxResults) break;
474
+ }
475
+ return deduped;
476
+ }
477
+
478
+ function flattenDdgRelatedTopics(topics, out = []) {
479
+ for (const topic of topics || []) {
480
+ if (topic?.Topics) {
481
+ flattenDdgRelatedTopics(topic.Topics, out);
482
+ continue;
483
+ }
484
+ if (topic?.FirstURL || topic?.Text) {
485
+ out.push({
486
+ title: topic.Text ? topic.Text.split(' - ')[0] : topic.FirstURL,
487
+ url: topic.FirstURL || '',
488
+ content: topic.Text || '',
489
+ });
490
+ }
491
+ }
492
+ return out;
493
+ }
494
+
495
+ async function tavilySearch(query, maxResults, searchConfig, rootConfig) {
496
+ if (isTavilyExhausted()) {
497
+ throw makeBackendError('tavily', 'tavily quota exhausted this month', { quotaExhausted: true });
498
+ }
499
+
500
+ const tavilyKey = getEnv(searchConfig.tavily.apiKeyEnv || 'TAVILY_API_KEY') || rootConfig?.apiKeys?.tavily;
501
+ if (!tavilyKey) {
502
+ throw makeBackendError('tavily', 'tavily api key not configured', { code: -32000 });
503
+ }
504
+
505
+ const retries = Math.max(1, Number(searchConfig.tavily.maxRetries || 1));
506
+ let lastError;
507
+ for (let attempt = 0; attempt < retries; attempt++) {
508
+ try {
509
+ const resp = await fetchWithTimeout('https://api.tavily.com/search', {
510
+ method: 'POST',
511
+ headers: { 'Content-Type': 'application/json' },
512
+ body: JSON.stringify({
513
+ api_key: tavilyKey,
514
+ query,
515
+ search_depth: searchConfig.tavily.searchDepth || 'advanced',
516
+ max_results: maxResults,
517
+ include_answer: true,
518
+ }),
519
+ }, Number(searchConfig.tavily.timeout || 10000));
520
+ const text = await resp.text();
521
+ const data = tryParseJson(text) || {};
522
+ if (!resp.ok || isTavilyQuotaError(resp.status, data, text)) {
523
+ if (isTavilyQuotaError(resp.status, data, text)) markTavilyExhausted();
524
+ throw makeBackendError('tavily', `tavily unavailable status=${resp.status}`, {
525
+ status: resp.status,
526
+ quotaExhausted: isTavilyQuotaError(resp.status, data, text),
527
+ });
528
+ }
529
+ return {
530
+ backend: 'tavily',
531
+ answer: data.answer || '',
532
+ results: dedupeSearchResults((data.results || []).map(r => ({
533
+ title: r.title,
534
+ url: r.url,
535
+ content: r.content || r.raw_content || '',
536
+ score: r.score,
537
+ source: 'tavily',
538
+ })), maxResults),
539
+ };
540
+ } catch (e) {
541
+ lastError = e;
542
+ if (e?.quotaExhausted || attempt === retries - 1) break;
543
+ }
544
+ }
545
+ throw lastError || makeBackendError('tavily', 'tavily unavailable');
546
+ }
547
+
548
+ async function duckDuckGoSearch(query, maxResults, searchConfig) {
549
+ const baseUrl = String(searchConfig.ddg.baseUrl || DEFAULT_SEARCH_CONFIG.ddg.baseUrl).replace(/\/+$/, '');
550
+ const timeout = Number(searchConfig.ddg.timeout || 10000);
551
+ const params = new URLSearchParams({
552
+ q: query,
553
+ format: 'json',
554
+ no_redirect: '1',
555
+ no_html: '1',
556
+ skip_disambig: '1',
557
+ });
558
+ const results = [];
559
+ let answer = '';
560
+
561
+ try {
562
+ const resp = await fetchWithTimeout(`${baseUrl}/?${params.toString()}`, {
563
+ headers: {
564
+ Accept: 'application/json',
565
+ 'User-Agent': searchConfig.direct_http.userAgent,
566
+ },
567
+ }, timeout);
568
+ const text = await resp.text();
569
+ const data = tryParseJson(text) || {};
570
+ if (!resp.ok) {
571
+ throw makeBackendError('ddg', `duckduckgo instant answer status=${resp.status}`, { status: resp.status });
572
+ }
573
+ answer = data.AbstractText || data.Answer || '';
574
+ if (data.AbstractURL || data.AbstractText) {
575
+ results.push({
576
+ title: data.Heading || data.AbstractSource || query,
577
+ url: data.AbstractURL || '',
578
+ content: data.AbstractText || data.Answer || '',
579
+ source: 'duckduckgo',
580
+ });
581
+ }
582
+ results.push(...flattenDdgRelatedTopics(data.RelatedTopics).map(r => ({ ...r, source: 'duckduckgo' })));
583
+ } catch (e) {
584
+ // Instant Answer经常没有通用网页结果,继续尝试HTML端点。
585
+ }
586
+
587
+ if (results.length < maxResults) {
588
+ try {
589
+ results.push(...await duckDuckGoHtmlSearch(query, maxResults - results.length, searchConfig));
590
+ } catch (e) {
591
+ if (results.length === 0 && !answer) throw e;
592
+ }
593
+ }
594
+
595
+ const finalResults = dedupeSearchResults(results, maxResults);
596
+ if (finalResults.length === 0 && !answer) {
597
+ throw makeBackendError('ddg', 'duckduckgo returned no results');
598
+ }
599
+ return { backend: 'duckduckgo', answer, results: finalResults };
600
+ }
601
+
602
+ async function duckDuckGoHtmlSearch(query, maxResults, searchConfig) {
603
+ const htmlBase = String(searchConfig.ddg.htmlBaseUrl || DEFAULT_SEARCH_CONFIG.ddg.htmlBaseUrl);
604
+ const url = `${htmlBase}${htmlBase.includes('?') ? '&' : '?'}${new URLSearchParams({ q: query }).toString()}`;
605
+ const resp = await fetchWithTimeout(url, {
606
+ headers: {
607
+ Accept: 'text/html,application/xhtml+xml',
608
+ 'User-Agent': searchConfig.direct_http.userAgent,
609
+ },
610
+ }, Number(searchConfig.ddg.timeout || 10000));
611
+ if (!resp.ok) {
612
+ throw makeBackendError('ddg', `duckduckgo html status=${resp.status}`, { status: resp.status });
613
+ }
614
+ const html = await readLimitedResponseText(resp, 300000);
615
+ const blocks = html.split(/<div[^>]+class=["'][^"']*result[^"']*["'][^>]*>/i).slice(1);
616
+ const results = [];
617
+ for (const block of blocks) {
618
+ const linkMatch = block.match(/<a[^>]+class=["'][^"']*result__a[^"']*["'][^>]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i);
619
+ if (!linkMatch) continue;
620
+ const snippetMatch = block.match(/class=["'][^"']*result__snippet[^"']*["'][^>]*>([\s\S]*?)<\/(?:a|div)>/i);
621
+ const parsedUrl = unwrapDuckDuckGoUrl(linkMatch[1]);
622
+ if (!isHttpUrl(parsedUrl)) continue;
623
+ results.push({
624
+ title: htmlToText(linkMatch[2]),
625
+ url: parsedUrl,
626
+ content: snippetMatch ? htmlToText(snippetMatch[1]) : '',
627
+ source: 'duckduckgo',
628
+ });
629
+ if (results.length >= maxResults) break;
630
+ }
631
+ return results;
632
+ }
633
+
634
+ async function searxngSearch(query, maxResults, searchConfig) {
635
+ const baseUrl = String(searchConfig.searxng.baseUrl || '').replace(/\/+$/, '');
636
+ if (!baseUrl) {
637
+ throw makeBackendError('searxng', 'searxng baseUrl not configured');
638
+ }
639
+ const params = new URLSearchParams({ q: query, format: 'json' });
640
+ const resp = await fetchWithTimeout(`${baseUrl}/search?${params.toString()}`, {
641
+ headers: {
642
+ Accept: 'application/json',
643
+ 'User-Agent': searchConfig.direct_http.userAgent,
644
+ },
645
+ }, Number(searchConfig.searxng.timeout || 10000));
646
+ const text = await resp.text();
647
+ const data = tryParseJson(text) || {};
648
+ if (!resp.ok) {
649
+ throw makeBackendError('searxng', `searxng status=${resp.status}`, { status: resp.status });
650
+ }
651
+ const results = dedupeSearchResults((data.results || []).map(r => ({
652
+ title: r.title,
653
+ url: r.url,
654
+ content: r.content || r.snippet || '',
655
+ source: 'searxng',
656
+ score: r.score,
657
+ })), maxResults);
658
+ if (results.length === 0) {
659
+ throw makeBackendError('searxng', 'searxng returned no results');
660
+ }
661
+ return { backend: 'searxng', answer: data.answers?.[0] || '', results };
662
+ }
663
+
664
+ async function directHttpFetch(url, searchConfig) {
665
+ if (!isHttpUrl(url)) {
666
+ throw Object.assign(new Error('url 必须是 http/https'), { code: -32602 });
667
+ }
668
+ const directConfig = searchConfig.direct_http;
669
+ const maxBytes = Number(directConfig.maxContentLength || 50000);
670
+ const resp = await fetchWithTimeout(url, {
671
+ redirect: 'follow',
672
+ headers: {
673
+ Accept: 'text/html,application/xhtml+xml,text/plain,application/json;q=0.9,*/*;q=0.8',
674
+ 'User-Agent': directConfig.userAgent,
675
+ },
676
+ }, Number(directConfig.timeout || 10000));
677
+ const raw = await readLimitedResponseText(resp, maxBytes);
678
+ if (!resp.ok) {
679
+ throw Object.assign(new Error(`HTTP ${resp.status}`), { code: -32603, status: resp.status });
680
+ }
681
+ const contentType = resp.headers.get('content-type') || '';
682
+ const content = /html|xml/i.test(contentType) || /<html[\s>]/i.test(raw)
683
+ ? htmlToText(raw)
684
+ : raw.trim();
685
+ return {
686
+ backend: 'direct_http',
687
+ results: [{
688
+ url: resp.url || url,
689
+ title: extractHtmlTitle(raw),
690
+ content: content.slice(0, maxBytes),
691
+ contentType,
692
+ status: resp.status,
693
+ truncated: raw.length >= maxBytes,
694
+ }],
695
+ };
696
+ }
697
+
698
+ async function directHttpSearch(query, maxResults, searchConfig) {
699
+ if (!isHttpUrl(query)) {
700
+ return directHttpSearchPage(query, maxResults, searchConfig);
701
+ }
702
+ const fetched = await directHttpFetch(query, searchConfig);
703
+ const item = fetched.results?.[0] || {};
704
+ return {
705
+ backend: 'direct_http',
706
+ answer: item.content?.slice(0, 500) || '',
707
+ results: [{
708
+ title: item.title || item.url || query,
709
+ url: item.url || query,
710
+ content: item.content || '',
711
+ source: 'direct_http',
712
+ }].slice(0, maxResults),
713
+ };
714
+ }
715
+
716
+ async function directHttpSearchPage(query, maxResults, searchConfig) {
717
+ const directConfig = searchConfig.direct_http;
718
+ const baseUrl = String(directConfig.searchUrl || DEFAULT_SEARCH_CONFIG.direct_http.searchUrl);
719
+ const joiner = baseUrl.includes('?') ? '&' : '?';
720
+ const url = `${baseUrl}${joiner}${new URLSearchParams({ q: query }).toString()}`;
721
+ const resp = await fetchWithTimeout(url, {
722
+ redirect: 'follow',
723
+ headers: {
724
+ Accept: 'text/html,application/xhtml+xml',
725
+ 'User-Agent': directConfig.userAgent,
726
+ },
727
+ }, Number(directConfig.timeout || 10000));
728
+ if (!resp.ok) {
729
+ throw makeBackendError('direct_http', `direct search status=${resp.status}`, { status: resp.status });
730
+ }
731
+ const html = await readLimitedResponseText(resp, 300000);
732
+ const results = parseBingHtmlResults(html, maxResults);
733
+ if (results.length === 0) {
734
+ throw makeBackendError('direct_http', 'direct search returned no results');
735
+ }
736
+ return {
737
+ backend: 'direct_http',
738
+ answer: '',
739
+ results,
740
+ };
741
+ }
742
+
743
+ function parseBingHtmlResults(html, maxResults) {
744
+ const blocks = String(html || '').split(/<li[^>]+class=["'][^"']*\bb_algo\b[^"']*["'][^>]*>/i).slice(1);
745
+ const results = [];
746
+ for (const block of blocks) {
747
+ const linkMatch = block.match(/<h2[^>]*>[\s\S]*?<a[^>]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i)
748
+ || block.match(/<a[^>]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i);
749
+ if (!linkMatch) continue;
750
+ const parsedUrl = decodeHtmlEntities(linkMatch[1]);
751
+ if (!isHttpUrl(parsedUrl)) continue;
752
+ const snippetMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
753
+ results.push({
754
+ title: htmlToText(linkMatch[2]),
755
+ url: parsedUrl,
756
+ content: snippetMatch ? htmlToText(snippetMatch[1]) : '',
757
+ source: 'direct_http',
758
+ });
759
+ if (results.length >= maxResults) break;
760
+ }
761
+ return dedupeSearchResults(results, maxResults);
762
+ }
763
+
764
+ async function runWebSearch(args, config) {
765
+ const searchConfig = getSearchConfig(config);
766
+ const query = args?.query;
767
+ if (!query) throw Object.assign(new Error('缺少 query'), { code: -32602 });
768
+ const maxResults = clampMaxResults(args?.maxResults || args?.max_results || 5);
769
+ const errors = [];
770
+ let fallbackUsed = false;
771
+
772
+ for (const backend of searchConfig.backends) {
773
+ try {
774
+ let backendResult;
775
+ switch (backend) {
776
+ case 'tavily':
777
+ backendResult = await tavilySearch(query, maxResults, searchConfig, config);
778
+ break;
779
+ case 'ddg':
780
+ case 'duckduckgo':
781
+ backendResult = await duckDuckGoSearch(query, maxResults, searchConfig);
782
+ break;
783
+ case 'searxng':
784
+ backendResult = await searxngSearch(query, maxResults, searchConfig);
785
+ break;
786
+ case 'direct_http':
787
+ backendResult = await directHttpSearch(query, maxResults, searchConfig);
788
+ break;
789
+ default:
790
+ throw makeBackendError(backend, `unknown backend: ${backend}`);
791
+ }
792
+ return {
793
+ action: 'web_search',
794
+ query,
795
+ backend: backendResult.backend || backend,
796
+ fallbackUsed,
797
+ notice: fallbackUsed ? '搜索服务暂时不可用,已切换备用渠道' : undefined,
798
+ answer: backendResult.answer || '',
799
+ results: (backendResult.results || []).slice(0, maxResults),
800
+ };
801
+ } catch (e) {
802
+ fallbackUsed = true;
803
+ errors.push({ backend, status: e?.status || null, quotaExhausted: Boolean(e?.quotaExhausted) });
804
+ console.warn(`[webSearch] backend=${backend} failed status=${e?.status || 'n/a'} quota=${Boolean(e?.quotaExhausted)}`);
805
+ }
806
+ }
807
+
808
+ throw Object.assign(new Error('搜索服务暂时不可用,备用渠道也未返回结果'), {
809
+ code: -32603,
810
+ safeErrors: errors,
811
+ });
812
+ }
813
+
814
+ async function runWebFetch(args, config) {
815
+ const searchConfig = getSearchConfig(config);
816
+ const url = args?.url;
817
+ if (!url) throw Object.assign(new Error('缺少 url'), { code: -32602 });
818
+ const fetched = await directHttpFetch(url, searchConfig);
819
+ return {
820
+ action: 'web_fetch',
821
+ backend: 'direct_http',
822
+ ...fetched,
823
+ };
824
+ }
825
+
826
+ // ====== MCP Server ======
827
+
828
+ /**
829
+ * 启动 MCP over SSE Server
830
+ * 使用 Node.js 内置 http 模块,零依赖
831
+ */
832
+ // ====== ⑨ 熔断器状态(MCP 工具级别 — 双通道痛觉系统)======
833
+ // 双通道设计:
834
+ // A-delta(快通道):60秒内相同session+工具连续失败≥3次→仅该session熔断30秒
835
+ // C纤维(慢通道) :每5分钟聚合所有工具成败比,<50%→emergency(仅主agent)
836
+ // 🔧 2026-06-09 改造:按 sessionId 隔离熔断,不改连坐。一个兵死了不影响其他兵。
837
+
838
+ // ---- A-delta 快通道状态 ----
839
+ const aDeltaErrors = new Map(); // "sessionId:toolName" -> [{errorMsg, timestamp}]
840
+ const circuitBreakerState = new Map(); // "sessionId:toolName" -> { meltedUntil }
841
+
842
+ function _makeCircuitKey(toolName, sessionId) {
843
+ return `${sessionId || 'anon'}:${toolName}`;
844
+ }
845
+
846
+ // ====== 子agent spawn 上限计数(Map<sessionId, count>)======
847
+ const activeSpawns = new Map();
848
+
849
+ function isToolMelted(toolName, sessionId) {
850
+ if (!toolName) return false;
851
+ const key = _makeCircuitKey(toolName, sessionId);
852
+ const state = circuitBreakerState.get(key);
853
+ if (!state) return false;
854
+ if (Date.now() >= state.meltedUntil) {
855
+ circuitBreakerState.delete(key);
856
+ return false;
857
+ }
858
+ return true;
859
+ }
860
+
861
+ /**
862
+ * A-delta 快通道 + C纤维慢通道:记录失败事件
863
+ * 1) A-delta: 60秒窗口内同一session+工具失败≥7次→仅该session熔断60秒(不连坐)
864
+ * 2) C纤维: 累积成败统计,触发5分钟聚合检查
865
+ * @param {string} toolName
866
+ * @param {string} errorMsg
867
+ * @param {string} [sessionId]
868
+ */
869
+ function recordToolFailure(toolName, errorMsg, sessionId) {
870
+ if (!toolName) return;
871
+ const now = Date.now();
872
+ const aDeltaKey = _makeCircuitKey(toolName, sessionId);
873
+
874
+ // ---- A-delta 快通道 ----
875
+ let queue = aDeltaErrors.get(aDeltaKey);
876
+ if (!queue) {
877
+ queue = [];
878
+ aDeltaErrors.set(aDeltaKey, queue);
879
+ }
880
+ queue.push({ errorMsg: errorMsg || '', timestamp: now });
881
+
882
+ // 清理超过60秒的旧记录
883
+ const cutoff = now - 60_000; // 60秒窗口
884
+ const recent = queue.filter(e => e.timestamp >= cutoff);
885
+ aDeltaErrors.set(aDeltaKey, recent);
886
+
887
+ // 统计相同错误模式的出现次数
888
+ const patternCounts = new Map();
889
+ for (const entry of recent) {
890
+ const msg = entry.errorMsg || '';
891
+ patternCounts.set(msg, (patternCounts.get(msg) || 0) + 1);
892
+ }
893
+
894
+ // 阈值: ≥20次 → 30秒熔断
895
+ for (const [msg, count] of patternCounts) {
896
+ if (count >= 20) {
897
+ const meltDuration = 30_000; // 熔断30秒
898
+ const meltKey = _makeCircuitKey(toolName, sessionId);
899
+ circuitBreakerState.set(meltKey, { meltedUntil: now + meltDuration });
900
+
901
+ writeCircuitBreakerLog({
902
+ type: 'a_delta_circuit_break',
903
+ tool: toolName,
904
+ sessionId: sessionId || 'anon',
905
+ reason: `A-delta: ${sessionId||'anon'} 在60秒内"${(msg || '').substring(0, 80)}" 出现${count}次 → 仅该session熔断60秒`,
906
+ diagnosis: {
907
+ channel: 'A-delta(快通道)',
908
+ pattern: (msg || '').substring(0, 120),
909
+ count,
910
+ windowMs: 60_000,
911
+ meltMs: meltDuration,
912
+ isolated: true,
913
+ },
914
+ }).catch(() => {});
915
+ console.warn(`[sc MCP] A-delta熔断: ${toolName} session=${sessionId||'anon'} 连续失败${count}次 → 冷却60秒`);
916
+ break; // 一次熔断只记录一个错误模式
917
+ }
918
+ }
919
+
920
+ }
921
+
922
+ /**
923
+ * 记录成功:
924
+ * 1) A-delta: 一次成功重置该session的错误队列(清空证据)
925
+ * 2) C纤维: 递增成功计数
926
+ * 3) 熔断状态: 一次成功解除该session的熔断
927
+ * @param {string} toolName
928
+ * @param {string} [sessionId]
929
+ */
930
+ function recordToolSuccess(toolName, sessionId) {
931
+ if (!toolName) return;
932
+ const aDeltaKey = _makeCircuitKey(toolName, sessionId);
933
+
934
+ // A-delta: 一次成功清除该session的失败队列
935
+ aDeltaErrors.delete(aDeltaKey);
936
+
937
+ // 一次成功解除该session的熔断
938
+ const meltKey = _makeCircuitKey(toolName, sessionId);
939
+ circuitBreakerState.delete(meltKey);
940
+
941
+ }/** 本地 writeCircuitBreakerLog(与 index.js 同名函数写入同一文件)*/
942
+ async function writeCircuitBreakerLog(entry) {
943
+ try {
944
+ const { readFile, writeFile } = await import('fs/promises');
945
+ const { join: jn } = await import('path');
946
+ const { homedir: hd } = await import('os');
947
+ const logPath = jn(hd(), '.openclaw', 'workspace', 'memory', 'shared', 'circuit-breaker-log.json');
948
+ let log = [];
949
+ try {
950
+ const raw = await readFile(logPath, 'utf-8');
951
+ log = JSON.parse(raw);
952
+ } catch {}
953
+ if (!Array.isArray(log)) log = [];
954
+ entry.timestamp = new Date().toISOString();
955
+ log.push(entry);
956
+ if (log.length > 100) log = log.slice(-100);
957
+ await writeFile(logPath, JSON.stringify(log, null, 2), 'utf-8');
958
+ } catch (err) {
959
+ console.warn('[sc Bridge] ⚠️ circuit breaker write failed:', err.message);
960
+ }
961
+ }
962
+
963
+ /** 从工具参数和结果中提取关键词,用于联想缓存 */
964
+ function extractToolKeywords(toolName, params, result) {
965
+ const keywords = [toolName];
966
+ if (params && typeof params === 'object') {
967
+ for (const [k, v] of Object.entries(params)) {
968
+ if (typeof v === 'string' && v.length < 100) keywords.push(v);
969
+ if (Array.isArray(v) && v.length > 0 && typeof v[0] === 'string') keywords.push(...v.slice(0, 3));
970
+ }
971
+ }
972
+ if (result && typeof result === 'object') {
973
+ const type = result.type || result.status || '';
974
+ if (type) keywords.push(type);
975
+ if (result.topic) keywords.push(result.topic);
976
+ if (result.summary) keywords.push(result.summary?.substring(0, 50));
977
+ }
978
+ return [...new Set(keywords.filter(Boolean))];
979
+ }
980
+
981
+ async function startMcpServer(port = MCP_PORT) {
982
+ // ??? shutdown 定义提到 try 外,ESM strict 模式下 block 内 function 声明不会被 hoist
983
+ // 否则 catch 块调 shutdown() 时 ReferenceError: shutdown is not defined
984
+ let sseClients;
985
+ let server;
986
+ let scInboxTimer = null;
987
+ let scInboxPollRunning = false;
988
+ const scInboxState = {
989
+ enabled: false,
990
+ autoNotify: false,
991
+ notifyAck: true,
992
+ chatInject: false,
993
+ pollMs: null,
994
+ lastPollAt: null,
995
+ lastNotifyOkAt: null,
996
+ lastChatInjectAttemptAt: null,
997
+ lastChatInjectOkAt: null,
998
+ lastAckAt: null,
999
+ lastEventCount: 0,
1000
+ lastNonOk: null,
1001
+ lastError: null,
1002
+ };
1003
+
1004
+ function getScInboxDeliveryMode() {
1005
+ const raw = String(process.env.SC_INBOX_DELIVERY_MODE || '').trim().toLowerCase();
1006
+ if (['notify-only', 'chat-inject', 'both', 'off'].includes(raw)) return raw;
1007
+ if (process.env.SC_INBOX_CHAT_INJECT === '0') return 'notify-only';
1008
+ if (process.env.SC_INBOX_AUTO_NOTIFY === '0' && process.env.SC_INBOX_CHAT_INJECT === '0') return 'off';
1009
+ return 'chat-inject';
1010
+ }
1011
+
1012
+ function shouldInjectScInboxChat(mode) {
1013
+ return mode === 'chat-inject' || mode === 'both';
1014
+ }
1015
+
1016
+ function parseBooleanEnv(value) {
1017
+ if (value === undefined || value === null || value === '') return null;
1018
+ const raw = String(value).trim().toLowerCase();
1019
+ if (!raw) return null;
1020
+ if (['0', 'false', 'no', 'off'].includes(raw)) return false;
1021
+ if (['1', 'true', 'yes', 'on'].includes(raw)) return true;
1022
+ return true;
1023
+ }
1024
+
1025
+ function shouldAckScInboxNotify(mode = getScInboxDeliveryMode()) {
1026
+ const explicit = parseBooleanEnv(process.env.SC_INBOX_NOTIFY_ACK);
1027
+ if (explicit !== null) return explicit;
1028
+ return mode === 'notify-only';
1029
+ }
1030
+
1031
+ function shutdown() {
1032
+ if (scInboxTimer) {
1033
+ clearInterval(scInboxTimer);
1034
+ scInboxTimer = null;
1035
+ }
1036
+ if (sseClients) {
1037
+ for (const [, res] of sseClients) {
1038
+ try { res.end(); } catch {}
1039
+ }
1040
+ sseClients.clear();
1041
+ }
1042
+ if (server) { try { server.close(); } catch {} }
1043
+ }
1044
+
1045
+ try {
1046
+ // SSE 客户端连接池 (sessionId -> response)
1047
+ sseClients = new Map();
1048
+
1049
+ let sessionIdCounter = 0;
1050
+ function generateSessionId() {
1051
+ return `session_${Date.now()}_${++sessionIdCounter}`;
1052
+ }
1053
+
1054
+ // ====== 从统一配置加载工具定义 ======
1055
+ // mcp-tools.config.json 集中管理所有 MCP 工具和访问规则
1056
+ const { readFileSync } = await import('fs');
1057
+ const configPath = join(PLUGIN_DIR, 'tools', 'mcp-tools.config.json');
1058
+ let config;
1059
+ try {
1060
+ config = JSON.parse(readFileSync(configPath, 'utf-8'));
1061
+ } catch (e) {
1062
+ console.error(`[bridge] 无法加载 MCP 工具配置: ${e.message}`);
1063
+ throw e;
1064
+ }
1065
+
1066
+ const tools = config.tools;
1067
+ const ALLOWED_MCP_TOOLS = new Set(tools.map(t => t.name));
1068
+ const MAIN_DENY_TOOLS = new Set(config.main?.deny || []);
1069
+ const SUBAGENT_BLOCKED_TOOLS = new Set(config.subagent.blocked);
1070
+ const SUBAGENT_PARTIAL_ALLOWED = config.subagent.partialAllowed || {};
1071
+
1072
+ // 心跳/轮询类工具 — cascade cancel时不追杀(当前无心跳工具,Set留空)
1073
+ const HEARTBEAT_TOOLS = new Set([]);
1074
+
1075
+ // ① 全局限流(滑动窗口 — 每 session 每 10 秒最多 30 次)
1076
+ const RATE_WINDOW_MS = 10000;
1077
+ const RATE_MAX_CALLS = 60;
1078
+ const mcpRateBuckets = new Map(); // sessionId -> { timestamps: [] }
1079
+ const antiRecurMap = new Map(); // 防递归: toolName:sessionId -> timestamp
1080
+ const toolRateLimitMap = new Map(); // 工具级限流: toolName:sessionId -> { sec, count }
1081
+
1082
+ // 🚫 硬编码 spawn 递归深度限制 — 杉哥2026-06-03颁令
1083
+ // 子agent调兵最多3层,谁调都不绕过,天王老子来了也最多3层
1084
+ const HARD_SPAWN_LIMIT = 3;
1085
+ const spawnDepthMap = new Map(); // parentSessionId -> depth (0=主agent)
1086
+
1087
+ /**
1088
+ * 追加审计日志条目到 audit chain
1089
+ * 用于 superExec/superFile 等超级工具,自动记录操作到日志文件
1090
+ */
1091
+ async function appendAuditLog(entry) {
1092
+ try {
1093
+ const { readFile, writeFile, mkdir } = await import('fs/promises');
1094
+ const { join: jn } = await import('path');
1095
+ const auditBase = jn(PLUGIN_DIR, 'logs', 'audit');
1096
+ await mkdir(auditBase, { recursive: true }).catch(() => {});
1097
+ const auditFile = jn(auditBase, 'super-operations.chain');
1098
+ const hashFile = jn(auditBase, 'super-last-hash.txt');
1099
+
1100
+ // 读取上一个hash
1101
+ let prevHash = '';
1102
+ try { prevHash = await readFile(hashFile, 'utf-8'); } catch {}
1103
+
1104
+ const crypto = await import('crypto');
1105
+ entry.prevLogHash = prevHash.trim();
1106
+ const entryStr = JSON.stringify(entry);
1107
+ const hash = crypto.createHash('sha256').update(entryStr).digest('hex');
1108
+ entry.hash = 'sha256:' + hash;
1109
+
1110
+ await writeFile(auditFile, JSON.stringify(entry) + '\n', { flag: 'a', encoding: 'utf-8' });
1111
+ await writeFile(hashFile, hash, 'utf-8');
1112
+ } catch (e) {
1113
+ console.warn('[sc 审计] ⚠️ appendAuditLog 写入失败:', e.message);
1114
+ }
1115
+ }
1116
+
1117
+ // ====== MCP 请求并发信号量(防 >5 并发瓶颈)======
1118
+ // 限制同时处理的 /messages POST 请求数,防止 Worker 池过载 + 主线程 Event Loop 阻塞
1119
+ const MCP_MAX_CONCURRENT = 200; // 适配20路日常+100路爆发
1120
+ let mcpConcurrentCount = 0;
1121
+ const mcpConcurrentQueue = []; // {resolve, reject, ts} 排队等待的请求
1122
+
1123
+ // 从 MCP 并发队列中取出等待的请求处理
1124
+ function drainMcpConcurrentQueue() {
1125
+ while (mcpConcurrentCount < MCP_MAX_CONCURRENT && mcpConcurrentQueue.length > 0) {
1126
+ const waiter = mcpConcurrentQueue.shift();
1127
+ mcpConcurrentCount++;
1128
+ waiter.resolve(); // 释放等待者
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * 尝试获取 MCP 并发槽位
1134
+ * 超出 MCP_MAX_CONCURRENT 则排队等待(最多等 30 秒超时)
1135
+ * @returns {Promise<boolean>} true=拿到槽位, false=排队超时
1136
+ */
1137
+ function acquireMcpSlot() {
1138
+ if (mcpConcurrentCount < MCP_MAX_CONCURRENT) {
1139
+ mcpConcurrentCount++;
1140
+ return Promise.resolve(true);
1141
+ }
1142
+ return new Promise((resolve, reject) => {
1143
+ const timeout = setTimeout(() => {
1144
+ const idx = mcpConcurrentQueue.indexOf(waiter);
1145
+ if (idx >= 0) mcpConcurrentQueue.splice(idx, 1);
1146
+ resolve(false); // 排队超时,返回 false
1147
+ }, 30000);
1148
+ const waiter = {
1149
+ resolve: () => { clearTimeout(timeout); resolve(true); },
1150
+ reject: () => { clearTimeout(timeout); reject(new Error('MCP 请求被取消')); },
1151
+ ts: Date.now(),
1152
+ };
1153
+ mcpConcurrentQueue.push(waiter);
1154
+ });
1155
+ }
1156
+
1157
+ function releaseMcpSlot() {
1158
+ mcpConcurrentCount = Math.max(0, mcpConcurrentCount - 1);
1159
+ drainMcpConcurrentQueue();
1160
+ }
1161
+
1162
+ // ====== SSE 写队列:防止同一 session 的并发 SSE write 交错 ======
1163
+ const sseWriteQueues = new Map(); // sessionId -> Promise chain
1164
+
1165
+ /**
1166
+ * 安全地向 SSE session 写数据,同一 session 串行化写操作
1167
+ */
1168
+ function safeSseWrite(sessionId, msg) {
1169
+ if (!sseWriteQueues.has(sessionId)) {
1170
+ sseWriteQueues.set(sessionId, Promise.resolve());
1171
+ }
1172
+ const chain = sseWriteQueues.get(sessionId);
1173
+ const next = chain.then(() => {
1174
+ const res = sseClients.get(sessionId);
1175
+ if (res) {
1176
+ try { res.write(msg); } catch { sseClients.delete(sessionId); }
1177
+ }
1178
+ });
1179
+ sseWriteQueues.set(sessionId, next.catch(() => {}));
1180
+ return next;
1181
+ }
1182
+
1183
+ // 工具级限流阈值(按 TOOL_TIERS 等级划分)
1184
+ const TOOL_RATE_LIMITS = {
1185
+ safe: Infinity, // safe级:无限流(本来就是低频的)
1186
+ suggest_delegate: 10, // suggest_delegate级:10次/秒
1187
+ };
1188
+
1189
+ function checkMcpRateLimit(sessionId) {
1190
+ if (!sessionId) return true; // 无 sessionId 不限制
1191
+ const now = Date.now();
1192
+ let bucket = mcpRateBuckets.get(sessionId);
1193
+ if (!bucket) {
1194
+ bucket = { timestamps: [] };
1195
+ mcpRateBuckets.set(sessionId, bucket);
1196
+ }
1197
+ // 滑窗修剪
1198
+ bucket.timestamps = bucket.timestamps.filter(t => now - t < RATE_WINDOW_MS);
1199
+ // [H-03 FIX] 惰性清理:空桶旧session条目自动删除
1200
+ if (bucket.timestamps.length === 0) {
1201
+ mcpRateBuckets.delete(sessionId);
1202
+ }
1203
+ if (bucket.timestamps.length >= RATE_MAX_CALLS) {
1204
+ return false; // 超限
1205
+ }
1206
+ bucket.timestamps.push(now);
1207
+ return true;
1208
+ }
1209
+
1210
+ // ③ MCP级连接口(保留供将来扩展)
1211
+
1212
+ // ====== JSON-RPC 处理 ======
1213
+ async function handleJsonRpc(body, sessionId, eventCtx = {}) {
1214
+ const { jsonrpc, id, method, params } = body;
1215
+ if (jsonrpc !== '2.0' || !method) {
1216
+ return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } };
1217
+ }
1218
+
1219
+ // ====== V4 完整安全链(前半段,仅 tools/call 生效)======
1220
+ // ⑧ 优先判断:子agent放行(跳过整条安全链直接执行)
1221
+ const isSubagent = eventCtx?.isSubagent === true;
1222
+
1223
+ // [M-01 FIX] Prompt Injection检测对所有 tools/call 生效(含子agent)
1224
+ if (method === 'tools/call') {
1225
+ const _piToolName = params?.name;
1226
+ if (_piToolName) {
1227
+ try {
1228
+ const injectResult = detectToolInjection(_piToolName, params?.arguments || {});
1229
+ if (injectResult.block) {
1230
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: `[🛡️] ${injectResult.reason}` } };
1231
+ }
1232
+ } catch (e) {
1233
+ console.warn('[PiFilter]⛔ 检测异常:', e.message);
1234
+ }
1235
+ }
1236
+ }
1237
+
1238
+ // [SUBAGENT-BLOCK] 子agent安全链 — v5.18.9 硬拦截(杉哥2026-06-03颁令)
1239
+ // 子agent一律禁调以下所有工具。返回统一消息。
1240
+ if (method === 'tools/call' && isSubagent) {
1241
+ const _subToolName = params?.name;
1242
+
1243
+ // [SUBAGENT-BLOCK v5.30.0] 子agent工具拦截 — 规则从 mcp-tools.config.json 动态加载
1244
+ if (_subToolName) {
1245
+ const partialRule = SUBAGENT_PARTIAL_ALLOWED[_subToolName];
1246
+ if (partialRule) {
1247
+ // 部分放行:检查 action 是否在配置的白名单内
1248
+ const _action = params?.arguments?.action || '';
1249
+ if (partialRule.actions.includes(_action)) {
1250
+ // 放行 — 走的工具在部分放行白名单内
1251
+ // 额外规则:fileManager write 需 workspace 路径检查
1252
+ if (_subToolName === 'fileManager' && _action === 'write') {
1253
+ const _writePath = params?.arguments?.path || '';
1254
+ const WORKSPACE_PREFIX = join(homedir(), '.openclaw', 'workspace');
1255
+ const normalizedWritePath = normalize(_writePath || '');
1256
+ if (!normalizedWritePath.startsWith(WORKSPACE_PREFIX)) {
1257
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: '[🛡️] fileManager write仅允许workspace路径(杉哥限制)' } };
1258
+ }
1259
+ }
1260
+ } else {
1261
+ // 操作不在白名单内 — 拦截
1262
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: `[🛡️] ${_subToolName} 子agent仅允许操作: ${partialRule.actions.join(', ')}(杉哥限制)` } };
1263
+ }
1264
+ } else if (SUBAGENT_BLOCKED_TOOLS.has(_subToolName)) {
1265
+ // 全量拦截:此工具子agent禁调
1266
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: '[🛡️] 此工具子agent无法调用(杉哥限制)' } };
1267
+ }
1268
+ }
1269
+
1270
+ // 通行证继承检查: 硬拦截之外的 force_delegate 级工具仍需通行证
1271
+ if (_subToolName) {
1272
+ try {
1273
+ const _stTiers = getToolTiers();
1274
+ if (_stTiers.force_delegate.includes(_subToolName)) {
1275
+ const coreMod = await getCore();
1276
+ const hasInheritedPass = typeof coreMod.checkSubagentPass === 'function' ? coreMod.checkSubagentPass(_subToolName) : false;
1277
+ if (!hasInheritedPass) {
1278
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: '[🛡️] 此工具子agent无法调用(杉哥限制)' } };
1279
+ }
1280
+ }
1281
+ } catch (e) {
1282
+ console.warn('[SubagentPass]⛔ 通行证检查异常:', e.message);
1283
+ }
1284
+
1285
+ // ① 防递归:子agent同一个工具名+同一个sessionId,3秒内不能重复调
1286
+ const _subAntiRecurKey = `${_subToolName}:${sessionId}`;
1287
+ const _subNow = Date.now();
1288
+ if (antiRecurMap.has(_subAntiRecurKey)) {
1289
+ const _subLastCall = antiRecurMap.get(_subAntiRecurKey);
1290
+ if (_subNow - _subLastCall < 3000) {
1291
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: `工具 ${_subToolName} 3秒内重复调用被拦截 (距上次仅 ${_subNow - _subLastCall}ms)` } };
1292
+ }
1293
+ }
1294
+ antiRecurMap.set(_subAntiRecurKey, _subNow);
1295
+ if (antiRecurMap.size > 1000) {
1296
+ const _subCutoff = _subNow - 20000;
1297
+ for (const [k, v] of antiRecurMap) {
1298
+ if (v < _subCutoff) antiRecurMap.delete(k);
1299
+ }
1300
+ }
1301
+ }
1302
+ }
1303
+
1304
+ if (method === 'tools/call' && !isSubagent) {
1305
+ const toolName = params?.name;
1306
+ if (!toolName) {
1307
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32602, message: '缺少工具名称' } };
1308
+ }
1309
+
1310
+ // ① Prompt Injection 检测已前置(见上方[M-01 FIX]块,子agent也覆盖),此处跳过
1311
+
1312
+
1313
+
1314
+ // ② 白名单判断
1315
+ if (!ALLOWED_MCP_TOOLS.has(toolName)) {
1316
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32601, message: `工具 ${toolName} 不允许通过 MCP 调用` } };
1317
+ }
1318
+
1319
+ // ⚡ 脊髓反射白名单 — 高频safe工具直通Worker,绕开步骤③④⑤⑥(当前无,Set留空)
1320
+ const SPINAL_REFLEX_TOOLS = new Set([
1321
+ ]);
1322
+ const isSpinalReflex = SPINAL_REFLEX_TOOLS.has(toolName);
1323
+
1324
+ if (!isSpinalReflex) {
1325
+ // ③ 通行证检查(仅 force_delegate 级工具需要通行证,safe/suggest_delegate/未分类defaultsafe的跳过)
1326
+ const _passBypass = [];
1327
+ const _passTiers = getToolTiers();
1328
+ if (_passTiers.force_delegate.includes(toolName) && !_passBypass.includes(toolName)) {
1329
+ try {
1330
+ const coreModule = await getCore();
1331
+ if (typeof coreModule.checkPass === 'function') {
1332
+ if (!coreModule.checkPass(toolName)) {
1333
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: `工具 ${toolName} 需要通行证或已被系统限制` } };
1334
+ }
1335
+ }
1336
+ } catch {}
1337
+ }
1338
+
1339
+ // ④ StewardGuard tier check (using getToolTiers)
1340
+ // 杉哥2026-06-09: 修复步骤③④互斥——步骤③已放行的force_delegate(有通行证)不再被④拦截
1341
+ try {
1342
+ const _sTiers = getToolTiers();
1343
+ if (_sTiers.force_delegate.includes(toolName)) {
1344
+ let hasPass = false;
1345
+ try {
1346
+ const coreModule = await getCore();
1347
+ if (typeof coreModule.checkPass === 'function') {
1348
+ hasPass = coreModule.checkPass(toolName);
1349
+ }
1350
+ } catch {}
1351
+ if (!hasPass) {
1352
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: `[Steward] 🚫 ${toolName} 属于 force_delegate 级,必须由子 agent 执行` } };
1353
+ }
1354
+ }
1355
+ } catch (e) { console.warn('[Steward]⛔ 步骤4异常:', e.message); }
1356
+
1357
+
1358
+ }
1359
+
1360
+ // ⑦ 全局限流(滑动窗口 — 每 session 每 10 秒最多 30 次)
1361
+ if (!checkMcpRateLimit(sessionId)) {
1362
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: 'MCP 调用频率过高(每10秒最多30次),请稍后再试' } };
1363
+ }
1364
+
1365
+ // ② 工具级限流 — 每个工具独立计数器,按 TOOL_TIERS 等级划分阈值
1366
+ // safe级:无限流 | suggest_delegate级:10次/秒 | force_delegate级:到不了MCP层
1367
+ const nowSec = Math.floor(Date.now() / 1000);
1368
+ const toolRateKey = `${toolName}:${sessionId || 'default'}`;
1369
+ let toolBucket = toolRateLimitMap.get(toolRateKey);
1370
+ if (!toolBucket || toolBucket.sec !== nowSec) {
1371
+ toolBucket = { sec: nowSec, count: 0 };
1372
+ toolRateLimitMap.set(toolRateKey, toolBucket);
1373
+ // [H-02 FIX] 惰性清理:toolRateLimitMap 过期条目(清理超过5秒的旧桶)
1374
+ if (toolRateLimitMap.size > 5000) {
1375
+ const cutoffSec = nowSec - 5;
1376
+ for (const [k, v] of toolRateLimitMap) {
1377
+ if (v.sec < cutoffSec) toolRateLimitMap.delete(k);
1378
+ if (toolRateLimitMap.size <= 3500) break;
1379
+ }
1380
+ }
1381
+ }
1382
+ toolBucket.count++;
1383
+ let toolLimit = TOOL_RATE_LIMITS.safe; // default不限流
1384
+ try {
1385
+ const tiers = getToolTiers();
1386
+ if (tiers.suggest_delegate.includes(toolName)) {
1387
+ toolLimit = TOOL_RATE_LIMITS.suggest_delegate;
1388
+ }
1389
+ // force_delegate 级的工具到不了这一步(步骤④已拦截,所以忽略)
1390
+ } catch {}
1391
+ if (toolBucket.count > toolLimit) {
1392
+ const limitMsg = toolLimit === Infinity ? '无限(安全级)' : `${toolLimit}次/秒`;
1393
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: `工具 ${toolName} 调用频率过高(阈值: ${limitMsg})` } };
1394
+ }
1395
+
1396
+
1397
+ }
1398
+
1399
+ // 内存保护(跨method通用)
1400
+ if (method === 'tools/call') {
1401
+ try {
1402
+ const coreModule = await getCore();
1403
+ if (typeof coreModule.getMemoryLevel === 'function') {
1404
+ const mem = coreModule.getMemoryLevel();
1405
+ if (mem.level === 'meltdown') {
1406
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: `系统内存严重不足(${mem.freeGB.toFixed(1)}GB)` } };
1407
+ }
1408
+ if (mem.level === 'red') {
1409
+ const writeTools = ['fileManager', 'codeEditor'];
1410
+ const toolName = params?.name;
1411
+ if (toolName && writeTools.includes(toolName)) {
1412
+ return { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: `空闲内存仅 ${mem.freeGB.toFixed(1)}GB(红灯),写操作被限制` } };
1413
+ }
1414
+ }
1415
+ }
1416
+ } catch {}
1417
+ }
1418
+
1419
+ // cascade cancel追踪(v12心跳跳过)
1420
+ if (method === 'tools/call') {
1421
+ const toolName = params?.name;
1422
+ if (toolName && HEARTBEAT_TOOLS.has(toolName)) {
1423
+ // 跳过级联追踪
1424
+ }
1425
+ }
1426
+
1427
+ switch (method) {
1428
+ case 'initialize': {
1429
+ return {
1430
+ jsonrpc: '2.0', id,
1431
+ result: {
1432
+ protocolVersion: '2024-11-05',
1433
+ capabilities: {
1434
+ tools: {},
1435
+ resources: {},
1436
+ },
1437
+ serverInfo: { name: 'sc', version: '5.37.0', description: 'sc v5.37.0 — 28核并行决策引擎 | Worker 池 + MCP 工具 + 语义搜索 + 子agent执行中心' },
1438
+ },
1439
+ };
1440
+ }
1441
+
1442
+ case 'tools/list': {
1443
+ // SSE连接(Gateway)传sessionId → 返回全部工具
1444
+ // 直接POST(子agent)无sessionId → 只返回子agent能用的工具
1445
+ // 注意:不要用 url.xxx!handleJsonRpc 不在HTTP闭包里,url不存在!
1446
+ // 必须用函数参数 sessionId
1447
+ const subagentOnly = params?.role === 'subagent';
1448
+ if (subagentOnly) {
1449
+ const safe = tools.filter(t => !SUBAGENT_BLOCKED_TOOLS.has(t.name));
1450
+ return { jsonrpc: '2.0', id, result: { tools: safe } };
1451
+ }
1452
+ // 主会话过滤 main.deny 工具
1453
+ const mainSafe = tools.filter(t => !MAIN_DENY_TOOLS.has(t.name));
1454
+ return { jsonrpc: '2.0', id, result: { tools: mainSafe } };
1455
+ }
1456
+
1457
+ case 'tools/call': {
1458
+ let toolName = params?.name;
1459
+ const args = params?.arguments || {};
1460
+
1461
+ const core = await getCore();
1462
+
1463
+ // ⑨ 熔断器:按session隔离检查(一个兵死了不影响其他兵)
1464
+ if (toolName && !HEARTBEAT_TOOLS.has(toolName) && isToolMelted(toolName, sessionId)) {
1465
+ return {
1466
+ jsonrpc: '2.0', id,
1467
+ error: { code: -32000, message: `工具 ${toolName} 在你的会话中暂时熔断(连续失败≥3次,30秒后自动恢复,不影响其他会话)` },
1468
+ };
1469
+ }
1470
+
1471
+ let _execStartTime;
1472
+ try {
1473
+ let result;
1474
+ _execStartTime = Date.now();
1475
+ switch (toolName) {
1476
+
1477
+
1478
+ case 'spawnWorker': {
1479
+ // 走Worker池(CPU)/USearch/本地CPU — 不走Sidecar,零Token
1480
+ // 杉哥2026-06-09: spawnWorker=Worker池,spawnAgent=Sidecar
1481
+ try {
1482
+ const action = args.action || 'search';
1483
+ const core = await getCore();
1484
+ const pool = core.pool;
1485
+ const priority = args.priority || 'normal';
1486
+ const maxResults = Math.min(Number(args.maxResults) || 20, 100);
1487
+ let result;
1488
+
1489
+ switch (action) {
1490
+ case 'search': {
1491
+ const keyword = args.keyword || '';
1492
+ const files = args.files || [];
1493
+ const searchPath = args.path || '';
1494
+ if (!keyword) throw new Error('search action \u7f3a\u5c11 keyword');
1495
+ // 如果传了 path 且没传 files,自动枚举该目录
1496
+ let searchFiles = files;
1497
+ if (files.length === 0 && searchPath) {
1498
+ const { readdirSync, statSync } = await import('fs');
1499
+ const { extname } = await import('path');
1500
+ const textExts = new Set(['.md','.js','.cjs','.json','.txt','.yml','.yaml','.conf','.cfg','.toml','.ini','.env','.css','.html','.htm','.xml','.svg','.py','.sh','.bat','.ps1']);
1501
+ function walkDir(dir) {
1502
+ try {
1503
+ const entries = readdirSync(dir, { withFileTypes: true });
1504
+ for (const e of entries) {
1505
+ if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === 'logs' || e.name === 'hippocampus') continue;
1506
+ const full = dir + '/' + e.name;
1507
+ if (e.isDirectory()) walkDir(full);
1508
+ else if (e.isFile() && textExts.has(extname(e.name).toLowerCase()) && statSync(full).size < 524288) searchFiles.push(full);
1509
+ }
1510
+ } catch {}
1511
+ }
1512
+ walkDir(searchPath);
1513
+ }
1514
+ // 走真正的 Worker 池并行搜索(28路 Worker 分块执行)
1515
+ const core = await getCore();
1516
+ const poolStats = core.getStats();
1517
+ const chunks = splitFiles(searchFiles, poolStats.maxWorkers || 28);
1518
+ const rawResults = await Promise.all(
1519
+ chunks.map(fc => core.pool.exec({ type: 'search-text', keyword, files: fc, maxFiles: 999999, maxMinResults: 999999 }, priority))
1520
+ );
1521
+ result = mergeSearchResults(rawResults, poolStats);
1522
+ result.action = 'search';
1523
+ result.keyword = keyword;
1524
+ result.originalFiles = searchFiles.length;
1525
+ break;
1526
+ }
1527
+ case 'analyze': {
1528
+ const anaFiles = args.files || [];
1529
+ const analysis = [];
1530
+ for (const fp of anaFiles.slice(0, 100)) {
1531
+ try {
1532
+ const content = readFileSync(fp, 'utf-8');
1533
+ const lineArr = content.split('\n');
1534
+ analysis.push({
1535
+ file: fp,
1536
+ size: Buffer.byteLength(content, 'utf8'),
1537
+ lines: lineArr.length,
1538
+ preview: lineArr.slice(0, 10).join('\n').substring(0, 500),
1539
+ ext: fp.split('.').pop()
1540
+ });
1541
+ } catch (e) { analysis.push({ file: fp, error: e.message }); }
1542
+ }
1543
+ result = { action: 'analyze', total: anaFiles.length, analyzed: analysis.length, files: analysis };
1544
+ break;
1545
+ }
1546
+ case 'semantic':
1547
+ if (!args.query) throw new Error('semantic action 缺少 query');
1548
+ result = await qdrantSearch(args.query, maxResults);
1549
+ break;
1550
+ case 'diff': {
1551
+ const diffFiles = args.files || [];
1552
+ if (diffFiles.length < 2) throw new Error('diff action \u9700\u8981\u81f3\u5c112\u4e2a\u6587\u4ef6');
1553
+ const contentA = readFileSync(diffFiles[0], 'utf-8').split('\n');
1554
+ const contentB = readFileSync(diffFiles[1], 'utf-8').split('\n');
1555
+ const maxLen = Math.max(contentA.length, contentB.length);
1556
+ const diffs = [];
1557
+ for (let i = 0; i < maxLen; i++) {
1558
+ const lineA = contentA[i] || '';
1559
+ const lineB = contentB[i] || '';
1560
+ if (lineA !== lineB) {
1561
+ diffs.push({ line: i + 1, a: lineA.substring(0, 200), b: lineB.substring(0, 200) });
1562
+ if (diffs.length >= 50) break;
1563
+ }
1564
+ }
1565
+ result = { action: 'diff', fileA: diffFiles[0], fileB: diffFiles[1], totalLines: maxLen, diffCount: diffs.length, diffs };
1566
+ break;
1567
+ }
1568
+ case 'stats': {
1569
+ const { readFile } = await import('fs/promises');
1570
+ const { statSync } = await import('fs');
1571
+ let statsResults = [];
1572
+ for (let fi=0; fi<Math.min((args.files||[]).length,200); fi++) {
1573
+ try {
1574
+ const fp=args.files[fi],s=statSync(fp);
1575
+ statsResults.push({file:fp,size:s.size,lines:0,mtime:s.mtime.toISOString()});
1576
+ }catch(e){statsResults.push({file:args.files[fi],error:e.message});}
1577
+ }
1578
+ result = {action:'stats',files:statsResults};
1579
+ break;
1580
+ }
1581
+ default: throw new Error('unknown action: '+action);
1582
+ }
1583
+ return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }] } };
1584
+ } catch (e) {
1585
+ return { jsonrpc: '2.0', id, error: { code: -32603, message: e.message } };
1586
+ }
1587
+ }
1588
+
1589
+ case 'spawnAgent': {
1590
+ const guard = validateScSpawnTask(args, 'spawnAgent', { mode: SC_TASKCARD_GUARD_MODE });
1591
+ if (!guard.ok) return scGuardJsonRpcError(id, guard);
1592
+ args.__scGuard = guard;
1593
+ const result = await spawnAgent(args);
1594
+ if (result) {
1595
+ // C方案兜底: 120秒后检查子agent是否完成
1596
+ const cTaskId = result.taskId || result.id;
1597
+ if (cTaskId) {
1598
+ setTimeout(() => {
1599
+ try {
1600
+ const watchDir = join(homedir(), '.openclaw', 'workspace', 'memory', 'dialog', 'subagent');
1601
+ const doneSuccess = join(watchDir, `DONE_${cTaskId}_success`);
1602
+ const doneFailed = join(watchDir, `DONE_${cTaskId}_failed`);
1603
+ const doneStalled = join(watchDir, `DONE_${cTaskId}_stalled`);
1604
+ const doneOrphaned = join(watchDir, `DONE_${cTaskId}_orphaned`);
1605
+ if (!existsSync(doneSuccess) && !existsSync(doneFailed) && !existsSync(doneStalled) && !existsSync(doneOrphaned)) {
1606
+ if (!existsSync(watchDir)) mkdirSync(watchDir, { recursive: true });
1607
+ writeFileSync(join(watchDir, `WATCH_${cTaskId}.json`), JSON.stringify({ taskId: cTaskId, status: 'overdue', checkedAt: new Date().toISOString(), message: '子agent可能超时, 请检查' }));
1608
+ }
1609
+ } catch (e) { /* 静默 */ }
1610
+ }, 120000);
1611
+ }
1612
+ return { jsonrpc: '2.0', id, result: result };
1613
+ }
1614
+ return { jsonrpc: '2.0', id, error: { code: -32603, message: 'spawnAgent failed' } };
1615
+ }
1616
+
1617
+ case 'taskPipeline': {
1618
+ const guard = validateScPipelineTask(args, { mode: SC_TASKCARD_GUARD_MODE });
1619
+ if (!guard.ok) return scGuardJsonRpcError(id, guard);
1620
+ args.__scGuard = guard;
1621
+ const result = await spawnTaskPipeline(args);
1622
+ if (result) return { jsonrpc: '2.0', id, result: result };
1623
+ return { jsonrpc: '2.0', id, error: { code: -32603, message: 'taskPipeline failed' } };
1624
+ }
1625
+
1626
+ case 'scInbox': {
1627
+ const result = await scInbox(args);
1628
+ return { jsonrpc: '2.0', id, result };
1629
+ }
1630
+
1631
+
1632
+
1633
+ case 'webSearch': {
1634
+ // 多后端搜索+直连抓取,0Token不走LLM
1635
+ const searchAction = args?.action || 'web_search';
1636
+ if (searchAction === 'web_search') {
1637
+ result = await runWebSearch(args, config);
1638
+ } else if (searchAction === 'web_fetch') {
1639
+ result = await runWebFetch(args, config);
1640
+ } else {
1641
+ result = { status: 'error', message: `Unknown操作: ${searchAction}` };
1642
+ }
1643
+ break;
1644
+ }
1645
+
1646
+ case 'glob': {
1647
+ // 文件 glob 匹配 — 主会话+子Agent 通用
1648
+ const { glob } = await import('fs/promises');
1649
+ const pattern = args?.pattern;
1650
+ if (!pattern) throw Object.assign(new Error('缺少 pattern'), { code: -32602 });
1651
+ const basePath = args?.path || join(homedir(), '.openclaw', 'workspace');
1652
+ const fullPattern = join(basePath, pattern).replace(/\\/g, '/');
1653
+ const raw = await Array.fromAsync(glob(fullPattern, { exclude: (filePath) => filePath.includes('node_modules') || filePath.includes('.git/') || filePath.includes('logs/') }));
1654
+ // 按修改时间降序
1655
+ const { stat } = await import('fs/promises');
1656
+ const withStats = await Promise.all(raw.map(async (f) => {
1657
+ try { const s = await stat(f); return { path: f, mtimeMs: s.mtimeMs }; }
1658
+ catch { return { path: f, mtimeMs: 0 }; }
1659
+ }));
1660
+ withStats.sort((a, b) => b.mtimeMs - a.mtimeMs);
1661
+ const files = withStats.map(s => s.path);
1662
+ result = { files, total: files.length };
1663
+ break;
1664
+ }
1665
+
1666
+ case 'grep': {
1667
+ // ripgrep 高速代码搜索 — 主会话+子Agent 通用
1668
+ const { execFileSync } = await import('child_process');
1669
+ const { existsSync } = await import('fs');
1670
+ const bundledRg = join(PLUGIN_DIR, 'tools', 'rg', 'ripgrep-14.1.1-x86_64-pc-windows-msvc', 'rg.exe');
1671
+ const rgExe = process.env.SC_RG_PATH || (existsSync(bundledRg) ? bundledRg : 'rg');
1672
+ const pattern = args?.pattern;
1673
+ if (!pattern) throw Object.assign(new Error('缺少 pattern'), { code: -32602 });
1674
+ const searchPath = args?.path || join(homedir(), '.openclaw', 'workspace');
1675
+ const outputMode = args?.outputMode || 'content';
1676
+ const rgArgs = ['--no-heading', '--color', 'never'];
1677
+ if (args?.ignoreCase) rgArgs.push('-i');
1678
+ if (args?.multiline) rgArgs.push('--multiline');
1679
+ if (args?.glob) { rgArgs.push('-g', args.glob); }
1680
+ if (args?.context) { rgArgs.push('-C', String(args.context)); }
1681
+ if (outputMode === 'files_with_matches') rgArgs.push('-l');
1682
+ else if (outputMode === 'count') rgArgs.push('--count');
1683
+ else rgArgs.push('-n'); // content mode: show line numbers
1684
+ rgArgs.push('--', pattern, searchPath);
1685
+ let raw = '';
1686
+ try {
1687
+ raw = execFileSync(rgExe, rgArgs, { timeout: 60000, maxBuffer: 2 * 1024 * 1024, encoding: 'utf-8' }).trim();
1688
+ } catch (err) {
1689
+ if (err?.status === 1 && !err?.stdout) raw = '';
1690
+ else throw Object.assign(new Error('ripgrep 不可用;请安装 rg 或设置 SC_RG_PATH 指向 rg 可执行文件'), { code: -32000, cause: err });
1691
+ }
1692
+ if (!raw) { result = { matches: [], summary: '无匹配' }; }
1693
+ else {
1694
+ const lines = raw.split('\n').filter(Boolean);
1695
+ const headLimit = args?.headLimit || 250;
1696
+ const offset = args?.offset || 0;
1697
+ const sliced = offset ? lines.slice(offset) : lines;
1698
+ const limited = headLimit ? sliced.slice(0, headLimit) : sliced;
1699
+ result = { matches: limited, total: lines.length, mode: outputMode };
1700
+ }
1701
+ break;
1702
+ }
1703
+
1704
+ case 'stats': {
1705
+ try {
1706
+ result = await handleCoreStats({}, core.pool);
1707
+ } catch (e) {
1708
+ return { jsonrpc: '2.0', id, error: { code: -32603, message: e.message } };
1709
+ }
1710
+ break;
1711
+ }
1712
+
1713
+ case 'batchVision': {
1714
+ const { files, prompt, model, priority } = args;
1715
+ try {
1716
+ result = await handleCoreImageBatch({ files, prompt, model, priority }, core.pool);
1717
+ } catch (e) {
1718
+ return { jsonrpc: '2.0', id, error: { code: -32603, message: e.message } };
1719
+ }
1720
+ break;
1721
+ }
1722
+
1723
+ case 'fileManager': {
1724
+ const { action, path, content } = args;
1725
+ if (!action) throw Object.assign(new Error('缺少 action'), { code: -32602 });
1726
+ if (!path) throw Object.assign(new Error('缺少 path'), { code: -32602 });
1727
+ const fs = await import('fs/promises');
1728
+ const { join: jn, dirname: dn } = await import('path');
1729
+ switch (action) {
1730
+ case 'read': {
1731
+ const safePath = await validatePath(path);
1732
+ const text = await fs.readFile(safePath, 'utf-8');
1733
+ result = { status: 'success', action: 'read', path, size: text.length, content: text };
1734
+ break;
1735
+ }
1736
+ case 'write': {
1737
+ if (content === undefined || content === null) throw Object.assign(new Error('write 操作需要 content 参数'), { code: -32602 });
1738
+ const safePath = await validatePath(path);
1739
+ await fs.mkdir(dn(safePath), { recursive: true });
1740
+ await fs.writeFile(safePath, String(content), 'utf-8');
1741
+ result = { status: 'success', action: 'write', path, size: String(content).length };
1742
+ break;
1743
+ }
1744
+ case 'copy': {
1745
+ const dest = args.dest || args.target;
1746
+ if (!dest) throw Object.assign(new Error('copy 操作需要 dest/target 参数'), { code: -32602 });
1747
+ const safeSrc = await validatePath(path);
1748
+ const safeDest = await validatePath(dest);
1749
+ await fs.mkdir(dn(safeDest), { recursive: true });
1750
+ await fs.cp(safeSrc, safeDest, { recursive: true });
1751
+ result = { status: 'success', action: 'copy', source: path, dest };
1752
+ break;
1753
+ }
1754
+ case 'move': {
1755
+ const dest = args.dest || args.target;
1756
+ if (!dest) throw Object.assign(new Error('move 操作需要 dest/target 参数'), { code: -32602 });
1757
+ const safeSrc = await validatePath(path);
1758
+ const safeDest = await validatePath(dest);
1759
+ await fs.mkdir(dn(safeDest), { recursive: true });
1760
+ await fs.rename(safeSrc, safeDest);
1761
+ result = { status: 'success', action: 'move', source: path, dest };
1762
+ break;
1763
+ }
1764
+ case 'list': {
1765
+ const safePath = await validatePath(path);
1766
+ const entries = await fs.readdir(safePath, { withFileTypes: true });
1767
+ const listing = entries.map(e => ({
1768
+ name: e.name,
1769
+ type: e.isDirectory() ? 'directory' : 'file',
1770
+ }));
1771
+ result = { status: 'success', action: 'list', path, entries: listing, total: listing.length };
1772
+ break;
1773
+ }
1774
+ default:
1775
+ throw Object.assign(new Error(`Unknown操作: ${action}`), { code: -32602 });
1776
+ }
1777
+ break;
1778
+ }
1779
+
1780
+ case 'validate': {
1781
+ const { action, path: vPath } = args;
1782
+ if (!action || !vPath) throw Object.assign(new Error('缺少 action 或 path'), { code: -32602 });
1783
+
1784
+ const safePath = await validatePath(vPath);
1785
+ const { execSync: es, spawnSync } = await import('child_process');
1786
+
1787
+ switch (action) {
1788
+ case 'check': {
1789
+ try {
1790
+ es(`node --check "${safePath}"`, { encoding: 'utf8', timeout: 15000, stdio: ['pipe','pipe','pipe'] });
1791
+ result = { valid: true, exitCode: 0, path: vPath, message: '✅ 语法检查通过' };
1792
+ } catch (e) {
1793
+ const errMsg = e.stderr || e.stdout || e.message || '';
1794
+ result = { valid: false, exitCode: e.status || 1, path: vPath, errors: errMsg.substring(0, 2000) };
1795
+ }
1796
+ break;
1797
+ }
1798
+ case 'load': {
1799
+ // 隔离子进程 require 验证模块能否加载
1800
+ const script = `try { require(${JSON.stringify(safePath)}); console.log('OK'); } catch(e) { console.error(e.message); process.exit(1); }`;
1801
+ const r = spawnSync('node', ['-e', script], { encoding: 'utf8', timeout: 15000, stdio: ['pipe','pipe','pipe'] });
1802
+ if (r.status === 0) {
1803
+ result = { valid: true, exitCode: 0, path: vPath, message: '✅ 模块加载成功' };
1804
+ } else {
1805
+ const errMsg = r.stderr || r.stdout || r.error?.message || '';
1806
+ result = { valid: false, exitCode: r.status || 1, path: vPath, errors: errMsg.substring(0, 2000) };
1807
+ }
1808
+ break;
1809
+ }
1810
+ case 'diff': {
1811
+ // git diff 查看文件改动(只读,安全)
1812
+ const { dirname: dn } = await import('path');
1813
+ const dir = dn(safePath);
1814
+ try {
1815
+ const r = spawnSync('git', ['-C', dir, 'diff', safePath], { encoding: 'utf8', timeout: 10000, stdio: ['pipe','pipe','pipe'] });
1816
+ if (r.status === 0 && r.stdout) {
1817
+ result = { path: vPath, diff: r.stdout.substring(0, 3000), message: r.stdout.length > 3000 ? 'diff过长已截断' : '✅ git diff 成功' };
1818
+ } else if (r.status === 0 && !r.stdout) {
1819
+ result = { path: vPath, diff: '', message: '📄 文件无改动' };
1820
+ } else if (r.stderr && r.stderr.includes('not a git repository')) {
1821
+ result = { path: vPath, diff: null, message: '⚠️ 文件不在 git 仓库中' };
1822
+ } else {
1823
+ result = { path: vPath, diff: null, message: '⚠️ git diff 不可用: ' + (r.stderr || '').substring(0, 200) };
1824
+ }
1825
+ } catch (e) {
1826
+ result = { path: vPath, diff: null, message: '⚠️ git 不可用: ' + (e.message || '').substring(0, 200) };
1827
+ }
1828
+ break;
1829
+ }
1830
+ default:
1831
+ throw Object.assign(new Error(`validate 不支持 action=${action},仅支持 check|load|diff`), { code: -32602 });
1832
+ }
1833
+ break;
1834
+ }
1835
+
1836
+ case 'emergencyStop': {
1837
+ try {
1838
+ const idxMod = await import(pathToFileURL(join(PLUGIN_DIR, 'index.js')).href);
1839
+ const abortFn = idxMod?.default?.cpuAbort || idxMod?.cpuAbort;
1840
+ if (typeof abortFn === 'function') {
1841
+ await abortFn();
1842
+ result = { status: 'success', message: '紧急停止已完成: 所有Worker已终止, 队列已清空' };
1843
+ } else {
1844
+ result = { status: 'error', message: 'cpuAbort 未就绪,紧急停止不可用' };
1845
+ }
1846
+ } catch (e) {
1847
+ result = { status: 'error', message: e.message };
1848
+ }
1849
+ break;
1850
+ }
1851
+ case 'codeEditor': {
1852
+ const codeAction = args.action || 'review_code';
1853
+ const codePath = args.path || '';
1854
+ switch (codeAction) {
1855
+ case 'edit_code':
1856
+ // 🔧 v5.37.0-v5.38.0: 仅保留 edits 模式精确替换(删 content 全量覆盖,防子 Agent 误写)
1857
+ // 用法:先 review_code 读文件 → edit_code(edits=[{oldText,newText}]) 精确替换
1858
+ try {
1859
+ const { writeFile } = await import('fs/promises');
1860
+ const safePath = await validatePath(codePath);
1861
+ if (!args.edits || !Array.isArray(args.edits) || args.edits.length === 0) {
1862
+ throw Object.assign(new Error('edit_code 只支持 edits=[{oldText,newText}] 精确替换模式(content 全量覆盖已删除)'), { code: -32602 });
1863
+ }
1864
+ let originalContent = '';
1865
+ try { originalContent = await rf(safePath, 'utf-8'); } catch {}
1866
+ let finalContent = originalContent;
1867
+ for (const edit of args.edits) {
1868
+ if (!edit.oldText) continue;
1869
+ if (!finalContent.includes(edit.oldText)) {
1870
+ throw Object.assign(new Error(`edit_code: 未找到匹配文本 "${edit.oldText.substring(0, 50)}..."`), { code: -32603 });
1871
+ }
1872
+ finalContent = finalContent.replace(edit.oldText, edit.newText || '');
1873
+ }
1874
+ await writeFile(safePath, finalContent, 'utf-8');
1875
+ // 🔒 硬性语法校验:JS文件写后自动跑 node --check,不通过则回滚
1876
+ if (/\.(js|cjs|mjs)$/i.test(safePath)) {
1877
+ const { execFileSync } = await import('child_process');
1878
+ try {
1879
+ execFileSync(process.execPath, ['--check', safePath], { timeout: 10000, stdio: 'pipe' });
1880
+ } catch (checkErr) {
1881
+ // 语法错误:恢复原文件,拒绝提交
1882
+ if (originalContent) {
1883
+ await writeFile(safePath, originalContent, 'utf-8');
1884
+ }
1885
+ const errMsg = checkErr.stderr?.toString() || checkErr.message;
1886
+ throw Object.assign(new Error(`❌ 语法校验失败,编辑已回滚: ${errMsg.split('\n')[0]}`), { code: -32603 });
1887
+ }
1888
+ }
1889
+ result = { content: [{ type: "text", text: `✅ 已编辑: ${codePath} (${finalContent.length} chars, ${finalContent.split('\n').length} lines)` }] };
1890
+ } catch (e) {
1891
+ throw Object.assign(new Error(`edit_code 失败: ${e.message}`), { code: e.code || -32603 });
1892
+ }
1893
+ break;
1894
+ case 'review_code':
1895
+ // 真实代码审查:读取文件返回给 LLM 分析
1896
+ try {
1897
+ const fileContent = await rf(codePath, 'utf-8');
1898
+ const fileStats = statSync(codePath);
1899
+ const ext = codePath.split('.').pop() || '';
1900
+ result = {
1901
+ content: [{
1902
+ type: "text",
1903
+ text: `## 📄 代码审查: ${codePath}\n` +
1904
+ `- 大小: ${fileStats.size} bytes\n` +
1905
+ `- 修改: ${fileStats.mtime}\n` +
1906
+ `- 行数: ${fileContent.split('\n').length}\n` +
1907
+ `- 扩展名: .${ext}\n\n` +
1908
+ `\`\`\`${ext}\n${fileContent}\n\`\`\``
1909
+ }]
1910
+ };
1911
+ } catch (e) {
1912
+ throw Object.assign(new Error(`review_code 读取文件失败: ${e.message}`), { code: -32603 });
1913
+ }
1914
+ break;
1915
+ default:
1916
+ result = { content: [{ type: "text", text: `Unknownaction: ${codeAction}` }] };
1917
+ break;
1918
+ }
1919
+ break;
1920
+ }
1921
+ case 'memorySearch': {
1922
+ let reasonAction = args.action || 'smart';
1923
+ // smart 自动判断:含时间关键词 → search_dialog, 否则 → semantic_search
1924
+ if (reasonAction === 'smart') {
1925
+ const q = (args.query || '').toLowerCase();
1926
+ const timePattern = /\b(今天|昨天|前天|上周|本周|最近|\d{4}[-/]\d{1,2}|\d{1,2}月\d{1,2}日|刚刚|刚才|早上|下午|晚上)\b/i;
1927
+ reasonAction = timePattern.test(q) ? 'search_dialog' : 'semantic_search';
1928
+ }
1929
+ switch (reasonAction) {
1930
+ case 'semantic_search': {
1931
+ // 🧠 Qdrant 语义搜索
1932
+ const { query, maxResults } = args;
1933
+ if (!query) throw Object.assign(new Error('缺少 query'), { code: -32602 });
1934
+
1935
+ try {
1936
+ const maxRes = Math.min(maxResults || 10, 50);
1937
+ const faissResult = await qdrantSearch(query, maxRes);
1938
+ const topResults = (faissResult.results || []).slice(0, maxRes);
1939
+ // 确保 total_in_index 是数字(防御 HTTP 序列化问题)
1940
+ const totalCount = Number(faissResult.total_in_index) || topResults.length || 0;
1941
+
1942
+ const summaryText = topResults.length > 0
1943
+ ? `🧠 USearch 语义搜索: "${query}" — ${totalCount} 条索引, ${faissResult.elapsed_ms}ms\n` +
1944
+ topResults.slice(0, 10).map((r, i) => ` ${i+1}. [${r.score}] ${r.source}: ${r.text.substring(0, 100)}...`).join('\n')
1945
+ : `🧠 USearch 语义搜索: "${query}" — 未找到匹配 (${totalCount} 条索引, ${faissResult.elapsed_ms}ms)`;
1946
+
1947
+ result = {
1948
+ content: [{ type: "text", text: summaryText.substring(0, 2000) }],
1949
+ status: 'success',
1950
+ action: 'semantic_search',
1951
+ query,
1952
+ mode: 'usearch',
1953
+ total_in_index: totalCount,
1954
+ elapsed_ms: faissResult.elapsed_ms,
1955
+ results: topResults,
1956
+ note: `USearch 语义搜索完成: ${topResults.length} 条, ${faissResult.elapsed_ms}ms`,
1957
+ };
1958
+ } catch (e) {
1959
+ // USearch 失败 → 回退到旧 Worker pool 方式
1960
+ console.warn(`[bridge] USearch 语义搜索失败, 回退到旧模式: ${e.message}`);
1961
+ const { files, rootDir, priority } = args;
1962
+ let fileList = files;
1963
+ if (!fileList || fileList.length === 0) {
1964
+ const { readdir: rd } = await import('fs/promises');
1965
+ const scanRoot = rootDir ? await validatePath(rootDir) : join(homedir(), '.openclaw');
1966
+ const foundFiles = [];
1967
+ async function scanDir(dir, depth = 0) {
1968
+ if (depth > 10 || foundFiles.length >= 200) return;
1969
+ try {
1970
+ const entries = await rd(dir, { withFileTypes: true });
1971
+ for (const e of entries) {
1972
+ if (foundFiles.length >= 200) break;
1973
+ const full = join(dir, e.name);
1974
+ if (e.isDirectory() && !e.name.startsWith('.')) await scanDir(full, depth + 1);
1975
+ else if (e.isFile() && !e.name.startsWith('.')) foundFiles.push(full);
1976
+ }
1977
+ } catch {}
1978
+ }
1979
+ await scanDir(scanRoot);
1980
+ fileList = foundFiles;
1981
+ }
1982
+ if (!fileList || fileList.length === 0) {
1983
+ throw Object.assign(new Error('没有找到可搜索的文件'), { code: -32602 });
1984
+ }
1985
+ const maxRes = Math.min(maxResults || 10, 50);
1986
+ const chunkSize = 50;
1987
+ const chunks = [];
1988
+ for (let i = 0; i < fileList.length; i += chunkSize) {
1989
+ chunks.push(fileList.slice(i, i + chunkSize));
1990
+ }
1991
+ const SEMANTIC_TIMEOUT = 30000;
1992
+ const workerResults = await Promise.race([
1993
+ Promise.allSettled(
1994
+ chunks.map(fc => core.pool.exec({
1995
+ type: 'semantic-search',
1996
+ query,
1997
+ files: fc,
1998
+ maxResults: Math.ceil(maxRes / Math.max(1, chunks.length)) + 10,
1999
+ }, priority || 'high'))
2000
+ ),
2001
+ new Promise((_, reject) =>
2002
+ setTimeout(() => reject(new Error('语义搜索超时(30秒) — Worker池可能繁忙或Ollama无响应')), SEMANTIC_TIMEOUT)
2003
+ ),
2004
+ ]);
2005
+ const allResults = [];
2006
+ for (const r of workerResults) {
2007
+ if (r.status === 'fulfilled' && r.value && r.value.results) {
2008
+ allResults.push(...r.value.results);
2009
+ }
2010
+ }
2011
+ allResults.sort((a, b) => b.score - a.score);
2012
+ const topResults = allResults.slice(0, maxRes);
2013
+ const summaryText = topResults.length > 0
2014
+ ? `语义搜索: "${query}" — 在 ${fileList.length} 个文件中找到 ${allResults.length} 处匹配 [回退模式]\n` +
2015
+ topResults.slice(0, 5).map(r => `📄 ${r.file} (相似度${r.score})`).join('\n')
2016
+ : `语义搜索: "${query}" — 未找到语义匹配 [回退模式]`;
2017
+ result = {
2018
+ content: [{ type: "text", text: summaryText.substring(0, 500) }],
2019
+ status: 'success',
2020
+ action: 'semantic_search',
2021
+ query,
2022
+ mode: 'fallback_worker',
2023
+ totalFiles: fileList.length,
2024
+ totalScored: topResults.filter(r => r.score > 0).length,
2025
+ results: topResults,
2026
+ };
2027
+ }
2028
+ break;
2029
+ }
2030
+ case 'search_dialog': {
2031
+ // 调 handleDialogRecall — 对接海马体对话日记检索 (Worker pool + 语义重排序)
2032
+ const { query, timeRange, context, mode } = args;
2033
+ if (!query) throw Object.assign(new Error('缺少 query'), { code: -32602 });
2034
+ try {
2035
+ const recallResult = await handleDialogRecall({ query, timeRange, context, mode }, core.pool);
2036
+ const topResults = (recallResult.results || []).slice(0, 10);
2037
+ result = {
2038
+ content: [{ type: "text", text:
2039
+ `搜索对话: "${query}" — ${recallResult.totalMatches} 条匹配, ${recallResult.totalFiles} 个文件\n` +
2040
+ (topResults.length > 0
2041
+ ? topResults.map(r => `📄 ${r.file} (${r.matchCount}处, 评分${r.score})`).join('\n')
2042
+ : '无匹配结果')
2043
+ }],
2044
+ status: 'success',
2045
+ action: 'search_dialog',
2046
+ query,
2047
+ mode: recallResult.mode || mode || 'keyword',
2048
+ totalMatches: recallResult.totalMatches,
2049
+ totalFiles: recallResult.totalFiles,
2050
+ searchedFiles: recallResult.searchedFiles,
2051
+ semantic: recallResult.semantic || null,
2052
+ results: topResults,
2053
+ note: `对话日记检索完成: ${recallResult.totalMatches} 条匹配`,
2054
+ };
2055
+ } catch (e) {
2056
+ return { jsonrpc: '2.0', id, error: { code: -32603, message: e.message } };
2057
+ }
2058
+ break;
2059
+ }
2060
+ case 'memory_query': {
2061
+ // 🧠 USearch 语义查询
2062
+ const memQuery = args.query || '';
2063
+ const maxResults = args.maxResults || 10;
2064
+
2065
+ try {
2066
+ const faissResult = await qdrantSearch(memQuery, Math.min(maxResults, 50));
2067
+ const topResults = (faissResult.results || []).slice(0, maxResults);
2068
+ const totalCount = Number(faissResult.total_in_index) || topResults.length || 0;
2069
+
2070
+ let text;
2071
+ if (topResults.length > 0) {
2072
+ text = `🧠 USearch 海马体查询: "${memQuery}" — ${totalCount} 条索引, ${faissResult.elapsed_ms}ms\n` +
2073
+ topResults.map((r, i) => ` ${i+1}. [${r.score}] ${r.source}: ${r.text.substring(0, 120)}...`).join('\n');
2074
+ } else {
2075
+ text = `🧠 USearch 海马体查询: "${memQuery}" — 无匹配 (共 ${totalCount} 条索引)`;
2076
+ }
2077
+
2078
+ result = {
2079
+ content: [{ type: "text", text: text.substring(0, 2000) }],
2080
+ status: 'success',
2081
+ action: 'memory_query',
2082
+ query: memQuery,
2083
+ mode: 'usearch',
2084
+ total_in_index: totalCount,
2085
+ elapsed_ms: faissResult.elapsed_ms,
2086
+ timing: faissResult.timing,
2087
+ results: topResults,
2088
+ note: `USearch 海马体查询完成: ${topResults.length} 条, ${faissResult.elapsed_ms}ms`,
2089
+ };
2090
+ } catch (e) {
2091
+ // USearch 查询失败 → 回退到旧的关键词 JSON 搜索
2092
+ console.warn(`[bridge] USearch 查询失败, 回退到旧模式: ${e.message}`);
2093
+ const hipDir = join(homedir(), '.openclaw', 'workspace', 'memory', 'hippocampus');
2094
+ const hipFiles = ['index.json', 'entities.json', 'timeline.json', 'decisions.json'];
2095
+ const results = [];
2096
+ let totalEntries = 0;
2097
+ for (const fName of hipFiles) {
2098
+ const fPath = join(hipDir, fName);
2099
+ try {
2100
+ const raw = await rf(fPath, 'utf-8');
2101
+ const data = JSON.parse(raw);
2102
+ totalEntries++;
2103
+ const matches = [];
2104
+ const inspect = (obj, path_ = '') => {
2105
+ if (typeof obj === 'string') {
2106
+ if (!memQuery || obj.toLowerCase().includes(memQuery.toLowerCase())) {
2107
+ matches.push({ value: obj, path: path_ });
2108
+ }
2109
+ } else if (Array.isArray(obj)) {
2110
+ obj.forEach((item, i) => inspect(item, `${path_}[${i}]`));
2111
+ } else if (obj && typeof obj === 'object') {
2112
+ for (const [k, v] of Object.entries(obj)) {
2113
+ const subPath = path_ ? `${path_}.${k}` : k;
2114
+ if (!memQuery || k.toLowerCase().includes(memQuery.toLowerCase())) {
2115
+ if (typeof v === 'string') matches.push({ value: v, key: k, path: subPath });
2116
+ }
2117
+ inspect(v, subPath);
2118
+ }
2119
+ }
2120
+ };
2121
+ inspect(data);
2122
+ if (matches.length > 0) {
2123
+ results.push({ file: fName, matches: matches.slice(0, maxResults) });
2124
+ }
2125
+ } catch (e2) {
2126
+ results.push({ file: fName, error: e2.message });
2127
+ }
2128
+ }
2129
+ result = {
2130
+ content: [{ type: "text", text: `海马体记忆索引查询: "${memQuery}" (${results.length}/${hipFiles.length} files with matches) [回退模式]` }],
2131
+ status: 'success',
2132
+ action: 'memory_query',
2133
+ query: memQuery,
2134
+ mode: 'fallback_keyword',
2135
+ hippocampus: { files: hipFiles.length, searched: totalEntries, matchedFiles: results.length, details: results },
2136
+ note: `回退到关键词搜索: ${results.filter(r => r.matches).reduce((a, r) => a + r.matches.length, 0)} 条匹配。`,
2137
+ };
2138
+ }
2139
+ break;
2140
+ }
2141
+ case 'lcm_recall': {
2142
+ // LCM上下文回忆 — lcm_grep/lcm_expand 是 OpenClaw 原生插件工具,不在 Worker pool 中
2143
+ // 返回指引提示,让调用方知道需使用原生工具而非 MCP
2144
+ result = {
2145
+ content: [{ type: "text", text: `LCM回忆需要 OpenClaw 原生插件工具支持,请使用 OpenClaw 原生 lcm_grep/lcm_expand 工具。` }],
2146
+ status: 'info',
2147
+ action: 'lcm_recall',
2148
+ query: args.query,
2149
+ note: '提示: LCM 是 OpenClaw 插件系统能力,无法通过 MCP Worker pool 调用。请使用 OpenClaw 原生工具的 lcm_grep/lcm_expand。',
2150
+ };
2151
+ break;
2152
+ }
2153
+ case 'multi_search': {
2154
+ // 🧠 五路并行记忆搜索
2155
+ const { query, timeRange, limit, enablePathB, enablePathC, enablePathD, enablePathE } = args;
2156
+ if (!query) throw Object.assign(new Error('缺少 query'), { code: -32602 });
2157
+ try {
2158
+ const mResult = await multiPathSearch({
2159
+ query,
2160
+ timeRange: timeRange || 'all',
2161
+ limit: limit || 10,
2162
+ enablePathB: enablePathB !== false,
2163
+ enablePathC: enablePathC !== false,
2164
+ enablePathD: enablePathD !== false,
2165
+ enablePathE: enablePathE !== false,
2166
+ }, core.pool);
2167
+
2168
+ // 构建文本摘要
2169
+ const topResults = (mResult.results || []).slice(0, 10);
2170
+ const pathStats = mResult.pathStats || {};
2171
+ const statLines = Object.entries(pathStats)
2172
+ .filter(([, s]) => s && s.count > 0)
2173
+ .map(([k, s]) => `${k}: ${s.count}条(${s.elapsed}ms)`)
2174
+ .join(', ');
2175
+
2176
+ const summaryText = [
2177
+ `🧠 多路径搜索: "${query}"`,
2178
+ ` 五路统计: ${statLines || '无有效路径'}`,
2179
+ ` 总耗时: ${mResult.totalElapsed || mResult.totalElapsed}ms`,
2180
+ topResults.length > 0
2181
+ ? topResults.map((r, i) => {
2182
+ const tag = r._tag || '📄';
2183
+ const name = r.file || r.summary || r.id || `结果${i + 1}`;
2184
+ const extra = r.matchCount ? ` (${r.matchCount}处)` : '';
2185
+ return ` ${tag} ${name.substring(0, 100)}${extra}`;
2186
+ }).join('\n')
2187
+ : ' 无匹配结果',
2188
+ ].join('\n');
2189
+
2190
+ result = {
2191
+ content: [{ type: 'text', text: summaryText.substring(0, 1000) }],
2192
+ status: 'success',
2193
+ action: 'multi_search',
2194
+ query,
2195
+ pathStats: mResult.pathStats,
2196
+ totalElapsed: mResult.totalElapsed,
2197
+ results: topResults,
2198
+ note: `五路搜索完成: ${topResults.length} 条结果, 总时长 ${mResult.totalElapsed}ms`,
2199
+ };
2200
+ } catch (e) {
2201
+ return { jsonrpc: '2.0', id, error: { code: -32603, message: `多路径搜索失败: ${e.message}` } };
2202
+ }
2203
+ break;
2204
+ }
2205
+ default:
2206
+ throw Object.assign(new Error(`Unknown action: ${reasonAction} (optional: search_dialog/semantic_search/memory_query/lcm_recall/multi_search)`), { code: -32602 });
2207
+ }
2208
+ break;
2209
+ }
2210
+ // old webSearch skeleton removed, v2 Tavily handler used instead
2211
+
2212
+
2213
+
2214
+ // ====== Subagent dispatch ======
2215
+
2216
+ // v5.37.0: worker池版已清理
2217
+
2218
+
2219
+
2220
+ default:
2221
+ throw Object.assign(new Error(`Unknown工具: ${toolName}`), { code: -32601 });
2222
+ }
2223
+
2224
+
2225
+ // ====== ⑨⑩⑪ 执行后回调链(失败不打断工具执行结果)======
2226
+ if (toolName && !HEARTBEAT_TOOLS.has(toolName)) {
2227
+ const _duration = Date.now() - (_execStartTime || Date.now());
2228
+
2229
+ // ⑨ 熔断器:记录成功(按session隔离)
2230
+ try { recordToolSuccess(toolName, sessionId); } catch (e) {
2231
+ console.warn('[sc MCP] ⚠️ circuit breaker record failed:', e.message);
2232
+ }
2233
+
2234
+
2235
+
2236
+ // ⑫ 海马体工具调用记录(全量记录,含心跳类)
2237
+ try {
2238
+ hippoRecordEvent({
2239
+ toolName,
2240
+ params: args,
2241
+ result,
2242
+ duration: _duration,
2243
+ status: 'success',
2244
+ sessionId: sessionId || '',
2245
+ }).catch(err => console.warn('[海马体] ⚠️ 异步记录失败:', err?.message || err));
2246
+ } catch (e) {
2247
+ console.warn('[sc MCP] ⚠️ hippocampus record failed:', e.message);
2248
+ }
2249
+ }
2250
+
2251
+ return {
2252
+ jsonrpc: '2.0', id,
2253
+ result: {
2254
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
2255
+ },
2256
+ };
2257
+ } catch (err) {
2258
+
2259
+ // ====== ⑨⑩ 执行失败后回调链(失败不打断错误返回)======
2260
+ if (toolName && !HEARTBEAT_TOOLS.has(toolName)) {
2261
+ const _duration = Date.now() - (_execStartTime || Date.now());
2262
+
2263
+ // ⑨ 熔断器:记录失败(按session隔离)
2264
+ try { recordToolFailure(toolName, err?.message || '', sessionId); } catch (e) {
2265
+ console.warn('[sc MCP] ⚠️ circuit breaker record failed:', e.message);
2266
+ }
2267
+
2268
+
2269
+
2270
+ // ⑪ 海马体工具调用记录(失败路径)
2271
+ try {
2272
+ hippoRecordEvent({
2273
+ toolName,
2274
+ params: args,
2275
+ result: { error: err?.message || '' },
2276
+ duration: _duration,
2277
+ status: 'error',
2278
+ sessionId: sessionId || '',
2279
+ }).catch(e => console.warn('[海马体] ⚠️ 异步记录失败:', e?.message || e));
2280
+ } catch (e) {
2281
+ console.warn('[sc MCP] ⚠️ hippocampus record failed:', e.message);
2282
+ }
2283
+ }
2284
+
2285
+ return {
2286
+ jsonrpc: '2.0', id,
2287
+ error: { code: err.code || -32603, message: err.message || 'Internal error' },
2288
+ };
2289
+ }
2290
+ }
2291
+
2292
+ case 'notifications/initialized': {
2293
+ return null;
2294
+ }
2295
+
2296
+ default:
2297
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } };
2298
+ }
2299
+ }
2300
+
2301
+ // ====== 向指定 SSE session 发送消息(串行化写操作,防并发交错)======
2302
+ function sendToSession(sessionId, data) {
2303
+ const msg = `data: ${JSON.stringify(data)}\n\n`;
2304
+ if (sessionId && sseClients.has(sessionId)) {
2305
+ safeSseWrite(sessionId, msg);
2306
+ } else {
2307
+ for (const [id] of sseClients) {
2308
+ safeSseWrite(id, msg);
2309
+ }
2310
+ }
2311
+ }
2312
+
2313
+ async function pollScInboxForNotifications() {
2314
+ if (!sseClients || sseClients.size === 0) return;
2315
+ try {
2316
+ const deliveryMode = getScInboxDeliveryMode();
2317
+ scInboxState.deliveryMode = deliveryMode;
2318
+ scInboxState.notifyAck = shouldAckScInboxNotify(deliveryMode);
2319
+ const limit = Number(process.env.SC_INBOX_NOTIFY_LIMIT || 20);
2320
+ const pending = await sidecarJson(`/inbox/pending?limit=${encodeURIComponent(limit)}&undeliveredOnly=true`);
2321
+ const events = pending.events || [];
2322
+ if (!events.length) return;
2323
+ const eventIds = events.map(e => e.id);
2324
+ scInboxState.lastEventCount = events.length;
2325
+ const report = compactScInboxReport(events);
2326
+ sendToSession(null, {
2327
+ jsonrpc: '2.0',
2328
+ method: 'notifications/message',
2329
+ params: {
2330
+ level: 'info',
2331
+ logger: 'sc.inbox',
2332
+ data: {
2333
+ type: 'sc.completion.batch',
2334
+ text: report.text,
2335
+ eventIds,
2336
+ events,
2337
+ stats: pending.stats,
2338
+ },
2339
+ },
2340
+ });
2341
+ await sidecarJson('/inbox/delivered', {
2342
+ method: 'POST',
2343
+ body: JSON.stringify({ eventIds, deliveredBy: 'bridge-sse-notification' }),
2344
+ });
2345
+ scInboxState.lastNotifyOkAt = new Date().toISOString();
2346
+ if (shouldAckScInboxNotify(deliveryMode)) {
2347
+ await sidecarJson('/inbox/ack', {
2348
+ method: 'POST',
2349
+ body: JSON.stringify({ eventIds, ackedBy: 'bridge-sse-notification' }),
2350
+ });
2351
+ scInboxState.lastAckAt = scInboxState.lastNotifyOkAt;
2352
+ }
2353
+ scInboxState.lastNonOk = null;
2354
+ scInboxState.lastError = null;
2355
+ } catch (e) {
2356
+ scInboxState.lastError = String(e?.stack || e?.message || e).slice(0, 2000);
2357
+ // Keep polling best-effort; scInbox tool can still drain pending events.
2358
+ }
2359
+ }
2360
+
2361
+ async function pollScInboxForChatInject() {
2362
+ const deliveryMode = getScInboxDeliveryMode();
2363
+ scInboxState.deliveryMode = deliveryMode;
2364
+ if (!shouldInjectScInboxChat(deliveryMode)) return;
2365
+ try {
2366
+ const limit = Math.min(Math.max(Number(process.env.SC_INBOX_CHAT_INJECT_LIMIT || 10), 1), 20);
2367
+ const pending = await sidecarJson(`/inbox/pending?limit=${encodeURIComponent(limit)}`);
2368
+ const events = pending.events || [];
2369
+ if (!events.length) return;
2370
+ scInboxState.lastChatInjectAttemptAt = new Date().toISOString();
2371
+ scInboxState.lastEventCount = events.length;
2372
+ const report = compactScInboxReport(events);
2373
+ const injected = await injectOpenClawChatMessage({
2374
+ sessionKey: process.env.SC_INBOX_CHAT_INJECT_SESSION || 'agent:main:main',
2375
+ label: process.env.SC_INBOX_CHAT_INJECT_LABEL || 'SC子Agent完成',
2376
+ message: clampText(report.text, Number(process.env.SC_INBOX_CHAT_INJECT_MAX_CHARS || 2500)),
2377
+ });
2378
+ if (!injected?.ok) {
2379
+ scInboxState.lastNonOk = JSON.stringify(injected).slice(0, 1000);
2380
+ console.warn('[sc inbox] chat.inject returned non-ok:', JSON.stringify(injected).slice(0, 1000));
2381
+ return;
2382
+ }
2383
+ await sidecarJson('/inbox/ack', {
2384
+ method: 'POST',
2385
+ body: JSON.stringify({ eventIds: events.map(e => e.id), ackedBy: 'bridge-chat-inject' }),
2386
+ });
2387
+ scInboxState.lastChatInjectOkAt = new Date().toISOString();
2388
+ scInboxState.lastAckAt = scInboxState.lastChatInjectOkAt;
2389
+ scInboxState.lastNonOk = null;
2390
+ scInboxState.lastError = null;
2391
+ } catch (e) {
2392
+ scInboxState.lastError = String(e?.stack || e?.message || e).slice(0, 2000);
2393
+ console.warn('[sc inbox] chat.inject consumer failed:', e?.stack || e?.message || e);
2394
+ // Keep polling best-effort; unacked events remain in the sidecar inbox.
2395
+ }
2396
+ }
2397
+
2398
+ async function pollScInbox() {
2399
+ if (scInboxPollRunning) return;
2400
+ scInboxPollRunning = true;
2401
+ try {
2402
+ scInboxState.lastPollAt = new Date().toISOString();
2403
+ if (process.env.SC_INBOX_AUTO_NOTIFY !== '0') {
2404
+ await pollScInboxForNotifications();
2405
+ }
2406
+ await pollScInboxForChatInject();
2407
+ } finally {
2408
+ scInboxPollRunning = false;
2409
+ }
2410
+ }
2411
+
2412
+ const scInboxDeliveryMode = getScInboxDeliveryMode();
2413
+ const scInboxAutoNotify = process.env.SC_INBOX_AUTO_NOTIFY !== '0' && scInboxDeliveryMode !== 'off';
2414
+ const scInboxChatInject = shouldInjectScInboxChat(scInboxDeliveryMode);
2415
+ if (scInboxAutoNotify || scInboxChatInject) {
2416
+ const pollMs = Math.max(Number(process.env.SC_INBOX_POLL_MS || 5000), 1000);
2417
+ scInboxState.enabled = true;
2418
+ scInboxState.autoNotify = scInboxAutoNotify;
2419
+ scInboxState.notifyAck = shouldAckScInboxNotify(scInboxDeliveryMode);
2420
+ scInboxState.chatInject = scInboxChatInject;
2421
+ scInboxState.deliveryMode = scInboxDeliveryMode;
2422
+ scInboxState.pollMs = pollMs;
2423
+ scInboxTimer = setInterval(pollScInbox, pollMs);
2424
+ if (typeof scInboxTimer.unref === 'function') scInboxTimer.unref();
2425
+ console.log('[sc inbox] consumer enabled:', JSON.stringify({
2426
+ pollMs,
2427
+ autoNotify: scInboxState.autoNotify,
2428
+ notifyAck: scInboxState.notifyAck,
2429
+ chatInject: scInboxState.chatInject,
2430
+ deliveryMode: scInboxState.deliveryMode,
2431
+ chatInjectSession: process.env.SC_INBOX_CHAT_INJECT_SESSION || 'agent:main:main',
2432
+ }));
2433
+ }
2434
+ // ====== HTTP Server ======
2435
+ server = http.createServer(async (req, res) => {
2436
+ res.setHeader('Access-Control-Allow-Origin', '*');
2437
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
2438
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
2439
+
2440
+ if (req.method === 'OPTIONS') {
2441
+ res.writeHead(204);
2442
+ res.end();
2443
+ return;
2444
+ }
2445
+
2446
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
2447
+
2448
+ // ====== 未匹配路由返回404,避免Gateway bundle-mcp超时报错 ======
2449
+ const knownMethods = ['GET', 'POST'];
2450
+ const knownPaths = ['/sse', '/messages', '/health', '/notify-done'];
2451
+ if (!knownMethods.includes(req.method) || !knownPaths.includes(url.pathname)) {
2452
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2453
+ res.end(JSON.stringify({ error: 'unknown path. known: GET /sse, POST /messages, GET /health, POST /notify-done' }));
2454
+ return;
2455
+ }
2456
+
2457
+ if (req.method === 'GET' && url.pathname === '/sse') {
2458
+ const sessionId = generateSessionId();
2459
+ res.writeHead(200, {
2460
+ 'Content-Type': 'text/event-stream',
2461
+ 'Cache-Control': 'no-cache',
2462
+ Connection: 'keep-alive',
2463
+ });
2464
+ res.write(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`);
2465
+ sseClients.set(sessionId, res);
2466
+ req.on('close', () => sseClients.delete(sessionId));
2467
+ const hbTimer = setInterval(() => {
2468
+ try { res.write(':ping\n\n'); } catch { clearInterval(hbTimer); }
2469
+ }, 30000);
2470
+ req.on('close', () => clearInterval(hbTimer));
2471
+ return;
2472
+ }
2473
+
2474
+ if (req.method === 'POST' && url.pathname === '/messages') {
2475
+ const sessionId = url.searchParams.get('sessionId');
2476
+ let body = '';
2477
+ let bodySize = 0;
2478
+ const MAX_BODY_SIZE = 1024 * 1024;
2479
+ req.on('data', chunk => {
2480
+ bodySize += chunk.length;
2481
+ if (bodySize > MAX_BODY_SIZE) {
2482
+ req.destroy();
2483
+ if (!res.headersSent) {
2484
+ res.writeHead(413, { 'Content-Type': 'application/json' });
2485
+ res.end(JSON.stringify({ error: 'Request body too large (max 1MB)' }));
2486
+ }
2487
+ }
2488
+ body += chunk;
2489
+ });
2490
+ req.on('end', async () => {
2491
+ // ====== MCP 并发槽位获取(防 >5 并发撑爆 Worker 池)======
2492
+ const hasSlot = await acquireMcpSlot();
2493
+ if (!hasSlot) {
2494
+ res.writeHead(503, { 'Content-Type': 'application/json' });
2495
+ res.end(JSON.stringify({ error: 'MCP 请求队列满(超过 ' + MCP_MAX_CONCURRENT + ' 个并发),请稍后重试' }));
2496
+ return;
2497
+ }
2498
+ try {
2499
+ const parsed = JSON.parse(body);
2500
+
2501
+ // ====== Worker 池背压检测:队列太深时阻止新任务入池 ======
2502
+ try {
2503
+ const core = await getCore();
2504
+ const stats = typeof cpu.getStats === 'function' ? core.getStats() : {};
2505
+ const queueDepth = stats.queueDepth || 0;
2506
+ const busyWorkers = stats.busy || 0;
2507
+ const totalWorkers = stats.total || 1;
2508
+ const utilRate = totalWorkers > 0 ? busyWorkers / totalWorkers : 0;
2509
+
2510
+ // 背压策略:队列深>10 且 利用率>80% → 返回 429 让客户端重试
2511
+ if (queueDepth > 10 && utilRate > 0.8) {
2512
+ releaseMcpSlot();
2513
+ const errRpc = { jsonrpc: '2.0', id: body?.id || null, error: { code: -32000, message: 'Worker 池过载(队列' + queueDepth + '个/利用率' + Math.round(utilRate*100) + '%),请稍后重试' } };
2514
+ sendToSession(sessionId, errRpc);
2515
+ res.writeHead(429, { 'Content-Type': 'application/json' });
2516
+ res.end(JSON.stringify({ error: 'too many requests', retryAfter: 5 }));
2517
+ return;
2518
+ }
2519
+ } catch {}
2520
+
2521
+ const response = await handleJsonRpc(parsed, sessionId);
2522
+ if (response) {
2523
+ sendToSession(sessionId, response);
2524
+ }
2525
+ res.writeHead(202, { 'Content-Type': 'application/json' });
2526
+ res.end(JSON.stringify({ accepted: true }));
2527
+ } catch (err) {
2528
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2529
+ res.end(JSON.stringify({ error: err.message }));
2530
+ } finally {
2531
+ releaseMcpSlot();
2532
+ }
2533
+ });
2534
+
2535
+ return;
2536
+ }
2537
+
2538
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/health' || url.pathname === '/api/stats')) {
2539
+ const core = await getCore();
2540
+ // 🐛 FIX: 直接调 pool.getStats() 绕过缓存,确保仪表盘每次刷新都拿到最新数据
2541
+ const stats = (core.pool && typeof core.pool.getStats === 'function')
2542
+ ? core.pool.getStats()
2543
+ : core.getStats();
2544
+
2545
+ // 内存数据
2546
+ const freeGB = freemem() / 1024 / 1024 / 1024;
2547
+ const totalGB = totalmem() / 1024 / 1024 / 1024;
2548
+ const usedPct = totalGB > 0 ? Math.round((1 - freeGB / totalGB) * 100) : 0;
2549
+ let level = 'green';
2550
+ let label = '充足';
2551
+ if (freeGB < 2) { level = 'meltdown'; label = '熔断'; }
2552
+ else if (freeGB < 4) { level = 'red'; label = '告警'; }
2553
+ else if (freeGB < 8) { level = 'yellow'; label = '紧张'; }
2554
+
2555
+ // 用户活跃度:检查对话日记最后修改时间
2556
+ const homedirPath = homedir();
2557
+ const dialogDir = join(homedirPath, '.openclaw', 'workspace', 'memory', 'dialog');
2558
+ const today = new Date();
2559
+ const yyyy = today.getFullYear();
2560
+ const mm = String(today.getMonth() + 1).padStart(2, '0');
2561
+ const dd = String(today.getDate()).padStart(2, '0');
2562
+ const dialogPath = join(dialogDir, `${yyyy}-${mm}-${dd}.md`);
2563
+ let userActive = true;
2564
+ try {
2565
+ if (existsSync(dialogPath)) {
2566
+ const mtime = statSync(dialogPath).mtimeMs;
2567
+ const elapsed = Date.now() - mtime;
2568
+ userActive = elapsed < 20 * 60 * 1000; // 20分钟
2569
+ } else {
2570
+ userActive = false;
2571
+ }
2572
+ } catch { /* 保守返回活跃 */ }
2573
+
2574
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2575
+ res.end(JSON.stringify({
2576
+ status: 'ok',
2577
+ version: '5.37.0',
2578
+ pool: stats,
2579
+ memory: {
2580
+ freeGB: Math.round(freeGB * 100) / 100,
2581
+ usedPct,
2582
+ totalGB: Math.round(totalGB * 100) / 100,
2583
+ level,
2584
+ label,
2585
+ },
2586
+ userActive,
2587
+ inboxConsumer: scInboxState,
2588
+ }));
2589
+ return;
2590
+ }
2591
+
2592
+ // ====== B方案: 子agent完成回调通知端点 ======
2593
+ if (req.method === 'POST' && url.pathname === '/notify-done') {
2594
+ let body = '';
2595
+ req.on('data', chunk => body += chunk);
2596
+ req.on('end', async () => {
2597
+ try {
2598
+ const { taskId, status } = JSON.parse(body);
2599
+ const notifyDir = join(homedir(), '.openclaw', 'workspace', 'memory', 'dialog', 'subagent');
2600
+ const notifyFile = join(notifyDir, `NOTIFY_${taskId}_${status}.json`);
2601
+ if (!existsSync(notifyDir)) mkdirSync(notifyDir, { recursive: true });
2602
+ writeFileSync(notifyFile, JSON.stringify({ taskId, status, receivedAt: new Date().toISOString() }));
2603
+ } catch (e) { /* 静默 */ }
2604
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2605
+ res.end(JSON.stringify({ ok: true }));
2606
+ });
2607
+ return;
2608
+ }
2609
+
2610
+ // ====== 主脑暂停/恢复 REST 端点(供仪表盘调用)======
2611
+ if (req.method === 'GET' && url.pathname === '/pause') {
2612
+ try {
2613
+ const coreMod = await getCore();
2614
+ if (typeof coreMod.setMainBrainPaused === 'function') coreMod.setMainBrainPaused(true);
2615
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2616
+ res.end(JSON.stringify({ status: 'ok', paused: true, message: '主脑已暂停' }));
2617
+ } catch (e) {
2618
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2619
+ res.end(JSON.stringify({ status: 'error', message: e.message }));
2620
+ }
2621
+ return;
2622
+ }
2623
+
2624
+ if (req.method === 'GET' && url.pathname === '/resume') {
2625
+ try {
2626
+ const coreMod = await getCore();
2627
+ if (typeof coreMod.setMainBrainPaused === 'function') coreMod.setMainBrainPaused(false);
2628
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2629
+ res.end(JSON.stringify({ status: 'ok', paused: false, message: '主脑已恢复' }));
2630
+ } catch (e) {
2631
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2632
+ res.end(JSON.stringify({ status: 'error', message: e.message }));
2633
+ }
2634
+ return;
2635
+ }
2636
+
2637
+ if (req.method === 'GET' && url.pathname === '/pause-status') {
2638
+ try {
2639
+ const coreMod = await getCore();
2640
+ const paused = typeof coreMod.isMainBrainPaused === 'function' ? coreMod.isMainBrainPaused() : false;
2641
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2642
+ res.end(JSON.stringify({ status: 'ok', paused, message: paused ? '主脑暂停中' : '主脑正常运行' }));
2643
+ } catch (e) {
2644
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2645
+ res.end(JSON.stringify({ status: 'error', message: e.message }));
2646
+ }
2647
+ return;
2648
+ }
2649
+
2650
+ res.writeHead(404);
2651
+ res.end('Not Found');
2652
+ });
2653
+
2654
+ server.on('error', (err) => {
2655
+ if (err.code === 'EADDRINUSE') {
2656
+ console.error(`[sc MCP] 端口 ${port} 已被占用,跳过 MCP Server 启动`);
2657
+ // 触发 listenReject,让调用方知道启动失败
2658
+ if (listenReject) listenReject(err);
2659
+ return;
2660
+ }
2661
+ console.error(`[sc MCP] Server 错误:`, err.message);
2662
+ if (listenReject) listenReject(err);
2663
+ });
2664
+
2665
+ // 将 server.listen 包装为 Promise,确保调用方能捕获 EADDRINUSE
2666
+ let listenResolve, listenReject;
2667
+ const listenPromise = new Promise((resolve, reject) => {
2668
+ listenResolve = resolve;
2669
+ listenReject = reject;
2670
+ });
2671
+
2672
+ server.listen(port, '127.0.0.1', () => {
2673
+ // TODO: 移除调试日志 console.log(`[sc MCP] ✅ Server 启动: http://127.0.0.1:${port}/sse`);
2674
+ // TODO: 移除调试日志 console.log(`[sc MCP] Health: http://127.0.0.1:${port}/health`);
2675
+ // TODO: 移除调试日志 console.log(`[sc MCP] Tools: ${tools.map(t => t.name).join(', ')}`);
2676
+ listenResolve();
2677
+ });
2678
+
2679
+ // 等待 listen 确认,若 EADDRINUSE 则 reject
2680
+ await listenPromise;
2681
+
2682
+ return { server, shutdown, port };
2683
+ } catch (err) {
2684
+ // EADDRINUSE 已在 server.on('error') 打过消息,这里不重复
2685
+ if (err?.code !== 'EADDRINUSE') {
2686
+ console.error(`[sc MCP] Server 启动失败:`, err.message);
2687
+ }
2688
+ shutdown();
2689
+ throw err;
2690
+ }
2691
+ }
2692
+
2693
+ // ====== 导出供插件 import ======
2694
+ export {
2695
+ startMcpServer,
2696
+ normalizeScTaskEnvelope,
2697
+ scGuardJsonRpcError,
2698
+ validateScPipelineTask,
2699
+ validateScSpawnTask,
2700
+ };
2701
+
2702
+ export async function search(keyword, files, priority) {
2703
+ // 🚀 L0 快车路径(共享模块)
2704
+ const fastResult = await fastPathSearch(keyword, files, validatePath);
2705
+ if (fastResult) return fastResult;
2706
+ const core = await getCore();
2707
+ const poolStats = core.getStats();
2708
+ const tasks = splitFiles(files, poolStats.maxWorkers || 4);
2709
+ const results = await Promise.all(
2710
+ tasks.map(fc => core.pool.exec({ type: 'search-text', keyword, files: fc }, priority))
2711
+ );
2712
+ return mergeSearchResults(results, poolStats);
2713
+ }
2714
+
2715
+ export async function processLog(files, priority) {
2716
+ const core = await getCore();
2717
+ const poolStats = core.getStats();
2718
+ const tasks = splitFiles(files, poolStats.maxWorkers || 4);
2719
+ const results = await Promise.all(
2720
+ tasks.map(fc => core.pool.exec({ type: 'process-log', files: fc }, priority))
2721
+ );
2722
+ return mergeLogResults(results, poolStats);
2723
+ }
2724
+
2725
+ export const getStats = async () => (await getCore()).getStats();
2726
+ export const resolveModel = async (provider, modelId) => (await getCore()).pool.exec({ type: 'resolve-model', provider, modelId });
2727
+
2728
+ function handleJsonTool({ action, input, indent }) {
2729
+ const _indent = indent !== undefined ? indent : 2;
2730
+ const validActions = ['format', 'validate', 'convert', 'csv2json', 'json2csv'];
2731
+ if (!validActions.includes(action)) {
2732
+ throw new Error(`不支持的 action: ${action},支持: ${validActions.join(', ')}`);
2733
+ }
2734
+ if (input === undefined || input === null) {
2735
+ throw new Error('Missing input');
2736
+ }
2737
+ switch (action) {
2738
+ case 'format': {
2739
+ const parsed = JSON.parse(input);
2740
+ return {
2741
+ status: 'success',
2742
+ action: 'format',
2743
+ output: JSON.stringify(parsed, null, _indent),
2744
+ size: { chars: JSON.stringify(parsed, null, _indent).length },
2745
+ };
2746
+ }
2747
+ case 'validate': {
2748
+ try {
2749
+ const parsed = JSON.parse(input);
2750
+ return {
2751
+ status: 'success',
2752
+ action: 'validate',
2753
+ valid: true,
2754
+ type: Array.isArray(parsed) ? 'array' : typeof parsed,
2755
+ keys: typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? Object.keys(parsed) : undefined,
2756
+ size: { chars: input.length, items: Array.isArray(parsed) ? parsed.length : undefined },
2757
+ };
2758
+ } catch (e) {
2759
+ return {
2760
+ status: 'success',
2761
+ action: 'validate',
2762
+ valid: false,
2763
+ error: e.message,
2764
+ };
2765
+ }
2766
+ }
2767
+ case 'convert': {
2768
+ const parsed = JSON.parse(input);
2769
+ return {
2770
+ status: 'success',
2771
+ action: 'convert',
2772
+ output: JSON.stringify(parsed, null, _indent),
2773
+ note: 'JSON 已重新序列化(压缩/规范化)',
2774
+ };
2775
+ }
2776
+ case 'csv2json': {
2777
+ const lines = input.split(/\r?\n/).filter(l => l.trim());
2778
+ if (lines.length < 2) {
2779
+ throw new Error('CSV 至少需要表头行+1行数据');
2780
+ }
2781
+ const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
2782
+ const jsonArray = [];
2783
+ for (let i = 1; i < lines.length; i++) {
2784
+ const values = [];
2785
+ let current = '';
2786
+ let inQuotes = false;
2787
+ for (const ch of lines[i]) {
2788
+ if (ch === '"') { inQuotes = !inQuotes; continue; }
2789
+ if (ch === ',' && !inQuotes) { values.push(current.trim()); current = ''; continue; }
2790
+ current += ch;
2791
+ }
2792
+ values.push(current.trim());
2793
+ const row = {};
2794
+ for (let j = 0; j < headers.length; j++) {
2795
+ const val = values[j] || '';
2796
+ const num = Number(val);
2797
+ row[headers[j]] = (!isNaN(num) && val.trim() !== '') ? num : val;
2798
+ }
2799
+ jsonArray.push(row);
2800
+ }
2801
+ return {
2802
+ status: 'success',
2803
+ action: 'csv2json',
2804
+ rows: jsonArray.length,
2805
+ columns: headers,
2806
+ output: JSON.stringify(jsonArray, null, _indent),
2807
+ };
2808
+ }
2809
+ case 'json2csv': {
2810
+ const parsed = JSON.parse(input);
2811
+ if (!Array.isArray(parsed) || parsed.length === 0) {
2812
+ throw new Error('input 应为非空 JSON 数组');
2813
+ }
2814
+ const headers = [...new Set(parsed.flatMap(obj => Object.keys(obj || {})))];
2815
+ let csv = headers.join(',') + '\n';
2816
+ for (const row of parsed) {
2817
+ const values = headers.map(h => {
2818
+ const val = row[h];
2819
+ if (val === null || val === undefined) return '';
2820
+ const str = String(val);
2821
+ return str.includes(',') || str.includes('"') || str.includes('\n')
2822
+ ? '"' + str.replace(/"/g, '""') + '"'
2823
+ : str;
2824
+ });
2825
+ csv += values.join(',') + '\n';
2826
+ }
2827
+ return {
2828
+ status: 'success',
2829
+ action: 'json2csv',
2830
+ rows: parsed.length,
2831
+ columns: headers,
2832
+ output: csv,
2833
+ };
2834
+ }
2835
+ }
2836
+ }
2837
+
2838
+ export default { startMcpServer, search, processLog, getStats, resolveModel, validateScSpawnTask, validateScPipelineTask };
2839
+
2840
+ // ====== CLI entry(直接运行时)=====
2841
+ const isMain = process.argv[1] && (
2842
+ fileURLToPath(import.meta.url) === process.argv[1] ||
2843
+ fileURLToPath(import.meta.url).replace(/\\/g, '/') === process.argv[1].replace(/\\/g, '/')
2844
+ );
2845
+
2846
+ if (isMain) {
2847
+ main().catch(err => {
2848
+ console.error('[sc Bridge] 致命错误:', err.message);
2849
+ process.exit(1);
2850
+ });
2851
+ }
2852
+
2853
+
2854
+
2855
+
2856
+
2857
+
2858
+
2859
+
2860
+
2861
+
2862
+
2863
+
2864
+
2865
+ async function sidecarJson(pathname, options = {}) {
2866
+ const resp = await fetch(`http://127.0.0.1:18792${pathname}`, {
2867
+ ...options,
2868
+ headers: {
2869
+ 'Content-Type': 'application/json',
2870
+ ...(options.headers || {}),
2871
+ },
2872
+ });
2873
+ const text = await resp.text();
2874
+ let data = {};
2875
+ try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
2876
+ if (!resp.ok) {
2877
+ throw new Error(data.error || `sidecar HTTP ${resp.status}`);
2878
+ }
2879
+ return data;
2880
+ }
2881
+
2882
+ function clampText(value, maxChars = 2500) {
2883
+ const text = String(value || '');
2884
+ const max = Math.max(Number(maxChars) || 2500, 200);
2885
+ if (text.length <= max) return text;
2886
+ return `${text.slice(0, max - 32)}\n\n[truncated by SC bridge]`;
2887
+ }
2888
+
2889
+ function cleanScInboxSummary(event = {}) {
2890
+ const raw = String(event.summary || event.error || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
2891
+ if (!raw) return '';
2892
+ const breaker = raw.match(/工具调用失败次数过多\([^)]*\),?熔断终止。?([^]*?)(?:\s+node:internal|\s+DOMException|\s+Node\.js\b|$)/);
2893
+ if (breaker) return clampText(`工具调用失败次数过多,已熔断终止。${breaker[1].trim()}`, 500);
2894
+ if (/The operation was aborted due to timeout/i.test(raw)) {
2895
+ const toolMatch = raw.match(/工具=([^,\s]+).*?action=([^,\s]+).*?最后错误=The operation was aborted due to timeout/);
2896
+ if (toolMatch) return `工具调用超时,已失败。工具=${toolMatch[1]},action=${toolMatch[2]},最后错误=timeout。`;
2897
+ return '工具调用超时,子 Agent 已失败;可缩小任务范围或更换检索方式重试。';
2898
+ }
2899
+ const withoutStack = raw
2900
+ .replace(/\[subagent\]\s*(?:创建新MCP session|session复用|调工具)[^[]*/g, '')
2901
+ .replace(/\[HB\][^[]*/g, '')
2902
+ .replace(/\s*node:internal\/process\/promises:[\s\S]*$/i, '')
2903
+ .replace(/\s*DOMException \[TimeoutError\]:[\s\S]*$/i, '')
2904
+ .replace(/\s*Node\.js v[\d.]+.*$/i, '')
2905
+ .replace(/\s+/g, ' ')
2906
+ .trim();
2907
+ return clampText(withoutStack || raw, 500);
2908
+ }
2909
+
2910
+ let openClawGatewayRuntimePromise = null;
2911
+
2912
+ function openClawGatewayRuntimeCandidates() {
2913
+ const candidates = [];
2914
+ if (process.env.OPENCLAW_GATEWAY_RUNTIME_MODULE) {
2915
+ candidates.push(process.env.OPENCLAW_GATEWAY_RUNTIME_MODULE);
2916
+ }
2917
+ if (process.env.APPDATA) {
2918
+ candidates.push(join(process.env.APPDATA, 'npm', 'node_modules', 'openclaw', 'dist', 'plugin-sdk', 'gateway-runtime.js'));
2919
+ }
2920
+ candidates.push(join(homedir(), 'AppData', 'Roaming', 'npm', 'node_modules', 'openclaw', 'dist', 'plugin-sdk', 'gateway-runtime.js'));
2921
+ candidates.push('openclaw/dist/plugin-sdk/gateway-runtime.js');
2922
+ return [...new Set(candidates.filter(Boolean))];
2923
+ }
2924
+
2925
+ function toImportSpecifier(candidate) {
2926
+ if (/^[a-z]+:\/\//i.test(candidate)) return candidate;
2927
+ if (/^[a-zA-Z]:[\\/]/.test(candidate) || candidate.startsWith('/') || candidate.startsWith('\\\\')) {
2928
+ return pathToFileURL(candidate).href;
2929
+ }
2930
+ return candidate;
2931
+ }
2932
+
2933
+ async function loadOpenClawGatewayRuntime() {
2934
+ if (openClawGatewayRuntimePromise) return openClawGatewayRuntimePromise;
2935
+ openClawGatewayRuntimePromise = (async () => {
2936
+ const errors = [];
2937
+ for (const candidate of openClawGatewayRuntimeCandidates()) {
2938
+ try {
2939
+ if ((/^[a-zA-Z]:[\\/]/.test(candidate) || candidate.startsWith('/') || candidate.startsWith('\\\\')) && !existsSync(candidate)) {
2940
+ continue;
2941
+ }
2942
+ const mod = await import(toImportSpecifier(candidate));
2943
+ if (typeof mod.callGatewayFromCli === 'function') return mod;
2944
+ } catch (e) {
2945
+ errors.push(`${candidate}: ${e.message}`);
2946
+ }
2947
+ }
2948
+ throw new Error(`OpenClaw gateway runtime unavailable: ${errors.join('; ')}`);
2949
+ })();
2950
+ return openClawGatewayRuntimePromise;
2951
+ }
2952
+
2953
+ async function injectOpenClawChatMessage({ sessionKey, label, message }) {
2954
+ const runtime = await loadOpenClawGatewayRuntime();
2955
+ return await runtime.callGatewayFromCli('chat.inject', {
2956
+ timeout: String(process.env.SC_INBOX_CHAT_INJECT_TIMEOUT_MS || 20000),
2957
+ json: true,
2958
+ }, {
2959
+ sessionKey,
2960
+ message,
2961
+ label,
2962
+ }, { expectFinal: false });
2963
+ }
2964
+
2965
+ function compactScInboxReport(events = []) {
2966
+ if (!events.length) {
2967
+ return {
2968
+ text: 'SC inbox 暂无未确认完成事件。',
2969
+ total: 0,
2970
+ byStatus: {},
2971
+ events: [],
2972
+ };
2973
+ }
2974
+ const byStatus = {};
2975
+ for (const event of events) {
2976
+ byStatus[event.status] = (byStatus[event.status] || 0) + 1;
2977
+ }
2978
+ const lines = [
2979
+ `SC 子Agent完成事件 ${events.length} 条:` +
2980
+ Object.entries(byStatus).map(([status, count]) => `${status}=${count}`).join(', '),
2981
+ ];
2982
+ for (const event of events.slice(0, 20)) {
2983
+ const label = event.taskName || event.groupName || event.taskId;
2984
+ const artifact = event.artifactPath ? ` artifact=${event.artifactPath}` : '';
2985
+ const summary = cleanScInboxSummary(event);
2986
+ lines.push(`- ${event.status} ${label} (${event.taskId}) ${summary}${artifact}`.slice(0, 700));
2987
+ }
2988
+ return {
2989
+ text: lines.join('\n'),
2990
+ total: events.length,
2991
+ byStatus,
2992
+ events,
2993
+ };
2994
+ }
2995
+
2996
+ async function scInbox(args = {}) {
2997
+ const action = args.action || 'pending';
2998
+ const limit = Math.min(Math.max(Number(args.limit) || 20, 1), 200);
2999
+ const includeAcked = args.includeAcked === true || action === 'recent';
3000
+
3001
+ if (action === 'stats') {
3002
+ return await sidecarJson('/inbox/stats');
3003
+ }
3004
+
3005
+ if (action === 'pending') {
3006
+ const pending = await sidecarJson(`/inbox/pending?limit=${encodeURIComponent(limit)}${includeAcked ? '&includeAcked=true' : ''}`);
3007
+ if (!includeAcked && !pending.events?.length && pending.stats?.acked > 0) {
3008
+ const recent = await sidecarJson(`/inbox/pending?limit=${encodeURIComponent(Math.min(limit, 5))}&includeAcked=true`);
3009
+ return {
3010
+ ...pending,
3011
+ note: 'pending 只返回未 ack 事件;chat-inject 成功后事件会被 ack。recentAcked 仅用于判断最近是否已送达 inbox。',
3012
+ recentAcked: (recent.events || []).map(event => ({ ...event, summary: cleanScInboxSummary(event) })),
3013
+ };
3014
+ }
3015
+ return pending;
3016
+ }
3017
+
3018
+ if (action === 'report' || action === 'recent') {
3019
+ const pending = await sidecarJson(`/inbox/pending?limit=${encodeURIComponent(limit)}${includeAcked ? '&includeAcked=true' : ''}`);
3020
+ const report = compactScInboxReport(pending.events || []);
3021
+ if (args.ack === true && pending.events?.length) {
3022
+ const acked = await sidecarJson('/inbox/ack', {
3023
+ method: 'POST',
3024
+ body: JSON.stringify({ eventIds: pending.events.map(e => e.id), ackedBy: 'scInbox.report' }),
3025
+ });
3026
+ return { ...report, acked };
3027
+ }
3028
+ return { ...report, stats: pending.stats };
3029
+ }
3030
+
3031
+ if (action === 'ack') {
3032
+ return await sidecarJson('/inbox/ack', {
3033
+ method: 'POST',
3034
+ body: JSON.stringify({
3035
+ eventIds: args.eventIds || [],
3036
+ taskIds: args.taskIds || [],
3037
+ all: args.all === true,
3038
+ ackedBy: 'scInbox.ack',
3039
+ }),
3040
+ });
3041
+ }
3042
+
3043
+ throw new Error(`unknown scInbox action: ${action}`);
3044
+ }
3045
+
3046
+ function normalizeScGuardMode(mode) {
3047
+ return mode === 'strict' ? 'strict' : 'warn';
3048
+ }
3049
+
3050
+ function scGuardError(status, missingFields = [], details = {}) {
3051
+ return {
3052
+ ok: false,
3053
+ status,
3054
+ code: -32602,
3055
+ message: 'Invalid params: ClarificationNeeded',
3056
+ missingFields,
3057
+ budgetExceeded: status === 'budget_exceeded',
3058
+ rawOutputPolicy: details.rawOutputPolicy || details.raw_output_policy || 'no_full_dump',
3059
+ details,
3060
+ };
3061
+ }
3062
+
3063
+ function scGuardJsonRpcError(id, guard) {
3064
+ return {
3065
+ jsonrpc: '2.0',
3066
+ id,
3067
+ error: {
3068
+ code: guard.code || -32602,
3069
+ message: guard.message || 'Invalid params: ClarificationNeeded',
3070
+ data: {
3071
+ status: guard.status,
3072
+ missingFields: guard.missingFields || [],
3073
+ budgetExceeded: guard.budgetExceeded === true,
3074
+ rawOutputPolicy: guard.rawOutputPolicy || 'no_full_dump',
3075
+ details: guard.details || {},
3076
+ },
3077
+ },
3078
+ };
3079
+ }
3080
+
3081
+ function isPlainObject(value) {
3082
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
3083
+ }
3084
+
3085
+ function scTaskCardRequiredMissing(args = {}) {
3086
+ const missing = [];
3087
+ if (!isPlainObject(args.budgets) && !isPlainObject(args.taskCard?.budgets)) missing.push('budgets');
3088
+ const rawPolicy = args.raw_output_policy || args.rawOutputPolicy || args.budgets?.raw_output_policy || args.taskCard?.raw_output_policy || args.taskCard?.budgets?.raw_output_policy;
3089
+ if (rawPolicy !== 'no_full_dump') missing.push('raw_output_policy');
3090
+ if (!isPlainObject(args.evidence) && !isPlainObject(args.taskCard?.evidence)) missing.push('evidence');
3091
+ return missing;
3092
+ }
3093
+
3094
+ function normalizeScTaskEnvelope(args = {}, prompt = '') {
3095
+ const budgets = isPlainObject(args.budgets) ? args.budgets : (isPlainObject(args.taskCard?.budgets) ? args.taskCard.budgets : {});
3096
+ const rawOutputPolicy = args.raw_output_policy || args.rawOutputPolicy || budgets.raw_output_policy || args.taskCard?.raw_output_policy || 'no_full_dump';
3097
+ const toolPolicy = args.toolPolicy || args.tool_policy || args.taskCard?.toolPolicy || args.taskCard?.tool_policy || null;
3098
+ return {
3099
+ taskCard: isPlainObject(args.taskCard) ? args.taskCard : null,
3100
+ runId: args.runId || args.taskCard?.runId || args.batchName || '',
3101
+ runDir: args.runDir || args.taskCard?.runDir || '',
3102
+ collector: isPlainObject(args.collector) ? args.collector : (isPlainObject(args.taskCard?.collector) ? args.taskCard.collector : null),
3103
+ budgets: {
3104
+ max_tool_output_chars: Number(budgets.max_tool_output_chars || budgets.maxToolOutputChars || 8000),
3105
+ max_total_tool_output_chars: Number(budgets.max_total_tool_output_chars || budgets.maxTotalToolOutputChars || 30000),
3106
+ raw_output_policy: rawOutputPolicy,
3107
+ },
3108
+ acceptance: isPlainObject(args.acceptance) ? args.acceptance : (isPlainObject(args.taskCard?.acceptance) ? args.taskCard.acceptance : null),
3109
+ evidence: isPlainObject(args.evidence) ? args.evidence : (isPlainObject(args.taskCard?.evidence) ? args.taskCard.evidence : null),
3110
+ toolPolicy,
3111
+ notifyPolicy: args.notifyPolicy || args.taskCard?.notifyPolicy || 'notify-only',
3112
+ promptChars: String(prompt || '').length,
3113
+ };
3114
+ }
3115
+
3116
+ function validateScSpawnTask(args = {}, modeOrName = 'spawnAgent', maybeOptions = {}) {
3117
+ const options = typeof modeOrName === 'object' ? modeOrName : maybeOptions;
3118
+ const mode = normalizeScGuardMode(options.mode || SC_TASKCARD_GUARD_MODE);
3119
+ const prompt = args.prompt;
3120
+ if (typeof prompt !== 'string' || prompt.trim() === '') {
3121
+ return scGuardError('ClarificationNeeded', ['prompt'], { mode, tool: 'spawnAgent' });
3122
+ }
3123
+ if (prompt.length > SC_PROMPT_MAX_CHARS) {
3124
+ return scGuardError('budget_exceeded', ['prompt'], { mode, tool: 'spawnAgent', promptChars: prompt.length, maxPromptChars: SC_PROMPT_MAX_CHARS });
3125
+ }
3126
+ const missing = scTaskCardRequiredMissing(args);
3127
+ const envelope = normalizeScTaskEnvelope(args, prompt);
3128
+ if (mode === 'strict' && missing.length > 0) {
3129
+ return scGuardError('ClarificationNeeded', missing, { mode, tool: 'spawnAgent', envelope });
3130
+ }
3131
+ return {
3132
+ ok: true,
3133
+ mode,
3134
+ guardWarnings: missing.map(field => `missing_${field}`),
3135
+ envelope,
3136
+ };
3137
+ }
3138
+
3139
+ function validateScPipelineTask(args = {}, options = {}) {
3140
+ const mode = normalizeScGuardMode(options.mode || SC_TASKCARD_GUARD_MODE);
3141
+ const groups = args.groups;
3142
+ if (!Array.isArray(groups) || groups.length === 0) {
3143
+ return scGuardError('ClarificationNeeded', ['groups'], { mode, tool: 'taskPipeline' });
3144
+ }
3145
+ if (groups.length > SC_PIPELINE_MAX_GROUPS) {
3146
+ return scGuardError('budget_exceeded', ['groups'], { mode, tool: 'taskPipeline', groupCount: groups.length, maxGroups: SC_PIPELINE_MAX_GROUPS });
3147
+ }
3148
+ const invalidIdx = groups.findIndex(g => !g || typeof g.prompt !== 'string' || g.prompt.trim() === '');
3149
+ if (invalidIdx >= 0) {
3150
+ return scGuardError('ClarificationNeeded', [`groups[${invalidIdx}].prompt`], { mode, tool: 'taskPipeline' });
3151
+ }
3152
+ const overIdx = groups.findIndex(g => String(g.prompt || '').length > SC_PROMPT_MAX_CHARS);
3153
+ if (overIdx >= 0) {
3154
+ return scGuardError('budget_exceeded', [`groups[${overIdx}].prompt`], { mode, tool: 'taskPipeline', promptChars: String(groups[overIdx].prompt || '').length, maxPromptChars: SC_PROMPT_MAX_CHARS });
3155
+ }
3156
+ const missing = scTaskCardRequiredMissing(args);
3157
+ const envelope = normalizeScTaskEnvelope(args, groups.map(g => g.prompt).join('\n'));
3158
+ const fireAndReturn = Number(args.staggerMs || 0) > 0 || (args.maxWait != null && Number(args.maxWait) <= 500);
3159
+ const needsStrictFields = groups.length > 1 || fireAndReturn || mode === 'strict';
3160
+ if (mode === 'strict' && needsStrictFields && missing.length > 0) {
3161
+ return scGuardError('ClarificationNeeded', missing, { mode, tool: 'taskPipeline', groupCount: groups.length, fireAndReturn, envelope });
3162
+ }
3163
+ return {
3164
+ ok: true,
3165
+ mode,
3166
+ guardWarnings: missing.map(field => `missing_${field}`),
3167
+ envelope,
3168
+ groupCount: groups.length,
3169
+ fireAndReturn,
3170
+ };
3171
+ }
3172
+
3173
+ function attachScGuardFields(payload, args = {}, guard) {
3174
+ const envelope = guard?.envelope || normalizeScTaskEnvelope(args, args.prompt || '');
3175
+ return {
3176
+ ...payload,
3177
+ taskCard: envelope.taskCard,
3178
+ runId: envelope.runId,
3179
+ runDir: envelope.runDir,
3180
+ collector: envelope.collector,
3181
+ budgets: envelope.budgets,
3182
+ acceptance: envelope.acceptance,
3183
+ evidence: envelope.evidence,
3184
+ toolPolicy: envelope.toolPolicy,
3185
+ notifyPolicy: envelope.notifyPolicy,
3186
+ raw_output_policy: envelope.budgets?.raw_output_policy || 'no_full_dump',
3187
+ guardMode: guard?.mode || normalizeScGuardMode(),
3188
+ guardWarnings: guard?.guardWarnings || [],
3189
+ };
3190
+ }
3191
+
3192
+ async function spawnAgent(args) {
3193
+ const guard = args.__scGuard || validateScSpawnTask(args, 'spawnAgent', { mode: SC_TASKCARD_GUARD_MODE });
3194
+ if (!guard.ok) throw Object.assign(new Error(guard.message || 'Invalid params: ClarificationNeeded'), { scGuard: guard });
3195
+ const resp = await fetch('http://127.0.0.1:18792/spawn_subagent', {
3196
+ method: 'POST', headers: {'Content-Type':'application/json'},
3197
+ body: JSON.stringify(attachScGuardFields({
3198
+ prompt: args.prompt || '',
3199
+ model: args.model || 'deepseek/deepseek-v4-flash',
3200
+ timeout: args.timeout || 600,
3201
+ maxRounds: args.maxRounds || 100,
3202
+ taskName: args.taskName || args.name || 'spawnAgent',
3203
+ batchName: args.batchName || '',
3204
+ groupName: args.groupName || ''
3205
+ }, args, guard))
3206
+ });
3207
+ return await resp.json();
3208
+ }
3209
+
3210
+ async function spawnTaskPipeline(args) {
3211
+ const guard = args.__scGuard || validateScPipelineTask(args, { mode: SC_TASKCARD_GUARD_MODE });
3212
+ if (!guard.ok) throw Object.assign(new Error(guard.message || 'Invalid params: ClarificationNeeded'), { scGuard: guard });
3213
+ const groups = args.groups || [];
3214
+ const maxWait = args.maxWait != null ? args.maxWait : 0;
3215
+ // 🔒 防呆:groups > 10 时自动 stagger,不传参数也安全
3216
+ const isLargeBatch = groups.length > 10;
3217
+ const staggerMs = args.staggerMs != null ? args.staggerMs : (isLargeBatch ? 20 : 0);
3218
+ const staggerBatchSize = args.staggerBatchSize || (isLargeBatch ? 10 : groups.length);
3219
+ const checkInterval = 3000;
3220
+ const doneFlagDir = join(homedir(), '.openclaw', 'workspace', 'memory', 'dialog', 'subagent');
3221
+
3222
+ // 1. 派兵(支持 stagger)
3223
+ const tasks = [];
3224
+ const dispatchNext = (idx) => {
3225
+ if (idx >= groups.length) return Promise.resolve();
3226
+ const batch = [];
3227
+ for (let j = 0; j < staggerBatchSize && idx + j < groups.length; j++) {
3228
+ batch.push(groups[idx + j]);
3229
+ }
3230
+ const nextIdx = idx + batch.length;
3231
+
3232
+ return Promise.all(batch.map((g, batchOffset) => {
3233
+ const groupArgs = { ...args, ...g, prompt: g.prompt };
3234
+ const groupGuard = {
3235
+ ...guard,
3236
+ envelope: normalizeScTaskEnvelope(groupArgs, g.prompt || ''),
3237
+ };
3238
+ return fetch('http://127.0.0.1:18792/spawn_subagent', {
3239
+ method: 'POST', headers: {'Content-Type':'application/json'},
3240
+ body: JSON.stringify(attachScGuardFields({
3241
+ prompt: g.prompt || '',
3242
+ model: g.model || 'deepseek/deepseek-v4-flash',
3243
+ timeout: g.timeout || 600,
3244
+ maxRounds: g.maxRounds || 100,
3245
+ taskName: g.taskName || g.name || `group-${idx + batchOffset + 1}`,
3246
+ batchName: args.batchName || '',
3247
+ groupName: g.name || ''
3248
+ }, groupArgs, groupGuard))
3249
+ }).then(r => r.json()).then(data => {
3250
+ tasks.push({ id: data.id, name: g.name, status: 'running' });
3251
+ }).catch(() => {
3252
+ tasks.push({ id: 'failed-' + g.name, name: g.name, status: 'failed' });
3253
+ });
3254
+ })).then(() => {
3255
+ if (nextIdx < groups.length) {
3256
+ return new Promise(r => setTimeout(r, staggerMs)).then(() => dispatchNext(nextIdx));
3257
+ }
3258
+ });
3259
+ };
3260
+
3261
+ await dispatchNext(0);
3262
+
3263
+ // 2. 如果设了stagger或极短maxWait,跳过轮询直接返回
3264
+ if (staggerMs > 0 || (maxWait > 0 && maxWait <= 500)) {
3265
+ return { batch: args.batchName, total: tasks.length, completed: 0, running: tasks.length, tasks, note: '派兵完成,兵在后台跑。用ai_collector收尾' };
3266
+ }
3267
+
3268
+ // 3. 轮询等待完成(传统模式)
3269
+ const startTime = Date.now();
3270
+ while (true) {
3271
+ let allDone = true;
3272
+ for (const t of tasks) {
3273
+ if (t.status === 'running') {
3274
+ if (existsSync(join(doneFlagDir, `DONE_${t.id}_success`))) { t.status = 'success';
3275
+ } else if (existsSync(join(doneFlagDir, `DONE_${t.id}_failed`))) { t.status = 'failed';
3276
+ } else if (existsSync(join(doneFlagDir, `DONE_${t.id}_stalled`))) { t.status = 'stalled';
3277
+ } else if (existsSync(join(doneFlagDir, `DONE_${t.id}_orphaned`))) { t.status = 'orphaned';
3278
+ } else { allDone = false; }
3279
+ }
3280
+ }
3281
+ if (allDone) break;
3282
+ if (maxWait > 0 && Date.now() - startTime > maxWait) break;
3283
+ await new Promise(resolve => setTimeout(resolve, checkInterval));
3284
+ }
3285
+
3286
+ const completed = tasks.filter(t => t.status !== 'running').length;
3287
+ return { batch: args.batchName, total: tasks.length, completed, running: tasks.length - completed, tasks };
3288
+ }