@bolloon/bolloon-agent 0.5.4 → 0.5.5

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 (126) hide show
  1. package/dist/agents/agent-identity-store.js +41 -5
  2. package/dist/agents/background-notices.js +124 -0
  3. package/dist/agents/channel-identity.js +54 -0
  4. package/dist/agents/code-write-gate.js +47 -0
  5. package/dist/agents/deny-pipeline.js +11 -2
  6. package/dist/agents/egress-sanitize.js +69 -0
  7. package/dist/agents/error-classifier.js +67 -5
  8. package/dist/agents/experience-review.js +226 -0
  9. package/dist/agents/gateway-group.js +153 -18
  10. package/dist/agents/iteration-budget.js +64 -0
  11. package/dist/agents/lesson-to-skill.js +211 -0
  12. package/dist/agents/managed-services.js +64 -0
  13. package/dist/agents/parse-tool-call.js +38 -32
  14. package/dist/agents/pi-sdk-session-manager.js +72 -3
  15. package/dist/agents/pi-sdk-tools.js +201 -32
  16. package/dist/agents/pi-sdk.js +463 -21
  17. package/dist/agents/plan-store.js +36 -0
  18. package/dist/agents/process-runner.js +64 -2
  19. package/dist/agents/shell-guard.js +109 -13
  20. package/dist/agents/skill-health.js +90 -0
  21. package/dist/agents/skill-ledger.js +129 -0
  22. package/dist/agents/tool-loop-guard.js +303 -0
  23. package/dist/agents/tool-path-args.js +53 -0
  24. package/dist/agents/tool-result-gate.js +50 -0
  25. package/dist/agents/tool-subset.js +109 -0
  26. package/dist/agents/tool-telemetry.js +97 -0
  27. package/dist/bootstrap/context-collector.js +37 -7
  28. package/dist/bootstrap/lifecycle-hooks.js +12 -3
  29. package/dist/bootstrap/persona-init.js +174 -0
  30. package/dist/bootstrap/persona-loader.js +5 -1
  31. package/dist/cli/ink-app.js +153 -6
  32. package/dist/cli/input-paste.js +218 -0
  33. package/dist/cli/loading-tui.js +3 -1
  34. package/dist/cli/log-gate.js +7 -0
  35. package/dist/cli/mention-data.js +30 -0
  36. package/dist/cli/reasoning-view.js +103 -0
  37. package/dist/cli/startup-notice.js +25 -0
  38. package/dist/cli/status-segments.js +87 -2
  39. package/dist/cli/trace-line.js +54 -0
  40. package/dist/context-compaction/auto-compact.js +37 -2
  41. package/dist/electron-build/agents/agent-identity-store.js +43 -5
  42. package/dist/electron-build/agents/agent-identity-store.js.map +1 -1
  43. package/dist/electron-build/agents/background-notices.js +138 -0
  44. package/dist/electron-build/agents/background-notices.js.map +1 -0
  45. package/dist/electron-build/agents/channel-identity.js +64 -0
  46. package/dist/electron-build/agents/channel-identity.js.map +1 -0
  47. package/dist/electron-build/agents/code-write-gate.js +55 -0
  48. package/dist/electron-build/agents/code-write-gate.js.map +1 -0
  49. package/dist/electron-build/agents/deny-pipeline.js +11 -2
  50. package/dist/electron-build/agents/deny-pipeline.js.map +1 -1
  51. package/dist/electron-build/agents/egress-sanitize.js +76 -0
  52. package/dist/electron-build/agents/egress-sanitize.js.map +1 -0
  53. package/dist/electron-build/agents/error-classifier.js +68 -5
  54. package/dist/electron-build/agents/error-classifier.js.map +1 -1
  55. package/dist/electron-build/agents/experience-review.js +243 -0
  56. package/dist/electron-build/agents/experience-review.js.map +1 -0
  57. package/dist/electron-build/agents/iteration-budget.js +73 -0
  58. package/dist/electron-build/agents/iteration-budget.js.map +1 -0
  59. package/dist/electron-build/agents/lesson-to-skill.js +228 -0
  60. package/dist/electron-build/agents/lesson-to-skill.js.map +1 -0
  61. package/dist/electron-build/agents/managed-services.js +69 -0
  62. package/dist/electron-build/agents/managed-services.js.map +1 -0
  63. package/dist/electron-build/agents/parse-tool-call.js +38 -32
  64. package/dist/electron-build/agents/parse-tool-call.js.map +1 -1
  65. package/dist/electron-build/agents/pi-sdk-session-manager.js +75 -3
  66. package/dist/electron-build/agents/pi-sdk-session-manager.js.map +1 -1
  67. package/dist/electron-build/agents/pi-sdk-tools.js +200 -31
  68. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  69. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  70. package/dist/electron-build/agents/pi-sdk.js +464 -21
  71. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  72. package/dist/electron-build/agents/plan-store.js +37 -0
  73. package/dist/electron-build/agents/plan-store.js.map +1 -1
  74. package/dist/electron-build/agents/process-runner.js +71 -2
  75. package/dist/electron-build/agents/process-runner.js.map +1 -1
  76. package/dist/electron-build/agents/shell-guard.js +110 -14
  77. package/dist/electron-build/agents/shell-guard.js.map +1 -1
  78. package/dist/electron-build/agents/skill-health.js +103 -0
  79. package/dist/electron-build/agents/skill-health.js.map +1 -0
  80. package/dist/electron-build/agents/skill-ledger.js +146 -0
  81. package/dist/electron-build/agents/skill-ledger.js.map +1 -0
  82. package/dist/electron-build/agents/tool-loop-guard.js +319 -0
  83. package/dist/electron-build/agents/tool-loop-guard.js.map +1 -0
  84. package/dist/electron-build/agents/tool-path-args.js +63 -0
  85. package/dist/electron-build/agents/tool-path-args.js.map +1 -0
  86. package/dist/electron-build/agents/tool-result-gate.js +59 -0
  87. package/dist/electron-build/agents/tool-result-gate.js.map +1 -0
  88. package/dist/electron-build/agents/tool-subset.js +116 -0
  89. package/dist/electron-build/agents/tool-subset.js.map +1 -0
  90. package/dist/electron-build/agents/tool-telemetry.js +108 -0
  91. package/dist/electron-build/agents/tool-telemetry.js.map +1 -0
  92. package/dist/electron-build/bootstrap/context-collector.js +38 -7
  93. package/dist/electron-build/bootstrap/context-collector.js.map +1 -1
  94. package/dist/electron-build/bootstrap/lifecycle-hooks.js +12 -3
  95. package/dist/electron-build/bootstrap/lifecycle-hooks.js.map +1 -1
  96. package/dist/electron-build/bootstrap/persona-init.js +216 -0
  97. package/dist/electron-build/bootstrap/persona-init.js.map +1 -0
  98. package/dist/electron-build/bootstrap/persona-loader.js +5 -1
  99. package/dist/electron-build/bootstrap/persona-loader.js.map +1 -1
  100. package/dist/electron-build/context-compaction/auto-compact.js +38 -2
  101. package/dist/electron-build/context-compaction/auto-compact.js.map +1 -1
  102. package/dist/electron-build/external-engines/delegate.js +22 -0
  103. package/dist/electron-build/external-engines/delegate.js.map +1 -1
  104. package/dist/electron-build/orbitdb/cid-database.js +55 -0
  105. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  106. package/dist/electron-build/orbitdb/ipfs-node.js +36 -11
  107. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  108. package/dist/electron-build/web/server-storage.js +26 -5
  109. package/dist/electron-build/web/server-storage.js.map +1 -1
  110. package/dist/external-engines/delegate.js +21 -0
  111. package/dist/index.js +503 -77
  112. package/dist/orbitdb/cid-database.js +55 -0
  113. package/dist/orbitdb/event-index.js +370 -0
  114. package/dist/orbitdb/event-query.js +131 -0
  115. package/dist/orbitdb/event-shell.js +510 -0
  116. package/dist/orbitdb/group-access.js +381 -0
  117. package/dist/orbitdb/group-shards.js +140 -0
  118. package/dist/orbitdb/ipfs-node.js +36 -11
  119. package/dist/web/bolloon-web.json +3 -3
  120. package/dist/web/client.js +3 -2
  121. package/dist/web/mobile-core.js +7762 -6289
  122. package/dist/web/routes-mobile-tasks.js +2 -1
  123. package/dist/web/server-storage.js +26 -5
  124. package/dist/web/server.js +2 -0
  125. package/dist/web/sw.js +1 -1
  126. package/package.json +2 -1
@@ -102,6 +102,61 @@ export class OrbitDBAdapter {
102
102
  get ipfsPaths() {
103
103
  return this.node?.paths ?? null;
104
104
  }
105
+ // ============ P2P 观测/接入口 (2026-09-30, 为真两节点复制验证加的) ============
106
+ // 之前 helia/libp2p 完全藏在私有字段里 —— 于是所有多节点测试只能注入 fake CIDDatabase,
107
+ // 「真两节点能不能同步」从来没被验过。这三个口只读/只拨号, 不改变任何既有行为。
108
+ /** 本节点 peerId (未初始化时 null; 验收/诊断用) */
109
+ get peerId() {
110
+ return this.node?.peerId ?? null;
111
+ }
112
+ /** 本节点当前监听地址 (multiaddr 字符串; 未初始化时 []) */
113
+ listenAddrs() {
114
+ const l = this.node?.helia?.libp2p;
115
+ try {
116
+ return (l?.getMultiaddrs?.() ?? []).map((m) => m.toString());
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ }
122
+ /**
123
+ * 当前与本节点**有连接**的对端 (诊断用只读口)。
124
+ * 用途: P0 门里 S3「不拨号必须看不见」不稳定 (关 mDNS、全静默档都仍 seen=101) ——
125
+ * 得先看清"是谁、通过什么连上的", 再谈判据。返回 `peerId ← 远端地址`。
126
+ */
127
+ peerConnections() {
128
+ const l = this.node?.helia?.libp2p;
129
+ try {
130
+ return (l?.getConnections?.() ?? []).map((c) => {
131
+ const p = c.remotePeer?.toString?.() ?? String(c.remotePeer);
132
+ const a = c.remoteAddr?.toString?.() ?? '';
133
+ return `${p} ← ${a}`;
134
+ });
135
+ }
136
+ catch {
137
+ return [];
138
+ }
139
+ }
140
+ /**
141
+ * 某个 pubsub 主题当前的订阅者 peerId (诊断用只读口)。
142
+ * OrbitDB 的同步靠 pubsub: 新 peer 订阅 `address` 主题后, 已在线的 peer 会把 heads 发给它
143
+ * (@orbitdb/core src/sync.js:143 的 join 事件)。所以"订阅者为 0"和"日志为空"是两回事, 必须分开看。
144
+ */
145
+ pubsubSubscribers(topic) {
146
+ const ps = this.node?.helia?.libp2p?.services?.pubsub;
147
+ try {
148
+ return (ps?.getSubscribers?.(topic) ?? []).map((x) => x.toString());
149
+ }
150
+ catch {
151
+ return [];
152
+ }
153
+ }
154
+ /** 拨号到另一个真节点 (验收/群组接入用)。失败原样抛, 不吞。 */
155
+ async dial(addr) {
156
+ await this.ensure();
157
+ const { multiaddr } = await import('@multiformats/multiaddr');
158
+ await this.node.helia.libp2p.dial(multiaddr(addr));
159
+ }
105
160
  /** 懒初始化: 首次使用时启动 helia + OrbitDB + 打开 keyvalue store */
106
161
  async ensure() {
107
162
  if (this.db)
@@ -0,0 +1,370 @@
1
+ /**
2
+ * event-index.ts — 事件网络的**可重建索引层** (P2, 2026-10-01)
3
+ *
4
+ * 三个索引 store (keyvalue, 天然可增量复制):
5
+ * `by-topic` key = <topic>::<ts>::<eventId>
6
+ * `by-capability` key = <capability>::<ts>::<eventId>
7
+ * `by-time` key = <ts>::<eventId> (所有事件都进这一条, 时间线)
8
+ *
9
+ * 核心不变式 (本文件存在的全部理由):
10
+ *
11
+ * ① 索引 = f(事件流), **纯函数**。
12
+ * `deriveIndexEntries(event)` / `rebuildIndexes(events)` 不读任何索引、不读网络、不读时钟 ——
13
+ * 输入相同 ⇒ 输出逐字节相同。所以「删掉索引 → 重放事件流 → 逐字节相同」是**可判**的,
14
+ * 而且同一结论在任何节点上重复一次都一样 (不需要中心索引)。
15
+ *
16
+ * ② 索引项**只放元数据 + CID**, 绝不放正文。
17
+ * `indexEntryFor` 用**显式白名单**构造 IndexEntry (不是展开外壳再删字段 ——
18
+ * "删字段"会在外壳加字段那天静默漏出正文), 白名单见 `ALLOWED_INDEX_ENTRY_KEYS`。
19
+ * 正文只以 `cid` 出现; 只有**有界摘要** (≤ event-shell 的 MAX_SUMMARY_LEN) 会进索引。
20
+ *
21
+ * ③ 字节可比。
22
+ * `indexBundleCbor(bundle)` = dag-cbor 编码的 `{store: {key: value}}` (键排序由 canonicalize 保证),
23
+ * 这就是三个 store 内容的**字节像**; `indexBundleFingerprint` 给它 sha256。
24
+ * 重建前后比这个指纹 = "逐字节相同"。
25
+ */
26
+ import * as dagCbor from '@ipld/dag-cbor';
27
+ import { sha256 } from 'multiformats/hashes/sha2';
28
+ import { canonicalJson, canonicalize, MAX_SUMMARY_LEN, } from './event-shell.js';
29
+ export const INDEX_STORE_NAMES = ['by-topic', 'by-capability', 'by-time'];
30
+ /** 索引项版本 (与外壳版本解耦: 索引布局变了就 +1) */
31
+ export const INDEX_ENTRY_VERSION = 1;
32
+ /**
33
+ * 索引项**只允许**有这些键。反事实门 (verify-event-index ④) 拿它当白名单:
34
+ * 出现集合外的键 = 索引里混进了别的东西。
35
+ */
36
+ export const ALLOWED_INDEX_ENTRY_KEYS = [
37
+ 'v',
38
+ 'id',
39
+ 'type',
40
+ 'actor',
41
+ 'actorId',
42
+ 'group',
43
+ 'ts',
44
+ 'refs',
45
+ 'topic',
46
+ 'capability',
47
+ 'summary',
48
+ 'cid',
49
+ ];
50
+ const TS_WIDTH = 13; // Date.now() 13 位 (够用到 2286 年); 补零 ⇒ 字符串序 = 时间序
51
+ /** key 里的变量部分一律 encodeURIComponent ⇒ 分隔符 `::` 不会被标识符里的字符污染 */
52
+ const enc = (s) => encodeURIComponent(s);
53
+ const tsPart = (ts) => String(Math.trunc(ts)).padStart(TS_WIDTH, '0');
54
+ export function topicKey(ev) {
55
+ return ev.topic ? `${enc(ev.topic)}::${tsPart(ev.ts)}::${enc(ev.id)}` : null;
56
+ }
57
+ export function capabilityKey(ev) {
58
+ return ev.capability ? `${enc(ev.capability)}::${tsPart(ev.ts)}::${enc(ev.id)}` : null;
59
+ }
60
+ export function timeKey(ev) {
61
+ return `${tsPart(ev.ts)}::${enc(ev.id)}`;
62
+ }
63
+ /** 从 key 反解 (读侧/调试用); 反解不出的返回 null, 不硬凑 */
64
+ export function parseIndexKey(store, key) {
65
+ const parts = key.split('::');
66
+ try {
67
+ if (store === 'by-time') {
68
+ if (parts.length !== 2)
69
+ return null;
70
+ return { ts: Number(parts[0]), id: decodeURIComponent(parts[1]) };
71
+ }
72
+ if (parts.length !== 3)
73
+ return null;
74
+ return { label: decodeURIComponent(parts[0]), ts: Number(parts[1]), id: decodeURIComponent(parts[2]) };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ /**
81
+ * 外壳 → 索引项。**显式白名单**: 只列出的字段会被搬过来。
82
+ * 这就是"索引项里不许出现正文"在实现层的落实 (不是靠自觉)。
83
+ */
84
+ export function indexEntryFor(ev) {
85
+ const entry = {
86
+ v: ev.v ?? INDEX_ENTRY_VERSION,
87
+ id: ev.id,
88
+ type: ev.type,
89
+ actor: ev.actor,
90
+ actorId: ev.actorId,
91
+ group: ev.group,
92
+ ts: ev.ts,
93
+ refs: (ev.refs ?? []).map((r) => (r.rel !== undefined ? { type: r.type, id: r.id, rel: r.rel } : { type: r.type, id: r.id })),
94
+ summary: ev.summary,
95
+ };
96
+ if (ev.topic)
97
+ entry.topic = ev.topic;
98
+ if (ev.capability)
99
+ entry.capability = ev.capability;
100
+ if (ev.cid)
101
+ entry.cid = ev.cid;
102
+ return entry;
103
+ }
104
+ /** 一个事件 → 它应该出现在哪几个索引里 (topic/capability 缺省就不进对应索引; 时间线一定进) */
105
+ export function deriveIndexEntries(ev) {
106
+ const value = indexEntryFor(ev);
107
+ const out = [
108
+ { store: 'by-time', key: timeKey(ev), value },
109
+ ];
110
+ const tk = topicKey(ev);
111
+ if (tk)
112
+ out.push({ store: 'by-topic', key: tk, value });
113
+ const ck = capabilityKey(ev);
114
+ if (ck)
115
+ out.push({ store: 'by-capability', key: ck, value });
116
+ return out;
117
+ }
118
+ function sortDedupe(entries) {
119
+ const byKey = new Map();
120
+ for (const e of entries)
121
+ byKey.set(e.key, e); // 同 key 后到者胜 (流顺序 ⇒ 确定性)
122
+ return Array.from(byKey.values()).sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
123
+ }
124
+ /**
125
+ * 纯重建: 事件流 → 三个索引的完整内容。**只依赖输入数组** (无时钟、无网络、无其它 store)。
126
+ * 结果按键排序 + 同键去重 ⇒ 与输入顺序无关 (除非真有同键冲突)。
127
+ */
128
+ export function rebuildIndexes(events) {
129
+ const bundle = { 'by-topic': [], 'by-capability': [], 'by-time': [] };
130
+ for (const ev of events)
131
+ for (const d of deriveIndexEntries(ev))
132
+ bundle[d.store].push(d);
133
+ for (const name of INDEX_STORE_NAMES)
134
+ bundle[name] = sortDedupe(bundle[name]);
135
+ return bundle;
136
+ }
137
+ /** bundle → `{store: {key: value}}` (就是三个 kv store 的内容像) */
138
+ export function indexStoreMap(bundle) {
139
+ const map = {};
140
+ for (const name of INDEX_STORE_NAMES) {
141
+ const m = {};
142
+ for (const e of bundle[name])
143
+ m[e.key] = e.value;
144
+ map[name] = m;
145
+ }
146
+ return map;
147
+ }
148
+ /** 索引内容的 canonical JSON (键排序) —— 人可读的"逐字节"版本 */
149
+ export function indexBundleCanonicalJson(bundle) {
150
+ return canonicalJson(indexStoreMap(bundle));
151
+ }
152
+ /** 索引内容的 dag-cbor 字节 —— 与 OrbitDB 实际存的编码同一套, 可做真字节比对 */
153
+ export function indexBundleCbor(bundle) {
154
+ const clean = canonicalize(indexStoreMap(bundle));
155
+ return dagCbor.encode(clean);
156
+ }
157
+ /** 内容指纹 (sha256 over dag-cbor 字节) + 计数 —— 重建前后比它 */
158
+ export async function indexBundleFingerprint(bundle) {
159
+ const bytes = indexBundleCbor(bundle);
160
+ const hash = await sha256.digest(bytes);
161
+ const perStore = {};
162
+ for (const name of INDEX_STORE_NAMES)
163
+ perStore[name] = bundle[name].length;
164
+ return {
165
+ sha256: toHex(hash.digest),
166
+ bytes: bytes.length,
167
+ entries: INDEX_STORE_NAMES.reduce((n, s) => n + bundle[s].length, 0),
168
+ perStore,
169
+ };
170
+ }
171
+ export function toHex(bytes) {
172
+ let out = '';
173
+ for (const b of bytes)
174
+ out += b.toString(16).padStart(2, '0');
175
+ return out;
176
+ }
177
+ export function bytesEqual(a, b) {
178
+ if (a.length !== b.length)
179
+ return false;
180
+ for (let i = 0; i < a.length; i++)
181
+ if (a[i] !== b[i])
182
+ return false;
183
+ return true;
184
+ }
185
+ /* 2026-10-01: 原注释「群/网络共用: 任何成员都可写」已不成立 —— 索引默认创建者独占, 见 INDEX_ACCESS */
186
+ /**
187
+ * 索引 store 的访问控制 (2026-10-01 收紧)。
188
+ *
189
+ * 之前是 `write: ['*']` —— 任何人可写。索引是**派生数据**, 而且**查询面读的正是它** (event-query.ts)
190
+ * ⇒ 放开的后果不是"多几行垃圾", 而是**查询结果可被投毒**。所以默认收紧成**创建者独占**
191
+ * (@orbitdb/core 的默认语义: 不传 write 列表 ⇒ write=[创建者身份 id])。
192
+ *
193
+ * 要与别人共享同一份索引 (例如由一个可信索引者统一派生、其他人只读), 由调用方显式传写身份。
194
+ *
195
+ * 如实记一条**未做**: 查询面目前**不校验**索引项与事件流的一致性 (P2 的 rebuildIndexes 能重建,
196
+ * 但"读到一份被改过的索引"这件事现在无法被查询侧发现)。要么后续加校验, 要么索引只由本节点自己派生。
197
+ */
198
+ export const INDEX_ACCESS = {};
199
+ export async function openIndexStores(opener, names = INDEX_STORE_NAMES) {
200
+ const stores = {};
201
+ for (const name of names)
202
+ stores[name] = await opener.openStore(name, 'keyvalue', INDEX_ACCESS);
203
+ return stores;
204
+ }
205
+ /** 把 bundle 写进真 store (幂等: 同 key 同值覆盖) */
206
+ export async function writeBundle(stores, bundle) {
207
+ let n = 0;
208
+ for (const name of INDEX_STORE_NAMES) {
209
+ for (const e of bundle[name]) {
210
+ await stores[name].put(e.key, e.value);
211
+ n++;
212
+ }
213
+ }
214
+ return n;
215
+ }
216
+ /** 增量写: 只写这一个事件该写的项 (P4 的日常路径) */
217
+ export async function applyEvent(stores, ev) {
218
+ const derived = deriveIndexEntries(ev);
219
+ for (const d of derived)
220
+ await stores[d.store].put(d.key, d.value);
221
+ return derived;
222
+ }
223
+ /**
224
+ * 从真 store 读回全部索引内容 → bundle。
225
+ * 排序 + 去重与 rebuildIndexes 同一套规则 ⇒ 可直接比指纹。
226
+ */
227
+ export async function readBundleFromStores(stores) {
228
+ const bundle = { 'by-topic': [], 'by-capability': [], 'by-time': [] };
229
+ for (const name of INDEX_STORE_NAMES) {
230
+ const all = await stores[name].all();
231
+ for (const e of all)
232
+ bundle[name].push({ store: name, key: String(e.key), value: e.value });
233
+ }
234
+ for (const name of INDEX_STORE_NAMES)
235
+ bundle[name] = sortDedupe(bundle[name]);
236
+ return bundle;
237
+ }
238
+ /** store 里现有的全部 key (删索引前的留档 / 裁剪用) */
239
+ export async function storeKeys(stores) {
240
+ const out = {};
241
+ for (const name of INDEX_STORE_NAMES) {
242
+ const all = await stores[name].all();
243
+ out[name] = all.map((e) => String(e.key)).sort();
244
+ }
245
+ return out;
246
+ }
247
+ /**
248
+ * 裁剪: 删掉不在 keep 里的 key。store 没暴露 del 就**如实报 skipped**, 不假装删了。
249
+ */
250
+ export async function pruneIndexes(stores, bundle) {
251
+ let removed = 0;
252
+ const skipped = [];
253
+ for (const name of INDEX_STORE_NAMES) {
254
+ const keep = new Set(bundle[name].map((e) => e.key));
255
+ const store = stores[name];
256
+ const all = await store.all();
257
+ const stale = all.map((e) => String(e.key)).filter((k) => !keep.has(k));
258
+ if (!stale.length)
259
+ continue;
260
+ if (typeof store.del !== 'function') {
261
+ skipped.push(`${name}: ${stale.length} 个陈旧 key (store 未暴露 del)`);
262
+ continue;
263
+ }
264
+ for (const k of stale) {
265
+ await store.del(k);
266
+ removed++;
267
+ }
268
+ }
269
+ return { removed, skipped };
270
+ }
271
+ /** 递归收集一个值里的全部字符串叶子 (正文只可能**整段**落在某个叶子字段里, 不会跨字段拼接) */
272
+ function stringLeaves(v, out = []) {
273
+ if (typeof v === 'string')
274
+ out.push(v);
275
+ else if (Array.isArray(v))
276
+ for (const x of v)
277
+ stringLeaves(x, out);
278
+ else if (typeof v === 'object' && v !== null)
279
+ for (const k of Object.keys(v))
280
+ stringLeaves(v[k], out);
281
+ return out;
282
+ }
283
+ const MIN_BODY_FOR_FULL_CHECK = 64; // 太短的"正文"没有判据意义
284
+ const MIN_TAIL_FOR_CHECK = 32;
285
+ /**
286
+ * 反事实检查: 索引项里**有没有正文**。三件事分开报, 不混成一个 bool:
287
+ * · forbiddenKeys — 结构面: 出现了白名单以外的键 (比如 content/body/text)
288
+ * · fullBodyHits — 内容面: 整段正文出现在索引项的某个字段里
289
+ * · overBudgetHits — 长度面: 正文里超过摘要预算 (bodyCharBudget) 的那一段出现在索引里
290
+ * `bodyOf(ev)` 由调用方给 (外壳本身不带正文 —— 这正是设计): 返回 null 表示这个事件没有正文可比。
291
+ */
292
+ export function indexLeakReport(events, bundle, opts) {
293
+ const budget = opts.bodyCharBudget ?? MAX_SUMMARY_LEN;
294
+ const forbidden = new Set();
295
+ let maxEntryKeys = 0;
296
+ let totalIndexBytes = 0;
297
+ let totalBodyChars = 0;
298
+ let fullBodyHits = 0;
299
+ let overBudgetHits = 0;
300
+ const unchecked = [];
301
+ const details = [];
302
+ const allowed = new Set(ALLOWED_INDEX_ENTRY_KEYS);
303
+ const byEvent = new Map();
304
+ for (const name of INDEX_STORE_NAMES) {
305
+ for (const e of bundle[name]) {
306
+ const list = byEvent.get(e.value.id) ?? [];
307
+ list.push(e.value);
308
+ byEvent.set(e.value.id, list);
309
+ }
310
+ }
311
+ for (const ev of events) {
312
+ const entries = byEvent.get(ev.id) ?? [];
313
+ byEvent.delete(ev.id);
314
+ const leaves = [];
315
+ const keys = [];
316
+ let bytes = 0;
317
+ for (const e of entries) {
318
+ for (const k of Object.keys(e)) {
319
+ keys.push(k);
320
+ if (!allowed.has(k))
321
+ forbidden.add(k);
322
+ }
323
+ bytes += canonicalJson(e).length;
324
+ stringLeaves(e, leaves);
325
+ }
326
+ maxEntryKeys = Math.max(maxEntryKeys, new Set(keys).size);
327
+ totalIndexBytes += bytes;
328
+ const bodyStr = opts.bodyOf(ev);
329
+ let fullHit = false;
330
+ let tailHit = false;
331
+ if (bodyStr !== null) {
332
+ if (bodyStr.length >= MIN_BODY_FOR_FULL_CHECK)
333
+ fullHit = leaves.some((s) => s.includes(bodyStr));
334
+ if (bodyStr.length > budget) {
335
+ const tail = bodyStr.slice(budget);
336
+ if (tail.length >= MIN_TAIL_FOR_CHECK)
337
+ tailHit = leaves.some((s) => s.includes(tail));
338
+ }
339
+ }
340
+ if (fullHit)
341
+ fullBodyHits++;
342
+ if (tailHit)
343
+ overBudgetHits++;
344
+ if (bodyStr === null)
345
+ unchecked.push(ev.id);
346
+ else
347
+ totalBodyChars += bodyStr.length;
348
+ details.push({
349
+ id: ev.id,
350
+ entryKeys: Array.from(new Set(keys)).sort(),
351
+ entryBytes: bytes,
352
+ bodyChars: bodyStr === null ? 0 : bodyStr.length,
353
+ fullBodyHit: fullHit,
354
+ overBudgetHit: tailHit,
355
+ });
356
+ }
357
+ details.sort((a, b) => (a.id < b.id ? -1 : 1));
358
+ return {
359
+ forbiddenKeys: Array.from(forbidden).sort(),
360
+ fullBodyHits,
361
+ overBudgetHits,
362
+ checkedEvents: details.length - unchecked.length,
363
+ uncheckedEvents: unchecked,
364
+ orphanEntryIds: Array.from(byEvent.keys()).sort(),
365
+ maxEntryKeys,
366
+ totalIndexBytes,
367
+ totalBodyChars,
368
+ details,
369
+ };
370
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * event-query.ts — 事件网络的**查询面** (P4, 2026-10-01)
3
+ *
4
+ * 定位: 1 万 agent 的目标里 "记录可被查询和调用" 是半壁。OrbitDB 只能按 key 迭代, 没有查询面;
5
+ * 本模块把 P2 的三个索引 store (by-topic / by-capability / by-time) 变成可查询的读口,
6
+ * 并且**只出元数据 + CID** —— 正文永远要按 CID 单独取 (L2/L3 分层, 与 cid-database 的惰性取块一致)。
7
+ *
8
+ * 三条硬规则 (都写进判据, 不靠自觉):
9
+ * ① 同一份索引 + 同一个查询 ⇒ **逐条相同**的结果 (纯函数, 无时钟无网络无随机);
10
+ * ② 窄范围查询**不许扫全量**: by-time 的 ts 是 13 位补零 ⇒ 字符串序 = 时间序 ⇒ 先用二分定位起点,
11
+ * 再顺序扫到 until 为止 (超出即停), 并如实报 keysRead / keysScanned 两个数;
12
+ * ③ **没有索引时不许假装成功**: 返回空结果 + 显式 degraded 标注 (reason), 由调用方决定怎么办
13
+ * (可以从事件流重建索引 —— 那是 P2 的 rebuildIndexes)。
14
+ */
15
+ import { INDEX_STORE_NAMES, parseIndexKey, timeKey, } from './event-index.js';
16
+ const TS_WIDTH = 13;
17
+ const tsPart = (ts) => String(Math.trunc(ts)).padStart(TS_WIDTH, '0');
18
+ /** 选索引: 有时间窗就用 by-time (可裁剪), 否则按 topic / capability */
19
+ export function planQuery(q) {
20
+ if (q.since !== undefined || q.until !== undefined)
21
+ return 'by-time';
22
+ if (q.topic)
23
+ return 'by-topic';
24
+ if (q.capability)
25
+ return 'by-capability';
26
+ return 'by-time';
27
+ }
28
+ function matches(entry, q) {
29
+ if (q.topic && entry.topic !== q.topic)
30
+ return false;
31
+ if (q.capability && entry.capability !== q.capability)
32
+ return false;
33
+ if (q.actor && entry.actor !== q.actor)
34
+ return false;
35
+ if (q.actorId && entry.actorId !== q.actorId)
36
+ return false;
37
+ if (q.group && entry.group !== q.group)
38
+ return false;
39
+ if (q.type) {
40
+ const want = Array.isArray(q.type) ? q.type : [q.type];
41
+ if (!want.includes(entry.type))
42
+ return false;
43
+ }
44
+ if (q.since !== undefined && entry.ts < q.since)
45
+ return false;
46
+ if (q.until !== undefined && entry.ts > q.until)
47
+ return false;
48
+ return true;
49
+ }
50
+ /** 二分: 第一个 ts >= 目标的 key 下标 (by-time 的 key 前缀就是补零 ts) */
51
+ function lowerBoundTime(sortedKeys, targetTs) {
52
+ const target = tsPart(targetTs);
53
+ let lo = 0, hi = sortedKeys.length;
54
+ while (lo < hi) {
55
+ const mid = (lo + hi) >> 1;
56
+ if ((sortedKeys[mid] ?? '') < target)
57
+ lo = mid + 1;
58
+ else
59
+ hi = mid;
60
+ }
61
+ return lo;
62
+ }
63
+ /**
64
+ * 纯函数查询: 输入一份索引 (P2 的 IndexBundle), 输出结果 + 真实扫描计数。
65
+ * by-time 的范围裁剪在这里发生 —— 超出 until 立刻停 (不是"全扫一遍再过滤")。
66
+ */
67
+ export function queryIndexBundle(bundle, q) {
68
+ const store = planQuery(q);
69
+ const all = bundle[store] ?? [];
70
+ const keys = all.map((d) => d.key).sort(); // 排序 = 复制一份并排好 (by-time 的 key 天然时间序)
71
+ const byKey = new Map(all.map((d) => [d.key, d.value]));
72
+ let keysScanned = 0;
73
+ const out = [];
74
+ const start = q.since !== undefined && store === 'by-time' ? lowerBoundTime(keys, q.since) : 0;
75
+ for (let i = start; i < keys.length; i++) {
76
+ const key = keys[i];
77
+ let ts;
78
+ try {
79
+ ts = Number(key.split('::')[0]);
80
+ }
81
+ catch {
82
+ ts = NaN;
83
+ }
84
+ if (store === 'by-time' && q.until !== undefined && ts > q.until)
85
+ break; // ← 早停: 不扫全量
86
+ keysScanned++;
87
+ const parsed = parseIndexKey(store, key);
88
+ if (!parsed)
89
+ continue; // 反解不出的不硬凑
90
+ const entry = byKey.get(key);
91
+ if (!entry)
92
+ continue;
93
+ if (matches(entry, q))
94
+ out.push(entry);
95
+ }
96
+ out.sort((a, b) => (a.ts - b.ts) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
97
+ const limited = q.limit !== undefined && out.length > q.limit ? out.slice(0, q.limit) : out;
98
+ return {
99
+ items: limited,
100
+ store,
101
+ keysRead: keys.length,
102
+ keysScanned,
103
+ complete: q.limit === undefined || out.length <= q.limit,
104
+ };
105
+ }
106
+ /** 从真 store 的 key 列表构造一个 IndexBundle (读侧用; key 列表由 P2 的 storeKeys 提供) */
107
+ export function bundleFromKeys(keys, values) {
108
+ const out = {};
109
+ for (const store of INDEX_STORE_NAMES) {
110
+ out[store] = (keys[store] ?? []).map((key) => ({ store, key, value: values[store]?.[key] }))
111
+ .filter((d) => d.value !== undefined && d.value !== null);
112
+ }
113
+ return out;
114
+ }
115
+ /** 空索引入口: 调用方拿到它必须知道"这是没有索引", 不是"查不到" */
116
+ export function emptyIndexDegradation() {
117
+ return {
118
+ reason: 'no-index',
119
+ hint: '这个节点上没有索引项。可以从事件流重建 (event-index.rebuildIndexes) 后重查; 不要把它当"查询结果为空"。',
120
+ };
121
+ }
122
+ /** 便捷: 全空 bundle */
123
+ export function emptyBundle() {
124
+ return { 'by-topic': [], 'by-capability': [], 'by-time': [] };
125
+ }
126
+ /** 全空 bundle 上的查询 —— 必须带 degraded 标注 (判据 ③) */
127
+ export function queryWithoutIndex(q) {
128
+ const r = queryIndexBundle(emptyBundle(), q);
129
+ return { ...r, degraded: emptyIndexDegradation() };
130
+ }
131
+ export { timeKey };