@bolloon/bolloon-agent 0.4.30 → 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 (68) hide show
  1. package/dist/agents/agent-gateway.js +5 -0
  2. package/dist/agents/agent-manifest-protocol.js +29 -0
  3. package/dist/agents/chain/chain-config.js +504 -0
  4. package/dist/agents/chain/chain-index-query.js +135 -0
  5. package/dist/agents/chain/chain-indexer.js +1045 -0
  6. package/dist/agents/chain/chain-settlement.js +284 -0
  7. package/dist/agents/chain/chain-state-store.js +245 -0
  8. package/dist/agents/chain/chain-wallet.js +198 -0
  9. package/dist/agents/chain/escrow-client.js +433 -0
  10. package/dist/agents/chain/index.js +20 -0
  11. package/dist/agents/chain/onchain-trade.js +691 -0
  12. package/dist/agents/economic-policy.js +7 -0
  13. package/dist/agents/gateway-network.js +6 -0
  14. package/dist/agents/local-signer.js +59 -0
  15. package/dist/agents/network-pulse.js +912 -0
  16. package/dist/agents/task/task-onchain-runner.js +284 -0
  17. package/dist/agents/task-contract.js +322 -0
  18. package/dist/agents/task-inbox.js +267 -0
  19. package/dist/agents/task-transport.js +280 -0
  20. package/dist/agents/x402/paid-info-store.js +75 -6
  21. package/dist/agents/x402/transaction-store.js +8 -1
  22. package/dist/cli/commands/agent.js +230 -0
  23. package/dist/cli/commands/chain.js +926 -0
  24. package/dist/cli/commands/index.js +69 -0
  25. package/dist/cli/commands/mcp.js +98 -0
  26. package/dist/cli/commands/network.js +334 -0
  27. package/dist/cli/commands/payment.js +125 -0
  28. package/dist/cli/commands/tasks.js +1236 -0
  29. package/dist/cli/commands/trade.js +229 -0
  30. package/dist/cli/commands/wallet.js +450 -0
  31. package/dist/cli/mcp/bridge.js +105 -0
  32. package/dist/cli/mcp/server.js +188 -0
  33. package/dist/cli/mcp/tools.js +955 -0
  34. package/dist/cli/protocol-envelope.js +358 -0
  35. package/dist/cli-entry.js +146 -17
  36. package/dist/electron-build/agents/agent-gateway.js +5 -0
  37. package/dist/electron-build/agents/agent-gateway.js.map +1 -1
  38. package/dist/electron-build/agents/agent-manifest-protocol.js +62 -0
  39. package/dist/electron-build/agents/agent-manifest-protocol.js.map +1 -1
  40. package/dist/electron-build/agents/chain/chain-config.js +556 -0
  41. package/dist/electron-build/agents/chain/chain-config.js.map +1 -0
  42. package/dist/electron-build/agents/chain/chain-index-query.js +145 -0
  43. package/dist/electron-build/agents/chain/chain-index-query.js.map +1 -0
  44. package/dist/electron-build/agents/chain/chain-indexer.js +1095 -0
  45. package/dist/electron-build/agents/chain/chain-indexer.js.map +1 -0
  46. package/dist/electron-build/agents/chain/chain-settlement.js +291 -0
  47. package/dist/electron-build/agents/chain/chain-settlement.js.map +1 -0
  48. package/dist/electron-build/agents/chain/escrow-client.js +443 -0
  49. package/dist/electron-build/agents/chain/escrow-client.js.map +1 -0
  50. package/dist/electron-build/agents/economic-policy.js +7 -0
  51. package/dist/electron-build/agents/economic-policy.js.map +1 -1
  52. package/dist/electron-build/agents/gateway-network.js +6 -0
  53. package/dist/electron-build/agents/gateway-network.js.map +1 -1
  54. package/dist/electron-build/agents/network-pulse.js +979 -0
  55. package/dist/electron-build/agents/network-pulse.js.map +1 -0
  56. package/dist/electron-build/agents/x402/paid-info-store.js +76 -6
  57. package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
  58. package/dist/electron-build/agents/x402/transaction-store.js +8 -1
  59. package/dist/electron-build/agents/x402/transaction-store.js.map +1 -1
  60. package/dist/web/chain-explorer.js +617 -0
  61. package/dist/web/explorer.html +163 -0
  62. package/dist/web/index.html +5 -0
  63. package/dist/web/routes-chain-index.js +108 -0
  64. package/dist/web/routes-onchain-trade.js +99 -0
  65. package/dist/web/server.js +42 -0
  66. package/dist/web/task-frame-server.js +211 -0
  67. package/package.json +2 -1
  68. package/scripts/build-web.ts +20 -1
@@ -0,0 +1,912 @@
1
+ /**
2
+ * network-pulse.ts — 网络脉冲 (Network Pulse): 把已有 P2P 生命周期投影成
3
+ * **匿名、可验证、可降级**的公开统计, 供 bolloon-UI 网关页动态展示。
4
+ *
5
+ * 设计约束 (leo 2026-09-18 计划):
6
+ * · 只对**公开统计**有用的事实留档: 不存任务正文、不存私有 payload
7
+ * · 浏览器**永远不接触原始事件** —— 对外只有聚合快照
8
+ * · 不暴露 DID / peerId / IP / 钱包地址 / Agent 私有内容
9
+ * · 单节点看到的数据**不许说成全网精确总量**: scope=observed / verified 明确写在快照里
10
+ * · 快照过期 → `stale` (不许伪装实时); 观察层不可用 → `unavailable`
11
+ * · 类别小于隐私阈值 → 合并进 other
12
+ * · 事件数 / 时间窗 / capability 数都有上限
13
+ * · **冻结形状 `confirmed_activity`** (2026-09-22): 公开页面要能列出「哪个任务 · 什么状态 ·
14
+ * 哪个块 · 多少确认」, 而不是只有数字 —— 数据源优先 P5 链上索引, 不可用时退回脉冲事件并
15
+ * 用 `confirmed_activity_source` 标注; 标识一律 sha256 短写 (前 8 位), 绝不落 taskKey /
16
+ * taskId / txHash 原文; 不够确认门槛的行只报 observed, 不冒充 confirmed。
17
+ *
18
+ * 借鉴 (只借产品与工程思想, 不借 Go/Postgres/API 项目):
19
+ * EigenFlux 的 "注册总量 / 当前活跃 / 最近出现" 时间窗统计、服务端生成匿名活动文本、
20
+ * live/stale/unavailable 状态、短缓存与时间边界、隐私阈值、白名单投影。
21
+ */
22
+ import * as fs from 'fs';
23
+ import * as os from 'os';
24
+ import * as path from 'path';
25
+ import * as crypto from 'crypto';
26
+ // 确认数门槛来自 chain-config (单一实现; 默认 confirmed=1 / finalized=12) —— 只借常量, 不借 RPC
27
+ import { DEFAULT_CONFIRMATIONS, LOCAL_DEV_CHAIN_ID } from './chain/chain-config.js';
28
+ // ── 常量 (上限 + 时间边界) ─────────────────────────────────────────────────
29
+ export const PULSE_LIMITS = {
30
+ /** 最多留多少条事件 (超出丢最旧) */
31
+ maxEvents: 5000,
32
+ /** 统计窗口 */
33
+ windowMs: 24 * 60 * 60 * 1000,
34
+ /** "活跃"的定义: 最近 5 分钟出现过 */
35
+ activeWindowMs: 5 * 60 * 1000,
36
+ /** 时间桶 (小时) */
37
+ bucketMs: 60 * 60 * 1000,
38
+ /** 对外最多列多少个 capability 类别 */
39
+ maxCapabilities: 12,
40
+ /** 隐私阈值: 少于这个数的类别合并进 other */
41
+ privacyThreshold: 3,
42
+ /** 快照新鲜期 (秒) → 之后 stale */
43
+ snapshotTtlMs: 30 * 1000,
44
+ /** 活动流最多几条 */
45
+ maxActivity: 8,
46
+ /** confirmed_activity (冻结形状的真实活动行) 上限 */
47
+ maxConfirmedActivity: 25,
48
+ };
49
+ /** 事件类型白名单 —— 只允许这些进统计 (其余一律丢弃) */
50
+ export const NETWORK_EVENT_TYPES = [
51
+ 'node_joined',
52
+ 'manifest_published',
53
+ 'capability_announced',
54
+ 'peer_connected',
55
+ 'delegation_completed',
56
+ // 经济事件 (P6 扩展; 原五类保持兼容, 老节点发来的事件仍被接受)
57
+ 'task_posted', 'task_accepted', 'task_completed', 'trade_settled', 'trade_verified',
58
+ 'wallet_signed',
59
+ ];
60
+ // ── 存储 ────────────────────────────────────────────────────────────────────
61
+ const home = (h) => h || process.env.HOME || os.homedir() || '/tmp';
62
+ export const pulseDir = (h) => path.join(home(h), '.bolloon', 'network-pulse');
63
+ const eventsFile = (h) => path.join(pulseDir(h), 'events.json');
64
+ const snapshotFile = (h) => path.join(pulseDir(h), 'snapshot.json');
65
+ /** 是否是一条结构合法的事件 (坏数据一律丢弃, 不让快照崩) */
66
+ function isValidEvent(e) {
67
+ return !!e && typeof e === 'object'
68
+ && NETWORK_EVENT_TYPES.includes(e.type)
69
+ && typeof e.sourceProof === 'string' && e.sourceProof.length > 0
70
+ && Number.isFinite(e.occurredAt);
71
+ }
72
+ function readEvents(h) {
73
+ try {
74
+ const raw = JSON.parse(fs.readFileSync(eventsFile(h), 'utf-8'));
75
+ return Array.isArray(raw) ? raw.filter(isValidEvent) : [];
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ }
81
+ function writeEvents(list, h) {
82
+ try {
83
+ fs.mkdirSync(pulseDir(h), { recursive: true });
84
+ fs.writeFileSync(eventsFile(h), JSON.stringify(list.slice(-PULSE_LIMITS.maxEvents)), 'utf8');
85
+ }
86
+ catch {
87
+ /* 统计失败绝不影响主路径 */
88
+ }
89
+ }
90
+ // ── 匿名化 ──────────────────────────────────────────────────────────────────
91
+ /** 节点摘要: sha256(值) 前 16 位。不可逆, 不暴露原值。 */
92
+ export function nodeDigest(value) {
93
+ return crypto.createHash('sha256').update(`bolloon-pulse|${String(value)}`).digest('hex').slice(0, 16);
94
+ }
95
+ const CAPABILITY_GROUPS = [
96
+ [/research|market|调研|分析|survey|search/i, 'research'],
97
+ [/code|review|refactor|debug|编程|代码/i, 'coding'],
98
+ [/data|dataset|etl|metric|数据/i, 'data'],
99
+ [/write|content|doc|translat|写作|文档|翻译/i, 'writing'],
100
+ [/automation|workflow|deploy|ops|自动化/i, 'automation'],
101
+ [/vision|image|audio|speech|视觉|图像|语音/i, 'multimodal'],
102
+ ];
103
+ /** 把原始能力名归并成粗类别 (不外泄原始能力名) */
104
+ export function capabilityGroup(raw) {
105
+ const s = String(raw || '').trim();
106
+ if (!s)
107
+ return 'other';
108
+ for (const [re, g] of CAPABILITY_GROUPS)
109
+ if (re.test(s))
110
+ return g;
111
+ return 'other';
112
+ }
113
+ export function bucketOf(at) {
114
+ return String(Math.floor(at / PULSE_LIMITS.bucketMs));
115
+ }
116
+ /** 完整性标记 (本地校验用; 不含任何可逆信息) */
117
+ function integrityOf(ev) {
118
+ return nodeDigest(`${ev.type}|${ev.bucket}|${ev.sourceProof}|${ev.occurredAt}`);
119
+ }
120
+ /**
121
+ * 记一条网络事件。**永不影响主路径**: 非法类型/写失败/异常一律静默丢弃。
122
+ * 幂等: 同一节点同一桶内的 `node_joined` 只记一次 (重复 join 不虚增节点数)。
123
+ */
124
+ export async function recordNetworkEvent(input, h) {
125
+ try {
126
+ const type = String(input?.type || '');
127
+ if (!NETWORK_EVENT_TYPES.includes(type))
128
+ return { ok: false, reason: `非白名单事件类型: ${type}` };
129
+ const at = Number(input.at ?? Date.now());
130
+ if (!Number.isFinite(at) || at <= 0)
131
+ return { ok: false, reason: 'occurredAt 非法' };
132
+ const sourceProof = nodeDigest(String(input.did || 'unknown-node'));
133
+ const bucket = bucketOf(at);
134
+ const agentProof = input.agentId ? nodeDigest(`${String(input.did || 'unknown-node')}:${String(input.agentId)}`) : undefined;
135
+ const taskProof = input.taskId ? nodeDigest(`task:${String(input.taskId)}`) : undefined;
136
+ const list = readEvents(h);
137
+ if (type === 'node_joined' && list.some((e) => e.type === 'node_joined' && e.sourceProof === sourceProof && e.bucket === bucket)) {
138
+ return { ok: true, reason: 'already' }; // 同桶重复入网不虚增
139
+ }
140
+ const ev = {
141
+ type,
142
+ bucket,
143
+ occurredAt: at,
144
+ sourceProof,
145
+ ...(input.capability ? { capabilityGroup: capabilityGroup(input.capability) } : {}),
146
+ ...(agentProof ? { agentProof } : {}),
147
+ ...(taskProof ? { taskProof } : {}),
148
+ ...(input.signed ? { signed: true } : {}),
149
+ };
150
+ ev.integrity = integrityOf(ev);
151
+ list.push(ev);
152
+ // 只保留窗口内 + 上限
153
+ const cutoff = at - PULSE_LIMITS.windowMs;
154
+ writeEvents(list.filter((e) => e.occurredAt >= cutoff), h);
155
+ return { ok: true };
156
+ }
157
+ catch (e) {
158
+ return { ok: false, reason: String(e?.message || e) };
159
+ }
160
+ }
161
+ // ── 快照 ────────────────────────────────────────────────────────────────────
162
+ /** 服务端固定模板生成匿名活动文本 (不让前端拼字符串) */
163
+ export function renderActivityText(kind) {
164
+ switch (kind) {
165
+ case 'node_joined': return { zh: '有新节点加入网络', en: 'A node joined the network' };
166
+ case 'manifest_published': return { zh: '有节点发布/更新了能力声明', en: 'A node published or updated its manifest' };
167
+ case 'capability_announced': return { zh: '有节点公开声明了新能力', en: 'A node announced a capability' };
168
+ case 'peer_connected': return { zh: '两个节点建立了连接', en: 'Two nodes connected' };
169
+ case 'delegation_completed': return { zh: '一次能力委派完成', en: 'A delegation completed' };
170
+ default: return { zh: '网络有活动', en: 'Network activity' };
171
+ }
172
+ }
173
+ export const MAX_AGENT_SITES = 5;
174
+ /** 归一化 IPNS 标识: 只接受裸 k51…/12D3… · ipns://… · /ipns/… —— 其它一律拒绝 (不猜、不放行) */
175
+ export function normalizeIpns(raw) {
176
+ const s = String(raw || '').trim();
177
+ if (!s)
178
+ return null;
179
+ let v = s;
180
+ if (/^ipns:\/\//i.test(v))
181
+ v = v.replace(/^ipns:\/\//i, '');
182
+ else if (/^\/ipns\//i.test(v))
183
+ v = v.replace(/^\/ipns\//i, '');
184
+ else if (/https?:\/\//i.test(v))
185
+ return null; // 不接受 http(s) 直链
186
+ v = v.split(/[/?#]/)[0].trim();
187
+ if (/^(k51|12D3)[a-zA-Z0-9]{20,}$/.test(v) || /^[a-zA-Z0-9]{46,}$/.test(v))
188
+ return v;
189
+ return null;
190
+ }
191
+ /** 读本节点显式发布的私有站清单 (~/.bolloon/agent-sites.json); 不存在/坏数据 → 空数组 */
192
+ export function readAgentSites(h) {
193
+ try {
194
+ const file = path.join(home(h), '.bolloon', 'agent-sites.json');
195
+ const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
196
+ if (!Array.isArray(raw))
197
+ return [];
198
+ const out = [];
199
+ const seen = new Set();
200
+ for (const item of raw) {
201
+ const ipns = normalizeIpns(item?.ipns);
202
+ if (!ipns || seen.has(ipns))
203
+ continue;
204
+ seen.add(ipns);
205
+ out.push({
206
+ label: String(item?.label || 'agent').slice(0, 40),
207
+ ipns,
208
+ added_at: Number(item?.added_at) || 0,
209
+ });
210
+ if (out.length >= MAX_AGENT_SITES)
211
+ break;
212
+ }
213
+ return out;
214
+ }
215
+ catch {
216
+ return [];
217
+ }
218
+ }
219
+ /**
220
+ * 把**真实交易活动**投影成脉冲事件 (由交易写路径调用, fire-and-forget)。
221
+ * 只按事实发: 交付 → task_completed · 真验真 → trade_verified · **链上口径**结算 → trade_settled。
222
+ * local-dev 上限是 payment_submitted, 永远进不了 trade_settled 那一支 —— 不冒充链上。
223
+ */
224
+ export async function emitTradePulse(before, after, h) {
225
+ try {
226
+ if (!after)
227
+ return;
228
+ const taskId = String(after.requestId || after.transactionId || '');
229
+ if (!taskId)
230
+ return;
231
+ const did = String(after.buyerDid || after.providerDid || after.payTo || after.paymentMode || 'unknown-node');
232
+ const agentId = String(after.agentId || after.runId || after.goalId || '') || undefined;
233
+ const base = { did, agentId, taskId, signed: true };
234
+ if (after.status === 'delivered' && before?.status !== 'delivered') {
235
+ await recordNetworkEvent({ type: 'task_completed', ...base }, h);
236
+ }
237
+ if (after.status === 'verified' && before?.status !== 'verified') {
238
+ await recordNetworkEvent({ type: 'trade_verified', ...base }, h);
239
+ }
240
+ if (after.settlementFact === 'fully_settled' && before?.settlementFact !== 'fully_settled') {
241
+ await recordNetworkEvent({ type: 'trade_settled', ...base }, h);
242
+ }
243
+ }
244
+ catch {
245
+ /* 统计失败绝不影响交易主路径 */
246
+ }
247
+ }
248
+ // ── 冻结形状: confirmed_activity (真实智能体任务 / 链上活动行) ────────────────
249
+ //
250
+ // 公开网页要能列出「哪个任务 · 什么状态 · 哪个块 · 多少确认」, 而不是只有数字。这块就是那批行。
251
+ // 形状**冻结** (字段名与取值域不许改), 内容一律**匿名短写**:
252
+ // · task = `sha256:` + sha256(域标签|taskKey) 的前 8 位十六进制 —— 绝不落 taskKey / taskId 原文
253
+ // · tx = `sha256:` + sha256(域标签|txHash) 的前 8 位十六进制 —— 绝不落 txHash 原文
254
+ // · 不出现 DID / peerId / IP / 完整钱包地址 / 任务正文 —— `assertNoPrivateFields` 兜底
255
+ // 数据源优先级: ① P5 链上索引 (真链上事实) → ② 索引不可用 → 退回脉冲事件, 并在快照里用
256
+ // `confirmed_activity_source` 如实标注 (chain-index / pulse-events / none)。
257
+ // 确认数口径走 chain-config 门槛 (默认 confirmed=1 / finalized=12): 够不着门槛的行只报
258
+ // `observed`, **绝不冒充** confirmed/finalized。
259
+ /** 冻结形状: 活动行上限 */
260
+ export const CONFIRMED_ACTIVITY_LIMIT = PULSE_LIMITS.maxConfirmedActivity;
261
+ /**
262
+ * 链上 escrow 事件名 → (kind, state) 的**唯一**映射表 (改这里才对, 别在别处写 switch)。
263
+ *
264
+ * kind 只能取冻结枚举的 5 个值, 链上有 6 类事件 → 按「任务生命周期 / 托管资金路径」归并:
265
+ * · EscrowCreatedV2 委托创建 + 资金入托管 → task_created / active
266
+ * · ProofSubmittedV2 交付物验真摘要落链 (= 该笔交付完成) → task_completed / active (托管仍 ACTIVE, 等结算)
267
+ * · ReleasedV2 结算出金 → trade_settled / released
268
+ * · RefundedV2 托管退款 → trade_settled / refunded
269
+ * · ExpiredV2 托管到期 → trade_settled / expired
270
+ * · DisputedV2 资金冻结进入争议 (托管资金路径) → trade_settled / disputed
271
+ * 真信号在 `state` (kind 只是粗桶); 事件名不在表里 → 该条**不成行** (不猜)。
272
+ */
273
+ export const CHAIN_EVENT_ACTIVITY = {
274
+ EscrowCreatedV2: { kind: 'task_created', state: 'active' },
275
+ ProofSubmittedV2: { kind: 'task_completed', state: 'active' },
276
+ ReleasedV2: { kind: 'trade_settled', state: 'released' },
277
+ RefundedV2: { kind: 'trade_settled', state: 'refunded' },
278
+ ExpiredV2: { kind: 'trade_settled', state: 'expired' },
279
+ DisputedV2: { kind: 'trade_settled', state: 'disputed' },
280
+ };
281
+ /** 脉冲事件 → 冻结 kind 的映射 (索引不可用时的降级路径; 没有 taskProof 的事件不成行) */
282
+ export const PULSE_EVENT_ACTIVITY = {
283
+ task_posted: 'task_created',
284
+ task_accepted: 'task_accepted',
285
+ task_completed: 'task_completed',
286
+ trade_settled: 'trade_settled',
287
+ trade_verified: 'trade_verified',
288
+ };
289
+ /** 标识 → `sha256:<前 8 位十六进制>`。域标签隔离用途: 同一原值稳定 (可 join), 不同用途不串, 不可逆。 */
290
+ export function anonShortRef(value, domain = 'id') {
291
+ return `sha256:${nodeDigest(`${domain}|${String(value)}`).slice(0, 8)}`;
292
+ }
293
+ /** ISO8601 UTC 秒级 (`2026-09-22T05:31:00Z`) —— 冻结形状里 `at` 的格式。非法时间 → null */
294
+ export function isoSeconds(at) {
295
+ const n = Number(at);
296
+ if (!Number.isFinite(n) || n <= 0)
297
+ return null;
298
+ try {
299
+ return new Date(n).toISOString().replace(/\.\d{3}Z$/, 'Z');
300
+ }
301
+ catch {
302
+ return null;
303
+ }
304
+ }
305
+ /** 确认门槛归一化: 缺/非法 → chain-config 默认 (1/12); finalized 不许低于 confirmed */
306
+ export function normalizeActivityGates(g) {
307
+ const c = Number(g?.confirmed);
308
+ const f = Number(g?.finalized);
309
+ const confirmed = Number.isInteger(c) && c > 0 ? c : DEFAULT_CONFIRMATIONS.confirmed;
310
+ const finalized = Number.isInteger(f) && f >= confirmed ? f : Math.max(confirmed, DEFAULT_CONFIRMATIONS.finalized);
311
+ return { confirmed, finalized };
312
+ }
313
+ /**
314
+ * finality **只按确认数复算** (单一实现): 够 finalized → finalized; 够 confirmed → confirmed;
315
+ * 不够 / 非法 / 被回退过 → observed。行里原本自报的 finality 一律不复用 —— 不满足就是 observed。
316
+ */
317
+ export function finalityFromConfirmations(confirmations, gates, opts = {}) {
318
+ if (opts.suspect)
319
+ return 'observed';
320
+ const g = normalizeActivityGates(gates);
321
+ const c = Number(confirmations);
322
+ if (!Number.isFinite(c) || c < g.confirmed)
323
+ return 'observed';
324
+ return c >= g.finalized ? 'finalized' : 'confirmed';
325
+ }
326
+ function activityLimit(limit) {
327
+ const n = Math.floor(Number(limit));
328
+ if (!Number.isFinite(n))
329
+ return CONFIRMED_ACTIVITY_LIMIT;
330
+ return Math.max(0, Math.min(CONFIRMED_ACTIVITY_LIMIT, n));
331
+ }
332
+ /**
333
+ * 链上索引条目 → 冻结活动行 (**纯函数**, 不读盘不发 RPC, 便于单测)。
334
+ * · suspect (被回退 / 链上已消失) 的记录**不成行** —— 它已经不在规范链上, 不该当活动列出
335
+ * · 不认识的事件名 / 非法 taskKey·txHash / 缺观察时间 → 跳过 (不猜、不臆造)
336
+ * · confirmations: 有 headBlock → head - block + 1 复算 (无 RPC, 用索引快照里的 head); 否则用记录值
337
+ * · finality: 按 chain-config 门槛复算 (不够 → observed)
338
+ * · 最新在前 (blockNumber desc, logIndex desc), 上限 25 行
339
+ */
340
+ export function buildConfirmedActivityFromIndex(entries, opts = {}) {
341
+ const gates = normalizeActivityGates(opts.gates);
342
+ const limit = activityLimit(opts.limit);
343
+ const chainId = Number.isInteger(Number(opts.chainId)) && Number(opts.chainId) >= 0 ? Number(opts.chainId) : 0;
344
+ const head = Number.isInteger(Number(opts.headBlock)) && Number(opts.headBlock) >= 0 ? Number(opts.headBlock) : null;
345
+ const out = [];
346
+ for (const e of Array.isArray(entries) ? entries : []) {
347
+ if (!e || e.suspect === true)
348
+ continue; // 回退过的记录不在规范链上 → 不成行
349
+ const map = CHAIN_EVENT_ACTIVITY[String(e.eventName || '')];
350
+ if (!map)
351
+ continue; // 不认识的事件 → 跳过 (不猜)
352
+ const taskKey = String(e.taskKey || '').toLowerCase();
353
+ const txHash = String(e.txHash || '').toLowerCase();
354
+ if (!/^0x[0-9a-f]{64}$/.test(taskKey) || !/^0x[0-9a-f]{64}$/.test(txHash))
355
+ continue;
356
+ const block = Number(e.blockNumber);
357
+ const logIndex = Number(e.logIndex);
358
+ if (!Number.isInteger(block) || block < 0 || !Number.isInteger(logIndex) || logIndex < 0)
359
+ continue;
360
+ const atMs = Number(e.firstSeenAt);
361
+ const at = isoSeconds(atMs);
362
+ if (!at)
363
+ continue; // 没观察时间 → 不臆造时间, 不成行
364
+ const confirmations = head == null ? Math.max(0, Number(e.confirmations) || 0) : Math.max(0, head - block + 1);
365
+ out.push({
366
+ block, logIndex,
367
+ row: {
368
+ task: anonShortRef(taskKey, 'task'),
369
+ kind: map.kind,
370
+ state: map.state,
371
+ chain_id: chainId,
372
+ block,
373
+ tx: anonShortRef(txHash, 'tx'),
374
+ confirmations,
375
+ finality: finalityFromConfirmations(confirmations, gates),
376
+ at,
377
+ },
378
+ });
379
+ }
380
+ out.sort((a, b) => b.block - a.block || b.logIndex - a.logIndex);
381
+ return out.slice(0, limit).map((x) => x.row);
382
+ }
383
+ /**
384
+ * 脉冲事件 → 冻结活动行 (**纯函数**, 索引不可用时的降级路径)。
385
+ * · 只有带 taskProof 的经济事件成行; state 一律 `unknown` —— 脉冲事件不带 escrow 结局, 不推
386
+ * · 没有链上事实: chain_id / block / confirmations = 0, finality = observed (绝不冒充已确认)
387
+ * · tx = 事件摘要短写 (不是交易哈希 —— 降级行没有 txHash, 字段名冻结只能这样填)
388
+ * · 同一 (task, kind) 只留最新一条; 最新在前, 上限 25 行
389
+ */
390
+ export function buildConfirmedActivityFromEvents(events, opts = {}) {
391
+ const limit = activityLimit(opts.limit);
392
+ if (limit <= 0)
393
+ return [];
394
+ const all = [];
395
+ for (const e of Array.isArray(events) ? events : []) {
396
+ if (!isValidEvent(e))
397
+ continue;
398
+ const kind = PULSE_EVENT_ACTIVITY[e.type];
399
+ if (!kind)
400
+ continue; // 非经济事件 (入网/能力/连接…) → 不成行
401
+ const proof = String(e.taskProof || '').toLowerCase();
402
+ if (!/^[0-9a-f]{16}$/.test(proof))
403
+ continue; // 没有任务摘要 → 不成行
404
+ const at = isoSeconds(e.occurredAt);
405
+ if (!at)
406
+ continue;
407
+ all.push({
408
+ at: e.occurredAt,
409
+ row: {
410
+ task: `sha256:${proof.slice(0, 8)}`,
411
+ kind,
412
+ state: 'unknown',
413
+ chain_id: 0,
414
+ block: 0,
415
+ tx: anonShortRef(`pulse|${e.sourceProof}|${e.occurredAt}|${e.type}`, 'tx'),
416
+ confirmations: 0,
417
+ finality: 'observed',
418
+ at,
419
+ },
420
+ });
421
+ }
422
+ all.sort((a, b) => b.at - a.at); // 最新在前
423
+ const seen = new Set();
424
+ const rows = [];
425
+ for (const x of all) {
426
+ const key = `${x.row.task}|${x.row.kind}`;
427
+ if (seen.has(key))
428
+ continue; // 同一任务同一动作只留最新一条
429
+ seen.add(key);
430
+ rows.push(x.row);
431
+ if (rows.length >= limit)
432
+ break;
433
+ }
434
+ return rows;
435
+ }
436
+ /** 纯函数降级: 直接从脉冲事件算活动行 (没有索引时用, source 如实写 pulse-events / none) */
437
+ export function confirmedActivityFromEvents(events, limit) {
438
+ const rows = buildConfirmedActivityFromEvents(events, { limit });
439
+ return { source: rows.length ? 'pulse-events' : 'none', rows, gates: normalizeActivityGates(DEFAULT_CONFIRMATIONS) };
440
+ }
441
+ /** 惰性加载链上索引只读查询模块 (只读索引文件, 不发 RPC; 加载/读取失败 → 降级) */
442
+ let chainQueryModule = null;
443
+ async function readChainIndex(home) {
444
+ try {
445
+ if (!chainQueryModule)
446
+ chainQueryModule = (await import('./chain/chain-index-query.js'));
447
+ return chainQueryModule.readIndexFile({ home });
448
+ }
449
+ catch {
450
+ return null;
451
+ }
452
+ }
453
+ /**
454
+ * 解析活动行 (唯一入口): **先链上索引, 再脉冲降级**, 并如实报出来源。
455
+ * 索引文件不存在/读不出/没有任何可用行 → 退回脉冲事件; 两边都没有 → none (不编行)。
456
+ */
457
+ export async function resolveConfirmedActivity(q) {
458
+ const now = Number(q.now ?? Date.now());
459
+ const limit = activityLimit(q.limit);
460
+ const windowed = (Array.isArray(q.events) ? q.events : [])
461
+ .filter((e) => isValidEvent(e) && e.occurredAt >= now - PULSE_LIMITS.windowMs);
462
+ // ① 链上索引优先 (真链上事实)
463
+ const reader = q.readIndex ?? readChainIndex;
464
+ try {
465
+ const file = await reader(q.home);
466
+ const entries = Array.isArray(file?.entries) ? file.entries : [];
467
+ if (entries.length > 0) {
468
+ const gates = normalizeActivityGates(file?.confirmations);
469
+ const rows = buildConfirmedActivityFromIndex(entries, {
470
+ gates,
471
+ headBlock: file?.headBlock,
472
+ chainId: file?.chainId,
473
+ limit,
474
+ });
475
+ if (rows.length > 0)
476
+ return { source: 'chain-index', rows, gates };
477
+ }
478
+ }
479
+ catch { /* 索引不可用 → 降级 (来源会在快照里标明) */ }
480
+ // ② 降级: 脉冲事件
481
+ return confirmedActivityFromEvents(windowed, limit);
482
+ }
483
+ // ── 同源计数 · 口径说明 · 链归属 (2026-09-22: 公开页数字不许自相矛盾) ──────────
484
+ //
485
+ // 起因: 真快照里 `totals.tasks=0` 而 `confirmed_activity` 有 25 行真实任务 —— 两块数据来自
486
+ // **两套口径** (totals = 24h 脉冲事件窗口; confirmed_activity = 链上索引全量), 但页面上它们同屏,
487
+ // 读者只会读成"自相矛盾/在撒谎"。修法不是把数字改漂亮, 而是:
488
+ // ① `activity_totals` = 与 confirmed_activity **同源**的计数 (rows 恒等于该数组长度);
489
+ // ② `totals_scope` = totals 的口径说明 + `differs_from_activity` 标记;
490
+ // ③ `chain_id_scope` = 上表各行属于哪条链 (本机 31337 ≠ 真网 Base Sepolia 84532);
491
+ // ④ `snapshotConsistencyIssues` = 导出前的自检 (两个数字打架就拒绝导出)。
492
+ /** 公网测试网 (Base Sepolia) chainId —— 只做**归属说明**, 不代表快照观察到了公网事件 */
493
+ export const PUBLIC_TESTNET_CHAIN_ID = 84532;
494
+ /** 公网主网 (Base) chainId —— 同上, 只做归属说明 */
495
+ export const PUBLIC_MAINNET_CHAIN_ID = 8453;
496
+ /**
497
+ * chainId → 展示用网络名 (只回答"这一屏的行属于哪条链")。
498
+ * 认不出的 chain id **不编名字** (chainLabelOf → null), 也不许算成公网。
499
+ */
500
+ export const CHAIN_LABELS = {
501
+ [LOCAL_DEV_CHAIN_ID]: { zh: '本机隔离开发链', en: 'local isolated dev chain', publicNetwork: false },
502
+ [PUBLIC_TESTNET_CHAIN_ID]: { zh: 'Base Sepolia 测试网', en: 'Base Sepolia testnet', publicNetwork: true },
503
+ [PUBLIC_MAINNET_CHAIN_ID]: { zh: 'Base 主网', en: 'Base mainnet', publicNetwork: true },
504
+ };
505
+ /** chainId 的展示名; 认不出的 chain id → null (不编名字) */
506
+ export function chainLabelOf(chainId) {
507
+ const id = Number(chainId);
508
+ return Number.isInteger(id) ? CHAIN_LABELS[id] ?? null : null;
509
+ }
510
+ /** 是不是公网链 id (白名单; 认不出的一律**不算**公网 —— 不替读者认领归属) */
511
+ export function isPublicChainId(chainId) {
512
+ return chainLabelOf(chainId)?.publicNetwork === true;
513
+ }
514
+ /**
515
+ * 活动行 → 同源计数 (**纯函数**; 输入就是 `confirmed_activity` 本身)。
516
+ * 存在的唯一理由: 公开页同时显示 totals 与本表行数, 两个数字肉眼可能"打架" ——
517
+ * 那就让它们同源可核: `activity_totals.rows` 恒等于 `confirmed_activity.length`。
518
+ */
519
+ export function summarizeActivityRows(rows, opts) {
520
+ const list = (Array.isArray(rows) ? rows : []).filter((r) => !!r);
521
+ const tasks = new Set();
522
+ const completed = new Set();
523
+ const settled = new Set();
524
+ const by_finality = { observed: 0, confirmed: 0, finalized: 0 };
525
+ for (const r of list) {
526
+ const t = String(r.task || '');
527
+ if (t)
528
+ tasks.add(t);
529
+ if (t && r.kind === 'task_completed')
530
+ completed.add(t);
531
+ if (t && r.kind === 'trade_settled')
532
+ settled.add(t);
533
+ if (r.finality === 'observed' || r.finality === 'confirmed' || r.finality === 'finalized') {
534
+ by_finality[r.finality] += 1;
535
+ }
536
+ }
537
+ return {
538
+ source: opts.source,
539
+ rows: list.length,
540
+ tasks: tasks.size,
541
+ tasks_completed: completed.size,
542
+ tasks_settled: settled.size,
543
+ by_finality,
544
+ gates: normalizeActivityGates(opts.gates),
545
+ };
546
+ }
547
+ /** 活动行 → 链归属 (**纯函数**)。所有结论都从行本身推, 不引用外部状态 → 不可能与行数打架。 */
548
+ export function buildChainIdScope(rows) {
549
+ const list = (Array.isArray(rows) ? rows : []).filter((r) => !!r);
550
+ const counts = new Map();
551
+ for (const r of list) {
552
+ const id = Number(r.chain_id);
553
+ if (!Number.isInteger(id) || id < 0)
554
+ continue;
555
+ counts.set(id, (counts.get(id) || 0) + 1);
556
+ }
557
+ const chain_ids = Array.from(counts.keys()).sort((a, b) => a - b);
558
+ let primary = 0;
559
+ let best = 0;
560
+ for (const id of chain_ids) { // 升序遍历 + 严格大于 → 并列取小
561
+ const c = counts.get(id);
562
+ if (c > best) {
563
+ best = c;
564
+ primary = id;
565
+ }
566
+ }
567
+ const label = chain_ids.length ? chainLabelOf(primary) : null;
568
+ const publicIds = chain_ids.filter(isPublicChainId);
569
+ const publicRows = publicIds.reduce((n, id) => n + (counts.get(id) || 0), 0);
570
+ const pub = CHAIN_LABELS[PUBLIC_TESTNET_CHAIN_ID];
571
+ // 点名口径: 提示里写出的公网链必须是**行里真出现过的**; 一条都没有才退回对照链
572
+ // (否则会出现「上表 3 行来自 chainId 8453(Base 主网)」却说「公网链(Base Sepolia 84532) 3 行」的自相矛盾)
573
+ const pubZh = publicIds.length
574
+ ? publicIds.map((id) => `${CHAIN_LABELS[id].zh} ${id}`).join('、')
575
+ : `${pub.zh} ${PUBLIC_TESTNET_CHAIN_ID}`;
576
+ const pubEn = publicIds.length
577
+ ? publicIds.map((id) => `${CHAIN_LABELS[id].en} ${id}`).join(', ')
578
+ : `${pub.en} ${PUBLIC_TESTNET_CHAIN_ID}`;
579
+ const rows_ = list.length;
580
+ const multi = chain_ids.length > 1 ? `(上表共 ${chain_ids.length} 条链)` : '';
581
+ const note = rows_ === 0
582
+ ? {
583
+ zh: 'chain_id 归属: 本快照没有链上活动行 —— 不是"链上没事件", 而是这一轮没有可列出的行',
584
+ en: 'chain_id scope: no on-chain activity row in this snapshot — not "nothing happened on chain", just no listable row this round',
585
+ }
586
+ : {
587
+ zh: `chain_id 归属: 上表 ${rows_} 行来自 chainId ${primary}${label ? `(${label.zh})` : ''}${multi} · ` +
588
+ `公网链(${pubZh})${publicRows} 行` +
589
+ `${publicRows === 0 ? ' —— 这不是公网活动' : ''}`,
590
+ 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)` : ''} · ` +
591
+ `public network (${pubEn}) rows: ${publicRows}` +
592
+ `${publicRows === 0 ? ' — this is not public-network activity' : ''}`,
593
+ };
594
+ return {
595
+ chain_ids,
596
+ activity_chain_id: primary,
597
+ activity_chain_label: label ? { zh: label.zh, en: label.en } : null,
598
+ is_public_network: chain_ids.length > 0 ? isPublicChainId(primary) : false,
599
+ public_network_rows: publicRows,
600
+ public_network: { chain_id: PUBLIC_TESTNET_CHAIN_ID, label: { zh: pub.zh, en: pub.en } },
601
+ note,
602
+ };
603
+ }
604
+ /**
605
+ * 快照自检: 公开页会**同屏展示**的数字之间不许自相矛盾。返回问题清单 (空 = 通过)。
606
+ * ① `activity_totals` 必须在 (与行同源的计数不能缺)
607
+ * ② `activity_totals.rows` === `confirmed_activity.length`
608
+ * ③ `by_finality` 三档之和 === 行数 (没有第四条腿)
609
+ * ④ `activity_totals.source` === `confirmed_activity_source` (两块来源标注必须一致)
610
+ * ⑤ 行里有任务 而 `totals.tasks === 0` 时, 必须有口径说明 note
611
+ * (页面不许出现「0 个任务」与「N 行任务」并存而**不解释**)
612
+ */
613
+ export function snapshotConsistencyIssues(snap) {
614
+ const issues = [];
615
+ const rows = Array.isArray(snap?.confirmed_activity) ? snap.confirmed_activity : [];
616
+ const at = snap?.activity_totals;
617
+ if (!at || typeof at !== 'object') {
618
+ return ['activity_totals 缺失 (与 confirmed_activity 同源的计数必须一起给)'];
619
+ }
620
+ if (Number(at.rows) !== rows.length)
621
+ issues.push(`activity_totals.rows=${at.rows} ≠ confirmed_activity.length=${rows.length}`);
622
+ const bf = at.by_finality || {};
623
+ const sum = ['observed', 'confirmed', 'finalized'].reduce((n, k) => n + (Number(bf[k]) || 0), 0);
624
+ if (sum !== rows.length)
625
+ issues.push(`by_finality 三档之和=${sum} ≠ 行数=${rows.length}`);
626
+ if (String(at.source || '') !== String(snap?.confirmed_activity_source || '')) {
627
+ issues.push(`activity_totals.source=${at.source} ≠ confirmed_activity_source=${snap?.confirmed_activity_source}`);
628
+ }
629
+ const tasks = Number(at.tasks) || 0;
630
+ const pulseTasks = Number(snap?.totals?.tasks) || 0;
631
+ if (rows.length > 0 && tasks > 0 && pulseTasks === 0) {
632
+ const notes = Array.isArray(snap.notes) ? snap.notes.join(' ') : '';
633
+ const explained = notes.includes('脉冲事件') && notes.includes(String(rows.length)) &&
634
+ (notes.includes('链上索引') || notes.includes('chain-index'));
635
+ if (!explained)
636
+ issues.push(`totals.tasks=0 与 ${rows.length} 行任务并存, 却没有口径说明 note`);
637
+ }
638
+ return issues;
639
+ }
640
+ export function computeSnapshot(events, opts) {
641
+ const now = opts.now;
642
+ const fresh_until = now + PULSE_LIMITS.snapshotTtlMs;
643
+ if (opts.unavailable) {
644
+ const emptyRows = [];
645
+ const gates = normalizeActivityGates(DEFAULT_CONFIRMATIONS);
646
+ return {
647
+ status: 'unavailable',
648
+ generated_at: now,
649
+ fresh_until,
650
+ scope: 'observed',
651
+ 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 },
653
+ totals_scope: {
654
+ source: 'pulse-events', window_ms: PULSE_LIMITS.windowMs,
655
+ 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)`,
658
+ },
659
+ differs_from_activity: false,
660
+ },
661
+ capabilities: [],
662
+ recent_activity: [],
663
+ confirmed_activity: emptyRows,
664
+ confirmed_activity_source: 'none',
665
+ activity_totals: summarizeActivityRows(emptyRows, { source: 'none', gates }),
666
+ chain_id_scope: buildChainIdScope(emptyRows),
667
+ notes: ['观察层暂不可用 — 这不是"网络为空"'],
668
+ };
669
+ }
670
+ const window = (Array.isArray(events) ? events : []).filter((e) => isValidEvent(e) && e.occurredAt >= now - PULSE_LIMITS.windowMs);
671
+ const nodes = new Set(window.map((e) => e.sourceProof).filter(Boolean));
672
+ const agents = new Set(window.map((e) => e.agentProof).filter(Boolean));
673
+ const active = new Set(window.filter((e) => e.occurredAt >= now - PULSE_LIMITS.activeWindowMs).map((e) => e.agentProof).filter(Boolean));
674
+ // capability 分布 (按粗类别计数, 每个类别按**不同的 agent**去重, 避免单节点刷高)
675
+ const capMap = new Map();
676
+ for (const e of window) {
677
+ if (e.type !== 'capability_announced' || !e.capabilityGroup)
678
+ continue;
679
+ const key = e.capabilityGroup;
680
+ if (!capMap.has(key))
681
+ capMap.set(key, new Set());
682
+ capMap.get(key).add(e.agentProof || e.sourceProof);
683
+ }
684
+ let capabilities = Array.from(capMap.entries())
685
+ .map(([key, set]) => ({ key, count: set.size }))
686
+ .sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1))
687
+ .slice(0, PULSE_LIMITS.maxCapabilities);
688
+ // 隐私阈值: 少于阈值的类别合并进 other (other 本身也按阈值判断是否保留)
689
+ const small = capabilities.filter((c) => c.count < PULSE_LIMITS.privacyThreshold && c.key !== 'other');
690
+ if (small.length > 0) {
691
+ capabilities = capabilities.filter((c) => !small.includes(c));
692
+ const otherCount = new Set();
693
+ for (const c of small)
694
+ otherCount.add(c.key);
695
+ capabilities.push({ key: 'other', count: otherCount.size });
696
+ capabilities.sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1));
697
+ }
698
+ // 经济计数: 按**不同任务**去重 (同一任务重复事件不虚增), 且只给聚合数不给内容
699
+ const tasks = new Set();
700
+ const tasksCompleted = new Set();
701
+ const tasksVerified = new Set();
702
+ for (const e of window) {
703
+ const t = e.taskProof;
704
+ if (!t)
705
+ continue;
706
+ if (e.type === 'task_posted' || e.type === 'task_accepted')
707
+ tasks.add(t);
708
+ if (e.type === 'task_completed' || e.type === 'trade_settled')
709
+ tasksCompleted.add(t);
710
+ if (e.type === 'trade_verified')
711
+ tasksVerified.add(t);
712
+ }
713
+ // 钱包签名计数: 按 (来源, 时刻) 去重 —— 同一次签名重复上报不虚增
714
+ const signatureKeys = new Set();
715
+ for (const e of window)
716
+ if (e.type === 'wallet_signed')
717
+ signatureKeys.add(`${e.sourceProof}|${e.occurredAt}`);
718
+ const recent = [...window]
719
+ .sort((a, b) => b.occurredAt - a.occurredAt)
720
+ .slice(0, PULSE_LIMITS.maxActivity)
721
+ .map((e) => ({ kind: e.type, at: e.occurredAt, text: renderActivityText(e.type) }));
722
+ const signedNodes = opts.signedNodes ?? new Set(window.filter((e) => e.signed).map((e) => e.sourceProof)).size;
723
+ const scope = signedNodes >= 2 ? 'verified' : 'observed';
724
+ const notes = [];
725
+ if (scope === 'observed')
726
+ notes.push('单节点观察: 这是本节点能看到的部分网络, 不是全网精确总量');
727
+ else
728
+ notes.push(`多签名来源汇总 (${signedNodes} 个签名节点)`);
729
+ // 活动行来源如实标注 (chain-index = 真链上事实; pulse-events = 降级, 非链上确认)
730
+ const activity = opts.confirmedActivity ?? confirmedActivityFromEvents(window);
731
+ // 同源计数 + 链归属: **输入就是上表那批行**, 所以不可能与行数/链 id 打架
732
+ const activity_totals = summarizeActivityRows(activity.rows, { source: activity.source, gates: activity.gates });
733
+ const chain_id_scope = buildChainIdScope(activity.rows);
734
+ const totals_scope = {
735
+ source: 'pulse-events',
736
+ window_ms: PULSE_LIMITS.windowMs,
737
+ 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)`,
740
+ },
741
+ differs_from_activity: activity_totals.rows > 0 &&
742
+ (tasks.size !== activity_totals.tasks || tasksCompleted.size !== activity_totals.tasks_completed),
743
+ };
744
+ if (activity.source === 'chain-index') {
745
+ notes.push(`confirmed_activity 来自链上索引 (chain-index): ${activity_totals.rows} 行 · ${activity_totals.tasks} 个不同任务 · ` +
746
+ `finality 分布 observed=${activity_totals.by_finality.observed} / confirmed=${activity_totals.by_finality.confirmed} / ` +
747
+ `finalized=${activity_totals.by_finality.finalized} (三档之和 = 行数); 最新在前, 上限 ${CONFIRMED_ACTIVITY_LIMIT} 行; ` +
748
+ `确认门槛 confirmed=${activity.gates.confirmed} · finalized=${activity.gates.finalized}`);
749
+ }
750
+ else if (activity.source === 'pulse-events') {
751
+ notes.push('confirmed_activity 降级为脉冲事件 (pulse-events): 非链上确认 —— chain_id/block/confirmations=0 且 finality=observed');
752
+ }
753
+ // 两套计数口径不同时**必须**解释 —— 页面不许出现「0 个任务」与「N 行任务」并存而不解释
754
+ 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`);
759
+ }
760
+ if (activity_totals.rows > 0)
761
+ notes.push(chain_id_scope.note.zh);
762
+ return {
763
+ status: 'live',
764
+ generated_at: now,
765
+ fresh_until,
766
+ scope,
767
+ scope_label: scope === 'verified'
768
+ ? { zh: '网络观察快照 (多签名来源)', en: 'Verified network snapshot' }
769
+ : { zh: '当前节点观察到', en: 'Observed by this node' },
770
+ totals: {
771
+ nodes: nodes.size,
772
+ agents: agents.size,
773
+ active_agents: active.size,
774
+ 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, // 真实签名次数 (只计数)
779
+ },
780
+ totals_scope,
781
+ capabilities,
782
+ recent_activity: recent,
783
+ confirmed_activity: activity.rows,
784
+ confirmed_activity_source: activity.source,
785
+ activity_totals,
786
+ chain_id_scope,
787
+ notes,
788
+ };
789
+ }
790
+ /** 读快照 (带 30s 短缓存; 过期 → 重算; 重算失败 → unavailable) */
791
+ export async function getNetworkPulse(opts = {}) {
792
+ const now = opts.now ?? Date.now();
793
+ if (opts.unavailable)
794
+ return computeSnapshot([], { now, unavailable: true });
795
+ if (!opts.force) {
796
+ try {
797
+ const cached = JSON.parse(fs.readFileSync(snapshotFile(opts.home), 'utf-8'));
798
+ // 老版本写的缓存缺冻结字段 `confirmed_activity` (或后来的 totals_scope / activity_totals /
799
+ // chain_id_scope) → 视为**过期形状**, 重算 (不能把缺字段的快照发出去 ——
800
+ // 缺 activity_totals 就会在页面上重新变成"两个数字打架"
801
+ const shapeOk = Array.isArray(cached?.confirmed_activity)
802
+ && typeof cached?.confirmed_activity_source === 'string'
803
+ && !!cached?.totals_scope
804
+ && !!cached?.activity_totals
805
+ && !!cached?.chain_id_scope;
806
+ if (shapeOk && cached && Number.isFinite(cached.generated_at) && cached.generated_at + PULSE_LIMITS.snapshotTtlMs > now)
807
+ return cached;
808
+ }
809
+ catch { /* 无缓存 */ }
810
+ }
811
+ let events = [];
812
+ try {
813
+ events = readEvents(opts.home);
814
+ }
815
+ catch (e) {
816
+ return computeSnapshot([], { now, unavailable: true });
817
+ }
818
+ // 活动行优先取 P5 链上索引 (真链上事实); 索引不可用 → 退回脉冲事件, 并在快照里标出来源
819
+ const confirmedActivity = await resolveConfirmedActivity({ home: opts.home, events, now });
820
+ const snap = computeSnapshot(events, { now, confirmedActivity });
821
+ try {
822
+ fs.mkdirSync(pulseDir(opts.home), { recursive: true });
823
+ fs.writeFileSync(snapshotFile(opts.home), JSON.stringify(snap), 'utf8');
824
+ }
825
+ catch { /* 写不进就算了, 返回值仍然有效 */ }
826
+ return snap;
827
+ }
828
+ /** 快照是否仍是 live (给前端/观察入口判断; 过期即 stale) */
829
+ export function snapshotStatus(snap, now = Date.now()) {
830
+ if (snap.status === 'unavailable')
831
+ return 'unavailable';
832
+ return now <= snap.fresh_until ? 'live' : 'stale';
833
+ }
834
+ // ── 签名 (可验证快照) ───────────────────────────────────────────────────────
835
+ /** 规范化 JSON (键排序), 用于签名覆盖 */
836
+ export function canonicalize(value) {
837
+ const walk = (v) => {
838
+ if (Array.isArray(v))
839
+ return v.map(walk);
840
+ if (v && typeof v === 'object') {
841
+ const out = {};
842
+ for (const k of Object.keys(v).sort())
843
+ out[k] = walk(v[k]);
844
+ return out;
845
+ }
846
+ return v;
847
+ };
848
+ return JSON.stringify(walk(value));
849
+ }
850
+ /** 用本机 DID 身份给快照签名 (公开入口可校验; 只暴露签名与指纹, 不暴露 DID) */
851
+ /** 上一次快照签名失败的原因 (给调用方诊断用; 绝不出现在公开输出里) */
852
+ let lastSignErr = null;
853
+ export function lastSnapshotSignError() { return lastSignErr; }
854
+ /** 快照签名输入是 Uint8Array (ed25519), 存 base64 —— 不能直接把字符串喂给 KeyManager.sign */
855
+ function snapBytes(snap) {
856
+ const payload = { ...snap, signature: undefined, signer_fingerprint: undefined };
857
+ return new TextEncoder().encode(canonicalize(payload));
858
+ }
859
+ function decodeSnapSig(sig) {
860
+ if (/^[0-9a-f]{128}$/i.test(sig))
861
+ return new Uint8Array(Buffer.from(sig, 'hex'));
862
+ return new Uint8Array(Buffer.from(sig, 'base64'));
863
+ }
864
+ export async function signSnapshot(snap, h) {
865
+ lastSignErr = null;
866
+ try {
867
+ const { KeyManager } = await import('@diap/sdk');
868
+ const file = path.join(home(h), '.bolloon', 'identity.json');
869
+ const kp = await KeyManager.fromFile(file);
870
+ if (!kp?.privateKey) {
871
+ lastSignErr = `没有可用身份 (${file}) → 未签名`;
872
+ return snap;
873
+ }
874
+ const sig = await KeyManager.sign(kp, snapBytes(snap));
875
+ const sigStr = typeof sig === 'string' ? sig : Buffer.from(sig).toString('base64');
876
+ return { ...snap, signature: sigStr, signer_fingerprint: nodeDigest(String(kp.did || '')) };
877
+ }
878
+ catch (e) {
879
+ lastSignErr = String(e?.message || e);
880
+ return snap; // 没身份/签名失败 → 就返回未签名快照 (绝不假装签过)
881
+ }
882
+ }
883
+ /** 校验快照签名 (需要显式传入公钥方; 内部/测试用) */
884
+ export async function verifySnapshotSignature(snap, publicKey) {
885
+ try {
886
+ if (!snap.signature)
887
+ return false;
888
+ const { KeyManager } = await import('@diap/sdk');
889
+ return await KeyManager.verify(publicKey, snapBytes(snap), decodeSnapSig(snap.signature));
890
+ }
891
+ catch {
892
+ return false;
893
+ }
894
+ }
895
+ /** 公开投影的"禁止出现"字段检查 (测试与调用方共用) */
896
+ export const FORBIDDEN_PUBLIC_KEYS = ['did', 'peerId', 'peer_id', 'multiaddrs', 'ip', 'wallet', 'address', 'privateKey', 'content', 'instruction', 'payload'];
897
+ export function assertNoPrivateFields(value, at = '$') {
898
+ const issues = [];
899
+ const walk = (v, p) => {
900
+ if (Array.isArray(v))
901
+ return v.forEach((x, i) => walk(x, `${p}[${i}]`));
902
+ if (v && typeof v === 'object') {
903
+ for (const [k, val] of Object.entries(v)) {
904
+ if (FORBIDDEN_PUBLIC_KEYS.includes(k))
905
+ issues.push(`${p}.${k} 不该出现在公开投影里`);
906
+ walk(val, `${p}.${k}`);
907
+ }
908
+ }
909
+ };
910
+ walk(value, at);
911
+ return issues;
912
+ }