@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.
@@ -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 — 读时虚拟投影.
@@ -251,6 +251,10 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
251
251
  const [tiKey, setTiKey] = useState(0);
252
252
  // 全局: 思考动画控制
253
253
  useEffect(() => {
254
+ // 2026-08-06: 防御 — 某些环境 (tsx/完整 CLI 初始化) 下 stdin 会处于 paused,
255
+ // 不恢复则 useInput 收不到任何输入 (实测 isPaused=true, listeners=0)
256
+ if (process.stdin.isPaused())
257
+ process.stdin.resume();
254
258
  globalThis.__inkSetThinking = (v) => setThinking(v);
255
259
  globalThis.__inkAppend = (line) => {
256
260
  setMsgs(prev => [...prev, line]);
@@ -308,6 +312,15 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
308
312
  acceptMention(it);
309
313
  return;
310
314
  }
315
+ if (key.return && filtered.length === 0) {
316
+ // 弹窗无匹配项: Enter = 提交当前输入 (否则 /channel 无参 + Enter 永远提交不了 — 2026-08-06)
317
+ const v = input.trim();
318
+ if (v)
319
+ onSubmit(v);
320
+ else
321
+ setDismissed(mentionKey);
322
+ return;
323
+ }
311
324
  if (key.escape) {
312
325
  if (tabState)
313
326
  setTabState(null);
@@ -346,11 +359,11 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
346
359
  });
347
360
  return;
348
361
  }
349
- if (_input && !key.ctrl && !key.meta) {
362
+ if (_input && !key.ctrl && !key.meta && !key.return) {
350
363
  setInput(cur => cur + _input);
351
364
  return;
352
365
  }
353
- return; // 其余键忽略
366
+ return; // 其余键忽略 (return/tab/esc 等由 TextInput 或上层处理)
354
367
  }
355
368
  // ── 正常模式 ──
356
369
  // Tab 命令补齐 (无触发符的普通 token 也补)
@@ -443,7 +456,7 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
443
456
  }, 600);
444
457
  return () => clearInterval(timer);
445
458
  }, [thinking]);
446
- 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) }) })] }));
447
460
  };
448
461
  export { InkApp };
449
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,28 +354,69 @@ 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;
361
360
  let cliModelName = '…';
362
361
  let cliAgentName = '…';
362
+ let cliActiveChannelId = null;
363
363
  function fmtDuration(ms) {
364
364
  const s = Math.floor(ms / 1000);
365
+ if (s < 60)
366
+ return `${s}s`;
365
367
  const m = Math.floor(s / 60);
368
+ if (m < 60)
369
+ return `${m}m ${s % 60}s`;
366
370
  const h = Math.floor(m / 60);
367
- if (h > 0)
368
- return `${h}h${m % 60}m`;
369
- if (m > 0)
370
- return `${m}m${s % 60}s`;
371
- return `${s}s`;
371
+ return `${h}h ${m % 60}m`;
372
+ }
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 色系) */
411
+ function getStatus() {
412
+ const usage = getCliCtxUsage();
413
+ const agentPart = cliActiveChannelId ? `${cliAgentName} ${C_DIM}(ch:${cliActiveChannelId.slice(0, 10)})${RESET}` : cliAgentName;
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)}`;
372
415
  }
373
416
  function statusBarLine() {
374
417
  const dur = cliStartTime ? fmtDuration(Date.now() - cliStartTime) : '0s';
375
- const barLen = 12;
376
- const filled = Math.round((cliContextPct / 100) * barLen);
377
- const bar = C_OK + '█'.repeat(filled) + C_DIM + '░'.repeat(barLen - filled) + RESET;
378
- 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)}`;
379
420
  }
380
421
  async function startCLI(comm) {
381
422
  isRunning = true;
@@ -412,14 +453,23 @@ async function startCLI(comm) {
412
453
  cliModelName = foundProvider ? foundProvider[1] : '未配置';
413
454
  cliAgentName = agentIdentity?.name || 'bolloon';
414
455
  cliStartTime = Date.now();
456
+ // 恢复上次 active channel (session 恢复: CLI 与 Web 共用 active-channel.json)
457
+ try {
458
+ const { getIdentityStore } = await import('./agents/agent-identity-store.js');
459
+ const store = getIdentityStore();
460
+ await store.load();
461
+ const active = await store.getActive();
462
+ if (active) {
463
+ cliAgentName = active.name;
464
+ cliActiveChannelId = active.channelId ?? null;
465
+ }
466
+ }
467
+ catch {
468
+ /* 无 channels/active 记录时保持默认 */
469
+ }
415
470
  // 进入 Ink TUI 输入循环
416
- const initialStatus = `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ 0s`;
417
- const getStatus = () => {
418
- const barLen = 12;
419
- const filled = Math.round((cliContextPct / 100) * barLen);
420
- const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
421
- return `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ ${fmtDuration(Date.now() - cliStartTime)}\x1b[90m │\x1b[0m ${bar} ${cliContextPct}%`;
422
- };
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())}`;
423
473
  startInk((text) => { processInput(text, comm); }, initialStatus, getStatus);
424
474
  // Wait on a promise that resolves on Ctrl+C / 双击 Esc
425
475
  // (ink-app 的 requestExit 调 __inkRequestExit → resolve, 清理后 process.exit)
@@ -461,6 +511,49 @@ async function processInput(input, comm) {
461
511
  appendLine(`${C_DIM}──${RESET}`);
462
512
  return;
463
513
  }
514
+ // /channel — 切换当前智能体 (agent channel), 参数 name/id/number 自动解析
515
+ if (trimmed.toLowerCase().startsWith('/channel')) {
516
+ const q = trimmed.slice('/channel'.length).trim();
517
+ try {
518
+ const { getIdentityStore } = await import('./agents/agent-identity-store.js');
519
+ const store = getIdentityStore();
520
+ await store.load();
521
+ if (!q) {
522
+ // 无参: 列出所有 channel + active
523
+ const list = await store.listForDisplay();
524
+ const active = await store.getActive();
525
+ if (list.length === 0) {
526
+ appendLine(`${C_DIM}暂无智能体 channel (channels.json 为空)${RESET}`);
527
+ return;
528
+ }
529
+ appendLine(`${C_ACCENT}智能体列表:${RESET} (${active ? `当前: ${active.name}` : ''})`);
530
+ for (const { index, identity, active: isActive } of list) {
531
+ const mark = isActive ? '●' : '○';
532
+ appendLine(` ${mark} ${index}. ${identity.name} ${C_DIM}${identity.id.slice(0, 24)}${RESET}`);
533
+ }
534
+ appendLine(`${C_DIM}用法: /channel <名字|id|序号>${RESET}`);
535
+ return;
536
+ }
537
+ const r = await store.resolve(q);
538
+ if (!r) {
539
+ appendLine(`${C_ERROR}未找到智能体: '${q}'${RESET} (可用 /channel 查看列表)`);
540
+ return;
541
+ }
542
+ const prev = await store.getActive();
543
+ await store.setActive(r.channel.id);
544
+ cliAgentName = r.identity.name;
545
+ cliActiveChannelId = r.channel.id;
546
+ inkSetStatus(getStatus()); // 触发状态栏立即重绘 (无需等 1s 定时器)
547
+ const extra = prev && prev.name !== r.identity.name ? ` (从 ${prev.name} 切换)` : '';
548
+ appendLine(`${C_ACCENT}→ 当前智能体: ${r.identity.name}${RESET}${extra}`);
549
+ appendLine(`${C_DIM} channel: ${r.channel.id} [${r.match}]${RESET}`);
550
+ appendLine(`${C_DIM} persona: ${r.channel.persona?.description || r.channel.persona?.personality || '无'}${RESET}`);
551
+ }
552
+ catch (e) {
553
+ appendLine(`${C_ERROR}/channel 失败: ${String(e.message || e).slice(0, 200)}${RESET}`);
554
+ }
555
+ return;
556
+ }
464
557
  // /queue — 切换队列模式
465
558
  if (trimmed.toLowerCase() === '/queue') {
466
559
  queueMode = !queueMode;
@@ -493,6 +586,7 @@ async function processInput(input, comm) {
493
586
  appendLine(` ${C_ACCENT}!<cmd>${RESET} 执行终端命令 ${C_DIM}如 !ls -la${RESET}`);
494
587
  appendLine(` ${C_ACCENT}/queue${RESET} 切换队列模式 ${C_DIM}输入排队, 当前结束后自动执行${RESET}`);
495
588
  appendLine(` ${C_ACCENT}/dequeue${RESET} 出队一条`);
589
+ appendLine(` ${C_ACCENT}/channel [名字|id|序号]${RESET} 切换当前智能体 ${C_DIM}无参列出所有; 支持名字/ID/序号三种解析${RESET}`);
496
590
  appendLine(` ${C_ACCENT}@名字${RESET} @ 命中智能体 ${C_DIM}弹出窗选择后发送给智能体${RESET}`);
497
591
  appendLine(` ${C_ACCENT}/名字${RESET} / 命中命令/技能/插件 ${C_DIM}输入 / 自动弹出${RESET}`);
498
592
  appendLine(` ${C_ACCENT}#路径${RESET} # 命中文件 ${C_DIM}输入 # 自动弹出文件列表${RESET}`);
@@ -617,14 +711,31 @@ async function processInput(input, comm) {
617
711
  catch { /* 非致命, 静默 */ }
618
712
  });
619
713
  }
620
- // 更新状态栏: 上下文进度
714
+ // 更新状态栏: 上下文进度 (2026-08-06: 每轮按当前 messageHistory 重算并写回 ContextManager,
715
+ // 保证状态栏按需更新 — 不依赖 pi-sdk loop 内部上报, 1s 定时器读到的一定是最新值)
621
716
  try {
622
- const msgLen = JSON.stringify(a.messageHistory ?? []).length;
623
- cliContextPct = Math.min(100, Math.round((msgLen / 240_000) * 100));
624
- const barLen = 12;
625
- const filled = Math.round((cliContextPct / 100) * barLen);
626
- const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
627
- 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)}`;
628
739
  inkSetStatus(statusText);
629
740
  }
630
741
  catch { /* 降级容忍 */ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * agent-tools.ts — OrbitDB/CID 数据层的 agent 工具注册 (2026-08-06)
3
+ *
4
+ * 注册 10 个工具 (懒加载: 首次调用才拉起 helia, 不阻塞启动):
5
+ * cid_save / cid_load / cid_update / cid_version / cid_list / cid_share
6
+ * context_save_snapshot / context_restore
7
+ * ui_save_component / ui_load_component
8
+ *
9
+ * 对应 Step 7 能力: agent.createMemory (cid_save type=memory),
10
+ * context.saveSnapshot (context_save_snapshot), ui.loadCID (ui_load_component),
11
+ * share(cid) (cid_share).
12
+ */
13
+ /** 包装: 动态 import cid-database (首调拉起 helia) */
14
+ async function db() {
15
+ const { getCIDDatabase } = await import('./cid-database.js');
16
+ return getCIDDatabase();
17
+ }
18
+ async function contextStore() {
19
+ const { getContextStore } = await import('./context-store.js');
20
+ return getContextStore();
21
+ }
22
+ async function uiStore() {
23
+ const { getUICidStore } = await import('./ui-cid.js');
24
+ return getUICidStore();
25
+ }
26
+ const fmt = (o) => JSON.stringify(o, null, 2).slice(0, 3000);
27
+ export function registerOrbitdbTools(ctx) {
28
+ ctx.tools.set('cid_save', {
29
+ name: 'cid_save',
30
+ description: '保存数据到去中心化数据库 (OrbitDB), 返回内容寻址 CID. type 支持 memory/context/state/ui/knowledge. 同内容同 CID, 可用 cid_load 读回、cid_share 分享、cid_update 更新版本.',
31
+ parameters: { agentId: '所属智能体 id (必填)', type: '记录类型: memory/context/state/ui/knowledge (必填)', content: '要保存的数据 (JSON 对象, 必填)', metadata: '可选元数据 (JSON 对象)' },
32
+ execute: async (args) => {
33
+ try {
34
+ const agentId = String(args.agentId || '').trim();
35
+ const type = String(args.type || '').trim();
36
+ if (!agentId || !type)
37
+ return { success: false, error: 'agentId 和 type 必填' };
38
+ const content = args.content ?? {};
39
+ const metadata = typeof args.metadata === 'object' && args.metadata ? args.metadata : undefined;
40
+ const rec = await (await db()).save({ agentId, type: type, content, metadata });
41
+ return { success: true, output: `✅ 已保存 (type=${type}):\n CID: ${rec.id}\n version: ${rec.version}\n 读回: cid_load(cid="${rec.id}")\n 分享: cid_share(cid="${rec.id}")` };
42
+ }
43
+ catch (e) {
44
+ return { success: false, error: `cid_save 失败: ${String(e.message || e).slice(0, 200)}` };
45
+ }
46
+ }
47
+ });
48
+ ctx.tools.set('cid_load', {
49
+ name: 'cid_load',
50
+ description: '按 CID 从去中心化数据库加载记录 (含跨节点分享的). 返回完整记录: id/agentId/timestamp/type/content/metadata/version.',
51
+ parameters: { cid: '记录 CID (必填)' },
52
+ execute: async (args) => {
53
+ try {
54
+ const cid = String(args.cid || '').trim();
55
+ if (!cid)
56
+ return { success: false, error: 'cid 必填' };
57
+ const rec = await (await db()).load(cid);
58
+ if (!rec)
59
+ return { success: false, error: `记录不存在: ${cid}` };
60
+ return { success: true, output: `📄 ${cid}\n${fmt(rec)}` };
61
+ }
62
+ catch (e) {
63
+ return { success: false, error: `cid_load 失败: ${String(e.message || e).slice(0, 200)}` };
64
+ }
65
+ }
66
+ });
67
+ ctx.tools.set('cid_update', {
68
+ name: 'cid_update',
69
+ description: '更新记录 → 生成新版本 (parentId 指向旧 CID). 用 cid_version 查看版本链.',
70
+ parameters: { cid: '旧记录 CID (必填)', content: '新内容 (JSON, 必填)', metadata: '可选新元数据' },
71
+ execute: async (args) => {
72
+ try {
73
+ const cid = String(args.cid || '').trim();
74
+ if (!cid)
75
+ return { success: false, error: 'cid 必填' };
76
+ const rec = await (await db()).update(cid, args.content ?? {}, typeof args.metadata === 'object' && args.metadata ? args.metadata : undefined);
77
+ if (!rec)
78
+ return { success: false, error: `记录不存在: ${cid}` };
79
+ return { success: true, output: `✅ 已更新 v${rec.version}:\n 新 CID: ${rec.id}\n 版本链: cid_version(cid="${rec.id}")` };
80
+ }
81
+ catch (e) {
82
+ return { success: false, error: `cid_update 失败: ${String(e.message || e).slice(0, 200)}` };
83
+ }
84
+ }
85
+ });
86
+ ctx.tools.set('cid_version', {
87
+ name: 'cid_version',
88
+ description: '查看记录的完整版本链 (从旧到新).',
89
+ parameters: { cid: '任意版本 CID (必填)' },
90
+ execute: async (args) => {
91
+ try {
92
+ const cid = String(args.cid || '').trim();
93
+ if (!cid)
94
+ return { success: false, error: 'cid 必填' };
95
+ const chain = await (await db()).version(cid);
96
+ if (chain.length === 0)
97
+ return { success: false, error: `记录不存在: ${cid}` };
98
+ return { success: true, output: `📚 版本链 (${chain.length} 个版本):\n${chain.map(r => ` v${r.version} ${r.id} ${new Date(r.timestamp).toISOString()}${r.parentId ? '' : ' (根)'}`).join('\n')}` };
99
+ }
100
+ catch (e) {
101
+ return { success: false, error: `cid_version 失败: ${String(e.message || e).slice(0, 200)}` };
102
+ }
103
+ }
104
+ });
105
+ ctx.tools.set('cid_list', {
106
+ name: 'cid_list',
107
+ description: '列出数据库记录 (可按 agentId / type 过滤). 适合查看某个智能体的全部记忆/状态/组件.',
108
+ parameters: { agentId: '可选: 按智能体过滤', type: '可选: 按类型过滤 (memory/context/state/ui/knowledge)' },
109
+ execute: async (args) => {
110
+ try {
111
+ const agentId = String(args.agentId || '').trim() || undefined;
112
+ const type = String(args.type || '').trim() || undefined;
113
+ const list = await (await db()).list({ agentId, type });
114
+ if (list.length === 0)
115
+ return { success: true, output: '📭 无记录' };
116
+ return { success: true, output: `📋 ${list.length} 条记录:\n${list.slice(-20).map(r => ` v${r.version} [${r.type}] ${r.id.slice(0, 24)}… ${new Date(r.timestamp).toISOString().slice(0, 19)}`).join('\n')}` };
117
+ }
118
+ catch (e) {
119
+ return { success: false, error: `cid_list 失败: ${String(e.message || e).slice(0, 200)}` };
120
+ }
121
+ }
122
+ });
123
+ ctx.tools.set('cid_share', {
124
+ name: 'cid_share',
125
+ description: '分享记录: 把记录块写入 IPFS 网络 (helia blockstore), 返回 bolloon-cid:// 引用, 其他节点可用 cid_load 拉取.',
126
+ parameters: { cid: '记录 CID (必填)' },
127
+ execute: async (args) => {
128
+ try {
129
+ const cid = String(args.cid || '').trim();
130
+ if (!cid)
131
+ return { success: false, error: 'cid 必填' };
132
+ const ref = await (await db()).share(cid);
133
+ return { success: true, output: `🔗 已分享: ${ref}` };
134
+ }
135
+ catch (e) {
136
+ return { success: false, error: `cid_share 失败: ${String(e.message || e).slice(0, 200)}` };
137
+ }
138
+ }
139
+ });
140
+ ctx.tools.set('context_save_snapshot', {
141
+ name: 'context_save_snapshot',
142
+ description: '保存 Context OS 快照: 抓取当前资产层 + 可选记忆摘要/focus → CID 版本化. 用 context_restore 恢复.',
143
+ parameters: { agentId: '智能体 id (必填)', memorySummary: '可选: 当前记忆摘要', focus: '可选: 当前 focus' },
144
+ execute: async (args) => {
145
+ try {
146
+ const agentId = String(args.agentId || '').trim();
147
+ if (!agentId)
148
+ return { success: false, error: 'agentId 必填' };
149
+ const store = await contextStore();
150
+ const snap = await store.captureCurrentContext(agentId, {
151
+ memorySummary: String(args.memorySummary || '').trim() || undefined,
152
+ focus: String(args.focus || '').trim() || undefined,
153
+ });
154
+ const rec = await store.saveSnapshot(snap);
155
+ return { success: true, output: `📸 Context 快照已保存:\n CID: ${rec.id}\n layers: ${Object.keys(snap.layers).filter(k => snap.layers[k].length).length} 层有资产\n 恢复: context_restore(agentId="${agentId}")` };
156
+ }
157
+ catch (e) {
158
+ return { success: false, error: `context_save_snapshot 失败: ${String(e.message || e).slice(0, 200)}` };
159
+ }
160
+ }
161
+ });
162
+ ctx.tools.set('context_restore', {
163
+ name: 'context_restore',
164
+ description: '恢复某智能体最近一次 Context 快照 (含资产层/记忆摘要/focus). 用于跨会话恢复上下文.',
165
+ parameters: { agentId: '智能体 id (必填)' },
166
+ execute: async (args) => {
167
+ try {
168
+ const agentId = String(args.agentId || '').trim();
169
+ if (!agentId)
170
+ return { success: false, error: 'agentId 必填' };
171
+ const snap = await (await contextStore()).restoreContext(agentId);
172
+ if (!snap)
173
+ return { success: false, error: `无快照: ${agentId}` };
174
+ return { success: true, output: `♻️ 已恢复 ${agentId} 的 Context 快照 (${new Date(snap.capturedAt).toISOString()}):\n${fmt(snap).slice(0, 2500)}` };
175
+ }
176
+ catch (e) {
177
+ return { success: false, error: `context_restore 失败: ${String(e.message || e).slice(0, 200)}` };
178
+ }
179
+ }
180
+ });
181
+ ctx.tools.set('ui_save_component', {
182
+ name: 'ui_save_component',
183
+ description: '保存 UI 组件到去中心化存储 (CID 化). code 是 React 函数组件源码 (function Name(props){ return React.createElement(...) }), framework 默认 react. 之后可用 ui_load_component 按 CID 动态加载渲染.',
184
+ parameters: { agentId: '智能体 id (必填)', name: '组件名 (必填)', code: 'React 组件源码 (必填)', framework: '可选: react/vanilla', theme: '可选: 主题 JSON', description: '可选: 组件说明' },
185
+ execute: async (args) => {
186
+ try {
187
+ const agentId = String(args.agentId || '').trim();
188
+ const name = String(args.name || '').trim();
189
+ const code = String(args.code || '').trim();
190
+ if (!agentId || !name || !code)
191
+ return { success: false, error: 'agentId/name/code 必填' };
192
+ const rec = await (await uiStore()).saveComponent(agentId, {
193
+ name,
194
+ code,
195
+ framework: args.framework === 'vanilla' ? 'vanilla' : 'react',
196
+ theme: typeof args.theme === 'object' && args.theme ? args.theme : undefined,
197
+ description: String(args.description || '').trim() || undefined,
198
+ });
199
+ return { success: true, output: `🖼️ UI 组件已保存: ${name}\n CID: ${rec.id}\n 加载: ui_load_component(cid="${rec.id}")` };
200
+ }
201
+ catch (e) {
202
+ return { success: false, error: `ui_save_component 失败: ${String(e.message || e).slice(0, 200)}` };
203
+ }
204
+ }
205
+ });
206
+ ctx.tools.set('ui_load_component', {
207
+ name: 'ui_load_component',
208
+ description: '按 CID 加载 UI 组件定义 (代码/theme/propsSchema). 配合 React 前端可动态渲染.',
209
+ parameters: { cid: '组件 CID (必填)' },
210
+ execute: async (args) => {
211
+ try {
212
+ const cid = String(args.cid || '').trim();
213
+ if (!cid)
214
+ return { success: false, error: 'cid 必填' };
215
+ const def = await (await uiStore()).loadComponent(cid);
216
+ if (!def)
217
+ return { success: false, error: `组件不存在: ${cid}` };
218
+ return { success: true, output: `🖼️ ${def.name} (${def.framework})\n CID: ${cid}\n theme: ${def.theme ? fmt(def.theme) : '无'}\n code:\n${String(def.code).slice(0, 1500)}` };
219
+ }
220
+ catch (e) {
221
+ return { success: false, error: `ui_load_component 失败: ${String(e.message || e).slice(0, 200)}` };
222
+ }
223
+ }
224
+ });
225
+ }