@bolloon/bolloon-agent 0.4.25 → 0.4.27

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 (35) hide show
  1. package/dist/agents/execution-supervisor.js +87 -2
  2. package/dist/agents/external-events.js +162 -0
  3. package/dist/agents/goal-criteria.js +124 -0
  4. package/dist/agents/goal-store.js +79 -4
  5. package/dist/agents/p2p-info.js +175 -0
  6. package/dist/agents/pi-sdk.js +39 -0
  7. package/dist/agents/run-store.js +15 -0
  8. package/dist/agents/skill-readiness.js +133 -0
  9. package/dist/agents/skill-supervisor-link.js +70 -0
  10. package/dist/agents/skills-manager.js +282 -0
  11. package/dist/agents/trace-export.js +125 -0
  12. package/dist/agents/write-staging.js +12 -4
  13. package/dist/agents/x402/goal-run-bridge.js +105 -0
  14. package/dist/agents/x402/milestone-settlement.js +150 -0
  15. package/dist/agents/x402/paid-info-store.js +66 -12
  16. package/dist/agents/x402/payment-recovery.js +290 -0
  17. package/dist/agents/x402/resource-contract.js +473 -0
  18. package/dist/agents/x402/settlement-state.js +378 -0
  19. package/dist/agents/x402/trade.js +257 -0
  20. package/dist/agents/x402/transaction-protocol.js +99 -0
  21. package/dist/agents/x402/transaction-store.js +350 -0
  22. package/dist/cli/setup-wizard.js +96 -127
  23. package/dist/cli-entry.js +74 -0
  24. package/dist/electron/first-run.js +33 -2
  25. package/dist/electron-build/electron/first-run.js +35 -2
  26. package/dist/electron-build/electron/first-run.js.map +1 -1
  27. package/dist/index.js +224 -4
  28. package/dist/llm/config-store.js +35 -4
  29. package/dist/network/agent-network.js +10 -0
  30. package/dist/network/goal-event-bridge.js +57 -0
  31. package/dist/setup/onboard.js +549 -0
  32. package/dist/setup/setup-store.js +592 -0
  33. package/dist/web/routes-x402-info.js +1 -0
  34. package/dist/web/server.js +330 -1
  35. package/package.json +1 -1
@@ -19,6 +19,10 @@ import * as os from 'os';
19
19
  import * as path from 'path';
20
20
  import * as fsp from 'fs/promises';
21
21
  import * as crypto from 'crypto';
22
+ // 2026-09-16 (2-G.3): 事务型导入复用底层真实实现 (别名避免与上面重名)
23
+ import { parseSkillBundle as parseBundleLoose, parseSkillRef as parseSkillRefLoose, fetchSkillBundle as fetchBundleLoose } from './skill-share.js';
24
+ import { sanitizeSkillName as sanitizeNameLoose, getUserSkillsDir as userSkillsDirLoose, getProjectSkillsDir as projectSkillsDirLoose } from './skill-writer.js';
25
+ import { parseSkillFile as parseSkillFileLoose } from './skill-loader.js';
22
26
  import { parseSkillFile, defaultSkillPaths } from './skill-loader.js';
23
27
  import { getUserSkillsDir } from './skill-writer.js';
24
28
  import { collectSkillBundle, parseSkillBundle, parseSkillRef, fetchSkillBundle, installSkillBundle, } from './skill-share.js';
@@ -319,6 +323,8 @@ export class SkillsManager {
319
323
  if (rec.issues.length)
320
324
  return { ok: false, reason: `技能不合格, 不能启用: ${rec.issues.join('; ')}` };
321
325
  const skill = await this.patchRegistry(name, { status: 'enabled' }, opts.home);
326
+ // 2-G.4: 技能又能用了 → 重评被它拦住的 Goal (回 active, 等 Supervisor 继续)
327
+ await this.onRegistryChanged({ home: opts.home ?? this.home, action: 'enable', name }).catch(() => null);
322
328
  return { ok: true, skill: skill || undefined };
323
329
  }
324
330
  async disable(name, opts = {}) {
@@ -326,6 +332,14 @@ export class SkillsManager {
326
332
  if (!rec)
327
333
  return { ok: false, reason: `没有这个技能: ${name}` };
328
334
  const skill = await this.patchRegistry(name, { status: 'disabled' }, opts.home);
335
+ // 2-G.4: 记录"谁依赖它" (不打断当前 Run; 下一次 Run 前由 2-G.2 门禁拦)
336
+ try {
337
+ const { markDependentsOfSkill } = await import('./skill-supervisor-link.js');
338
+ const affected = await markDependentsOfSkill(name, { home: opts.home ?? this.home, reason: '技能被禁用' });
339
+ if (affected.length)
340
+ console.warn(`[skills] ${name} 被禁用; 依赖它的 Goal: ${affected.join(', ')} (下一次 Run 前会门禁)`);
341
+ }
342
+ catch { /* 联动失败不影响禁用结果 */ }
329
343
  return { ok: true, skill: skill || undefined };
330
344
  }
331
345
  /** 人工批准 (信任等级 verified); 2-G.3 的 import 事务会要求它才算"可用" */
@@ -349,6 +363,14 @@ export class SkillsManager {
349
363
  };
350
364
  await writeRegistry(reg, opts.home ?? this.home);
351
365
  this.cache = null;
366
+ // 2-G.4: 隔离 = 不再可信 → 依赖它的 Goal 下一次 Run 前必然门禁失败
367
+ try {
368
+ const { markDependentsOfSkill } = await import('./skill-supervisor-link.js');
369
+ const affected = await markDependentsOfSkill(name, { home: opts.home ?? this.home, reason: `技能被隔离: ${reason}` });
370
+ if (affected.length)
371
+ console.warn(`[skills] ${name} 被隔离; 依赖它的 Goal: ${affected.join(', ')}`);
372
+ }
373
+ catch { /* 联动失败不影响隔离结果 */ }
352
374
  return { ok: true, skill: (await this.inspect(name, opts)) || undefined };
353
375
  }
354
376
  /** 结构校验: 重算问题清单并把状态落成 invalid (或从 invalid 恢复) */
@@ -372,6 +394,14 @@ export class SkillsManager {
372
394
  * 事务化 (临时目录 + 原子移动 + hash 校验) 属 2-G.3。
373
395
  */
374
396
  async import(ref, opts = {}) {
397
+ // 2026-09-16 (2-G.3): 统一走事务版 (暂存 → 原子替换 → 校验 → 回滚), 失败不污染当前技能环境
398
+ const tx = await this.importTransactional({ ref }, opts);
399
+ if (tx.ok)
400
+ await this.onRegistryChanged({ home: opts.home ?? this.home, action: 'import', name: tx.name }).catch(() => null);
401
+ return { ok: tx.ok, name: tx.name, version: tx.version, error: tx.error, skill: tx.skill };
402
+ }
403
+ /** @deprecated 保留旧签名以兼容; 内部已走事务版 */
404
+ async importLegacy(ref, opts = {}) {
375
405
  const cid = parseSkillRef(ref);
376
406
  if (!cid)
377
407
  return { ok: false, error: `无法识别的技能引用 (要 bolloon://skill/<cid> / ipfs://<cid> / 裸 CID): ${ref.slice(0, 60)}` };
@@ -395,6 +425,14 @@ export class SkillsManager {
395
425
  }
396
426
  /** 从已解析的技能包装入 (本地文件/已取到的包) */
397
427
  async install(bundleJson, opts = {}) {
428
+ // 2026-09-16 (2-G.3): 同样走事务 (install 的默认来源保持 'imported')
429
+ const tx = await this.importTransactional({ bundleJson }, { ...opts, source: opts.source || 'imported' });
430
+ if (tx.ok)
431
+ await this.onRegistryChanged({ home: opts.home ?? this.home, action: 'install', name: tx.name }).catch(() => null);
432
+ return { ok: tx.ok, name: tx.name, error: tx.error, skill: tx.skill };
433
+ }
434
+ /** @deprecated 旧的"直接写正式目录"实现 (保留对照, 不再被 import/install 调用) */
435
+ async installLegacy(bundleJson, opts = {}) {
398
436
  const parsed = parseSkillBundle(bundleJson);
399
437
  if (!parsed.ok || !parsed.bundle)
400
438
  return { ok: false, error: parsed.error || '包格式非法' };
@@ -412,6 +450,203 @@ export class SkillsManager {
412
450
  }
413
451
  return { ok: true, name: parsed.bundle.name, skill: (await this.inspect(parsed.bundle.name, { home: h })) || undefined };
414
452
  }
453
+ // ── 事务型导入 (2-G.3, 2026-09-16) ───────────────────────────────────────
454
+ /**
455
+ * 把 import/install 变成**事务**:
456
+ * 读来源 → 预备校验 (名字/路径穿越/SKILL.md frontmatter/版本门) → 写暂存目录
457
+ * → 原子替换 (旧目录先改名保留) → 校验落地结果 → 更新 registry
458
+ * 任何一步失败: 正式目录不变 · registry 不变 · 暂存清理 · 失败原因可查询 (importHistory)。
459
+ * 中途被 SIGKILL: 只可能留下 `.<name>-staging-*` 暂存或 `.<name>.bak-*` 备份 →
460
+ * recoverInterruptedImports() 会清理暂存并把备份恢复回正式位置。
461
+ */
462
+ async importTransactional(input, opts = {}) {
463
+ const h = opts.home ?? this.home;
464
+ const started = Date.now();
465
+ const fail = async (step, error, name) => {
466
+ await this.recordImportFailure({ step, error, name, ref: input.ref, at: new Date().toISOString() });
467
+ return { ok: false, error, step, name };
468
+ };
469
+ // 0) 先清上一次中断留下的暂存/备份, 避免互相干扰
470
+ await this.recoverInterruptedImports({ home: h }).catch(() => null);
471
+ // 1) 取包 (ref 走 IPFS; bundleJson 直接用)
472
+ let bundle = null;
473
+ let sourceRef = 'local-bundle';
474
+ if (input.bundleJson) {
475
+ const parsed = parseBundleLoose(input.bundleJson);
476
+ if (!parsed?.ok || !parsed.bundle)
477
+ return await fail('parse', parsed?.error || '包格式非法 (不是 bolloon-skill-bundle/1 JSON)');
478
+ bundle = parsed.bundle;
479
+ sourceRef = 'local-bundle';
480
+ }
481
+ else if (input.ref) {
482
+ const cid = parseSkillRefLoose(input.ref);
483
+ if (!cid)
484
+ return await fail('parse', `无法识别的技能引用: ${String(input.ref).slice(0, 60)}`);
485
+ const fetched = await fetchBundleLoose(cid);
486
+ if (!fetched?.ok || !fetched.bundle)
487
+ return await fail('fetch', fetched?.error || '取包失败');
488
+ bundle = fetched.bundle;
489
+ sourceRef = cid;
490
+ }
491
+ else {
492
+ return await fail('parse', '必须给 ref 或 bundleJson');
493
+ }
494
+ // 2) 预备校验 (全部发生在动正式目录之前)
495
+ if (!bundle || typeof bundle !== 'object')
496
+ return await fail('parse', '包内容不是对象');
497
+ const name = sanitizeNameLoose(bundle.name);
498
+ if (!name)
499
+ return await fail('validate', '技能名非法 (清洗后为空)');
500
+ if (!bundle.files || typeof bundle.files !== 'object')
501
+ return await fail('validate', '包内没有 files', name);
502
+ const skillMd = bundle.files['SKILL.md'];
503
+ if (typeof skillMd !== 'string' || !skillMd.trim())
504
+ return await fail('validate', '包内缺少 SKILL.md', name);
505
+ const fm = parseFrontmatterLoose(skillMd);
506
+ if (!fm)
507
+ return await fail('validate', 'SKILL.md 缺少合法 frontmatter (--- 包裹)', name);
508
+ if (!fm.name)
509
+ return await fail('validate', 'SKILL.md frontmatter 缺 name', name);
510
+ for (const rel of Object.keys(bundle.files)) {
511
+ const norm = path.normalize(rel).replace(/^([/\\])+/, '');
512
+ if (norm.startsWith('..') || path.isAbsolute(norm))
513
+ return await fail('validate', `技能包含非法路径 (路径穿越): ${rel}`, name);
514
+ }
515
+ const base = opts.scope === 'project' ? projectSkillsDirLoose(opts.cwd ?? this.cwd) : userSkillsDirLoose(h);
516
+ const targetDir = path.join(base, name);
517
+ const existingMeta = await parseSkillFileLoose(path.join(targetDir, 'SKILL.md'));
518
+ const existingVersion = String(existingMeta?.frontmatter?.version ?? '0.0.0');
519
+ const incomingVersion = String(bundle.version || fm.version || '0.0.0');
520
+ if (existingMeta && cmpVersionLoose(existingVersion, incomingVersion) >= 0 && !opts.force) {
521
+ return await fail('version', `本地已有 ${name}@${existingVersion}, 来的是 ${incomingVersion} (不更新; force=true 可强制)`, name);
522
+ }
523
+ // 3) 写暂存目录 (同名 . 前缀 → discover 不会当成技能)
524
+ const staging = path.join(base, `.${name}-staging-${started}`);
525
+ await fsp.mkdir(staging, { recursive: true });
526
+ try {
527
+ for (const [rel, content] of Object.entries(bundle.files)) {
528
+ const norm = path.normalize(rel).replace(/^([/\\])+/, '');
529
+ const abs = path.join(staging, norm);
530
+ await fsp.mkdir(path.dirname(abs), { recursive: true });
531
+ await fsp.writeFile(abs, String(content), 'utf-8');
532
+ }
533
+ }
534
+ catch (err) {
535
+ await fsp.rm(staging, { recursive: true, force: true }).catch(() => { });
536
+ return await fail('stage', `写暂存失败: ${String(err?.message || err).slice(0, 160)}`, name);
537
+ }
538
+ // 4) 原子替换: 旧目录改名保留 (备份), 暂存改名就位
539
+ let backup;
540
+ try {
541
+ const exists = await fsp.stat(targetDir).then(() => true).catch(() => false);
542
+ if (exists) {
543
+ backup = path.join(base, `.${name}.bak-${started}`);
544
+ await fsp.rename(targetDir, backup);
545
+ }
546
+ await fsp.rename(staging, targetDir);
547
+ }
548
+ catch (err) {
549
+ // 回滚: 备份放回, 暂存清掉
550
+ await fsp.rm(staging, { recursive: true, force: true }).catch(() => { });
551
+ if (backup)
552
+ await fsp.rename(backup, targetDir).catch(() => { });
553
+ return await fail('swap', `原子替换失败 (已回滚): ${String(err?.message || err).slice(0, 160)}`, name);
554
+ }
555
+ // 5) 校验落地结果 (读回来再确认一次)
556
+ // 回滚条件只看**结构性失败** (SKILL.md 解析不出来); 内容质量类提示 (如正文过少) 只记警告 ——
557
+ // 否则用户自己的"简洁技能"永远装不回来 (导出→安装 自洽被打破)。
558
+ this.cache = null;
559
+ const parsedBack = await parseSkillFileLoose(path.join(targetDir, 'SKILL.md')).catch(() => null);
560
+ const after = await this.inspect(name, { home: h }).catch(() => null);
561
+ if (!parsedBack) {
562
+ await fsp.rm(targetDir, { recursive: true, force: true }).catch(() => { });
563
+ if (backup)
564
+ await fsp.rename(backup, targetDir).catch(() => { });
565
+ return await fail('verify', `落地校验失败 (已回滚): SKILL.md 装完解析不出来`, name);
566
+ }
567
+ if (after && (after.issues || []).length) {
568
+ await appendImportHistory(h, { at: new Date().toISOString(), ok: true, kind: 'warning', name, step: 'verify', warning: (after.issues || []).slice(0, 3).join('; ') }).catch(() => { });
569
+ }
570
+ // 6) registry 更新 (失败不致命: 下次 discover 会重建)
571
+ try {
572
+ await this.patchRegistry(name, {
573
+ status: 'installed', source: opts.source || 'shared', sourceRef,
574
+ trust: 'unverified', contentHash: after?.contentHash || '', version: after?.version || incomingVersion,
575
+ }, h);
576
+ }
577
+ catch (err) {
578
+ await this.recordImportFailure({ step: 'registry', error: `registry 更新失败: ${String(err?.message || err).slice(0, 140)}`, name, ref: input.ref, at: new Date().toISOString() });
579
+ }
580
+ await this.recordImportSuccess({ name, version: after?.version || incomingVersion, ref: input.ref, backup, at: new Date().toISOString() });
581
+ return { ok: true, name, version: after?.version || incomingVersion, backup, skill: after || undefined };
582
+ }
583
+ // ── 与 Supervisor 的长期联动 (2-G.4, 2026-09-16) ────────────────────────
584
+ /**
585
+ * 技能注册表发生变化 (导入成功 / 启用 / 禁用 / 隔离 / 漂移) → 重新评估**被技能拦住的 Goal**:
586
+ * · 技能又能用了 → 重新冻结快照 + Goal 回 active (等 Supervisor 下一轮继续)
587
+ * · 技能被禁用/隔离/漂移 → 交给 2-G.2 的执行前门禁拦 (这里只把状态标清, 不抢执行权)
588
+ */
589
+ async onRegistryChanged(opts = { action: 'unknown' }) {
590
+ const h = opts.home ?? this.home;
591
+ try {
592
+ const { reconsiderSkillBlockedGoals } = await import('./skill-supervisor-link.js');
593
+ const res = await reconsiderSkillBlockedGoals({ home: h, action: opts.action, name: opts.name });
594
+ return res;
595
+ }
596
+ catch (err) {
597
+ console.warn(`[skills] 联动重评失败 (不影响导入结果): ${String(err?.message || err).slice(0, 140)}`);
598
+ return { rechecked: 0, resumed: [], stillBlocked: [] };
599
+ }
600
+ }
601
+ /** 清理中断残留: 暂存目录删掉; 备份且正式目录缺失 → 恢复备份 (不丢已装技能) */
602
+ async recoverInterruptedImports(opts = {}) {
603
+ const h = opts.home ?? this.home;
604
+ const removedStaging = [];
605
+ const restored = [];
606
+ for (const base of [userSkillsDirLoose(h), projectSkillsDirLoose(opts.cwd ?? this.cwd)]) {
607
+ let entries = [];
608
+ try {
609
+ entries = await fsp.readdir(base);
610
+ }
611
+ catch {
612
+ continue;
613
+ }
614
+ for (const e of entries) {
615
+ if (/^\..*-staging-\d+$/.test(e)) {
616
+ await fsp.rm(path.join(base, e), { recursive: true, force: true }).catch(() => { });
617
+ removedStaging.push(e);
618
+ continue;
619
+ }
620
+ const m = /^\.(.+)\.bak-(\d+)$/.exec(e);
621
+ if (m) {
622
+ const live = path.join(base, m[1]);
623
+ const liveExists = await fsp.stat(live).then(() => true).catch(() => false);
624
+ if (!liveExists) {
625
+ await fsp.rename(path.join(base, e), live).catch(() => { });
626
+ restored.push(`${m[1]} (from ${e})`);
627
+ }
628
+ }
629
+ }
630
+ }
631
+ return { removedStaging, restored };
632
+ }
633
+ /** 导入历史/失败原因 (CLI/Web 可查) */
634
+ async importHistory(opts = {}) {
635
+ const h = opts.home ?? this.home;
636
+ try {
637
+ const raw = JSON.parse(await fsp.readFile(importHistoryPath(h), 'utf8'));
638
+ return (Array.isArray(raw) ? raw : []).slice(-1 * (opts.limit || 20));
639
+ }
640
+ catch {
641
+ return [];
642
+ }
643
+ }
644
+ async recordImportSuccess(rec) {
645
+ await appendImportHistory(this.home, { ...rec, ok: true, kind: 'success' }).catch(() => { });
646
+ }
647
+ async recordImportFailure(rec) {
648
+ await appendImportHistory(this.home, { ...rec, ok: false, kind: 'failure' }).catch(() => { });
649
+ }
415
650
  // ── 视图 ─────────────────────────────────────────────────────────────────
416
651
  /** 给 CLI / Web / agent 的同一份列表 (字段一致, 顺序一致: name 升序) */
417
652
  async view(opts = {}) {
@@ -433,3 +668,50 @@ export function resetSkillsManagerForTest() {
433
668
  export function formatSkillLine(s) {
434
669
  return `${s.name.padEnd(28)} ${String(s.status).padEnd(11)} ${String(s.source).padEnd(9)} ${String(s.trust).padEnd(10)} v${s.version.padEnd(8)} ${s.contentHash.slice(0, 10)}${s.issues.length ? ` ⚠ ${s.issues.length} 个问题` : ''}`;
435
670
  }
671
+ // ── 事务型导入的小工具 (2-G.3) ───────────────────────────────────────────────
672
+ function importHistoryPath(home) {
673
+ return path.join(home, '.bolloon', 'skill-imports.json');
674
+ }
675
+ async function appendImportHistory(home, rec) {
676
+ const p = importHistoryPath(home);
677
+ await fsp.mkdir(path.dirname(p), { recursive: true });
678
+ let arr = [];
679
+ try {
680
+ const raw = JSON.parse(await fsp.readFile(p, 'utf8'));
681
+ if (Array.isArray(raw))
682
+ arr = raw;
683
+ }
684
+ catch { /* 首次 */ }
685
+ arr.push(rec);
686
+ await fsp.writeFile(p, JSON.stringify(arr.slice(-50), null, 2), 'utf8');
687
+ }
688
+ /** 极简 frontmatter 解析 (只要 name/version, 用于预备校验) */
689
+ export function parseFrontmatterLoose(text) {
690
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''));
691
+ if (!m)
692
+ return null;
693
+ const out = {};
694
+ for (const line of m[1].split(/\r?\n/)) {
695
+ const kv = /^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.+)$/.exec(line.trim());
696
+ if (!kv)
697
+ continue;
698
+ const key = kv[1].toLowerCase();
699
+ const val = kv[2].replace(/^["']|["']$/g, '').trim();
700
+ if (key === 'name')
701
+ out.name = val;
702
+ if (key === 'version')
703
+ out.version = val;
704
+ }
705
+ return out;
706
+ }
707
+ /** 版本比较 (semver 数字段; 非法段当 0) */
708
+ export function cmpVersionLoose(a, b) {
709
+ const pa = String(a).split('.').map((x) => Number(String(x).replace(/[^0-9]/g, '')) || 0);
710
+ const pb = String(b).split('.').map((x) => Number(String(x).replace(/[^0-9]/g, '')) || 0);
711
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
712
+ const d = (pa[i] || 0) - (pb[i] || 0);
713
+ if (d !== 0)
714
+ return d > 0 ? 1 : -1;
715
+ }
716
+ return 0;
717
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * trace-export.ts — 智能体**工具执行轨迹**的导出/解析 (2026-09-18)
3
+ *
4
+ * 定位: Run 的 steps[] 就是这台机器上真实发生过的工具调用事实
5
+ * (n / ts / tool / argsDigest / ok / ms / summary / error)。
6
+ * 这一层只做一件事: 把这份事实变成**可读、可交换、可再解析**的文本/JSON,
7
+ * 好让 App、CLI、Web 与外部工具(如小红书小工具)看到同一份轨迹。
8
+ *
9
+ * 文本格式 (小工具侧解析器依赖, 改前先看 minitools/agent-card/src/assets/app.js 的 parseTraceText):
10
+ * # Bolloon 执行轨迹 · run <runId> (<N> 步)
11
+ * 1. [ok] 2026-09-18T08:46:15.093Z shell_exec — ls -la /tmp (12ms)
12
+ * 2. [fail] 2026-09-18T08:46:17.001Z write_file — EACCES: permission denied (3ms)
13
+ * 规则: "<n>. [ok|fail] <无空格时间戳> <工具名> — <细节>" —— 时间戳与工具名不能含空格。
14
+ */
15
+ export const TRACE_HEADER_PREFIX = '# Bolloon 执行轨迹';
16
+ function stepDetail(s) {
17
+ const base = String(s.summary || s.error || (s.ok ? '完成' : '失败')).replace(/\s+/g, ' ').trim();
18
+ const ms = s.ms ? ` (${s.ms}ms)` : '';
19
+ const args = s.argsDigest ? ` [args:${String(s.argsDigest).slice(0, 8)}]` : '';
20
+ return `${base}${args}${ms}`.slice(0, 400);
21
+ }
22
+ /** Run → 轨迹 JSON (机器可读; 所有消费方都应基于这个结构, 而不是各自解析文本) */
23
+ export function runToTraceJson(run) {
24
+ const steps = (run.steps || []).map((s) => ({
25
+ n: s.n,
26
+ ts: s.ts,
27
+ tool: s.tool,
28
+ ok: !!s.ok,
29
+ ms: s.ms,
30
+ summary: s.summary,
31
+ error: s.error,
32
+ argsDigest: s.argsDigest,
33
+ }));
34
+ const ok = steps.filter((s) => s.ok).length;
35
+ const byTool = new Map();
36
+ for (const s of steps) {
37
+ const cur = byTool.get(s.tool) || { tool: s.tool, count: 0, fail: 0 };
38
+ cur.count += 1;
39
+ if (!s.ok)
40
+ cur.fail += 1;
41
+ byTool.set(s.tool, cur);
42
+ }
43
+ return {
44
+ schema: 'bolloon-agent-trace/1',
45
+ runId: run.runId,
46
+ goalId: run.goalId,
47
+ surface: run.surface,
48
+ status: run.status,
49
+ goal: run.goal,
50
+ startedAt: run.startedAt || (run.steps || [])[0]?.ts,
51
+ updatedAt: run.updatedAt,
52
+ steps,
53
+ counts: { total: steps.length, ok, fail: steps.length - ok, totalMs: steps.reduce((a, s) => a + (s.ms || 0), 0) },
54
+ tools: Array.from(byTool.values()).sort((a, b) => b.count - a.count),
55
+ evidence: run.evidence,
56
+ error: run.error,
57
+ };
58
+ }
59
+ /** Run → 轨迹文本 (人可读 + 可被小工具解析; 供 CLI / 复制粘贴) */
60
+ export function runToTraceText(run, opts = {}) {
61
+ const steps = (run.steps || []).slice(opts.limit ? Math.max(0, (run.steps || []).length - opts.limit) : 0);
62
+ const lines = [];
63
+ lines.push(`${TRACE_HEADER_PREFIX} · run ${run.runId} (${(run.steps || []).length} 步)`);
64
+ if (run.goal)
65
+ lines.push(`# 目标: ${String(run.goal).replace(/\s+/g, ' ').slice(0, 160)}`);
66
+ lines.push(`# 状态: ${run.status}${run.goalId ? ` · goal ${run.goalId}` : ''}${run.surface ? ` · surface ${run.surface}` : ''}`);
67
+ if (!steps.length)
68
+ lines.push('# (这次运行没有工具步骤)');
69
+ for (const s of steps) {
70
+ lines.push(`${s.n}. [${s.ok ? 'ok' : 'fail'}] ${s.ts} ${s.tool} — ${stepDetail(s)}`);
71
+ }
72
+ if (run.error)
73
+ lines.push(`# 结束原因: ${String(run.error).replace(/\s+/g, ' ').slice(0, 200)}`);
74
+ return lines.join('\n');
75
+ }
76
+ /**
77
+ * 解析轨迹文本 (容错: 只认 "<n>. [ok|fail] <ts> <tool> — <detail>" 这一行格式)。
78
+ * 用于: 把别处(小工具/别的机器)的轨迹读回来, 以及本模块的自校验往返。
79
+ */
80
+ export function parseTraceText(text) {
81
+ const out = { steps: [] };
82
+ const lines = String(text || '').split('\n');
83
+ const re = /^\s*(\d+)\.\s*\[(ok|fail)\]\s*(\S+)\s+(\S+)\s*(?:—\s*)?([\s\S]*)$/;
84
+ for (const raw of lines) {
85
+ const line = raw.replace(/\r$/, '');
86
+ if (line.startsWith(TRACE_HEADER_PREFIX)) {
87
+ out.header = line;
88
+ continue;
89
+ }
90
+ if (line.startsWith('# 目标:')) {
91
+ out.goal = line.slice(5).trim();
92
+ continue;
93
+ }
94
+ if (line.startsWith('# 状态:')) {
95
+ out.status = line.slice(5).trim();
96
+ continue;
97
+ }
98
+ if (line.startsWith('# 结束原因:')) {
99
+ out.errorLine = line.slice(7).trim();
100
+ continue;
101
+ }
102
+ if (!line.trim() || line.startsWith('#'))
103
+ continue;
104
+ const m = re.exec(line);
105
+ if (!m)
106
+ continue;
107
+ const detail = String(m[5] || '');
108
+ const msMatch = /\s*\((\d+)ms\)\s*$/.exec(detail);
109
+ out.steps.push({
110
+ n: Number(m[1]),
111
+ ok: m[2] === 'ok',
112
+ ts: m[3],
113
+ tool: m[4],
114
+ ms: msMatch ? Number(msMatch[1]) : undefined,
115
+ summary: msMatch ? detail.slice(0, msMatch.index).trim() : detail.trim(),
116
+ });
117
+ }
118
+ return out;
119
+ }
120
+ /** 一行摘要 (CLI 列表/日志用) */
121
+ export function summarizeTrace(run) {
122
+ const j = runToTraceJson(run);
123
+ const tools = j.tools.slice(0, 3).map((t) => `${t.tool}×${t.count}`).join(' ');
124
+ return `${j.counts.total} 步 (✓${j.counts.ok}/✗${j.counts.fail}, ${j.counts.totalMs}ms)${tools ? ` · ${tools}` : ''}`;
125
+ }
@@ -18,9 +18,16 @@ const home = () => process.env.HOME || os.homedir() || '/tmp';
18
18
  export function writeLogDir(homeDir = home()) {
19
19
  return path.join(homeDir, '.bolloon', 'write-log');
20
20
  }
21
- /** 生成唯一 stage id */
21
+ /**
22
+ * 生成唯一 stage id。
23
+ * ★ 2026-09-18: 加**进程内单调序号** —— 原来只有 `Date.now()-随机`, 同一毫秒内的两次写入
24
+ * 顺序由随机后缀决定, `listStagedWrites` 的"最新在前"就不成立了 (真跑: 同毫秒 a.txt/b.txt 偶发反序,
25
+ * 让 write-staging 单测在全量跑时随机变红)。序号按 3 位 36 进制, 与时间戳一起保证字典序 == 发生顺序。
26
+ */
27
+ let stageSeq = 0;
22
28
  function genId() {
23
- return `${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
29
+ const seq = (stageSeq++ % 46656).toString(36).padStart(3, '0');
30
+ return `${Date.now()}-${seq}-${Math.random().toString(36).substring(2, 8)}`;
24
31
  }
25
32
  /**
26
33
  * 写前暂存: 记录一次写操作 (准备阶段). 返回记录; 失败静默返回 null.
@@ -54,13 +61,14 @@ export async function listStagedWrites(homeDir = home()) {
54
61
  return [];
55
62
  }
56
63
  const out = [];
57
- for (const f of files.filter(f => f.endsWith('.json')).sort().reverse()) {
64
+ for (const f of files.filter(f => f.endsWith('.json'))) {
58
65
  try {
59
66
  out.push(JSON.parse(await fs.readFile(path.join(dir, f), 'utf-8')));
60
67
  }
61
68
  catch { /* 坏文件跳过 */ }
62
69
  }
63
- return out;
70
+ // 最新在前: 先按 createdAt, 同一毫秒再按 id (id 内含单调序号) —— 确定性, 不看 readdir 顺序
71
+ return out.sort((a, b) => (b.createdAt - a.createdAt) || String(b.id).localeCompare(String(a.id)));
64
72
  }
65
73
  /** 撤销最近一次写 (若文件内容仍等于 afterContent → 恢复 beforeContent). 返回是否撤销. */
66
74
  export async function undoLastWrite(homeDir = home()) {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * goal-run-bridge.ts — 交易证据接入 Run / Goal (Phase 3, 2026-09-16)
3
+ *
4
+ * 支付不能只是工具副作用: 一次 Run 必须能回答 —— 为什么付款、买了什么、花了多少、
5
+ * 是否真结算、拿到什么、资源是否验证通过、结果对 Goal 有没有帮助。
6
+ *
7
+ * 事件映射 (与 leo 的规格一致):
8
+ * discovered → transaction.discovered · quoted → transaction.quoted · policy_denied → transaction.policy_denied
9
+ * paying → transaction.paying · settled → transaction.settled · delivered → transaction.delivered
10
+ * verified → transaction.verified · delivery_failed/verification_failed → 同名
11
+ *
12
+ * Goal 侧只在 **交易 verified + 资源执行成功 + 命中判据** 时累计成功证据;
13
+ * 仅付款成功或仅拿到内容 **不能**满足 Goal 判据。
14
+ */
15
+ import { recordStep, addRunEvidence } from '../run-store.js';
16
+ import { addEvidence as addGoalEvidenceViaStore } from '../goal-store.js';
17
+ import { aggregateMilestones, milestoneGoalEligibility } from './milestone-settlement.js';
18
+ export function bridgeEventFor(status) {
19
+ switch (status) {
20
+ case 'discovered': return 'transaction.discovered';
21
+ case 'quoted': return 'transaction.quoted';
22
+ case 'policy_denied': return 'transaction.policy_denied';
23
+ case 'paying':
24
+ case 'payment_required': return 'transaction.paying';
25
+ case 'settled': return 'transaction.settled';
26
+ case 'delivered': return 'transaction.delivered';
27
+ case 'verified': return 'transaction.verified';
28
+ case 'delivery_failed': return 'transaction.delivery_failed';
29
+ case 'verification_failed': return 'transaction.verification_failed';
30
+ default: return 'transaction.quoted';
31
+ }
32
+ }
33
+ /** 一次交易的证据行 (写进 Run evidence / Goal evidence 的同一组字段) */
34
+ export function transactionEvidenceLines(rec) {
35
+ return [
36
+ `transactionId=${rec.transactionId}`,
37
+ `itemId=${rec.itemId}`,
38
+ `paymentMode=${rec.paymentMode}`,
39
+ `chainSettled=${rec.chainSettled}`,
40
+ `settlementFact=${rec.settlementFact || '(legacy-未记)'}`, // 两层状态: 钱到底动没动, 审计一眼可见
41
+ `protocolVerified=${rec.protocolVerified === true}`,
42
+ `txHash=${rec.txHash || '(none)'}`,
43
+ `receiptHash=${rec.receiptHash || '(none)'}`,
44
+ `contentHash=${rec.contentHash || '(none)'}`,
45
+ `verificationTrust=${rec.verificationTrust || 'unverified'}`,
46
+ `transactionStatus=${rec.status}`,
47
+ ...(rec.responsibility ? [`responsibility=${rec.responsibility.type}(${rec.responsibility.reason})`] : []),
48
+ ...(rec.milestones?.length ? (() => { const a = aggregateMilestones(rec.milestones); return [`milestones=${a.verified}/${a.total}`, `milestoneSettlement=${a.settlementFact}`]; })() : []),
49
+ ...(rec.dispute ? [`dispute=opened(${rec.dispute.reason})`, `disputeResolved=${rec.dispute.resolution ? rec.dispute.resolution.decision : 'no'}`] : []),
50
+ ...(rec.execution ? [`executionOk=${rec.execution.ok === true} schemaOk=${rec.execution.schemaOk === true}`] : []),
51
+ ];
52
+ }
53
+ /**
54
+ * 把一笔交易写进 Run (step + evidence) 与 Goal (仅在满足条件时)。
55
+ * @param opts.goalCriteriaHit 资源执行结果是否命中 Goal 判据 (由调用方判定, 默认 false)
56
+ * @param opts.executionOk 资源是否被实际执行且符合契约
57
+ */
58
+ export async function bridgeTransactionToRunGoal(rec, opts = {}) {
59
+ const out = { runId: opts.runId, goalId: opts.goalId, stepWritten: false, evidenceWritten: false, goalEvidenceWritten: false };
60
+ const lines = transactionEvidenceLines(rec);
61
+ const ok = rec.status === 'verified' || rec.status === 'delivered';
62
+ if (opts.runId) {
63
+ try {
64
+ const step = {
65
+ tool: 'x402_transaction',
66
+ ok,
67
+ summary: `${bridgeEventFor(rec.status)} · ${opts.summary || rec.itemId} (${rec.amount || rec.price || '?'} ${rec.currency || ''} via ${rec.paymentMode} · 结算 ${rec.settlementFact || '?'})`,
68
+ error: ok ? undefined : (rec.failureReason || rec.status),
69
+ args: { itemId: rec.itemId, amount: rec.amount, currency: rec.currency, network: rec.network, requestId: rec.requestId },
70
+ };
71
+ await recordStep(opts.runId, step);
72
+ out.stepWritten = true;
73
+ await addRunEvidence(opts.runId, lines);
74
+ out.evidenceWritten = true;
75
+ }
76
+ catch { /* 记账失败不改变交易事实, 但上面会让 out.* 保持 false (调用方可见) */ }
77
+ }
78
+ // Goal 侧: 只有 verified + 执行成功 + 命中判据 才计入成功证据
79
+ if (opts.goalId) {
80
+ // 纵深防御 + Phase 4 门槛: 链上结算 + 全部里程碑完成 + 无争议 + 执行成功 + 命中判据
81
+ // (partially_settled 一律不算 —— leo: 不要让 partially_settled 直接进 Goal 成功证据)
82
+ const elig = milestoneGoalEligibility(rec, { executionOk: opts.executionOk, goalCriteriaHit: opts.goalCriteriaHit });
83
+ const eligible = elig.eligible;
84
+ try {
85
+ if (eligible) {
86
+ await addGoalEvidenceViaStore(opts.goalId, [
87
+ `付费资源已执行并命中判据: ${lines.join(' ')}`,
88
+ ]);
89
+ out.goalEvidenceWritten = true;
90
+ }
91
+ else if (rec.status === 'verified' || (rec.milestones?.length || 0) > 0) {
92
+ await addGoalEvidenceViaStore(opts.goalId, [
93
+ `付费资源未达成功证据门槛 (${elig.reason}): ${lines.join(' ')}`,
94
+ ]);
95
+ }
96
+ else {
97
+ await addGoalEvidenceViaStore(opts.goalId, [
98
+ `交易未成立 (${rec.status}): ${lines.join(' ')}`,
99
+ ]);
100
+ }
101
+ }
102
+ catch { /* 同上 */ }
103
+ }
104
+ return out;
105
+ }