@bolloon/bolloon-agent 0.5.3 → 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 (144) 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 +268 -47
  16. package/dist/agents/pi-sdk.js +470 -22
  17. package/dist/agents/plan-store.js +36 -0
  18. package/dist/agents/process-runner.js +64 -2
  19. package/dist/agents/session-store.js +144 -3
  20. package/dist/agents/shell-guard.js +109 -13
  21. package/dist/agents/skill-health.js +90 -0
  22. package/dist/agents/skill-ledger.js +129 -0
  23. package/dist/agents/tool-loop-guard.js +303 -0
  24. package/dist/agents/tool-path-args.js +53 -0
  25. package/dist/agents/tool-result-gate.js +50 -0
  26. package/dist/agents/tool-subset.js +109 -0
  27. package/dist/agents/tool-telemetry.js +97 -0
  28. package/dist/agents/wallet-tools.js +63 -0
  29. package/dist/bootstrap/context-collector.js +37 -7
  30. package/dist/bootstrap/lifecycle-hooks.js +12 -3
  31. package/dist/bootstrap/persona-init.js +174 -0
  32. package/dist/bootstrap/persona-loader.js +5 -1
  33. package/dist/cli/ink-app.js +231 -32
  34. package/dist/cli/input-history.js +119 -0
  35. package/dist/cli/input-paste.js +218 -0
  36. package/dist/cli/loading-tui.js +69 -7
  37. package/dist/cli/log-gate.js +46 -1
  38. package/dist/cli/mention-data.js +30 -0
  39. package/dist/cli/reasoning-view.js +103 -0
  40. package/dist/cli/session-summary.js +129 -0
  41. package/dist/cli/session-tree.js +141 -0
  42. package/dist/cli/startup-notice.js +25 -0
  43. package/dist/cli/status-segments.js +155 -0
  44. package/dist/cli/stores.js +16 -0
  45. package/dist/cli/trace-line.js +54 -0
  46. package/dist/cli/wallet-store.js +159 -0
  47. package/dist/cli/wallet-table.js +46 -0
  48. package/dist/cli-entry.js +22 -0
  49. package/dist/context-compaction/auto-compact.js +37 -2
  50. package/dist/electron-build/agents/agent-identity-store.js +43 -5
  51. package/dist/electron-build/agents/agent-identity-store.js.map +1 -1
  52. package/dist/electron-build/agents/background-notices.js +138 -0
  53. package/dist/electron-build/agents/background-notices.js.map +1 -0
  54. package/dist/electron-build/agents/channel-identity.js +64 -0
  55. package/dist/electron-build/agents/channel-identity.js.map +1 -0
  56. package/dist/electron-build/agents/code-write-gate.js +55 -0
  57. package/dist/electron-build/agents/code-write-gate.js.map +1 -0
  58. package/dist/electron-build/agents/deny-pipeline.js +11 -2
  59. package/dist/electron-build/agents/deny-pipeline.js.map +1 -1
  60. package/dist/electron-build/agents/egress-sanitize.js +76 -0
  61. package/dist/electron-build/agents/egress-sanitize.js.map +1 -0
  62. package/dist/electron-build/agents/error-classifier.js +68 -5
  63. package/dist/electron-build/agents/error-classifier.js.map +1 -1
  64. package/dist/electron-build/agents/experience-review.js +243 -0
  65. package/dist/electron-build/agents/experience-review.js.map +1 -0
  66. package/dist/electron-build/agents/iteration-budget.js +73 -0
  67. package/dist/electron-build/agents/iteration-budget.js.map +1 -0
  68. package/dist/electron-build/agents/lesson-to-skill.js +228 -0
  69. package/dist/electron-build/agents/lesson-to-skill.js.map +1 -0
  70. package/dist/electron-build/agents/managed-services.js +69 -0
  71. package/dist/electron-build/agents/managed-services.js.map +1 -0
  72. package/dist/electron-build/agents/parse-tool-call.js +38 -32
  73. package/dist/electron-build/agents/parse-tool-call.js.map +1 -1
  74. package/dist/electron-build/agents/pi-sdk-session-manager.js +75 -3
  75. package/dist/electron-build/agents/pi-sdk-session-manager.js.map +1 -1
  76. package/dist/electron-build/agents/pi-sdk-tools.js +268 -46
  77. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  78. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  79. package/dist/electron-build/agents/pi-sdk.js +471 -22
  80. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  81. package/dist/electron-build/agents/plan-store.js +37 -0
  82. package/dist/electron-build/agents/plan-store.js.map +1 -1
  83. package/dist/electron-build/agents/process-runner.js +71 -2
  84. package/dist/electron-build/agents/process-runner.js.map +1 -1
  85. package/dist/electron-build/agents/session-store.js +144 -3
  86. package/dist/electron-build/agents/session-store.js.map +1 -1
  87. package/dist/electron-build/agents/shell-guard.js +110 -14
  88. package/dist/electron-build/agents/shell-guard.js.map +1 -1
  89. package/dist/electron-build/agents/skill-health.js +103 -0
  90. package/dist/electron-build/agents/skill-health.js.map +1 -0
  91. package/dist/electron-build/agents/skill-ledger.js +146 -0
  92. package/dist/electron-build/agents/skill-ledger.js.map +1 -0
  93. package/dist/electron-build/agents/tool-loop-guard.js +319 -0
  94. package/dist/electron-build/agents/tool-loop-guard.js.map +1 -0
  95. package/dist/electron-build/agents/tool-path-args.js +63 -0
  96. package/dist/electron-build/agents/tool-path-args.js.map +1 -0
  97. package/dist/electron-build/agents/tool-result-gate.js +59 -0
  98. package/dist/electron-build/agents/tool-result-gate.js.map +1 -0
  99. package/dist/electron-build/agents/tool-subset.js +116 -0
  100. package/dist/electron-build/agents/tool-subset.js.map +1 -0
  101. package/dist/electron-build/agents/tool-telemetry.js +108 -0
  102. package/dist/electron-build/agents/tool-telemetry.js.map +1 -0
  103. package/dist/electron-build/agents/wallet-tools.js +102 -0
  104. package/dist/electron-build/agents/wallet-tools.js.map +1 -0
  105. package/dist/electron-build/bootstrap/context-collector.js +38 -7
  106. package/dist/electron-build/bootstrap/context-collector.js.map +1 -1
  107. package/dist/electron-build/bootstrap/lifecycle-hooks.js +12 -3
  108. package/dist/electron-build/bootstrap/lifecycle-hooks.js.map +1 -1
  109. package/dist/electron-build/bootstrap/persona-init.js +216 -0
  110. package/dist/electron-build/bootstrap/persona-init.js.map +1 -0
  111. package/dist/electron-build/bootstrap/persona-loader.js +5 -1
  112. package/dist/electron-build/bootstrap/persona-loader.js.map +1 -1
  113. package/dist/electron-build/cli/wallet-store.js +200 -0
  114. package/dist/electron-build/cli/wallet-store.js.map +1 -0
  115. package/dist/electron-build/context-compaction/auto-compact.js +38 -2
  116. package/dist/electron-build/context-compaction/auto-compact.js.map +1 -1
  117. package/dist/electron-build/external-engines/delegate.js +22 -0
  118. package/dist/electron-build/external-engines/delegate.js.map +1 -1
  119. package/dist/electron-build/llm/pi-ai.js +25 -0
  120. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  121. package/dist/electron-build/orbitdb/cid-database.js +55 -0
  122. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  123. package/dist/electron-build/orbitdb/ipfs-node.js +36 -11
  124. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  125. package/dist/electron-build/web/server-storage.js +26 -5
  126. package/dist/electron-build/web/server-storage.js.map +1 -1
  127. package/dist/external-engines/delegate.js +21 -0
  128. package/dist/index.js +1324 -173
  129. package/dist/llm/pi-ai.js +23 -0
  130. package/dist/orbitdb/cid-database.js +55 -0
  131. package/dist/orbitdb/event-index.js +370 -0
  132. package/dist/orbitdb/event-query.js +131 -0
  133. package/dist/orbitdb/event-shell.js +510 -0
  134. package/dist/orbitdb/group-access.js +381 -0
  135. package/dist/orbitdb/group-shards.js +140 -0
  136. package/dist/orbitdb/ipfs-node.js +36 -11
  137. package/dist/web/bolloon-web.json +3 -3
  138. package/dist/web/client.js +3 -2
  139. package/dist/web/mobile-core.js +7762 -6289
  140. package/dist/web/routes-mobile-tasks.js +2 -1
  141. package/dist/web/server-storage.js +26 -5
  142. package/dist/web/server.js +2 -0
  143. package/dist/web/sw.js +1 -1
  144. package/package.json +2 -1
@@ -0,0 +1,226 @@
1
+ /**
2
+ * 回合后自审 → 沉淀可复用经验 (2026-10-01, 用户「落实」)。
3
+ *
4
+ * 为什么: 智能体目前只在**回合内**反应 —— 做完就完了, 经验不落库。飞轮目标里"结束后沉淀可复用经验"
5
+ * 是唯一还没动工的一段。做法学的是运行时那套**回合后自审**的机制, 约束照搬:
6
+ * · 只写**沉淀库**, **绝不碰主对话 / prompt 缓存**(所以纯旁路, 不影响用户看到的内容);
7
+ * · **不阻塞主回合**: 调用方 fire-and-forget, 本模块自己吞掉所有异常;
8
+ * · **工具白名单**: v1 连工具都不用 —— 只把"本回合摘要 + 现有库目录"喂给模型, 要它回一段 JSON;
9
+ * · **fail-open**: 节流/配置读不出来时**放行**(宁可偶尔多审一次, 不要因配置坏掉就永久不审);
10
+ * · **绝不编造**: 没有值得记的就回 none, 那就什么都不写(写空是正常结果, 不是失败)。
11
+ * 库的形态(反囤积): `~/.bolloon/experience/<类>.md`, **类级**文件 + 条目标题去重 ——
12
+ * 「同一教训学两次 = 改那一条, 不是加一条」。
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import os from 'node:os';
17
+ export const EXPERIENCE_DIR_NAME = 'experience';
18
+ /** 同一个 agent 两次自审的最小间隔 (默认 10 分钟; 可用 BOLLOON_EXPERIENCE_REVIEW_MIN_MS 覆盖) */
19
+ export const DEFAULT_MIN_INTERVAL_MS = 10 * 60 * 1000;
20
+ export function experienceDir(home = os.homedir()) {
21
+ return path.join(home, '.bolloon', EXPERIENCE_DIR_NAME);
22
+ }
23
+ /** 类名净化 (只允许安全字符; 空 ⇒ 'general') */
24
+ export function sanitizeClass(k) {
25
+ const s = String(k ?? '').trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
26
+ return s || 'general';
27
+ }
28
+ /** 条目标题净化 (空 ⇒ 拒绝写: 没有标题的沉淀无法去重, 宁可不要) */
29
+ export function sanitizeTitle(t) {
30
+ return String(t ?? '').trim().replace(/\s+/g, ' ').slice(0, 120);
31
+ }
32
+ /**
33
+ * 节流判据 (纯函数)。**fail-open**: 时间戳坏了/缺失 ⇒ 放行。
34
+ * @param lastAtMs 上一次自审时间 (0/undefined = 从未)
35
+ */
36
+ export function shouldReview(nowMs, lastAtMs, minIntervalMs = DEFAULT_MIN_INTERVAL_MS) {
37
+ if (!Number.isFinite(nowMs) || nowMs <= 0)
38
+ return true; // 时钟坏了 ⇒ 放行 (fail-open)
39
+ if (!lastAtMs || !Number.isFinite(lastAtMs) || lastAtMs <= 0)
40
+ return true; // 从未审过 ⇒ 放行
41
+ if (!Number.isFinite(minIntervalMs) || minIntervalMs <= 0)
42
+ return true; // 配置坏了 ⇒ 放行 (fail-open)
43
+ return nowMs - lastAtMs >= minIntervalMs;
44
+ }
45
+ /**
46
+ * **每次任务都复盘** (2026-10-01, 用户: 「自动每次做完任务都要总结经验」)。
47
+ * 与 `shouldReview` 的区别: 那个是"每 10 分钟最多一次" ✗ —— 会漏掉"做完一个任务"这种**事件** ✓;
48
+ * 这条按**任务签名**判: 签名变了(换任务了) ⇒ **立刻复盘** ✓; 同一任务重复 ⇒ 才回到节流 ✓。
49
+ * (成本控制: 只在"这一回合真的干了活"时才算签名 —— 由调用方保证 ✓; 且复盘是 fire-and-forget ✓。)
50
+ */
51
+ export function shouldReviewTask(nowMs, lastAtMs, lastSig, sig, minIntervalMs = DEFAULT_MIN_INTERVAL_MS) {
52
+ if (sig && sig !== lastSig)
53
+ return true; // ★ 换了任务 ⇒ 立刻复盘
54
+ return shouldReview(nowMs, lastAtMs, minIntervalMs); // 同一任务 ⇒ 仍按节流, 免得反复审同一段
55
+ }
56
+ /** 现有库的目录 (喂给模型, 让它知道"已经有哪些类/条目", 从而倾向于改而不是加) */
57
+ export function listExperienceIndex(home = os.homedir()) {
58
+ const dir = experienceDir(home);
59
+ try {
60
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
61
+ if (!files.length)
62
+ return '(沉淀库还是空的)';
63
+ return files.slice(0, 30).map((f) => {
64
+ const body = fs.readFileSync(path.join(dir, f), 'utf-8');
65
+ const titles = [...body.matchAll(/^##\s+(.+)$/gm)].map((m) => m[1].trim()).slice(0, 20);
66
+ return `- ${f} (${titles.length} 条)\n${titles.map((t) => ` · ${t}`).join('\n')}`;
67
+ }).join('\n');
68
+ }
69
+ catch {
70
+ return '(沉淀库还是空的)';
71
+ }
72
+ }
73
+ /**
74
+ * 自审提示词 (纯函数)。**反囤积**规矩逐条写进去 —— 这是"库会变干净还是变垃圾堆"的分水岭。
75
+ */
76
+ export function buildReviewPrompt(turnSummary, existingIndex) {
77
+ return [
78
+ '你在做**回合后自审**: 判断上面这一回合里有没有值得沉淀成"可复用经验"的东西。',
79
+ '',
80
+ '已经沉淀过的(先看这里, 别重复):',
81
+ existingIndex,
82
+ '',
83
+ '这一回合发生了什么:',
84
+ '"""',
85
+ String(turnSummary || '').slice(0, 6000),
86
+ '"""',
87
+ '',
88
+ '规矩(按这个判):',
89
+ '1. **同一教训学两次 = 一条规矩**: 若上面已有同类条目 ⇒ 用同一个 title, 表示"更新那一条"; 否则才算新条目。',
90
+ '2. 只沉淀**可复用**的: 类级规律 · 踩过的坑与判据 · 用户对做法/风格的纠正 · 稳定的环境事实。',
91
+ '3. **不要**沉淀: 本次事件叙述 · 版本号/日期流水 · 一次性任务进度 · 环境依赖型失败(缺二进制、首次安装报错)。',
92
+ '4. 没有值得记的就回 none —— **写空是正常结果**, 不是失败; 编一条凑数比不写更糟。',
93
+ '5. 内容要短: title ≤ 60 字, body ≤ 400 字, body 写"规矩 + 为什么", 不写过程。',
94
+ '',
95
+ '只回一个 JSON(不要别的文字):',
96
+ '{"action":"none"} 或 {"action":"write","class":"类名(小写-连字符)","title":"一句话标题","body":"规矩与理由"}',
97
+ ].join('\n');
98
+ }
99
+ /** 容错解析模型回的那段 JSON (纯函数)。任何异常/不完整 ⇒ none (绝不半写) */
100
+ export function parseReviewDecision(text) {
101
+ const raw = String(text || '');
102
+ const m = raw.match(/\{[\s\S]*\}/);
103
+ // 2026-10-01 (用户: 「卡在这就不动了」现场: 日志里连着出现 "审了但没写: 没找到 JSON" ✗ ⇒ 复盘白跑):
104
+ // 模型 Often 不给严格 JSON ⇒ 原来直接当"没写" ✗。现在: ① 没花括号时**宽解析**(从文本里抠字段 ✓);
105
+ // ② 仍解析不出 ⇒ 把**原文头部**记进日志(才能知道它到底回了什么 ✓, 不再瞎 ✗)。
106
+ if (!m) {
107
+ const act = /(?:^|["'\s])action["'\s::]*["']?([a-z_]+)/i.exec(raw)?.[1]
108
+ || (/\bwrite\b/i.test(raw) ? 'write' : (/\bnone\b|不用|无需/i.test(raw) ? 'none' : ''));
109
+ if (act === 'write') {
110
+ const title = /(?:title|标题)["'\s::]*["']?([^"'\n]{2,80})/i.exec(raw)?.[1]?.trim() || '未命名教训';
111
+ const body = /(?:body|内容|教训)["'\s::]*["']?([\s\S]{20,1200}?)(?:["'\n]|$)/i.exec(raw)?.[1]?.trim() || raw.slice(0, 400);
112
+ if (body.length >= 20)
113
+ return { action: 'write', file: 'lessons.md', title, body, klass: 'general', reason: '宽解析' };
114
+ }
115
+ if (act === 'none')
116
+ return { action: 'none', reason: '模型判定无需记录(宽解析)' };
117
+ return { action: 'none', reason: `没找到 JSON(模型原文头部: ${raw.replace(/\s+/g, ' ').slice(0, 120)})` };
118
+ }
119
+ try {
120
+ const j = JSON.parse(m[0]);
121
+ if (String(j?.action || '').toLowerCase() !== 'write')
122
+ return { action: 'none' };
123
+ const title = sanitizeTitle(j.title);
124
+ const body = String(j?.body ?? '').trim().slice(0, 800);
125
+ if (!title || !body)
126
+ return { action: 'none', reason: 'title/body 不全' }; // 宁可丢, 不写半条
127
+ return { action: 'write', klass: sanitizeClass(j.class), title, body };
128
+ }
129
+ catch {
130
+ return { action: 'none', reason: 'JSON 解析失败' };
131
+ }
132
+ }
133
+ /**
134
+ * 落库: **同 title 就更新那一条**(hit+1, 替换正文), 否则追加。
135
+ * @returns 实际动作, 便于测试与日志
136
+ */
137
+ export function applyExperience(d, home = os.homedir(), nowIso = new Date().toISOString()) {
138
+ if (d.action !== 'write' || !d.title || !d.body)
139
+ return { wrote: false };
140
+ const dir = experienceDir(home);
141
+ const file = path.join(dir, `${sanitizeClass(d.klass)}.md`);
142
+ try {
143
+ fs.mkdirSync(dir, { recursive: true });
144
+ let cur = '';
145
+ try {
146
+ cur = fs.readFileSync(file, 'utf-8');
147
+ }
148
+ catch {
149
+ cur = '';
150
+ }
151
+ const header = cur.trim() ? cur : `# 可复用经验 · ${sanitizeClass(d.klass)}\n\n> 回合后自审沉淀; 同一教训只留一条(重复出现则更新) · 每条带出现次数与末次时间\n`;
152
+ // 2026-10-01 修: 上一版用正则 `...(?=^## |\Z)` 找条目边界 —— **JS 不支持 \Z**, 它被当成字面量 Z,
153
+ // 而条目的时间戳正好以 Z 结尾 ⇒ 从那儿截断, 更新后留下旧正文残渣(实跑看到: "Z -->" + 旧正文)✗。
154
+ // 改成**按 `## ` 切块再拼** —— 不依赖任何正则边界语义。
155
+ const blockOf = (t, body, hits) => `## ${t}\n<!-- hit:${hits} last:${nowIso} -->\n${body}\n`;
156
+ const parts = header.split(/\n(?=## )/);
157
+ const head = parts[0].trimEnd();
158
+ const entries = parts.slice(1).filter((x) => x.trim().startsWith('## '));
159
+ const titleOf = (blk) => (blk.split('\n')[0] || '').replace(/^##\s*/, '').trim();
160
+ const hitsOf = (blk) => Number((blk.match(/<!--\s*hit:(\d+)/) || [])[1] || 0);
161
+ const idx = entries.findIndex((blk) => titleOf(blk) === d.title);
162
+ if (idx >= 0) {
163
+ const n = hitsOf(entries[idx]) + 1; // 上一次次数 + 本次 (更新 ⇒ 至少 2)
164
+ entries[idx] = blockOf(d.title, d.body, n);
165
+ fs.writeFileSync(file, head + '\n\n' + entries.map((x) => x.trimEnd()).join('\n\n') + '\n', 'utf-8');
166
+ return { wrote: true, file, updated: true };
167
+ }
168
+ entries.push(blockOf(d.title, d.body, 1));
169
+ fs.writeFileSync(file, head + '\n\n' + entries.map((x) => x.trimEnd()).join('\n\n') + '\n', 'utf-8');
170
+ return { wrote: true, file, updated: false };
171
+ }
172
+ catch {
173
+ return { wrote: false };
174
+ }
175
+ }
176
+ function escapeRe(s) {
177
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
178
+ }
179
+ /**
180
+ * 自审编排 (纯旁路, **绝不抛错**)。
181
+ *
182
+ * `chat` 由调用方注入 (便于门测; 生产用现成的单次补全)。整段逻辑:
183
+ * 节流 ⇒ 取库目录 ⇒ 拼提示 ⇒ 一次补全 ⇒ 解析 ⇒ 落库。
184
+ * 任何一步失败都只是"这次没沉淀", 不影响主回合 —— 调用方 fire-and-forget 即可。
185
+ */
186
+ export async function runExperienceReview(opts) {
187
+ const home = opts.home || os.homedir();
188
+ const now = opts.nowMs ?? Date.now();
189
+ const log = opts.log || (() => { });
190
+ try {
191
+ if (!opts.force && !shouldReview(now, opts.lastAtMs, opts.minIntervalMs))
192
+ return { reviewed: false, applied: false, reason: 'throttled' };
193
+ const summary = String(opts.turnSummary || '').trim();
194
+ if (summary.length < 40)
195
+ return { reviewed: false, applied: false, reason: '回合太短, 不值得审' };
196
+ const prompt = buildReviewPrompt(summary, listExperienceIndex(home));
197
+ // 2026-10-01 (用户: 「卡在这就不动了」+ 复盘日志里 "开始" 之后没有下文 ✗):
198
+ // review 的 chat 调用**原来没有超时** ⇒ 一旦 provider 慢/挂, 这条就永远不返回 ✓。
199
+ // 现在给**硬超时**(默认 25s, 可配 ✓), 超时如实记一条并放行 ✓ —— 复盘是锦上添花, 绝不许拖住主流程 ✓。
200
+ const chatTimeoutMs = Number(process.env.BOLLOON_REVIEW_TIMEOUT_MS) || 25_000;
201
+ const raw = await Promise.race([
202
+ opts.chat(prompt),
203
+ new Promise((resolve) => setTimeout(() => resolve('__TIMEOUT__'), chatTimeoutMs)),
204
+ ]);
205
+ if (raw === '__TIMEOUT__') {
206
+ log(`审查超时(${chatTimeoutMs}ms) —— 本轮不复盘, 已放行(绝不拖住主流程)`);
207
+ return { reviewed: false, applied: false, reason: 'chat-timeout' };
208
+ }
209
+ const d = parseReviewDecision(raw);
210
+ if (d.action !== 'write')
211
+ return { reviewed: true, applied: false, reason: d.reason || 'none' };
212
+ const r = applyExperience(d, home, new Date(now).toISOString());
213
+ if (r.wrote) {
214
+ log(`[experience] ${r.updated ? '更新' : '新增'} ${path.basename(r.file)} · ${d.title}`);
215
+ try {
216
+ opts.onLesson?.({ title: d.title, body: d.body, klass: d.klass });
217
+ }
218
+ catch { /* 回调绝不影响落库 */ }
219
+ }
220
+ return { reviewed: true, applied: r.wrote, file: r.wrote ? r.file : undefined, updated: r.wrote ? r.updated : undefined };
221
+ }
222
+ catch (e) {
223
+ log(`[experience] 自审跳过(非致命): ${String(e?.message || e).slice(0, 120)}`);
224
+ return { reviewed: false, applied: false, reason: 'error' };
225
+ }
226
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * gateway-group.ts — Agent Gateway P2P 群组 (2026-08-14)
3
3
  *
4
- * 群组 = OrbitDB events store (accessController write:'*'), 任何成员可广播消息,
5
- * 全成员通过 OrbitDB pubsub 复制实时同步. 链接: orbitdb://<addr>?type=group&name=<群名>.
4
+ * 群组 = OrbitDB events store, 全成员通过 OrbitDB pubsub 复制实时同步.
5
+ * 链接: orbitdb://<addr>?type=group&name=<群名>.
6
6
  *
7
7
  * 符合用户习惯: 群组 = 微信式群聊 — 加入链接即进群, 发消息全网同步.
8
8
  * 持久化: ~/.bolloon/gateway-groups.json (重启后仍是群成员, 自动重开 store).
@@ -14,12 +14,26 @@
14
14
  * (修复前: 区块只在内存, 新进程一开就报 "No block brokers capable of retrieving blocks")
15
15
  * · 读不到必须报读不到: `groupMessages` 在 store 打不开时**抛 GroupStoreUnreachableError**,
16
16
  * 不再返回 `[]`。把"读不到"显示成"群里没有消息"是骗人, 本条不可回退。
17
+ *
18
+ * 2026-10-01 (P1) DID 门控:
19
+ * · `createGroup(name, { gate: { owner, members?, membershipEvents? } })` 建的是**门控群**:
20
+ * 写入白名单 = 群主 OrbitDB 写身份 + 显式成员, 非名单成员 add 会被拒。
21
+ * · 不给 `gate` → 行为**一字不变** (write:['*']); 默认翻成门控需要先迁移全部既有调用点。
22
+ * · 成员变更 = `MembershipEvent` (成员自签 + 群主签, 双侧 Ed25519 验签), 读写入口
23
+ * `recordMembershipEvent` / `groupMembershipEvents` / `groupWriteList`。
24
+ * · **白名单不能就地改** (IPFS 型 AC 的白名单在内容寻址的 ACL 块里, 改它 = 换 store 地址),
25
+ * 所以"改成员"= 重建 store; 代价清单见 `group-access.ts` 的 `aclChangePlan()`。
17
26
  */
18
27
  import * as os from 'os';
19
28
  import * as path from 'path';
20
29
  import { getCIDDatabase } from '../orbitdb/cid-database.js';
21
30
  // 2026-09-28: 交流语言 (Efficode 是可选项, 不是默认项) —— 只有双方都声明才用
22
31
  import { decodeFromPeer, encodeForPeer, recordLangDecision } from '../efficode/negotiate.js';
32
+ // 2026-10-01 (P1): **DID 门控** —— 群写入白名单从 write:['*'] 收紧成成员白名单。
33
+ // 白名单里放的是 **OrbitDB 写身份 id** (66 位压缩 secp256k1 公钥 hex), 不是 DID 字符串
34
+ // (canAppend 只比 identity.id; 放 DID 会永远匹配不上 → 谁都写不了)。
35
+ // 成员变更 = 一条**双侧 Ed25519 验签**的群事件 (成员自签 + 群主签), 细节见 group-access.ts。
36
+ import { applyMembershipEvents, groupAccessOptions, membershipEntryOf, normalizeWriteList, verifyMembershipEvent, GROUP_MEMBERSHIP_KIND, } from '../orbitdb/group-access.js';
23
37
  // ============ 依赖注入 (测试用, 避免单测起真实 OrbitDB 节点) ============
24
38
  let _dbOverride = null;
25
39
  /** 测试用: 注入 fake CIDDatabase */
@@ -116,9 +130,15 @@ function groupIdOf(address) {
116
130
  return m ? m[1] : address;
117
131
  }
118
132
  /**
119
- * 打开群组 store (缓存) — 可写 (replica=false, write:'*')。
133
+ * 打开群组 store (缓存)。
120
134
  * 打不开 → 记下原始原因 + 返回 null (不抛: 调用方各自决定用什么话术报)。
121
135
  * 2026-09-24: 必须 try/catch —— openStoreByAddress 现在抛 OrbitDBStoreUnreachableError。
136
+ *
137
+ * 2026-10-01 (P1): **不再传 `accessController`**。打开一个**合法已存在地址**时, OrbitDB
138
+ * 从 manifest 里取回 ACL 并覆盖入参 (`@orbitdb/core` src/orbitdb.js:129-131), 所以这里
139
+ * 传 `write:['*']` 是**死代码** —— 之前那行会让人误以为"打开时放开成任何人可写"。
140
+ * 真正的写权限 = 建群时烧进 manifest 的白名单 (DID 门控群 = 成员白名单)。
141
+ * 能不能写由 manifest 决定 + 调用方是否 add/put 决定; 本函数不做权限判定。
122
142
  */
123
143
  async function openGroupStore(address) {
124
144
  const id = groupIdOf(address);
@@ -127,13 +147,7 @@ async function openGroupStore(address) {
127
147
  const db = getDb();
128
148
  let store = null;
129
149
  try {
130
- // 2026-09-24: 不再传 `replica` —— `@orbitdb/core` 4.0.0 的 open() 没有这个参数
131
- // (src/orbitdb.js:118), 传了被丢; 之前那行是"不存在的语义"。
132
- // 可写与否由 manifest 里的 ACL 决定, 且**打开既有地址时 ACL 从 manifest 取回、
133
- // 入参 accessController 被覆盖** (src/orbitdb.js:129-131) —— 所以这里传什么都不改变权限。
134
- store = await db.openStoreByAddress(address, 'events', {
135
- accessController: { write: ['*'] },
136
- });
150
+ store = await db.openStoreByAddress(address, 'events', {});
137
151
  }
138
152
  catch (e) {
139
153
  openFailures.set(id, e);
@@ -169,17 +183,49 @@ export function onGroupMessage(groupId, fn) {
169
183
  onChangeCallbacks.get(groupId).add(fn);
170
184
  return () => { onChangeCallbacks.get(groupId)?.delete(fn); };
171
185
  }
172
- // ============ 群组操作 ============
173
- /**
174
- * 创建群组: 新 events store (write:'*') + 持久化 + 生成邀请链接.
175
- */
176
186
  export async function createGroup(name, opts) {
177
187
  const groupName = String(name || '').trim() || `group-${Date.now().toString(36).slice(-4)}`;
178
188
  try {
179
189
  const db = getDb();
180
- const store = await db.openStore(`bolloon-gw-group-${groupName}`, 'events', {
181
- accessController: { write: ['*'] },
182
- });
190
+ // ---- 门控分支: 先派生白名单 (验签不过就直接拒), 再建 store ----
191
+ // null = 不传 write 列表 ⇒ OrbitDB 默认 (创建者独占). 不再默认 ['*'].
192
+ let writeList = null;
193
+ let gateInfo = {};
194
+ let membershipEntries = [];
195
+ if (opts?.gate) {
196
+ const gate = opts.gate;
197
+ const applied = await applyMembershipEvents({
198
+ owner: gate.owner,
199
+ events: gate.membershipEvents ?? [],
200
+ });
201
+ if (applied.rejected.length > 0) {
202
+ return {
203
+ ok: false,
204
+ error: `拒绝据此建群: ${applied.rejected.length} 条成员事件未通过验签/授权 —— ${applied.rejected[0].reason}`,
205
+ };
206
+ }
207
+ // 白名单 = 群主 ∪ 事件派生成员 ∪ 直接点名成员
208
+ const ids = [
209
+ gate.owner.orbitdbId,
210
+ ...applied.members.map((m) => m.orbitdbId),
211
+ ...(gate.members ?? []).map((m) => m.orbitdbId),
212
+ ];
213
+ writeList = normalizeWriteList(ids);
214
+ gateInfo = { gated: true, ownerDid: gate.owner.did, aclWrite: writeList };
215
+ membershipEntries = applied.accepted.map(membershipEntryOf);
216
+ }
217
+ else if (opts?.acl === 'open') {
218
+ // 显式声明开放写入 (产品语义: 谁拿到邀请链接都能发言) —— 打一行 warn 留痕
219
+ writeList = ['*'];
220
+ console.warn(`[group] ${groupName}: acl=open ⇒ 任何人可写 (调用方显式声明)`);
221
+ }
222
+ else {
223
+ console.warn(`[group] ${groupName}: 未声明 acl ⇒ 创建者独占写入 (2026-10-01 起的默认)`);
224
+ }
225
+ const store = await db.openStore(`bolloon-gw-group-${groupName}`, 'events',
226
+ // 注意: groupAccessOptions([]) 会抛错 (空名单会被 accessControllerOption 丢掉 ⇒ 落回默认),
227
+ // 所以"创建者独占"这条路只能**不传** AC 选项 ⇒ 传 {}.
228
+ writeList ? groupAccessOptions(writeList) : {});
183
229
  const address = store.address;
184
230
  const id = groupIdOf(address);
185
231
  const link = `orbitdb://${address}?type=group&name=${encodeURIComponent(groupName)}`;
@@ -187,10 +233,14 @@ export async function createGroup(name, opts) {
187
233
  id, name: groupName, address, link,
188
234
  createdAt: new Date().toISOString(),
189
235
  lastSyncAt: new Date().toISOString(),
236
+ ...gateInfo,
190
237
  };
191
238
  storeCache.set(id, store);
239
+ // 成员变更事件先落库 (它们是"谁能写"的凭证; 带 kind 字段, 不会被当成聊天消息)
240
+ for (const entry of membershipEntries)
241
+ await store.add(entry);
192
242
  // 欢迎消息 (群主自我介绍)
193
- const from = opts?.from || 'group-owner';
243
+ const from = opts?.from || gateInfo.ownerDid || 'group-owner';
194
244
  await store.add({ from, text: opts?.hello || `📢 群主创建了群「${groupName}」, 分享链接邀请成员加入`, ts: Date.now() });
195
245
  const groups = await loadGroups();
196
246
  await saveGroups([...groups.filter((g) => g.id !== id), info]);
@@ -298,6 +348,91 @@ export async function groupMembers(groupId) {
298
348
  const msgs = await groupMessages(groupId, 500);
299
349
  return Array.from(new Set(msgs.map((m) => m.from)));
300
350
  }
351
+ // ============ 2026-10-01 (P1): DID 门控 —— 成员事件读写 / 白名单自检 ============
352
+ /** 读群 store 的**原始条目** (含非消息条目, 如成员事件)。打不开 → 抛 (与 groupMessages 同规矩) */
353
+ async function allRawEntries(groupId) {
354
+ let store = storeCache.get(groupId) ?? null;
355
+ if (!store) {
356
+ const groups = await loadGroups();
357
+ const g = groups.find((x) => x.id === groupId);
358
+ if (!g)
359
+ return [];
360
+ store = await openGroupStore(g.address);
361
+ if (!store)
362
+ throw new GroupStoreUnreachableError(groupId, g.address, openFailures.get(groupId));
363
+ }
364
+ return store.all();
365
+ }
366
+ /**
367
+ * 读本群的全部成员变更事件, 并**逐条重新验签**。
368
+ * 没验过的事件**不算数** (进 rejected, 不进 accepted) —— 别把"库里有一条"当成"它是真的"。
369
+ */
370
+ export async function groupMembershipEvents(groupId) {
371
+ const raw = await allRawEntries(groupId);
372
+ const accepted = [];
373
+ const rejected = [];
374
+ for (const e of raw) {
375
+ const v = e.value;
376
+ if (!v || typeof v !== 'object' || v.kind !== GROUP_MEMBERSHIP_KIND)
377
+ continue;
378
+ const ev = v.event;
379
+ const r = await verifyMembershipEvent(ev);
380
+ if (r.ok)
381
+ accepted.push(ev);
382
+ else {
383
+ rejected.push({ event: ev, ok: false, reason: r.checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail}`).join(' | ') });
384
+ }
385
+ }
386
+ accepted.sort((a, b) => a.ts - b.ts);
387
+ return { accepted, rejected };
388
+ }
389
+ /**
390
+ * 落一条成员变更事件 (验签不过 → 拒写, 返回 error)。
391
+ * 注意: 能写这个 store 本身就要求调用者在白名单里 —— 本函数不代替 ACL, 只是入口处的形状/签名闸门。
392
+ */
393
+ export async function recordMembershipEvent(groupId, event) {
394
+ const v = await verifyMembershipEvent(event);
395
+ if (!v.ok) {
396
+ return { ok: false, error: `成员事件验签不过: ${v.checks.filter((c) => !c.ok).map((c) => c.name).join(',')}` };
397
+ }
398
+ let store = storeCache.get(groupId) ?? null;
399
+ if (!store) {
400
+ const groups = await loadGroups();
401
+ const g = groups.find((x) => x.id === groupId);
402
+ if (!g)
403
+ return { ok: false, error: '群组不存在 (先 joinGroup/createGroup)' };
404
+ store = await openGroupStore(g.address);
405
+ if (!store)
406
+ return { ok: false, error: `群组 store 不可达: ${unreachableReason(openFailures.get(groupId))}` };
407
+ }
408
+ try {
409
+ await store.add(membershipEntryOf(event));
410
+ return { ok: true };
411
+ }
412
+ catch (e) {
413
+ return { ok: false, error: `写成员事件失败: ${String(e?.message || e).slice(0, 160)}` };
414
+ }
415
+ }
416
+ /**
417
+ * 从本群库里的成员事件**派生**写白名单 (群主 + add − remove)。
418
+ * 这就是"当前这条群地址的 ACL 应该长什么样"的**可核验答案**:
419
+ * 拿它去和 manifest 里真正的白名单比对 (或拿它去重建一个新 store)。
420
+ */
421
+ export async function groupWriteList(groupId, owner) {
422
+ const groups = await loadGroups();
423
+ const g = groups.find((x) => x.id === groupId);
424
+ const raw = await allRawEntries(groupId);
425
+ const events = [];
426
+ for (const e of raw) {
427
+ const v = e.value;
428
+ if (v && typeof v === 'object' && v.kind === GROUP_MEMBERSHIP_KIND && v.event)
429
+ events.push(v.event);
430
+ }
431
+ const applied = await applyMembershipEvents({ owner, events });
432
+ const gated = !!(g?.gated || applied.accepted.length > 0);
433
+ // 非门控的老群 (write:['*']) 派生不出成员白名单 —— 如实标 gated:false, 不硬凑
434
+ return { write: applied.write, members: applied.members, rejectedCount: applied.rejected.length, gated };
435
+ }
301
436
  /** 发送群消息 (广播给所有成员) */
302
437
  export async function groupSend(groupId, text, from, opts) {
303
438
  const msg = String(text || '').trim();
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 这些工具"一次调用可顶多次操作" ⇒ 用它换来的迭代要**退还**。
3
+ * 加新工具时问自己: 它能不能在一次调用里做完 N 件事? 能 ⇒ 加进来。
4
+ */
5
+ export const REFUNDABLE_TOOLS = new Set(['execute_code']);
6
+ export function capsFromEnv(env = process.env) {
7
+ const num = (v, d) => {
8
+ const n = Number(v);
9
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : d;
10
+ };
11
+ return {
12
+ parent: num(env.BOLLOON_MAX_ITERATIONS, 500),
13
+ subagent: num(env.BOLLOON_SUBAGENT_MAX_ITERATIONS, 50),
14
+ };
15
+ }
16
+ /** 警告比例: 严格 (0,1) 的有限数; 否则 null = 关闭该功能 (与"只搬约束"一致) */
17
+ export function normalizeWarnRatio(v) {
18
+ if (v === null || v === undefined || typeof v === 'boolean')
19
+ return null;
20
+ const n = Number(v);
21
+ return Number.isFinite(n) && n > 0 && n < 1 ? n : null;
22
+ }
23
+ export function shouldWarn(used, maxTotal, ratio = 0.8) {
24
+ const r = normalizeWarnRatio(ratio);
25
+ if (r === null || !Number.isFinite(used) || !Number.isFinite(maxTotal) || maxTotal <= 0)
26
+ return false;
27
+ return used / maxTotal >= r;
28
+ }
29
+ /** 该工具的调用是否值得退还一次迭代 */
30
+ export function isRefundableTool(toolName) {
31
+ return REFUNDABLE_TOOLS.has(String(toolName ?? '').trim());
32
+ }
33
+ export class IterationBudget {
34
+ maxTotal;
35
+ _used = 0;
36
+ _refunded = 0;
37
+ constructor(maxTotal) {
38
+ this.maxTotal = Number.isFinite(maxTotal) && maxTotal > 0 ? Math.floor(maxTotal) : Number.POSITIVE_INFINITY;
39
+ }
40
+ /** 尝试消费一次; 返回 false = 预算用尽 (调用方据此收尾) */
41
+ consume() {
42
+ if (this._used >= this.maxTotal)
43
+ return false;
44
+ this._used += 1;
45
+ return true;
46
+ }
47
+ /** 归还一次 (批处理工具调用后) —— 这是"奖励批处理"的落点 */
48
+ refund() {
49
+ if (this._used > 0) {
50
+ this._used -= 1;
51
+ this._refunded += 1;
52
+ }
53
+ }
54
+ get used() { return this._used; }
55
+ get refunded() { return this._refunded; }
56
+ get remaining() { return Math.max(0, this.maxTotal - this._used); }
57
+ get depleted() { return this._used >= this.maxTotal; }
58
+ warn(ratio = 0.8) { return shouldWarn(this._used, this.maxTotal, ratio); }
59
+ /** 供 UI/日志: `已用/上限 (退还 N 次)` */
60
+ describe() {
61
+ const cap = Number.isFinite(this.maxTotal) ? String(this.maxTotal) : '∞';
62
+ return `${this._used}/${cap}${this._refunded ? ` (退还 ${this._refunded} 次)` : ''}`;
63
+ }
64
+ }