@bolloon/bolloon-agent 0.4.32 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/dist/agents/chain/explorer.js +52 -0
  2. package/dist/agents/chain/index.js +2 -0
  3. package/dist/agents/chain/onchain-trade.js +15 -1
  4. package/dist/agents/contacts/chain.js +27 -7
  5. package/dist/agents/execution-supervisor.js +493 -35
  6. package/dist/agents/external-events.js +35 -6
  7. package/dist/agents/gateway-group.js +100 -12
  8. package/dist/agents/goal-criteria.js +10 -3
  9. package/dist/agents/goal-flywheel/block-executor.js +346 -0
  10. package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
  11. package/dist/agents/goal-flywheel/goal-change.js +754 -0
  12. package/dist/agents/goal-flywheel/index.js +26 -0
  13. package/dist/agents/goal-flywheel/memory-layers.js +227 -0
  14. package/dist/agents/goal-flywheel/run-closure.js +809 -0
  15. package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
  16. package/dist/agents/goal-flywheel/types.js +329 -0
  17. package/dist/agents/goal-flywheel/wiring/change.js +288 -0
  18. package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
  19. package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
  20. package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
  21. package/dist/agents/goal-flywheel/wiring/index.js +26 -0
  22. package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
  23. package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
  24. package/dist/agents/goal-flywheel/work-contract.js +478 -0
  25. package/dist/agents/goal-flywheel/work-monitor.js +488 -0
  26. package/dist/agents/goal-flywheel-wiring.js +1870 -0
  27. package/dist/agents/goal-state-reducer.js +406 -0
  28. package/dist/agents/goal-store.js +35 -1
  29. package/dist/agents/mobile-flywheel-view.js +144 -0
  30. package/dist/agents/mobile-task-actions-verify.js +74 -0
  31. package/dist/agents/mobile-task-actions.js +178 -0
  32. package/dist/agents/mobile-task-views.js +199 -0
  33. package/dist/agents/network-pulse.js +525 -27
  34. package/dist/agents/pi-sdk.js +75 -20
  35. package/dist/agents/run-store.js +18 -5
  36. package/dist/agents/skill-readiness.js +52 -10
  37. package/dist/agents/subagent-manager.js +209 -3
  38. package/dist/agents/task/task-onchain-runner.js +19 -1
  39. package/dist/agents/task/task-runner.js +36 -6
  40. package/dist/agents/task-board.js +702 -0
  41. package/dist/agents/task-group.js +524 -0
  42. package/dist/agents/task-public-text.js +89 -0
  43. package/dist/cli/commands/index.js +3 -0
  44. package/dist/cli/commands/tasks.js +977 -8
  45. package/dist/cli/identity-command.js +177 -0
  46. package/dist/cli/protocol-envelope.js +4 -0
  47. package/dist/cli/setup-wizard.js +71 -0
  48. package/dist/cli/update-commands.js +150 -24
  49. package/dist/cli-entry.js +12 -2
  50. package/dist/electron-build/agents/chain/explorer.js +59 -0
  51. package/dist/electron-build/agents/chain/explorer.js.map +1 -0
  52. package/dist/electron-build/agents/contacts/chain.js +26 -6
  53. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  54. package/dist/electron-build/agents/execution-supervisor.js +492 -34
  55. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  56. package/dist/electron-build/agents/external-events.js +34 -5
  57. package/dist/electron-build/agents/external-events.js.map +1 -1
  58. package/dist/electron-build/agents/goal-criteria.js +10 -3
  59. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  60. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  61. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  62. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  63. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  64. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  65. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  66. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  67. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  68. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  69. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  70. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  71. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  72. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  73. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  74. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  75. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  76. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  78. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  79. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  80. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  81. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  82. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  83. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  84. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  85. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  86. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  87. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  88. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  89. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  90. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  91. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  92. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  93. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  94. package/dist/electron-build/agents/goal-store.js +35 -1
  95. package/dist/electron-build/agents/goal-store.js.map +1 -1
  96. package/dist/electron-build/agents/local-signer.js +101 -0
  97. package/dist/electron-build/agents/local-signer.js.map +1 -0
  98. package/dist/electron-build/agents/network-pulse.js +531 -28
  99. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  100. package/dist/electron-build/agents/pi-sdk.js +74 -19
  101. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  102. package/dist/electron-build/agents/run-store.js +20 -5
  103. package/dist/electron-build/agents/run-store.js.map +1 -1
  104. package/dist/electron-build/agents/skill-readiness.js +52 -9
  105. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  106. package/dist/electron-build/agents/task/task-runner.js +35 -5
  107. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  108. package/dist/electron-build/agents/task-board.js +761 -0
  109. package/dist/electron-build/agents/task-board.js.map +1 -0
  110. package/dist/electron-build/agents/task-contract.js +382 -0
  111. package/dist/electron-build/agents/task-contract.js.map +1 -0
  112. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  113. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  114. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  115. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  116. package/dist/electron-build/utils/dual-source.js +471 -0
  117. package/dist/electron-build/utils/dual-source.js.map +1 -0
  118. package/dist/electron-build/utils/update-manager.js +331 -57
  119. package/dist/electron-build/utils/update-manager.js.map +1 -1
  120. package/dist/electron-build/utils/update-state.js +16 -1
  121. package/dist/electron-build/utils/update-state.js.map +1 -1
  122. package/dist/electron-build/utils/version-info.js +82 -2
  123. package/dist/electron-build/utils/version-info.js.map +1 -1
  124. package/dist/index.js +103 -12
  125. package/dist/ios/chain-explorer.js +617 -0
  126. package/dist/ios/explorer.html +163 -0
  127. package/dist/ios/index.html +96 -0
  128. package/dist/ios/mobile-agent.js +2 -2
  129. package/dist/ios/mobile-contacts.js +329 -0
  130. package/dist/ios/mobile-core.js +1379 -226
  131. package/dist/ios/mobile-privacy.js +185 -0
  132. package/dist/ios/mobile.css +19 -0
  133. package/dist/ios/mobile.html +96 -0
  134. package/dist/ios/mobile.js +706 -7
  135. package/dist/ios/routes-chain-index.js +108 -0
  136. package/dist/ios/routes-contacts.js +420 -0
  137. package/dist/ios/routes-onchain-trade.js +99 -0
  138. package/dist/ios/routes-x402-info.js +1 -0
  139. package/dist/ios/server.js +762 -0
  140. package/dist/ios/task-frame-server.js +211 -0
  141. package/dist/orbitdb/cid-database.js +81 -16
  142. package/dist/orbitdb/ipfs-node.js +31 -2
  143. package/dist/utils/dual-source.js +426 -0
  144. package/dist/utils/update-cli.js +16 -21
  145. package/dist/utils/update-health.js +35 -5
  146. package/dist/utils/update-manager.js +329 -57
  147. package/dist/utils/update-state.js +16 -1
  148. package/dist/utils/version-info.js +75 -1
  149. package/dist/web/mobile-contacts.js +44 -11
  150. package/dist/web/mobile-core.js +1197 -513
  151. package/dist/web/mobile-tasks.js +339 -0
  152. package/dist/web/mobile.css +19 -0
  153. package/dist/web/mobile.html +64 -0
  154. package/dist/web/mobile.js +531 -1
  155. package/dist/web/routes-mobile-tasks.js +353 -0
  156. package/dist/web/server.js +163 -4
  157. package/package.json +3 -1
@@ -10,10 +10,19 @@
10
10
  * · 快照过期 → `stale` (不许伪装实时); 观察层不可用 → `unavailable`
11
11
  * · 类别小于隐私阈值 → 合并进 other
12
12
  * · 事件数 / 时间窗 / capability 数都有上限
13
- * · **冻结形状 `confirmed_activity`** (2026-09-22): 公开页面要能列出「哪个任务 · 什么状态 ·
14
- * 哪个块 · 多少确认」, 而不是只有数字 —— 数据源优先 P5 链上索引, 不可用时退回脉冲事件并
15
- * 用 `confirmed_activity_source` 标注; 标识一律 sha256 短写 (前 8 位), 绝不落 taskKey /
16
- * taskId / txHash 原文; 不够确认门槛的行只报 observed, 不冒充 confirmed。
13
+ * · **冻结形状 `confirmed_activity`** (2026-09-22; 2026-09-23 加链上可核验字段): 公开页面要能列出
14
+ * 「哪个任务 · 什么状态 · 哪个块 · 多少确认」, 而不是只有数字 —— 数据源优先 P5 链上索引, 不可用时
15
+ * 退回脉冲事件并用 `confirmed_activity_source` 标注; 任务标识一律 sha256 短写 (前 8 位), 绝不落
16
+ * taskKey / taskId 原文; 不够确认门槛的行只报 observed, 不冒充 confirmed。
17
+ * 2026-09-23 隐私决定**变更一处** (只放开公开链上事实, 别的一律不变): 交易哈希 (`tx_hash`) 与
18
+ * escrow **合约**地址 (`contract`, 只给索引/诊断) 允许出现; 页面可点的只有**交易**链接 `explorer_tx`
19
+ * (仅当该链有已知公网浏览器)。EOA / 钱包地址 (买方·卖方)、taskKey 原文、taskId、args 里的地址、
20
+ * DID、peer IP/multiaddr、私钥 —— 仍然一个都不许出现; 老字段 `tx` (sha256 短写) 保留不删。
21
+ * · **冻结形状 `open_tasks[]`** (2026-09-23): 公开页要能列「本节点公告板上哪些任务还没被接单」。
22
+ * 数据源 = `~/.bolloon/tasks/board/*.json` 里**未认领且未过期**的公告, 每行只含白名单 7 个字段:
23
+ * `capability` / `budget`(原子单位) / `currency` / `network` / `deadline` / `claimed`(恒 false) /
24
+ * `announcementId`(前 8 位)。**任务正文与它的摘要/预览、买方 DID 与公钥、认领者、公告签名
25
+ * 一律不进公开投影** —— 行由 `buildOpenTaskRow` 逐字段拷贝构造, 不做对象展平。
17
26
  *
18
27
  * 借鉴 (只借产品与工程思想, 不借 Go/Postgres/API 项目):
19
28
  * EigenFlux 的 "注册总量 / 当前活跃 / 最近出现" 时间窗统计、服务端生成匿名活动文本、
@@ -25,6 +34,11 @@ import * as path from 'path';
25
34
  import * as crypto from 'crypto';
26
35
  // 确认数门槛来自 chain-config (单一实现; 默认 confirmed=1 / finalized=12) —— 只借常量, 不借 RPC
27
36
  import { DEFAULT_CONFIRMATIONS, LOCAL_DEV_CHAIN_ID } from './chain/chain-config.js';
37
+ // 区块浏览器映射 (chainId → 公网浏览器); 认不出的链没有链接 —— 宁缺勿错, 不编死链
38
+ import { ADDRESS_RE, EXPLORER_URL_RE, TX_HASH_RE, explorerTxUrl } from './chain/explorer.js';
39
+ // 「待接单任务」的数据源 = 本机公告板目录 (~/.bolloon/tasks/board)。只借**路径常量**,
40
+ // 公告的解析/发布/接单一律留在 task-board.ts (这里不做第二套实现)。
41
+ import { boardDir, REMOTE_CLAIMS_FILE } from './task-board.js';
28
42
  // ── 常量 (上限 + 时间边界) ─────────────────────────────────────────────────
29
43
  export const PULSE_LIMITS = {
30
44
  /** 最多留多少条事件 (超出丢最旧) */
@@ -56,6 +70,8 @@ export const NETWORK_EVENT_TYPES = [
56
70
  // 经济事件 (P6 扩展; 原五类保持兼容, 老节点发来的事件仍被接受)
57
71
  'task_posted', 'task_accepted', 'task_completed', 'trade_settled', 'trade_verified',
58
72
  'wallet_signed',
73
+ // 2026-09-23 (C1): 任务对外发布 (公告板 publish) —— 只记"有节点公告了一个待接单任务", 正文/ID 原文不落盘
74
+ 'task_announced',
59
75
  ];
60
76
  // ── 存储 ────────────────────────────────────────────────────────────────────
61
77
  const home = (h) => h || process.env.HOME || os.homedir() || '/tmp';
@@ -87,7 +103,34 @@ function writeEvents(list, h) {
87
103
  /* 统计失败绝不影响主路径 */
88
104
  }
89
105
  }
90
- // ── 匿名化 ──────────────────────────────────────────────────────────────────
106
+ export function tallySignatureAudit(h, opts = {}) {
107
+ const now = opts.now ?? Date.now();
108
+ const windowMs = opts.windowMs ?? PULSE_LIMITS.windowMs;
109
+ const file = path.join(home(h), '.bolloon', 'wallet-signatures.jsonl');
110
+ if (!fs.existsSync(file))
111
+ return { available: false, count: 0, window_ms: windowMs };
112
+ let count = 0;
113
+ try {
114
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
115
+ if (!line.trim())
116
+ continue;
117
+ let row = null;
118
+ try {
119
+ row = JSON.parse(line);
120
+ }
121
+ catch {
122
+ continue;
123
+ } // 坏行跳过, 不让一行坏数据毁掉整个计数
124
+ const at = Number(row?.at);
125
+ if (Number.isFinite(at) && at >= now - windowMs)
126
+ count += 1;
127
+ }
128
+ }
129
+ catch {
130
+ return { available: false, count: 0, window_ms: windowMs };
131
+ }
132
+ return { available: true, count, window_ms: windowMs };
133
+ }
91
134
  /** 节点摘要: sha256(值) 前 16 位。不可逆, 不暴露原值。 */
92
135
  export function nodeDigest(value) {
93
136
  return crypto.createHash('sha256').update(`bolloon-pulse|${String(value)}`).digest('hex').slice(0, 16);
@@ -167,6 +210,7 @@ export function renderActivityText(kind) {
167
210
  case 'capability_announced': return { zh: '有节点公开声明了新能力', en: 'A node announced a capability' };
168
211
  case 'peer_connected': return { zh: '两个节点建立了连接', en: 'Two nodes connected' };
169
212
  case 'delegation_completed': return { zh: '一次能力委派完成', en: 'A delegation completed' };
213
+ case 'task_announced': return { zh: '有节点公告了一个待接单任务', en: 'A node announced an open task' };
170
214
  default: return { zh: '网络有活动', en: 'Network activity' };
171
215
  }
172
216
  }
@@ -216,6 +260,82 @@ export function readAgentSites(h) {
216
260
  return [];
217
261
  }
218
262
  }
263
+ /** 上限: 公开页最多列几条待接单任务 (超出按最近截止排序取前 N) */
264
+ export const MAX_OPEN_TASKS = 25;
265
+ /** 公开页只列这条公告的这 7 个字段; 其余(正文/买方/摘要/签名)一律不出这个函数 */
266
+ function buildOpenTaskRow(a) {
267
+ const capability = String(a?.capability ?? '').trim();
268
+ const id = String(a?.announcementId ?? '').trim();
269
+ const deadline = Number(a?.deadline);
270
+ if (!capability || !id || !Number.isFinite(deadline) || deadline <= 0)
271
+ return null;
272
+ const b = a?.budget && typeof a.budget === 'object' ? a.budget : null;
273
+ const maxAmount = b ? String(b.maxAmount ?? '').trim() : '';
274
+ const currency = b ? String(b.currency ?? '').trim() : '';
275
+ const network = b ? String(b.network ?? '').trim() : '';
276
+ return {
277
+ capability,
278
+ budget: maxAmount || null,
279
+ currency: currency || null,
280
+ network: network || null,
281
+ deadline,
282
+ claimed: (Array.isArray(a?.claims) ? a.claims.length : 0) > 0,
283
+ announcementId: id.slice(0, 8), // 前 8 位 (按字面, 不重新格式化)
284
+ };
285
+ }
286
+ /**
287
+ * 读本节点公告板上**未认领且未过期**的公告, 投影成公开「待接单任务」行。
288
+ *
289
+ * 三条筛选 (缺一个都会把不该上的东西露出来):
290
+ * · `protocol= bolloon-task/1` + `kind= task_announcement` (同目录还有 `remote-claims.json`,
291
+ * 它是个数组 —— 不按 kind 判会把认领台账当公告解析)
292
+ * · `status === 'open'` (已认领 / 已取消 → 不是「待接单」)
293
+ * · `deadline > now` (过期只是不能再接单, 文件不删 → 必须在这里判掉)
294
+ *
295
+ * 读不到目录 / 坏 JSON / 缺字段 → 该条跳过 (一条都不编); 整个目录不可读 → 空数组 + 不抛。
296
+ */
297
+ export function readOpenTasks(h, now = Date.now()) {
298
+ const out = [];
299
+ try {
300
+ const dir = boardDir(h);
301
+ let names;
302
+ try {
303
+ names = fs.readdirSync(dir);
304
+ }
305
+ catch {
306
+ return [];
307
+ }
308
+ for (const name of names.slice().sort()) {
309
+ if (!name.endsWith('.json') || name === `${REMOTE_CLAIMS_FILE}`)
310
+ continue;
311
+ let raw;
312
+ try {
313
+ raw = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
314
+ }
315
+ catch {
316
+ continue;
317
+ }
318
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
319
+ continue; // 认领台账是数组 → 这里就出局
320
+ if (raw.protocol !== 'bolloon-task/1' || raw.kind !== 'task_announcement')
321
+ continue;
322
+ if (String(raw.status) !== 'open')
323
+ continue;
324
+ const deadline = Number(raw.deadline);
325
+ if (!Number.isFinite(deadline) || deadline <= now)
326
+ continue;
327
+ const row = buildOpenTaskRow(raw);
328
+ if (row)
329
+ out.push(row);
330
+ }
331
+ }
332
+ catch {
333
+ return [];
334
+ }
335
+ // 稳定排序: 快到期在前 (读者最需要知道的先看); 同截止按短 id 定序 (排序必须确定, 否则快照每次都"变")
336
+ out.sort((x, y) => (x.deadline - y.deadline) || (x.announcementId < y.announcementId ? -1 : x.announcementId > y.announcementId ? 1 : 0));
337
+ return out.slice(0, MAX_OPEN_TASKS);
338
+ }
219
339
  /**
220
340
  * 把**真实交易活动**投影成脉冲事件 (由交易写路径调用, fire-and-forget)。
221
341
  * 只按事实发: 交付 → task_completed · 真验真 → trade_verified · **链上口径**结算 → trade_settled。
@@ -248,10 +368,18 @@ export async function emitTradePulse(before, after, h) {
248
368
  // ── 冻结形状: confirmed_activity (真实智能体任务 / 链上活动行) ────────────────
249
369
  //
250
370
  // 公开网页要能列出「哪个任务 · 什么状态 · 哪个块 · 多少确认」, 而不是只有数字。这块就是那批行。
251
- // 形状**冻结** (字段名与取值域不许改), 内容一律**匿名短写**:
252
- // · task = `sha256:` + sha256(域标签|taskKey) 的前 8 位十六进制 —— 绝不落 taskKey / taskId 原文
253
- // · tx = `sha256:` + sha256(域标签|txHash) 的前 8 位十六进制 —— 绝不落 txHash 原文
254
- // · 不出现 DID / peerId / IP / 完整钱包地址 / 任务正文 —— `assertNoPrivateFields` 兜底
371
+ // 形状**冻结** (老字段名与取值域不许改), 内容按用途分两类:
372
+ // · **匿名短写** (老口径, 保留不删): task = `sha256:` + sha256(域标签|taskKey) 的前 8 位;
373
+ // tx = `sha256:` + sha256(域标签|txHash) 的前 8 位 —— 绝不落 taskKey / taskId 原文
374
+ // · **公开链上事实** (2026-09-23 新增; 同日按 leo 拍板收窄): tx_hash (真交易哈希) /
375
+ // explorer_tx (交易浏览器链接, 仅当该链有已知公网浏览器) —— 网页上的行因此**可核验地**跳回区块
376
+ // 浏览器; contract (escrow 合约地址) 仍保留在行里**供索引/诊断**, 但**不上页面、不生成合约链接**
377
+ // 隐私红线 (违反即失败, **只放开上面那两种 0x 字符串**):
378
+ // · 允许: 交易哈希 (0x+64 hex) 与 escrow 合约地址 (0x+40 hex), 且只出现在 `tx_hash`/`contract`/`explorer_tx` 这 3 个键下
379
+ // · 禁止: EOA / 钱包地址 (买方/卖方/payTo)、taskKey 原文、taskId、args 里的地址、DID、peerId /
380
+ // IP / multiaddr、私钥 —— `assertNoPrivateFields`(键名) + `auditPublicHexLeaks`(越界 0x) 兜底
381
+ // · 拿不到链配置里的 escrow 地址, 或这条索引记录的 address 不是它 → **不填** contract
382
+ // (宁缺勿错: 宁可少一个字段, 也不把可能是别人的地址写成合约)
255
383
  // 数据源优先级: ① P5 链上索引 (真链上事实) → ② 索引不可用 → 退回脉冲事件, 并在快照里用
256
384
  // `confirmed_activity_source` 如实标注 (chain-index / pulse-events / none)。
257
385
  // 确认数口径走 chain-config 门槛 (默认 confirmed=1 / finalized=12): 够不着门槛的行只报
@@ -335,6 +463,8 @@ function activityLimit(limit) {
335
463
  * · 不认识的事件名 / 非法 taskKey·txHash / 缺观察时间 → 跳过 (不猜、不臆造)
336
464
  * · confirmations: 有 headBlock → head - block + 1 复算 (无 RPC, 用索引快照里的 head); 否则用记录值
337
465
  * · finality: 按 chain-config 门槛复算 (不够 → observed)
466
+ * · 新增: `tx_hash` (真交易哈希) 恒给; `contract` 仅当 opts.escrowAddress 合法且 === 本条 address;
467
+ * `explorer_tx` 仅当该 chainId 有**已知公网浏览器** (否则键整个不存在, 不填 null)
338
468
  * · 最新在前 (blockNumber desc, logIndex desc), 上限 25 行
339
469
  */
340
470
  export function buildConfirmedActivityFromIndex(entries, opts = {}) {
@@ -342,6 +472,9 @@ export function buildConfirmedActivityFromIndex(entries, opts = {}) {
342
472
  const limit = activityLimit(opts.limit);
343
473
  const chainId = Number.isInteger(Number(opts.chainId)) && Number(opts.chainId) >= 0 ? Number(opts.chainId) : 0;
344
474
  const head = Number.isInteger(Number(opts.headBlock)) && Number(opts.headBlock) >= 0 ? Number(opts.headBlock) : null;
475
+ // escrow 合约地址白名单 (唯一权威): 只有索引文件自己记的这个地址算数, 别的地址一律不当合约
476
+ const escrow = typeof opts.escrowAddress === 'string' ? opts.escrowAddress.toLowerCase() : '';
477
+ const escrowOk = ADDRESS_RE.test(escrow) ? escrow : '';
345
478
  const out = [];
346
479
  for (const e of Array.isArray(entries) ? entries : []) {
347
480
  if (!e || e.suspect === true)
@@ -362,6 +495,12 @@ export function buildConfirmedActivityFromIndex(entries, opts = {}) {
362
495
  if (!at)
363
496
  continue; // 没观察时间 → 不臆造时间, 不成行
364
497
  const confirmations = head == null ? Math.max(0, Number(e.confirmations) || 0) : Math.max(0, head - block + 1);
498
+ // 合约地址: 只有「本条记录的 address 就是链配置里的 escrow 地址」才填 (宁缺勿错)
499
+ const entryAddr = String(e.address || '').toLowerCase();
500
+ const contract = escrowOk && entryAddr === escrowOk ? escrowOk : '';
501
+ // 浏览器链接: 该链没有已知公网浏览器 → 返回 null → 键整个不出现 (不编 href="#")
502
+ // 只有**交易**链接 (explorer_tx); 合约地址只在数据里 (contract), 不生成链接 (2026-09-23 收窄)
503
+ const explorerTx = explorerTxUrl(chainId, txHash);
365
504
  out.push({
366
505
  block, logIndex,
367
506
  row: {
@@ -374,6 +513,10 @@ export function buildConfirmedActivityFromIndex(entries, opts = {}) {
374
513
  confirmations,
375
514
  finality: finalityFromConfirmations(confirmations, gates),
376
515
  at,
516
+ // 公开链上事实 (白名单键; 老字段 tx 已在上方保留)
517
+ tx_hash: txHash,
518
+ ...(contract ? { contract } : {}),
519
+ ...(explorerTx ? { explorer_tx: explorerTx } : {}),
377
520
  },
378
521
  });
379
522
  }
@@ -471,6 +614,7 @@ export async function resolveConfirmedActivity(q) {
471
614
  headBlock: file?.headBlock,
472
615
  chainId: file?.chainId,
473
616
  limit,
617
+ escrowAddress: file?.escrowAddress, // 合约地址白名单 = 索引文件自己的部署身份
474
618
  });
475
619
  if (rows.length > 0)
476
620
  return { source: 'chain-index', rows, gates };
@@ -609,6 +753,10 @@ export function buildChainIdScope(rows) {
609
753
  * ④ `activity_totals.source` === `confirmed_activity_source` (两块来源标注必须一致)
610
754
  * ⑤ 行里有任务 而 `totals.tasks === 0` 时, 必须有口径说明 note
611
755
  * (页面不许出现「0 个任务」与「N 行任务」并存而**不解释**)
756
+ * ⑥ 0x 长 hex 越界 (2026-09-23): 只许出现在 tx_hash/contract/explorer_* 四个白名单键下 ——
757
+ * EOA 地址跑到别的键 (或白名单键形状不对) 就拒绝导出
758
+ * ⑧ ★ **同一概念不许并排两个数** (2026-09-24, 见 `totalsScopeIssues`): 顶部计数与表格行数/
759
+ * 同源计数矛盾, 或者某个数没有来源却不标「未接入」→ 拒绝导出 (这道门在 UI 侧还有一份镜像)
612
760
  */
613
761
  export function snapshotConsistencyIssues(snap) {
614
762
  const issues = [];
@@ -635,28 +783,258 @@ export function snapshotConsistencyIssues(snap) {
635
783
  if (!explained)
636
784
  issues.push(`totals.tasks=0 与 ${rows.length} 行任务并存, 却没有口径说明 note`);
637
785
  }
786
+ issues.push(...auditPublicHexLeaks(snap)); // ⑥ 0x 长 hex 越界 → 拒绝导出 (不静默放行)
787
+ issues.push(...openTasksIssues(snap)); // ⑦ 待接单任务的字段白名单与「只含未认领」不变式
788
+ issues.push(...totalsScopeIssues(snap)); // ⑧ ★ 同一概念不得并排两个数 (顶部 vs 表格)
638
789
  return issues;
639
790
  }
791
+ /** 顶部逐字段口径的键 (与 `TotalsFieldKey` 逐字一致; 少一个 = 那个数没有来源说明) */
792
+ export const TOTALS_FIELD_KEYS = [
793
+ 'nodes', 'agents', 'active_agents', 'seen_last_24h',
794
+ 'tasks', 'tasks_completed', 'tasks_verified', 'tasks_settled', 'signatures',
795
+ ];
796
+ const TOTALS_FIELD_SOURCES = ['pulse-events', 'chain-index', 'signature-audit', 'none'];
797
+ const TOTALS_FIELD_WINDOWS = ['window-24h', 'full', 'unknown'];
798
+ /** 「未接入」类措辞 (无源时必须出现, 否则等于没说清为什么没有数) */
799
+ const UNAVAILABLE_WORDS = /未接入|not connected|无可用源|no available source/;
800
+ /**
801
+ * ★ 新不变量门 (2026-09-24 leo:「数量怎么对不上」): **同一概念不许并排两个数**。
802
+ *
803
+ * 检查的是「页面会把它们放在同一屏里」的那几个数 —— 它们的口径必须各自挂得住:
804
+ * ① 逐字段口径 `totals_scope.fields` 九个键必须齐 (source/window/short/label 双语都在) ——
805
+ * 缺了就只能靠 notes 辩解, 而那正是当初「顶部 0 个任务 vs 表格 25 行」能上线的路径;
806
+ * ② 值 `null` ⇔ 口径标 `none` + 明说「未接入」; **有源却不给数** 或 **无源却拿 0 冒充** 都算矛盾;
807
+ * ③ 同一概念对账 (表里有 5 个任务而顶部写 0 任务 → 判红):
808
+ * · 链上索引可用时, 顶部 tasks/tasks_completed/tasks_settled **必须逐字等于** activity_totals
809
+ * 的同名字段 (同源即恒等, 任何解释都救不了);
810
+ * · 表格行数/同源计数非零而顶部为 0 或缺值 → 必须有口径说明 (note 里写清) 才放行;
811
+ * · 顶部说有 N 个任务而表格一行都没有 (反向矛盾) → 同样判红。
812
+ * `status === 'unavailable'` 的整份快照不发布任何计数 (页面整块显示「快照读不到」), 故只查形状。
813
+ */
814
+ export function totalsScopeIssues(snap) {
815
+ const out = [];
816
+ const t = snap?.totals || {};
817
+ const ts = snap?.totals_scope;
818
+ const at = snap?.activity_totals || {};
819
+ const rows = Array.isArray(snap?.confirmed_activity) ? snap.confirmed_activity : [];
820
+ const unavailableSnap = String(snap?.status || '') === 'unavailable';
821
+ if (!ts || typeof ts !== 'object')
822
+ return ['totals_scope 缺失 (顶部计数的口径必须随快照一起给)'];
823
+ if (!ts.fields || typeof ts.fields !== 'object') {
824
+ return ['totals_scope.fields 缺失 (顶部每个数都要能就地说明来自哪 —— 不许只靠 notes 辩解)'];
825
+ }
826
+ // ① 形状: 九个键齐 + 每个键的 source/window/short/label 都合法
827
+ for (const k of TOTALS_FIELD_KEYS) {
828
+ const f = ts.fields[k];
829
+ if (!f || typeof f !== 'object') {
830
+ out.push(`totals_scope.fields.${k} 缺失`);
831
+ continue;
832
+ }
833
+ if (!TOTALS_FIELD_SOURCES.includes(f.source))
834
+ out.push(`totals_scope.fields.${k}.source=${JSON.stringify(f.source)} 不在白名单`);
835
+ if (!TOTALS_FIELD_WINDOWS.includes(f.window))
836
+ out.push(`totals_scope.fields.${k}.window=${JSON.stringify(f.window)} 不在白名单`);
837
+ if (!f.label?.zh || !f.label?.en)
838
+ out.push(`totals_scope.fields.${k}.label 必须双语都给`);
839
+ if (!f.short?.zh || !f.short?.en)
840
+ out.push(`totals_scope.fields.${k}.short 必须双语都给 (页面要就地贴在数字旁)`);
841
+ }
842
+ if (out.length)
843
+ return out; // 形状都不对就不往下算数
844
+ if (!unavailableSnap) {
845
+ // ② 值 ⇔ 口径 (null = 未接入; 有源必须有数; 无源不许给 0)
846
+ for (const k of TOTALS_FIELD_KEYS) {
847
+ const f = ts.fields[k];
848
+ const v = t[k];
849
+ const isNull = v === null || v === undefined;
850
+ if (isNull) {
851
+ if (f.source !== 'none') {
852
+ out.push(`totals.${k} 没有值 (未接入) 却标了源 ${f.source} —— 有源就必须给真值, 没源就标 none`);
853
+ }
854
+ if (!UNAVAILABLE_WORDS.test(`${f.short?.zh}${f.short?.en}${f.label?.zh}${f.label?.en}`)) {
855
+ out.push(`totals.${k} 没有值却没有一句「未接入」说明 (读者会把空白读成 0)`);
856
+ }
857
+ }
858
+ else {
859
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
860
+ out.push(`totals.${k}=${JSON.stringify(v)} 不是非负有限数`);
861
+ }
862
+ else if (f.source === 'none') {
863
+ out.push(`totals.${k}=${v} 却标「无源」 —— 不许拿裸 0 冒充「没有接入」(那会被读成"没发生过")`);
864
+ }
865
+ }
866
+ }
867
+ // ③ 同一概念对账 (顶部计数 vs 表格行数/同源计数)
868
+ const notes = Array.isArray(snap.notes) ? snap.notes.join(' ') : '';
869
+ const pairs = [
870
+ ['tasks', t.tasks, at.tasks],
871
+ ['tasks_completed', t.tasks_completed, at.tasks_completed],
872
+ ['tasks_settled', t.tasks_settled, at.tasks_settled],
873
+ ];
874
+ const tableRows = Number(at.rows) || rows.length;
875
+ const sameSourceChain = String(at.source) === 'chain-index';
876
+ // ⑨ 声明与能力不符: 某字段自称「链上索引」口径, 而这一份快照的链上索引根本不可用 ——
877
+ // 那就是替一个不存在的源背书 (页面会写「链上索引·全量」, 而索引这一份没读出来)。
878
+ // 注: 本块必须放在 pairs / sameSourceChain 之后 (先声明后使用, 否则 TDZ 崩)。
879
+ if (!sameSourceChain) {
880
+ for (const [k] of pairs) {
881
+ const f = ts.fields[k];
882
+ if (f.source === 'chain-index') {
883
+ out.push(`totals_scope.fields.${k}.source='chain-index' 而这一份快照的链上索引不可用 ` +
884
+ `(activity_totals.source=${JSON.stringify(at.source)}) —— 声明与能力不符`);
885
+ }
886
+ }
887
+ }
888
+ for (const [k, top, src] of pairs) {
889
+ const n = Number(src) || 0;
890
+ const topN = (top === null || top === undefined) ? null : Number(top);
891
+ const topScope = ts.fields[k];
892
+ if (sameSourceChain && topScope.source === 'chain-index') {
893
+ // 同源: 必须逐字相等, 不需要任何"解释"
894
+ if (topN !== n) {
895
+ out.push(`同一概念两个数: 顶部 totals.${k}=${topN === null ? '未接入' : topN} ≠ 链上索引同源计数 activity_totals.${k}=${n}` +
896
+ ` (表格 ${tableRows} 行) —— 同源即恒等, 页面并排出现两个数就是矛盾`);
897
+ }
898
+ continue;
899
+ }
900
+ if (n > 0 && (topN === null || topN === 0)) {
901
+ const explained = notes.includes('口径') && notes.includes(String(tableRows)) && notes.includes('脉冲事件');
902
+ if (!explained) {
903
+ out.push(`顶部 totals.${k}=${topN === null ? '未接入' : 0} 与表格里的 ${n} 个同类 (${tableRows} 行) 矛盾, 且没有任何口径说明`);
904
+ }
905
+ }
906
+ if (topN !== null && n === 0 && tableRows === 0 && topN > 0) {
907
+ out.push(`顶部 totals.${k}=${topN} 而表格一行都没有 (${tableRows} 行) —— 反向矛盾: 数不出行却报了数`);
908
+ }
909
+ }
910
+ }
911
+ // ④ 明说"数字不一样"时必须有解释 (老规矩保留; 不许只靠一句 label)
912
+ if (ts.differs_from_activity === true) {
913
+ const notes = Array.isArray(snap.notes) ? snap.notes.join(' ') : '';
914
+ if (!notes.includes('口径'))
915
+ out.push('totals_scope.differs_from_activity=true 但 notes 里没有任何口径说明');
916
+ }
917
+ return out;
918
+ }
919
+ /** open_tasks 行的字段白名单 (与 `OpenTaskRow` 逐字一致; 多一个键 = 可能把正文/身份带出去了) */
920
+ export const OPEN_TASK_KEYS = ['capability', 'budget', 'currency', 'network', 'deadline', 'claimed', 'announcementId'];
921
+ /**
922
+ * 待接单任务行的自检 (空 = 通过)。这不是格式洁癖, 每条都对着一个真泄漏路径:
923
+ * · 键集 ≠ 白名单 → 有人把 instruction / buyerDid / claims 之类**展平**进公开投影了;
924
+ * · `claimed !== false` → 「只导出未认领」这个筛选坏了 (页面会把已接单的当待接单);
925
+ * · `announcementId` 长于 8 位 → 短写丢了, 原始公告 id 上公开页 (页面上的 id 会被读者当完整 id 用);
926
+ * · capability 空 / deadline 非正 → 页面上会出现一条说不出是什么、也不知道何时截止的行。
927
+ */
928
+ export function openTasksIssues(snap) {
929
+ const out = [];
930
+ const rows = snap?.open_tasks;
931
+ if (!Array.isArray(rows))
932
+ return ['open_tasks 缺失 (公开「待接单任务」必须是数组, 没有就给空数组)'];
933
+ const allow = OPEN_TASK_KEYS;
934
+ rows.forEach((r, i) => {
935
+ const at = `open_tasks[${i}]`;
936
+ if (!r || typeof r !== 'object' || Array.isArray(r)) {
937
+ out.push(`${at} 不是对象`);
938
+ return;
939
+ }
940
+ const extra = Object.keys(r).filter((k) => !allow.includes(k));
941
+ if (extra.length)
942
+ out.push(`${at} 出现白名单外的键: ${extra.join(', ')} (公开投影只许 ${allow.join('/')})`);
943
+ if (typeof r.capability !== 'string' || !r.capability.trim())
944
+ out.push(`${at}.capability 缺失/为空`);
945
+ if (r.claimed !== false)
946
+ out.push(`${at}.claimed=${JSON.stringify(r.claimed)} —— 公开页只许列**未认领**的公告`);
947
+ if (typeof r.announcementId !== 'string' || r.announcementId.length === 0 || r.announcementId.length > 8) {
948
+ out.push(`${at}.announcementId 必须是 announcementId 的**前 8 位**(1..8 字符), 实得 ${JSON.stringify(r.announcementId)}`);
949
+ }
950
+ if (!Number.isFinite(r.deadline) || r.deadline <= 0)
951
+ out.push(`${at}.deadline 必须是正的 ms 时间戳`);
952
+ for (const k of ['budget', 'currency', 'network']) {
953
+ if (r[k] !== null && typeof r[k] !== 'string')
954
+ out.push(`${at}.${k} 只许字符串或 null (不替公告猜值)`);
955
+ }
956
+ });
957
+ return out;
958
+ }
959
+ // ── 顶部计数的逐字段口径 (2026-09-24) ────────────────────────────────────────
960
+ // 页面把 8~9 个数字并排放在**一行**里, 一行一个总口径是解释不了它们的 —— 每个数必须自带来源。
961
+ const WINDOW_HOURS = Math.round(PULSE_LIMITS.windowMs / 3600000);
962
+ /** 「本节点 N h 脉冲事件」口径 (节点/智能体类计数, 以及链上索引不可用时的降级口径) */
963
+ function pulseField(what) {
964
+ return {
965
+ source: 'pulse-events',
966
+ window: 'window-24h',
967
+ short: { zh: `${WINDOW_HOURS}h 脉冲`, en: `${WINDOW_HOURS}h pulse` },
968
+ label: {
969
+ zh: `本节点 ${WINDOW_HOURS}h 观察窗口内收到的脉冲事件 · ${what.zh}`,
970
+ en: `pulse events received by this node within the ${WINDOW_HOURS}h window · ${what.en}`,
971
+ },
972
+ };
973
+ }
974
+ /** 「链上索引 · 全量」口径 (与下方活动表**同源**: 同源即恒等, 页面不许并排两个数) */
975
+ function chainField(what) {
976
+ return {
977
+ source: 'chain-index',
978
+ window: 'full',
979
+ short: { zh: '链上索引·全量', en: 'chain index · whole' },
980
+ label: {
981
+ zh: `链上索引全量 (与下方活动表同源) · ${what.zh}`,
982
+ en: `chain index, whole (same source as the activity table below) · ${what.en}`,
983
+ },
984
+ };
985
+ }
986
+ /** 「本机签名审计账」口径 (真源: `<home>/.bolloon/wallet-signatures.jsonl` 窗口内条数) */
987
+ function signatureAuditField() {
988
+ return {
989
+ source: 'signature-audit',
990
+ window: 'window-24h',
991
+ short: { zh: `${WINDOW_HOURS}h 签名审计`, en: `${WINDOW_HOURS}h signature audit` },
992
+ label: {
993
+ zh: `本机签名审计账 (wallet-signatures.jsonl) ${WINDOW_HOURS}h 内条数 —— 只数条数, 不含签名内容`,
994
+ en: `rows in this node's signature audit log (wallet-signatures.jsonl) within ${WINDOW_HOURS}h — count only, no content`,
995
+ },
996
+ };
997
+ }
998
+ /** 「没有可用源」= 未接入 (值必须是 null; 页面必须写「未接入」, 不许拿 0 冒充「没发生过」) */
999
+ function noneField(why) {
1000
+ return {
1001
+ source: 'none',
1002
+ window: 'unknown',
1003
+ short: { zh: '未接入', en: 'not connected' },
1004
+ label: { zh: `没有可用源 → 报「未接入」而不是 0 · ${why.zh}`, en: `no available source → reported as "not connected", not 0 · ${why.en}` },
1005
+ unavailable: true,
1006
+ };
1007
+ }
640
1008
  export function computeSnapshot(events, opts) {
641
1009
  const now = opts.now;
642
1010
  const fresh_until = now + PULSE_LIMITS.snapshotTtlMs;
643
1011
  if (opts.unavailable) {
644
1012
  const emptyRows = [];
645
1013
  const gates = normalizeActivityGates(DEFAULT_CONFIRMATIONS);
1014
+ // 观察层不可用 = **没有数** (不是 0): 计数一律 null/0 占位由页面整块隐藏, 口径逐字段标「未接入」。
1015
+ const downField = noneField({ zh: '观察层不可用', en: 'observation layer unavailable' });
646
1016
  return {
647
1017
  status: 'unavailable',
648
1018
  generated_at: now,
649
1019
  fresh_until,
650
1020
  scope: 'observed',
651
1021
  scope_label: { zh: '当前节点观察到', en: 'Observed by this node' },
652
- totals: { nodes: 0, agents: 0, active_agents: 0, seen_last_24h: 0, tasks: 0, tasks_completed: 0, tasks_verified: 0, signatures: 0 },
1022
+ totals: {
1023
+ nodes: 0, agents: 0, active_agents: 0, seen_last_24h: 0,
1024
+ tasks: null, tasks_completed: null, tasks_verified: null, signatures: null, tasks_settled: null,
1025
+ },
653
1026
  totals_scope: {
654
1027
  source: 'pulse-events', window_ms: PULSE_LIMITS.windowMs,
655
1028
  label: {
656
- zh: `只统计本节点 ${PULSE_LIMITS.windowMs / 3600000}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
657
- en: `Only pulse events received by this node within the ${PULSE_LIMITS.windowMs / 3600000}h observation window (reported by this node itself)`,
1029
+ zh: `本节点 ${WINDOW_HOURS}h 观察窗口的脉冲事件层不可用 → 没有数 (不是 0)`,
1030
+ en: `This node's ${WINDOW_HOURS}h pulse layer is unavailable → no counts (not zero)`,
658
1031
  },
659
1032
  differs_from_activity: false,
1033
+ fields: {
1034
+ nodes: downField, agents: downField, active_agents: downField, seen_last_24h: downField,
1035
+ tasks: downField, tasks_completed: downField, tasks_verified: downField,
1036
+ tasks_settled: downField, signatures: downField,
1037
+ },
660
1038
  },
661
1039
  capabilities: [],
662
1040
  recent_activity: [],
@@ -664,6 +1042,9 @@ export function computeSnapshot(events, opts) {
664
1042
  confirmed_activity_source: 'none',
665
1043
  activity_totals: summarizeActivityRows(emptyRows, { source: 'none', gates }),
666
1044
  chain_id_scope: buildChainIdScope(emptyRows),
1045
+ // 观察层不可用 ≠ 公告板读不到: 这一支只说明「脉冲事件层」挂了, 待接单任务照实带出来
1046
+ // (调用方没给就空数组 —— 不拿空数组冒充「板上没有」: 页面空态说的是「暂未观察到」)
1047
+ open_tasks: Array.isArray(opts.openTasks) ? opts.openTasks : [],
667
1048
  notes: ['观察层暂不可用 — 这不是"网络为空"'],
668
1049
  };
669
1050
  }
@@ -695,10 +1076,13 @@ export function computeSnapshot(events, opts) {
695
1076
  capabilities.push({ key: 'other', count: otherCount.size });
696
1077
  capabilities.sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1));
697
1078
  }
698
- // 经济计数: 按**不同任务**去重 (同一任务重复事件不虚增), 且只给聚合数不给内容
1079
+ // 经济计数 (**脉冲口径**) —— 注意: 顶部「任务/已完成/已结算」的**权威源是链上索引**
1080
+ // (见下面 chainAuthoritative 那一段), 脉冲口径只在索引不可用时才顶上去。
1081
+ // 按**不同任务**去重 (同一任务重复事件不虚增), 且只给聚合数不给内容
699
1082
  const tasks = new Set();
700
1083
  const tasksCompleted = new Set();
701
1084
  const tasksVerified = new Set();
1085
+ const tasksSettledPulse = new Set();
702
1086
  for (const e of window) {
703
1087
  const t = e.taskProof;
704
1088
  if (!t)
@@ -707,6 +1091,8 @@ export function computeSnapshot(events, opts) {
707
1091
  tasks.add(t);
708
1092
  if (e.type === 'task_completed' || e.type === 'trade_settled')
709
1093
  tasksCompleted.add(t);
1094
+ if (e.type === 'trade_settled')
1095
+ tasksSettledPulse.add(t);
710
1096
  if (e.type === 'trade_verified')
711
1097
  tasksVerified.add(t);
712
1098
  }
@@ -731,15 +1117,62 @@ export function computeSnapshot(events, opts) {
731
1117
  // 同源计数 + 链归属: **输入就是上表那批行**, 所以不可能与行数/链 id 打架
732
1118
  const activity_totals = summarizeActivityRows(activity.rows, { source: activity.source, gates: activity.gates });
733
1119
  const chain_id_scope = buildChainIdScope(activity.rows);
1120
+ // ★ 2026-09-24 (leo: 「数量怎么对不上, 尤其是后面的任务和钱包」):
1121
+ // 顶部任务类计数的**权威源改成链上索引** —— 它与下面那张表**同源**, 同源即恒等,
1122
+ // 从根上消灭「顶部 0 个任务 / 表格 15 行任务」这种并排矛盾 (不再靠 notes 辩解)。
1123
+ // 链上索引不可用 (降级 pulse-events) 时才退回旧的脉冲口径, 并在 fields 里如实标出降级。
1124
+ const chainAuthoritative = activity.source === 'chain-index';
1125
+ const totalsTasks = chainAuthoritative ? activity_totals.tasks : tasks.size;
1126
+ const totalsCompleted = chainAuthoritative ? activity_totals.tasks_completed : tasksCompleted.size;
1127
+ const totalsSettled = chainAuthoritative ? activity_totals.tasks_settled : tasksSettledPulse.size;
1128
+ // 「验真」在链上索引里**没有**对应事件 → 链上口径下这个数没有源 (null + 未接入), 不拿结算数冒充已验证
1129
+ const totalsVerified = chainAuthoritative ? null : tasksVerified.size;
1130
+ // 钱包签名: 真源 = 本机签名审计账 (窗口内条数, 只计数); 没有审计账 → 退回脉冲事件上报数;
1131
+ // 两个都没有 → null (未接入)。**绝不**在没有源的时候报 0 (那是「没发生过」, 是另一句话)。
1132
+ const sigTally = opts.signatureAudit ?? null;
1133
+ const auditWired = !!sigTally && sigTally.available === true;
1134
+ const totalsSignatures = auditWired
1135
+ ? Number(sigTally.count)
1136
+ : (signatureKeys.size > 0 ? signatureKeys.size : null);
1137
+ const fields = {
1138
+ nodes: pulseField({ zh: '不同节点数', en: 'distinct nodes' }),
1139
+ agents: pulseField({ zh: '不同 Agent 数', en: 'distinct agents' }),
1140
+ active_agents: pulseField({ zh: '活跃 Agent 数', en: 'active agents' }),
1141
+ seen_last_24h: pulseField({ zh: '24h 内出现过的不同 Agent', en: 'distinct agents seen within 24h' }),
1142
+ tasks: chainAuthoritative
1143
+ ? chainField({ zh: '有链上活动的不同任务', en: 'distinct tasks with on-chain activity' })
1144
+ : pulseField({ zh: '观察到发起的任务', en: 'tasks observed as created' }),
1145
+ tasks_completed: chainAuthoritative
1146
+ ? chainField({ zh: '交付完成 (ProofSubmittedV2) 的不同任务', en: 'distinct tasks with ProofSubmittedV2' })
1147
+ : pulseField({ zh: '交付完成的任务', en: 'tasks observed as completed' }),
1148
+ tasks_settled: chainAuthoritative
1149
+ ? chainField({ zh: '托管结算 (Released/Refunded/Expired/Disputed) 的不同任务', en: 'distinct tasks with an on-chain escrow settlement' })
1150
+ : pulseField({ zh: '链上口径结算的任务', en: 'tasks with on-chain settlement' }),
1151
+ tasks_verified: chainAuthoritative
1152
+ ? noneField({ zh: '链上索引没有「验真」事件', en: 'the chain index has no verification event' })
1153
+ : pulseField({ zh: '真验真 (trade_verified) 的任务', en: 'tasks truly verified (trade_verified)' }),
1154
+ signatures: auditWired
1155
+ ? signatureAuditField()
1156
+ : (signatureKeys.size > 0
1157
+ ? pulseField({ zh: '上报过的钱包签名 (按来源+时刻去重)', en: 'reported wallet signatures (deduped by source+time)' })
1158
+ : noneField({ zh: '本节点既没有签名审计账, 也没有签名脉冲事件', en: 'this node has neither a signature audit log nor signature pulse events' })),
1159
+ };
734
1160
  const totals_scope = {
735
- source: 'pulse-events',
1161
+ source: chainAuthoritative ? 'mixed' : 'pulse-events',
736
1162
  window_ms: PULSE_LIMITS.windowMs,
737
1163
  label: {
738
- zh: `只统计本节点 ${PULSE_LIMITS.windowMs / 3600000}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
739
- en: `Only pulse events received by this node within the ${PULSE_LIMITS.windowMs / 3600000}h observation window (reported by this node itself)`,
1164
+ zh: chainAuthoritative
1165
+ ? `节点/智能体 = 本节点 ${WINDOW_HOURS}h 脉冲事件; 任务/已完成/已结算 = 链上索引全量 (与下表同源); 钱包签名 = 本机签名审计`
1166
+ : `只统计本节点 ${WINDOW_HOURS}h 观察窗口内收到的脉冲事件 (本节点自己上报的)`,
1167
+ en: chainAuthoritative
1168
+ ? `nodes/agents = this node's ${WINDOW_HOURS}h pulse events; tasks/completed/settled = chain index, whole (same source as the table below); wallet signatures = this node's signature audit log`
1169
+ : `Only pulse events received by this node within the ${WINDOW_HOURS}h observation window (reported by this node itself)`,
740
1170
  },
741
1171
  differs_from_activity: activity_totals.rows > 0 &&
742
- (tasks.size !== activity_totals.tasks || tasksCompleted.size !== activity_totals.tasks_completed),
1172
+ (totalsTasks !== activity_totals.tasks ||
1173
+ totalsCompleted !== activity_totals.tasks_completed ||
1174
+ totalsSettled !== activity_totals.tasks_settled),
1175
+ fields,
743
1176
  };
744
1177
  if (activity.source === 'chain-index') {
745
1178
  notes.push(`confirmed_activity 来自链上索引 (chain-index): ${activity_totals.rows} 行 · ${activity_totals.tasks} 个不同任务 · ` +
@@ -752,10 +1185,21 @@ export function computeSnapshot(events, opts) {
752
1185
  }
753
1186
  // 两套计数口径不同时**必须**解释 —— 页面不许出现「0 个任务」与「N 行任务」并存而不解释
754
1187
  if (totals_scope.differs_from_activity) {
755
- notes.push(`口径不同, 不是数据丢失: totals.tasks/tasks_completed/tasks_verified/signatures 只数本节点 ` +
756
- `${PULSE_LIMITS.windowMs / 3600000}h 窗口内的脉冲事件 (本快照 tasks=${tasks.size} · tasks_completed=${tasksCompleted.size} · ` +
757
- `tasks_verified=${tasksVerified.size} · signatures=${signatureKeys.size}); 上表 ${activity_totals.rows} 行来自链上索引 ` +
758
- `(全量, 不是 ${PULSE_LIMITS.windowMs / 3600000}h 窗口) —— 同源计数见 activity_totals`);
1188
+ notes.push(`口径不同, 不是数据丢失: 链上索引不可用 → 顶部任务类计数**降级为脉冲口径** (本快照 tasks=${tasks.size} · ` +
1189
+ `tasks_completed=${tasksCompleted.size} · tasks_verified=${tasksVerified.size} · tasks_settled=${tasksSettledPulse.size} · ` +
1190
+ `signatures=${signatureKeys.size}); 上表 ${activity_totals.rows} 行来自脉冲事件 (同源计数见 activity_totals, ` +
1191
+ `不是链上索引全量) —— 每个数的来源见 totals_scope.fields`);
1192
+ }
1193
+ if (chainAuthoritative) {
1194
+ notes.push(`顶部「任务/已完成/已结算」= 链上索引的**同源计数** (= activity_totals, 与下表同源, 同源即恒等): ` +
1195
+ `tasks=${totalsTasks} · tasks_completed=${totalsCompleted} · tasks_settled=${totalsSettled}; ` +
1196
+ `「节点/智能体」= 本节点 ${WINDOW_HOURS}h 脉冲事件; 「已验证」在链上索引里没有对应事件 → ` +
1197
+ `该口径无对应事件源, 不下发该字段 (不是 0); 「钱包签名」= ${auditWired
1198
+ ? `本机签名审计账 ${totalsSignatures} 条 (${WINDOW_HOURS}h 窗口, 只计数)`
1199
+ : (totalsSignatures === null
1200
+ ? '无可用源 (本节点既没有签名审计账, 也没有签名脉冲事件), 不下发该字段'
1201
+ : `脉冲事件上报 ${totalsSignatures} 条`)} — ` +
1202
+ `每个数的来源见 totals_scope.fields`);
759
1203
  }
760
1204
  if (activity_totals.rows > 0)
761
1205
  notes.push(chain_id_scope.note.zh);
@@ -772,10 +1216,12 @@ export function computeSnapshot(events, opts) {
772
1216
  agents: agents.size,
773
1217
  active_agents: active.size,
774
1218
  seen_last_24h: agents.size, // 24h 窗口内的不同 Agent
775
- tasks: tasks.size, // 观察到发起的任务数 (聚合, 无内容)
776
- tasks_completed: tasksCompleted.size,
777
- tasks_verified: tasksVerified.size,
778
- signatures: signatureKeys.size, // 真实签名次数 (只计数)
1219
+ // ★ 任务类计数 = 链上索引同源值 (与下表恒等); 索引不可用才退回脉冲口径 (见 fields.tasks)
1220
+ tasks: totalsTasks,
1221
+ tasks_completed: totalsCompleted,
1222
+ tasks_verified: totalsVerified, // 链上口径下 = null (未接入), 不冒充 0
1223
+ signatures: totalsSignatures, // 真源 = 本机签名审计账; 无源 = null (未接入)
1224
+ tasks_settled: totalsSettled, // 新增字段一律排最后 (老 8 个字段顺序逐字不变)
779
1225
  },
780
1226
  totals_scope,
781
1227
  capabilities,
@@ -784,6 +1230,7 @@ export function computeSnapshot(events, opts) {
784
1230
  confirmed_activity_source: activity.source,
785
1231
  activity_totals,
786
1232
  chain_id_scope,
1233
+ open_tasks: Array.isArray(opts.openTasks) ? opts.openTasks : [],
787
1234
  notes,
788
1235
  };
789
1236
  }
@@ -801,8 +1248,10 @@ export async function getNetworkPulse(opts = {}) {
801
1248
  const shapeOk = Array.isArray(cached?.confirmed_activity)
802
1249
  && typeof cached?.confirmed_activity_source === 'string'
803
1250
  && !!cached?.totals_scope
1251
+ && !!cached?.totals_scope?.fields // 老缓存没有逐字段口径 → 过期形状, 重算
804
1252
  && !!cached?.activity_totals
805
- && !!cached?.chain_id_scope;
1253
+ && !!cached?.chain_id_scope
1254
+ && Array.isArray(cached?.open_tasks); // 老缓存没有待接单任务 → 过期形状, 重算
806
1255
  if (shapeOk && cached && Number.isFinite(cached.generated_at) && cached.generated_at + PULSE_LIMITS.snapshotTtlMs > now)
807
1256
  return cached;
808
1257
  }
@@ -817,7 +1266,11 @@ export async function getNetworkPulse(opts = {}) {
817
1266
  }
818
1267
  // 活动行优先取 P5 链上索引 (真链上事实); 索引不可用 → 退回脉冲事件, 并在快照里标出来源
819
1268
  const confirmedActivity = await resolveConfirmedActivity({ home: opts.home, events, now });
820
- const snap = computeSnapshot(events, { now, confirmedActivity });
1269
+ // 钱包签名的**真源**: 本机签名审计账 (窗口内条数)。没有账 → 注入 available:false → 快照标「未接入」
1270
+ // (不拿 0 冒充「没发生过」: 本机明明签过而脉冲事件丢了的那次真事故就是这么来的)
1271
+ const signatureAudit = tallySignatureAudit(opts.home, { now, windowMs: PULSE_LIMITS.windowMs });
1272
+ // 待接单任务: 只读本机公告板目录 (未认领且未过期), 投影成白名单 7 字段 —— 与脉冲事件层无关
1273
+ const snap = computeSnapshot(events, { now, confirmedActivity, openTasks: readOpenTasks(opts.home, now), signatureAudit });
821
1274
  try {
822
1275
  fs.mkdirSync(pulseDir(opts.home), { recursive: true });
823
1276
  fs.writeFileSync(snapshotFile(opts.home), JSON.stringify(snap), 'utf8');
@@ -910,3 +1363,48 @@ export function assertNoPrivateFields(value, at = '$') {
910
1363
  walk(value, at);
911
1364
  return issues;
912
1365
  }
1366
+ /**
1367
+ * 允许出现 0x 长 hex 的键 (**唯一白名单**, 2026-09-23 leo 拍板; 同日收窄): 交易哈希 / escrow 合约地址 /
1368
+ * 交易浏览器链接 —— 都是公开链上事实。除了这 3 个键, 别处出现 0x 长 hex (尤其 EOA 钱包地址) 一律算泄露。
1369
+ * 注意 `contract` **只在数据里**(供索引/诊断), 页面不渲染合约地址也不给合约链接 ⇒ 没有 `explorer_contract` 这个键。
1370
+ */
1371
+ export const PUBLIC_HEX_KEYS = ['tx_hash', 'contract', 'explorer_tx'];
1372
+ /**
1373
+ * 0x 长 hex 越界审计 (**粒度比 `assertNoPrivateFields` 更细**: 那个只看键名, 这个看值形态)。
1374
+ * · 白名单键下: 形状必须精确 —— `tx_hash` = 0x+64 hex; `contract` = 0x+40 hex;
1375
+ * `explorer_tx` = 我们造的**交易**浏览器链接形状 (`EXPLORER_URL_RE`)
1376
+ * · **其他任何键**的值里出现 `0x` + 40/64 位 hex → 报出来 (买方·卖方 EOA、taskKey 原文、
1377
+ * args 里的地址都属这一类); `explorer_contract` 之类不在白名单的键**同样**按泄露处理
1378
+ * 返回越界说明列表 (空 = 干净)。这条尺子**只收紧不放松**: 老口径下「任何 0x 长 hex 都不许」
1379
+ * 现在等价于「除了这 3 个白名单键, 任何 0x 长 hex 都不许」。
1380
+ */
1381
+ export function auditPublicHexLeaks(value, at = '$') {
1382
+ const issues = [];
1383
+ const allow = PUBLIC_HEX_KEYS;
1384
+ const walk = (v, p) => {
1385
+ if (Array.isArray(v))
1386
+ return v.forEach((x, i) => walk(x, `${p}[${i}]`));
1387
+ if (v && typeof v === 'object') {
1388
+ for (const [k, val] of Object.entries(v))
1389
+ walk(val, `${p}.${k}`);
1390
+ return;
1391
+ }
1392
+ if (typeof v !== 'string')
1393
+ return;
1394
+ const s = v;
1395
+ const key = p.slice(p.lastIndexOf('.') + 1);
1396
+ // 白名单键: 形状必须精确 —— 不管值长短都查 (拿短串/怪串冒充同样拒绝)
1397
+ if (allow.includes(key)) {
1398
+ const ok = (key === 'tx_hash' && TX_HASH_RE.test(s))
1399
+ || (key === 'contract' && ADDRESS_RE.test(s))
1400
+ || (key === 'explorer_tx' && EXPLORER_URL_RE.test(s));
1401
+ if (!ok)
1402
+ issues.push(`${p} = 白名单键 ${key} 下的形状不对 (只认精确形状, 不认"含 0x 就算")`);
1403
+ return;
1404
+ }
1405
+ if (/0x[0-9a-fA-F]{40}/.test(s))
1406
+ issues.push(`${p} = 0x 长 hex (只许出现在 ${allow.join('/')} 下) → 按泄露处理`);
1407
+ };
1408
+ walk(value, at);
1409
+ return issues;
1410
+ }