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,602 @@
1
+ /**
2
+ * 🦞 sc — 子agent执行中心(task-center)
3
+ *
4
+ * 提供三个核心工具:
5
+ * core_createTask — 子agent注册任务,生成taskId,写状态文件
6
+ * core_reportResult — 子agent完成任务,结构化压缩结果,原子写文件,不yield
7
+ * core_collectResults — 主脑收结果,阅后即焚,分批返回
8
+ *
9
+ * 结果自动结构化压缩:
10
+ * - 提取结论(summary)— 最重要的1-2句话
11
+ * - 提取关键证据(evidence)— 支撑结论的数据点(最多3)
12
+ * - 提取建议(recommendation)— 下一步建议
13
+ * - 压缩率stats(rawSize → compressedSize)
14
+ *
15
+ * 全部写文件到 shared/tasks/ 目录,原子写(tmp+rename)。
16
+ * 子agent调 core_reportResult 后直接退出,不yield。
17
+ * 主脑调 core_collectResults 收集(阅后即焚,读后删文件)。
18
+ */
19
+
20
+ import { join, dirname } from 'path';
21
+ import { homedir } from 'os';
22
+ import { readFile, writeFile, mkdir, readdir, unlink, rename, stat } from 'fs/promises';
23
+ import { randomBytes, randomUUID } from 'crypto';
24
+ import { calculatePriority } from './priority-calc.js';
25
+ import { TASK_CENTER_DIR, DEFAULT_BATCH_SIZE } from './constants.js';
26
+ import { checkTaskOverSpecificity } from './task-checker.js';
27
+ import { StewardGuard } from './steward-rules.js';
28
+
29
+ // ====== 目录保障 ======
30
+
31
+ let _ensureCalled = false;
32
+
33
+ async function ensureTasksDir() {
34
+ try {
35
+ await mkdir(TASK_CENTER_DIR, { recursive: true });
36
+ } catch (err) {
37
+ console.warn(`[task-center] ⚠️ 任务目录创建失败: ${err.message}`);
38
+ }
39
+ _ensureCalled = true;
40
+ }
41
+
42
+ function sanitizeId(id) {
43
+ return String(id || '').replace(/[^a-zA-Z0-9_\-\.]/g, '_');
44
+ }
45
+
46
+ // ====== 内部辅助 ======
47
+
48
+ /**
49
+ * 单次原子写
50
+ */
51
+ async function atomicWrite(fp, data) {
52
+ const tmpSuffix = randomBytes(4).toString('hex');
53
+ const tmp = fp + `.${tmpSuffix}.tmp`;
54
+ await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
55
+ await rename(tmp, fp);
56
+ }
57
+
58
+ /**
59
+ * 安全读取+删除(阅后即焚核心)
60
+ * 先 stat 检查文件是否存在,避免并发场景下 rename 竞态
61
+ */
62
+ async function readAndDelete(fp) {
63
+ // stat 检查:文件不存在则直接返回 null,避免 rename 竞态
64
+ try {
65
+ await stat(fp);
66
+ } catch {
67
+ return null;
68
+ }
69
+
70
+ const readingFp = fp.replace(/\.json$/, '.reading.json');
71
+ try {
72
+ await rename(fp, readingFp);
73
+ const content = await readFile(readingFp, 'utf-8');
74
+ await unlink(readingFp);
75
+ try { await unlink(fp); } catch {} // 清理原文件(rename后原文件可能残留)
76
+ return JSON.parse(content);
77
+ } catch (err) {
78
+ // rename 失败时尝试直接读
79
+ try { await unlink(readingFp); } catch {}
80
+ try {
81
+ const content = await readFile(fp, 'utf-8');
82
+ return JSON.parse(content);
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * 打包结果到任务状态文件
91
+ */
92
+ function buildTaskFile(taskId, type, priority, status, output, errors, compensate, startedAt, completedAt) {
93
+ const taskFile = {
94
+ taskId,
95
+ type,
96
+ priority,
97
+ status,
98
+ output: output || {},
99
+ errors: errors || [],
100
+ progress: status === 'done' ? 100 : (status === 'failed' ? 0 : 50),
101
+ startedAt: startedAt || new Date().toISOString(),
102
+ completedAt: completedAt || (status === 'done' || status === 'failed' ? new Date().toISOString() : null),
103
+ compensations: compensate || [],
104
+ };
105
+ return taskFile;
106
+ }
107
+
108
+ /**
109
+ * 结构化压缩:提取 conclusion/evidence/recommendation
110
+ * 不是简单截断,而是按优先级结构化重组
111
+ */
112
+ function structuralCompress(data) {
113
+ const rawSize = JSON.stringify(data).length;
114
+ if (rawSize === 0) {
115
+ return {
116
+ summary: '',
117
+ evidence: [],
118
+ recommendation: '',
119
+ compressionStats: { rawSize: 0, compressedSize: 21, ratio: 0, method: 'empty' },
120
+ };
121
+ }
122
+
123
+ // 如果data已经是结构化对象,直接提取
124
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
125
+ const hasStructured = data.summary || data.evidence || data.recommendation;
126
+ if (hasStructured) {
127
+ const compressedStr = JSON.stringify({
128
+ summary: data.summary || '',
129
+ evidence: (data.evidence || []).slice(0, 3),
130
+ recommendation: data.recommendation || '',
131
+ });
132
+ return {
133
+ summary: String(data.summary || '').substring(0, 500),
134
+ evidence: (data.evidence || []).slice(0, 3),
135
+ recommendation: String(data.recommendation || '').substring(0, 500),
136
+ compressionStats: {
137
+ rawSize,
138
+ compressedSize: compressedStr.length,
139
+ ratio: rawSize > 0 ? Math.round((1 - compressedStr.length / rawSize) * 100) : 0,
140
+ method: 'structured',
141
+ },
142
+ };
143
+ }
144
+ }
145
+
146
+ // 非结构化 → 自动提取
147
+ let rawStr = '';
148
+ try {
149
+ rawStr = typeof data === 'string' ? data : JSON.stringify(data);
150
+ } catch {
151
+ rawStr = String(data);
152
+ }
153
+
154
+ // 自动提取
155
+ let summary = '';
156
+ let evidence = [];
157
+ let recommendation = '';
158
+
159
+ if (rawStr.length < 500) {
160
+ // 短内容,整体当摘要
161
+ summary = rawStr;
162
+ } else {
163
+ // 长内容 → 取前200字做摘要
164
+ summary = rawStr.substring(0, 200) + (rawStr.length > 200 ? '...' : '');
165
+
166
+ // 找关键数据点:带 ":" 或 "=" 且非标点符号结尾的行
167
+ const lines = rawStr.split('\n').filter(l => l.trim());
168
+ const dataPoints = lines.filter(l =>
169
+ l.includes(':') || l.includes('=') || l.includes('→') || l.includes('->')
170
+ ).filter(l => !l.match(/^[{}[\]]/));
171
+
172
+ // 最多取3
173
+ evidence = dataPoints.slice(0, 3).map(l => l.substring(0, 200));
174
+
175
+ // 推荐:取包含"建议"/"推荐"/"should"/"need"/"must"的行
176
+ const recLines = lines.filter(l =>
177
+ /建议|推荐|推荐|should|need|must|next|下一步|then/i.test(l)
178
+ );
179
+ recommendation = recLines.length > 0
180
+ ? recLines.slice(0, 2).join('; ').substring(0, 500)
181
+ : '';
182
+ }
183
+
184
+ const compressedObj = { summary, evidence, recommendation };
185
+ const compressedSize = JSON.stringify(compressedObj).length;
186
+
187
+ return {
188
+ summary,
189
+ evidence,
190
+ recommendation,
191
+ compressionStats: {
192
+ rawSize,
193
+ compressedSize,
194
+ ratio: rawSize > 0 ? Math.round((1 - compressedSize / rawSize) * 100) : 0,
195
+ method: 'auto-extract',
196
+ },
197
+ };
198
+ }
199
+
200
+ // ====== Excel 公开API ======
201
+
202
+ /**
203
+ * core_createTask — 注册任务
204
+ * 子agent调此工具注册任务,生成taskId,写状态文件
205
+ *
206
+ * @param {Object} params
207
+ * @param {string} params.type - 任务类型(search/code/analysis/system等)
208
+ * @param {number} [params.priority] - 手动指定优先级(0-10),不传则自动计算
209
+ * @param {number} [params.timeout] - 超时s数(可选)
210
+ * @returns {Promise<{status: string, taskId: string, priority: number}>}
211
+ */
212
+ export async function core_createTask(params) {
213
+ const type = params?.type || 'general';
214
+ const manualPriority = params?.priority;
215
+ const timeout = params?.timeout || 120;
216
+
217
+ await ensureTasksDir();
218
+
219
+ // 生成taskId
220
+ const taskId = `${sanitizeId(type)}-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
221
+
222
+ // 自动计算优先级
223
+ const priority = (manualPriority !== undefined && manualPriority !== null)
224
+ ? Math.max(0, Math.min(10, manualPriority))
225
+ : await calculatePriority(type);
226
+
227
+ // ⚠️ 先做提示词质量检查,再写文件(确保 specificityWarn 写入persist)
228
+ const taskText = params?.description || params?.taskName || params?.task || '';
229
+ const specificityCheck = checkTaskOverSpecificity(taskText);
230
+
231
+ const taskFile = buildTaskFile(
232
+ taskId,
233
+ type,
234
+ priority,
235
+ 'running',
236
+ {},
237
+ [],
238
+ [],
239
+ new Date().toISOString(),
240
+ null
241
+ );
242
+
243
+ // 提示词质量检查结果合并到 taskFile(在 atomicWrite 之前)
244
+ if (specificityCheck.level === 'warn') {
245
+ taskFile.specificityWarn = {
246
+ reasons: specificityCheck.reasons,
247
+ suggestion: specificityCheck.suggestion,
248
+ };
249
+ }
250
+
251
+ // StewardGuard estimateDelegate:判断是否应派子agent,结果写入 taskFile
252
+ try {
253
+ const delegateEval = StewardGuard.estimateDelegate(taskText);
254
+ taskFile.delegateEval = delegateEval;
255
+ } catch (delegateErr) {
256
+ // estimateDelegate 失败不阻断主流程
257
+ taskFile.delegateEval = { shouldDelegate: null, reason: `评估异常: ${delegateErr.message}`, suggestedLevel: 'auto', tools: ['core_routeTask'] };
258
+ }
259
+
260
+ // 合并 timeout 和 createdAt 到主文件(原子性:单文件写入,消除双写孤儿文件)
261
+ taskFile.timeout = timeout;
262
+ taskFile.createdAt = Date.now();
263
+
264
+ const fp = join(TASK_CENTER_DIR, `${sanitizeId(taskId)}.json`);
265
+ await atomicWrite(fp, taskFile);
266
+
267
+ return {
268
+ status: 'success',
269
+ taskId,
270
+ priority,
271
+ specificityWarn: specificityCheck.level === 'warn' ? specificityCheck.reasons : undefined,
272
+ message: `任务 ${taskId} 已注册(类型=${type}, 优先级=${priority})` +
273
+ (specificityCheck.level === 'warn' ? ` ⚠️ 提示词过于具体:${specificityCheck.reasons[0]}` : ''),
274
+ };
275
+ }
276
+
277
+ /**
278
+ * core_reportResult — 完成任务(子agent调)
279
+ * 对data做结构化压缩,原子写任务文件,不yield直接退出
280
+ *
281
+ * @param {Object} params
282
+ * @param {string} params.taskId - 任务ID(由core_createTask返回)
283
+ * @param {string} params.status - 状态: 'success' | 'error' | 'timeout'
284
+ * @param {Object} [params.data] - 原始结果数据
285
+ * @param {string} [params.error] - 错误信息
286
+ * @returns {Promise<{status: string, taskId: string, compressed: boolean, compressionStats: Object}>}
287
+ */
288
+ export async function core_reportResult(params) {
289
+ const taskId = params?.taskId;
290
+ if (!taskId) {
291
+ return { status: 'error', errorCode: 'MISSING_TASK_ID', errorDetail: 'missing taskId 参数' };
292
+ }
293
+
294
+ const resultStatus = params?.status || 'success';
295
+ const rawData = params?.data || {};
296
+ const rawError = params?.error || '';
297
+
298
+ await ensureTasksDir();
299
+
300
+ // 1. 结构化压缩
301
+ const compressed = structuralCompress(rawData);
302
+
303
+ // 2. 读取现有任务文件(如果存在的话),以保留优先级和类型
304
+ let existingTask = null;
305
+ const existingFp = join(TASK_CENTER_DIR, `${sanitizeId(taskId)}.json`);
306
+ try {
307
+ const existingRaw = await readFile(existingFp, 'utf-8');
308
+ existingTask = JSON.parse(existingRaw);
309
+ } catch {}
310
+
311
+ // 3. 写任务文件
312
+ const taskFile = buildTaskFile(
313
+ taskId,
314
+ existingTask?.type || 'general',
315
+ existingTask?.priority || 5,
316
+ resultStatus === 'success' ? 'done' : resultStatus === 'error' ? 'failed' : 'timeout',
317
+ compressed,
318
+ rawError ? [rawError] : (existingTask?.errors || []),
319
+ [],
320
+ existingTask?.startedAt || new Date().toISOString(),
321
+ new Date().toISOString()
322
+ );
323
+
324
+ await atomicWrite(existingFp, taskFile);
325
+
326
+ // 清理旧版元数据标记(v2.0前遗留的 .meta.json,向后兼容)
327
+ const metaFp = join(TASK_CENTER_DIR, `${sanitizeId(taskId)}.meta.json`);
328
+ try { await unlink(metaFp); } catch {}
329
+
330
+ return {
331
+ status: 'success',
332
+ taskId,
333
+ compressed: true,
334
+ compressionStats: compressed.compressionStats,
335
+ };
336
+ }
337
+
338
+ /**
339
+ * core_collectResults — 主脑收结果
340
+ * 扫 shared/tasks/ 目录,取已完成任务,阅后即焚
341
+ *
342
+ * @param {Object} [params]
343
+ * @param {number} [params.batchSize] - 每次返回的任务数(默认DEFAULT_BATCH_SIZE)
344
+ * @returns {Promise<{status: string, collected: number, results: Array, errors: Array}>}
345
+ */
346
+ export async function core_collectResults(params) {
347
+ const batchSize = params?.batchSize || DEFAULT_BATCH_SIZE;
348
+
349
+ await ensureTasksDir();
350
+
351
+ // 收集结果:主文件(不含 .meta. 和 .tmp 和 .reading.)
352
+ let files = [];
353
+ try {
354
+ files = await readdir(TASK_CENTER_DIR);
355
+ } catch {
356
+ return { status: 'success', collected: 0, results: [], errors: [] };
357
+ }
358
+
359
+ const taskFiles = files.filter(f =>
360
+ f.endsWith('.json') &&
361
+ !f.endsWith('.meta.json') &&
362
+ !f.endsWith('.tmp') &&
363
+ !f.endsWith('.reading.json')
364
+ );
365
+
366
+ // 按修改时间排序(最新的优先)
367
+ const withMtime = await Promise.all(
368
+ taskFiles.map(async (f) => {
369
+ try {
370
+ const st = await stat(join(TASK_CENTER_DIR, f));
371
+ return { name: f, mtime: st.mtimeMs };
372
+ } catch {
373
+ return { name: f, mtime: 0 };
374
+ }
375
+ })
376
+ );
377
+ withMtime.sort((a, b) => b.mtime - a.mtime);
378
+ const sortedFiles = withMtime.map(f => f.name);
379
+
380
+ // 阅后即焚
381
+ const results = [];
382
+ const errors = [];
383
+ const toCollect = sortedFiles.slice(0, batchSize);
384
+
385
+ for (const f of toCollect) {
386
+ try {
387
+ const data = await readAndDelete(join(TASK_CENTER_DIR, f));
388
+ if (data !== null) {
389
+ results.push(data);
390
+ }
391
+ } catch (err) {
392
+ errors.push({ file: f, error: err.message });
393
+ }
394
+ }
395
+
396
+ // 清理孤儿 .reading.json(上一轮崩溃遗留)
397
+ const readingFiles = files.filter(f => f.endsWith('.reading.json'));
398
+ for (const rf of readingFiles) {
399
+ try { await unlink(join(TASK_CENTER_DIR, rf)); } catch {}
400
+ }
401
+
402
+ return {
403
+ status: 'success',
404
+ collected: results.length,
405
+ results,
406
+ errors: errors.length > 0 ? errors : undefined,
407
+ };
408
+ }
409
+
410
+ // ====== Checkpoint 系统(子agent强杀保护)======
411
+
412
+ const CHECKPOINT_SHARED_DIR = join(homedir(), '.openclaw', 'workspace', 'memory', 'shared');
413
+
414
+ export const CHECKPOINT_DIR = join(CHECKPOINT_SHARED_DIR, 'checkpoints');
415
+ const CHECKPOINT_MAX_AGE_MS = 30 * 60 * 1000; // 30min
416
+
417
+ let _checkpointDirEnsured = false;
418
+
419
+ export async function ensureCheckpointDir() {
420
+ if (_checkpointDirEnsured) return;
421
+ try { await mkdir(CHECKPOINT_DIR, { recursive: true }); _checkpointDirEnsured = true; } catch {}
422
+ }
423
+
424
+ /**
425
+ * 从 checkpoint session JSONL 文件中提取已完成的步骤
426
+ */
427
+ function extractCheckpointSteps(messages) {
428
+ const steps = [];
429
+ for (const msg of messages) {
430
+ if (msg?.type === 'message' && msg?.message?.role === 'assistant') {
431
+ const content = typeof msg.message?.content === 'string' ? msg.message.content : '';
432
+ const stepMatches = content.matchAll(/步骤\s*[::]\s*([^\n。]+)/g);
433
+ for (const m of stepMatches) steps.push(m[1].trim());
434
+ }
435
+ if (msg?.type === 'tool_call') {
436
+ steps.push(`调用了 ${msg.toolName || msg.name || 'unknown'}`);
437
+ }
438
+ }
439
+ return [...new Set(steps)].slice(0, 20);
440
+ }
441
+
442
+ /**
443
+ * 从 checkpoint session 消息中提取部分输出结果
444
+ */
445
+ function extractCheckpointOutputs(messages) {
446
+ const outputs = {};
447
+ for (const msg of messages) {
448
+ if (msg?.type === 'tool_result') {
449
+ const toolName = msg.toolName || msg.name || 'unknown';
450
+ const result = typeof msg.result === 'string' ? msg.result.substring(0, 500) : JSON.stringify(msg.result || '').substring(0, 500);
451
+ if (result && result !== '{}') outputs[toolName] = result;
452
+ }
453
+ if (msg?.type === 'message' && msg?.message?.role === 'user') {
454
+ const text = typeof msg.message?.content === 'string' ? msg.message.content : '';
455
+ if (text) outputs['lastUserInput'] = text.substring(0, 1000);
456
+ }
457
+ }
458
+ return outputs;
459
+ }
460
+
461
+ /**
462
+ * 从 checkpoint session 消息中提取错误计数
463
+ */
464
+ function extractCheckpointErrors(messages, sessionData = {}) {
465
+ let count = 0;
466
+ for (const msg of messages) {
467
+ if (msg?.type === 'tool_result' && msg.error) count++;
468
+ if (msg?.type === 'tool_call' && msg.error) count++;
469
+ }
470
+ for (const s of Object.values(sessionData)) {
471
+ if (s?.errorCount) count += s.errorCount;
472
+ if (s?.errors?.length) count += s.errors.length;
473
+ }
474
+ return count;
475
+ }
476
+
477
+ /**
478
+ * 保存 checkpoint 到 shared/checkpoints/{sessionId}.json
479
+ * 增强版:尝试读取 session 的 JSONL 文件获取完整上下文,提取已完成步骤、部分结果、错误计数
480
+ *
481
+ * @param {string} sessionId - 子agent session ID
482
+ * @param {Object} [extraData={}] - 额外数据
483
+ * @param {string} [extraData.taskName] - 任务名称
484
+ * @param {string[]} [extraData.completedSteps] - 手动指定的已完成步骤
485
+ * @param {Object} [extraData.partialOutputs] - 手动指定的部分输出
486
+ * @param {number} [extraData.errorCount] - 手动指定的错误计数
487
+ * @returns {Promise<string|null>} 文件路径或 null
488
+ */
489
+ export async function saveCheckpoint(sessionId, extraData = {}) {
490
+ try {
491
+ await ensureCheckpointDir();
492
+
493
+ // 1. 读取 session 的 JSONL 文件获取最近消息(真正执行过的上下文)
494
+ let sessionMessages = [];
495
+ try {
496
+ const sessionsDir = join(homedir(), '.openclaw', 'agents', 'main', 'sessions');
497
+ const files = await readdir(sessionsDir).catch(() => []);
498
+ const jsonlFile = files.find(f => f.startsWith(sessionId) && f.endsWith('.jsonl') && !f.includes('.trajectory'));
499
+ if (jsonlFile) {
500
+ const raw = await readFile(join(sessionsDir, jsonlFile), 'utf-8');
501
+ sessionMessages = raw.trim().split('\n').slice(-50).map(l => {
502
+ try { return JSON.parse(l); } catch { return null; }
503
+ }).filter(Boolean);
504
+ }
505
+ } catch {}
506
+
507
+ // 2. 尝试从 sessions.json 读取会话元数据
508
+ let sessionData = {};
509
+ try {
510
+ const sessionsJsonPath = join(homedir(), '.openclaw', 'agents', 'main', 'sessions.json');
511
+ const raw = await readFile(sessionsJsonPath, 'utf-8');
512
+ const data = JSON.parse(raw);
513
+ for (const [key, session] of Object.entries(data)) {
514
+ if (session.sessionId === sessionId) {
515
+ sessionData[key] = { ...session };
516
+ }
517
+ }
518
+ } catch {}
519
+
520
+ // 3. 从消息中提取完成步骤、部分输出、错误计数
521
+ const completedSteps = extractCheckpointSteps(sessionMessages);
522
+ const partialOutputs = extractCheckpointOutputs(sessionMessages);
523
+ const errorCount = extractCheckpointErrors(sessionMessages, sessionData);
524
+
525
+ // 4. 构建 checkpoint(extraData 优先级高于自动提取,允许手动覆盖)
526
+ const checkpoint = {
527
+ sessionId,
528
+ taskName: extraData.taskName || '',
529
+ sessionData,
530
+ recentMessages: sessionMessages.slice(-20),
531
+ completedSteps: extraData.completedSteps?.length ? extraData.completedSteps : completedSteps,
532
+ partialOutputs: extraData.partialOutputs ? { ...extraData.partialOutputs, ...partialOutputs } : partialOutputs,
533
+ errorCount: extraData.errorCount ?? errorCount,
534
+ interruptedAt: Date.now(),
535
+ createdAt: Date.now(),
536
+ };
537
+
538
+ const safeName = (sessionId || 'unknown').replace(/[^a-zA-Z0-9_\-]/g, '_').substring(0, 100);
539
+ const fp = join(CHECKPOINT_DIR, `${safeName}.json`);
540
+ await atomicWrite(fp, checkpoint);
541
+ // TODO: 移除调试日志 console.log(`[task-center] 💾 Checkpoint 已保存: ${sessionId}`);
542
+ return fp;
543
+ } catch (err) {
544
+ console.warn(`[task-center] ⚠️ Checkpoint 保存失败: ${err.message}`);
545
+ return null;
546
+ }
547
+ }
548
+
549
+ /**
550
+ * 读取 checkpoint, 30min过期自动清理
551
+ *
552
+ * @param {string} sessionId - 子agent session ID
553
+ * @returns {Promise<Object|null>} checkpoint 对象或 null
554
+ */
555
+ export async function readCheckpoint(sessionId) {
556
+ try {
557
+ await ensureCheckpointDir();
558
+ const safeId = (sessionId || 'unknown').replace(/[^a-zA-Z0-9_\-]/g, '_').substring(0, 100);
559
+ const fp = join(CHECKPOINT_DIR, `${safeId}.json`);
560
+ const content = await readFile(fp, 'utf-8');
561
+ const checkpoint = JSON.parse(content);
562
+
563
+ const age = Date.now() - (checkpoint.createdAt || checkpoint.interruptedAt || 0);
564
+ if (age > CHECKPOINT_MAX_AGE_MS) {
565
+ // TODO: 移除调试日志 console.log(`[task-center] ⏰ Checkpoint ${sessionId} 已过期 (${Math.round(age / 60000)}min > 30min)`);
566
+ await unlink(fp).catch(() => {});
567
+ return null;
568
+ }
569
+
570
+ return checkpoint;
571
+ } catch {
572
+ return null;
573
+ }
574
+ }
575
+
576
+ /**
577
+ * 清理超过30min的 checkpoint 文件
578
+ */
579
+ export async function cleanupCheckpoints() {
580
+ try {
581
+ await ensureCheckpointDir();
582
+ const files = await readdir(CHECKPOINT_DIR).catch(() => []);
583
+ const now = Date.now();
584
+ let cleaned = 0;
585
+ for (const f of files) {
586
+ if (!f.endsWith('.json')) continue;
587
+ try {
588
+ const content = await readFile(join(CHECKPOINT_DIR, f), 'utf-8');
589
+ const cp = JSON.parse(content);
590
+ const age = now - (cp.createdAt || cp.interruptedAt || 0);
591
+ if (age > CHECKPOINT_MAX_AGE_MS) {
592
+ await unlink(join(CHECKPOINT_DIR, f));
593
+ cleaned++;
594
+ }
595
+ } catch {
596
+ // 损坏的文件也清理
597
+ try { await unlink(join(CHECKPOINT_DIR, f)); cleaned++; } catch {}
598
+ }
599
+ }
600
+ if (cleaned > 0) console.log(`[task-center] 🧹 清理了 ${cleaned} 个过期 Checkpoint`);
601
+ } catch {}
602
+ }