foliko 2.0.19 → 2.0.20

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.
@@ -0,0 +1,926 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Planner Plugin
5
+ * 计划模式:为复杂/可中断任务创建持久化步骤、记录执行状态、重启可继续
6
+ *
7
+ * 触发方式:
8
+ * 1. LLM 自主判断:PROMPT.md 注入 system prompt 引导
9
+ * 2. 显式命令:WeChat /plan <task>、/plan_list、/plan_continue <id>、/plan_show <id>
10
+ *
11
+ * 文件结构:每个计划一个 .md 文件,存于 .foliko/plans/
12
+ * - frontmatter: 计划元数据
13
+ * - 正文: 任务描述 + 步骤清单 (GFM checkbox) + 执行日志
14
+ */
15
+
16
+ const { Plugin } = require('../../../src/plugin/base');
17
+ const { logger } = require('../../../src/common/logger');
18
+ const { z } = require('zod');
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { randomUUID } = require('crypto');
22
+
23
+ const log = logger.child('Planner');
24
+
25
+ // ============================================================
26
+ // 持久化层:原子写(先 .tmp 再 rename)
27
+ // 复用 plugins/core/scheduler/index.js:60-86 TaskStore 模式
28
+ // ============================================================
29
+ class PlanStore {
30
+ constructor(dir) {
31
+ this._dir = dir;
32
+ this._ensureDir();
33
+ }
34
+
35
+ _ensureDir() {
36
+ if (!fs.existsSync(this._dir)) {
37
+ fs.mkdirSync(this._dir, { recursive: true });
38
+ }
39
+ }
40
+
41
+ _toFilePath(planId) {
42
+ return path.join(this._dir, `${planId}.md`);
43
+ }
44
+
45
+ /**
46
+ * 原子写:先写 .tmp 再 rename(同卷下原子替换)
47
+ * 避免进程在 writeFileSync 中途崩溃导致 .md 半残
48
+ */
49
+ save(plan) {
50
+ const finalPath = this._toFilePath(plan.planId);
51
+ const tmpPath = finalPath + '.tmp';
52
+ try {
53
+ const md = PlanSerializer.toMarkdown(plan);
54
+ fs.writeFileSync(tmpPath, md, 'utf-8');
55
+ fs.renameSync(tmpPath, finalPath);
56
+ } catch (err) {
57
+ log.error(`[PlanStore] 保存 ${plan.planId} 失败:`, err.message);
58
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
59
+ throw err;
60
+ }
61
+ }
62
+
63
+ load(planId) {
64
+ const fp = this._toFilePath(planId);
65
+ if (!fs.existsSync(fp)) return null;
66
+ try {
67
+ return PlanSerializer.fromMarkdown(fs.readFileSync(fp, 'utf-8'));
68
+ } catch (err) {
69
+ log.error(`[PlanStore] 加载 ${planId} 失败:`, err.message);
70
+ return null;
71
+ }
72
+ }
73
+
74
+ list({ status, limit } = {}) {
75
+ if (!fs.existsSync(this._dir)) return [];
76
+ const files = fs.readdirSync(this._dir).filter(f => f.endsWith('.md') && !f.endsWith('.tmp'));
77
+ const plans = [];
78
+ for (const f of files) {
79
+ try {
80
+ const plan = PlanSerializer.fromMarkdown(
81
+ fs.readFileSync(path.join(this._dir, f), 'utf-8')
82
+ );
83
+ if (status && plan.status !== status) continue;
84
+ plans.push(plan);
85
+ } catch (err) {
86
+ log.warn(`[PlanStore] 跳过无效文件 ${f}: ${err.message}`);
87
+ }
88
+ }
89
+ plans.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
90
+ return limit ? plans.slice(0, limit) : plans;
91
+ }
92
+
93
+ delete(planId) {
94
+ const fp = this._toFilePath(planId);
95
+ let ok = false;
96
+ if (fs.existsSync(fp)) {
97
+ fs.unlinkSync(fp);
98
+ ok = true;
99
+ }
100
+ // 清理残留 .tmp
101
+ try { fs.unlinkSync(fp + '.tmp'); } catch { /* ignore */ }
102
+ return ok;
103
+ }
104
+
105
+ exists(planId) {
106
+ return fs.existsSync(this._toFilePath(planId));
107
+ }
108
+ }
109
+
110
+ // ============================================================
111
+ // 序列化:手写 frontmatter 解析(不引入 gray-matter)
112
+ // 理由:格式固定无需泛化、减少 supply chain 风险
113
+ // ============================================================
114
+ class PlanSerializer {
115
+ static CHECKBOX_MAP = {
116
+ completed: '[x]',
117
+ in_progress: '[-]',
118
+ failed: '[!]',
119
+ skipped: '[~]',
120
+ pending: '[ ]',
121
+ };
122
+
123
+ static CHECKBOX_REVERSE = {
124
+ 'x': 'completed',
125
+ '-': 'in_progress',
126
+ '!': 'failed',
127
+ '~': 'skipped',
128
+ ' ': 'pending',
129
+ };
130
+
131
+ static _escapeYaml(s) {
132
+ if (s == null) return '';
133
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
134
+ }
135
+
136
+ /**
137
+ * 从 markdown 中提取指定 step 的日志段
138
+ * 日志格式:### Step N (尝试 M) - ...,段间用 --- 或下一个 Step 标题分隔
139
+ * @param {string} md 完整 markdown
140
+ * @param {number} stepIndex 0-based
141
+ * @returns {string|null}
142
+ */
143
+ static _extractStepLog(md, stepIndex) {
144
+ if (!md) return null;
145
+ const stepNum = stepIndex + 1;
146
+ // 匹配 ### Step N (尝试 M) - ... 到下一个 ### Step N+1 或 ## 📋 或文件结尾
147
+ const re = new RegExp(
148
+ `### Step ${stepNum}[\\s\\S]*?(?=### Step ${stepNum + 1}\\b|## 📋|## 执行日志结束|$)`,
149
+ 'm'
150
+ );
151
+ // 上面匹配整个日志节;我们只需要从 "## 执行日志" 之后到下一个分隔符的内容
152
+ const logsStart = md.indexOf('## 执行日志');
153
+ if (logsStart === -1) return null;
154
+ const logsBlock = md.slice(logsStart);
155
+ const stepRe = new RegExp(
156
+ `### Step ${stepNum}\\b[\\s\\S]*?(?=### Step ${stepNum + 1}\\b|## 📋|## 完成总结|$)`
157
+ );
158
+ const match = logsBlock.match(stepRe);
159
+ return match ? match[0] : null;
160
+ }
161
+
162
+ static toMarkdown(plan) {
163
+ const fm = [
164
+ '---',
165
+ `planId: "${PlanSerializer._escapeYaml(plan.planId)}"`,
166
+ `title: "${PlanSerializer._escapeYaml(plan.title)}"`,
167
+ `status: "${plan.status || 'in_progress'}"`,
168
+ `totalSteps: ${plan.totalSteps || 0}`,
169
+ `completedSteps: ${plan.completedSteps || 0}`,
170
+ `failedSteps: ${plan.failedSteps || 0}`,
171
+ `createdAt: "${plan.createdAt || ''}"`,
172
+ `updatedAt: "${plan.updatedAt || ''}"`,
173
+ plan.sessionId ? `sessionId: "${PlanSerializer._escapeYaml(plan.sessionId)}"` : null,
174
+ '---',
175
+ ].filter(Boolean).join('\n');
176
+
177
+ const stepList = (plan.steps || []).map((s, i) => {
178
+ const box = PlanSerializer.CHECKBOX_MAP[s.status] || '[ ]';
179
+ const line = `- ${box} ${i + 1}. ${s.name || ''}${s.description ? ` — ${s.description}` : ''}`;
180
+ return line;
181
+ }).join('\n');
182
+
183
+ const logs = (plan.steps || [])
184
+ .filter(s => s.log)
185
+ .map(s => s.log)
186
+ .join('\n\n') || '_暂无日志_';
187
+
188
+ return [
189
+ fm,
190
+ '',
191
+ `# ${plan.title || '(无标题)'}`,
192
+ '',
193
+ `## 任务描述`,
194
+ '',
195
+ plan.task || '(无)',
196
+ '',
197
+ `## 计划步骤`,
198
+ '',
199
+ stepList,
200
+ '',
201
+ `## 执行日志`,
202
+ '',
203
+ logs,
204
+ '',
205
+ ].join('\n');
206
+ }
207
+
208
+ static fromMarkdown(md) {
209
+ if (!md || !md.startsWith('---\n')) {
210
+ throw new Error('无效的计划格式:缺少 frontmatter');
211
+ }
212
+ const fmEnd = md.indexOf('\n---', 4);
213
+ if (fmEnd === -1) {
214
+ throw new Error('无效的计划格式:frontmatter 未闭合');
215
+ }
216
+ const fmBlock = md.slice(4, fmEnd);
217
+ const meta = {};
218
+ for (const line of fmBlock.split('\n')) {
219
+ const m = line.match(/^([\w-]+):\s*(.*)$/);
220
+ if (!m) continue;
221
+ let val = m[2];
222
+ if (val.startsWith('"') && val.endsWith('"')) {
223
+ val = val.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
224
+ }
225
+ if (['totalSteps', 'completedSteps', 'failedSteps'].includes(m[1])) {
226
+ val = parseInt(val, 10) || 0;
227
+ }
228
+ meta[m[1]] = val;
229
+ }
230
+
231
+ // 解析步骤清单:- [x] 1. 名称 — 描述
232
+ const steps = [];
233
+ const stepRegex = /^- \[(.)\] (\d+)\. (.+?)(?: — (.+))?$/gm;
234
+ let match;
235
+ while ((match = stepRegex.exec(md)) !== null) {
236
+ steps.push({
237
+ index: Number(match[2]) - 1,
238
+ name: match[3],
239
+ description: match[4] || null,
240
+ status: PlanSerializer.CHECKBOX_REVERSE[match[1]] || 'pending',
241
+ attempts: 0,
242
+ log: null,
243
+ });
244
+ }
245
+
246
+ // 从日志的"### Step N (尝试 M) - ..."模式恢复 attempts 和 log
247
+ for (const step of steps) {
248
+ const logText = PlanSerializer._extractStepLog(md, step.index);
249
+ if (logText) {
250
+ step.log = logText;
251
+ const attemptsMatches = logText.match(/尝试 (\d+)/g) || [];
252
+ const nums = attemptsMatches.map(s => parseInt(s.match(/\d+/)[0], 10));
253
+ step.attempts = nums.length ? Math.max(...nums) : 0;
254
+ }
255
+ }
256
+
257
+ return {
258
+ planId: meta.planId,
259
+ title: meta.title,
260
+ task: '',
261
+ status: meta.status || 'in_progress',
262
+ totalSteps: meta.totalSteps || steps.length,
263
+ completedSteps: meta.completedSteps || 0,
264
+ failedSteps: meta.failedSteps || 0,
265
+ createdAt: meta.createdAt,
266
+ updatedAt: meta.updatedAt,
267
+ sessionId: meta.sessionId || null,
268
+ steps,
269
+ };
270
+ }
271
+ }
272
+
273
+ // ============================================================
274
+ // 主插件类
275
+ // ============================================================
276
+ class PlannerPlugin extends Plugin {
277
+ constructor(config = {}) {
278
+ super();
279
+ this.name = 'planner';
280
+ this.version = '1.0.0';
281
+ this.description = '计划模式:为复杂/可中断任务创建持久化步骤、记录执行状态、重启可继续';
282
+ this.priority = 50;
283
+ this.enabled = true;
284
+ this.system = false;
285
+ this.config = {
286
+ dir: config.dir || '.foliko/plans',
287
+ autoPlanThreshold: config.autoPlanThreshold || 3,
288
+ };
289
+ this._framework = null;
290
+ this._store = null;
291
+ this._index = new Map(); // planId → in-memory plan
292
+ }
293
+
294
+ // === 声明式 prompt 注入(基类自动注册/清理) ===
295
+ // 参考 src/plugin/base.js:811-842 _autoRegisterPrompts
296
+ // 文件路径相对插件目录,由 base.js:850-880 _createFilePromptProvider 解析
297
+ prompts = [
298
+ {
299
+ name: 'planner-mode',
300
+ file: './PROMPT.md',
301
+ scope: 'global',
302
+ priority: 70,
303
+ description: '计划模式触发条件与工作流',
304
+ },
305
+ // ★ 关键:动态状态提示。每次 LLM 调用重新渲染,
306
+ // 防止 messages 被压缩/截断后 LLM 忘记计划进度。
307
+ // priority 58 排在 CAPABILITIES (60) 之前,确保显眼。
308
+ {
309
+ name: 'planner-status',
310
+ scope: 'global',
311
+ priority: 58,
312
+ description: '当前活跃计划状态摘要(每次 LLM 调用重渲染)',
313
+ provider() {
314
+ return this._buildPlanStatusReminder();
315
+ },
316
+ },
317
+ ];
318
+
319
+ /**
320
+ * 失效 planner-status 缓存,让下次 LLM 调用看到最新状态
321
+ */
322
+ _invalidatePlanStatusPrompt() {
323
+ try {
324
+ this._framework?.prompts?.invalidate?.('planner', 'planner-status');
325
+ } catch { /* ignore */ }
326
+ }
327
+
328
+ /**
329
+ * 渲染当前活跃计划的状态摘要(注入 system prompt)
330
+ * - 只显示当前 session 的 in_progress 计划
331
+ * - 即使 messages 被压缩/截断,LLM 仍能从此处读到"执行到第几步"
332
+ * @returns {string} 摘要文本(空字符串表示无活跃计划)
333
+ */
334
+ _buildPlanStatusReminder() {
335
+ if (!this._framework || !this._store) return '';
336
+
337
+ // 从多个来源尝试获取 sessionId
338
+ const sessionId =
339
+ this._framework.getCurrentSessionId?.() ||
340
+ this._framework.getExecutionContext?.()?.sessionId ||
341
+ null;
342
+ if (!sessionId) return '';
343
+
344
+ // ★ 关键:通过 _loadPlan 读(内存优先)而非直接 _store.list(只读磁盘),
345
+ // 这样能拿到运行期字段(completedAt、attempts、log 等),
346
+ // 这些字段在 markdown 序列化时被丢弃,必须从内存取。
347
+ let plans = [];
348
+ try {
349
+ const files = fs.readdirSync(this._store._dir)
350
+ .filter(f => f.endsWith('.md') && !f.endsWith('.tmp'));
351
+ for (const f of files) {
352
+ const planId = f.replace(/\.md$/, '');
353
+ try {
354
+ const plan = this._loadPlan(planId);
355
+ if (plan && plan.status === 'in_progress') plans.push(plan);
356
+ } catch { /* skip */ }
357
+ }
358
+ plans.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0));
359
+ } catch { return ''; }
360
+ const myPlans = plans.filter(p => p.sessionId === sessionId);
361
+ if (myPlans.length === 0) return '';
362
+
363
+ const lines = ['## 📋 当前活跃计划', ''];
364
+ for (const plan of myPlans.slice(0, 3)) {
365
+ const inProgressStep = plan.steps.find(s => s.status === 'in_progress');
366
+ const lastFinished = plan.steps
367
+ .filter(s => s.completedAt)
368
+ .sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt))[0];
369
+
370
+ lines.push(`### ${plan.title}`);
371
+ lines.push(`- **planId**: \`${plan.planId}\``);
372
+ lines.push(`- **进度**: ${plan.completedSteps}/${plan.totalSteps}(✅ ${plan.completedSteps} 完成,❌ ${plan.failedSteps} 失败)`);
373
+ if (inProgressStep) {
374
+ lines.push(`- **当前步骤**: Step ${inProgressStep.index + 1} — **${inProgressStep.name}**`);
375
+ if (inProgressStep.attempts > 1) {
376
+ lines.push(` - 已尝试 ${inProgressStep.attempts} 次`);
377
+ }
378
+ } else {
379
+ lines.push(`- **当前步骤**: 全部已执行,等待 plan_complete/plan_fail`);
380
+ }
381
+ if (lastFinished) {
382
+ const dur = lastFinished.durationMs ? ` (${lastFinished.durationMs}ms)` : '';
383
+ let lastLabel;
384
+ if (lastFinished.status === 'completed') {
385
+ lastLabel = `✅ 完成${dur}`;
386
+ } else if (lastFinished.status === 'failed') {
387
+ lastLabel = `❌ 失败${lastFinished.error ? `: ${lastFinished.error}` : ''}${dur}`;
388
+ } else if (lastFinished.status === 'skipped') {
389
+ lastLabel = `⏭️ 跳过${lastFinished.note ? `: ${lastFinished.note}` : ''}`;
390
+ } else {
391
+ lastLabel = lastFinished.status;
392
+ }
393
+ lines.push(`- **上一步**: Step ${lastFinished.index + 1} ${lastLabel}`);
394
+ }
395
+ lines.push('');
396
+ }
397
+ lines.push('💡 即使上下文被压缩/截断,**继续用 planId 调 `plan_execute_step` 即可恢复执行**。');
398
+ lines.push('完整计划可调 `plan_show({planId})` 查看。');
399
+ return lines.join('\n');
400
+ }
401
+
402
+ install(framework) {
403
+ this._framework = framework;
404
+ const cwd = framework?.getCwd?.() ?? process.cwd();
405
+ this._store = new PlanStore(path.resolve(cwd, this.config.dir));
406
+ this._initIndex();
407
+ return this;
408
+ }
409
+
410
+ start(framework) {
411
+ this._registerTools();
412
+ return this;
413
+ }
414
+
415
+ // === 启动时把所有 .md 加载到内存索引 ===
416
+ _initIndex() {
417
+ if (!this._store) return;
418
+ for (const plan of this._store.list()) {
419
+ this._index.set(plan.planId, plan);
420
+ }
421
+ log.info(`已加载 ${this._index.size} 个计划(${this.config.dir})`);
422
+ }
423
+
424
+ // === 生成 planId: plan_<ts>-<slug>-<rand6> ===
425
+ _generatePlanId(title) {
426
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);
427
+ const slug = (title || 'untitled')
428
+ .toLowerCase()
429
+ .replace(/[^\w一-龥-]+/g, '-')
430
+ .replace(/^-+|-+$/g, '')
431
+ .slice(0, 40) || 'untitled';
432
+ return `plan_${ts}-${slug}-${randomUUID().slice(0, 6)}`;
433
+ }
434
+
435
+ // === 内部:从内存或磁盘加载 ===
436
+ _loadPlan(planId) {
437
+ let plan = this._index.get(planId);
438
+ if (plan) return plan;
439
+ plan = this._store?.load(planId);
440
+ if (!plan) {
441
+ const err = new Error(`Plan ${planId} not found`);
442
+ err.code = 'PLAN_NOT_FOUND';
443
+ throw err;
444
+ }
445
+ // 从磁盘 markdown 中补回 log 字段(log 是衍生视图,不存 frontmatter)
446
+ if (this._store) {
447
+ try {
448
+ const md = fs.readFileSync(this._store._toFilePath(planId), 'utf-8');
449
+ plan.steps = plan.steps.map(s => ({
450
+ ...s,
451
+ log: PlanSerializer._extractStepLog(md, s.index),
452
+ }));
453
+ } catch { /* 文件可能不存在,忽略 */ }
454
+ }
455
+ this._index.set(planId, plan);
456
+ return plan;
457
+ }
458
+
459
+ // === 内部:是否应该中止(最近已执行步骤中连续 2 步失败) ===
460
+ // 跳过 pending 步骤,只看已执行(completed/failed/skipped)的最近序列
461
+ _shouldAbort(plan) {
462
+ let consec = 0;
463
+ for (let i = plan.steps.length - 1; i >= 0; i--) {
464
+ const s = plan.steps[i];
465
+ if (s.status === 'pending') continue; // 跳过未启动
466
+ if (s.status === 'failed') consec++;
467
+ else break; // 遇到成功/跳过/进行中 → 中断计数
468
+ }
469
+ return consec >= 2;
470
+ }
471
+
472
+ // ============================================================
473
+ // 工具实现(8 个)
474
+ // ============================================================
475
+
476
+ async _createPlan({ title, task, steps, sessionId }) {
477
+ if (!title) return { success: false, error: 'title 不能为空' };
478
+ if (!Array.isArray(steps) || steps.length === 0) {
479
+ return { success: false, error: 'steps 不能为空数组' };
480
+ }
481
+ // 死循环防护
482
+ for (const s of steps) {
483
+ if (s.tool === 'plan_create' || s.tool === 'plan_continue') {
484
+ return { success: false, error: `禁止将 ${s.tool} 作为步骤工具(防止循环)` };
485
+ }
486
+ }
487
+
488
+ const planId = this._generatePlanId(title);
489
+ const now = new Date().toISOString();
490
+ const resolvedSessionId = sessionId
491
+ || this._framework?.getExecutionContext?.()?.sessionId
492
+ || null;
493
+
494
+ const plan = {
495
+ planId,
496
+ title,
497
+ task: task || '',
498
+ sessionId: resolvedSessionId,
499
+ status: 'in_progress',
500
+ totalSteps: steps.length,
501
+ completedSteps: 0,
502
+ failedSteps: 0,
503
+ createdAt: now,
504
+ updatedAt: now,
505
+ steps: steps.map((s, i) => ({
506
+ index: i,
507
+ name: s.name,
508
+ description: s.description || null,
509
+ status: i === 0 ? 'in_progress' : 'pending',
510
+ startedAt: i === 0 ? now : null,
511
+ completedAt: null,
512
+ durationMs: null,
513
+ tool: s.tool || null,
514
+ args: s.args || null,
515
+ result: null,
516
+ error: null,
517
+ note: null,
518
+ log: null,
519
+ attempts: 0,
520
+ })),
521
+ };
522
+
523
+ try {
524
+ this._store.save(plan);
525
+ } catch (err) {
526
+ return { success: false, error: `保存失败: ${err.message}` };
527
+ }
528
+ this._index.set(planId, plan);
529
+ this._invalidatePlanStatusPrompt();
530
+
531
+ return {
532
+ success: true,
533
+ planId,
534
+ stepsCount: steps.length,
535
+ markdownPath: this._store._toFilePath(planId),
536
+ currentStep: 0,
537
+ };
538
+ }
539
+
540
+ async _listPlans({ status, sessionId, limit } = {}) {
541
+ let plans = this._store.list({ status, limit });
542
+ if (sessionId) {
543
+ plans = plans.filter(p => p.sessionId === sessionId);
544
+ }
545
+ return {
546
+ success: true,
547
+ count: plans.length,
548
+ plans: plans.map(p => ({
549
+ planId: p.planId,
550
+ title: p.title,
551
+ status: p.status,
552
+ totalSteps: p.totalSteps,
553
+ completedSteps: p.completedSteps,
554
+ failedSteps: p.failedSteps,
555
+ sessionId: p.sessionId,
556
+ createdAt: p.createdAt,
557
+ updatedAt: p.updatedAt,
558
+ })),
559
+ };
560
+ }
561
+
562
+ async _showPlan({ planId }) {
563
+ if (!planId) return { success: false, error: 'planId 必填' };
564
+ const plan = this._loadPlan(planId);
565
+ // 重新从磁盘读最新 markdown(确保展示与文件一致)
566
+ let markdown;
567
+ try {
568
+ markdown = fs.readFileSync(this._store._toFilePath(planId), 'utf-8');
569
+ } catch (err) {
570
+ return { success: false, error: `读取失败: ${err.message}` };
571
+ }
572
+ return {
573
+ success: true,
574
+ planId,
575
+ markdown,
576
+ plan: {
577
+ planId: plan.planId,
578
+ title: plan.title,
579
+ status: plan.status,
580
+ completedSteps: plan.completedSteps,
581
+ totalSteps: plan.totalSteps,
582
+ },
583
+ };
584
+ }
585
+
586
+ async _executeStep({ planId, stepIndex, status, tool, args, result, error, note, durationMs }) {
587
+ if (!planId) return { success: false, error: 'planId 必填' };
588
+ if (stepIndex === undefined || stepIndex === null) {
589
+ return { success: false, error: 'stepIndex 必填' };
590
+ }
591
+ if (!['in_progress', 'completed', 'failed', 'skipped'].includes(status)) {
592
+ return { success: false, error: `invalid status: ${status}` };
593
+ }
594
+
595
+ let plan;
596
+ try {
597
+ plan = this._loadPlan(planId);
598
+ } catch (err) {
599
+ return { success: false, error: err.message };
600
+ }
601
+
602
+ const step = plan.steps[stepIndex];
603
+ if (!step) {
604
+ return { success: false, error: `Step ${stepIndex} not found (total ${plan.steps.length})` };
605
+ }
606
+
607
+ const now = new Date().toISOString();
608
+ const attemptNumber = (step.attempts || 0) + 1;
609
+ const isRetry = attemptNumber > 1;
610
+
611
+ // ★ 修复:每次 executeStep 视为"新一次尝试",
612
+ // 即使没传 error/note/result,也要清空旧的(避免日志残留上一次失败的信息)。
613
+ // 显式传值则用新值(用 ?? 让 null/undefined 都视为清空)。
614
+ step.status = status;
615
+ step.tool = tool !== undefined ? tool : null;
616
+ step.args = args !== undefined ? args : null;
617
+ step.result = result !== undefined ? result : null;
618
+ step.error = error !== undefined ? error : null;
619
+ step.note = note !== undefined ? note : null;
620
+ step.durationMs = durationMs !== undefined ? durationMs : null;
621
+ if (status === 'in_progress') {
622
+ step.startedAt = now;
623
+ }
624
+ if (['completed', 'failed', 'skipped'].includes(status)) {
625
+ step.completedAt = now;
626
+ }
627
+ step.attempts = attemptNumber;
628
+
629
+ // 重算汇总
630
+ plan.completedSteps = plan.steps.filter(s => s.status === 'completed').length;
631
+ plan.failedSteps = plan.steps.filter(s => s.status === 'failed').length;
632
+ plan.updatedAt = now;
633
+
634
+ // 自动推进:completed 时下一步自动 in_progress
635
+ if (status === 'completed') {
636
+ const nextStep = plan.steps[stepIndex + 1];
637
+ if (nextStep && nextStep.status === 'pending') {
638
+ nextStep.status = 'in_progress';
639
+ nextStep.startedAt = now;
640
+ }
641
+ }
642
+
643
+ // 追加本次尝试的日志块(保留历史)
644
+ const attemptLog = [
645
+ `### Step ${stepIndex + 1} (尝试 ${attemptNumber}) - ${now} - ${status}`,
646
+ `- 工具: ${step.tool || 'N/A'}`,
647
+ step.args ? `- 参数: ${JSON.stringify(step.args, null, 2)}` : null,
648
+ step.error ? `- 错误: ${step.error}` : null,
649
+ step.result !== null && step.result !== undefined
650
+ ? `- 结果: ${JSON.stringify(step.result, null, 2).slice(0, 800)}`
651
+ : null,
652
+ `- 耗时: ${step.durationMs ?? 'N/A'}ms`,
653
+ step.note ? `- 备注: ${step.note}` : null,
654
+ ].filter(Boolean).join('\n');
655
+
656
+ // 累加日志:每次新尝试追加(用 --- 分隔),第一次直接赋值
657
+ step.log = step.log
658
+ ? step.log + '\n\n---\n\n' + attemptLog
659
+ : attemptLog;
660
+
661
+ try {
662
+ this._store.save(plan);
663
+ } catch (err) {
664
+ return { success: false, error: `保存失败: ${err.message}` };
665
+ }
666
+ this._index.set(planId, plan);
667
+ this._invalidatePlanStatusPrompt();
668
+
669
+ const shouldAbort = this._shouldAbort(plan);
670
+ const next = plan.steps[stepIndex + 1];
671
+ return {
672
+ success: true,
673
+ stepIndex,
674
+ status: step.status,
675
+ attempts: attemptNumber,
676
+ isRetry,
677
+ progress: `${plan.completedSteps}/${plan.totalSteps}`,
678
+ nextStep: next && next.status === 'in_progress' ? stepIndex + 1 : null,
679
+ shouldAbort,
680
+ abortReason: shouldAbort ? '连续失败 >= 2 步,建议中止' : null,
681
+ };
682
+ }
683
+
684
+ async _continuePlan({ planId }) {
685
+ if (!planId) return { success: false, error: 'planId 必填' };
686
+ let plan;
687
+ try {
688
+ plan = this._loadPlan(planId);
689
+ } catch (err) {
690
+ return { success: false, error: err.message };
691
+ }
692
+ if (plan.status === 'completed') {
693
+ return { success: false, error: '计划已完成,无需 continue', plan };
694
+ }
695
+
696
+ // 找到第一个非 completed/failed/skipped 的 step
697
+ let resumableFrom = -1;
698
+ for (let i = 0; i < plan.steps.length; i++) {
699
+ if (!['completed', 'failed', 'skipped'].includes(plan.steps[i].status)) {
700
+ resumableFrom = i;
701
+ break;
702
+ }
703
+ }
704
+ if (resumableFrom === -1) {
705
+ return { success: false, error: '没有可恢复的步骤', plan };
706
+ }
707
+
708
+ // 把当前 step 标记为 in_progress(若不是)
709
+ const step = plan.steps[resumableFrom];
710
+ if (step.status === 'pending') {
711
+ step.status = 'in_progress';
712
+ step.startedAt = step.startedAt || new Date().toISOString();
713
+ }
714
+ plan.status = 'in_progress';
715
+ plan.updatedAt = new Date().toISOString();
716
+ try {
717
+ this._store.save(plan);
718
+ } catch (err) {
719
+ return { success: false, error: `保存失败: ${err.message}` };
720
+ }
721
+ this._index.set(planId, plan);
722
+ this._invalidatePlanStatusPrompt();
723
+
724
+ return {
725
+ success: true,
726
+ planId,
727
+ plan: {
728
+ planId: plan.planId,
729
+ title: plan.title,
730
+ status: plan.status,
731
+ totalSteps: plan.totalSteps,
732
+ completedSteps: plan.completedSteps,
733
+ failedSteps: plan.failedSteps,
734
+ task: plan.task,
735
+ steps: plan.steps.map(s => ({
736
+ index: s.index,
737
+ name: s.name,
738
+ description: s.description,
739
+ status: s.status,
740
+ })),
741
+ },
742
+ resumableFrom,
743
+ nextStepName: step.name,
744
+ };
745
+ }
746
+
747
+ async _completePlan({ planId, summary }) {
748
+ if (!planId) return { success: false, error: 'planId 必填' };
749
+ let plan;
750
+ try {
751
+ plan = this._loadPlan(planId);
752
+ } catch (err) {
753
+ return { success: false, error: err.message };
754
+ }
755
+ const incomplete = plan.steps.filter(
756
+ s => !['completed', 'skipped'].includes(s.status)
757
+ );
758
+ if (incomplete.length > 0) {
759
+ return {
760
+ success: false,
761
+ error: `还有 ${incomplete.length} 步未完成:${incomplete.map(s => `#${s.index + 1}`).join(', ')}`,
762
+ plan,
763
+ };
764
+ }
765
+ plan.status = 'completed';
766
+ plan.updatedAt = new Date().toISOString();
767
+ if (summary) {
768
+ // 在最后一个 completed step 的日志中附加 summary
769
+ const last = plan.steps[plan.steps.length - 1];
770
+ last.log = (last.log || '') + `\n\n## 📋 完成总结\n${summary}`;
771
+ }
772
+ try {
773
+ this._store.save(plan);
774
+ } catch (err) {
775
+ return { success: false, error: `保存失败: ${err.message}` };
776
+ }
777
+ this._index.set(planId, plan);
778
+ this._invalidatePlanStatusPrompt();
779
+ return { success: true, planId, status: 'completed' };
780
+ }
781
+
782
+ async _failPlan({ planId, reason }) {
783
+ if (!planId) return { success: false, error: 'planId 必填' };
784
+ let plan;
785
+ try {
786
+ plan = this._loadPlan(planId);
787
+ } catch (err) {
788
+ return { success: false, error: err.message };
789
+ }
790
+ plan.status = 'failed';
791
+ plan.updatedAt = new Date().toISOString();
792
+ if (reason) {
793
+ const logIdx = plan.steps.findIndex(s => s.status === 'in_progress');
794
+ const target = logIdx >= 0 ? plan.steps[logIdx] : plan.steps[plan.steps.length - 1];
795
+ target.log = (target.log || '') + `\n\n## ❌ 计划中止\n原因: ${reason}`;
796
+ }
797
+ try {
798
+ this._store.save(plan);
799
+ } catch (err) {
800
+ return { success: false, error: `保存失败: ${err.message}` };
801
+ }
802
+ this._index.set(planId, plan);
803
+ this._invalidatePlanStatusPrompt();
804
+ return { success: true, planId, status: 'failed', reason };
805
+ }
806
+
807
+ async _deletePlan({ planId }) {
808
+ if (!planId) return { success: false, error: 'planId 必填' };
809
+ const ok = this._store.delete(planId);
810
+ this._index.delete(planId);
811
+ this._invalidatePlanStatusPrompt();
812
+ return { success: ok, planId, deleted: ok };
813
+ }
814
+
815
+ // ============================================================
816
+ // 工具注册(用 this.tool.register 注入到 ToolRegistry,
817
+ // LLM 在 system prompt 中直接看到 plan_create 等顶层工具名)
818
+ // ============================================================
819
+ _registerTools() {
820
+ this.tool.register({
821
+ name: 'plan_create',
822
+ description: '创建一个执行计划:把多步任务拆解为可追踪步骤并持久化到 .foliko/plans/。返回 planId 后请按 planId 依次 plan_execute_step。',
823
+ inputSchema: z.object({
824
+ title: z.string().describe('计划标题,简短描述任务'),
825
+ task: z.string().optional().describe('原始任务描述,用于 continue 时回放上下文'),
826
+ steps: z.array(z.object({
827
+ name: z.string().describe('步骤名(如"读取配置文件")'),
828
+ description: z.string().optional().describe('步骤详细说明'),
829
+ tool: z.string().optional().describe('预计要调用的工具名(仅提示)'),
830
+ args: z.record(z.any()).optional().describe('预计参数(仅提示)'),
831
+ })).min(1).describe('至少 1 步'),
832
+ sessionId: z.string().optional().describe('关联的 sessionId(可选,自动从执行上下文取)'),
833
+ }),
834
+ execute: this._createPlan.bind(this),
835
+ });
836
+
837
+ this.tool.register({
838
+ name: 'plan_list',
839
+ description: '列出 .foliko/plans/ 下的所有计划。',
840
+ inputSchema: z.object({
841
+ status: z.enum(['in_progress', 'completed', 'failed', 'paused']).optional().describe('按状态过滤'),
842
+ sessionId: z.string().optional().describe('按 session 过滤'),
843
+ limit: z.number().int().min(1).max(100).optional().describe('最多返回几条'),
844
+ }),
845
+ execute: this._listPlans.bind(this),
846
+ });
847
+
848
+ this.tool.register({
849
+ name: 'plan_show',
850
+ description: '显示计划完整 markdown 内容(含任务、步骤、执行日志)。',
851
+ inputSchema: z.object({
852
+ planId: z.string().describe('计划 ID(plan_create 返回)'),
853
+ }),
854
+ execute: this._showPlan.bind(this),
855
+ });
856
+
857
+ this.tool.register({
858
+ name: 'plan_execute_step',
859
+ description: '记录一步执行结果:LLM 在调用某个工具完成/失败/跳过后,**必须**调用此工具持久化状态。返回 {progress, nextStep, shouldAbort}。',
860
+ inputSchema: z.object({
861
+ planId: z.string().describe('计划 ID'),
862
+ stepIndex: z.number().int().min(0).describe('步骤索引(0-based)'),
863
+ status: z.enum(['in_progress', 'completed', 'failed', 'skipped']).describe('步骤新状态'),
864
+ tool: z.string().optional().describe('实际调用的工具名'),
865
+ args: z.record(z.any()).optional().describe('实际参数'),
866
+ result: z.any().optional().describe('工具返回结果(可序列化部分)'),
867
+ error: z.string().optional().describe('失败时的错误信息'),
868
+ note: z.string().optional().describe('备注(如"重试 2 次后")'),
869
+ durationMs: z.number().int().optional().describe('本步耗时(毫秒)'),
870
+ }),
871
+ execute: this._executeStep.bind(this),
872
+ });
873
+
874
+ this.tool.register({
875
+ name: 'plan_continue',
876
+ description: '恢复一个 in_progress/paused/failed 的计划:返回 plan 详情 + 从哪一步(resumableFrom)继续。',
877
+ inputSchema: z.object({
878
+ planId: z.string().describe('计划 ID'),
879
+ }),
880
+ execute: this._continuePlan.bind(this),
881
+ });
882
+
883
+ this.tool.register({
884
+ name: 'plan_complete',
885
+ description: '把计划标记为已完成。只能所有步骤都 completed/skipped 时调用。',
886
+ inputSchema: z.object({
887
+ planId: z.string().describe('计划 ID'),
888
+ summary: z.string().optional().describe('完成总结(写入日志)'),
889
+ }),
890
+ execute: this._completePlan.bind(this),
891
+ });
892
+
893
+ this.tool.register({
894
+ name: 'plan_fail',
895
+ description: '中止并标记计划为失败。',
896
+ inputSchema: z.object({
897
+ planId: z.string().describe('计划 ID'),
898
+ reason: z.string().optional().describe('中止原因'),
899
+ }),
900
+ execute: this._failPlan.bind(this),
901
+ });
902
+
903
+ this.tool.register({
904
+ name: 'plan_delete',
905
+ description: '删除计划文件(不可恢复)。',
906
+ inputSchema: z.object({
907
+ planId: z.string().describe('计划 ID'),
908
+ }),
909
+ execute: this._deletePlan.bind(this),
910
+ });
911
+ }
912
+
913
+ // ============================================================
914
+ // 卸载清理(基类已自动管理 _registeredTools)
915
+ // ============================================================
916
+ uninstall(framework) {
917
+ this._framework = null;
918
+ this._store = null;
919
+ this._index.clear();
920
+ }
921
+ }
922
+
923
+ module.exports = PlannerPlugin;
924
+ module.exports.PlannerPlugin = PlannerPlugin;
925
+ module.exports.PlanStore = PlanStore;
926
+ module.exports.PlanSerializer = PlanSerializer;