@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,218 @@
1
+ /**
2
+ * 长输入/粘贴的"折叠" (2026-10-01, 用户: 「输入文本框有压缩吗…教给 bolloon」)。
3
+ *
4
+ * 学到并会照做的做法(研究得到的口径, 用自己的话写):
5
+ * ① 折叠的**产出** = 把整段写到一个文件 + 输入处留**一行短引用**(带 序号/行数/路径) ⇒ 输入框和对话流都不会被一大段糊满;
6
+ * ② 关键设计: 这个短引用**指向文件**, 需要细节时**读文件**就能拿到全文 ⇒ 效果上**无损**;
7
+ * ③ 阈值由**前端**决定(没有硬阈值约定) ⇒ 我取"≥8 行 或 ≥1200 字符"。
8
+ * 顺带解决另一件事: 长用户消息不再整段躺在历史里 ⇒ 等价于"超长用户消息要精简"那条口径, 而且**在入口就收住**(比压缩时再精简更早 ✓)。
9
+ *
10
+ * 纪律: 只对**长输入**生效(短的一句不动 ✓) · 文件落 `~/.bolloon/pastes/`(0600 ✓) · 失败**绝不打断发送**(退化成原样发送 ✓)。
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+ /**
16
+ * 折叠阈值。**按真实数据定的** (2026-10-01 实测: 用户的一次粘贴 = 479 + 137 两块的纯文本,
17
+ * 旧阈值 8 行 / 1200 字符**两个都没到** ⇒ 不折叠 ⇒ 原文进输入框 ⇒ 里面 '/'/'@'/'#' 触发弹窗
18
+ * ⇒ "没编号 + 有弹窗" 同一个根因 ✓)。
19
+ * 新口径: **只要是多行(含换行)就折叠** ✓ —— 输入框里本来不可能有换行(回车即提交 ✓) ⇒ 含换行必是粘贴 ✓;
20
+ * 单行则到 300 字符就折叠 ✓。Enter 本身是单个 '\n' ⇒ 但 `looksLikePasteChunk` 已按长度排除 ⇒ 不会误解 ✓。
21
+ */
22
+ export const PASTE_MIN_LINES = 2;
23
+ export const PASTE_MIN_CHARS = 300;
24
+ export function pastesDir(home = os.homedir()) {
25
+ return path.join(home, '.bolloon', 'pastes');
26
+ }
27
+ export function pasteLineCount(text) {
28
+ return String(text ?? '').split('\n').length;
29
+ }
30
+ export function shouldCollapsePaste(text, opts = {}) {
31
+ const s = String(text ?? '');
32
+ if (!s.trim())
33
+ return false;
34
+ return pasteLineCount(s) >= (opts.minLines ?? PASTE_MIN_LINES) || s.length >= (opts.minChars ?? PASTE_MIN_CHARS);
35
+ }
36
+ /**
37
+ * 把长输入折叠成"一行引用 + 文件路径"。写盘失败 ⇒ 原样返回(不打断用户) ✓。
38
+ */
39
+ export function collapsePaste(text, opts = {}) {
40
+ const s = String(text ?? '');
41
+ const lines = pasteLineCount(s);
42
+ const base = { inputText: s, sendText: s, lines, chars: s.length, collapsed: false };
43
+ if (!shouldCollapsePaste(s, opts))
44
+ return base;
45
+ try {
46
+ const home = opts.home || os.homedir();
47
+ const dir = pastesDir(home);
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ const n = opts.counter ?? (() => {
50
+ try {
51
+ return fs.readdirSync(dir).filter((f) => f.startsWith('paste_')).length + 1;
52
+ }
53
+ catch {
54
+ return 1;
55
+ }
56
+ })();
57
+ const hhmmss = (opts.now || new Date()).toTimeString().slice(0, 8).replace(/:/g, '');
58
+ const file = path.join(dir, `p${n}_${hhmmss}.txt`);
59
+ fs.writeFileSync(file, s, { encoding: 'utf-8', mode: 0o600 });
60
+ // 输入框那份**不能带 '@' '/' '#'** —— 它们会触发补全弹窗(实测: 粘贴后弹窗冒出来 ✗)。
61
+ // 所以输入框只放"序号 + 行数"; **完整路径只出现在发送出去的那份**(sendText)里 ✓。
62
+ // 2026-10-01 用户指定形态(并要求"比这个短一点"): `[粘贴 #N: M 行 → 路径]` ✓
63
+ // · 路径**必须留着** ✓ —— 所以"路径里的 '/' + '#N' 会触发补全弹窗"不能靠摘路径来躲 ✗,
64
+ // 改为**确定性判断**: 输入框里只要是"粘贴引用"就一律不当补全来源 ✓(见 `isPasteRef` ✓)。
65
+ // · 短一点: 文件名从 `paste_<n>_<HHMMSS>` 缩成 `p<n>_<HHMMSS>` ✓; 家目录显示成 `~` ✓。
66
+ const shortFile = `~/${path.relative(home, file).split(path.sep).join('/')}`;
67
+ const ref = `[粘贴 #${n}: ${lines} 行 → ${shortFile}]`;
68
+ return {
69
+ inputText: ref,
70
+ sendText: ref,
71
+ path: file,
72
+ lines,
73
+ chars: s.length,
74
+ collapsed: true,
75
+ };
76
+ }
77
+ catch {
78
+ return base;
79
+ }
80
+ }
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+ // 粘贴"真实形态"处理 (2026-10-01, 用户连报三次「还是会有弹窗,无法进入输入框」后补)
83
+ //
84
+ // 教训: 我先前假设"一次粘贴 = 一个 chunk" ✗ —— 真实终端里至少两种形态会打脸:
85
+ // ① **括号粘贴**: 终端把整段包在 `\x1b[200~ … \x1b[201~` 里 ⇒ 我的"含 ESC 就不当粘贴"直接放弃 ✗
86
+ // ⇒ 原文进了输入框 ⇒ 里面的 '@'/'/'/'#' 触发补全弹窗 ⇒ 弹窗又把后续按键吃掉 ⇒ "无法进入输入框" ✗;
87
+ // ② **逐行成块**: 多行粘贴可能一行一个 chunk ⇒ 每块都短 ⇒ 都不够折叠阈值 ✗。
88
+ // 对策: 先剥括号标记 ⇒ 再判断"像不像粘贴" ⇒ 把**一串**粘贴块**攒起来**(80ms 静默算一次粘贴结束) ⇒ 整段折叠 ✓;
89
+ // 并且在粘贴进行期间**抑制补全弹窗** ✓(粘进来的文本不该弹窗 ✓)。
90
+ // ─────────────────────────────────────────────────────────────────────────────
91
+ /** 括号粘贴标记 (终端自动加的, 必须剥掉再判断) */
92
+ const PASTE_START = '\u001b[200~';
93
+ const PASTE_END = '\u001b[201~';
94
+ export function stripBracketedPaste(text) {
95
+ return String(text ?? '').split(PASTE_START).join('').split(PASTE_END).join('');
96
+ }
97
+ export function hasBracketedPasteMarker(text) {
98
+ const s = String(text ?? '');
99
+ return s.includes(PASTE_START) || s.includes(PASTE_END);
100
+ }
101
+ /** 一个 chunk 是否"像粘贴"(而不是手敲/方向键): 长、含换行、或带括号粘贴标记 */
102
+ export const PASTE_CHUNK_MIN_CHARS = 40;
103
+ export function looksLikePasteChunk(chunk) {
104
+ const s = String(chunk ?? '');
105
+ if (!s)
106
+ return false;
107
+ // ⚠️ 2026-10-01 修正: **单个 \n 就是回车** —— 早先"含换行即粘贴"会把回车吞掉 ✗(Enter 提交不了 ✗)。
108
+ // 现在: 长度够才算粘贴; 换行只在"已经够长"时作为辅助信号; 括号粘贴标记也要有实际内容。
109
+ if (s.length >= PASTE_CHUNK_MIN_CHARS)
110
+ return true;
111
+ // 带括号粘贴标记 ⇒ 只要剥掉标记后**有内容**就是粘贴(标记只可能来自终端的粘贴路径 ✓)
112
+ if (hasBracketedPasteMarker(s) && s.replace(/\u001b\[20[01]~/g, '').length > 0)
113
+ return true;
114
+ return false;
115
+ }
116
+ /** 攒块的静默窗口: 这么久没有新块 ⇒ 认为"这次粘贴结束了" */
117
+ export const PASTE_BURST_IDLE_MS = 80;
118
+ /**
119
+ * 粘贴形态**观测** (有界, 只记形态不记正文) ⇒ `~/.bolloon/logs/input-chunks.jsonl`。
120
+ * 为什么留它: 这次"弹窗/进不去输入框"连报三轮, 全靠**猜** chunk 形态 ✗ ⇒ 以后一眼能看出真实形态 ✓。
121
+ * 纪律: best-effort(失败静默) · 只记 长度/有无括号标记/有无换行/有无 ESC · 上限 500 行(超了就重写)。
122
+ */
123
+ export function logPasteChunk(info, home = os.homedir()) {
124
+ try {
125
+ const dir = path.join(home, '.bolloon', 'logs');
126
+ fs.mkdirSync(dir, { recursive: true });
127
+ const file = path.join(dir, 'input-chunks.jsonl');
128
+ try {
129
+ if (fs.existsSync(file) && fs.readFileSync(file, 'utf-8').split('\n').length > 500)
130
+ fs.writeFileSync(file, '', 'utf-8');
131
+ }
132
+ catch { /* 忽略 */ }
133
+ fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), ...info }) + '\n', 'utf-8');
134
+ }
135
+ catch { /* 观测失败绝不影响输入 */ }
136
+ }
137
+ /**
138
+ * **输入框只能是单行** (2026-10-01, 用户: 「发送框也会分成好几行」)。
139
+ * 为什么必须净化: Ink 的输入组件拿到带 `\n` 的值就会**撑成多行** ⇒ 底部输入栏被顶高、
140
+ * 内容置顶的布局跟着抖 ✗。所以: 值进输入框之前一律把换行压成空格 ✓(原文照旧进文件/照旧发出去 ✓,
141
+ * 只是**显示**单行 —— 显示单行不等于内容丢 ✓)。
142
+ */
143
+ export function singleLine(text) {
144
+ return String(text ?? '').replace(/[\r\n]+/g, ' ').replace(/[\t]/g, ' ');
145
+ }
146
+ /**
147
+ * 粘贴后**短暂封住补全弹窗**的判定 (2026-10-01, 按真实数据收尾)。
148
+ * 实测: 一次粘贴是**一整块 762 字符纯文本** ✓(无括号标记/无换行) ⇒ 它的最后一个 token 只要以 '/' 开头,
149
+ * 或含不在字母数字后面的 '@'/'#' ⇒ `getMention` 就会返回命中 ⇒ 弹窗 ⇒ **抢走输入焦点** ✗。
150
+ * 双保险: ① 超长输入(>400 字符)本来就不该当 mention 来源 ✓; ② 粘贴后 **1.5 秒内** 一律不弹 ✓
151
+ * —— 封禁期一过(或用户手动敲键)立刻恢复, 免得影响正常的 '@'/'/'/'#' 补全 ✓。
152
+ */
153
+ export const PASTE_MENTION_SHIELD_MS = 1500;
154
+ export const MENTION_MAX_INPUT_CHARS = 400;
155
+ /**
156
+ * 输入框里是不是"粘贴引用"本身(形态: `[粘贴 #N: M 行 → ~/.bolloon/pastes/pN_…txt]`)。
157
+ * 是引用 ⇒ **一律不当补全来源** ✓ —— 这样引用里可以放心带 `#` 与路径的 `/` ✓(不必为躲弹窗牺牲信息 ✓)。
158
+ */
159
+ export function isPasteRef(text) {
160
+ return /^\s*\[\s*粘贴\s*#?\d+\s*[::]\s*\d+\s*行\s*→\s*\S+\s*\]\s*$/.test(String(text ?? ''));
161
+ }
162
+ export function shouldSuppressMention(input, shieldUntil, now = Date.now()) {
163
+ if (isPasteRef(input))
164
+ return true; // 确定性抑制(不靠时间窗) ✓
165
+ if (String(input ?? '').length > MENTION_MAX_INPUT_CHARS)
166
+ return true;
167
+ return Number(shieldUntil || 0) > now;
168
+ }
169
+ /**
170
+ * 补全弹窗**观测** (2026-10-01, 用户连报"还是跳出弹窗") —— 目的: 下一次粘贴一读日志就知道是**哪个**弹窗。
171
+ * 只记 事件名/类型/触发字符/输入长度/前 40 字符(足以定位, 不含大段正文) ✓; 上限 500 行 ✓; 失败静默 ✓。
172
+ */
173
+ export function logPopupEvent(ev, home = os.homedir()) {
174
+ try {
175
+ const dir = path.join(home, '.bolloon', 'logs');
176
+ fs.mkdirSync(dir, { recursive: true });
177
+ const file = path.join(dir, 'popup-events.jsonl');
178
+ try {
179
+ if (fs.existsSync(file) && fs.readFileSync(file, 'utf-8').split('\n').length > 500)
180
+ fs.writeFileSync(file, '', 'utf-8');
181
+ }
182
+ catch { /* 忽略 */ }
183
+ fs.appendFileSync(file, JSON.stringify({ ts: new Date().toISOString(), ...ev, head: (ev.head || '').slice(0, 40) }) + '\n', 'utf-8');
184
+ }
185
+ catch { /* 观测失败绝不影响输入 */ }
186
+ }
187
+ /**
188
+ * 剥掉 UI 残影 (2026-10-01, 用户: 「颜文字被加载进去了,防一下」)。
189
+ * 为什么: 思考动画/状态行是**屏幕上的东西**, 但它们会被 Ink 重绘留进滚动区 ⇒ 用户框选/拖拽时
190
+ * 会把 `(◕‿◕) 思考中...`、`🔧 … 运行中...`、ANSI 控制符一起带进输入框 ⇒ 混进送给模型的正文 ✗。
191
+ * 做法: 只在**发送前**清洗(不碰用户真正打的字 ✓): 去 ANSI · 去行首颜文字 + "思考中/运行中" 状态片段 ✓。
192
+ */
193
+ /**
194
+ * 判"整行只是状态残影" —— **按语义判, 不靠字符类** ✓:
195
+ * 把非字母数字的装饰(颜文字/组合变音符/符号)全部拿掉, 剩下的如果只有"思考中/运行中/自动整理经验中"
196
+ * ⇒ 这行就是状态行 ⇒ 丢掉 ✓(实测 `ᕙ(▀̿̿Ĺ̯̿̿▀̿ ̿)ᕗ 思考中...` 带**组合变音符**, 用字符类必漏 ✗)。
197
+ */
198
+ const STATUS_WORD = /(思考中|运行中|自动整理经验中)/;
199
+ function isStatusOnlyLine(line) {
200
+ const t = line.trim();
201
+ if (!t)
202
+ return false;
203
+ const core = t.replace(/[^\p{Script=Han}]/gu, ''); // 只看汉字: 颜文字里的 Ĺ/ᕙ 也是\p{L} ⇒ 用\p{L} 会漏 ✗
204
+ return STATUS_WORD.test(core) && /^(思考中|运行中|自动整理经验中)+$/.test(core);
205
+ }
206
+ export function stripUiNoise(text) {
207
+ let out = String(text ?? '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
208
+ const kept = [];
209
+ for (const line of out.split('\n')) {
210
+ const t = line.trim();
211
+ if (isStatusOnlyLine(t))
212
+ continue; // 纯状态行 ⇒ 整行丢掉(不管前面挂什么颜文字)
213
+ if (/^🔧\s+.*运行中[..。…\s]*$/u.test(t))
214
+ continue; // 工具运行中行 ⇒ 丢掉
215
+ kept.push(line.replace(/^\s*[\u2190-\u2bff\u3000-\u303f]{1,3}\s*(思考中|运行中)[..。…\s]*/u, ''));
216
+ }
217
+ return kept.join('\n');
218
+ }
@@ -11,6 +11,7 @@
11
11
  * renderDialog(opts) 带品牌边框的对话框 (艺术字在框内)
12
12
  * LoadingTUI 原地刷新的启动仪表盘类
13
13
  */
14
+ import { truncateSafe } from './status-segments.js';
14
15
  import * as fs from 'fs';
15
16
  import * as path from 'path';
16
17
  import { fileURLToPath } from 'url';
@@ -71,6 +72,17 @@ export const BOLLOON_BANNER = [
71
72
  `${C_TEXT}${BOLD}╚═════╝ ╚═════╝ ╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝${RESET}`,
72
73
  `${C_DIM}Bolloon Agent v${BOLLOON_VERSION}${RESET}`,
73
74
  ].join('\n');
75
+ /**
76
+ * 窄终端档: 同一套风格的小号艺术字 (leo 2026-09-30: 「我看不到我的 logo」).
77
+ * 原来宽 < ~97 列时整块 87 列艺术字被丢掉、只剩一行文字 —— 那不是 logo.
78
+ * 现在按"装得下就上"挑档: 大(87) → 小(37) → 文字(兜底), 保证任何宽度都有字标.
79
+ */
80
+ export const BOLLOON_BANNER_SMALL = [
81
+ `${C_TEXT}${BOLD} ___ ___ _ _ ___ ___ _ _${RESET}`,
82
+ `${C_TEXT}${BOLD}| _ )/ _ \\| | | | / _ \\ / _ \\| \\| |${RESET}`,
83
+ `${C_TEXT}${BOLD}| _ \\ (_) | |__| |_| (_) | (_) | .\` |${RESET}`,
84
+ `${C_TEXT}${BOLD}|___/\\___/|____|____\\___/ \\___/|_|\\_|${RESET}`,
85
+ ].join('\n');
74
86
  /** 艺术字全部行 (机器人头在左, BOLLOON 艺术字在右), 供框内渲染 */
75
87
  export function brandArtLines() {
76
88
  // 2026-08-09: icon 只取机器人头 (裁掉末行 BOLLOON 文字), 避免和右侧 banner 双 logo
@@ -114,6 +126,52 @@ export function termWidth() {
114
126
  const c = process.stdout.columns;
115
127
  return typeof c === 'number' && c > 24 ? c : 80;
116
128
  }
129
+ /**
130
+ * 等真实终端尺寸就位 (2026-09-30, leo 报「TUI 渲染有问题: 顶框被重复打印 + 宽度一路涨」).
131
+ *
132
+ * 真 pty 下 `process.stdout.columns` 在启动最初那一刻可能**还没被写进来** ⇒ 面板先按兜底的 80 列
133
+ * 渲染一次; 等真尺寸到位后又渲染一次 —— 而面板落在 Ink 的静态输出(scrollback)里, 重画覆盖不掉旧内容,
134
+ * 于是**每渲染一次就多留一条顶框**, 宽度还在变 (实测 24x120: 110 → 120; 12x80: 80 → 76).
135
+ * 所以: 出任何一帧之前先等尺寸就位 (通常下一个 tick 或一次 resize 事件就到; 上限 timeoutMs).
136
+ */
137
+ export async function waitForTermSize(timeoutMs = 600) {
138
+ const ok = () => {
139
+ const c = process.stdout.columns;
140
+ return typeof c === 'number' && c > 24;
141
+ };
142
+ if (ok())
143
+ return;
144
+ await new Promise((resolve) => {
145
+ let done = false;
146
+ const finish = () => {
147
+ if (done)
148
+ return;
149
+ done = true;
150
+ clearInterval(iv);
151
+ clearTimeout(t);
152
+ try {
153
+ process.stdout.off('resize', finish);
154
+ }
155
+ catch { /* 老 Node 没有 off */ }
156
+ resolve();
157
+ };
158
+ const iv = setInterval(() => { if (ok())
159
+ finish(); }, 20);
160
+ const t = setTimeout(finish, timeoutMs);
161
+ try {
162
+ process.stdout.once('resize', finish);
163
+ }
164
+ catch { /* 忽略 */ }
165
+ });
166
+ }
167
+ /**
168
+ * 终端行数 (拿不到就按 24 —— 与 termWidth 同一套兜底口径).
169
+ * 2026-09-30 新增: 面板/盒子必须**知道自己有多少行可用**, 否则会长过屏幕把输入行顶出视野.
170
+ */
171
+ export function termHeight() {
172
+ const r = process.stdout.rows;
173
+ return typeof r === 'number' && r > 8 ? r : 24;
174
+ }
117
175
  function stripAnsi(s) {
118
176
  return s.replace(/\x1b\[[0-9;]*m/g, '');
119
177
  }
@@ -160,8 +218,8 @@ function fitLeft(text, inner) {
160
218
  }
161
219
  return out + '…';
162
220
  }
163
- /** 按显示宽度截断并加省略号 */
164
- function truncate(text, max) {
221
+ /** 按显示宽度截断并加省略号 (导出: 调用方要自己把内容裁进盒子, 见 buildBootBox) */
222
+ export function truncate(text, max) {
165
223
  if (dispWidth(text) <= max)
166
224
  return text;
167
225
  let out = '';
@@ -201,12 +259,15 @@ export function renderDashboard(opts) {
201
259
  const maxTitle = dispWidth(opts.title ?? 'Bolloon Agent · 仪表盘') + 4;
202
260
  const inner = Math.max(40, maxArt, maxRow, maxTitle);
203
261
  const width = Math.min(termWidth() - 2, opts.width ?? inner + 4);
262
+ // 2026-09-30: width 被夹到 termWidth()-2 之后, 内宽可能**小于**艺术字宽度 ——
263
+ // 那种情况下 boxRow 装不下, 终端会把每行折成两截 (实测: 艺术字裂成两列错位).
264
+ // 宁可不显示艺术字, 也不让它被折行.
265
+ const innerW = Math.max(0, width - 4);
266
+ const useArt = showBrand && maxArt <= innerW ? art : [];
204
267
  const lines = [];
205
268
  lines.push(boxTop(opts.title ?? 'Bolloon Agent · 仪表盘', width));
206
- if (showBrand) {
207
- for (const l of art)
208
- lines.push(boxRow(l, width, 'center'));
209
- }
269
+ for (const l of useArt)
270
+ lines.push(boxRow(l, width, 'center'));
210
271
  for (const r of opts.rows) {
211
272
  const sym = STATUS_SYMBOL[r.status ?? 'info'];
212
273
  const detail = r.detail ? ` ${C_DIM}${r.detail}${RESET}` : '';
@@ -376,7 +437,8 @@ function colorizeDiff(text) {
376
437
  export function renderToolCallListItem(item, index, total) {
377
438
  const dur = item.durationMs != null ? ` ${C_DIM}${item.durationMs}ms${RESET}` : '';
378
439
  const label = item.status === 'error' ? `${C_ERROR}${item.tool}${RESET}` : `${C_ACCENT}${item.tool}${RESET}`;
379
- return ` 🔧 ${label}${dur}`;
440
+ // 2026-10-01: 工具行也按**保守宽度**截断 —— 超宽会让终端折行、打乱 Ink 光标 (底栏那块就是这么坏的)
441
+ return truncateSafe(` 🔧 ${label}${dur}`, Math.max(20, (process.stdout.columns || 100) - 1));
380
442
  }
381
443
  /** 紧凑渲染工具输出 (检测到 diff 则着色, 否则普通截断) — 当前不显示 body */
382
444
  export function renderToolCallBody(item, width = 72) {
@@ -51,6 +51,13 @@ const UNTAGGED_STARTUP_NARRATION = [
51
51
  /^(?:复用|新建) P2P 身份: /,
52
52
  /^P2P 身份已生成: /,
53
53
  /^DID: did:/,
54
+ // 2026-10-01 (用户连续三次: 「iroh: … / 主题: … 还没去掉」): 这几句**不带模块标签也不带时间戳** ✗
55
+ // ⇒ 原来不在任何判据里 ⇒ 闸门、console 拦截、writeOut 三道全都拦不住 ✓。
56
+ // 仍然按行首锚定、只覆盖**实测观察到**的形状 ✓(不做宽泛匹配 ✓); 带缩进也要认 ✓。
57
+ /^[ \t]*iroh:\s*[0-9a-f]{6,}/i,
58
+ /^[ \t]*主题:\s*[0-9a-f]{8}/i,
59
+ /^[ \t]*复用 DID:\s*did:/i,
60
+ /^[ \t]+名称:\s*[\w.\-]{2,40}\s*$/,
54
61
  ];
55
62
  /** 「错误 / 降级 / 需人介入」信号 —— 命中的行**任何模式下都不许被吞** */
56
63
  const HUMAN_SIGNAL = new RegExp([
@@ -79,11 +86,49 @@ export function isStartupLogLine(line) {
79
86
  || UNTAGGED_STARTUP_NARRATION.some((re) => re.test(line));
80
87
  // inspect dump 的续行 (纯数字/逗号) 由 filterText 的 dump 状态机处理, 这里不判
81
88
  }
89
+ /**
90
+ * 「可选启动富化」任务的失败 —— **落日志, 但默认不占屏**。
91
+ *
92
+ * 2026-09-30 (leo: 「都是启动期可选富化任务失败, 却用带时间戳的'失败'打在屏上」):
93
+ * `[warn]: ⚠️ 本地 IPNS 发布失败: TimeoutError … ❌ 失败 (30003ms)` 这类来自 SDK / 内部后台任务,
94
+ * 用户启动时**无可行动**(看见了也没法处理), 但句子带'失败' ⇒ 按'错误不许被吞'被放上屏 = 纯噪音。
95
+ * 它们仍然**一字不漏落 startup.log**, `--verbose` 照原样上屏 ⇒ 诊断能力一点不丢。
96
+ * 这是把「要不要占屏」和「要不要留痕」分开, 不是把错误藏起来。
97
+ *
98
+ * 判据要求**同时**命中「上面这条窄规则」+「失败词」; 只命中一个照旧按人味信号上报。
99
+ * **刻意只覆盖这两类**: SDK 的 IPNS 发布重试行 + 上下文扫描超时。
100
+ * 不收 `ipfs daemon 启动超时, 可稍后手动运行` 这类**可行动**提示 —— 门自己的契约测试
101
+ * (`log-gate.test.ts` 的"信号行"用例) 就是拿它当反例, 第一版规则太宽时被它判红。
102
+ */
103
+ const OPTIONAL_BOOT_TASK = /(?:本地\s*IPNS\s*发布失败|IPNS\s*发布失败[,,]\s*尝试备用发布|上下文扫描\s*未完成|上下文扫描\s*超时|Bolloon 上下文扫描)/;
104
+ /**
105
+ * SDK(winston) 的**进度诊断**行形状: `2026-09-30T09:27:03.848Z [info]: 结果: ❌ 失败 (30004ms)`。
106
+ *
107
+ * 2026-09-30 (leo): 这类行来自 @diap/sdk 的 logger —— 它**直写控制台、绕过日志门**, 句子里带
108
+ * "失败/❌" ⇒ 按"错误不许被吞"被放上屏, 但它是 `[info]` 级**进度尾巴**(IPNS 发布 30s 超时), 启动时不可行动。
109
+ *
110
+ * **只吞 info/debug, 绝不吞 warn/error** —— 这条收窄是门自己的契约测试逼出来的:
111
+ * `log-gate.test.ts` 断言 `… [warn]: ⚠️ 守护进程启动超时, 可稍后手动运行 ipfs daemon` 必须算人味信号
112
+ * (它确实可行动)。第一版规则连 warn 一起吞 ⇒ 被那条测试判红, 于是收窄。
113
+ */
114
+ // 2026-09-30 真机修正: 原来只认"以 ISO 时间戳开头 + 未染色的 [info]:"。
115
+ // 实测真身是 `[2026-…] 2026-…Z [info]: …` —— 行首可能有日志器加的 `[ts] ` 前缀,
116
+ // `[info]` 里的 info 还会被 ANSI 染色 ⇒ 两条都让旧规则漏网, 于是
117
+ // `2026-09-30T10:22:44.768Z [info]: 结果: ❌ 失败 (30004ms)` 照样上屏 (leo 报过多次)。
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;
82
119
  /** 这一行是不是「错误 / 降级 / 需人介入」—— 是则任何模式下都不许被静默 */
83
120
  export function carriesHumanSignal(line) {
84
121
  if (!line)
85
122
  return false;
86
- return HUMAN_SIGNAL.test(line.replace(BENIGN_COUNT, ' '));
123
+ const l = line.replace(BENIGN_COUNT, ' ');
124
+ const isSignal = HUMAN_SIGNAL.test(l);
125
+ // SDK 诊断行 (info/warn/debug): 只落日志, 不占屏 —— error 级不在其中, 照旧上报
126
+ if (SDK_DIAG_LINE.test(l))
127
+ return false;
128
+ // 可选启动富化的失败: 留痕(日志)但默认不占屏 —— 见 OPTIONAL_BOOT_TASK 的说明
129
+ if (isSignal && OPTIONAL_BOOT_TASK.test(l))
130
+ return false;
131
+ return isSignal;
87
132
  }
88
133
  // ---------------------------------------------------------------------------
89
134
  // verbose / disabled / 日志路径
@@ -37,6 +37,36 @@ const CLI_COMMANDS = [
37
37
  { kind: 'command', label: 'skill', hint: '技能候选', insert: 'skill' },
38
38
  { kind: 'command', label: 'mcp', hint: 'MCP 服务器列表', insert: 'mcp' },
39
39
  { kind: 'command', label: 'agent', hint: '当前智能体', insert: 'agent' },
40
+ // 2026-10-01 (用户报「/channel 没加入 cli 命令栏, 无法选中」): 有处理器但没有条目 ⇒ 打 / 选不到。
41
+ // 一并补上同样漏掉的 /fork (会话分叉)。
42
+ { kind: 'command', label: 'channel', hint: '切换智能体 (无参=列出所有)', insert: 'channel' },
43
+ { kind: 'command', label: 'fork', hint: '从第 N 条会话分叉 (/fork <#>)', insert: 'fork' },
44
+ { kind: 'command', label: 'answer', hint: '回答智能体提出的问题', insert: 'answer' },
45
+ { kind: 'command', label: 'approve', hint: '批准一次待审操作', insert: 'approve' },
46
+ { kind: 'command', label: 'contacts', hint: '联系人 (P2P)', insert: 'contacts' },
47
+ { kind: 'command', label: 'criteria', hint: '判据 / 评分标准', insert: 'criteria' },
48
+ { kind: 'command', label: 'cron', hint: '定时任务', insert: 'cron' },
49
+ { kind: 'command', label: 'dq', hint: '队列 (dequeue)', insert: 'dq' },
50
+ { kind: 'command', label: 'goals', hint: '目标', insert: 'goals' },
51
+ { kind: 'command', label: 'group', hint: 'OrbitDB 群聊 (new/join/send/log/members)', insert: 'group' },
52
+ { kind: 'command', label: 'judgments', hint: '判断记录', insert: 'judgments' },
53
+ { kind: 'command', label: 'net', hint: 'Agent 网络 (join/status/ctx)', insert: 'net' },
54
+ { kind: 'command', label: 'p2p', hint: 'P2P 节点 / 连接', insert: 'p2p' },
55
+ { kind: 'command', label: 'pause', hint: '暂停当前 run', insert: 'pause' },
56
+ { kind: 'command', label: 'payments', hint: '支付 / 任务公告', insert: 'payments' },
57
+ { kind: 'command', label: 'q', hint: '查队列', insert: 'q' },
58
+ { kind: 'command', label: 'questions', hint: '待回答的问题', insert: 'questions' },
59
+ { kind: 'command', label: 'reject', hint: '拒绝一次待审操作', insert: 'reject' },
60
+ { kind: 'command', label: 'runs', hint: '运行记录', insert: 'runs' },
61
+ { kind: 'command', label: 'sessions', hint: '会话列表', insert: 'sessions' },
62
+ { kind: 'command', label: 'setup', hint: '配置向导', insert: 'setup' },
63
+ { kind: 'command', label: 'skills', hint: '技能列表', insert: 'skills' },
64
+ { kind: 'command', label: 'suggestions', hint: '建议队列', insert: 'suggestions' },
65
+ { kind: 'command', label: 'supervise', hint: '监督视图', insert: 'supervise' },
66
+ { kind: 'command', label: 'trace', hint: '过程痕迹', insert: 'trace' },
67
+ { kind: 'command', label: 'tx', hint: '链上交易', insert: 'tx' },
68
+ { kind: 'command', label: 'wake', hint: '唤醒 / 继续目标', insert: 'wake' },
69
+ { kind: 'command', label: 'x402', hint: 'x402 支付信息', insert: 'x402' },
40
70
  { kind: 'command', label: 'did', hint: 'DID 身份', insert: 'did' },
41
71
  { kind: 'command', label: 'ipfs', hint: 'Kubo 状态', insert: 'ipfs' },
42
72
  { kind: 'command', label: 'ipns', hint: 'IPNS keys', insert: 'ipns' },
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 思考记录的可视化 (2026-10-01, 用户: 「bolloon 智能体思考的记录可以也显示出来吗」)。
3
+ *
4
+ * 现状: 模型那边**是有**思考内容的 (deepseek 思考模式: `reasoning_content`, 非流式攒在
5
+ * `ChatResult.reasoningContent`, 流式在 delta 里累), 但只被用来回带(wire 协议要求), **没显示** ✗。
6
+ * 规矩: 显示但要**可控** —— 默认开(用户要的), 能一键关(BOLLOON_SHOW_THINKING=0), 且有界(字符/行数封顶,
7
+ * 免得思考本身把屏幕和上下文吃爆)。显示形态是**暗色块**并标明"思考(未验证)", 与正式回答区分开。
8
+ */
9
+ export const REASONING_MAX_CHARS = 1500;
10
+ export const REASONING_MAX_LINES = 12;
11
+ /** 是否显示思考 (默认**开**; BOLLOON_SHOW_THINKING=0/false/off 关) */
12
+ export function shouldShowReasoning(env = process.env) {
13
+ const v = String(env.BOLLOON_SHOW_THINKING ?? '').trim().toLowerCase();
14
+ if (!v)
15
+ return true;
16
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no');
17
+ }
18
+ /** 归一化: 去掉多余空行; 无内容 ⇒ 空串 */
19
+ export function normalizeReasoning(text) {
20
+ return String(text ?? '')
21
+ .replace(/\r\n?/g, '\n')
22
+ .split('\n')
23
+ .map((l) => l.replace(/\s+$/g, ''))
24
+ .filter((l, i, arr) => !(l.trim() === '' && (i === 0 || arr[i - 1].trim() === '')))
25
+ .join('\n')
26
+ .trim();
27
+ }
28
+ /**
29
+ * 渲染成给终端的一段 (纯函数, 便于门测)。有界 + 标出被省略的量。
30
+ * 返回空串 = 不该显示 (没内容/被关掉)。
31
+ */
32
+ export function formatReasoningForDisplay(text, opts = {}) {
33
+ const enabled = opts.enabled ?? shouldShowReasoning();
34
+ if (!enabled)
35
+ return '';
36
+ const clean = normalizeReasoning(text);
37
+ if (!clean)
38
+ return '';
39
+ const maxChars = opts.maxChars ?? REASONING_MAX_CHARS;
40
+ const maxLines = opts.maxLines ?? REASONING_MAX_LINES;
41
+ let lines = clean.split('\n');
42
+ let truncated = false;
43
+ if (lines.length > maxLines) {
44
+ lines = lines.slice(0, maxLines);
45
+ truncated = true;
46
+ }
47
+ let out = lines.join('\n');
48
+ if (out.length > maxChars) {
49
+ out = out.slice(0, maxChars);
50
+ truncated = true;
51
+ }
52
+ return truncated ? `${out}\n…[思考已截断]` : out;
53
+ }
54
+ export function reasoningMode(env = process.env) {
55
+ const v = String(env.BOLLOON_SHOW_THINKING ?? '').trim().toLowerCase();
56
+ if (v === 'full' || v === 'chain' || v === 'long')
57
+ return 'full';
58
+ if (v === '0' || v === 'false' || v === 'off' || v === 'no')
59
+ return 'off';
60
+ if (v === 'short')
61
+ return 'short';
62
+ if (v === 'trace')
63
+ return 'trace';
64
+ // 2026-10-01 (用户: 「这里的思考能不能变成 trace 的执行描述」): 默认 = trace ——
65
+ // 不显示思维流(实测: 英文/重复四遍/还夹着错的推断), 改在工具行写这一步在做什么。
66
+ return 'trace';
67
+ }
68
+ /** 该模式下是否显示 (兼容旧 API: shouldShowReasoning) */
69
+ export function isReasoningVisible(env = process.env) {
70
+ const m = reasoningMode(env);
71
+ return m !== 'off' && m !== 'trace'; // trace 模式不显示思维流(由工具行的执行描述替代)
72
+ }
73
+ /**
74
+ * 把思维链压成**一句** "在想什么" (纯函数)。
75
+ * 规矩: 取第一句人话; 去掉编号/项目符号/引号; 压空白; 超长截断加省略号; 出不来就空串。
76
+ */
77
+ export function summarizeReasoning(text, maxChars = 120) {
78
+ const clean = normalizeReasoning(text);
79
+ if (!clean)
80
+ return '';
81
+ // 2026-10-01 (实跑发现): 第一行往往是**场景铺垫**("注意上下文有点混乱…") ⇒ 用户要的是"**要做什么**"那句。
82
+ // 规则: 跳过以"铺垫词"开头的行 (注意/背景/上下文/现在/刚才/首先看), 取第一行"像是判断/行动"的。
83
+ const SCENERY = /^(?:注意|背景|上下文|现在|刚才|首先看|先看|情况是)/;
84
+ const lines = clean.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
85
+ const firstLine = lines.find((l) => !SCENERY.test(l)) || lines[0] || '';
86
+ const noBullet = firstLine.replace(/^[-*•\d.、)\s]+/, '').trim();
87
+ // 取第一句 (中英句末都认); 太短就整行
88
+ const m = noBullet.match(/^(.{6,}?[。.!!??;;])/);
89
+ let gist = (m ? m[1] : noBullet).replace(/\s+/g, ' ').trim();
90
+ if (gist.length > maxChars)
91
+ gist = `${gist.slice(0, maxChars - 1).trimEnd()}…`;
92
+ return gist;
93
+ }
94
+ /** 按模式渲染: short ⇒ 单行摘要; full ⇒ 有界块; off ⇒ 空串 */
95
+ export function renderReasoning(text, opts = {}) {
96
+ const mode = opts.mode ?? reasoningMode();
97
+ // off 与 trace 都不给思维流(trace 的执行描述在工具行上, 见 trace-line.ts)
98
+ if (mode === 'off' || mode === 'trace')
99
+ return { mode, text: '' };
100
+ if (mode === 'full')
101
+ return { mode, text: formatReasoningForDisplay(text, { enabled: true, maxChars: opts.maxChars, maxLines: opts.maxLines }) };
102
+ return { mode, text: summarizeReasoning(text, opts.summaryChars ?? 120) };
103
+ }