@bolloon/bolloon-agent 0.3.34 → 0.3.36
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.
- package/dist/agents/pi-sdk-tools.js +34 -5
- package/dist/agents/pi-sdk.js +98 -12
- package/dist/bootstrap/context-manager.js +166 -0
- package/dist/bootstrap/memory-compressor.js +26 -16
- package/dist/bootstrap/snip-collapse.js +39 -35
- package/dist/cli/ink-app.js +44 -1
- package/dist/cli/loading-tui.js +4 -3
- package/dist/cli/mention-data.js +23 -0
- package/dist/cli-entry.js +113 -6
- package/dist/index.js +494 -18
- package/dist/web/client.js +33 -0
- package/dist/web/index.html +11 -11
- package/dist/web/server.js +26 -0
- package/package.json +3 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}` };
|
|
@@ -2103,7 +2131,8 @@ async function ensureKuboReady() {
|
|
|
2103
2131
|
}
|
|
2104
2132
|
}
|
|
2105
2133
|
}
|
|
2106
|
-
|
|
2134
|
+
/** Kubo HTTP API helper (POST only, 2026-08-03 实测 Kubo 只接受 POST). 2026-08-06 export 供 CLI /ipfs /ipns 命令用. */
|
|
2135
|
+
export async function kuboApi(pathAndQuery, init, timeoutMs = 30000) {
|
|
2107
2136
|
const controller = new AbortController();
|
|
2108
2137
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2109
2138
|
try {
|
package/dist/agents/pi-sdk.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
1127
|
+
const overflowDecision = decideContextOverflow(estimatedTokens, this.maxContextTokens());
|
|
1112
1128
|
if (overflowDecision.shouldExit) {
|
|
1113
|
-
console.warn(`[PiAgent] context overflow (${estimatedTokens} tokens > ${this.
|
|
1114
|
-
onStream?.({ type: 'error', content: `⏹️ 上下文溢出 (${estimatedTokens} tokens, 阈值 ${this.
|
|
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
|
-
//
|
|
1739
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
109
|
-
if (typeof
|
|
110
|
-
throw new Error('
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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 — 读时虚拟投影.
|
package/dist/cli/ink-app.js
CHANGED
|
@@ -62,6 +62,21 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
62
62
|
const skillCache = useRef(null);
|
|
63
63
|
const pluginCache = useRef(null);
|
|
64
64
|
const fileCache = useRef(null);
|
|
65
|
+
// ── 程序化选择器 (2026-08-06: /login /model 等命令触发, 复用 MentionPopup 渲染) ──
|
|
66
|
+
const [picker, setPicker] = useState(null);
|
|
67
|
+
const pickerCb = useRef(null);
|
|
68
|
+
const pickerSelRef = useRef(0);
|
|
69
|
+
// 全局钩子: index.ts 命令打开/关闭选择器
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
globalThis.__inkOpenPicker = (itemsArg, title, onPick) => {
|
|
72
|
+
pickerSelRef.current = 0;
|
|
73
|
+
pickerCb.current = onPick;
|
|
74
|
+
setPicker({ title: title || '选择', items: itemsArg, sel: 0 });
|
|
75
|
+
setInput('');
|
|
76
|
+
};
|
|
77
|
+
globalThis.__inkClosePicker = () => { pickerCb.current = null; setPicker(null); };
|
|
78
|
+
return () => { delete globalThis.__inkOpenPicker; delete globalThis.__inkClosePicker; };
|
|
79
|
+
}, []);
|
|
65
80
|
// ── 输入历史 (↑/↓ 切换) ───────────────────────────────────────────────────
|
|
66
81
|
const historyRef = useRef([]);
|
|
67
82
|
const historyIdxRef = useRef(-1); // -1 = 正在编辑新草稿
|
|
@@ -296,6 +311,34 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
296
311
|
requestExit();
|
|
297
312
|
return;
|
|
298
313
|
}
|
|
314
|
+
// ── 程序化选择器: 全键接管 (↑↓ 选择, Enter 确认, Esc 关闭) ──
|
|
315
|
+
if (picker) {
|
|
316
|
+
const itemsArg = picker.items;
|
|
317
|
+
if (key.upArrow) {
|
|
318
|
+
pickerSelRef.current = Math.max(0, pickerSelRef.current - 1);
|
|
319
|
+
setPicker({ ...picker, sel: pickerSelRef.current });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (key.downArrow) {
|
|
323
|
+
pickerSelRef.current = Math.min(itemsArg.length - 1, pickerSelRef.current + 1);
|
|
324
|
+
setPicker({ ...picker, sel: pickerSelRef.current });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if ((key.return || key.tab) && itemsArg.length > 0) {
|
|
328
|
+
const it = itemsArg[Math.min(pickerSelRef.current, itemsArg.length - 1)];
|
|
329
|
+
const cb = pickerCb.current;
|
|
330
|
+
pickerCb.current = null;
|
|
331
|
+
setPicker(null);
|
|
332
|
+
cb?.(it);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (key.escape) {
|
|
336
|
+
pickerCb.current = null;
|
|
337
|
+
setPicker(null);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
return; // 其余键忽略
|
|
341
|
+
}
|
|
299
342
|
// ── 弹出窗打开: 全键接管 (TextInput focus=false 不处理) ──
|
|
300
343
|
if (popupOpen) {
|
|
301
344
|
if (key.upArrow) {
|
|
@@ -456,7 +499,7 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
|
|
|
456
499
|
}, 600);
|
|
457
500
|
return () => clearInterval(timer);
|
|
458
501
|
}, [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: "
|
|
502
|
+
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 })), picker && (_jsx(MentionPopup, { title: picker.title, items: picker.items, sel: Math.min(picker.sel, picker.items.length - 1), width: terminalW })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "#c4d640", children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, focus: !popupOpen && !picker, 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
503
|
};
|
|
461
504
|
export { InkApp };
|
|
462
505
|
// ─── 启动 ────────────────────────────────────────────────────────────────────
|