@bolloon/bolloon-agent 0.3.23 → 0.3.25

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.
@@ -271,16 +271,18 @@ export function registerBuiltinTools(ctx) {
271
271
  // add_friend_by_id — 通过 Hyperswarm publicKey 添加 P2P 好友
272
272
  ctx.tools.set('add_friend_by_id', {
273
273
  name: 'add_friend_by_id',
274
- description: '通过 Hyperswarm P2P publicKey (64 字符 hex) 添加好友. 对方在线时会收到好友申请弹窗, 接受后自动分享 channel.',
274
+ description: '通过 Hyperswarm P2P publicKey (64 字符 hex) 添加好友. 对方在线时会收到好友申请弹窗, 接受后自动分享 channel. 强烈建议填 note 备注 (自我介绍/来源), 对方才能分辨你是谁.',
275
275
  parameters: {
276
276
  publicKey: '64 字符 hex publicKey (必填)',
277
277
  name: '可选, 给好友的备注名 (如: 同事-张磊)',
278
- message: '可选, 附加的好友申请消息'
278
+ message: '可选, 附加的好友申请消息',
279
+ note: '可选, 备注 (自我介绍/来源), 对方接受时会看到. 如 "我是[姓名]的 Bolloon agent[name], 来自[来源], 想加你为好友共享 channel 协作,技能: [技能列表]"'
279
280
  },
280
281
  execute: async (args) => {
281
282
  const publicKey = String(args.publicKey || '').trim();
282
283
  const name = String(args.name || '').trim();
283
284
  const message = String(args.message || '想加你为 P2P 好友, 共享 channel 协作').trim();
285
+ const note = String(args.note || '').trim();
284
286
  if (!publicKey || publicKey.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(publicKey)) {
285
287
  return { success: false, error: 'publicKey 必须是 64 字符 hex 格式' };
286
288
  }
@@ -289,7 +291,7 @@ export function registerBuiltinTools(ctx) {
289
291
  const res = await fetch(`http://127.0.0.1:${port}/api/friend-request`, {
290
292
  method: 'POST',
291
293
  headers: { 'Content-Type': 'application/json' },
292
- body: JSON.stringify({ targetPublicKey: publicKey, name: name || undefined, message })
294
+ body: JSON.stringify({ targetPublicKey: publicKey, name: name || undefined, message, note: note || undefined })
293
295
  });
294
296
  const data = await res.json();
295
297
  if (!res.ok) {
@@ -303,6 +305,95 @@ export function registerBuiltinTools(ctx) {
303
305
  }
304
306
  }
305
307
  });
308
+ // list_pending_friend_requests — 查看待处理的好友申请 (智能体侧处理入口)
309
+ ctx.tools.set('list_pending_friend_requests', {
310
+ name: 'list_pending_friend_requests',
311
+ description: '查看当前待处理的好友申请列表 (对方想加你为好友但还没接受). 每个申请带 fromName / note 备注 (自我介绍/来源), 便于判断是否接受. 用 accept_friend_request 接受, 或 ignore_friend_request 忽略.',
312
+ parameters: {},
313
+ execute: async () => {
314
+ try {
315
+ const port = process.env.PORT || '54188';
316
+ const res = await fetch(`http://127.0.0.1:${port}/api/friend-requests`);
317
+ const data = await res.json();
318
+ if (!res.ok)
319
+ return { success: false, error: data.error || '查询失败' };
320
+ if (data.count === 0) {
321
+ return { success: true, output: '当前没有待处理的好友申请。' };
322
+ }
323
+ const lines = data.requests.map((r, i) => `${i + 1}. ${r.fromName} (publicKey=${r.fromPublicKey.substring(0, 16)}...)\n 备注: ${r.note || r.message || '(无)'}\n requestId: ${r.requestId}`);
324
+ return { success: true, output: `待处理好友申请 ${data.count} 个:\n${lines.join('\n')}\n\n用 accept_friend_request 接受, 或 ignore_friend_request 忽略.` };
325
+ }
326
+ catch (e) {
327
+ return { success: false, error: `查询好友申请失败: ${String(e.message || e)}` };
328
+ }
329
+ }
330
+ });
331
+ // accept_friend_request — 接受一个待处理的好友申请 (智能体侧一键通过)
332
+ ctx.tools.set('accept_friend_request', {
333
+ name: 'accept_friend_request',
334
+ description: '接受一个待处理的好友申请. 对方会出现在你的 P2P 好友列表, 对方分享的 channel 自动可见. 参数 requestId 来自 list_pending_friend_requests 的返回.',
335
+ parameters: {
336
+ requestId: '待处理申请的 requestId (必填, 来自 list_pending_friend_requests)',
337
+ name: '可选, 给这个新好友的备注名 (默认用对方自称的名字)'
338
+ },
339
+ execute: async (args) => {
340
+ const requestId = String(args.requestId || '').trim();
341
+ if (!requestId) {
342
+ return { success: false, error: 'requestId 必填 (先调 list_pending_friend_requests 获取)' };
343
+ }
344
+ try {
345
+ const port = process.env.PORT || '54188';
346
+ // 先查 pending 拿到 fromPublicKey / fromName
347
+ const q = await fetch(`http://127.0.0.1:${port}/api/friend-requests`);
348
+ const qd = await q.json();
349
+ const req = (qd.requests || []).find((r) => r.requestId === requestId);
350
+ if (!req) {
351
+ return { success: false, error: `未找到 requestId=${requestId} 的申请 (可能已被处理或过期)` };
352
+ }
353
+ const acceptName = String(args.name || '').trim() || req.fromName;
354
+ const res = await fetch(`http://127.0.0.1:${port}/api/friend-accept`, {
355
+ method: 'POST',
356
+ headers: { 'Content-Type': 'application/json' },
357
+ body: JSON.stringify({ fromPublicKey: req.fromPublicKey, name: acceptName, requestId })
358
+ });
359
+ const data = await res.json();
360
+ if (!res.ok)
361
+ return { success: false, error: data.error || '接受失败' };
362
+ return { success: true, output: `✅ 已接受 ${req.fromName} 的好友申请 (备注: ${req.note || req.message || '(无)'}), 已加为好友: ${data.persistedAs || acceptName}` };
363
+ }
364
+ catch (e) {
365
+ return { success: false, error: `接受好友申请失败: ${String(e.message || e)}` };
366
+ }
367
+ }
368
+ });
369
+ // ignore_friend_request — 忽略一个待处理的好友申请
370
+ ctx.tools.set('ignore_friend_request', {
371
+ name: 'ignore_friend_request',
372
+ description: '忽略 (拒绝) 一个待处理的好友申请, 不加入好友. 参数 requestId 来自 list_pending_friend_requests 的返回.',
373
+ parameters: {
374
+ requestId: '待处理申请的 requestId (必填, 来自 list_pending_friend_requests)'
375
+ },
376
+ execute: async (args) => {
377
+ const requestId = String(args.requestId || '').trim();
378
+ if (!requestId)
379
+ return { success: false, error: 'requestId 必填' };
380
+ try {
381
+ const port = process.env.PORT || '54188';
382
+ const res = await fetch(`http://127.0.0.1:${port}/api/friend-requests/ignore`, {
383
+ method: 'POST',
384
+ headers: { 'Content-Type': 'application/json' },
385
+ body: JSON.stringify({ requestId })
386
+ });
387
+ const data = await res.json();
388
+ if (!res.ok)
389
+ return { success: false, error: data.error || '忽略失败' };
390
+ return { success: true, output: `已忽略好友申请 ${requestId.substring(0, 8)}...` };
391
+ }
392
+ catch (e) {
393
+ return { success: false, error: `忽略好友申请失败: ${String(e.message || e)}` };
394
+ }
395
+ }
396
+ });
306
397
  // delegate_to_engine — 把编码任务委派给本机已安装的其他 AI 编码智能体 CLI
307
398
  // (codex / claude-code / opencode / openclaw / hermes). 它们必须已安装且可达 PATH.
308
399
  // 实验 API 引擎 (experiment:xxx) 是供应商不是 CLI, 不支持委派, 工具会提示改用 import.
@@ -642,15 +733,12 @@ export function registerBuiltinTools(ctx) {
642
733
  // 通用文件读取 (M4)
643
734
  ctx.tools.set('read_file', {
644
735
  name: 'read_file',
645
- description: '读取任意文件内容 (相对 cwd). shell-guard 路径白名单保护.',
736
+ description: '读取任意文件内容 (相对 cwd). 只读操作, 无白名单限制.',
646
737
  parameters: { path: '相对路径 (必填)', startLine: '起始行号 (可选, 默认 0)', maxLines: '最大行数 (可选, 默认 500)' },
647
738
  execute: async (args) => {
648
739
  const relPath = String(args.path || '').trim();
649
740
  if (!relPath)
650
741
  return { success: false, error: 'path 必填' };
651
- const pathResult = checkWritePath(relPath);
652
- if (!pathResult.allowed)
653
- return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
654
742
  try {
655
743
  const absPath = path.resolve(ctx.cwd, relPath);
656
744
  const content = fsSync.readFileSync(absPath, 'utf-8');
@@ -740,16 +828,13 @@ export function registerBuiltinTools(ctx) {
740
828
  });
741
829
  ctx.tools.set('grep_files', {
742
830
  name: 'grep_files',
743
- description: '在文件中搜索匹配 pattern 的行. 类似 grep -rn. 路径必须在白名单.',
831
+ description: '在文件中搜索匹配 pattern 的行. 类似 grep -rn. 只读操作, 无白名单限制.',
744
832
  parameters: { pattern: '搜索 pattern (必填, 字符串, 不是正则)', path: '搜索目录 (可选, 默认 .)', filePattern: '文件名 glob (可选)' },
745
833
  execute: async (args) => {
746
834
  const pattern = String(args.pattern || '').trim();
747
835
  if (!pattern)
748
836
  return { success: false, error: 'pattern 必填' };
749
837
  const searchPath = String(args.path || '.').trim();
750
- const pathResult = checkWritePath(searchPath);
751
- if (!pathResult.allowed)
752
- return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
753
838
  try {
754
839
  const { execFile } = await import('child_process');
755
840
  const { promisify } = await import('util');
@@ -768,16 +853,13 @@ export function registerBuiltinTools(ctx) {
768
853
  });
769
854
  ctx.tools.set('glob_files', {
770
855
  name: 'glob_files',
771
- description: '用 glob pattern 找文件. 例如 "**/*.test.ts".',
856
+ description: '用 glob pattern 找文件. 例如 "**/*.test.ts". 只读操作, 无白名单限制.',
772
857
  parameters: { pattern: 'glob pattern (必填, e.g. "src/**/*.ts")' },
773
858
  execute: async (args) => {
774
859
  const pattern = String(args.pattern || '').trim();
775
860
  if (!pattern)
776
861
  return { success: false, error: 'pattern 必填' };
777
862
  try {
778
- const pathResult = checkWritePath(pattern.replace(/\*\*.*$/, '').replace(/\/\*.*$/, '') || '.');
779
- if (!pathResult.allowed && pattern !== '**/*' && pattern !== '*')
780
- return { success: false, error: `路径被护栏拒: ${pathResult.reason}` };
781
863
  const { execFile } = await import('child_process');
782
864
  const { promisify } = await import('util');
783
865
  const pExecFile = promisify(execFile);
@@ -1111,6 +1193,246 @@ export function registerBuiltinTools(ctx) {
1111
1193
  }
1112
1194
  }
1113
1195
  });
1196
+ // ============================================================
1197
+ // skill 写工具 (2026-08-02) — 让 agent 从成功经验沉淀技能
1198
+ // create_skill / update_skill / list_skill_candidates / promote_skill
1199
+ // 实现: skill-writer.ts (写 ~/.bolloon/skills/<name>/SKILL.md)
1200
+ // ============================================================
1201
+ ctx.tools.set('create_skill', {
1202
+ name: 'create_skill',
1203
+ description: '创建/覆盖一个 skill (SKILL.md). 当你学会一个可复用的做事方法 (成功流程/命令组合/踩坑教训) 时调用, 沉淀成技能供以后复用. 写 ~/.bolloon/skills/<name>/SKILL.md. 只读技能请用 update_skill 追加.',
1204
+ parameters: {
1205
+ name: 'skill 名 (必填, 小写字母数字连字符, e.g. "p2p-debug")',
1206
+ description: '一句话描述这个 skill 什么时候用 (必填)',
1207
+ body: 'Markdown 正文 (必填): 步骤 / 命令 / 注意事项',
1208
+ scope: '可选: user (默认, ~/.bolloon/skills) 或 project (.bolloon/skills)',
1209
+ triggers: '可选: 触发条件数组 (JSON 字符串数组, e.g. ["p2p", "连接失败"])',
1210
+ },
1211
+ execute: async (args) => {
1212
+ try {
1213
+ const { createSkill } = await import('./skill-writer.js');
1214
+ const name = String(args.name || '').trim();
1215
+ if (!name)
1216
+ return { success: false, error: 'name 必填' };
1217
+ const body = String(args.body || '').trim();
1218
+ if (!body)
1219
+ return { success: false, error: 'body 必填' };
1220
+ let triggers;
1221
+ try {
1222
+ const t = JSON.parse(String(args.triggers || '[]'));
1223
+ if (Array.isArray(t))
1224
+ triggers = t.map(String);
1225
+ }
1226
+ catch { /* triggers 解析失败忽略 */ }
1227
+ const r = await createSkill(name, String(args.description || ''), body, {
1228
+ scope: args.scope === 'project' ? 'project' : 'user',
1229
+ triggers,
1230
+ });
1231
+ return r.ok
1232
+ ? { success: true, output: `✅ skill '${name}' 已写入 ${r.path}` }
1233
+ : { success: false, error: r.error };
1234
+ }
1235
+ catch (e) {
1236
+ return { success: false, error: `create_skill 失败: ${String(e).slice(0, 200)}` };
1237
+ }
1238
+ }
1239
+ });
1240
+ ctx.tools.set('update_skill', {
1241
+ name: 'update_skill',
1242
+ description: '更新已有 skill: 追加新经验 (append_body) 或整体替换 (body), 或改描述/触发条件. skill 不存在时用 create_skill.',
1243
+ parameters: {
1244
+ name: 'skill 名 (必填)',
1245
+ append_body: '追加到正文尾部的增量经验 (可选)',
1246
+ body: '整体替换正文 (可选, 与 append_body 二选一)',
1247
+ description: '新描述 (可选)',
1248
+ triggers: '新触发条件数组 JSON (可选)',
1249
+ },
1250
+ execute: async (args) => {
1251
+ try {
1252
+ const { updateSkill } = await import('./skill-writer.js');
1253
+ const name = String(args.name || '').trim();
1254
+ if (!name)
1255
+ return { success: false, error: 'name 必填' };
1256
+ let triggers;
1257
+ try {
1258
+ const t = JSON.parse(String(args.triggers || '[]'));
1259
+ if (Array.isArray(t))
1260
+ triggers = t.map(String);
1261
+ }
1262
+ catch { /* 忽略 */ }
1263
+ const r = await updateSkill(name, {
1264
+ description: args.description ? String(args.description) : undefined,
1265
+ appendBody: args.append_body ? String(args.append_body) : undefined,
1266
+ body: args.body ? String(args.body) : undefined,
1267
+ triggers,
1268
+ });
1269
+ return r.ok
1270
+ ? { success: true, output: `✅ skill '${name}' 已更新 ${r.path}` }
1271
+ : { success: false, error: r.error };
1272
+ }
1273
+ catch (e) {
1274
+ return { success: false, error: `update_skill 失败: ${String(e).slice(0, 200)}` };
1275
+ }
1276
+ }
1277
+ });
1278
+ ctx.tools.set('list_skill_candidates', {
1279
+ name: 'list_skill_candidates',
1280
+ description: '查看待沉淀的 skill 候选 (后台任务从成功的工具调用模式生成的候选). 返回候选列表, 可用 promote_skill 转正.',
1281
+ parameters: {},
1282
+ execute: async () => {
1283
+ try {
1284
+ const { listSkillCandidates } = await import('./skill-writer.js');
1285
+ const cands = await listSkillCandidates();
1286
+ if (cands.length === 0)
1287
+ return { success: true, output: '暂无待沉淀的 skill 候选.' };
1288
+ const lines = cands.map(c => `- ${c.name}: ${c.description} [来源 ${c.source}]`).join('\n');
1289
+ return { success: true, output: `📋 ${cands.length} 个 skill 候选:\n${lines}` };
1290
+ }
1291
+ catch (e) {
1292
+ return { success: false, error: `list_skill_candidates 失败: ${String(e).slice(0, 200)}` };
1293
+ }
1294
+ }
1295
+ });
1296
+ ctx.tools.set('promote_skill', {
1297
+ name: 'promote_skill',
1298
+ description: '把 skill 候选转正为正式 skill. 转正后候选文件自动清理.',
1299
+ parameters: { name: '候选名 (必填, 见 list_skill_candidates)' },
1300
+ execute: async (args) => {
1301
+ try {
1302
+ const { promoteCandidate } = await import('./skill-writer.js');
1303
+ const name = String(args.name || '').trim();
1304
+ if (!name)
1305
+ return { success: false, error: 'name 必填' };
1306
+ const r = await promoteCandidate(name);
1307
+ return r.ok
1308
+ ? { success: true, output: `✅ 候选 '${name}' 已转正 → ${r.path}` }
1309
+ : { success: false, error: r.error };
1310
+ }
1311
+ catch (e) {
1312
+ return { success: false, error: `promote_skill 失败: ${String(e).slice(0, 200)}` };
1313
+ }
1314
+ }
1315
+ });
1316
+ // ============================================================
1317
+ // plan / todo / review 工具 (2026-08-02) — 显式执行闭环
1318
+ // create_plan / update_plan / review_plan / list_plans
1319
+ // 实现: plan-store.ts (~/.bolloon/plans/<planId>.json)
1320
+ // ============================================================
1321
+ ctx.tools.set('create_plan', {
1322
+ name: 'create_plan',
1323
+ description: '执行复杂任务前先显式列计划: 拆成 3-8 个可执行步骤. 之后每完成一步调 update_plan 勾选, 全部完成调 review_plan 总结. 落盘 ~/.bolloon/plans/.',
1324
+ parameters: {
1325
+ goal: '一句话目标 (必填)',
1326
+ steps: '步骤数组 JSON (必填, e.g. ["读需求", "写代码", "测试"])',
1327
+ },
1328
+ execute: async (args) => {
1329
+ try {
1330
+ const { createPlan, planToContext } = await import('./plan-store.js');
1331
+ const goal = String(args.goal || '').trim();
1332
+ let steps = [];
1333
+ try {
1334
+ const s = JSON.parse(String(args.steps || '[]'));
1335
+ if (Array.isArray(s))
1336
+ steps = s.map(String);
1337
+ }
1338
+ catch { /* steps 解析失败 */ }
1339
+ const r = await createPlan({ goal, steps, createdBy: 'agent', originChannel: ctx.channelId || '' });
1340
+ if (!r.ok || !r.plan)
1341
+ return { success: false, error: r.error };
1342
+ return { success: true, output: `✅ 计划已创建 ${r.plan.planId}\n\n${planToContext(r.plan)}` };
1343
+ }
1344
+ catch (e) {
1345
+ return { success: false, error: `create_plan 失败: ${String(e).slice(0, 200)}` };
1346
+ }
1347
+ }
1348
+ });
1349
+ ctx.tools.set('update_plan', {
1350
+ name: 'update_plan',
1351
+ description: '更新计划: 勾选某步完成/阻塞 (step_id + status), 或追加新步骤 (append_steps), 或整体结束 (finish=true). 执行中每完成一步必调.',
1352
+ parameters: {
1353
+ plan_id: '计划 ID (必填, create_plan 返回)',
1354
+ step_id: '步骤 ID (可选, e.g. step_1)',
1355
+ status: '步骤新状态: done / blocked (可选)',
1356
+ note: '完成/阻塞备注 (可选)',
1357
+ append_steps: '追加步骤数组 JSON (可选)',
1358
+ finish: 'true 表示整个计划结束 (可选)',
1359
+ },
1360
+ execute: async (args) => {
1361
+ try {
1362
+ const { updatePlan, planToContext } = await import('./plan-store.js');
1363
+ const planId = String(args.plan_id || '').trim();
1364
+ if (!planId)
1365
+ return { success: false, error: 'plan_id 必填' };
1366
+ let appendSteps;
1367
+ try {
1368
+ const s = JSON.parse(String(args.append_steps || '[]'));
1369
+ if (Array.isArray(s))
1370
+ appendSteps = s.map(String);
1371
+ }
1372
+ catch { /* 忽略 */ }
1373
+ const r = await updatePlan(planId, {
1374
+ stepId: args.step_id ? String(args.step_id) : undefined,
1375
+ status: (args.status === 'done' || args.status === 'blocked') ? args.status : undefined,
1376
+ note: args.note ? String(args.note) : undefined,
1377
+ appendSteps,
1378
+ finish: String(args.finish) === 'true',
1379
+ });
1380
+ if (!r.ok || !r.plan)
1381
+ return { success: false, error: r.error };
1382
+ return { success: true, output: `✅ 计划已更新\n\n${planToContext(r.plan)}` };
1383
+ }
1384
+ catch (e) {
1385
+ return { success: false, error: `update_plan 失败: ${String(e).slice(0, 200)}` };
1386
+ }
1387
+ }
1388
+ });
1389
+ ctx.tools.set('review_plan', {
1390
+ name: 'review_plan',
1391
+ description: '计划全部执行完后审查: 总结完成度 + 产出结论. 完成后 plan 标记 done.',
1392
+ parameters: {
1393
+ plan_id: '计划 ID (必填)',
1394
+ summary: '审查总结 (必填, 说明完成了什么/卡在哪/下一步)',
1395
+ },
1396
+ execute: async (args) => {
1397
+ try {
1398
+ const { reviewPlan } = await import('./plan-store.js');
1399
+ const planId = String(args.plan_id || '').trim();
1400
+ if (!planId)
1401
+ return { success: false, error: 'plan_id 必填' };
1402
+ const summary = String(args.summary || '').trim();
1403
+ if (!summary)
1404
+ return { success: false, error: 'summary 必填' };
1405
+ const r = await reviewPlan(planId, summary);
1406
+ if (!r.ok || !r.plan)
1407
+ return { success: false, error: r.error };
1408
+ return {
1409
+ success: true,
1410
+ output: `✅ 计划审查完成: ${r.plan.review.completedSteps}/${r.plan.review.totalSteps} 步\n📝 ${summary}`,
1411
+ };
1412
+ }
1413
+ catch (e) {
1414
+ return { success: false, error: `review_plan 失败: ${String(e).slice(0, 200)}` };
1415
+ }
1416
+ }
1417
+ });
1418
+ ctx.tools.set('list_plans', {
1419
+ name: 'list_plans',
1420
+ description: '列出所有进行中的计划 (active). 用于恢复上下文/继续未完成的计划.',
1421
+ parameters: {},
1422
+ execute: async () => {
1423
+ try {
1424
+ const { listActivePlans, planToContext } = await import('./plan-store.js');
1425
+ const plans = await listActivePlans();
1426
+ if (plans.length === 0)
1427
+ return { success: true, output: '暂无进行中的计划.' };
1428
+ const text = plans.map(p => planToContext(p)).join('\n\n');
1429
+ return { success: true, output: `📋 ${plans.length} 个进行中的计划:\n\n${text}` };
1430
+ }
1431
+ catch (e) {
1432
+ return { success: false, error: `list_plans 失败: ${String(e).slice(0, 200)}` };
1433
+ }
1434
+ }
1435
+ });
1114
1436
  }
1115
1437
  /**
1116
1438
  * 注册 Wallet + Polymarket + Safe 工具 (基于 constraint-runtime/src/tools/).
@@ -0,0 +1,167 @@
1
+ /**
2
+ * plan-store.ts — 轻量 plan / todo / review 原语 (2026-08-02)
3
+ *
4
+ * 补齐 agent 执行闭环里的显式环节:
5
+ * - plan: 执行前显式列出步骤 (LLM 写计划)
6
+ * - todo: 步骤级状态 (pending / done / blocked), 执行中勾选
7
+ * - review: 执行后审查 (对照计划看完成度, 产出结论)
8
+ *
9
+ * 设计原则 (减法):
10
+ * - 不建数据库. 落盘 = ~/.bolloon/plans/<planId>.json (单文件, append 不适用, 直接重写)
11
+ * - 不引入新调度. 工具入口在 pi-sdk-tools.ts 注册, 复用现有 LLM 调度.
12
+ * - 任何 IO 失败静默返回 error 对象, 不阻塞主对话.
13
+ * - plan 上下文注入: create_plan 后会把计划摘要写回 messageHistory (由工具返回, LLM 自己看到),
14
+ * 同时 planId 存 session, 后续 update_plan / review_plan 按 planId 操作.
15
+ */
16
+ import * as fs from 'fs/promises';
17
+ import * as os from 'os';
18
+ import * as path from 'path';
19
+ import * as crypto from 'crypto';
20
+ // ============================================================
21
+ // 落盘路径
22
+ // ============================================================
23
+ export function getPlansDir(home = os.homedir()) {
24
+ return path.join(home, '.bolloon', 'plans');
25
+ }
26
+ export function getPlanPath(planId, home = os.homedir()) {
27
+ return path.join(getPlansDir(home), `${planId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
28
+ }
29
+ export function sanitizePlanId(id) {
30
+ return id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
31
+ }
32
+ export async function createPlan(input, home) {
33
+ const goal = String(input.goal || '').trim();
34
+ const steps = Array.isArray(input.steps) ? input.steps.map(s => String(s).trim()).filter(Boolean) : [];
35
+ if (!goal)
36
+ return { ok: false, error: 'goal 必填' };
37
+ if (steps.length === 0)
38
+ return { ok: false, error: 'steps 至少 1 步' };
39
+ const now = new Date().toISOString();
40
+ const planId = `plan_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
41
+ const plan = {
42
+ planId,
43
+ goal,
44
+ createdBy: input.createdBy || 'agent',
45
+ createdAt: now,
46
+ originChannel: input.originChannel || '',
47
+ steps: steps.map((s, i) => ({
48
+ id: `step_${i + 1}`,
49
+ description: s,
50
+ status: 'pending',
51
+ })),
52
+ status: 'active',
53
+ updatedAt: now,
54
+ };
55
+ try {
56
+ const dir = getPlansDir(home);
57
+ await fs.mkdir(dir, { recursive: true });
58
+ await fs.writeFile(getPlanPath(planId, home), JSON.stringify(plan, null, 2), 'utf-8');
59
+ return { ok: true, plan };
60
+ }
61
+ catch (e) {
62
+ return { ok: false, error: `写入失败: ${e?.message || String(e)}` };
63
+ }
64
+ }
65
+ export async function loadPlan(planId, home) {
66
+ try {
67
+ const raw = await fs.readFile(getPlanPath(planId, home), 'utf-8');
68
+ return JSON.parse(raw);
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ async function savePlan(plan, home) {
75
+ plan.updatedAt = new Date().toISOString();
76
+ await fs.writeFile(getPlanPath(plan.planId, home), JSON.stringify(plan, null, 2), 'utf-8');
77
+ }
78
+ export async function updatePlan(planId, input, home) {
79
+ const plan = await loadPlan(planId, home);
80
+ if (!plan)
81
+ return { ok: false, error: `plan '${planId}' 不存在` };
82
+ if (input.stepId && input.status) {
83
+ const step = plan.steps.find(s => s.id === input.stepId);
84
+ if (!step)
85
+ return { ok: false, error: `步骤 '${input.stepId}' 不存在` };
86
+ step.status = input.status;
87
+ if (input.note)
88
+ step.note = input.note;
89
+ step.updatedAt = new Date().toISOString();
90
+ }
91
+ if (Array.isArray(input.appendSteps) && input.appendSteps.length > 0) {
92
+ for (const s of input.appendSteps) {
93
+ const desc = String(s).trim();
94
+ if (!desc)
95
+ continue;
96
+ plan.steps.push({ id: `step_${plan.steps.length + 1}`, description: desc, status: 'pending' });
97
+ }
98
+ }
99
+ if (input.finish) {
100
+ plan.status = 'done';
101
+ // 未完成的步骤标 blocked (收尾语义)
102
+ for (const s of plan.steps) {
103
+ if (s.status === 'pending')
104
+ s.status = 'blocked';
105
+ }
106
+ }
107
+ try {
108
+ await savePlan(plan, home);
109
+ return { ok: true, plan };
110
+ }
111
+ catch (e) {
112
+ return { ok: false, error: `保存失败: ${e?.message || String(e)}` };
113
+ }
114
+ }
115
+ export async function reviewPlan(planId, summary, home) {
116
+ const plan = await loadPlan(planId, home);
117
+ if (!plan)
118
+ return { ok: false, error: `plan '${planId}' 不存在` };
119
+ const completedSteps = plan.steps.filter(s => s.status === 'done').length;
120
+ plan.review = {
121
+ completedSteps,
122
+ totalSteps: plan.steps.length,
123
+ summary: String(summary || '').trim() || `完成 ${completedSteps}/${plan.steps.length} 步`,
124
+ reviewedAt: new Date().toISOString(),
125
+ };
126
+ plan.status = 'done';
127
+ try {
128
+ await savePlan(plan, home);
129
+ return { ok: true, plan };
130
+ }
131
+ catch (e) {
132
+ return { ok: false, error: `保存失败: ${e?.message || String(e)}` };
133
+ }
134
+ }
135
+ export async function listActivePlans(home) {
136
+ const dir = getPlansDir(home);
137
+ let entries;
138
+ try {
139
+ entries = await fs.readdir(dir);
140
+ }
141
+ catch {
142
+ return [];
143
+ }
144
+ const out = [];
145
+ for (const f of entries.filter(f => f.endsWith('.json')).sort().slice(-50)) {
146
+ try {
147
+ const raw = await fs.readFile(path.join(dir, f), 'utf-8');
148
+ const p = JSON.parse(raw);
149
+ if (p.status === 'active')
150
+ out.push(p);
151
+ }
152
+ catch { /* 坏文件跳过 */ }
153
+ }
154
+ return out;
155
+ }
156
+ /** 把 plan 渲染成注入 context 的文本 (LLM 看到计划 + 进度) */
157
+ export function planToContext(plan) {
158
+ const lines = [`📋 当前计划: ${plan.goal} (${plan.planId})`];
159
+ for (const s of plan.steps) {
160
+ const mark = s.status === 'done' ? '✅' : s.status === 'blocked' ? '⛔' : '⬜';
161
+ lines.push(` ${mark} ${s.id}: ${s.description}${s.note ? ` — ${s.note}` : ''}`);
162
+ }
163
+ if (plan.review) {
164
+ lines.push(` 📝 审查: ${plan.review.summary} (${plan.review.completedSteps}/${plan.review.totalSteps} 步)`);
165
+ }
166
+ return lines.join('\n');
167
+ }