@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,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
+ }
@@ -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): 启动前言全量输出`
@@ -0,0 +1,155 @@
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, widthFn = dispWidth) {
42
+ // 2026-09-30: 宽度一律用 loading-tui 的 dispWidth (按 East Asian Width 表) ——
43
+ // 之前这里和 getStatus 各写了一份 "charCode > 255 ⇒ 2" 的启发式, 它把 `│ ░ ◷ ▸ ◎ ✓ ↑ ⚙`
44
+ // 这些**单宽**符号都算成 2 列 ⇒ 右对齐时每行凭空多占十几列 (leo: 「没有完全右对齐」)。
45
+ const w = (s) => widthFn(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
+ }
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
+ }
@@ -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) {
@@ -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
+ }