@bolloon/bolloon-agent 0.4.31 → 0.4.32

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 (40) hide show
  1. package/dist/agents/chain/chain-config.js +504 -0
  2. package/dist/agents/chain/chain-index-query.js +135 -0
  3. package/dist/agents/chain/chain-indexer.js +1045 -0
  4. package/dist/agents/chain/chain-settlement.js +284 -0
  5. package/dist/agents/chain/chain-state-store.js +245 -0
  6. package/dist/agents/chain/chain-wallet.js +198 -0
  7. package/dist/agents/chain/escrow-client.js +433 -0
  8. package/dist/agents/chain/index.js +20 -0
  9. package/dist/agents/chain/onchain-trade.js +691 -0
  10. package/dist/agents/network-pulse.js +464 -2
  11. package/dist/agents/task/task-onchain-runner.js +284 -0
  12. package/dist/agents/x402/paid-info-store.js +75 -6
  13. package/dist/cli/commands/chain.js +926 -0
  14. package/dist/cli/commands/index.js +10 -2
  15. package/dist/cli/mcp/server.js +4 -1
  16. package/dist/cli/mcp/tools.js +348 -5
  17. package/dist/cli/protocol-envelope.js +12 -0
  18. package/dist/cli-entry.js +5 -0
  19. package/dist/electron-build/agents/chain/chain-config.js +556 -0
  20. package/dist/electron-build/agents/chain/chain-config.js.map +1 -0
  21. package/dist/electron-build/agents/chain/chain-index-query.js +145 -0
  22. package/dist/electron-build/agents/chain/chain-index-query.js.map +1 -0
  23. package/dist/electron-build/agents/chain/chain-indexer.js +1095 -0
  24. package/dist/electron-build/agents/chain/chain-indexer.js.map +1 -0
  25. package/dist/electron-build/agents/chain/chain-settlement.js +291 -0
  26. package/dist/electron-build/agents/chain/chain-settlement.js.map +1 -0
  27. package/dist/electron-build/agents/chain/escrow-client.js +443 -0
  28. package/dist/electron-build/agents/chain/escrow-client.js.map +1 -0
  29. package/dist/electron-build/agents/network-pulse.js +478 -3
  30. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  31. package/dist/electron-build/agents/x402/paid-info-store.js +76 -6
  32. package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
  33. package/dist/web/chain-explorer.js +617 -0
  34. package/dist/web/explorer.html +163 -0
  35. package/dist/web/index.html +5 -0
  36. package/dist/web/routes-chain-index.js +108 -0
  37. package/dist/web/routes-onchain-trade.js +99 -0
  38. package/dist/web/server.js +13 -0
  39. package/package.json +2 -1
  40. package/scripts/build-web.ts +20 -1
@@ -11,6 +11,10 @@
11
11
  * · 快照过期 → `stale` (不许伪装实时); 观察层不可用 → `unavailable`
12
12
  * · 类别小于隐私阈值 → 合并进 other
13
13
  * · 事件数 / 时间窗 / capability 数都有上限
14
+ * · **冻结形状 `confirmed_activity`** (2026-09-22): 公开页面要能列出「哪个任务 · 什么状态 ·
15
+ * 哪个块 · 多少确认」, 而不是只有数字 —— 数据源优先 P5 链上索引, 不可用时退回脉冲事件并
16
+ * 用 `confirmed_activity_source` 标注; 标识一律 sha256 短写 (前 8 位), 绝不落 taskKey /
17
+ * taskId / txHash 原文; 不够确认门槛的行只报 observed, 不冒充 confirmed。
14
18
  *
15
19
  * 借鉴 (只借产品与工程思想, 不借 Go/Postgres/API 项目):
16
20
  * EigenFlux 的 "注册总量 / 当前活跃 / 最近出现" 时间窗统计、服务端生成匿名活动文本、
@@ -50,7 +54,7 @@ var __importStar = (this && this.__importStar) || (function () {
50
54
  };
51
55
  })();
52
56
  Object.defineProperty(exports, "__esModule", { value: true });
53
- exports.FORBIDDEN_PUBLIC_KEYS = exports.MAX_AGENT_SITES = exports.pulseDir = exports.NETWORK_EVENT_TYPES = exports.PULSE_LIMITS = void 0;
57
+ exports.FORBIDDEN_PUBLIC_KEYS = exports.CHAIN_LABELS = exports.PUBLIC_MAINNET_CHAIN_ID = exports.PUBLIC_TESTNET_CHAIN_ID = exports.PULSE_EVENT_ACTIVITY = exports.CHAIN_EVENT_ACTIVITY = exports.CONFIRMED_ACTIVITY_LIMIT = exports.MAX_AGENT_SITES = exports.pulseDir = exports.NETWORK_EVENT_TYPES = exports.PULSE_LIMITS = void 0;
54
58
  exports.nodeDigest = nodeDigest;
55
59
  exports.capabilityGroup = capabilityGroup;
56
60
  exports.bucketOf = bucketOf;
@@ -59,6 +63,19 @@ exports.renderActivityText = renderActivityText;
59
63
  exports.normalizeIpns = normalizeIpns;
60
64
  exports.readAgentSites = readAgentSites;
61
65
  exports.emitTradePulse = emitTradePulse;
66
+ exports.anonShortRef = anonShortRef;
67
+ exports.isoSeconds = isoSeconds;
68
+ exports.normalizeActivityGates = normalizeActivityGates;
69
+ exports.finalityFromConfirmations = finalityFromConfirmations;
70
+ exports.buildConfirmedActivityFromIndex = buildConfirmedActivityFromIndex;
71
+ exports.buildConfirmedActivityFromEvents = buildConfirmedActivityFromEvents;
72
+ exports.confirmedActivityFromEvents = confirmedActivityFromEvents;
73
+ exports.resolveConfirmedActivity = resolveConfirmedActivity;
74
+ exports.chainLabelOf = chainLabelOf;
75
+ exports.isPublicChainId = isPublicChainId;
76
+ exports.summarizeActivityRows = summarizeActivityRows;
77
+ exports.buildChainIdScope = buildChainIdScope;
78
+ exports.snapshotConsistencyIssues = snapshotConsistencyIssues;
62
79
  exports.computeSnapshot = computeSnapshot;
63
80
  exports.getNetworkPulse = getNetworkPulse;
64
81
  exports.snapshotStatus = snapshotStatus;
@@ -71,6 +88,8 @@ const fs = __importStar(require("fs"));
71
88
  const os = __importStar(require("os"));
72
89
  const path = __importStar(require("path"));
73
90
  const crypto = __importStar(require("crypto"));
91
+ // 确认数门槛来自 chain-config (单一实现; 默认 confirmed=1 / finalized=12) —— 只借常量, 不借 RPC
92
+ const chain_config_js_1 = require("./chain/chain-config.js");
74
93
  // ── 常量 (上限 + 时间边界) ─────────────────────────────────────────────────
75
94
  exports.PULSE_LIMITS = {
76
95
  /** 最多留多少条事件 (超出丢最旧) */
@@ -89,6 +108,8 @@ exports.PULSE_LIMITS = {
89
108
  snapshotTtlMs: 30 * 1000,
90
109
  /** 活动流最多几条 */
91
110
  maxActivity: 8,
111
+ /** confirmed_activity (冻结形状的真实活动行) 上限 */
112
+ maxConfirmedActivity: 25,
92
113
  };
93
114
  /** 事件类型白名单 —— 只允许这些进统计 (其余一律丢弃) */
94
115
  exports.NETWORK_EVENT_TYPES = [
@@ -290,10 +311,404 @@ async function emitTradePulse(before, after, h) {
290
311
  /* 统计失败绝不影响交易主路径 */
291
312
  }
292
313
  }
314
+ // ── 冻结形状: confirmed_activity (真实智能体任务 / 链上活动行) ────────────────
315
+ //
316
+ // 公开网页要能列出「哪个任务 · 什么状态 · 哪个块 · 多少确认」, 而不是只有数字。这块就是那批行。
317
+ // 形状**冻结** (字段名与取值域不许改), 内容一律**匿名短写**:
318
+ // · task = `sha256:` + sha256(域标签|taskKey) 的前 8 位十六进制 —— 绝不落 taskKey / taskId 原文
319
+ // · tx = `sha256:` + sha256(域标签|txHash) 的前 8 位十六进制 —— 绝不落 txHash 原文
320
+ // · 不出现 DID / peerId / IP / 完整钱包地址 / 任务正文 —— `assertNoPrivateFields` 兜底
321
+ // 数据源优先级: ① P5 链上索引 (真链上事实) → ② 索引不可用 → 退回脉冲事件, 并在快照里用
322
+ // `confirmed_activity_source` 如实标注 (chain-index / pulse-events / none)。
323
+ // 确认数口径走 chain-config 门槛 (默认 confirmed=1 / finalized=12): 够不着门槛的行只报
324
+ // `observed`, **绝不冒充** confirmed/finalized。
325
+ /** 冻结形状: 活动行上限 */
326
+ exports.CONFIRMED_ACTIVITY_LIMIT = exports.PULSE_LIMITS.maxConfirmedActivity;
327
+ /**
328
+ * 链上 escrow 事件名 → (kind, state) 的**唯一**映射表 (改这里才对, 别在别处写 switch)。
329
+ *
330
+ * kind 只能取冻结枚举的 5 个值, 链上有 6 类事件 → 按「任务生命周期 / 托管资金路径」归并:
331
+ * · EscrowCreatedV2 委托创建 + 资金入托管 → task_created / active
332
+ * · ProofSubmittedV2 交付物验真摘要落链 (= 该笔交付完成) → task_completed / active (托管仍 ACTIVE, 等结算)
333
+ * · ReleasedV2 结算出金 → trade_settled / released
334
+ * · RefundedV2 托管退款 → trade_settled / refunded
335
+ * · ExpiredV2 托管到期 → trade_settled / expired
336
+ * · DisputedV2 资金冻结进入争议 (托管资金路径) → trade_settled / disputed
337
+ * 真信号在 `state` (kind 只是粗桶); 事件名不在表里 → 该条**不成行** (不猜)。
338
+ */
339
+ exports.CHAIN_EVENT_ACTIVITY = {
340
+ EscrowCreatedV2: { kind: 'task_created', state: 'active' },
341
+ ProofSubmittedV2: { kind: 'task_completed', state: 'active' },
342
+ ReleasedV2: { kind: 'trade_settled', state: 'released' },
343
+ RefundedV2: { kind: 'trade_settled', state: 'refunded' },
344
+ ExpiredV2: { kind: 'trade_settled', state: 'expired' },
345
+ DisputedV2: { kind: 'trade_settled', state: 'disputed' },
346
+ };
347
+ /** 脉冲事件 → 冻结 kind 的映射 (索引不可用时的降级路径; 没有 taskProof 的事件不成行) */
348
+ exports.PULSE_EVENT_ACTIVITY = {
349
+ task_posted: 'task_created',
350
+ task_accepted: 'task_accepted',
351
+ task_completed: 'task_completed',
352
+ trade_settled: 'trade_settled',
353
+ trade_verified: 'trade_verified',
354
+ };
355
+ /** 标识 → `sha256:<前 8 位十六进制>`。域标签隔离用途: 同一原值稳定 (可 join), 不同用途不串, 不可逆。 */
356
+ function anonShortRef(value, domain = 'id') {
357
+ return `sha256:${nodeDigest(`${domain}|${String(value)}`).slice(0, 8)}`;
358
+ }
359
+ /** ISO8601 UTC 秒级 (`2026-09-22T05:31:00Z`) —— 冻结形状里 `at` 的格式。非法时间 → null */
360
+ function isoSeconds(at) {
361
+ const n = Number(at);
362
+ if (!Number.isFinite(n) || n <= 0)
363
+ return null;
364
+ try {
365
+ return new Date(n).toISOString().replace(/\.\d{3}Z$/, 'Z');
366
+ }
367
+ catch {
368
+ return null;
369
+ }
370
+ }
371
+ /** 确认门槛归一化: 缺/非法 → chain-config 默认 (1/12); finalized 不许低于 confirmed */
372
+ function normalizeActivityGates(g) {
373
+ const c = Number(g?.confirmed);
374
+ const f = Number(g?.finalized);
375
+ const confirmed = Number.isInteger(c) && c > 0 ? c : chain_config_js_1.DEFAULT_CONFIRMATIONS.confirmed;
376
+ const finalized = Number.isInteger(f) && f >= confirmed ? f : Math.max(confirmed, chain_config_js_1.DEFAULT_CONFIRMATIONS.finalized);
377
+ return { confirmed, finalized };
378
+ }
379
+ /**
380
+ * finality **只按确认数复算** (单一实现): 够 finalized → finalized; 够 confirmed → confirmed;
381
+ * 不够 / 非法 / 被回退过 → observed。行里原本自报的 finality 一律不复用 —— 不满足就是 observed。
382
+ */
383
+ function finalityFromConfirmations(confirmations, gates, opts = {}) {
384
+ if (opts.suspect)
385
+ return 'observed';
386
+ const g = normalizeActivityGates(gates);
387
+ const c = Number(confirmations);
388
+ if (!Number.isFinite(c) || c < g.confirmed)
389
+ return 'observed';
390
+ return c >= g.finalized ? 'finalized' : 'confirmed';
391
+ }
392
+ function activityLimit(limit) {
393
+ const n = Math.floor(Number(limit));
394
+ if (!Number.isFinite(n))
395
+ return exports.CONFIRMED_ACTIVITY_LIMIT;
396
+ return Math.max(0, Math.min(exports.CONFIRMED_ACTIVITY_LIMIT, n));
397
+ }
398
+ /**
399
+ * 链上索引条目 → 冻结活动行 (**纯函数**, 不读盘不发 RPC, 便于单测)。
400
+ * · suspect (被回退 / 链上已消失) 的记录**不成行** —— 它已经不在规范链上, 不该当活动列出
401
+ * · 不认识的事件名 / 非法 taskKey·txHash / 缺观察时间 → 跳过 (不猜、不臆造)
402
+ * · confirmations: 有 headBlock → head - block + 1 复算 (无 RPC, 用索引快照里的 head); 否则用记录值
403
+ * · finality: 按 chain-config 门槛复算 (不够 → observed)
404
+ * · 最新在前 (blockNumber desc, logIndex desc), 上限 25 行
405
+ */
406
+ function buildConfirmedActivityFromIndex(entries, opts = {}) {
407
+ const gates = normalizeActivityGates(opts.gates);
408
+ const limit = activityLimit(opts.limit);
409
+ const chainId = Number.isInteger(Number(opts.chainId)) && Number(opts.chainId) >= 0 ? Number(opts.chainId) : 0;
410
+ const head = Number.isInteger(Number(opts.headBlock)) && Number(opts.headBlock) >= 0 ? Number(opts.headBlock) : null;
411
+ const out = [];
412
+ for (const e of Array.isArray(entries) ? entries : []) {
413
+ if (!e || e.suspect === true)
414
+ continue; // 回退过的记录不在规范链上 → 不成行
415
+ const map = exports.CHAIN_EVENT_ACTIVITY[String(e.eventName || '')];
416
+ if (!map)
417
+ continue; // 不认识的事件 → 跳过 (不猜)
418
+ const taskKey = String(e.taskKey || '').toLowerCase();
419
+ const txHash = String(e.txHash || '').toLowerCase();
420
+ if (!/^0x[0-9a-f]{64}$/.test(taskKey) || !/^0x[0-9a-f]{64}$/.test(txHash))
421
+ continue;
422
+ const block = Number(e.blockNumber);
423
+ const logIndex = Number(e.logIndex);
424
+ if (!Number.isInteger(block) || block < 0 || !Number.isInteger(logIndex) || logIndex < 0)
425
+ continue;
426
+ const atMs = Number(e.firstSeenAt);
427
+ const at = isoSeconds(atMs);
428
+ if (!at)
429
+ continue; // 没观察时间 → 不臆造时间, 不成行
430
+ const confirmations = head == null ? Math.max(0, Number(e.confirmations) || 0) : Math.max(0, head - block + 1);
431
+ out.push({
432
+ block, logIndex,
433
+ row: {
434
+ task: anonShortRef(taskKey, 'task'),
435
+ kind: map.kind,
436
+ state: map.state,
437
+ chain_id: chainId,
438
+ block,
439
+ tx: anonShortRef(txHash, 'tx'),
440
+ confirmations,
441
+ finality: finalityFromConfirmations(confirmations, gates),
442
+ at,
443
+ },
444
+ });
445
+ }
446
+ out.sort((a, b) => b.block - a.block || b.logIndex - a.logIndex);
447
+ return out.slice(0, limit).map((x) => x.row);
448
+ }
449
+ /**
450
+ * 脉冲事件 → 冻结活动行 (**纯函数**, 索引不可用时的降级路径)。
451
+ * · 只有带 taskProof 的经济事件成行; state 一律 `unknown` —— 脉冲事件不带 escrow 结局, 不推
452
+ * · 没有链上事实: chain_id / block / confirmations = 0, finality = observed (绝不冒充已确认)
453
+ * · tx = 事件摘要短写 (不是交易哈希 —— 降级行没有 txHash, 字段名冻结只能这样填)
454
+ * · 同一 (task, kind) 只留最新一条; 最新在前, 上限 25 行
455
+ */
456
+ function buildConfirmedActivityFromEvents(events, opts = {}) {
457
+ const limit = activityLimit(opts.limit);
458
+ if (limit <= 0)
459
+ return [];
460
+ const all = [];
461
+ for (const e of Array.isArray(events) ? events : []) {
462
+ if (!isValidEvent(e))
463
+ continue;
464
+ const kind = exports.PULSE_EVENT_ACTIVITY[e.type];
465
+ if (!kind)
466
+ continue; // 非经济事件 (入网/能力/连接…) → 不成行
467
+ const proof = String(e.taskProof || '').toLowerCase();
468
+ if (!/^[0-9a-f]{16}$/.test(proof))
469
+ continue; // 没有任务摘要 → 不成行
470
+ const at = isoSeconds(e.occurredAt);
471
+ if (!at)
472
+ continue;
473
+ all.push({
474
+ at: e.occurredAt,
475
+ row: {
476
+ task: `sha256:${proof.slice(0, 8)}`,
477
+ kind,
478
+ state: 'unknown',
479
+ chain_id: 0,
480
+ block: 0,
481
+ tx: anonShortRef(`pulse|${e.sourceProof}|${e.occurredAt}|${e.type}`, 'tx'),
482
+ confirmations: 0,
483
+ finality: 'observed',
484
+ at,
485
+ },
486
+ });
487
+ }
488
+ all.sort((a, b) => b.at - a.at); // 最新在前
489
+ const seen = new Set();
490
+ const rows = [];
491
+ for (const x of all) {
492
+ const key = `${x.row.task}|${x.row.kind}`;
493
+ if (seen.has(key))
494
+ continue; // 同一任务同一动作只留最新一条
495
+ seen.add(key);
496
+ rows.push(x.row);
497
+ if (rows.length >= limit)
498
+ break;
499
+ }
500
+ return rows;
501
+ }
502
+ /** 纯函数降级: 直接从脉冲事件算活动行 (没有索引时用, source 如实写 pulse-events / none) */
503
+ function confirmedActivityFromEvents(events, limit) {
504
+ const rows = buildConfirmedActivityFromEvents(events, { limit });
505
+ return { source: rows.length ? 'pulse-events' : 'none', rows, gates: normalizeActivityGates(chain_config_js_1.DEFAULT_CONFIRMATIONS) };
506
+ }
507
+ /** 惰性加载链上索引只读查询模块 (只读索引文件, 不发 RPC; 加载/读取失败 → 降级) */
508
+ let chainQueryModule = null;
509
+ async function readChainIndex(home) {
510
+ try {
511
+ if (!chainQueryModule)
512
+ chainQueryModule = (await Promise.resolve().then(() => __importStar(require('./chain/chain-index-query.js'))));
513
+ return chainQueryModule.readIndexFile({ home });
514
+ }
515
+ catch {
516
+ return null;
517
+ }
518
+ }
519
+ /**
520
+ * 解析活动行 (唯一入口): **先链上索引, 再脉冲降级**, 并如实报出来源。
521
+ * 索引文件不存在/读不出/没有任何可用行 → 退回脉冲事件; 两边都没有 → none (不编行)。
522
+ */
523
+ async function resolveConfirmedActivity(q) {
524
+ const now = Number(q.now ?? Date.now());
525
+ const limit = activityLimit(q.limit);
526
+ const windowed = (Array.isArray(q.events) ? q.events : [])
527
+ .filter((e) => isValidEvent(e) && e.occurredAt >= now - exports.PULSE_LIMITS.windowMs);
528
+ // ① 链上索引优先 (真链上事实)
529
+ const reader = q.readIndex ?? readChainIndex;
530
+ try {
531
+ const file = await reader(q.home);
532
+ const entries = Array.isArray(file?.entries) ? file.entries : [];
533
+ if (entries.length > 0) {
534
+ const gates = normalizeActivityGates(file?.confirmations);
535
+ const rows = buildConfirmedActivityFromIndex(entries, {
536
+ gates,
537
+ headBlock: file?.headBlock,
538
+ chainId: file?.chainId,
539
+ limit,
540
+ });
541
+ if (rows.length > 0)
542
+ return { source: 'chain-index', rows, gates };
543
+ }
544
+ }
545
+ catch { /* 索引不可用 → 降级 (来源会在快照里标明) */ }
546
+ // ② 降级: 脉冲事件
547
+ return confirmedActivityFromEvents(windowed, limit);
548
+ }
549
+ // ── 同源计数 · 口径说明 · 链归属 (2026-09-22: 公开页数字不许自相矛盾) ──────────
550
+ //
551
+ // 起因: 真快照里 `totals.tasks=0` 而 `confirmed_activity` 有 25 行真实任务 —— 两块数据来自
552
+ // **两套口径** (totals = 24h 脉冲事件窗口; confirmed_activity = 链上索引全量), 但页面上它们同屏,
553
+ // 读者只会读成"自相矛盾/在撒谎"。修法不是把数字改漂亮, 而是:
554
+ // ① `activity_totals` = 与 confirmed_activity **同源**的计数 (rows 恒等于该数组长度);
555
+ // ② `totals_scope` = totals 的口径说明 + `differs_from_activity` 标记;
556
+ // ③ `chain_id_scope` = 上表各行属于哪条链 (本机 31337 ≠ 真网 Base Sepolia 84532);
557
+ // ④ `snapshotConsistencyIssues` = 导出前的自检 (两个数字打架就拒绝导出)。
558
+ /** 公网测试网 (Base Sepolia) chainId —— 只做**归属说明**, 不代表快照观察到了公网事件 */
559
+ exports.PUBLIC_TESTNET_CHAIN_ID = 84532;
560
+ /** 公网主网 (Base) chainId —— 同上, 只做归属说明 */
561
+ exports.PUBLIC_MAINNET_CHAIN_ID = 8453;
562
+ /**
563
+ * chainId → 展示用网络名 (只回答"这一屏的行属于哪条链")。
564
+ * 认不出的 chain id **不编名字** (chainLabelOf → null), 也不许算成公网。
565
+ */
566
+ exports.CHAIN_LABELS = {
567
+ [chain_config_js_1.LOCAL_DEV_CHAIN_ID]: { zh: '本机隔离开发链', en: 'local isolated dev chain', publicNetwork: false },
568
+ [exports.PUBLIC_TESTNET_CHAIN_ID]: { zh: 'Base Sepolia 测试网', en: 'Base Sepolia testnet', publicNetwork: true },
569
+ [exports.PUBLIC_MAINNET_CHAIN_ID]: { zh: 'Base 主网', en: 'Base mainnet', publicNetwork: true },
570
+ };
571
+ /** chainId 的展示名; 认不出的 chain id → null (不编名字) */
572
+ function chainLabelOf(chainId) {
573
+ const id = Number(chainId);
574
+ return Number.isInteger(id) ? exports.CHAIN_LABELS[id] ?? null : null;
575
+ }
576
+ /** 是不是公网链 id (白名单; 认不出的一律**不算**公网 —— 不替读者认领归属) */
577
+ function isPublicChainId(chainId) {
578
+ return chainLabelOf(chainId)?.publicNetwork === true;
579
+ }
580
+ /**
581
+ * 活动行 → 同源计数 (**纯函数**; 输入就是 `confirmed_activity` 本身)。
582
+ * 存在的唯一理由: 公开页同时显示 totals 与本表行数, 两个数字肉眼可能"打架" ——
583
+ * 那就让它们同源可核: `activity_totals.rows` 恒等于 `confirmed_activity.length`。
584
+ */
585
+ function summarizeActivityRows(rows, opts) {
586
+ const list = (Array.isArray(rows) ? rows : []).filter((r) => !!r);
587
+ const tasks = new Set();
588
+ const completed = new Set();
589
+ const settled = new Set();
590
+ const by_finality = { observed: 0, confirmed: 0, finalized: 0 };
591
+ for (const r of list) {
592
+ const t = String(r.task || '');
593
+ if (t)
594
+ tasks.add(t);
595
+ if (t && r.kind === 'task_completed')
596
+ completed.add(t);
597
+ if (t && r.kind === 'trade_settled')
598
+ settled.add(t);
599
+ if (r.finality === 'observed' || r.finality === 'confirmed' || r.finality === 'finalized') {
600
+ by_finality[r.finality] += 1;
601
+ }
602
+ }
603
+ return {
604
+ source: opts.source,
605
+ rows: list.length,
606
+ tasks: tasks.size,
607
+ tasks_completed: completed.size,
608
+ tasks_settled: settled.size,
609
+ by_finality,
610
+ gates: normalizeActivityGates(opts.gates),
611
+ };
612
+ }
613
+ /** 活动行 → 链归属 (**纯函数**)。所有结论都从行本身推, 不引用外部状态 → 不可能与行数打架。 */
614
+ function buildChainIdScope(rows) {
615
+ const list = (Array.isArray(rows) ? rows : []).filter((r) => !!r);
616
+ const counts = new Map();
617
+ for (const r of list) {
618
+ const id = Number(r.chain_id);
619
+ if (!Number.isInteger(id) || id < 0)
620
+ continue;
621
+ counts.set(id, (counts.get(id) || 0) + 1);
622
+ }
623
+ const chain_ids = Array.from(counts.keys()).sort((a, b) => a - b);
624
+ let primary = 0;
625
+ let best = 0;
626
+ for (const id of chain_ids) { // 升序遍历 + 严格大于 → 并列取小
627
+ const c = counts.get(id);
628
+ if (c > best) {
629
+ best = c;
630
+ primary = id;
631
+ }
632
+ }
633
+ const label = chain_ids.length ? chainLabelOf(primary) : null;
634
+ const publicIds = chain_ids.filter(isPublicChainId);
635
+ const publicRows = publicIds.reduce((n, id) => n + (counts.get(id) || 0), 0);
636
+ const pub = exports.CHAIN_LABELS[exports.PUBLIC_TESTNET_CHAIN_ID];
637
+ // 点名口径: 提示里写出的公网链必须是**行里真出现过的**; 一条都没有才退回对照链
638
+ // (否则会出现「上表 3 行来自 chainId 8453(Base 主网)」却说「公网链(Base Sepolia 84532) 3 行」的自相矛盾)
639
+ const pubZh = publicIds.length
640
+ ? publicIds.map((id) => `${exports.CHAIN_LABELS[id].zh} ${id}`).join('、')
641
+ : `${pub.zh} ${exports.PUBLIC_TESTNET_CHAIN_ID}`;
642
+ const pubEn = publicIds.length
643
+ ? publicIds.map((id) => `${exports.CHAIN_LABELS[id].en} ${id}`).join(', ')
644
+ : `${pub.en} ${exports.PUBLIC_TESTNET_CHAIN_ID}`;
645
+ const rows_ = list.length;
646
+ const multi = chain_ids.length > 1 ? `(上表共 ${chain_ids.length} 条链)` : '';
647
+ const note = rows_ === 0
648
+ ? {
649
+ zh: 'chain_id 归属: 本快照没有链上活动行 —— 不是"链上没事件", 而是这一轮没有可列出的行',
650
+ en: 'chain_id scope: no on-chain activity row in this snapshot — not "nothing happened on chain", just no listable row this round',
651
+ }
652
+ : {
653
+ zh: `chain_id 归属: 上表 ${rows_} 行来自 chainId ${primary}${label ? `(${label.zh})` : ''}${multi} · ` +
654
+ `公网链(${pubZh})${publicRows} 行` +
655
+ `${publicRows === 0 ? ' —— 这不是公网活动' : ''}`,
656
+ en: `chain_id scope: all ${rows_} rows above come from chainId ${primary}${label ? ` (${label.en})` : ''}${chain_ids.length > 1 ? ` (${chain_ids.length} chains in total)` : ''} · ` +
657
+ `public network (${pubEn}) rows: ${publicRows}` +
658
+ `${publicRows === 0 ? ' — this is not public-network activity' : ''}`,
659
+ };
660
+ return {
661
+ chain_ids,
662
+ activity_chain_id: primary,
663
+ activity_chain_label: label ? { zh: label.zh, en: label.en } : null,
664
+ is_public_network: chain_ids.length > 0 ? isPublicChainId(primary) : false,
665
+ public_network_rows: publicRows,
666
+ public_network: { chain_id: exports.PUBLIC_TESTNET_CHAIN_ID, label: { zh: pub.zh, en: pub.en } },
667
+ note,
668
+ };
669
+ }
670
+ /**
671
+ * 快照自检: 公开页会**同屏展示**的数字之间不许自相矛盾。返回问题清单 (空 = 通过)。
672
+ * ① `activity_totals` 必须在 (与行同源的计数不能缺)
673
+ * ② `activity_totals.rows` === `confirmed_activity.length`
674
+ * ③ `by_finality` 三档之和 === 行数 (没有第四条腿)
675
+ * ④ `activity_totals.source` === `confirmed_activity_source` (两块来源标注必须一致)
676
+ * ⑤ 行里有任务 而 `totals.tasks === 0` 时, 必须有口径说明 note
677
+ * (页面不许出现「0 个任务」与「N 行任务」并存而**不解释**)
678
+ */
679
+ function snapshotConsistencyIssues(snap) {
680
+ const issues = [];
681
+ const rows = Array.isArray(snap?.confirmed_activity) ? snap.confirmed_activity : [];
682
+ const at = snap?.activity_totals;
683
+ if (!at || typeof at !== 'object') {
684
+ return ['activity_totals 缺失 (与 confirmed_activity 同源的计数必须一起给)'];
685
+ }
686
+ if (Number(at.rows) !== rows.length)
687
+ issues.push(`activity_totals.rows=${at.rows} ≠ confirmed_activity.length=${rows.length}`);
688
+ const bf = at.by_finality || {};
689
+ const sum = ['observed', 'confirmed', 'finalized'].reduce((n, k) => n + (Number(bf[k]) || 0), 0);
690
+ if (sum !== rows.length)
691
+ issues.push(`by_finality 三档之和=${sum} ≠ 行数=${rows.length}`);
692
+ if (String(at.source || '') !== String(snap?.confirmed_activity_source || '')) {
693
+ issues.push(`activity_totals.source=${at.source} ≠ confirmed_activity_source=${snap?.confirmed_activity_source}`);
694
+ }
695
+ const tasks = Number(at.tasks) || 0;
696
+ const pulseTasks = Number(snap?.totals?.tasks) || 0;
697
+ if (rows.length > 0 && tasks > 0 && pulseTasks === 0) {
698
+ const notes = Array.isArray(snap.notes) ? snap.notes.join(' ') : '';
699
+ const explained = notes.includes('脉冲事件') && notes.includes(String(rows.length)) &&
700
+ (notes.includes('链上索引') || notes.includes('chain-index'));
701
+ if (!explained)
702
+ issues.push(`totals.tasks=0 与 ${rows.length} 行任务并存, 却没有口径说明 note`);
703
+ }
704
+ return issues;
705
+ }
293
706
  function computeSnapshot(events, opts) {
294
707
  const now = opts.now;
295
708
  const fresh_until = now + exports.PULSE_LIMITS.snapshotTtlMs;
296
709
  if (opts.unavailable) {
710
+ const emptyRows = [];
711
+ const gates = normalizeActivityGates(chain_config_js_1.DEFAULT_CONFIRMATIONS);
297
712
  return {
298
713
  status: 'unavailable',
299
714
  generated_at: now,
@@ -301,8 +716,20 @@ function computeSnapshot(events, opts) {
301
716
  scope: 'observed',
302
717
  scope_label: { zh: '当前节点观察到', en: 'Observed by this node' },
303
718
  totals: { nodes: 0, agents: 0, active_agents: 0, seen_last_24h: 0, tasks: 0, tasks_completed: 0, tasks_verified: 0, signatures: 0 },
719
+ totals_scope: {
720
+ source: 'pulse-events', window_ms: exports.PULSE_LIMITS.windowMs,
721
+ label: {
722
+ zh: `只统计本节点 ${exports.PULSE_LIMITS.windowMs / 3600000}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
723
+ en: `Only pulse events received by this node within the ${exports.PULSE_LIMITS.windowMs / 3600000}h observation window (reported by this node itself)`,
724
+ },
725
+ differs_from_activity: false,
726
+ },
304
727
  capabilities: [],
305
728
  recent_activity: [],
729
+ confirmed_activity: emptyRows,
730
+ confirmed_activity_source: 'none',
731
+ activity_totals: summarizeActivityRows(emptyRows, { source: 'none', gates }),
732
+ chain_id_scope: buildChainIdScope(emptyRows),
306
733
  notes: ['观察层暂不可用 — 这不是"网络为空"'],
307
734
  };
308
735
  }
@@ -365,6 +792,39 @@ function computeSnapshot(events, opts) {
365
792
  notes.push('单节点观察: 这是本节点能看到的部分网络, 不是全网精确总量');
366
793
  else
367
794
  notes.push(`多签名来源汇总 (${signedNodes} 个签名节点)`);
795
+ // 活动行来源如实标注 (chain-index = 真链上事实; pulse-events = 降级, 非链上确认)
796
+ const activity = opts.confirmedActivity ?? confirmedActivityFromEvents(window);
797
+ // 同源计数 + 链归属: **输入就是上表那批行**, 所以不可能与行数/链 id 打架
798
+ const activity_totals = summarizeActivityRows(activity.rows, { source: activity.source, gates: activity.gates });
799
+ const chain_id_scope = buildChainIdScope(activity.rows);
800
+ const totals_scope = {
801
+ source: 'pulse-events',
802
+ window_ms: exports.PULSE_LIMITS.windowMs,
803
+ label: {
804
+ zh: `只统计本节点 ${exports.PULSE_LIMITS.windowMs / 3600000}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
805
+ en: `Only pulse events received by this node within the ${exports.PULSE_LIMITS.windowMs / 3600000}h observation window (reported by this node itself)`,
806
+ },
807
+ differs_from_activity: activity_totals.rows > 0 &&
808
+ (tasks.size !== activity_totals.tasks || tasksCompleted.size !== activity_totals.tasks_completed),
809
+ };
810
+ if (activity.source === 'chain-index') {
811
+ notes.push(`confirmed_activity 来自链上索引 (chain-index): ${activity_totals.rows} 行 · ${activity_totals.tasks} 个不同任务 · ` +
812
+ `finality 分布 observed=${activity_totals.by_finality.observed} / confirmed=${activity_totals.by_finality.confirmed} / ` +
813
+ `finalized=${activity_totals.by_finality.finalized} (三档之和 = 行数); 最新在前, 上限 ${exports.CONFIRMED_ACTIVITY_LIMIT} 行; ` +
814
+ `确认门槛 confirmed=${activity.gates.confirmed} · finalized=${activity.gates.finalized}`);
815
+ }
816
+ else if (activity.source === 'pulse-events') {
817
+ notes.push('confirmed_activity 降级为脉冲事件 (pulse-events): 非链上确认 —— chain_id/block/confirmations=0 且 finality=observed');
818
+ }
819
+ // 两套计数口径不同时**必须**解释 —— 页面不许出现「0 个任务」与「N 行任务」并存而不解释
820
+ if (totals_scope.differs_from_activity) {
821
+ notes.push(`口径不同, 不是数据丢失: totals.tasks/tasks_completed/tasks_verified/signatures 只数本节点 ` +
822
+ `${exports.PULSE_LIMITS.windowMs / 3600000}h 窗口内的脉冲事件 (本快照 tasks=${tasks.size} · tasks_completed=${tasksCompleted.size} · ` +
823
+ `tasks_verified=${tasksVerified.size} · signatures=${signatureKeys.size}); 上表 ${activity_totals.rows} 行来自链上索引 ` +
824
+ `(全量, 不是 ${exports.PULSE_LIMITS.windowMs / 3600000}h 窗口) —— 同源计数见 activity_totals`);
825
+ }
826
+ if (activity_totals.rows > 0)
827
+ notes.push(chain_id_scope.note.zh);
368
828
  return {
369
829
  status: 'live',
370
830
  generated_at: now,
@@ -383,8 +843,13 @@ function computeSnapshot(events, opts) {
383
843
  tasks_verified: tasksVerified.size,
384
844
  signatures: signatureKeys.size, // 真实签名次数 (只计数)
385
845
  },
846
+ totals_scope,
386
847
  capabilities,
387
848
  recent_activity: recent,
849
+ confirmed_activity: activity.rows,
850
+ confirmed_activity_source: activity.source,
851
+ activity_totals,
852
+ chain_id_scope,
388
853
  notes,
389
854
  };
390
855
  }
@@ -396,7 +861,15 @@ async function getNetworkPulse(opts = {}) {
396
861
  if (!opts.force) {
397
862
  try {
398
863
  const cached = JSON.parse(fs.readFileSync(snapshotFile(opts.home), 'utf-8'));
399
- if (cached && Number.isFinite(cached.generated_at) && cached.generated_at + exports.PULSE_LIMITS.snapshotTtlMs > now)
864
+ // 老版本写的缓存缺冻结字段 `confirmed_activity` (或后来的 totals_scope / activity_totals /
865
+ // chain_id_scope) → 视为**过期形状**, 重算 (不能把缺字段的快照发出去 ——
866
+ // 缺 activity_totals 就会在页面上重新变成"两个数字打架"
867
+ const shapeOk = Array.isArray(cached?.confirmed_activity)
868
+ && typeof cached?.confirmed_activity_source === 'string'
869
+ && !!cached?.totals_scope
870
+ && !!cached?.activity_totals
871
+ && !!cached?.chain_id_scope;
872
+ if (shapeOk && cached && Number.isFinite(cached.generated_at) && cached.generated_at + exports.PULSE_LIMITS.snapshotTtlMs > now)
400
873
  return cached;
401
874
  }
402
875
  catch { /* 无缓存 */ }
@@ -408,7 +881,9 @@ async function getNetworkPulse(opts = {}) {
408
881
  catch (e) {
409
882
  return computeSnapshot([], { now, unavailable: true });
410
883
  }
411
- const snap = computeSnapshot(events, { now });
884
+ // 活动行优先取 P5 链上索引 (真链上事实); 索引不可用 → 退回脉冲事件, 并在快照里标出来源
885
+ const confirmedActivity = await resolveConfirmedActivity({ home: opts.home, events, now });
886
+ const snap = computeSnapshot(events, { now, confirmedActivity });
412
887
  try {
413
888
  fs.mkdirSync((0, exports.pulseDir)(opts.home), { recursive: true });
414
889
  fs.writeFileSync(snapshotFile(opts.home), JSON.stringify(snap), 'utf8');