@bolloon/bolloon-agent 0.3.33 → 0.3.35

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.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * agent-identity-store.ts — 统一 Agent Identity 源 (2026-08-06)
3
+ *
4
+ * 解决 CLI 状态栏与 Web UI 智能体名称不一致的根因:
5
+ * 多个地方各自维护 agent 名称 → 统一从这里读。
6
+ *
7
+ * 数据流:
8
+ * channels.json (唯一数据源)
9
+ * │
10
+ * AgentIdentityStore (读取 + active 持久化)
11
+ * │
12
+ * CLI 状态栏 / /channel 命令 ── Web UI (GET /active-channel + /channels)
13
+ *
14
+ * active channel 持久化: ~/.bolloon/active-channel.json (CLI 与 Web 共用,
15
+ * 重启后自动恢复上次 channel / identity)。
16
+ */
17
+ import * as fs from 'fs/promises';
18
+ import * as path from 'path';
19
+ const HOME = () => process.env.HOME || '/tmp';
20
+ /** channels.json 路径 (与 server-types.ts CHANNELS_PATH 对齐) */
21
+ export function channelsPaths(home = HOME()) {
22
+ return [
23
+ path.join(home, '.bolloon', 'sessions', 'channels.json'),
24
+ path.join(home, '.bolloon', 'channels.json'),
25
+ ];
26
+ }
27
+ export function activeChannelFile(home = HOME()) {
28
+ return path.join(home, '.bolloon', 'active-channel.json');
29
+ }
30
+ export class AgentIdentityStore {
31
+ home;
32
+ channels = [];
33
+ activeChannelId = null;
34
+ loaded = false;
35
+ constructor(home = HOME()) {
36
+ this.home = home;
37
+ }
38
+ /** 读 channels.json + active-channel.json (幂等, 可重复调) */
39
+ async load() {
40
+ for (const p of channelsPaths(this.home)) {
41
+ try {
42
+ const raw = JSON.parse(await fs.readFile(p, 'utf-8'));
43
+ if (Array.isArray(raw)) {
44
+ this.channels = raw;
45
+ break;
46
+ }
47
+ }
48
+ catch { /* 该路径不存在则试下一个 */ }
49
+ }
50
+ try {
51
+ const a = JSON.parse(await fs.readFile(activeChannelFile(this.home), 'utf-8'));
52
+ if (a && typeof a.channelId === 'string')
53
+ this.activeChannelId = a.channelId;
54
+ }
55
+ catch { /* 无 active 记录 */ }
56
+ this.loaded = true;
57
+ }
58
+ get isLoaded() { return this.loaded; }
59
+ get rawChannels() { return this.channels; }
60
+ /** channel → AgentIdentity (persona.name 优先) */
61
+ toIdentity(c) {
62
+ const name = c.persona?.name?.trim() || c.name || c.agentId || 'agent';
63
+ return {
64
+ id: c.id,
65
+ name,
66
+ channelId: c.id,
67
+ avatar: c.persona?.name ? undefined : undefined,
68
+ metadata: {
69
+ agentId: c.agentId,
70
+ did: c.did,
71
+ persona: c.persona,
72
+ publicKey: c.publicKey,
73
+ cid: c.cid,
74
+ ipnsName: c.ipnsName,
75
+ },
76
+ };
77
+ }
78
+ /** 全部智能体身份 (channel 顺序 = 索引顺序, 1-based) */
79
+ getIdentities() {
80
+ return this.channels.map(c => this.toIdentity(c));
81
+ }
82
+ /**
83
+ * 解析 /channel <query>:
84
+ * 纯数字 → number (1-based 索引)
85
+ * 匹配 id → id (完整或前缀)
86
+ * 匹配 name → name (大小写不敏感)
87
+ * 优先级: number > id > name
88
+ */
89
+ async resolve(query) {
90
+ if (!this.loaded)
91
+ await this.load();
92
+ const q = String(query || '').trim();
93
+ if (!q)
94
+ return null;
95
+ // 1. number: 纯数字 → 1-based 索引
96
+ if (/^\d+$/.test(q)) {
97
+ const idx = parseInt(q, 10);
98
+ const ch = this.channels[idx - 1];
99
+ if (ch)
100
+ return { identity: this.toIdentity(ch), channel: ch, match: 'number', index: idx };
101
+ }
102
+ // 2. id: 完整或前缀
103
+ let found = this.channels.find(c => c.id === q);
104
+ if (found) {
105
+ const idx = this.channels.indexOf(found) + 1;
106
+ return { identity: this.toIdentity(found), channel: found, match: 'id', index: idx };
107
+ }
108
+ found = this.channels.find(c => c.id.startsWith(q));
109
+ if (found) {
110
+ const idx = this.channels.indexOf(found) + 1;
111
+ return { identity: this.toIdentity(found), channel: found, match: 'id', index: idx };
112
+ }
113
+ // 3. name: persona.name / channel.name 大小写不敏感 (含子串)
114
+ const ql = q.toLowerCase();
115
+ found = this.channels.find(c => {
116
+ const names = [c.persona?.name, c.name].filter(Boolean).map(n => String(n).toLowerCase());
117
+ return names.some(n => n === ql || n.includes(ql));
118
+ });
119
+ if (found) {
120
+ const idx = this.channels.indexOf(found) + 1;
121
+ return { identity: this.toIdentity(found), channel: found, match: 'name', index: idx };
122
+ }
123
+ return null;
124
+ }
125
+ /** 当前 active 身份 (无 active 或找不到时 → 第一个 channel, 与 Web UI 默认一致) */
126
+ async getActive() {
127
+ if (!this.loaded)
128
+ await this.load();
129
+ if (this.activeChannelId) {
130
+ const ch = this.channels.find(c => c.id === this.activeChannelId);
131
+ if (ch)
132
+ return this.toIdentity(ch);
133
+ }
134
+ return this.channels.length > 0 ? this.toIdentity(this.channels[0]) : null;
135
+ }
136
+ /** 切换 active channel + 持久化 (CLI /channel 与 Web POST /active-channel 共用) */
137
+ async setActive(channelId) {
138
+ if (!this.loaded)
139
+ await this.load();
140
+ const ch = this.channels.find(c => c.id === channelId);
141
+ if (!ch)
142
+ return null;
143
+ this.activeChannelId = channelId;
144
+ try {
145
+ await fs.mkdir(path.dirname(activeChannelFile(this.home)), { recursive: true });
146
+ await fs.writeFile(activeChannelFile(this.home), JSON.stringify({ channelId, updatedAt: Date.now() }, null, 2), 'utf-8');
147
+ }
148
+ catch (e) {
149
+ console.warn(`[identity-store] 持久化 active channel 失败 (非致命): ${e?.message}`);
150
+ }
151
+ return this.toIdentity(ch);
152
+ }
153
+ /** 列出所有 channel 供 /channel 无参显示 */
154
+ async listForDisplay() {
155
+ if (!this.loaded)
156
+ await this.load();
157
+ const active = this.activeChannelId;
158
+ return this.channels.map((c, i) => ({
159
+ index: i + 1,
160
+ identity: this.toIdentity(c),
161
+ active: c.id === active,
162
+ }));
163
+ }
164
+ }
165
+ let _store = null;
166
+ /** 单例 (CLI / server 共用; 测试可 new AgentIdentityStore(tmpHome)) */
167
+ export function getIdentityStore() {
168
+ if (!_store)
169
+ _store = new AgentIdentityStore();
170
+ return _store;
171
+ }
@@ -1966,13 +1966,28 @@ export function registerBuiltinTools(ctx) {
1966
1966
  let ipnsName = '';
1967
1967
  try {
1968
1968
  const ipfs = await sdk.IpfsClient.newWithRemoteNode('http://127.0.0.1:5001', 'http://127.0.0.1:8080');
1969
- const pub = await ipfs.publishAfterUpload?.(cid, kp);
1969
+ // 2026-08-06 fix: 之前传 kp (KeyPair 对象) 当 keyName → ensureKeyExists 拿对象比
1970
+ // 字符串永远 false → key/gen 自动生成名为 "[object Object]" 的 key.
1971
+ // 改用确定性 key 名 (与 did-builder.ts 一致: did-<did 冒号转横线>).
1972
+ const keyName = `did-${String(identity.did || '').replace(':', '-').replace(' ', '')}` || 'self';
1973
+ const pub = await ipfs.publishAfterUpload?.(cid, keyName);
1970
1974
  ipnsName = pub?.name || pub?.ipnsName || '';
1971
1975
  }
1972
1976
  catch { /* IPNS 失败不致命, CID 仍可用 */ }
1977
+ // 2026-08-06: 发布诊断 — 节点地址 + 公网可达性提示 (IPNS 记录进 DHT 但内容拉取依赖源节点可达)
1978
+ let diag = '';
1979
+ try {
1980
+ const id = await kuboApi('/api/v0/id');
1981
+ const addrs = id?.Addresses || [];
1982
+ const pubAddrs = addrs.filter((a) => !/127\.0\.0\.1|::1|10\.|100\.|192\.168|172\.(1[6-9]|2\d|3[01])\./.test(a));
1983
+ const peers = await kuboApi('/api/v0/swarm/peers');
1984
+ const peerCount = peers?.Peers?.length || 0;
1985
+ diag = `\n [诊断] 节点在线, ${peerCount} peers; 公网可达地址 ${pubAddrs.length} 个${pubAddrs.length === 0 ? ' ⚠️ 无公网地址 (NAT 后), 公网用户只能解析 IPNS 但拉不到内容, 建议 pin 到公共服务或配置端口映射' : ''}`;
1986
+ }
1987
+ catch { /* 诊断失败静默 */ }
1973
1988
  return {
1974
1989
  success: true,
1975
- output: `✅ DID 已发布到 IPFS:\n DID: ${identity.did}\n CID: ${cid}\n IPNS: ${ipnsName || '(发布失败, CID 仍可用)'}\n 读回验证: curl -X POST "http://127.0.0.1:5001/api/v0/cat?arg=${cid}"`,
1990
+ output: `✅ DID 已发布到 IPFS:\n DID: ${identity.did}\n CID: ${cid}\n IPNS: ${ipnsName || '(发布失败, CID 仍可用)'}\n 读回验证: curl -X POST "http://127.0.0.1:5001/api/v0/cat?arg=${cid}"${diag}`,
1976
1991
  };
1977
1992
  }
1978
1993
  catch (e) {
@@ -2063,7 +2078,18 @@ export function registerBuiltinTools(ctx) {
2063
2078
  const keyName = String(args.keyName || 'self').trim() || 'self';
2064
2079
  await ipfs.ensureKeyExists(keyName);
2065
2080
  const r = await ipfs.publishIpns(cid, keyName, '8760h', '1h');
2066
- return { success: true, output: `✅ IPNS 已发布:\n name: ${r.name}\n value: ${r.value}\n 解析: ipns_resolve(name="${r.name}")\n 公网访问: https://ipfs.io/ipns/${r.name}` };
2081
+ // 2026-08-06: 诊断 公网可达性提示 (IPNS 记录进 DHT, 内容拉取依赖源节点公网可达)
2082
+ let diag = '';
2083
+ try {
2084
+ const id = await kuboApi('/api/v0/id');
2085
+ const addrs = id?.Addresses || [];
2086
+ const pubAddrs = addrs.filter((a) => !/127\.0\.0\.1|::1|10\.|100\.|192\.168|172\.(1[6-9]|2\d|3[01])\./.test(a));
2087
+ if (pubAddrs.length === 0) {
2088
+ diag = '\n ⚠️ [诊断] 本机无公网可达地址 (NAT 后): 其他节点能解析 IPNS 但拉不到内容. 公网访问需 pin 到公共服务 (web3.storage/Pinata) 或给本机配置公网端口映射.';
2089
+ }
2090
+ }
2091
+ catch { /* 诊断失败静默 */ }
2092
+ return { success: true, output: `✅ IPNS 已发布:\n name: ${r.name}\n value: ${r.value}\n 解析: ipns_resolve(name="${r.name}")\n 公网访问: https://ipfs.io/ipns/${r.name}${diag}` };
2067
2093
  }
2068
2094
  catch (e) {
2069
2095
  return { success: false, error: `ipns_publish 失败: ${String(e.message || e).slice(0, 200)}` };
@@ -2080,7 +2106,9 @@ export function registerBuiltinTools(ctx) {
2080
2106
  if (!name)
2081
2107
  return { success: false, error: 'name 必填' };
2082
2108
  await ensureKuboReady();
2083
- const r = await kuboApi(`/api/v0/name/resolve?arg=${encodeURIComponent(name)}`, undefined, 60000);
2109
+ // 2026-08-06 fix: recursive+nocache — 之前不带 nocache, 同一 key 重发布后
2110
+ // Kubo 返回本地缓存旧 CID (TTL 1h), 实测新内容发布后 resolve 到旧值.
2111
+ const r = await kuboApi(`/api/v0/name/resolve?arg=${encodeURIComponent(name)}&recursive=true&nocache=true`, undefined, 60000);
2084
2112
  const path = typeof r === 'object' && r !== null ? r.Path : String(r);
2085
2113
  const cid = String(path).replace(/^\/ipfs\//, '').trim();
2086
2114
  return { success: true, output: `🔗 ${name} → ${path}\n CID: ${cid}` };
@@ -2574,6 +2602,17 @@ export function registerWalletTools(ctx) {
2574
2602
  catch (lspErr) {
2575
2603
  console.warn('[registerTools] LSP 工具注册失败 (非致命):', lspErr);
2576
2604
  }
2605
+ // 2026-08-06: 注册 OrbitDB/CID 数据层工具 (cid_save/load/update/version/list/share + context 快照 + UI CID)
2606
+ try {
2607
+ import('../orbitdb/agent-tools.js').then(({ registerOrbitdbTools }) => {
2608
+ registerOrbitdbTools(ctx);
2609
+ }).catch((e) => {
2610
+ console.warn('[registerTools] OrbitDB 工具注册失败 (非致命):', e);
2611
+ });
2612
+ }
2613
+ catch (odbErr) {
2614
+ console.warn('[registerTools] OrbitDB 工具注册失败 (非致命):', odbErr);
2615
+ }
2577
2616
  }
2578
2617
  /**
2579
2618
  * 监听 P2P 消息 (远端 + 本地 inbox bus), 把消息存到 _inboxMessages 供 check_inbox 读.
@@ -12,6 +12,7 @@
12
12
  import * as fsSync from 'fs';
13
13
  import * as os from 'os';
14
14
  import * as path from 'path';
15
+ import { getContextManager } from '../bootstrap/context-manager.js';
15
16
  import { documentReader } from '../documents/reader.js';
16
17
  import { getMinimax } from '../constraints/index.js';
17
18
  import { p2pNetwork } from '../network/p2p.js';
@@ -82,8 +83,21 @@ export class PiAgentSession {
82
83
  MAX_REACT_ITERATIONS = 10_000;
83
84
  MAX_REFINE_ATTEMPTS = 3;
84
85
  QUALITY_THRESHOLD = 0.6;
85
- /** P1: 上下文溢出阈值 (单轮估算 token 数, 超过则强制终止防止 prompt-too-long) */
86
- MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD = 60_000; // 60K tokens 上限
86
+ /** P1: 上下文溢出阈值 (单轮估算 token 数, 超过则强制终止防止 prompt-too-long)
87
+ * 2026-08-06: ContextManager 动态读 (默认 1M, env MAX_CONTEXT_TOKENS 可调).
88
+ * 保留字段仅作降级兜底 (ContextManager 初始化失败时用 60K 老行为). */
89
+ MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD = 60_000; // fallback 60K tokens
90
+ /** 2026-08-06: 上下文窗口 (tokens) — 统一走 ContextManager 配置 (1M 默认). */
91
+ maxContextTokens() {
92
+ try {
93
+ const { getContextManager } = require('../bootstrap/context-manager.js');
94
+ const n = getContextManager().getConfig().maxTokens;
95
+ return Number.isFinite(n) && n > 0 ? n : this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD;
96
+ }
97
+ catch {
98
+ return this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD;
99
+ }
100
+ }
87
101
  /** 2026-06-16 新增: 累计错误总数兜底 (不管是否同工具, 累计 N 次就强制退出)
88
102
  * 防 LLM 轮换工具名绕开 MAX_SAME_TOOL_FAILURES 的死循环攻击 */
89
103
  MAX_TOTAL_ERRORS = 20;
@@ -1091,8 +1105,10 @@ ${this.getToolDefinitions()}
1091
1105
  // 2026-06-16 新增: loop 内自动压缩 — token 超 80% 阈值时跑一次
1092
1106
  // compact 失败走 C 路径: 不强行 break, 让现有 60K 阈值兜底 (后面有检查)
1093
1107
  // 2026-07-01 (v0.2.4 子任务 1): 触发判定走 shouldCompactBeforeIteration 纯函数
1094
- const compactThreshold = this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD * this.LOOP_COMPACT_RATIO;
1108
+ const compactThreshold = this.maxContextTokens() * this.LOOP_COMPACT_RATIO;
1095
1109
  const estimatedTokensBefore = this.estimateHistoryTokens();
1110
+ // 2026-08-06: 每轮上报 usage 到 ContextManager (CLI/Web 状态栏数据源, warning 事件触发点)
1111
+ getContextManager().updateUsage(estimatedTokensBefore);
1096
1112
  if (shouldCompactBeforeIteration(estimatedTokensBefore, compactThreshold)) {
1097
1113
  const tokensBeforeCompact = estimatedTokensBefore;
1098
1114
  console.log(`[PiAgent] loop 入口 token ${tokensBeforeCompact} > ${compactThreshold}, 触发自动压缩`);
@@ -1108,10 +1124,10 @@ ${this.getToolDefinitions()}
1108
1124
  // 停止条件 3: context overflow (compact 后还超, 强制终止)
1109
1125
  // 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideContextOverflow 纯函数
1110
1126
  const estimatedTokens = this.estimateHistoryTokens();
1111
- const overflowDecision = decideContextOverflow(estimatedTokens, this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD);
1127
+ const overflowDecision = decideContextOverflow(estimatedTokens, this.maxContextTokens());
1112
1128
  if (overflowDecision.shouldExit) {
1113
- console.warn(`[PiAgent] context overflow (${estimatedTokens} tokens > ${this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD})`);
1114
- onStream?.({ type: 'error', content: `⏹️ 上下文溢出 (${estimatedTokens} tokens, 阈值 ${this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD})`, tool: 'loop' });
1129
+ console.warn(`[PiAgent] context overflow (${estimatedTokens} tokens > ${this.maxContextTokens()})`);
1130
+ onStream?.({ type: 'error', content: `⏹️ 上下文溢出 (${estimatedTokens} tokens, 阈值 ${this.maxContextTokens()})`, tool: 'loop' });
1115
1131
  finalResponse = finalResponse || overflowDecision.finalAnswer;
1116
1132
  break;
1117
1133
  }
@@ -1735,9 +1751,47 @@ ${toolDefs}
1735
1751
  */
1736
1752
  buildMessages() {
1737
1753
  try {
1738
- // 直接取 history 最后 15 条, tool 结果转 user role, 避免 tool_calls 配对
1739
- const slice = this.messageHistory.slice(-15);
1754
+ // 2026-08-06: 来源优先用 projectedHistory (Context Collapse 投影, 非破坏)
1755
+ // 与 buildContext 一致; 之前只让字符串路径用投影, messages 数组路径被跳过,
1756
+ // 导致 LLM 实际看到的还是未压缩的历史.
1757
+ const source = this.projectedHistory ?? this.messageHistory;
1758
+ const WINDOW = 15;
1740
1759
  const out = [];
1760
+ // 早期历史压缩: 超过窗口时, 不直接丢弃 — 提取前段用户意图摘要注入 (同步, 无 LLM).
1761
+ // 结构对齐 Context OS: System Prompt(persona) + 压缩摘要 + 最近消息.
1762
+ if (source.length > WINDOW) {
1763
+ const early = source.slice(0, source.length - WINDOW);
1764
+ const slice = source.slice(-WINDOW);
1765
+ const earlyUsers = early.filter(m => m.role === 'user' && (m.content || '').trim());
1766
+ const earlyTools = early.filter(m => m.role === 'tool').length;
1767
+ const earlyAssist = early.filter(m => m.role === 'assistant' && (m.content || '').trim()).length;
1768
+ const snippet = earlyUsers.slice(-5).map(m => `- ${(m.content || '').slice(0, 120).replace(/\n/g, ' ')}`).join('\n') || '- (早期对话无用户文本)';
1769
+ out.push({
1770
+ role: 'system',
1771
+ content: `[上下文压缩] 早期 ${early.length} 条消息已压缩 (用户 ${earlyUsers.length} 条 / AI ${earlyAssist} 条 / 工具结果 ${earlyTools} 条). 关键用户意图摘要:\n${snippet}\n[压缩结束] 以下是最近消息:`,
1772
+ });
1773
+ for (const m of slice) {
1774
+ const r = m.role;
1775
+ if (r === 'tool') {
1776
+ out.push({ role: 'user', content: `[工具结果]\n${(m.content || '').slice(0, 2000)}` });
1777
+ continue;
1778
+ }
1779
+ if (r === 'assistant') {
1780
+ out.push({ role: 'assistant', content: (m.content || '').slice(0, 4000) });
1781
+ continue;
1782
+ }
1783
+ if (r === 'user') {
1784
+ out.push({ role: 'user', content: (m.content || '').slice(0, 2000) });
1785
+ continue;
1786
+ }
1787
+ if (r === 'system') {
1788
+ out.push({ role: 'system', content: (m.content || '').slice(0, 2000) });
1789
+ }
1790
+ }
1791
+ return out;
1792
+ }
1793
+ // 窗口内: 原逻辑 (tool 转 user role, 避免 tool_calls 配对)
1794
+ const slice = source.slice(-WINDOW);
1741
1795
  for (const m of slice) {
1742
1796
  const r = m.role;
1743
1797
  if (r === 'tool') {
@@ -1787,13 +1841,13 @@ ${toolDefs}
1787
1841
  async callLlmWithRecovery(llm, contextOrMessages, systemPrompt, signal, onStream, tools) {
1788
1842
  // Reactive compaction 预检: 估算 token 超 80% 阈值, 跑一次
1789
1843
  const estimated = this.estimateHistoryTokens();
1790
- if (estimated > this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD * 0.8) {
1844
+ if (estimated > this.maxContextTokens() * 0.8) {
1791
1845
  console.warn(`[PiAgent] reactive compaction pre-check (${estimated} tokens > 80% threshold)`);
1792
1846
  onStream?.({ type: 'status', content: '⚠️ reactive compaction 预检触发', tool: 'recovery' });
1793
1847
  try {
1794
1848
  const compacted = this.compressHistorySync(this.messageHistory);
1795
1849
  this.messageHistory = compacted;
1796
- if (this.estimateHistoryTokens() > this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD * 0.8) {
1850
+ if (this.estimateHistoryTokens() > this.maxContextTokens() * 0.8) {
1797
1851
  await this.maybeAutoCompact(onStream, signal);
1798
1852
  }
1799
1853
  }
@@ -1951,9 +2005,15 @@ ${toolDefs}
1951
2005
  const r = await llm.chat(userPrompt, systemPrompt, signal);
1952
2006
  return r.reply;
1953
2007
  };
2008
+ // 2026-08-06: 预算 = ContextManager 配置 (1M * 55% ≈ 550K), 不再写死 8000.
2009
+ // 之前 8000 与 48K 触发阈值矛盾: 一触发就一路跑到 LLM 摘要 (贵), 且 8000 远小于实际窗口.
2010
+ const cm = getContextManager();
2011
+ const cfg = cm.getConfig();
2012
+ const maxTokens = Math.max(4000, Math.round(cfg.maxTokens * cfg.compressionThreshold));
2013
+ const beforeTokens = this.estimateHistoryTokens();
1954
2014
  const { compactPipeline, isContextCollapseEnabled } = await import('../context-compaction/index.js');
1955
2015
  const result = await compactPipeline(this.messageHistory, {
1956
- maxTokens: 8000,
2016
+ maxTokens,
1957
2017
  llmChat,
1958
2018
  collapseLlmChat: llmChat, // P1.2: Context Collapse 投影也用同一 LLM
1959
2019
  cacheScope: this.currentChannelId || 'default',
@@ -1961,9 +2021,12 @@ ${toolDefs}
1961
2021
  if (result.compacted && result.history.length < this.messageHistory.length) {
1962
2022
  const saved = this.messageHistory.length - result.history.length;
1963
2023
  const stagesApplied = result.stages.filter((s) => s.applied).map((s) => s.stage).join(' → ');
2024
+ const afterTokens = this.estimateHistoryTokens();
2025
+ const savedTokens = Math.max(0, beforeTokens - afterTokens);
2026
+ cm.markCompressStart(beforeTokens);
1964
2027
  onStream?.({
1965
2028
  type: 'status',
1966
- content: `🗜️ 上下文压缩: ${stagesApplied || 'no-op'} | 节省 ${saved} 条 (剩余 ${result.history.length}, collapse=${isContextCollapseEnabled() ? 'on' : 'off'})`,
2029
+ content: `🗜️ 上下文压缩: ${stagesApplied || 'no-op'} | 节省 ${saved} 条 / ${savedTokens.toLocaleString()} tokens (剩余 ${result.history.length}, collapse=${isContextCollapseEnabled() ? 'on' : 'off'})`,
1967
2030
  tool: 'compactor',
1968
2031
  });
1969
2032
  // 关键: 第 4 层 (Context Collapse) 是读时投影 (非破坏)
@@ -1977,6 +2040,29 @@ ${toolDefs}
1977
2040
  this.messageHistory = result.history; // 真破坏性更新
1978
2041
  this.projectedHistory = null;
1979
2042
  }
2043
+ // 2026-08-06: snapshot 记录 before/after + 摘要 (供恢复/调试/UI), 事件广播
2044
+ try {
2045
+ const summaryLine = result.stages.map((s) => `${s.stage}(${s.before}→${s.after})`).join(' ');
2046
+ const snap = cm.makeSnapshot({
2047
+ beforeTokens,
2048
+ afterTokens,
2049
+ summary: `压缩管道: ${summaryLine}; 节省 ${savedTokens} tokens / ${saved} 条消息`,
2050
+ preservedMemory: [
2051
+ ...this.messageHistory.filter(m => m.role === 'user').slice(-3).map(m => (m.content || '').slice(0, 80)),
2052
+ ],
2053
+ agentId: this.currentAgentId,
2054
+ channelId: this.currentChannelId,
2055
+ });
2056
+ cm.markCompressComplete(snap);
2057
+ }
2058
+ catch (snapErr) {
2059
+ // snapshot 失败不阻塞主流程
2060
+ }
2061
+ cm.updateUsage(afterTokens);
2062
+ }
2063
+ else {
2064
+ // 没压成也更新 usage (数据源保持新鲜)
2065
+ cm.updateUsage(beforeTokens);
1980
2066
  }
1981
2067
  }
1982
2068
  isFinalResponse(content) {
@@ -0,0 +1,166 @@
1
+ /**
2
+ * context-manager.ts — Context OS 资源管理器 (2026-08-06)
3
+ *
4
+ * 把"上下文压缩"升级为资源管理系统:
5
+ * Token Budget → Monitor → Event → Compression Worker → Snapshot → Memory → UI 反馈
6
+ *
7
+ * 配置 (env 可覆盖):
8
+ * MAX_CONTEXT_TOKENS 默认 1_000_000 (1M tokens 全局窗口)
9
+ * COMPRESSION_THRESHOLD 默认 0.55 (55% 自动压缩)
10
+ * WARNING_THRESHOLD 默认 0.50 (50% 状态栏 warning)
11
+ *
12
+ * 生命周期:
13
+ * < 50% normal
14
+ * 50-55% warning (UI 提示即将压缩)
15
+ * >= 55% auto compression (触发后生成 summary + snapshot, 替换历史)
16
+ *
17
+ * 设计:
18
+ * - 纯模块, 不 import pi-sdk (避免循环依赖); 只依赖 context-compaction 的 token 估算
19
+ * - 事件订阅: CLI / Web UI / Logger 各自订阅, 压缩状态实时可见
20
+ * - Snapshot 落盘 ~/.bolloon/context-os/snapshots/ (JSON), 可选 CID 化 (ContextStore)
21
+ */
22
+ import * as fs from 'fs/promises';
23
+ import * as path from 'path';
24
+ import * as os from 'os';
25
+ import { randomUUID } from 'crypto';
26
+ export function getContextConfig(env = process.env) {
27
+ const num = (v, def) => {
28
+ const n = Number(v);
29
+ return Number.isFinite(n) && n > 0 ? n : def;
30
+ };
31
+ const maxTokens = num(env.MAX_CONTEXT_TOKENS, 1_000_000);
32
+ const compressionThreshold = Math.min(1, Math.max(0, num(env.COMPRESSION_THRESHOLD, 0.55)));
33
+ const warningThreshold = Math.min(compressionThreshold, Math.max(0, num(env.WARNING_THRESHOLD, 0.5)));
34
+ return { maxTokens, compressionThreshold, warningThreshold };
35
+ }
36
+ export function usageFromTokens(usedTokens, config, lastCompressedAt = 0, lastSavedTokens = 0) {
37
+ const pct = config.maxTokens > 0 ? usedTokens / config.maxTokens : 0;
38
+ let stage = 'normal';
39
+ if (usedTokens >= config.maxTokens * config.compressionThreshold)
40
+ stage = 'compressing';
41
+ else if (usedTokens >= config.maxTokens * config.warningThreshold)
42
+ stage = 'warning';
43
+ if (lastCompressedAt > 0 && stage === 'normal')
44
+ stage = 'compressed';
45
+ return { usedTokens, maxTokens: config.maxTokens, pct, stage, lastCompressedAt, lastSavedTokens };
46
+ }
47
+ export function getSnapshotsDir(home = os.homedir()) {
48
+ return path.join(home, '.bolloon', 'context-os', 'snapshots');
49
+ }
50
+ /** 持久化 snapshot 到磁盘 (JSON 文件, 供恢复/调试). 失败静默. */
51
+ export async function saveSnapshotToDisk(snap, home) {
52
+ try {
53
+ const dir = getSnapshotsDir(home);
54
+ await fs.mkdir(dir, { recursive: true });
55
+ const file = path.join(dir, `${snap.timestamp}-${snap.id.slice(0, 8)}.json`);
56
+ await fs.writeFile(file, JSON.stringify(snap, null, 2), 'utf-8');
57
+ return file;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /** 读取最近一次 snapshot (按时间戳). 无 → null. */
64
+ export async function loadLatestSnapshot(home) {
65
+ try {
66
+ const dir = getSnapshotsDir(home);
67
+ const files = (await fs.readdir(dir)).filter(f => f.endsWith('.json')).sort();
68
+ if (files.length === 0)
69
+ return null;
70
+ const raw = await fs.readFile(path.join(dir, files[files.length - 1]), 'utf-8');
71
+ return JSON.parse(raw);
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ // ============================================================
78
+ // ContextManager (单例)
79
+ // ============================================================
80
+ export class ContextManager {
81
+ config;
82
+ listeners = new Set();
83
+ _usage;
84
+ constructor(config = getContextConfig()) {
85
+ this.config = config;
86
+ this._usage = usageFromTokens(0, config);
87
+ }
88
+ getConfig() {
89
+ return this.config;
90
+ }
91
+ /** 重载配置 (env 变化/测试用) */
92
+ reloadConfig(env) {
93
+ this.config = getContextConfig(env);
94
+ }
95
+ getUsage() {
96
+ return this._usage;
97
+ }
98
+ /** 更新当前 token 用量 (由 pi-sdk 每轮 LLM 调用前报告). 返回 stage 是否变化. */
99
+ updateUsage(usedTokens) {
100
+ const next = usageFromTokens(usedTokens, this.config, this._usage.lastCompressedAt, this._usage.lastSavedTokens);
101
+ const prevStage = this._usage.stage;
102
+ this._usage = next;
103
+ // warning 边界: normal → warning 时发一次 (不重复发)
104
+ if (prevStage !== 'warning' && next.stage === 'warning') {
105
+ this.emit({ type: 'context.warning', usage: next });
106
+ }
107
+ return next;
108
+ }
109
+ /** 压缩开始: 记录 before, 发事件, 返回 threshold 供日志. */
110
+ markCompressStart(beforeTokens) {
111
+ const thresholdTokens = Math.round(this.config.maxTokens * this.config.compressionThreshold);
112
+ this._usage = { ...this._usage, stage: 'compressing' };
113
+ this.emit({ type: 'context.compress.start', beforeTokens, thresholdTokens });
114
+ return thresholdTokens;
115
+ }
116
+ /** 压缩完成: 记录 after + snapshot, 发事件. */
117
+ markCompressComplete(snap) {
118
+ this._usage = {
119
+ usedTokens: snap.afterTokens,
120
+ maxTokens: this.config.maxTokens,
121
+ pct: snap.afterTokens / this.config.maxTokens,
122
+ stage: 'compressed',
123
+ lastCompressedAt: snap.timestamp,
124
+ lastSavedTokens: Math.max(0, snap.beforeTokens - snap.afterTokens),
125
+ };
126
+ this.emit({ type: 'context.compress.complete', snapshot: snap, usage: this._usage });
127
+ this.emit({ type: 'context.snapshot.created', snapshot: snap });
128
+ saveSnapshotToDisk(snap).catch(() => { });
129
+ }
130
+ /** 订阅事件. 返回取消函数. */
131
+ onEvent(handler) {
132
+ this.listeners.add(handler);
133
+ return () => this.listeners.delete(handler);
134
+ }
135
+ emit(evt) {
136
+ for (const h of this.listeners) {
137
+ try {
138
+ h(evt);
139
+ }
140
+ catch { /* 单个订阅者失败不影响其余 */ }
141
+ }
142
+ }
143
+ /** 创建 snapshot 对象 (不含 CID, 由调用方决定是否 CID 化) */
144
+ makeSnapshot(opts) {
145
+ return {
146
+ id: randomUUID(),
147
+ timestamp: Date.now(),
148
+ beforeTokens: opts.beforeTokens,
149
+ afterTokens: opts.afterTokens,
150
+ summary: opts.summary,
151
+ preservedMemory: opts.preservedMemory ?? [],
152
+ agentId: opts.agentId,
153
+ channelId: opts.channelId,
154
+ };
155
+ }
156
+ }
157
+ let _manager = null;
158
+ export function getContextManager() {
159
+ if (!_manager)
160
+ _manager = new ContextManager();
161
+ return _manager;
162
+ }
163
+ /** 测试钩子: 重置单例 */
164
+ export function _resetContextManagerForTest() {
165
+ _manager = null;
166
+ }
@@ -14,6 +14,20 @@
14
14
  import * as fs from 'fs/promises';
15
15
  import * as os from 'os';
16
16
  import * as path from 'path';
17
+ /** 统一消息字段: SessionStore (session-store.ts) 用 role, 老 cache 格式用 type. 读成 SessionMessageLite. */
18
+ function toLite(m) {
19
+ const raw = m && typeof m === 'object' ? m : { content: String(m ?? '') };
20
+ let type = raw.type ?? raw.role ?? '';
21
+ if (type === 'assistant')
22
+ type = 'ai';
23
+ if (type === 'system' || type === 'tool')
24
+ type = 'system';
25
+ return {
26
+ type: type || 'unknown',
27
+ content: typeof raw.content === 'string' ? raw.content : JSON.stringify(raw.content ?? ''),
28
+ timestamp: raw.timestamp || raw.createdAt,
29
+ };
30
+ }
17
31
  export function sanitizeAgentId(id) {
18
32
  return id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
19
33
  }
@@ -64,11 +78,9 @@ async function readSessionMessages(sessionCacheFile) {
64
78
  try {
65
79
  const raw = await fs.readFile(sessionCacheFile, 'utf-8');
66
80
  const parsed = JSON.parse(raw);
67
- if (Array.isArray(parsed))
68
- return parsed;
69
- if (Array.isArray(parsed.messages))
70
- return parsed.messages;
71
- return [];
81
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.messages) ? parsed.messages : [];
82
+ // 2026-08-06: 统一 role/type 字段 (SessionStore 写 role, 老格式写 type), 过滤空壳消息
83
+ return list.map((m) => toLite(m)).filter(m => m.type !== 'unknown' && m.content);
72
84
  }
73
85
  catch {
74
86
  return [];
@@ -100,22 +112,20 @@ ${aiSnippet || ' (无)'}
100
112
  /**
101
113
  * 尝试调 LLM 生成更精炼的中文摘要. 失败 → 抛错, 由 caller fallback.
102
114
  *
115
+ * 2026-08-06 修复: 之前调用不存在的 pi-ai.generateText → 100% 抛错 → 永远模板 fallback.
116
+ * 改用真实接口 getMinimax().chat(userMsg, systemPrompt) (pi-ai.ts 导出的 PiAIModel).
103
117
  * 用动态 import + 失败静默 — 不引入 pi-ai 强依赖 (避免循环).
104
118
  */
105
119
  async function tryLlmSummary(systemPrompt, userPrompt) {
106
120
  try {
107
121
  const piAi = await import('../llm/pi-ai.js');
108
- const generateText = piAi.generateText;
109
- if (typeof generateText !== 'function')
110
- throw new Error('generateText not exported');
111
- const result = await generateText({
112
- messages: [
113
- { role: 'system', content: systemPrompt },
114
- { role: 'user', content: userPrompt },
115
- ],
116
- temperature: 0.2,
117
- maxTokens: 800,
118
- });
122
+ const getMinimax = piAi.getMinimax;
123
+ if (typeof getMinimax !== 'function')
124
+ throw new Error('getMinimax not exported');
125
+ const llm = getMinimax();
126
+ if (!llm || typeof llm.chat !== 'function')
127
+ throw new Error('LLM chat not available');
128
+ const result = await llm.chat(userPrompt, systemPrompt);
119
129
  const text = result?.reply || result?.text || '';
120
130
  if (typeof text !== 'string' || text.length < 20)
121
131
  throw new Error('LLM returned too-short text');