@bolloon/bolloon-agent 0.6.1 → 0.6.3

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 (78) hide show
  1. package/README.md +128 -111
  2. package/dist/agents/gateway-group.js +231 -6
  3. package/dist/agents/group-autopilot.js +256 -0
  4. package/dist/agents/pi-sdk-tools.js +302 -0
  5. package/dist/agents/pi-sdk.js +89 -140
  6. package/dist/agents/task-group.js +21 -4
  7. package/dist/cli/log-gate.js +68 -4
  8. package/dist/cli/mention-data.js +1 -1
  9. package/dist/cli/model-selector.js +84 -104
  10. package/dist/cli/setup-wizard.js +2 -10
  11. package/dist/cli/tui-select.js +99 -10
  12. package/dist/cli-entry.js +1 -9
  13. package/dist/electron-build/agents/gateway-group.js +814 -0
  14. package/dist/electron-build/agents/gateway-group.js.map +1 -0
  15. package/dist/electron-build/agents/group-autopilot.js +302 -0
  16. package/dist/electron-build/agents/group-autopilot.js.map +1 -0
  17. package/dist/electron-build/agents/pi-sdk-tools.js +302 -0
  18. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  19. package/dist/electron-build/agents/pi-sdk.js +88 -139
  20. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  21. package/dist/electron-build/agents/task-group.js +601 -0
  22. package/dist/electron-build/agents/task-group.js.map +1 -0
  23. package/dist/electron-build/agents/task-public-text.js +97 -0
  24. package/dist/electron-build/agents/task-public-text.js.map +1 -0
  25. package/dist/electron-build/efficode/compress.js +86 -0
  26. package/dist/electron-build/efficode/compress.js.map +1 -0
  27. package/dist/electron-build/efficode/crc32.js +44 -0
  28. package/dist/electron-build/efficode/crc32.js.map +1 -0
  29. package/dist/electron-build/efficode/did.js +74 -0
  30. package/dist/electron-build/efficode/did.js.map +1 -0
  31. package/dist/electron-build/efficode/lz77.js +175 -0
  32. package/dist/electron-build/efficode/lz77.js.map +1 -0
  33. package/dist/electron-build/efficode/negotiate.js +233 -0
  34. package/dist/electron-build/efficode/negotiate.js.map +1 -0
  35. package/dist/electron-build/efficode/ops.js +96 -0
  36. package/dist/electron-build/efficode/ops.js.map +1 -0
  37. package/dist/electron-build/efficode/packet.js +279 -0
  38. package/dist/electron-build/efficode/packet.js.map +1 -0
  39. package/dist/electron-build/efficode/types.js +50 -0
  40. package/dist/electron-build/efficode/types.js.map +1 -0
  41. package/dist/electron-build/kernel/code-write-gate.js +55 -0
  42. package/dist/electron-build/kernel/code-write-gate.js.map +1 -0
  43. package/dist/electron-build/kernel/run-lifecycle.js +23 -0
  44. package/dist/electron-build/kernel/run-lifecycle.js.map +1 -1
  45. package/dist/electron-build/kernel/run-resume.js +62 -0
  46. package/dist/electron-build/kernel/run-resume.js.map +1 -0
  47. package/dist/electron-build/kernel/session-lifecycle.js +223 -0
  48. package/dist/electron-build/kernel/session-lifecycle.js.map +1 -0
  49. package/dist/electron-build/kernel/turn-selfcheck.js +65 -0
  50. package/dist/electron-build/kernel/turn-selfcheck.js.map +1 -0
  51. package/dist/electron-build/llm/config-store.js +2 -2
  52. package/dist/electron-build/llm/config-store.js.map +1 -1
  53. package/dist/electron-build/llm/model-selection.js +16 -2
  54. package/dist/electron-build/llm/model-selection.js.map +1 -1
  55. package/dist/electron-build/llm/pi-ai.js +2 -2
  56. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  57. package/dist/electron-build/orbitdb/cid-database.js +98 -19
  58. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  59. package/dist/electron-build/orbitdb/group-access.js +399 -0
  60. package/dist/electron-build/orbitdb/group-access.js.map +1 -0
  61. package/dist/index.js +143 -10
  62. package/dist/ios/bolloon-web.json +3 -3
  63. package/dist/ios/sw.js +1 -1
  64. package/dist/kernel/code-write-gate.js +47 -0
  65. package/dist/kernel/gate-scan.js +5 -1
  66. package/dist/kernel/plan-harness.js +3 -2
  67. package/dist/kernel/roster.js +6 -6
  68. package/dist/kernel/run-lifecycle.js +21 -0
  69. package/dist/kernel/run-resume.js +58 -0
  70. package/dist/kernel/session-lifecycle.js +210 -0
  71. package/dist/kernel/turn-selfcheck.js +60 -0
  72. package/dist/llm/config-store.js +2 -2
  73. package/dist/llm/model-selection.js +16 -2
  74. package/dist/llm/pi-ai.js +2 -2
  75. package/dist/orbitdb/cid-database.js +95 -19
  76. package/dist/web/bolloon-web.json +3 -3
  77. package/dist/web/sw.js +1 -1
  78. package/package.json +1 -1
@@ -17,13 +17,15 @@ import { createRunContext } from './run-context.js';
17
17
  import { createPrivateActor } from '../kernel/channel-actor.js';
18
18
  // K10 ①: 运行生命周期写入经内核端口 (与 kernel/control.ts 的控制面分开 —— 一个回答"谁命令我停",
19
19
  // 一个回答"我这一轮发生了什么"; 失败语义也不同: 控制面被拒 → 409, 事实写不进 → 响亮失败)
20
- import { submitRunLifecycle, assertLifecycleOk } from '../kernel/run-lifecycle.js';
20
+ import { submitRunLifecycle, assertLifecycleOk, pickRunIdForResume } from '../kernel/run-lifecycle.js';
21
21
  // K10 ④: 通信发送入口经内核端口 (上层不再直接对某条传输说话; 换传输只改这一处注入)
22
22
  import { submitTransport, transportPeersSync } from '../kernel/transport.js';
23
23
  // K10 ①: run 状态迁移经内核**控制面**端口 (它本来就管"谁命令这条 run 改状态": 带 origin 审计 + 拒绝归一化)
24
24
  import { submitRunControl } from '../kernel/control.js';
25
25
  // K10 ②: 回合的模型经**内核运行时**只读取租约 (传输仍是 Pi 客户端 ⇒ 行为不变; 取不到如实回落)
26
26
  import { ModelRuntime, snapshotFromSelection } from '../kernel/model-runtime.js';
27
+ // K10 余项: 会话生命周期的**规则**归内核 (落盘映射 · key 校验 · 读回过滤/水合 · 运行种子合并顺序), I/O 留本类经端口注入
28
+ import { submitSessionOp, assertSessionOk, composeRunSeed, filterSessionMessages, hydrateSessionMessages, } from '../kernel/session-lifecycle.js';
27
29
  import { renderDelegateNotices, pushNotice, renderNoticeBlock } from './background-notices.js';
28
30
  import { runWithWriteOrigin } from './skill-ledger.js';
29
31
  import { createHash } from 'node:crypto';
@@ -31,7 +33,12 @@ import { shouldReviewTask, runExperienceReview } from './experience-review.js';
31
33
  import { decideLessonSink, skillsFromDirs, logLessonSuggestions, routeLessonToSkill } from './lesson-to-skill.js';
32
34
  import { managedServices } from './managed-services.js';
33
35
  import { renderToolListWithParams } from './tool-subset.js';
34
- import { codeWriteTarget, decideTypecheck, formatTypecheckResult } from './code-write-gate.js';
36
+ // K10 余项: 收尾自检的**规则**归内核 (`kernel/code-write-gate.ts`) —— 原来是 agents 侧的一个模块
37
+ import { codeWriteTarget } from '../kernel/code-write-gate.js';
38
+ // K10 余项: 收尾自检的**编排与政策**也归内核 (该不该跑 · 系统自检也过门 · 被拒不执行且可见 · 失败不影响回合)
39
+ import { runTurnEndSelfCheck } from '../kernel/turn-selfcheck.js';
40
+ // K10 余项: 恢复一次运行的**编排与政策**也归内核 (漂移上报口径 · 只在漂了/核对不了时装 · 恢复态一定被清)
41
+ import { resumeRunViaKernel } from '../kernel/run-resume.js';
35
42
  // 2026-10-01: 身份解析必须**静态导入** —— 之前在函数体里用 require(), 而打包后是 ESM ⇒
36
43
  // require 是 undefined ⇒ 抛错被 catch 静默吞掉 ⇒ "自愈"根本没跑, DID 一直是空的 ✗。
37
44
  import { loadOrCreateAgentIdentity } from './agent-identity.js';
@@ -453,7 +460,9 @@ export class PiAgentSession {
453
460
  * · K5 Channel Actor 完成后改为 `createRunContext({ runId: request.resumeRunId })`, 届时删除 `currentRunId` 字段本体。
454
461
  */
455
462
  seedRunContext(extra = {}) {
456
- return createRunContext({ runId: this.actor.state.activeRun, ...extra });
463
+ // **K10 余项**: 合并顺序 (活跃运行打底 · extra 覆盖) 是内核规则 —— 与 `seed-run` 端口共用 `composeRunSeed`
464
+ // "活跃运行"这一处读取仍**只在本方法体内** (K2 播种的唯一读点, 有门钉住); 合并顺序由内核给。
465
+ return createRunContext(composeRunSeed(this.actor.state.activeRun, extra));
457
466
  }
458
467
  /**
459
468
  * **K5 迁移中**: 本会话所属 channel 的 Actor (由 session factory 在创建时绑定)。
@@ -709,7 +718,7 @@ export class PiAgentSession {
709
718
  if (this.actor) {
710
719
  const n = await this.actor.hydrateHistory({
711
720
  load: () => this._sessionStore.loadMessages(sessionKey),
712
- filter: (loaded) => this._filterToMessage(loaded),
721
+ filter: (loaded) => filterSessionMessages(loaded),
713
722
  maxMessages,
714
723
  });
715
724
  if (n > 0)
@@ -721,7 +730,7 @@ export class PiAgentSession {
721
730
  console.log(`[PiAgent] hydrate: 没有 ${sessionKey} 的历史`);
722
731
  return;
723
732
  }
724
- const hydrated = this._filterToMessage(loaded).slice(-maxMessages);
733
+ const hydrated = hydrateSessionMessages(loaded, maxMessages);
725
734
  if (hydrated.length > 0) {
726
735
  this.replaceHistory(hydrated);
727
736
  console.log(`[PiAgent] 从 ${sessionKey} 回灌 ${hydrated.length} 条历史`);
@@ -742,20 +751,24 @@ export class PiAgentSession {
742
751
  * 公开方法 — claude code / 外部 harness 在每次 prompt 完成后调一下,
743
752
  * 即可获得"重启 / 跨进程接续"的语义.
744
753
  */
754
+ /**
755
+ * **K10 余项**: 取数拍 (Actor 快照, 走邮箱 ⇒ 与 append 串行) 与真写入仍是本类的;
756
+ * 而「消息 → 持久形态」的**映射规则**与 key 校验搬进内核 (`submitSessionOp` 的 `save-session`)。
757
+ * 写不进 ⇒ 响亮失败 (`assertSessionOk`), 不静默继续。
758
+ */
745
759
  async saveCurrentSession(key) {
746
- // **K5 第 4 步**: 取数拍走 Actor 的快照 (走邮箱 ⇒ 与 append 串行, 不会抓到"边写边读"的半截状态);
747
- // 未绑定 actor 的会话走原路径 (行为不变)。
748
- const source = this.actor ? await this.actor.historySnapshot() : this.actor.state.messageHistory;
749
- const persisted = source.map((m) => ({
750
- role: m.role,
751
- content: m.content,
752
- toolCall: m.toolCall,
753
- toolResult: m.toolResult,
754
- toolCallId: m.toolCallId,
755
- timestamp: Date.now(),
756
- source: 'pi-session',
757
- }));
758
- await this._sessionStore.saveMessages(key, persisted);
760
+ const out = await submitSessionOp({ op: 'save-session', origin: 'pi-session', key }, this.sessionPorts());
761
+ assertSessionOk(out);
762
+ }
763
+ /** K10 余项: 会话生命周期端口实现 —— I/O 留本类, 规则在内核 (`kernel/session-lifecycle.ts`) */
764
+ sessionPorts() {
765
+ return {
766
+ historySnapshot: () => (this.actor ? this.actor.historySnapshot() : this.actor.state.messageHistory),
767
+ saveMessages: (key, messages) => this._sessionStore.saveMessages(key, messages),
768
+ loadMessages: (key) => this._sessionStore.loadMessages(key),
769
+ // 注意: **故意不提供** `activeRunId` / `newRunContext` —— "活跃运行"的读取必须只有一处 (K2 播种唯一读点, 门钉住),
770
+ // 本类走的是**同步** `seedRunContext` + 内核的 `composeRunSeed`。异步 `seed-run` 端口留给别的调用方 (其语义由内核门覆盖)。
771
+ };
759
772
  }
760
773
  /**
761
774
  * 2026-06-30: 从 disk 拉历史覆盖当前 messageHistory.
@@ -773,47 +786,12 @@ export class PiAgentSession {
773
786
  * 返回 Message[] 数组 (空数组表示无历史).
774
787
  */
775
788
  async peekSessionHistory(key, maxMessages = 30) {
776
- try {
777
- const loaded = await this._sessionStore.loadMessages(key);
778
- if (!loaded)
779
- return [];
780
- return this._filterToMessage(loaded).slice(-maxMessages);
781
- }
782
- catch {
783
- return [];
784
- }
785
- }
786
- /** hydrateMessageHistory 用的过滤逻辑 — 提到外面复用 */
787
- _filterToMessage(loaded) {
788
- const hydrated = [];
789
- const VALID_ROLES = new Set(['user', 'assistant', 'tool', 'system']);
790
- for (const m of loaded) {
791
- // role 必须合法 (拒绝旧 schema {type:'user'} 没 role 字段的)
792
- if (!VALID_ROLES.has(m.role))
793
- continue;
794
- // 跳过污染消息
795
- if (typeof m.content === 'string' && m.content.startsWith('[AI 服务调用失败]'))
796
- continue;
797
- if (typeof m.content === 'string' && m.content.startsWith('[错误:'))
798
- continue;
799
- // 注意: '!m.content' 会跳过 content='' 的 tool call 消息 (assistant role + toolCall 字段),
800
- // 这种是合法的 (LLM 输出只有 tool call, 没有正文) — 必须保留.
801
- // 这里只跳过"无内容 + 也没 tool call/tool result"的废消息.
802
- if (!m.content && !m.toolCall && !m.toolResult)
803
- continue;
804
- // 跳过空 tool role (tool result 占位但没有任何内容)
805
- if (m.role === 'tool' && !m.toolResult)
806
- continue;
807
- hydrated.push({
808
- role: m.role,
809
- content: m.content ?? '',
810
- toolCall: m.toolCall,
811
- toolResult: m.toolResult,
812
- toolCallId: m.toolCallId,
813
- });
814
- }
815
- return hydrated;
789
+ // **K10 余项**: 读回过滤/水合规则归内核; 读失败 ⇒ 空数组, 但**原因进内核审计** (不再完全静默)
790
+ const out = await submitSessionOp({ op: 'peek-history', origin: 'pi-session', key, maxMessages }, this.sessionPorts());
791
+ return out.ok ? out.result : [];
816
792
  }
793
+ // **K10 余项 (减法)**: 原来的 `_filterToMessage` (合法 role 白名单 · 剔污染消息 · 保留"只有 tool call 无正文"的合法消息)
794
+ // 已整条搬进内核 `kernel/session-lifecycle.ts` 的 `filterSessionMessages` —— 规则一处一份, 这里不再留副本。
817
795
  /** 暴露 store 给测试 / 高级集成用. */
818
796
  get sessionStoreInstance() {
819
797
  return this._sessionStore;
@@ -1121,7 +1099,14 @@ export class PiAgentSession {
1121
1099
  return `[初始化未就绪] ${why}\n${hint}`;
1122
1100
  }
1123
1101
  }
1124
- catch { /* 门禁自身异常 → 按"不能判定"处理会阻塞一切; 这里只在能读到状态时才拦 */ }
1102
+ catch (err) {
1103
+ // fail-closed: 门禁异常不能绕过初始化,否则会出现"身份未就绪但 Agent 已调用工具"。
1104
+ const why = `初始化门禁读取失败: ${String(err?.message ?? err).slice(0, 240)}`;
1105
+ this.pushHistory({ role: 'user', content: input });
1106
+ this.pushHistory({ role: 'assistant', content: `[初始化未就绪] ${why}\n请运行 bolloon setup status;修复后再重试。` });
1107
+ console.warn(`[PiAgent] 拒绝执行: ${why}`);
1108
+ return `[初始化未就绪] ${why}\n请运行 bolloon setup status;修复后再重试。`;
1109
+ }
1125
1110
  }
1126
1111
  this.minimaxAvailable = this.checkMinimax();
1127
1112
  this.actor.state.channelId = options?.channelId ?? this.actor.state.channelId;
@@ -1286,8 +1271,10 @@ export class PiAgentSession {
1286
1271
  .catch(() => { });
1287
1272
  }
1288
1273
  catch { /* 落日志失败不影响主流程 */ }
1274
+ // 2026-10-02 (leo: 「cli 的 UI 需要有渲染过滤」): 这条是**内部运行日志** (复盘过程), 不是用户要看的结论
1275
+ // ⇒ 不再进用户可见对话流; 走 console (由 CLI 的渲染过滤只落盘, `--verbose` 下照原样上屏)。
1289
1276
  try {
1290
- this.runCtx.eventSink?.({ type: 'status', content: `📚 复盘: ${m}`, tool: 'system' });
1277
+ console.log(`📚 复盘: ${m}`);
1291
1278
  }
1292
1279
  catch { /* 上屏失败不打断 */ }
1293
1280
  };
@@ -2088,7 +2075,8 @@ ${await this.renderActivePlansSection()}
2088
2075
  * 而 Supervisor / 控制面要在运行结束后才知道"刚才跑的是哪条 run" (否则会拿旧 run 做决策)。
2089
2076
  */
2090
2077
  getLastRunId() {
2091
- return this.lastRunId || this.actor.state.activeRun;
2078
+ // K10 余项: 取值优先级 (显式 lastRunId 优先 · 回落活跃运行) 是**内核规则**
2079
+ return pickRunIdForResume(this.lastRunId, this.actor.state.activeRun);
2092
2080
  }
2093
2081
  /**
2094
2082
  * 2026-09-16 (M2): 从 checkpoint 恢复一次运行 —— **不是**重发原 prompt。
@@ -2101,49 +2089,32 @@ ${await this.renderActivePlansSection()}
2101
2089
  * 并把结论放进返回值 (`modelDrift`) 让调用方能上报; 一致时也有一句明确结论。
2102
2090
  * 判断失败 (读不到 Run 等) 不阻塞恢复。
2103
2091
  */
2092
+ /**
2093
+ * **K10 余项**: 编排与政策归内核 (`kernel/run-resume.ts`) —— 这里只做七件事:
2094
+ * 核对漂移 · 准备 · 按需装配 · 落恢复态 · 驱动同一 runId 继续 · 清态 · 日志。
2095
+ * 语义 (漂移上报口径 · 只在漂了/核对不了时装 · 装配失败不阻塞 · 恢复态一定被清) 由内核拥有。
2096
+ */
2104
2097
  async resumeRun(runId) {
2105
- let modelDrift;
2106
- let modelApplied;
2107
- try {
2108
- const { detectRunConfigDrift } = await import('../llm/model-selection.js');
2109
- modelDrift = (await detectRunConfigDrift(runId)) || undefined;
2110
- if (modelDrift?.drifted) {
2111
- console.warn('[pi-sdk] 恢复时模型配置已偏离 Run 快照:', modelDrift.message);
2112
- }
2113
- else if (modelDrift) {
2114
- console.log('[pi-sdk] 恢复前核对:', modelDrift.message);
2115
- }
2116
- }
2117
- catch { /* 核对本身失败不阻塞恢复 */ }
2118
- const prep = await prepareResume(runId);
2119
- if (!prep.ok || !prep.plan)
2120
- return { ok: false, reason: prep.reason, ...(modelDrift ? { modelDrift } : {}) };
2121
- // 2026-09-26 (P6 × P7): 恢复一个在跑的 Run, 用的是**它自己那份快照**, 不是盘上现在的全局默认 ——
2122
- // 否则这次恢复就是"带着漂移继续跑", 之后再存快照会把漂移固化成历史。
2123
- // 只在"漂了"或"核对不了"时重装 (一致时运行时本来就是对的, 重装是白跑一趟)。
2124
- // 装配只有一处实现: `applyRunModelConfigToRuntime` → `installRuntime` (这里不自己 initMinimax)。
2125
- if (modelDrift && (modelDrift.drifted || !modelDrift.verified)) {
2126
- try {
2098
+ const out = await resumeRunViaKernel(runId, {
2099
+ detectDrift: async (id) => {
2100
+ const { detectRunConfigDrift } = await import('../llm/model-selection.js');
2101
+ return ((await detectRunConfigDrift(id)) || undefined);
2102
+ },
2103
+ prepareResume: (id) => prepareResume(id),
2104
+ applySnapshot: async (snapshot) => {
2127
2105
  const { applyRunModelConfigToRuntime } = await import('../llm/model-selection.js');
2128
- modelApplied = await applyRunModelConfigToRuntime(modelDrift.snapshot);
2129
- console.log(`[pi-sdk] 已按 Run 快照装配运行时: ${modelApplied.provider}/${modelApplied.model} (configHash ${String(modelApplied.configHash).slice(0, 12)})`);
2130
- }
2131
- catch (e) {
2132
- // 装配失败不阻塞恢复 (如实说; 调用方可以从返回值里看出没装成)
2133
- console.warn('[pi-sdk] 按 Run 快照装配运行时失败, 保持当前运行时:', String(e?.message || e).slice(0, 160));
2134
- }
2135
- }
2136
- this.resumeRunId = runId;
2137
- this.resumePlan = prep.plan;
2138
- this.actor.state.goalBinding = prep.plan.goalId || this.actor.state.goalBinding;
2139
- try {
2140
- const reply = await this.prompt(buildResumeInstruction(prep.plan), {});
2141
- return { ok: true, reply, ...(modelDrift ? { modelDrift } : {}), ...(modelApplied ? { modelApplied } : {}) };
2142
- }
2143
- finally {
2144
- this.resumeRunId = '';
2145
- this.resumePlan = null;
2146
- }
2106
+ return (await applyRunModelConfigToRuntime(snapshot));
2107
+ },
2108
+ applyPlan: (plan) => {
2109
+ this.resumeRunId = runId;
2110
+ this.resumePlan = plan;
2111
+ this.actor.state.goalBinding = plan.goalId || this.actor.state.goalBinding;
2112
+ },
2113
+ continueRun: (plan) => this.prompt(buildResumeInstruction(plan), {}),
2114
+ clearPlan: () => { this.resumeRunId = ''; this.resumePlan = null; },
2115
+ log: (level, body) => (level === 'warn' ? console.warn('[pi-sdk] ' + body) : console.log('[pi-sdk] ' + body)),
2116
+ });
2117
+ return out;
2147
2118
  }
2148
2119
  /**
2149
2120
  * 2026-10-02 (K7): 把**本会话的 Harness 判定**包成 skill 门, 供 `BollharnessIntegration.setSkillGuard` 注入。
@@ -2198,46 +2169,24 @@ ${await this.renderActivePlansSection()}
2198
2169
  * (`usePivotLoop: true`, `promptWithPivotLoop` 提前 return) ⇒ 这条"系统自检过门"在**生产根本没触发过**。
2199
2170
  * 抽成方法后两条 loop 路径都调它 (一次一轮, 由 `typecheckRanThisTurn` 保证)。
2200
2171
  */
2172
+ /**
2173
+ * **K10 余项**: 编排与政策归内核 (`kernel/turn-selfcheck.ts`) —— 这里只做三件事: 取工具 · 问门 · 上报。
2174
+ * 语义 (该不该跑 · 系统自检也过门 · 被拒不执行且可见 · 失败不影响回合) 由内核拥有, 换入口不必各写一份。
2175
+ * 状态维护 (一轮只跑一次 / 清空本回合触达) 跟内核的 `decided` 走: 与迁移前逐条一致。
2176
+ */
2201
2177
  async runTurnEndTypecheck() {
2202
- // 2026-10-01: **本回合改过 TS ⇒ 收尾自动类型检查** (一轮一次) —— 复用已有的 tsc_check 工具
2203
- if (decideTypecheck(this.tsTouchedThisTurn, this.typecheckRanThisTurn)) {
2178
+ const out = await runTurnEndSelfCheck({ touched: this.tsTouchedThisTurn, ranThisTurn: this.typecheckRanThisTurn }, {
2179
+ getTool: (name) => this.tools.get(name),
2180
+ askGate: async (req) => this.piHarness().beforeToolCall({
2181
+ tool: req.tool,
2182
+ args: req.args,
2183
+ ctx: this.harnessCtx(),
2184
+ permissionMode: this.currentPermissionMode,
2185
+ }),
2186
+ emit: (evt) => this.runCtx.eventSink?.({ type: 'status', content: evt.content, tool: 'system' }),
2187
+ });
2188
+ if (out.decided) {
2204
2189
  this.typecheckRanThisTurn = true;
2205
- try {
2206
- const tscTool = this.tools.get('tsc_check');
2207
- if (tscTool?.execute) {
2208
- // 2026-10-02 (K7): **系统自检也过门** —— 统一成"任何工具执行都走同一扇 Harness 门"。
2209
- // 这里不是模型发起的工具调用 (args 恒空, 是本回合改过 TS 后的收尾自检), 所以**更**不能例外:
2210
- // 例外一旦靠"没人知道它绕过"活着, 门就不再是唯一的执行咽喉。
2211
- // 语义: 被拒或门抛错 ⇒ **不执行** (fail-closed), 且**可见**地报出来 (拒绝不许静默)。
2212
- let tscAllowed = true;
2213
- let tscWhy = '';
2214
- try {
2215
- const d = await this.piHarness().beforeToolCall({
2216
- tool: 'tsc_check',
2217
- args: {},
2218
- ctx: this.harnessCtx(),
2219
- permissionMode: this.currentPermissionMode,
2220
- });
2221
- tscAllowed = d.allow;
2222
- tscWhy = d.reason || d.rejectedBy || '';
2223
- }
2224
- catch (gateErr) {
2225
- tscAllowed = false;
2226
- tscWhy = `harness-error: ${String(gateErr?.message || gateErr)}`;
2227
- }
2228
- if (!tscAllowed) {
2229
- this.runCtx.eventSink?.({ type: 'status', content: `🔎 类型检查被门拒绝, 未执行: ${tscWhy.slice(0, 120)}`, tool: 'system' });
2230
- }
2231
- else {
2232
- const r = await tscTool.execute({});
2233
- const line = formatTypecheckResult(r?.success !== false && !/error TS\d+/.test(String(r?.output || '')), String(r?.output || ''));
2234
- this.runCtx.eventSink?.({ type: 'status', content: `🔎 ${line}`, tool: 'system' });
2235
- }
2236
- }
2237
- }
2238
- catch (e) {
2239
- this.runCtx.eventSink?.({ type: 'status', content: `🔎 类型检查没能跑起来: ${String(e?.message || e).slice(0, 100)} (改动已落盘, 记得自己跑一次)`, tool: 'system' });
2240
- }
2241
2190
  this.tsTouchedThisTurn = [];
2242
2191
  }
2243
2192
  }
@@ -384,8 +384,12 @@ export function buildPostMessage(input) {
384
384
  }
385
385
  return { ok: true, text: [...base, `verdict=${v}`].join(' ') };
386
386
  }
387
- /** 唯一发送出口: 隐私闸 → groupSend。任何路径都不写本地副本 (本地副本 = 假痕迹) */
388
- export async function sendTrailMessage(groupId, text, from) {
387
+ /**
388
+ * 2026-10-02: 富字段版 (leo: 「@成员 / 回复某条 / 图片音频 / 建分支」)。
389
+ * **text 与 mentions 都过隐私闸** —— @ 的内容可能被写成 DID/地址形态, 一样不许出网。
390
+ * 附件只带 CID (字节在内容寻址层), CID 本身不是隐私形态。
391
+ */
392
+ export async function sendTrailMessage(groupId, text, from, rich = {}) {
389
393
  const gate = requirePublicText(text);
390
394
  if (!gate.ok) {
391
395
  return {
@@ -398,10 +402,23 @@ export async function sendTrailMessage(groupId, text, from) {
398
402
  if (!g.ok) {
399
403
  return { ok: false, sent: false, text, error: `发送者标记命中隐私红线: ${g.violations.map((v) => v.rule).join(', ')}`, violations: g.violations, localFallback: false };
400
404
  }
401
- const r = await groupSend(groupId, text, from);
405
+ const mentionList = Array.isArray(rich.mentions) ? rich.mentions.map((x) => String(x ?? '').trim()).filter(Boolean) : [];
406
+ for (const m of mentionList) {
407
+ const mg = requirePublicText(m);
408
+ if (!mg.ok) {
409
+ return { ok: false, sent: false, text, error: `@ 的内容命中隐私红线, 拒绝发送: ${mg.violations.map((v) => v.rule).join(', ')}`, violations: mg.violations, localFallback: false };
410
+ }
411
+ }
412
+ const r = await groupSend(groupId, text, from, {
413
+ ...(mentionList.length ? { mentions: mentionList } : {}),
414
+ ...(rich.replyTo ? { replyTo: rich.replyTo } : {}),
415
+ ...(rich.attachments?.length ? { attachments: rich.attachments } : {}),
416
+ ...(rich.branch ? { branch: rich.branch } : {}),
417
+ ...(rich.kind ? { kind: rich.kind } : {}),
418
+ });
402
419
  if (!r.ok)
403
420
  return { ok: false, sent: false, text, error: r.error || '群消息没发出去', localFallback: false };
404
- return { ok: true, sent: true, text, localFallback: false };
421
+ return { ok: true, sent: true, text, ...(r.id ? { id: r.id } : {}), localFallback: false };
405
422
  }
406
423
  /** 解析一行群消息 (不是本模块发的 / 格式不认识 → null, 不猜) */
407
424
  export function parseTrailLine(text) {
@@ -116,6 +116,54 @@ const OPTIONAL_BOOT_TASK = /(?:本地\s*IPNS\s*发布失败|IPNS\s*发布失败[
116
116
  // `[info]` 里的 info 还会被 ANSI 染色 ⇒ 两条都让旧规则漏网, 于是
117
117
  // `2026-09-30T10:22:44.768Z [info]: 结果: ❌ 失败 (30004ms)` 照样上屏 (leo 报过多次)。
118
118
  const SDK_DIAG_LINE = /^(?:\[[^\]]*\]\s*)?\d{4}-\d{2}-\d{2}T[\d:.]+Z?\s*\[(?:\x1b\[[0-9;]*m)*(?:info|debug)(?:\x1b\[[0-9;]*m)*\]:/i;
119
+ /**
120
+ * **对话流渲染过滤** (2026-10-02, leo: 「cli 的 UI 需要有渲染过滤」)。
121
+ *
122
+ * 真机污染样本 (用户贴的原文): `⚠ [identity] channel=… · 查到=yes · …` ·
123
+ * `[pi-ai timing] total=1143ms …` · `📚 复盘: 开始(换了任务 ⇒ 立刻复盘)` ·
124
+ * `✗ [session-note] 已更新: …` · `[PiAgent] reviewFinal …` (含**被截断的 prompt 片段**)。
125
+ *
126
+ * 口径与启动期门同一套: **按行首模块标签锚定**, 不做宽泛匹配 (免得吞掉用户内容) ——
127
+ * 只认实测过的这些内部模块; **带 `⚠/✗` 字样也不例外** (它们是内部诊断, 不是"需人介入")。
128
+ * 去处: 仍**一字不漏落日志文件**; `BOLLOON_VERBOSE=1` 照原样上屏 ⇒ 诊断能力一点不丢。
129
+ * 这是把「要不要占屏」和「要不要留痕」分开, 不是把问题藏起来。
130
+ */
131
+ // 允许标签前有一个**严重度字形** (`⚠ [identity] …` / `✗ [session-note] …` 真机就是这样) —— 只放这几个字形, 不做通配
132
+ const INTERNAL_CHATTER_TAG = /^[ \t]{0,8}[⚠✗✓!·]?[ \t]*\[(?:pi-ai(?:\s+timing)?|identity|session-note|react-harness|DocumentStore|DocumentReceiver|kv-server|loadSkills|v3-manifest|v3-async|supervisor-host|tool-telemetry|runs|abort|broadcast)\b[^\]\n]*\]/i;
133
+ /**
134
+ * 没有 ASCII 标签、但实测属于内部运行日志的行 (2026-10-02 leo 追加: 「✅ 最终回复 (质量 8.5/10) ·
135
+ * 🔄 循环 N/10 这类也去掉」)。
136
+ *
137
+ * 定位: 这批本来是**发送点自己声明** `internal: true` (见 `reply-hygiene.ts` / pivot loop 的遥测),
138
+ * 这里是**兜底** —— 历史/未标的同族事件、以及包一层后丢了标记的副本, 也要在同一处收口。
139
+ * 所以它**只认实测过的具体形状** (不做通配), 且与正向声明共用同一个判据函数。
140
+ */
141
+ const INTERNAL_CHATTER_LINE = [
142
+ /^\s*📚\s*复盘[::]/,
143
+ /^\s*\[PiAgent\]\s*reviewFinal\b/,
144
+ /^\s*\[PiAgent\]\s*推理适配器\s*=/,
145
+ /^\s*[✅✓]\s*(?:检测到)?最终回复/,
146
+ /^\s*🔄\s*循环\s*\d+\s*[//]\s*\d+/,
147
+ /^\s*🔍\s*任务复杂度[::]/,
148
+ /^\s*⚙️?\s*动态配置[::]/,
149
+ /^\s*⏹️?\s*pivot loop/,
150
+ /^\s*📋\s*参数/,
151
+ ];
152
+ /**
153
+ * 循环过渡噪音: **行首锚定**, 不算任意子串 —— 老黑名单用的是 `content.includes('继续循环')`,
154
+ * 那会把像"好的, 继续总结一下"这种正经状态行一起吞掉 (误吞比漏吞更糟, 见本文件头)。
155
+ * 实测的真身形状: `工具执行完成继续循环` · `继续总结上一段` 这类**以它开头**的推进过程行。
156
+ */
157
+ const INTERNAL_CHATTER_SUBSTR = [/^\s*工具执行完成\s*继续循环/, /^\s*继续(?:循环|总结)/];
158
+ /** 这一行是不是**内部诊断** (只该进日志, 不该进对话流) */
159
+ export function isInternalChatter(line) {
160
+ if (!line)
161
+ return false;
162
+ const l = String(line).replace(/\x1b\[[0-9;]*m/g, '');
163
+ return INTERNAL_CHATTER_TAG.test(l)
164
+ || INTERNAL_CHATTER_LINE.some((re) => re.test(l))
165
+ || INTERNAL_CHATTER_SUBSTR.some((re) => re.test(l));
166
+ }
119
167
  /** 这一行是不是「错误 / 降级 / 需人介入」—— 是则任何模式下都不许被静默 */
120
168
  export function carriesHumanSignal(line) {
121
169
  if (!line)
@@ -228,7 +276,7 @@ export function installStartupLogGate(opts = {}) {
228
276
  const rerouteSignals = mode === 'cli-interactive';
229
277
  const writeFile = opts.writeFile !== false;
230
278
  const stats = {
231
- suppressed: 0, forwarded: 0, signalKept: 0, fileLines: 0, fileError: null,
279
+ suppressed: 0, forwarded: 0, signalKept: 0, internalSuppressed: 0, fileLines: 0, fileError: null,
232
280
  };
233
281
  const filtering = !verbose && !disabled;
234
282
  // ---- 日志文件 (append; 每 chunk 一次 appendFileSync, 保证进程被强杀也不丢) ----
@@ -351,7 +399,11 @@ export function installStartupLogGate(opts = {}) {
351
399
  const toFile = [];
352
400
  for (const line of text.split('\n')) {
353
401
  toFile.push(line);
354
- if (carriesHumanSignal(line)) {
402
+ // 2026-10-02: **内部诊断行优先判** —— 只落盘, 一律不进对话流 (即便带 ⚠/✗ 字样也不会被当成"需人介入")
403
+ if (isInternalChatter(line)) {
404
+ stats.internalSuppressed++;
405
+ }
406
+ else if (carriesHumanSignal(line)) {
355
407
  // 需人介入的信息必须可见: 交互 CLI 的 stdout 归 Ink, 所以走 stderr
356
408
  stats.signalKept++;
357
409
  origStderr(line + '\n');
@@ -366,9 +418,21 @@ export function installStartupLogGate(opts = {}) {
366
418
  function makeConsoleError() {
367
419
  return (...args) => {
368
420
  const text = formatConsoleArgs(args);
369
- writeLines(text.split('\n'));
421
+ // 2026-10-02: error 里混进的**内部诊断行**同样只落盘 (`✗ [session-note] 已更新: …` 就是这么漏进对话流的);
422
+ // 真正的错误 (没有内部标签) 照旧一行不吞 —— 文件那份**全留**(留痕), 只有 stderr 这份过渲染过滤。
423
+ const lines = text.split('\n');
424
+ const passToScreen = lines.filter((l) => {
425
+ if (isInternalChatter(l)) {
426
+ stats.internalSuppressed++;
427
+ return false;
428
+ }
429
+ return true;
430
+ });
431
+ writeLines(lines);
432
+ if (passToScreen.length === 0)
433
+ return;
370
434
  stats.forwarded++;
371
- origStderr(text + '\n');
435
+ origStderr(passToScreen.join('\n') + '\n');
372
436
  };
373
437
  }
374
438
  let stopped = false;
@@ -48,7 +48,7 @@ const CLI_COMMANDS = [
48
48
  { kind: 'command', label: 'cron', hint: '定时任务', insert: 'cron' },
49
49
  { kind: 'command', label: 'dq', hint: '队列 (dequeue)', insert: 'dq' },
50
50
  { kind: 'command', label: 'goals', hint: '目标', insert: 'goals' },
51
- { kind: 'command', label: 'group', hint: 'OrbitDB 群聊 (new/join/send/log/members)', insert: 'group' },
51
+ { kind: 'command', label: 'group', hint: 'OrbitDB 群聊 (new/join/send/log/members/invite/kick/link/privacy/status)', insert: 'group' },
52
52
  { kind: 'command', label: 'judgments', hint: '判断记录', insert: 'judgments' },
53
53
  { kind: 'command', label: 'net', hint: 'Agent 网络 (join/status/ctx)', insert: 'net' },
54
54
  { kind: 'command', label: 'p2p', hint: 'P2P 节点 / 连接', insert: 'p2p' },