@bolloon/bolloon-agent 0.5.2 → 0.5.4

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 (80) hide show
  1. package/README.md +4 -4
  2. package/dist/agents/chain/transfer-classify.js +99 -0
  3. package/dist/agents/chain/transfer-index.js +354 -0
  4. package/dist/agents/identity/address-binding.js +613 -0
  5. package/dist/agents/network-pulse.js +706 -27
  6. package/dist/agents/pi-sdk-tools.js +67 -15
  7. package/dist/agents/pi-sdk.js +7 -1
  8. package/dist/agents/session-store.js +144 -3
  9. package/dist/agents/wallet-tools.js +63 -0
  10. package/dist/agents/x402/direct-payment.js +689 -0
  11. package/dist/agents/x402/order-identity.js +408 -0
  12. package/dist/agents/x402/paid-info-store.js +17 -0
  13. package/dist/agents/x402/seller-signing.js +614 -0
  14. package/dist/agents/x402/seller-summary.js +398 -0
  15. package/dist/cli/identity-command.js +327 -2
  16. package/dist/cli/ink-app.js +79 -27
  17. package/dist/cli/input-history.js +119 -0
  18. package/dist/cli/loading-tui.js +66 -6
  19. package/dist/cli/log-gate.js +39 -1
  20. package/dist/cli/protocol-envelope.js +4 -0
  21. package/dist/cli/session-summary.js +129 -0
  22. package/dist/cli/session-tree.js +141 -0
  23. package/dist/cli/status-segments.js +70 -0
  24. package/dist/cli/stores.js +16 -0
  25. package/dist/cli/update-commands.js +41 -25
  26. package/dist/cli/wallet-store.js +159 -0
  27. package/dist/cli/wallet-table.js +46 -0
  28. package/dist/cli/x402-buyer-command.js +463 -0
  29. package/dist/cli/x402-seller-command.js +289 -0
  30. package/dist/cli-entry.js +43 -5
  31. package/dist/electron-build/agents/chain/transfer-classify.js +106 -0
  32. package/dist/electron-build/agents/chain/transfer-classify.js.map +1 -0
  33. package/dist/electron-build/agents/chain/transfer-index.js +400 -0
  34. package/dist/electron-build/agents/chain/transfer-index.js.map +1 -0
  35. package/dist/electron-build/agents/identity/address-binding.js +672 -0
  36. package/dist/electron-build/agents/identity/address-binding.js.map +1 -0
  37. package/dist/electron-build/agents/network-pulse.js +714 -28
  38. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  39. package/dist/electron-build/agents/pi-sdk-tools.js +68 -15
  40. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  41. package/dist/electron-build/agents/pi-sdk.js +7 -1
  42. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  43. package/dist/electron-build/agents/session-store.js +144 -3
  44. package/dist/electron-build/agents/session-store.js.map +1 -1
  45. package/dist/electron-build/agents/wallet-tools.js +102 -0
  46. package/dist/electron-build/agents/wallet-tools.js.map +1 -0
  47. package/dist/electron-build/agents/x402/direct-payment.js +744 -0
  48. package/dist/electron-build/agents/x402/direct-payment.js.map +1 -0
  49. package/dist/electron-build/agents/x402/order-identity.js +458 -0
  50. package/dist/electron-build/agents/x402/order-identity.js.map +1 -0
  51. package/dist/electron-build/agents/x402/paid-info-protocol.js.map +1 -1
  52. package/dist/electron-build/agents/x402/paid-info-store.js +18 -0
  53. package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
  54. package/dist/electron-build/agents/x402/seller-signing.js +682 -0
  55. package/dist/electron-build/agents/x402/seller-signing.js.map +1 -0
  56. package/dist/electron-build/cli/wallet-store.js +200 -0
  57. package/dist/electron-build/cli/wallet-store.js.map +1 -0
  58. package/dist/electron-build/llm/pi-ai.js +25 -0
  59. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  60. package/dist/electron-build/utils/auto-update.js +2 -2
  61. package/dist/electron-build/utils/auto-update.js.map +1 -1
  62. package/dist/electron-build/utils/runtime-bootstrap.js +1 -1
  63. package/dist/electron-build/utils/runtime-bootstrap.js.map +1 -1
  64. package/dist/electron-build/utils/update-manager.js +3 -3
  65. package/dist/electron-build/utils/update-manager.js.map +1 -1
  66. package/dist/electron-build/utils/version-identity.js +1 -1
  67. package/dist/electron-build/utils/version-identity.js.map +1 -1
  68. package/dist/electron-build/web/routes-x402-info.js +28 -1
  69. package/dist/electron-build/web/routes-x402-info.js.map +1 -1
  70. package/dist/index.js +829 -104
  71. package/dist/llm/pi-ai.js +23 -0
  72. package/dist/utils/auto-update.js +2 -2
  73. package/dist/utils/runtime-bootstrap.js +1 -1
  74. package/dist/utils/update-health.js +2 -2
  75. package/dist/utils/update-manager.js +3 -3
  76. package/dist/utils/version-identity.js +1 -1
  77. package/dist/web/bolloon-web.json +3 -3
  78. package/dist/web/routes-x402-info.js +28 -1
  79. package/dist/web/sw.js +1 -1
  80. package/package.json +2 -1
@@ -0,0 +1,119 @@
1
+ /**
2
+ * input-history.ts — 输入历史落盘 (leo 2026-09-30: 「输入历史有落盘文件夹吗,要实现一下」)
3
+ *
4
+ * 之前: 历史只在内存 (`ink-app.tsx` 的 historyRef, 上限 100, **重启即失**)。
5
+ * 现在: 落 `~/.bolloon/history/`, **一渠道一文件** `input-<channelId>.jsonl` (0600) ——
6
+ * 渠道隔离 (不同 agent/channel 的输入不该互相串), 重启后 ↑ 还能翻出来。
7
+ *
8
+ * 四条纪律:
9
+ * 1. **秘密不入历史** —— 64 位 hex 私钥 / `privateKey` / 助记词形态 (12+ 个小写词) 一律不写盘。
10
+ * 输入历史是纯文本、会被翻出来、也可能被贴出去; 钱包助记词进去就是事故。
11
+ * 2. **不打断输入** —— 落盘失败只记日志 (console.error), 绝不让打字报错。
12
+ * 3. **去重** —— 连续重复的同一行不重复写 (↑ 翻起来不烦)。
13
+ * 4. **有上限** —— 内存 200 / 文件 500 (超了重写成最后 500, 原子写)。
14
+ */
15
+ import * as fs from 'fs/promises';
16
+ import * as path from 'path';
17
+ /** 内存里最多留多少条 (CLI 进程内) */
18
+ export const MEMORY_CAP = 200;
19
+ /** 文件里最多留多少条 (超了裁剪) */
20
+ export const FILE_CAP = 500;
21
+ let scope = 'default';
22
+ /** 由 CLI 在切渠道时调用 (index.ts), 决定写哪个文件 */
23
+ export function setHistoryScope(channel) {
24
+ scope = sanitizeChannel(channel) || 'default';
25
+ }
26
+ export function historyScope() { return scope; }
27
+ function sanitizeChannel(channel) {
28
+ return String(channel || '').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 64);
29
+ }
30
+ export function historyDir() {
31
+ return path.join(process.env.HOME || '/tmp', '.bolloon', 'history');
32
+ }
33
+ export function historyFile(channel) {
34
+ const ch = sanitizeChannel(channel) || scope || 'default';
35
+ return path.join(historyDir(), `input-${ch}.jsonl`);
36
+ }
37
+ /**
38
+ * 看着像秘密的输入 —— **不写历史**。
39
+ * 判据刻意保守 (宁可漏存一条普通输入, 也不能把私钥/助记词写进历史文件):
40
+ * · 64 位 hex (可带 0x) / 40 位 hex 地址不拦 (地址是公开的)
41
+ * · 出现 privateKey / private_key / mnemonic / 助记词 字样
42
+ * · 12 个以上连续小写单词 (助记词形态)
43
+ */
44
+ export function looksSecret(line) {
45
+ const s = String(line || '');
46
+ if (/0x[0-9a-fA-F]{64}\b/.test(s))
47
+ return true;
48
+ if (/\b(privateKey|private_key|mnemonic|助记词|seedPhrase|seed_phrase)\b/i.test(s))
49
+ return true;
50
+ // 助记词形态: 整行 (去掉常见前缀/标点) 是 12/15/18/21/24 个小写词
51
+ const words = s.replace(/^[^A-Za-z]*/, '').trim().split(/\s+/);
52
+ if (words.length >= 12 && words.length <= 30 && words.every(w => /^[a-z]{3,8}$/.test(w)))
53
+ return true;
54
+ return false;
55
+ }
56
+ /** 读历史 (新→旧? 不, 这里按**旧→新**返回: 与内存数组同序, ↑ 取最后一条) */
57
+ export async function loadInputHistory(channel) {
58
+ try {
59
+ const text = await fs.readFile(historyFile(channel), 'utf-8');
60
+ const out = [];
61
+ for (const raw of text.split('\n')) {
62
+ const line = raw.trim();
63
+ if (!line)
64
+ continue;
65
+ try {
66
+ const v = JSON.parse(line);
67
+ if (typeof v === 'string' && v.trim())
68
+ out.push(v);
69
+ }
70
+ catch { /* 坏行跳过 (历史坏了不该让 CLI 起不来) */ }
71
+ }
72
+ return out.slice(-MEMORY_CAP);
73
+ }
74
+ catch (e) {
75
+ if (e?.code === 'ENOENT')
76
+ return [];
77
+ console.error(`[input-history] 读不了 ${historyFile(channel)}: ${String(e?.message || e).slice(0, 120)}`);
78
+ return [];
79
+ }
80
+ }
81
+ /**
82
+ * 追加一条 (去重: 与最后一条相同则不写)。
83
+ * 返回 true = 真写了; false = 跳过了 (秘密/空/重复) —— 调用方可以据此决定是否提示。
84
+ */
85
+ export async function appendInputHistory(line, channel) {
86
+ const v = String(line || '').trim();
87
+ if (!v)
88
+ return false;
89
+ if (looksSecret(v)) {
90
+ console.error(`[input-history] 这行看着像密钥/助记词, **不写历史** (长度 ${v.length})`);
91
+ return false;
92
+ }
93
+ const file = historyFile(channel);
94
+ try {
95
+ await fs.mkdir(historyDir(), { recursive: true });
96
+ // 去重: 看最后一条
97
+ const existing = await loadInputHistory(channel);
98
+ if (existing.length && existing[existing.length - 1] === v)
99
+ return false;
100
+ await fs.appendFile(file, JSON.stringify(v) + '\n', { encoding: 'utf-8', mode: 0o600 });
101
+ try {
102
+ await fs.chmod(file, 0o600);
103
+ }
104
+ catch { /* 已足够 */ }
105
+ if (existing.length + 1 > FILE_CAP + 300) {
106
+ // 裁剪: 原子重写成最后 FILE_CAP 条
107
+ const kept = [...existing, v].slice(-FILE_CAP);
108
+ const tmp = `${file}.tmp`;
109
+ await fs.writeFile(tmp, kept.map(x => JSON.stringify(x) + '\n').join(''), { encoding: 'utf-8', mode: 0o600 });
110
+ await fs.rename(tmp, file);
111
+ }
112
+ return true;
113
+ }
114
+ catch (e) {
115
+ // 不打断输入: 只记日志
116
+ console.error(`[input-history] 写不了 ${file}: ${String(e?.message || e).slice(0, 120)}`);
117
+ return false;
118
+ }
119
+ }
@@ -71,6 +71,17 @@ export const BOLLOON_BANNER = [
71
71
  `${C_TEXT}${BOLD}╚═════╝ ╚═════╝ ╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝${RESET}`,
72
72
  `${C_DIM}Bolloon Agent v${BOLLOON_VERSION}${RESET}`,
73
73
  ].join('\n');
74
+ /**
75
+ * 窄终端档: 同一套风格的小号艺术字 (leo 2026-09-30: 「我看不到我的 logo」).
76
+ * 原来宽 < ~97 列时整块 87 列艺术字被丢掉、只剩一行文字 —— 那不是 logo.
77
+ * 现在按"装得下就上"挑档: 大(87) → 小(37) → 文字(兜底), 保证任何宽度都有字标.
78
+ */
79
+ export const BOLLOON_BANNER_SMALL = [
80
+ `${C_TEXT}${BOLD} ___ ___ _ _ ___ ___ _ _${RESET}`,
81
+ `${C_TEXT}${BOLD}| _ )/ _ \\| | | | / _ \\ / _ \\| \\| |${RESET}`,
82
+ `${C_TEXT}${BOLD}| _ \\ (_) | |__| |_| (_) | (_) | .\` |${RESET}`,
83
+ `${C_TEXT}${BOLD}|___/\\___/|____|____\\___/ \\___/|_|\\_|${RESET}`,
84
+ ].join('\n');
74
85
  /** 艺术字全部行 (机器人头在左, BOLLOON 艺术字在右), 供框内渲染 */
75
86
  export function brandArtLines() {
76
87
  // 2026-08-09: icon 只取机器人头 (裁掉末行 BOLLOON 文字), 避免和右侧 banner 双 logo
@@ -114,6 +125,52 @@ export function termWidth() {
114
125
  const c = process.stdout.columns;
115
126
  return typeof c === 'number' && c > 24 ? c : 80;
116
127
  }
128
+ /**
129
+ * 等真实终端尺寸就位 (2026-09-30, leo 报「TUI 渲染有问题: 顶框被重复打印 + 宽度一路涨」).
130
+ *
131
+ * 真 pty 下 `process.stdout.columns` 在启动最初那一刻可能**还没被写进来** ⇒ 面板先按兜底的 80 列
132
+ * 渲染一次; 等真尺寸到位后又渲染一次 —— 而面板落在 Ink 的静态输出(scrollback)里, 重画覆盖不掉旧内容,
133
+ * 于是**每渲染一次就多留一条顶框**, 宽度还在变 (实测 24x120: 110 → 120; 12x80: 80 → 76).
134
+ * 所以: 出任何一帧之前先等尺寸就位 (通常下一个 tick 或一次 resize 事件就到; 上限 timeoutMs).
135
+ */
136
+ export async function waitForTermSize(timeoutMs = 600) {
137
+ const ok = () => {
138
+ const c = process.stdout.columns;
139
+ return typeof c === 'number' && c > 24;
140
+ };
141
+ if (ok())
142
+ return;
143
+ await new Promise((resolve) => {
144
+ let done = false;
145
+ const finish = () => {
146
+ if (done)
147
+ return;
148
+ done = true;
149
+ clearInterval(iv);
150
+ clearTimeout(t);
151
+ try {
152
+ process.stdout.off('resize', finish);
153
+ }
154
+ catch { /* 老 Node 没有 off */ }
155
+ resolve();
156
+ };
157
+ const iv = setInterval(() => { if (ok())
158
+ finish(); }, 20);
159
+ const t = setTimeout(finish, timeoutMs);
160
+ try {
161
+ process.stdout.once('resize', finish);
162
+ }
163
+ catch { /* 忽略 */ }
164
+ });
165
+ }
166
+ /**
167
+ * 终端行数 (拿不到就按 24 —— 与 termWidth 同一套兜底口径).
168
+ * 2026-09-30 新增: 面板/盒子必须**知道自己有多少行可用**, 否则会长过屏幕把输入行顶出视野.
169
+ */
170
+ export function termHeight() {
171
+ const r = process.stdout.rows;
172
+ return typeof r === 'number' && r > 8 ? r : 24;
173
+ }
117
174
  function stripAnsi(s) {
118
175
  return s.replace(/\x1b\[[0-9;]*m/g, '');
119
176
  }
@@ -160,8 +217,8 @@ function fitLeft(text, inner) {
160
217
  }
161
218
  return out + '…';
162
219
  }
163
- /** 按显示宽度截断并加省略号 */
164
- function truncate(text, max) {
220
+ /** 按显示宽度截断并加省略号 (导出: 调用方要自己把内容裁进盒子, 见 buildBootBox) */
221
+ export function truncate(text, max) {
165
222
  if (dispWidth(text) <= max)
166
223
  return text;
167
224
  let out = '';
@@ -201,12 +258,15 @@ export function renderDashboard(opts) {
201
258
  const maxTitle = dispWidth(opts.title ?? 'Bolloon Agent · 仪表盘') + 4;
202
259
  const inner = Math.max(40, maxArt, maxRow, maxTitle);
203
260
  const width = Math.min(termWidth() - 2, opts.width ?? inner + 4);
261
+ // 2026-09-30: width 被夹到 termWidth()-2 之后, 内宽可能**小于**艺术字宽度 ——
262
+ // 那种情况下 boxRow 装不下, 终端会把每行折成两截 (实测: 艺术字裂成两列错位).
263
+ // 宁可不显示艺术字, 也不让它被折行.
264
+ const innerW = Math.max(0, width - 4);
265
+ const useArt = showBrand && maxArt <= innerW ? art : [];
204
266
  const lines = [];
205
267
  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
- }
268
+ for (const l of useArt)
269
+ lines.push(boxRow(l, width, 'center'));
210
270
  for (const r of opts.rows) {
211
271
  const sym = STATUS_SYMBOL[r.status ?? 'info'];
212
272
  const detail = r.detail ? ` ${C_DIM}${r.detail}${RESET}` : '';
@@ -79,11 +79,49 @@ export function isStartupLogLine(line) {
79
79
  || UNTAGGED_STARTUP_NARRATION.some((re) => re.test(line));
80
80
  // inspect dump 的续行 (纯数字/逗号) 由 filterText 的 dump 状态机处理, 这里不判
81
81
  }
82
+ /**
83
+ * 「可选启动富化」任务的失败 —— **落日志, 但默认不占屏**。
84
+ *
85
+ * 2026-09-30 (leo: 「都是启动期可选富化任务失败, 却用带时间戳的'失败'打在屏上」):
86
+ * `[warn]: ⚠️ 本地 IPNS 发布失败: TimeoutError … ❌ 失败 (30003ms)` 这类来自 SDK / 内部后台任务,
87
+ * 用户启动时**无可行动**(看见了也没法处理), 但句子带'失败' ⇒ 按'错误不许被吞'被放上屏 = 纯噪音。
88
+ * 它们仍然**一字不漏落 startup.log**, `--verbose` 照原样上屏 ⇒ 诊断能力一点不丢。
89
+ * 这是把「要不要占屏」和「要不要留痕」分开, 不是把错误藏起来。
90
+ *
91
+ * 判据要求**同时**命中「上面这条窄规则」+「失败词」; 只命中一个照旧按人味信号上报。
92
+ * **刻意只覆盖这两类**: SDK 的 IPNS 发布重试行 + 上下文扫描超时。
93
+ * 不收 `ipfs daemon 启动超时, 可稍后手动运行` 这类**可行动**提示 —— 门自己的契约测试
94
+ * (`log-gate.test.ts` 的"信号行"用例) 就是拿它当反例, 第一版规则太宽时被它判红。
95
+ */
96
+ const OPTIONAL_BOOT_TASK = /(?:本地\s*IPNS\s*发布失败|IPNS\s*发布失败[,,]\s*尝试备用发布|上下文扫描\s*未完成|上下文扫描\s*超时|Bolloon 上下文扫描)/;
97
+ /**
98
+ * SDK(winston) 的**进度诊断**行形状: `2026-09-30T09:27:03.848Z [info]: 结果: ❌ 失败 (30004ms)`。
99
+ *
100
+ * 2026-09-30 (leo): 这类行来自 @diap/sdk 的 logger —— 它**直写控制台、绕过日志门**, 句子里带
101
+ * "失败/❌" ⇒ 按"错误不许被吞"被放上屏, 但它是 `[info]` 级**进度尾巴**(IPNS 发布 30s 超时), 启动时不可行动。
102
+ *
103
+ * **只吞 info/debug, 绝不吞 warn/error** —— 这条收窄是门自己的契约测试逼出来的:
104
+ * `log-gate.test.ts` 断言 `… [warn]: ⚠️ 守护进程启动超时, 可稍后手动运行 ipfs daemon` 必须算人味信号
105
+ * (它确实可行动)。第一版规则连 warn 一起吞 ⇒ 被那条测试判红, 于是收窄。
106
+ */
107
+ // 2026-09-30 真机修正: 原来只认"以 ISO 时间戳开头 + 未染色的 [info]:"。
108
+ // 实测真身是 `[2026-…] 2026-…Z [info]: …` —— 行首可能有日志器加的 `[ts] ` 前缀,
109
+ // `[info]` 里的 info 还会被 ANSI 染色 ⇒ 两条都让旧规则漏网, 于是
110
+ // `2026-09-30T10:22:44.768Z [info]: 结果: ❌ 失败 (30004ms)` 照样上屏 (leo 报过多次)。
111
+ 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
112
  /** 这一行是不是「错误 / 降级 / 需人介入」—— 是则任何模式下都不许被静默 */
83
113
  export function carriesHumanSignal(line) {
84
114
  if (!line)
85
115
  return false;
86
- return HUMAN_SIGNAL.test(line.replace(BENIGN_COUNT, ' '));
116
+ const l = line.replace(BENIGN_COUNT, ' ');
117
+ const isSignal = HUMAN_SIGNAL.test(l);
118
+ // SDK 诊断行 (info/warn/debug): 只落日志, 不占屏 —— error 级不在其中, 照旧上报
119
+ if (SDK_DIAG_LINE.test(l))
120
+ return false;
121
+ // 可选启动富化的失败: 留痕(日志)但默认不占屏 —— 见 OPTIONAL_BOOT_TASK 的说明
122
+ if (isSignal && OPTIONAL_BOOT_TASK.test(l))
123
+ return false;
124
+ return isSignal;
87
125
  }
88
126
  // ---------------------------------------------------------------------------
89
127
  // verbose / disabled / 日志路径
@@ -75,6 +75,10 @@ const OPTIONS_WITH_VALUE = new Set([
75
75
  '--announcement-id',
76
76
  // 2026-09-23 (C7): 群聊通道 `bolloon task announce|trail|post` 的群链接/groupId + 极短事实字段
77
77
  '--group', '--round', '--criteria', '--kind', '--hash', '--bytes', '--checks', '--verdict', '--limit', '--from',
78
+ // 2026-09-29: `bolloon identity bind-address|bindings` 的显式路径与声明字段
79
+ // (名字先查过全仓: `--address/--label/--out/--chain-id/--bindings-dir/--address-key-file/--did-key-file/--expires-at`
80
+ // 在其他 cli-entry 命令里都没有被当布尔开关用过 ⇒ 加进"带值选项"不会改变既有解析)
81
+ '--address', '--label', '--out', '--chain-id', '--bindings-dir', '--address-key-file', '--did-key-file', '--expires-at',
78
82
  ]);
79
83
  export function parseFlags(args) {
80
84
  const flags = { json: false, quiet: false, positionals: [], options: new Map(), raw: args };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * session-summary.ts — 用 AI 给**会话**起标题 + 一句话摘要 (leo 2026-09-30)
3
+ *
4
+ * 起因: `/sessions` 表格里 Title 那列原来只能显示会话 key (`20260930_171712_840093`),
5
+ * Preview 常常是 `—` —— leo: 「Title, Preview 要有内容, 使用 AI 来总结进去」。
6
+ *
7
+ * 三条纪律:
8
+ * 1. **只算一次** —— 结果写回会话文件 `metadata.{title,preview,summarizedAt}`; 下次直接读, 不再花钱。
9
+ * 2. **不编** —— AI 不可用/输出不合规时, 退回"真实首条用户消息"当 preview, title 保持 key,
10
+ * 并把 `source` 如实标出来 (调用方据此决定要不要提示用户)。
11
+ * 3. **不卡** —— 单条会话有超时 (默认 12s), 失败不留半截标题 (整条要么写成功要么不写)。
12
+ */
13
+ import { SessionStore } from '../agents/session-store.js';
14
+ import { getMinimax } from '../llm/pi-ai.js';
15
+ /**
16
+ * 自动注入的上下文块 (不是用户说的话)。
17
+ * ⚠ 2026-09-30 真机发现 (leo: 「结束进程的时候没有加载 title 和总结」): CLI 把**用户那句话
18
+ * **附在注入块末尾**, 用 `\n---\n` 分隔 —— 实测长这样:
19
+ * <!-- current-turn: … -->
20
+ * # 你的项目上下文 (自动 bootstrap …)
21
+ * … [预算截断]
22
+ *
23
+ * ---
24
+ *
25
+ * 你好
26
+ * ⇒ 整块丢掉的话, 所有 CLI 会话都"没有用户内容" (标题永远出不来)。
27
+ * 正确做法: **取末尾那一段**当用户输入 (见 userTextFrom), 只把真正的上下文头当注入。
28
+ */
29
+ const isInjected = (x) => /^<!--/.test(x.trim()) || /^#\s*你的项目上下文/.test(x.trim()) || /^\[cron\]/.test(x.trim()) || /^<!-- current-turn/.test(x.trim());
30
+ /**
31
+ * 从一条 user 消息里取"真正属于用户的那句话":
32
+ * · 普通消息 → 原样
33
+ * · 注入块 → 取最后一个 `---` 分隔线之后的尾巴 (那是用户的输入)
34
+ * · 拿不到有效尾巴 (还是上下文/太短) → 返回空串 (调用方跳过)
35
+ */
36
+ export function userTextFrom(content) {
37
+ const c = String(content || '').trim();
38
+ if (!c)
39
+ return '';
40
+ if (!isInjected(c))
41
+ return c;
42
+ const parts = c.split(/\n\s*-{3,}\s*\n/);
43
+ const tail = (parts[parts.length - 1] || '').trim();
44
+ if (!tail || isInjected(tail))
45
+ return '';
46
+ // 尾巴太短 (一两个字符) 多半是分隔符残渣, 不算用户内容
47
+ return tail.length >= 2 ? tail : '';
48
+ }
49
+ const clip = (s, n) => s.replace(/\s+/g, ' ').trim().slice(0, n);
50
+ const SYS = '你是终端会话的命名器。只输出一行 JSON, 不要解释、不要代码块、不要多余文字。';
51
+ const buildPrompt = (head, tail) => `下面是同一个终端会话的「开头」和「结尾」。请用中文输出一行 JSON:\n` +
52
+ `{"title":"不超过 20 个字的标题, 说清这次会话在做什么", "preview":"不超过 40 个字, 一句话摘要, 最有信息量的那件事"}\n\n` +
53
+ `【开头】\n${head}\n\n【结尾】\n${tail}\n`;
54
+ /** 从模型输出里抠出第一个 JSON 对象 (抗代码块/前后废话) */
55
+ function parseDigest(raw) {
56
+ const m = raw.match(/\{[\s\S]*?\}/);
57
+ if (!m)
58
+ return null;
59
+ try {
60
+ const o = JSON.parse(m[0]);
61
+ const title = clip(String(o.title ?? ''), 28);
62
+ const preview = clip(String(o.preview ?? ''), 56);
63
+ if (!title)
64
+ return null;
65
+ return { title, preview };
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ /**
72
+ * 给一条会话生成标题 + 摘要。已经算过 (metadata.title) 就直接返回, 不再调模型。
73
+ * 失败时不抛错 —— 返回 raw/none, 让调用方如实显示。
74
+ */
75
+ export async function summarizeSession(key, opts = {}) {
76
+ const store = opts.store ?? new SessionStore();
77
+ let msgs = [];
78
+ let meta = {};
79
+ try {
80
+ const raw = JSON.parse(await (await import('fs/promises')).readFile(store.pathFor(key), 'utf-8'));
81
+ msgs = Array.isArray(raw) ? raw : (raw?.messages || []);
82
+ meta = (!Array.isArray(raw) && raw?.metadata) || {};
83
+ }
84
+ catch {
85
+ return { title: key, preview: '', source: 'none' };
86
+ }
87
+ if (!opts.force && typeof meta.title === 'string' && meta.title.trim()) {
88
+ return { title: String(meta.title), preview: String(meta.preview ?? ''), source: 'ai' };
89
+ }
90
+ // 2026-09-30: 用 userTextFrom 而不是"整块丢掉" —— 用户的话在注入块末尾 (见上面注释)
91
+ const userTexts = msgs
92
+ .filter(m => m?.role === 'user' && typeof m.content === 'string')
93
+ .map(m => userTextFrom(String(m.content)))
94
+ .filter(t => t.length > 0);
95
+ const userMsgs = userTexts.map(t => ({ content: t }));
96
+ const rawPreview = userMsgs.length ? clip(String(userMsgs[0].content), 56) : '';
97
+ if (!userMsgs.length)
98
+ return { title: key, preview: '', source: 'none' };
99
+ const head = userMsgs.slice(0, 2).map(m => clip(String(m.content), 300)).join('\n');
100
+ const tailFrom = msgs.filter(m => typeof m?.content === 'string' && m.content.trim()).slice(-2);
101
+ const tail = tailFrom.map(m => `${m.role}: ${clip(String(m.content), 300)}`).join('\n');
102
+ const ac = new AbortController();
103
+ const timer = setTimeout(() => ac.abort(), Math.max(2000, opts.timeoutMs ?? 12_000));
104
+ try {
105
+ const llm = getMinimax();
106
+ const res = await llm.chat(buildPrompt(head, tail), SYS, ac.signal, undefined, 'auto-compact', 'session-summary');
107
+ // 2026-09-30 实测: chat() 返回的是**对象** `{reply, reasoningContent, toolCalls, messages, usage}`
108
+ // —— 文本在 `reply` (第一版写成 String(raw) ⇒ 拿到 "[object Object]" ⇒ 总结永远退回 raw)。
109
+ const text = typeof res === 'string'
110
+ ? res
111
+ : String(res?.reply ?? res?.content ?? res?.text ?? '');
112
+ const parsed = parseDigest(text);
113
+ if (parsed) {
114
+ // 整条写回 (要么全写, 要么不写) —— 下次 /sessions 直接读, 不再调模型
115
+ try {
116
+ await store.updateMetadata(key, { title: parsed.title, preview: parsed.preview, summarizedAt: Date.now() });
117
+ }
118
+ catch { /* 写不进也不影响这次显示 */ }
119
+ return { title: parsed.title, preview: parsed.preview, source: 'ai' };
120
+ }
121
+ return { title: key, preview: rawPreview, source: 'raw' };
122
+ }
123
+ catch {
124
+ return { title: key, preview: rawPreview, source: 'raw' };
125
+ }
126
+ finally {
127
+ clearTimeout(timer);
128
+ }
129
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * session-tree.ts — 会话树: 用 git 管理 fork 分支 (leo 2026-09-30)
3
+ *
4
+ * 语义 (leo 确认, 标准 branch 语义):
5
+ * · `/fork <#|id>` 从某条会话**分叉出一条新会话**: 新会话**继承 fork 点之前的全部历史**,
6
+ * 之后两条各自独立 (各自继续写自己的文件)。
7
+ * · 树用 **git** 管: `~/.bolloon/sessions/` 变成一个仓库, 每次 fork / 退出存档 = 一次 commit,
8
+ * commit 里带 `session: <key>` 与 `parent: <源 key>` ⇒ `git log --graph` 就是会话树。
9
+ *
10
+ * 三条纪律:
11
+ * 1. **不吞 git 的输出** —— 成功给 commit 短哈希, 失败给原因 (调用方如实显示)。
12
+ * 2. **仓库是用户的会话历史** —— 只 init (无仓库时) + add/commit; **绝不** reset/checkout/clean。
13
+ * 3. **不阻塞对话** —— git 不可用/失败时, fork 本身仍然成功 (文件已写好), 只是树没记上, 如实标注。
14
+ */
15
+ import * as fs from 'fs/promises';
16
+ import * as path from 'path';
17
+ import { execFile } from 'child_process';
18
+ import { promisify } from 'util';
19
+ const pexec = promisify(execFile);
20
+ /** 会话仓库根 = sessions 目录 (里面 cache/ 与 channels.json 都进树) */
21
+ function repoDir(store) {
22
+ return path.dirname(store.dir);
23
+ }
24
+ async function git(cwd, args) {
25
+ try {
26
+ const { stdout, stderr } = await pexec('git', args, { cwd, timeout: 20_000, maxBuffer: 8 * 1024 * 1024 });
27
+ return { ok: true, out: String(stdout), err: String(stderr) };
28
+ }
29
+ catch (e) {
30
+ return { ok: false, out: String(e?.stdout || ''), err: String(e?.stderr || e?.message || e).slice(0, 300) };
31
+ }
32
+ }
33
+ /** 仓库不存在就建 (并设本地 user.name/email, 不依赖用户全局 git 配置) */
34
+ export async function ensureTree(store) {
35
+ const dir = repoDir(store);
36
+ try {
37
+ await fs.mkdir(dir, { recursive: true });
38
+ }
39
+ catch { /* 已存在 */ }
40
+ const inside = await git(dir, ['rev-parse', '--git-dir']);
41
+ if (!inside.ok) {
42
+ const init = await git(dir, ['init', '-q', '-b', 'main']);
43
+ if (!init.ok)
44
+ return { ok: false, error: `git init 失败: ${init.err}` };
45
+ await git(dir, ['config', 'user.name', 'bolloon']);
46
+ await git(dir, ['config', 'user.email', 'bolloon@localhost']);
47
+ // 忽略半截临时文件 (session-store 的原子写会留 .tmp 一瞬间)
48
+ try {
49
+ await fs.writeFile(path.join(dir, '.gitignore'), '*.tmp\n', { flag: 'wx' });
50
+ }
51
+ catch { /* 已存在 */ }
52
+ }
53
+ return { ok: true };
54
+ }
55
+ /**
56
+ * 把当前状态提交进树。
57
+ * @param subject 形如 `session: <key> · fork from <src>` —— 树里一眼能看出发生了什么
58
+ */
59
+ export async function commitTree(store, subject, body) {
60
+ const ens = await ensureTree(store);
61
+ if (!ens.ok)
62
+ return ens;
63
+ const dir = repoDir(store);
64
+ const add = await git(dir, ['add', '-A', '--', '.']);
65
+ if (!add.ok)
66
+ return { ok: false, error: `git add 失败: ${add.err}` };
67
+ const args = ['commit', '-q', '--no-verify', '-m', subject];
68
+ if (body)
69
+ args.push('-m', body);
70
+ const c = await git(dir, args);
71
+ if (!c.ok) {
72
+ // 没有变化 (nothing to commit) 不算失败 —— 如实区分
73
+ if (/nothing to commit|no changes added/i.test(c.err) || /nothing to commit/i.test(c.out))
74
+ return { ok: true, commit: '(无变化)' };
75
+ return { ok: false, error: `git commit 失败: ${c.err}` };
76
+ }
77
+ const rev = await git(dir, ['rev-parse', '--short', 'HEAD']);
78
+ return { ok: true, commit: rev.ok ? rev.out.trim() : '' };
79
+ }
80
+ /**
81
+ * fork 一条会话: 继承 fork 点之前的全部历史 + 记 parent 关系 + 落一次 commit。
82
+ * 标准 branch 语义: 之后两条会话各自独立 (各写各的文件)。
83
+ */
84
+ export async function forkSession(store, srcKey) {
85
+ let raw;
86
+ let srcPath = '';
87
+ for (const cand of [store.pathFor(srcKey), path.join(store.dir, `${srcKey}.json`)]) {
88
+ try {
89
+ raw = JSON.parse(await fs.readFile(cand, 'utf-8'));
90
+ srcPath = cand;
91
+ break;
92
+ }
93
+ catch { /* 试下一个 */ }
94
+ }
95
+ if (!raw)
96
+ return { ok: false, error: `源会话读不到: ${srcKey}` };
97
+ const msgs = Array.isArray(raw) ? raw : (raw?.messages || []);
98
+ const ts = new Date();
99
+ const p = (n) => String(n).padStart(2, '0');
100
+ const base = `${ts.getFullYear()}${p(ts.getMonth() + 1)}${p(ts.getDate())}_${p(ts.getHours())}${p(ts.getMinutes())}${p(ts.getSeconds())}`;
101
+ let newKey = `${base}_fork`;
102
+ for (let i = 2; i < 50; i++) {
103
+ try {
104
+ await fs.access(store.pathFor(newKey));
105
+ newKey = `${base}_fork${i}`;
106
+ }
107
+ catch {
108
+ break;
109
+ }
110
+ }
111
+ const out = {
112
+ key: newKey,
113
+ messages: msgs, // ← 共享 fork 点之前的全部历史 (标准 branch 语义)
114
+ metadata: {
115
+ ...(Array.isArray(raw) ? {} : (raw?.metadata || {})),
116
+ forkedFrom: srcKey,
117
+ forkPoint: msgs.length,
118
+ forkedAt: Date.now(),
119
+ savedAt: Date.now(),
120
+ cwd: process.cwd(),
121
+ totalCount: msgs.length,
122
+ // 继承来的标题加前缀, /sessions 里一眼看出它是分支
123
+ title: `↳ ${String((Array.isArray(raw) ? {} : raw?.metadata?.title) || srcKey)}`,
124
+ preview: `从 ${srcKey} 分叉 (fork 点 ${msgs.length} 条)`,
125
+ },
126
+ };
127
+ await fs.writeFile(store.pathFor(newKey), JSON.stringify(out, null, 2), 'utf-8');
128
+ const committed = await commitTree(store, `session: ${newKey} · fork from ${srcKey} @${msgs.length}`, `fork 点: ${msgs.length} 条消息\n源文件: ${path.relative(repoDir(store), srcPath) || srcKey}`);
129
+ return { ...committed, newKey, inherited: msgs.length, from: srcKey };
130
+ }
131
+ /** 会话树概览 (给 `/fork --tree` / 诊断用): 最近 N 次提交 + 分支名 */
132
+ export async function treeLog(store, limit = 12) {
133
+ const ens = await ensureTree(store);
134
+ if (!ens.ok)
135
+ return { ok: false, text: '', error: ens.error };
136
+ const dir = repoDir(store);
137
+ const log = await git(dir, ['log', `-n${Math.max(1, limit)}`, '--pretty=format:%h %ad | %s', '--date=format:%m-%d %H:%M']);
138
+ if (!log.ok)
139
+ return { ok: false, text: '', error: log.err };
140
+ return { ok: true, text: log.out };
141
+ }
@@ -0,0 +1,70 @@
1
+ import { dispWidth } from './loading-tui.js';
2
+ /** 字节 → token 的估算比 (只有这一步是估, 所以显示时标 ≈) */
3
+ export const BYTES_PER_TOKEN = 3.5;
4
+ /** 返回"标签 值"对 (无 ANSI, 调用方拼颜色) —— 顺序: ◷ ↑ ⚙ ✓ */
5
+ export function statusSegments(f) {
6
+ const out = [];
7
+ if (f.running) {
8
+ const secs = Math.max(0.1, f.turnElapsedMs / 1000);
9
+ out.push(`◷ ${secs.toFixed(1)}s`);
10
+ const ti = f.aiTiming;
11
+ if (ti && ti.ms > 0 && ti.bytes > 0 && (!f.turnStartedAt || ti.at >= f.turnStartedAt)) {
12
+ const tps = Math.round(ti.bytes / BYTES_PER_TOKEN / (ti.ms / 1000));
13
+ if (tps > 0)
14
+ out.push(`↑ ≈${tps} t/s`);
15
+ }
16
+ }
17
+ else if (f.lastTurnMs > 0) {
18
+ out.push(`✓ ${(f.lastTurnMs / 1000).toFixed(1)}s`);
19
+ if (f.lastTurnReplyBytes > 0) {
20
+ const tps = Math.round(f.lastTurnReplyBytes / BYTES_PER_TOKEN / Math.max(0.1, f.lastTurnMs / 1000));
21
+ if (tps > 0)
22
+ out.push(`↑ ≈${tps} t/s`);
23
+ }
24
+ }
25
+ // ◎ 缓存命中率 = cached / prompt (provider 给的实测值; 没记录/没 prompt 就不显示)
26
+ const u = f.aiUsage;
27
+ if (u && u.prompt > 0) {
28
+ const pct = (u.cached / u.prompt) * 100;
29
+ out.push(`◎ ${pct.toFixed(1)}%`);
30
+ }
31
+ out.push(`⚙ ${f.toolCount}`); // 常显
32
+ // 会话 Title 不在这里拼 —— leo: 「这个 title 需要顶格右侧」⇒ 由 CLI 右对齐 (见 rightAlignPad)。
33
+ return out;
34
+ }
35
+ /**
36
+ * 按可用宽度取舍 (2026-09-30, 真机抓包照出来的问题):
37
+ * 整行 ~130 字符, 112 列终端里**右边的段直接被截掉** ⇒ 看着像"没出现"。
38
+ * 规则: 丢的顺序 = ↑ → ◎ → ✓ → ◷ (⚙ 永不丢 —— 它最短, 且"有没有在跑工具"最有信息量);
39
+ * 每一段都按显示宽算 (中文 2 列)。
40
+ */
41
+ export function fitSegments(segs, avail) {
42
+ // 2026-09-30: 宽度一律用 loading-tui 的 dispWidth (按 East Asian Width 表) ——
43
+ // 之前这里和 getStatus 各写了一份 "charCode > 255 ⇒ 2" 的启发式, 它把 `│ ░ ◷ ▸ ◎ ✓ ↑ ⚙`
44
+ // 这些**单宽**符号都算成 2 列 ⇒ 右对齐时每行凭空多占十几列 (leo: 「没有完全右对齐」)。
45
+ const w = (s) => dispWidth(s);
46
+ const dropOrder = ['↑', '◎', '✓', '◷']; // 先丢谁 (从重要性的低到高; ⚙ 永不丢)
47
+ let cur = [...segs];
48
+ const total = () => cur.reduce((n, s) => n + w(s), 0) + Math.max(0, cur.length - 1) * 3; // 3 = ' │ '
49
+ for (const tag of dropOrder) {
50
+ if (total() <= avail)
51
+ break;
52
+ const i = cur.findIndex(s => s.startsWith(tag));
53
+ if (i >= 0)
54
+ cur.splice(i, 1);
55
+ }
56
+ return cur;
57
+ }
58
+ /**
59
+ * 右对齐一段注释 (会话 Title) 需要的**左填充空格数** (纯函数, 可确定性验证)。
60
+ *
61
+ * leo 2026-09-30: 「这个 title 需要顶格右侧」= 贴终端右边缘, 不在段后面跟着走。
62
+ * 规则:
63
+ * · 目标右边缘 = width - 1 (**绝不出正好等于终端宽度的行** —— 终端自动换行会把版面撑歪);
64
+ * · 至少留 1 个空格与左边内容分开 (挨着就分不清是标题还是段);
65
+ * · 放不下 (pad < 1) 返回 null ⇒ 调用方**不显示**标题 (宁可没有, 不挤坏这一行)。
66
+ */
67
+ export function rightAlignPad(usedWidth, noteWidth, width) {
68
+ const pad = (width - 1) - usedWidth - noteWidth;
69
+ return pad >= 1 ? pad : null;
70
+ }
@@ -28,11 +28,27 @@ export function useStore(s) {
28
28
  /** transcript: 消息列表 (agent/用户/系统行) — 供虚拟化/滚动/桥接读写 */
29
29
  export const transcriptStore = createStore([]);
30
30
  export const uiStore = createStore({ status: '', thinking: false, transient: null });
31
+ /**
32
+ * panel: **固定区**内容 (启动面板等) —— 与会话区**分开**存放.
33
+ * 2026-09-30 (leo: 「没有做切分区域, 导致顶部面板在回复的时候也被 AI 内容挤坏了」):
34
+ * 原来面板和会话共用一个 transcript 缓冲 ⇒ 回复一来窗口一滚, 面板就被挤烂/切掉.
35
+ * 现在面板活在独立 store, 渲染成"上半块固定区", 有自己的高度, 不随会话滚动.
36
+ */
37
+ export const panelStore = createStore([]);
31
38
  // ── 纯函数 action (bridge 与组件都走这里) ────────────────────────────────────
32
39
  export function appendMsg(line) {
33
40
  const c = transcriptStore.get();
34
41
  transcriptStore.set([...c, line]);
35
42
  }
43
+ export function appendPanelMsg(line) {
44
+ const c = panelStore.get();
45
+ panelStore.set([...c, line]);
46
+ }
47
+ /** 清空固定区 (面板重打时用; 保证"只一块") */
48
+ export function clearPanelMsg() {
49
+ if (panelStore.get().length)
50
+ panelStore.set([]);
51
+ }
36
52
  export function replaceLastMsg(line) {
37
53
  const c = transcriptStore.get();
38
54
  if (c.length === 0) {