@bolloon/bolloon-agent 0.3.34 → 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.
@@ -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}` };
@@ -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');
@@ -45,50 +45,54 @@ export function snipHistory(messages, opts = {}) {
45
45
  }
46
46
  return m;
47
47
  });
48
+ // Step 3 (提前): 窗口内也截断过长 tool 结果 — 2026-08-06 fix: 原来在 Step 2 return 之后,
49
+ // 窗口内消息永远走不到 tool 截断 (budgeted.length <= maxMessages 时提前返回).
50
+ // originalLength 保留最早值 (budget-reduce 已记录原始长度, 不覆盖).
51
+ const trimToolResults = (list, boundaryProtected = false) => list.map(m => {
52
+ if (m.role === 'tool' && m.content.length > maxToolResultChars && m.transform !== 'snip') {
53
+ return {
54
+ ...m,
55
+ content: m.content.slice(0, maxToolResultChars) + `\n[...工具结果截断]`,
56
+ originalLength: m.originalLength ?? m.content.length,
57
+ transform: m.transform || 'snip-tool',
58
+ };
59
+ }
60
+ return m;
61
+ });
48
62
  // Step 2: Snip — 超过 maxMessages 时裁最老的
49
63
  if (budgeted.length <= maxMessages)
50
- return budgeted;
51
- // 工具调用链保护: 从尾部往前数, 保留最近的 assistant+tool 对
64
+ return trimToolResults(budgeted);
52
65
  const keepCount = maxMessages;
53
- const result = [];
54
66
  const toRemove = budgeted.length - keepCount;
55
- // 策略: 从最老的开始裁, 但要保证不会裁掉未配对的 assistant (tool_calls)
56
- // 遍历时追踪 "悬空的 tool 消息" 保护
57
- let removed = 0;
58
- let protectedToolChain = 0; // 从尾部连续 tool 消息不裁
59
- for (let i = budgeted.length - 1; i >= 0; i--) {
60
- const m = budgeted[i];
61
- if (m.role === 'tool' && protectedToolChain < 5) {
62
- protectedToolChain++;
63
- }
64
- else if (m.role === 'assistant' && protectedToolChain > 0) {
65
- // 遇到 assistant 代表这个工具链结束了
67
+ // 2026-08-06 重写: 原实现 protectedToolChain 计数逻辑混乱 (assistant 分支不重置,
68
+ // 条件 `budgeted.length - i > protectedToolChain` 语义错误), 且占位符路径只在
69
+ // removed<=toRemove 时生效导致被裁消息数与实际不符.
70
+ // 新语义:
71
+ // - 从头部裁 toRemove (最老历史), 占位区间 = [0, removedIndex)
72
+ // - 裁剪边界保护: 若保留区第一条是 tool 且前一条是 assistant, assistant 天然在
73
+ // 移除区 它变成占位符, 保持 [assistant占位, tool] 相邻, 不产生悬空 tool
74
+ // (原生 tool_calls 路径要求 assistant→tool 相邻; 占位保留 role 序列形状)
75
+ // - 被裁消息 → '[已裁减, Snip]' 占位符 (保留 role 顺序, 防配对错乱)
76
+ const result = [];
77
+ const removedIndex = budgeted.length - keepCount; // 保留区起点 (含)
78
+ // 边界保护 (2026-08-06 fix): 只验证不改变 cutAt —
79
+ // 之前 `cutAt -= 1` 把占位区间缩窄, 导致边界 assistant 反而保留 (悬空 tool 依旧).
80
+ // assistant 本来就在移除区 [0, removedIndex), 转占位后 tool 前紧邻占位 assistant, 配对形状保持.
81
+ const firstKept = budgeted[removedIndex];
82
+ const before = removedIndex > 0 ? budgeted[removedIndex - 1] : undefined;
83
+ // boundaryProtected 仅语义标记 (测试/日志用), 占位区间不变
84
+ const boundaryProtected = firstKept.role === 'tool' && before?.role === 'assistant';
85
+ if (boundaryProtected) { /* 保护生效: tool 前紧邻占位 assistant */ }
86
+ for (let i = 0; i < budgeted.length; i++) {
87
+ if (i < removedIndex) {
88
+ result.push({ ...budgeted[i], content: '[已裁减, Snip]', originalLength: budgeted[i].content.length, transform: 'snip' });
66
89
  }
67
90
  else {
68
- protectedToolChain = 0;
91
+ result.push(budgeted[i]);
69
92
  }
70
- // 如果在保护区内, 不裁
71
- if (i < budgeted.length - keepCount && budgeted.length - i > protectedToolChain) {
72
- removed++;
73
- if (removed <= toRemove) {
74
- result.unshift({ ...m, content: '[已裁减, Snip]', transform: 'snip' });
75
- continue;
76
- }
77
- }
78
- result.unshift(m);
79
93
  }
80
94
  // Step 3: 如果 tool result 仍然太长, 进一步截断
81
- return result.map(m => {
82
- if (m.role === 'tool' && m.content.length > maxToolResultChars) {
83
- return {
84
- ...m,
85
- content: m.content.slice(0, maxToolResultChars) + `\n[...工具结果截断]`,
86
- originalLength: m.content.length,
87
- transform: m.transform || 'snip-tool',
88
- };
89
- }
90
- return m;
91
- });
95
+ return trimToolResults(result);
92
96
  }
93
97
  /**
94
98
  * Phase 3: Context Collapse — 读时虚拟投影.
@@ -456,7 +456,7 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
456
456
  }, 600);
457
457
  return () => clearInterval(timer);
458
458
  }, [thinking]);
459
- return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(LogoBox, { width: terminalW }), _jsx(Messages, { msgs: msgs }), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) }), popupOpen && (tabState || mention) && (_jsx(MentionPopup, { title: popupTitle, items: filtered, sel: safeSel, width: terminalW, loading: loadingFiles })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "green", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, focus: !popupOpen, placeholder: "\u8F93\u5165\u6D88\u606F... @\u667A\u80FD\u4F53 /\u547D\u4EE4 #\u6587\u4EF6 \u00B7 Esc \u53CC\u51FB\u9000\u51FA \u00B7 /queue \u6392\u961F \u00B7 !\u7EC8\u7AEF\u547D\u4EE4" }, tiKey)] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "white", children: '─'.repeat(terminalW) }) })] }));
459
+ return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsxs(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", children: [_jsx(LogoBox, { width: terminalW }), _jsx(Messages, { msgs: msgs }), thinking && (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] }) }))] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "#c4d640", children: '─'.repeat(terminalW) }) }), _jsx(Box, { children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "#c4d640", children: '─'.repeat(terminalW) }) }), popupOpen && (tabState || mention) && (_jsx(MentionPopup, { title: popupTitle, items: filtered, sel: safeSel, width: terminalW, loading: loadingFiles })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "#c4d640", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, focus: !popupOpen, placeholder: "\u8F93\u5165\u6D88\u606F... @\u667A\u80FD\u4F53 /\u547D\u4EE4 #\u6587\u4EF6 \u00B7 Esc \u53CC\u51FB\u9000\u51FA \u00B7 /queue \u6392\u961F \u00B7 !\u7EC8\u7AEF\u547D\u4EE4" }, tiKey)] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: "#c4d640", children: '─'.repeat(terminalW) }) })] }));
460
460
  };
461
461
  export { InkApp };
462
462
  // ─── 启动 ────────────────────────────────────────────────────────────────────
@@ -287,10 +287,11 @@ export function renderMessageBox(opts) {
287
287
  const inner = Math.max(20, dispWidth(title) + 4, maxLine);
288
288
  const width = Math.min(termWidth() - 2, opts.width ?? inner + 4);
289
289
  const lines = [];
290
- lines.push(boxTop(`${color}${title}${RESET}`, width, RD));
290
+ // 2026-08-06: 边框改 bolloon 色系 (C_BORDER #3a3a36 暗色描边, 标题色不变)
291
+ lines.push(`${C_BORDER}${boxTop(`${color}${title}${RESET}`, width, RD)}${RESET}`);
291
292
  for (const l of wrapText(opts.body, width - 4))
292
- lines.push(boxRow(l, width, 'left', RD));
293
- lines.push(boxBottom(width, RD));
293
+ lines.push(`${C_BORDER}${boxRow(l, width, 'left', RD)}${RESET}`);
294
+ lines.push(`${C_BORDER}${boxBottom(width, RD)}${RESET}`);
294
295
  return lines.join('\n');
295
296
  }
296
297
  /** 取首条非空行作为预览 (按可见宽度截断, 加省略号) */
package/dist/index.js CHANGED
@@ -354,7 +354,6 @@ function rpcErr(code, msg) {
354
354
  // CLI with persistent bottom prompt
355
355
  // 2026-07-28: 改用 readline.createInterface + replReadline 循环
356
356
  let isRunning = false;
357
- let cliContextPct = 0;
358
357
  let queueMode = false;
359
358
  const pendingQueue = [];
360
359
  let cliStartTime = 0;
@@ -371,20 +370,53 @@ function fmtDuration(ms) {
371
370
  const h = Math.floor(m / 60);
372
371
  return `${h}h ${m % 60}m`;
373
372
  }
374
- /** 状态栏: 模型 当前智能体 ( channel) ⏱ 时间 │ 上下文进度条 (模块级, CLI/Web 共用) */
373
+ /** 2026-08-06: ContextManager 读上下文用量 (CLI 状态栏数据源, 失败退化 0/1M) */
374
+ function getCliCtxUsage() {
375
+ try {
376
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
377
+ const cm = require('./bootstrap/context-manager.js').getContextManager();
378
+ const u = cm.getUsage();
379
+ return {
380
+ // 保留浮点 (0-100), 由 buildContextBar 格式化 — round 会让 <0.5% 全变 0, 状态栏像死代码
381
+ pct: Math.min(100, u.pct * 100),
382
+ usedTokens: u.usedTokens,
383
+ maxTokens: u.maxTokens,
384
+ stage: u.stage,
385
+ };
386
+ }
387
+ catch {
388
+ return { pct: 0, usedTokens: 0, maxTokens: 1_000_000, stage: 'normal' };
389
+ }
390
+ }
391
+ /** 上下文进度条: 320k/1M │ [██████░░░░] 32% (bolloon 色系: #c4d640 主色) */
392
+ function buildContextBar(usage) {
393
+ const barLen = 10;
394
+ const filled = Math.min(barLen, Math.max(0, Math.round((usage.pct / 100) * barLen)));
395
+ const barColor = usage.stage === 'warning' || usage.stage === 'compressing' ? C_WARN : C_ACCENT;
396
+ const bar = `${C_DIM}[${RESET}${barColor}${'█'.repeat(filled)}${RESET}${C_DIM}${'░'.repeat(barLen - filled)}${RESET}${C_DIM}]${RESET}`;
397
+ const fmtK = (n) => (n >= 1_000_000 ? (n % 1_000_000 === 0 ? `${n / 1_000_000}M` : `${(n / 1_000_000).toFixed(1)}M`) : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
398
+ const usageTxt = `${C_TEXT}${fmtK(usage.usedTokens)}/${fmtK(usage.maxTokens)}${RESET}`;
399
+ // 百分比: >=10% 整数, >=1% 一位小数, <1% 两位小数 (1M 窗口下小 token 数也可见变化)
400
+ const pctTxt = usage.pct >= 10 ? `${Math.round(usage.pct)}%` : usage.pct >= 1 ? `${usage.pct.toFixed(1)}%` : `${usage.pct.toFixed(2)}%`;
401
+ let suffix = '';
402
+ if (usage.stage === 'warning')
403
+ suffix = ` ${C_WARN}⚠ 即将压缩${RESET}`;
404
+ else if (usage.stage === 'compressing')
405
+ suffix = ` ${C_WARN}🗜️ 压缩中...${RESET}`;
406
+ else if (usage.stage === 'compressed')
407
+ suffix = ` ${C_OK}✓ 已压缩${RESET}`;
408
+ return `${usageTxt} ${C_DIM}│${RESET} ${bar} ${barColor}${pctTxt}${RESET}${suffix}`;
409
+ }
410
+ /** 状态栏: 模型 │ 当前智能体 (含 channel) │ ⏱ 时间 │ 320k/1M │ [██████░░░░] 32% (bolloon 色系) */
375
411
  function getStatus() {
376
- const barLen = 12;
377
- const filled = Math.round((cliContextPct / 100) * barLen);
378
- const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
412
+ const usage = getCliCtxUsage();
379
413
  const agentPart = cliActiveChannelId ? `${cliAgentName} ${C_DIM}(ch:${cliActiveChannelId.slice(0, 10)})${RESET}` : cliAgentName;
380
- return `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${agentPart} \x1b[90m│\x1b[0m ⏱ ${fmtDuration(Date.now() - cliStartTime)}\x1b[90m │\x1b[0m ${bar} ${cliContextPct}%`;
414
+ return `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${agentPart} ${C_DIM}│${RESET} ⏱ ${C_TEXT}${fmtDuration(Date.now() - cliStartTime)}${RESET}${C_DIM} │${RESET} ${buildContextBar(usage)}`;
381
415
  }
382
416
  function statusBarLine() {
383
417
  const dur = cliStartTime ? fmtDuration(Date.now() - cliStartTime) : '0s';
384
- const barLen = 12;
385
- const filled = Math.round((cliContextPct / 100) * barLen);
386
- const bar = C_OK + '█'.repeat(filled) + C_DIM + '░'.repeat(barLen - filled) + RESET;
387
- return `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ ${C_ACCENT}${dur}${RESET} ${C_DIM}│${RESET} ${bar} ${C_DIM}${cliContextPct}%${RESET}`;
418
+ const usage = getCliCtxUsage();
419
+ return `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ${C_ACCENT}${dur}${RESET} ${C_DIM}│${RESET} ${buildContextBar(usage)}`;
388
420
  }
389
421
  async function startCLI(comm) {
390
422
  isRunning = true;
@@ -436,7 +468,8 @@ async function startCLI(comm) {
436
468
  /* 无 channels/active 记录时保持默认 */
437
469
  }
438
470
  // 进入 Ink TUI 输入循环
439
- const initialStatus = `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ 0s`;
471
+ // 2026-08-06: 初始状态栏也带上下文显示 (0/1M [░░░░░░░░░░] 0%)
472
+ const initialStatus = `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ 0s${C_DIM} │${RESET} ${buildContextBar(getCliCtxUsage())}`;
440
473
  startInk((text) => { processInput(text, comm); }, initialStatus, getStatus);
441
474
  // Wait on a promise that resolves on Ctrl+C / 双击 Esc
442
475
  // (ink-app 的 requestExit 调 __inkRequestExit → resolve, 清理后 process.exit)
@@ -678,14 +711,31 @@ async function processInput(input, comm) {
678
711
  catch { /* 非致命, 静默 */ }
679
712
  });
680
713
  }
681
- // 更新状态栏: 上下文进度
714
+ // 更新状态栏: 上下文进度 (2026-08-06: 每轮按当前 messageHistory 重算并写回 ContextManager,
715
+ // 保证状态栏按需更新 — 不依赖 pi-sdk loop 内部上报, 1s 定时器读到的一定是最新值)
682
716
  try {
683
- const msgLen = JSON.stringify(a.messageHistory ?? []).length;
684
- cliContextPct = Math.min(100, Math.round((msgLen / 240_000) * 100));
685
- const barLen = 12;
686
- const filled = Math.round((cliContextPct / 100) * barLen);
687
- const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
688
- const statusText = `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ ${fmtDuration(Date.now() - cliStartTime)} \x1b[90m│\x1b[0m ${msgLen.toLocaleString()}B/240K │ ${bar} ${cliContextPct}%`;
717
+ const { getContextManager } = await import('./bootstrap/context-manager.js');
718
+ const cm = getContextManager();
719
+ // context-compaction 的估算器 (与 pi-sdk estimateHistoryTokens 同源: 4 字符 ≈ 1 token)
720
+ const history = a.messageHistory ?? [];
721
+ let usedTokens = 0;
722
+ try {
723
+ const { estimateTokens } = require('./context-compaction/index.js');
724
+ usedTokens = estimateTokens(history);
725
+ }
726
+ catch {
727
+ usedTokens = Math.max(0, Math.round(JSON.stringify(history).length / 4));
728
+ }
729
+ // 写回数据源 — 状态栏/Web/任何订阅方都拿到新鲜值
730
+ const usage = cm.updateUsage(usedTokens);
731
+ const usageView = {
732
+ // 保留浮点 (0-100), buildContextBar 内部格式化
733
+ pct: Math.min(100, (usage.usedTokens / Math.max(1, usage.maxTokens)) * 100),
734
+ usedTokens: usage.usedTokens,
735
+ maxTokens: usage.maxTokens,
736
+ stage: usage.stage,
737
+ };
738
+ const statusText = `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ ${fmtDuration(Date.now() - cliStartTime)}${C_DIM} │${RESET} ${buildContextBar(usageView)}`;
689
739
  inkSetStatus(statusText);
690
740
  }
691
741
  catch { /* 降级容忍 */ }
@@ -1672,6 +1672,15 @@
1672
1672
  async function loadChannels() {
1673
1673
  try {
1674
1674
  const res = await fetch("/channels");
1675
+ const ct = res.headers.get("content-type") || "";
1676
+ if (!ct.includes("application/json")) {
1677
+ const text = await res.text().catch(() => "");
1678
+ if (!text.trim().startsWith("[") && !text.trim().startsWith("{")) {
1679
+ console.warn("[\u52A0\u8F7D\u9891\u9053] \u68C0\u6D4B\u5230 IPFS \u9759\u6001\u6A21\u5F0F (\u65E0\u540E\u7AEF server), \u529F\u80FD\u53D7\u9650");
1680
+ showStaticModeNotice();
1681
+ return;
1682
+ }
1683
+ }
1675
1684
  channels = await res.json();
1676
1685
  console.log("[\u52A0\u8F7D\u9891\u9053] \u4ECE\u670D\u52A1\u5668\u83B7\u53D6\u5230", channels.length, "\u4E2A\u9891\u9053");
1677
1686
  channels.forEach((ch, i) => {
@@ -1694,6 +1703,17 @@
1694
1703
  console.error("[\u52A0\u8F7D\u9891\u9053] \u5931\u8D25:", err);
1695
1704
  }
1696
1705
  }
1706
+ function showStaticModeNotice() {
1707
+ if (document.getElementById("ipfs-static-notice")) return;
1708
+ const notice = document.createElement("div");
1709
+ notice.id = "ipfs-static-notice";
1710
+ notice.style.cssText = "position:fixed;bottom:60px;left:50%;transform:translateX(-50%);z-index:9999;background:#1a1a18;border:1px solid #c4d640;color:#d8d8c8;padding:10px 16px;border-radius:8px;font-size:12px;box-shadow:0 4px 20px rgba(0,0,0,.5);max-width:560px;text-align:center;";
1711
+ notice.innerHTML = `\u{1F4E1} <b style="color:#c4d640">IPFS \u9759\u6001\u6A21\u5F0F</b> \u2014 \u6B64\u9875\u9762\u901A\u8FC7 IPNS \u4ECE\u53BB\u4E2D\u5FC3\u5316\u7F51\u7EDC\u52A0\u8F7D.<br>\u5B8C\u6574\u529F\u80FD (\u5BF9\u8BDD/\u5DE5\u5177/\u5224\u65AD\u529B) \u9700\u8FDE\u63A5\u672C\u5730 Bolloon server: <code style="color:#c4d640">bolloon --web</code>`;
1712
+ document.body.appendChild(notice);
1713
+ setTimeout(() => {
1714
+ notice.remove();
1715
+ }, 15e3);
1716
+ }
1697
1717
  var v3GlobalEventSource = null;
1698
1718
  function startV3GlobalSSE() {
1699
1719
  if (v3GlobalEventSource) return;
@@ -1937,6 +1957,19 @@ ${msg.text || ""}`, "ai", false, log);
1937
1957
  console.log(`[v3-friend] \u2705 ack \u6536\u5230: ${name} \u5DF2\u6536\u5230\u597D\u53CB\u7533\u8BF7`);
1938
1958
  showSimpleToast(`\u{1F4EC} ${name} \u5DF2\u6536\u5230\u4F60\u7684\u597D\u53CB\u7533\u8BF7, \u7B49\u5BF9\u65B9\u63A5\u53D7`);
1939
1959
  }
1960
+ } else if (msg.type === "context_event") {
1961
+ try {
1962
+ const evt = msg.evt || {};
1963
+ if (evt.type === "context.warning") {
1964
+ showSimpleToast(`\u26A0\uFE0F \u4E0A\u4E0B\u6587\u4F7F\u7528\u7387 ${Math.round((evt.usage?.pct || 0) * 100)}%, \u5373\u5C06\u81EA\u52A8\u538B\u7F29`);
1965
+ } else if (evt.type === "context.compress.start") {
1966
+ showSimpleToast(`\u{1F5DC}\uFE0F \u4E0A\u4E0B\u6587\u538B\u7F29\u5F00\u59CB (${(evt.beforeTokens || 0).toLocaleString()} tokens)`);
1967
+ } else if (evt.type === "context.compress.complete") {
1968
+ const s = evt.snapshot || {};
1969
+ showSimpleToast(`\u2713 \u4E0A\u4E0B\u6587\u5DF2\u538B\u7F29: ${((s.beforeTokens || 0) / 1e3).toFixed(0)}k \u2192 ${((s.afterTokens || 0) / 1e3).toFixed(0)}k tokens`);
1970
+ }
1971
+ } catch (ctxErr) {
1972
+ }
1940
1973
  }
1941
1974
  } catch (err) {
1942
1975
  console.error("[v3] \u5168\u5C40 SSE \u89E3\u6790\u5931\u8D25:", err);
@@ -6,20 +6,20 @@
6
6
  <title>Bolloon Agent</title>
7
7
 
8
8
  <!-- Favicon -->
9
- <link rel="icon" type="image/x-icon" href="/icons/favicon.ico">
10
- <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png">
11
- <link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png">
9
+ <link rel="icon" type="image/x-icon" href="./icons/favicon.ico">
10
+ <link rel="icon" type="image/png" sizes="32x32" href="./icons/favicon-32x32.png">
11
+ <link rel="icon" type="image/png" sizes="16x16" href="./icons/favicon-16x16.png">
12
12
 
13
13
  <!-- Apple Touch Icon -->
14
- <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
14
+ <link rel="apple-touch-icon" href="./icons/apple-touch-icon.png">
15
15
 
16
16
  <!-- PWA Manifest -->
17
- <link rel="manifest" href="/manifest.json">
17
+ <link rel="manifest" href="./manifest.json">
18
18
 
19
19
  <!-- 2026-06-11: 移除 Google Fonts + jsdelivr 外部 CDN 阻塞 — 在大陆/跨公网 timeout 拖慢首屏/返回主页
20
20
  字体: style.css 只用字面量 'JetBrains Mono', 系统有就用, 没有自动 fall back monospace
21
21
  marked/qrcode: 改 async 不阻塞 (下载失败时本地降级到 escape 文本, 不影响主聊天) -->
22
- <link rel="stylesheet" href="/style.css">
22
+ <link rel="stylesheet" href="./style.css">
23
23
  <script async src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
24
24
  <script async src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
25
25
  </head>
@@ -414,11 +414,11 @@
414
414
  </main>
415
415
  </div>
416
416
 
417
- <script type="module" src="/components/wallet-viem.mjs"></script>
418
- <script type="module" src="/components/p2p/index.js"></script>
419
- <script type="module" src="/ui/step-timeline.js"></script>
420
- <script type="module" src="/ui/message-renderer.js"></script>
417
+ <script type="module" src="./components/wallet-viem.mjs"></script>
418
+ <script type="module" src="./components/p2p/index.js"></script>
419
+ <script type="module" src="./ui/step-timeline.js"></script>
420
+ <script type="module" src="./ui/message-renderer.js"></script>
421
421
  <!-- 2026-07-06: client.js 含 import 语句 (safeChannelName 兜底), 必须 type="module" -->
422
- <script type="module" src="/client.js"></script>
422
+ <script type="module" src="./client.js"></script>
423
423
  </body>
424
424
  </html>
@@ -1644,6 +1644,20 @@ export async function createWebServer(port = 3000, options = {}) {
1644
1644
  console.warn('[ipfs] Kubo 自动安装失败 (非致命):', e?.message?.slice(0, 120));
1645
1645
  }
1646
1646
  })();
1647
+ // 2026-08-06: ContextManager 事件 → SSE broadcast (CLI/Web 状态栏实时同步)
1648
+ try {
1649
+ const { getContextManager } = await import('../bootstrap/context-manager.js');
1650
+ getContextManager().onEvent((evt) => {
1651
+ try {
1652
+ broadcast({ type: 'context_event', evt }, undefined);
1653
+ }
1654
+ catch { /* 广播失败静默 */ }
1655
+ });
1656
+ console.log('[context] ContextManager 事件已接入 SSE (context_event)');
1657
+ }
1658
+ catch (e) {
1659
+ console.warn('[context] ContextManager 事件接入失败 (非致命):', e?.message?.slice(0, 120));
1660
+ }
1647
1661
  // 2026-08-03: 初始化 MCP 适配器 (读 ~/.mcp.json, 自动握手发现工具).
1648
1662
  // 后台异步: 不阻塞启动, 失败静默 (agent 调 mcp_list_tools 时再触发).
1649
1663
  (async () => {
@@ -5745,6 +5759,18 @@ ${goalDesc}
5745
5759
  res.status(500).json({ error: err.message });
5746
5760
  }
5747
5761
  });
5762
+ // 2026-08-06: Context OS 资源管理 API — 上下文用量 + 最近一次压缩快照 (Web UI 同步)
5763
+ app.get('/api/context/usage', async (_req, res) => {
5764
+ try {
5765
+ const { getContextManager, loadLatestSnapshot } = await import('../bootstrap/context-manager.js');
5766
+ const usage = getContextManager().getUsage();
5767
+ const latest = await loadLatestSnapshot();
5768
+ res.json({ ok: true, usage, latestSnapshot: latest });
5769
+ }
5770
+ catch (err) {
5771
+ res.status(500).json({ error: err.message });
5772
+ }
5773
+ });
5748
5774
  app.get('/api/iroh/info', async (_req, res) => {
5749
5775
  if (!irohInitialized || !irohNodeInfo) {
5750
5776
  res.json({ initialized: false });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.3.34",
3
+ "version": "0.3.35",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",
@@ -46,7 +46,8 @@
46
46
  "test": "vitest run",
47
47
  "test:watch": "vitest",
48
48
  "test:pi-sdk": "tsx src/test/pi-sdk.test.ts",
49
- "postinstall": "node scripts/postinstall.js"
49
+ "postinstall": "node scripts/postinstall.js",
50
+ "verify:ipns": "tsx scripts/verify-ipns-pipeline.ts"
50
51
  },
51
52
  "workspaces": [
52
53
  "src/constraint-runtime"