@bolloon/bolloon-agent 0.5.4 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/agent-identity-store.js +41 -5
- package/dist/agents/background-notices.js +124 -0
- package/dist/agents/channel-identity.js +54 -0
- package/dist/agents/code-write-gate.js +47 -0
- package/dist/agents/deny-pipeline.js +11 -2
- package/dist/agents/egress-sanitize.js +69 -0
- package/dist/agents/error-classifier.js +67 -5
- package/dist/agents/experience-review.js +226 -0
- package/dist/agents/gateway-group.js +153 -18
- package/dist/agents/iteration-budget.js +64 -0
- package/dist/agents/lesson-to-skill.js +211 -0
- package/dist/agents/managed-services.js +64 -0
- package/dist/agents/parse-tool-call.js +38 -32
- package/dist/agents/pi-sdk-session-manager.js +72 -3
- package/dist/agents/pi-sdk-tools.js +201 -32
- package/dist/agents/pi-sdk.js +463 -21
- package/dist/agents/plan-store.js +36 -0
- package/dist/agents/process-runner.js +64 -2
- package/dist/agents/shell-guard.js +109 -13
- package/dist/agents/skill-health.js +90 -0
- package/dist/agents/skill-ledger.js +129 -0
- package/dist/agents/tool-loop-guard.js +303 -0
- package/dist/agents/tool-path-args.js +53 -0
- package/dist/agents/tool-result-gate.js +50 -0
- package/dist/agents/tool-subset.js +109 -0
- package/dist/agents/tool-telemetry.js +97 -0
- package/dist/bootstrap/context-collector.js +37 -7
- package/dist/bootstrap/lifecycle-hooks.js +12 -3
- package/dist/bootstrap/persona-init.js +174 -0
- package/dist/bootstrap/persona-loader.js +5 -1
- package/dist/cli/ink-app.js +153 -6
- package/dist/cli/input-paste.js +218 -0
- package/dist/cli/loading-tui.js +3 -1
- package/dist/cli/log-gate.js +7 -0
- package/dist/cli/mention-data.js +30 -0
- package/dist/cli/reasoning-view.js +103 -0
- package/dist/cli/startup-notice.js +25 -0
- package/dist/cli/status-segments.js +87 -2
- package/dist/cli/trace-line.js +54 -0
- package/dist/context-compaction/auto-compact.js +37 -2
- package/dist/electron-build/agents/agent-identity-store.js +43 -5
- package/dist/electron-build/agents/agent-identity-store.js.map +1 -1
- package/dist/electron-build/agents/background-notices.js +138 -0
- package/dist/electron-build/agents/background-notices.js.map +1 -0
- package/dist/electron-build/agents/channel-identity.js +64 -0
- package/dist/electron-build/agents/channel-identity.js.map +1 -0
- package/dist/electron-build/agents/code-write-gate.js +55 -0
- package/dist/electron-build/agents/code-write-gate.js.map +1 -0
- package/dist/electron-build/agents/deny-pipeline.js +11 -2
- package/dist/electron-build/agents/deny-pipeline.js.map +1 -1
- package/dist/electron-build/agents/egress-sanitize.js +76 -0
- package/dist/electron-build/agents/egress-sanitize.js.map +1 -0
- package/dist/electron-build/agents/error-classifier.js +68 -5
- package/dist/electron-build/agents/error-classifier.js.map +1 -1
- package/dist/electron-build/agents/experience-review.js +243 -0
- package/dist/electron-build/agents/experience-review.js.map +1 -0
- package/dist/electron-build/agents/iteration-budget.js +73 -0
- package/dist/electron-build/agents/iteration-budget.js.map +1 -0
- package/dist/electron-build/agents/lesson-to-skill.js +228 -0
- package/dist/electron-build/agents/lesson-to-skill.js.map +1 -0
- package/dist/electron-build/agents/managed-services.js +69 -0
- package/dist/electron-build/agents/managed-services.js.map +1 -0
- package/dist/electron-build/agents/parse-tool-call.js +38 -32
- package/dist/electron-build/agents/parse-tool-call.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-session-manager.js +75 -3
- package/dist/electron-build/agents/pi-sdk-session-manager.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-tools.js +200 -31
- package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
- package/dist/electron-build/agents/pi-sdk.js +464 -21
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- package/dist/electron-build/agents/plan-store.js +37 -0
- package/dist/electron-build/agents/plan-store.js.map +1 -1
- package/dist/electron-build/agents/process-runner.js +71 -2
- package/dist/electron-build/agents/process-runner.js.map +1 -1
- package/dist/electron-build/agents/shell-guard.js +110 -14
- package/dist/electron-build/agents/shell-guard.js.map +1 -1
- package/dist/electron-build/agents/skill-health.js +103 -0
- package/dist/electron-build/agents/skill-health.js.map +1 -0
- package/dist/electron-build/agents/skill-ledger.js +146 -0
- package/dist/electron-build/agents/skill-ledger.js.map +1 -0
- package/dist/electron-build/agents/tool-loop-guard.js +319 -0
- package/dist/electron-build/agents/tool-loop-guard.js.map +1 -0
- package/dist/electron-build/agents/tool-path-args.js +63 -0
- package/dist/electron-build/agents/tool-path-args.js.map +1 -0
- package/dist/electron-build/agents/tool-result-gate.js +59 -0
- package/dist/electron-build/agents/tool-result-gate.js.map +1 -0
- package/dist/electron-build/agents/tool-subset.js +116 -0
- package/dist/electron-build/agents/tool-subset.js.map +1 -0
- package/dist/electron-build/agents/tool-telemetry.js +108 -0
- package/dist/electron-build/agents/tool-telemetry.js.map +1 -0
- package/dist/electron-build/bootstrap/context-collector.js +38 -7
- package/dist/electron-build/bootstrap/context-collector.js.map +1 -1
- package/dist/electron-build/bootstrap/lifecycle-hooks.js +12 -3
- package/dist/electron-build/bootstrap/lifecycle-hooks.js.map +1 -1
- package/dist/electron-build/bootstrap/persona-init.js +216 -0
- package/dist/electron-build/bootstrap/persona-init.js.map +1 -0
- package/dist/electron-build/bootstrap/persona-loader.js +5 -1
- package/dist/electron-build/bootstrap/persona-loader.js.map +1 -1
- package/dist/electron-build/context-compaction/auto-compact.js +38 -2
- package/dist/electron-build/context-compaction/auto-compact.js.map +1 -1
- package/dist/electron-build/external-engines/delegate.js +22 -0
- package/dist/electron-build/external-engines/delegate.js.map +1 -1
- package/dist/electron-build/orbitdb/cid-database.js +55 -0
- package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
- package/dist/electron-build/orbitdb/ipfs-node.js +36 -11
- package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
- package/dist/electron-build/web/server-storage.js +26 -5
- package/dist/electron-build/web/server-storage.js.map +1 -1
- package/dist/external-engines/delegate.js +21 -0
- package/dist/index.js +503 -77
- package/dist/orbitdb/cid-database.js +55 -0
- package/dist/orbitdb/event-index.js +370 -0
- package/dist/orbitdb/event-query.js +131 -0
- package/dist/orbitdb/event-shell.js +510 -0
- package/dist/orbitdb/group-access.js +381 -0
- package/dist/orbitdb/group-shards.js +140 -0
- package/dist/orbitdb/ipfs-node.js +36 -11
- package/dist/web/bolloon-web.json +3 -3
- package/dist/web/client.js +3 -2
- package/dist/web/mobile-core.js +7762 -6289
- package/dist/web/routes-mobile-tasks.js +2 -1
- package/dist/web/server-storage.js +26 -5
- package/dist/web/server.js +2 -0
- package/dist/web/sw.js +1 -1
- 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
|
+
}
|
package/dist/cli/loading-tui.js
CHANGED
|
@@ -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';
|
|
@@ -436,7 +437,8 @@ function colorizeDiff(text) {
|
|
|
436
437
|
export function renderToolCallListItem(item, index, total) {
|
|
437
438
|
const dur = item.durationMs != null ? ` ${C_DIM}${item.durationMs}ms${RESET}` : '';
|
|
438
439
|
const label = item.status === 'error' ? `${C_ERROR}${item.tool}${RESET}` : `${C_ACCENT}${item.tool}${RESET}`;
|
|
439
|
-
|
|
440
|
+
// 2026-10-01: 工具行也按**保守宽度**截断 —— 超宽会让终端折行、打乱 Ink 光标 (底栏那块就是这么坏的)
|
|
441
|
+
return truncateSafe(` 🔧 ${label}${dur}`, Math.max(20, (process.stdout.columns || 100) - 1));
|
|
440
442
|
}
|
|
441
443
|
/** 紧凑渲染工具输出 (检测到 diff 则着色, 否则普通截断) — 当前不显示 body */
|
|
442
444
|
export function renderToolCallBody(item, width = 72) {
|
package/dist/cli/log-gate.js
CHANGED
|
@@ -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([
|
package/dist/cli/mention-data.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -153,6 +153,31 @@ export function alertsFromSetup(ev) {
|
|
|
153
153
|
}
|
|
154
154
|
return out;
|
|
155
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* 这个未就绪状态是否**只差"连通性复测"** (2026-10-01)。
|
|
158
|
+
*
|
|
159
|
+
* 为什么单列这一类: 连通性实测结果 >24h 就"过期", 此后**每次启动**都会命中未就绪 ⇒
|
|
160
|
+
* 启动路径会去跑整个 setup 向导 ⇒ 向导按"真跑了步骤就要把前言放出来"的规矩刷约 30 行
|
|
161
|
+
* (初始化框 / Onboard 模式 / 每步 ✓✗ / 就绪度报告) —— 用户明确不要看到这些。
|
|
162
|
+
* 这类不需要在启动时抢跑: 门禁照旧拦住 agent 执行 (不绕过), 用户按需跑 `bolloon setup --test` 即可。
|
|
163
|
+
*
|
|
164
|
+
* 判据 (两条都满足才算):
|
|
165
|
+
* ① 门禁是 setup 且 basic 就绪度没过;
|
|
166
|
+
* ② basic 的原因里**只**出现连通性复测相关字样 (连通 / 过期 / 重测 / connectivity)。
|
|
167
|
+
* 首次使用 (缺 provider/key) 的 basic 原因不匹配 ⇒ 仍然照常跑向导 (onboarding 不能被跳过)。
|
|
168
|
+
*/
|
|
169
|
+
export function onlyConnectivityRetest(ev) {
|
|
170
|
+
if (!ev || ev.gate === 'ready')
|
|
171
|
+
return false;
|
|
172
|
+
const basic = ev.state?.readiness?.basic;
|
|
173
|
+
if (basic !== false)
|
|
174
|
+
return false;
|
|
175
|
+
const reasons = (ev.state?.readinessWhy?.basic || []).map((x) => String(x));
|
|
176
|
+
if (reasons.length === 0)
|
|
177
|
+
return false;
|
|
178
|
+
const connLike = /连通|过期|重测|connectivity|expired/i;
|
|
179
|
+
return reasons.every((r) => connLike.test(r));
|
|
180
|
+
}
|
|
156
181
|
/** verbose 模式下那句"现在是全量"的提示 (默认不出现) */
|
|
157
182
|
export function preambleHintLine(env = process.env) {
|
|
158
183
|
return `[startup] 诊断模式 (--verbose / ${VERBOSE_ENV}=1 / ${PREAMBLE_ENV}=1): 启动前言全量输出`
|
|
@@ -38,11 +38,11 @@ export function statusSegments(f) {
|
|
|
38
38
|
* 规则: 丢的顺序 = ↑ → ◎ → ✓ → ◷ (⚙ 永不丢 —— 它最短, 且"有没有在跑工具"最有信息量);
|
|
39
39
|
* 每一段都按显示宽算 (中文 2 列)。
|
|
40
40
|
*/
|
|
41
|
-
export function fitSegments(segs, avail) {
|
|
41
|
+
export function fitSegments(segs, avail, widthFn = dispWidth) {
|
|
42
42
|
// 2026-09-30: 宽度一律用 loading-tui 的 dispWidth (按 East Asian Width 表) ——
|
|
43
43
|
// 之前这里和 getStatus 各写了一份 "charCode > 255 ⇒ 2" 的启发式, 它把 `│ ░ ◷ ▸ ◎ ✓ ↑ ⚙`
|
|
44
44
|
// 这些**单宽**符号都算成 2 列 ⇒ 右对齐时每行凭空多占十几列 (leo: 「没有完全右对齐」)。
|
|
45
|
-
const w = (s) =>
|
|
45
|
+
const w = (s) => widthFn(s);
|
|
46
46
|
const dropOrder = ['↑', '◎', '✓', '◷']; // 先丢谁 (从重要性的低到高; ⚙ 永不丢)
|
|
47
47
|
let cur = [...segs];
|
|
48
48
|
const total = () => cur.reduce((n, s) => n + w(s), 0) + Math.max(0, cur.length - 1) * 3; // 3 = ' │ '
|
|
@@ -68,3 +68,88 @@ export function rightAlignPad(usedWidth, noteWidth, width) {
|
|
|
68
68
|
const pad = (width - 1) - usedWidth - noteWidth;
|
|
69
69
|
return pad >= 1 ? pad : null;
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* **保守测宽** (2026-10-01, 修"底栏整块重复打印")。
|
|
73
|
+
*
|
|
74
|
+
* 背景: `dispWidth` 按 East Asian Width 只把 **W/F 区**算 2 列, 而我们在底栏实际用了一批
|
|
75
|
+
* **Ambiguous(A) 区**的字符 —— `·`(00B7) `↑`(2191) `◎`(25CE) `≈`(2248) `│`(2502)
|
|
76
|
+
* `—`(2014) `─`(2500) 等。它们在**部分终端/字体**下占 **2 列**, 我们按 1 列算 ⇒ 整行可能
|
|
77
|
+
* 超宽 1–2 列 ⇒ 终端**自动折行**多出一行 ⇒ Ink 的光标数学被打乱 ⇒ **整个底栏被重复打印**
|
|
78
|
+
* (用户屏上同一条状态栏出现三份, 只有计时在变)。也正因为 `◎`/`↑` 不是每屏都有, 症状是"偶尔"。
|
|
79
|
+
*
|
|
80
|
+
* 口径: 这里把这些**我们确实用到的** Ambiguous 字符一律按 **2 列**算 (宁可短一列, 绝不超宽)。
|
|
81
|
+
* 取的是**保守子集** —— 不追求覆盖全部 A 区, 只覆盖底栏/状态栏里会出现的那些; 新增符号时补进来。
|
|
82
|
+
*/
|
|
83
|
+
const AMBIGUOUS_AS_WIDE = new Set([
|
|
84
|
+
'·', '↑', '↓', '◎', '≈', '│', '─', '—', '↕', '§', '¶', '×', '÷', '±', '°', 'µ', '∞', '≠',
|
|
85
|
+
]);
|
|
86
|
+
/** 保守宽度: dispWidth + Ambiguous 字符每个再加 1 列 (即按 2 列算) */
|
|
87
|
+
export function dispWidthSafe(s) {
|
|
88
|
+
const plain = String(s || '');
|
|
89
|
+
let extra = 0;
|
|
90
|
+
for (const ch of plain)
|
|
91
|
+
if (AMBIGUOUS_AS_WIDE.has(ch))
|
|
92
|
+
extra += 1;
|
|
93
|
+
return dispWidth(plain) + extra;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 底栏左段的**可用宽度预算** (2026-10-01)。
|
|
97
|
+
* 不变量: 保守宽度(left) + 至少 1 格 + 保守宽度(title) ≤ width - 1
|
|
98
|
+
* ⇒ 在任何终端字体下都不会因为"正好等于终端宽"而自动折行 (折行会让 Ink 光标数学错位,
|
|
99
|
+
* 表现就是整块底栏被重复打印)。
|
|
100
|
+
*/
|
|
101
|
+
export function statusLineBudget(width, leftPlainWidth, notePlain, margin = 8) {
|
|
102
|
+
const room = width - 1 - dispWidthSafe(notePlain) - 1 - Math.max(0, leftPlainWidth) - margin;
|
|
103
|
+
return Math.max(20, room);
|
|
104
|
+
}
|
|
105
|
+
/** 按**保守宽度**截断 (兜底: 即便有没登记进表的歧义字符, 也不会把行撑出终端) */
|
|
106
|
+
export function truncateSafe(s, maxWidth) {
|
|
107
|
+
const text = String(s || '');
|
|
108
|
+
if (dispWidthSafe(text) <= maxWidth)
|
|
109
|
+
return text;
|
|
110
|
+
let out = '';
|
|
111
|
+
let w = 0;
|
|
112
|
+
for (const ch of text) {
|
|
113
|
+
const cw = dispWidthSafe(ch);
|
|
114
|
+
if (w + cw > Math.max(0, maxWidth - 1))
|
|
115
|
+
break;
|
|
116
|
+
out += ch;
|
|
117
|
+
w += cw;
|
|
118
|
+
}
|
|
119
|
+
return `${out}…`;
|
|
120
|
+
}
|
|
121
|
+
/** 画线字符的**保守**宽度 (U+2500 属 East Asian Width = Ambiguous ⇒ 部分终端按 2 列渲染) */
|
|
122
|
+
/**
|
|
123
|
+
* 分界线默认字符 (2026-10-01, 用户报「输入框宽度变窄了」)。
|
|
124
|
+
*
|
|
125
|
+
* 上一版把 `─`(U+2500, East Asian Width = **Ambiguous**)按 **2 列**算字符数 ⇒ 在"真的按 2 列渲染"
|
|
126
|
+
* 的终端上不折行了 ✓, 但线只剩**一半长** ✗ —— 用户看到的就是"输入框变窄"。
|
|
127
|
+
* 两难: Ambiguous 字符的宽度**由终端决定**, 想既全宽又绝不折行, 只能用**宽度确定是 1 列**的字符。
|
|
128
|
+
* **用户要的是好看**: 所以默认就用 `─` 且按 1 列算 ⇒ **满宽实线** ✓ (2026-10-01 用户明确: 「我不要这样的虚线」)。
|
|
129
|
+
* 如果哪台终端的 `─` 是 2 列宽, 那它这种满宽线会折行 ⇒ 用 `BOLLOON_RULE_CHAR_WIDTH=2` 一键退回半宽。
|
|
130
|
+
*/
|
|
131
|
+
export function defaultRuleChar() {
|
|
132
|
+
const env = String(process.env.BOLLOON_RULE_CHAR || '').trim();
|
|
133
|
+
return env ? env.slice(0, 1) : '─';
|
|
134
|
+
}
|
|
135
|
+
export const RULE_CHAR_SAFE_WIDTH = dispWidthSafe(defaultRuleChar());
|
|
136
|
+
/**
|
|
137
|
+
* 生成一条**保证不折行**的水平线 (2026-10-01)。
|
|
138
|
+
*
|
|
139
|
+
* 用户实测: 底栏那块(输入提示行 + 三条分界线)整块被**重复打印 4 次** ✗。
|
|
140
|
+
* 根因: 分界线写的是 `'─'.repeat(W - 1)` —— `─`(U+2500) 是 **Ambiguous 宽度**:
|
|
141
|
+
* 按 1 列算应该是 W-1 个, 但很多终端按 **2 列**渲染 ⇒ 整行变成 2×(W-1) 列 ✗✗
|
|
142
|
+
* ⇒ 超出终端宽 ⇒ 终端**自动折行** ⇒ Ink 光标数学错乱 ⇒ 整块反复重打。
|
|
143
|
+
* 规矩: 一律按**保守宽度**(Ambiguous 当 2 列)算字符数 —— 宁可线短一点, 绝不超宽。
|
|
144
|
+
* 另一端(终端真的按 1 列渲染)只会让线看起来短一半, 不会坏。
|
|
145
|
+
*/
|
|
146
|
+
export function ruleFor(width, char = defaultRuleChar()) {
|
|
147
|
+
// 2026-10-01 (用户: 「我不要这样的虚线」): 默认 `─` 且**按 1 列**算 ⇒ 满宽的实线 ✓
|
|
148
|
+
// 代价: 某些终端把 `─`(Ambiguous) 渲染成 2 列 ⇒ 这种全宽线会超过终端宽、触发折行(底栏重复打印)。
|
|
149
|
+
// 给那种机器留了一键开关: BOLLOON_RULE_CHAR_WIDTH=2 ⇒ 按 2 列算(线短一半, 但不折行)。
|
|
150
|
+
// 也可以换字符: BOLLOON_RULE_CHAR=-
|
|
151
|
+
const override = parseInt(String(process.env.BOLLOON_RULE_CHAR_WIDTH || ''), 10);
|
|
152
|
+
const per = Math.max(1, Number.isFinite(override) && override > 0 ? override : 1);
|
|
153
|
+
const cells = Math.max(2, Math.floor(Math.max(0, width) / per));
|
|
154
|
+
return char.repeat(cells);
|
|
155
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trace 执行描述 (2026-10-01, 用户: 「反思还是有错误,这里的思考能不能变成 trace 的执行描述」)。
|
|
3
|
+
*
|
|
4
|
+
* 用户实测的痛点: 原来显示的是模型的**思维流**(💭) —— 英文、常重复四遍、还夹着**错的推断** ✗
|
|
5
|
+
* (例: 同一句 "The user wants to push to GitHub." 出现四次; 中途还有一句 `Reflection: ❌ …` 把
|
|
6
|
+
* lefthook 的正常输出当成失败 ✗)。人要的是"它**在做什么**" ✓, 不是"它**在想什么**" ✗。
|
|
7
|
+
* 做法: 把思维流默认关掉, 改在**工具行**上写一句中文动作短语(trace ✓) —— 一行说清这一步干了什么 ✓。
|
|
8
|
+
* 纯函数 ⇒ 可测。
|
|
9
|
+
*/
|
|
10
|
+
/** 工具名 → 中文动作短语 (按"人看到这一步在干嘛"来写, 不照搬工具名) */
|
|
11
|
+
const ACTIONS = [
|
|
12
|
+
[/^git_status$/, '查看 git 状态'],
|
|
13
|
+
[/^git_diff$/, '看改动差异'],
|
|
14
|
+
[/^git_log$/, '看提交历史'],
|
|
15
|
+
[/^git_commit$/, '提交改动'],
|
|
16
|
+
[/^git_push$/, '推送'],
|
|
17
|
+
[/^grep_files$/, '搜内容'],
|
|
18
|
+
[/^glob_files$/, '找文件'],
|
|
19
|
+
[/^list_files$/, '列目录'],
|
|
20
|
+
[/^read_file$/, '读文件'],
|
|
21
|
+
[/^read_document$/, '读文档'],
|
|
22
|
+
[/^write_file$/, '写文件'],
|
|
23
|
+
[/^(edit_file|patch)$/, '改文件'],
|
|
24
|
+
[/^(mkdir|move_file|delete_file|copy_file)$/, '改文件系统'],
|
|
25
|
+
[/^terminal$/, '跑命令'],
|
|
26
|
+
[/^execute_code$/, '跑代码'],
|
|
27
|
+
[/^tsc_check$/, '查类型'],
|
|
28
|
+
[/^list_tools$/, '看有哪些工具'],
|
|
29
|
+
[/^list_skills$/, '找技能'],
|
|
30
|
+
[/^read_skill$/, '读技能'],
|
|
31
|
+
[/^process$/, '管后台进程/服务'],
|
|
32
|
+
[/^task|^plan|^todo/i, '理计划/待办'],
|
|
33
|
+
[/^send_|^broadcast|^check_inbox|^list_peers/, '发消息/看连接'],
|
|
34
|
+
[/^group|^create_group|^join_group/, '群聊操作'],
|
|
35
|
+
[/^wallet|^get_balance|^tx|^token/, '链上/钱包操作'],
|
|
36
|
+
[/^fetch_url|^web_search/, '联网取信息'],
|
|
37
|
+
[/^get_identity|^set_persona|^bolloon_config/, '看/改身份与配置'],
|
|
38
|
+
[/^delegate_task|^agent_call/, '派子智能体'],
|
|
39
|
+
];
|
|
40
|
+
/** 一句话说清这一步在做什么; 认不出的工具 ⇒ 返回空(调用方退回显示工具名) ✓ */
|
|
41
|
+
export function describeToolCall(name) {
|
|
42
|
+
const n = String(name ?? '').trim();
|
|
43
|
+
if (!n)
|
|
44
|
+
return '';
|
|
45
|
+
for (const [re, label] of ACTIONS)
|
|
46
|
+
if (re.test(n))
|
|
47
|
+
return label;
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
/** 渲染成工具行文字 (认不出就用工具名本身, 绝不显示空白 ✓) */
|
|
51
|
+
export function traceLabel(name) {
|
|
52
|
+
const d = describeToolCall(name);
|
|
53
|
+
return d ? `${d} · ${name}` : name;
|
|
54
|
+
}
|
|
@@ -108,10 +108,12 @@ export async function autoCompact(history, opts = {}) {
|
|
|
108
108
|
role: 'system',
|
|
109
109
|
content: `[Auto-Compact Summary] ${summary}`,
|
|
110
110
|
};
|
|
111
|
+
// 2026-10-01: **摘要后面紧跟一条"保目标"锚点** ✓ —— 摘要丢目标它还在(详见 buildGoalAnchor ✓)
|
|
112
|
+
const anchor = buildGoalAnchor(toCollapse, remaining);
|
|
111
113
|
return {
|
|
112
|
-
history: [summaryMsg, ...remaining],
|
|
114
|
+
history: [summaryMsg, ...(anchor ? [anchor] : []), ...remaining],
|
|
113
115
|
applied: true,
|
|
114
|
-
detail: `collapsed ${cutTo} messages into 1 summary (cache key: ${key})`,
|
|
116
|
+
detail: `collapsed ${cutTo} messages into 1 summary (cache key: ${key})${anchor ? ' + 保目标锚点' : ''}`,
|
|
115
117
|
};
|
|
116
118
|
}
|
|
117
119
|
catch (err) {
|
|
@@ -142,3 +144,36 @@ export function _resetAutoCompactCacheForTest() {
|
|
|
142
144
|
// 实际缓存由 readCache/writeCache 管理, 这里只暴露接口
|
|
143
145
|
// 测试可以走 fs.rm CACHE_DIR() 清理
|
|
144
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* **压缩前后"保目标"锚点** (2026-10-01, 用户: 「压缩前后保目标」)。
|
|
149
|
+
* 为什么: auto-compact 会把较早的历史**整段换成一句摘要** ✗ —— 摘要写给"过去发生了什么",
|
|
150
|
+
* 而**当前目标**往往丢在里面 ⇒ 压完模型醒过来半失忆 ⇒ 答非所问 / 重头问 / 改目标 ✓(真机实测 ✓)。
|
|
151
|
+
* 做法: 折叠前从历史里**确定性**抽出三件事(用户要什么 · 已经做到哪 · 下一步 ✓)+ 落成一条 system 消息
|
|
152
|
+
* **紧跟摘要**放回 ⇒ 摘要丢了目标它也还在 ✓。不调模型(便宜 ✓ 可测 ✓)。
|
|
153
|
+
*/
|
|
154
|
+
export function buildGoalAnchor(collapsed, remaining = []) {
|
|
155
|
+
const all = [...collapsed, ...remaining];
|
|
156
|
+
if (!all.length)
|
|
157
|
+
return null;
|
|
158
|
+
const clip = (s, n) => String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
|
|
159
|
+
// ⓐ 用户当前要的(最近一条 user ✓ —— 若在 remaining 里也没关系, 取全局最后一条 ✓)
|
|
160
|
+
const lastUser = [...all].reverse().find((m) => m.role === 'user');
|
|
161
|
+
const ask = lastUser ? clip(String(lastUser.content || ''), 300) : '';
|
|
162
|
+
// ⓑ 已经做到哪: 最近 3 个工具动作(名字 + 结果要点 ✓)
|
|
163
|
+
const tools = all.filter((m) => m.role === 'tool').slice(-3).map((m) => {
|
|
164
|
+
const r = m.toolResult;
|
|
165
|
+
const head = clip(String(r?.error || r?.output || m.content || ''), 80);
|
|
166
|
+
return ` · ${m.tool || 'tool'}: ${head}`;
|
|
167
|
+
});
|
|
168
|
+
// ⓒ 助手最后说的一句话(它的意图/下一步 ✓)
|
|
169
|
+
const lastAssistant = [...all].reverse().find((m) => m.role === 'assistant' && String(m.content || '').trim());
|
|
170
|
+
const said = lastAssistant ? clip(String(lastAssistant.content || ''), 200) : '';
|
|
171
|
+
const body = [
|
|
172
|
+
'【压缩前的现场 · 别丢】',
|
|
173
|
+
ask ? `用户当前要的: ${ask}` : '用户当前要的: (未捕捉到 —— 若不确定, 先按最近的目标继续, 别改题)',
|
|
174
|
+
tools.length ? `已经做过:\n${tools.join('\n')}` : '',
|
|
175
|
+
said ? `我上一步说的是: ${said}` : '',
|
|
176
|
+
'下一步: 接着把上面这件事**做完** —— 不要重头问、不要换目标、不要重复已完成的步骤。',
|
|
177
|
+
].filter(Boolean).join('\n');
|
|
178
|
+
return { role: 'system', content: body };
|
|
179
|
+
}
|