mocode-ai 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/core.js +22 -8
- package/dist/agent/index.js +66 -16
- package/dist/context/budget.js +22 -33
- package/dist/context/index.js +1 -1
- package/dist/context/lifecycle.js +1 -55
- package/dist/context/relevance.js +1 -62
- package/dist/context/utils.js +68 -0
- package/dist/repl/index.js +27 -21
- package/dist/rollback/index.js +1 -13
- package/dist/session/compact.js +9 -14
- package/dist/session/drop.js +1 -43
- package/dist/session/scheduler.js +3 -3
- package/dist/ui/batch.js +142 -75
- package/dist/ui/content.js +6 -0
- package/dist/ui/layout.js +24 -3
- package/dist/ui/markdown.js +5 -0
- package/package.json +1 -1
package/dist/agent/core.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// 与 index.ts 的关系:index.ts 的 runAgent = runAgentCore + TUI hooks 薄封装(行为不变)。
|
|
6
6
|
// spawn.ts 的 spawnAgent = runAgentCore + 静默 hooks(子 agent)。
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
|
-
import { chat, planChatTools, } from '../llm/index.js';
|
|
8
|
+
import { chat, estimateMessagesTokens, estimateToolSchemaTokens, planChatTools, } from '../llm/index.js';
|
|
9
9
|
import { executeTool, tools } from '../tools/registry.js';
|
|
10
10
|
import { checkPermission } from '../permissions/index.js';
|
|
11
11
|
import { getPlanDisabledTools } from '../tools/constants.js';
|
|
@@ -262,6 +262,16 @@ export async function runAgentCore(opts) {
|
|
|
262
262
|
}
|
|
263
263
|
contextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
|
|
264
264
|
addUsage(result.usage); // 本轮累计:onDone 摘要行 + AgentRunResult.usage 透传
|
|
265
|
+
// 校正系数:API 实测 prompt_tokens / 估算 token。
|
|
266
|
+
// 每次 chat 响应后刷新,让下次 evaluateBudget 用更接近真实的 actual。
|
|
267
|
+
// 钳位 [0.5, 2.0]:防单次异常值(极短回复 / 空 history)导致系数跳变。
|
|
268
|
+
if (result.usage?.promptTokens && result.usage.promptTokens > 100) {
|
|
269
|
+
const estimated = estimateMessagesTokens(history) + estimateToolSchemaTokens();
|
|
270
|
+
if (estimated > 100) {
|
|
271
|
+
const raw = result.usage.promptTokens / estimated;
|
|
272
|
+
contextState.correction = Math.max(0.5, Math.min(2.0, raw));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
265
275
|
hooks.onChatDone?.(); // 主 agent:spinner.stop()
|
|
266
276
|
// lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
|
|
267
277
|
onContextUpdate?.();
|
|
@@ -280,8 +290,9 @@ export async function runAgentCore(opts) {
|
|
|
280
290
|
function: { name: tc.name, arguments: tc.arguments },
|
|
281
291
|
})),
|
|
282
292
|
});
|
|
283
|
-
// 工具分组执行(保 tool_calls 原顺序):连续的只读工具(READ_TOOL_NAMES)
|
|
284
|
-
//
|
|
293
|
+
// 工具分组执行(保 tool_calls 原顺序):连续的只读工具(READ_TOOL_NAMES)成组并发——先一次性
|
|
294
|
+
// 渲染全部 header,让摘要在任何同步工具真正执行前立即可见;随后启动全部 executeTool,
|
|
295
|
+
// 再按原顺序逐个 await + 回灌结果。
|
|
285
296
|
// mutation(write_file/edit_file)及 run_command/use_skill 各为单步串行屏障——mutation 串行保
|
|
286
297
|
// recordMutation 调用序 = 回滚快照序(executeTool 内写前记 before 快照,同文件多次写需按序)。
|
|
287
298
|
// 渲染与 history 回灌一律按原顺序;并发只影响执行时序,tool_call_id 仍按序配对。
|
|
@@ -290,25 +301,28 @@ export async function runAgentCore(opts) {
|
|
|
290
301
|
let i = 0;
|
|
291
302
|
while (i < calls.length) {
|
|
292
303
|
if (READ_TOOL_NAMES.has(calls[i].name)) {
|
|
293
|
-
// 收集连续只读组(≥1)
|
|
294
|
-
//
|
|
304
|
+
// 收集连续只读组(≥1),并发执行:先渲染所有 header,再一次性启动所有
|
|
305
|
+
// (executeTool 调用即开始 I/O),最后按原顺序逐个 await + 回灌。
|
|
306
|
+
// 必须先 header 后 execute:todolist/grep 等同步快速工具会在 executeTool 返回 Promise 前
|
|
307
|
+
// 已经完成;若先 started.map,用户只能在工具完成后才看到摘要与其前面的换行。
|
|
295
308
|
// 异步工具(web_fetch 等)并发跑、总耗时 ≈ 最慢一个;同步工具(glob/grep)map 时已顺序跑完,await 即返。
|
|
296
309
|
let j = i;
|
|
297
310
|
while (j < calls.length && READ_TOOL_NAMES.has(calls[j].name))
|
|
298
311
|
j++;
|
|
299
312
|
const batch = calls.slice(i, j);
|
|
313
|
+
for (const tc of batch)
|
|
314
|
+
hooks.onToolHeader?.(tc);
|
|
315
|
+
hooks.onToolStart?.(batch[0].name);
|
|
300
316
|
const started = batch.map((tc) => executeTool(tc.name, tc.arguments, signal, { skipRollback, dropContext }));
|
|
301
317
|
for (let k = 0; k < batch.length; k++) {
|
|
302
318
|
const tc = batch[k];
|
|
303
|
-
hooks.onToolHeader?.(tc);
|
|
304
|
-
hooks.onToolStart?.(tc.name);
|
|
305
319
|
const output = await started[k];
|
|
306
|
-
hooks.onToolDone?.();
|
|
307
320
|
hooks.onToolResult?.(tc, output, null, null, 1); // 只读工具无 diff
|
|
308
321
|
// Thrashing:history 里附 hint(UI 已用干净 output 渲染,避免屏幕噪声)
|
|
309
322
|
const hint = recordAndHint(tc.name, tc.arguments);
|
|
310
323
|
pushToolResult(history, tc, hint ? `${output}${hint}` : output, relprune, lifecycle, scheduler);
|
|
311
324
|
}
|
|
325
|
+
hooks.onToolDone?.();
|
|
312
326
|
i = j;
|
|
313
327
|
}
|
|
314
328
|
else if (calls[i].name === 'task') {
|
package/dist/agent/index.js
CHANGED
|
@@ -25,9 +25,14 @@ function firstLineOf(ui) {
|
|
|
25
25
|
* 重构后改为累积到 BatchRenderer,onToolBatchEnd 时统一打摘要行;
|
|
26
26
|
* 展开/折叠由 BatchRenderer + 鼠标 release 决定,本函数不再直接写屏。 */
|
|
27
27
|
function writeToolHeader(tc) {
|
|
28
|
+
// 改文件工具是 batch 屏障:先收尾之前的普通工具,确保 mutation 永远独占一批。
|
|
29
|
+
if (isMutationTool(tc.name))
|
|
30
|
+
flushToolBatch();
|
|
28
31
|
if (!currentBatchId)
|
|
29
32
|
currentBatchId = batch.beginBatch();
|
|
30
33
|
batch.recordCall(currentBatchId, tc.name, summarizeToolCall(tc.name, tc.arguments));
|
|
34
|
+
// 第一条工具开始时立即落摘要;后续调用加入同一 batch,并原地刷新计数。
|
|
35
|
+
batch.showLiveBatch(currentBatchId, layout);
|
|
31
36
|
}
|
|
32
37
|
/** 渲染工具结果:mutation 成功走 diff 块(行号 + 语法高亮,仿 Claude Code);其余走一行 preview。
|
|
33
38
|
* 同 writeToolHeader,改为累积到 BatchRenderer(只缓存字符串,不写屏)。 */
|
|
@@ -48,6 +53,21 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
|
|
|
48
53
|
}
|
|
49
54
|
const preview = diff ? '' : summarizeToolResult(tc.name, output);
|
|
50
55
|
batch.recordResult(currentBatchId, tc.name, preview, diff, output);
|
|
56
|
+
// mutation 结果(成功 diff 或错误输出)立即可见,并阻止后续普通工具并入这一批。
|
|
57
|
+
if (isMutationTool(tc.name))
|
|
58
|
+
flushToolBatch(true);
|
|
59
|
+
}
|
|
60
|
+
/** 将跨 LLM 工具轮次累计的调用写入内容区;正文开始或整个 turn 收尾时才切批。 */
|
|
61
|
+
function flushToolBatch(expandSingleEntry = false) {
|
|
62
|
+
if (!currentBatchId)
|
|
63
|
+
return;
|
|
64
|
+
const id = currentBatchId;
|
|
65
|
+
currentBatchId = null;
|
|
66
|
+
batch.endBatch(id, layout);
|
|
67
|
+
if (expandSingleEntry)
|
|
68
|
+
batch.expandSingleEntryFully(id, layout);
|
|
69
|
+
// 摘要本身已有行尾;再补一行,保持工具与后续正文/状态摘要之间的原有间距。
|
|
70
|
+
layout.contentWrite('\n');
|
|
51
71
|
}
|
|
52
72
|
/**
|
|
53
73
|
* agent 核心循环(主 agent,TUI 渲染版):
|
|
@@ -79,19 +99,43 @@ onContextUpdate) {
|
|
|
79
99
|
// lastChar 镜像:core 跟踪流式末字符决定补换行,但 TUI hooks 需读它决定 layout.contentWrite('\n')。
|
|
80
100
|
// core 的 onTextEnd hook 只在 lastChar !== '\n' 时才调,调后置 '\n';镜像与此同步。
|
|
81
101
|
let lastChar = '';
|
|
102
|
+
// 正文 -> 工具的边界由 core.onTextEnd 与本层 onToolCall 分两段完成。
|
|
103
|
+
// markdown 段末已经是完整物理行,因此再写 1 个 \n 就代表 1 条空白行;
|
|
104
|
+
// 不能按普通字符串的“两个换行才有一个空行”来计算。
|
|
105
|
+
let textBoundaryNewlines = 0;
|
|
106
|
+
let hasPendingTextBoundary = false;
|
|
82
107
|
const hooks = {
|
|
83
108
|
onText: (s) => {
|
|
84
|
-
|
|
85
|
-
|
|
109
|
+
// 纯空白 chunk 在视觉上不是正文:既不切 batch,也不写入 markdown 缓冲。
|
|
110
|
+
// 部分兼容后端会在连续工具轮次间流出 " " / "\n",若据此切批会漏掉首个工具。
|
|
111
|
+
if (currentBatchId && s.trim().length === 0)
|
|
112
|
+
return;
|
|
113
|
+
const followsToolBatch = currentBatchId !== null;
|
|
114
|
+
// batch 收尾已经统一留了一条空白行。部分后端会把下一段正文以 \n / \n\n
|
|
115
|
+
// 开头发来;去掉这些“边界换行”,避免与 UI 分隔叠成两条空白行。
|
|
116
|
+
const visible = followsToolBatch ? s.replace(/^(?:[ \t]*\r?\n)+/, '') : s;
|
|
86
117
|
if (s)
|
|
87
|
-
|
|
118
|
+
flushToolBatch();
|
|
119
|
+
spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
|
|
120
|
+
layout.contentWriteMd(visible); // 正文走 markdown 渲染(代码块高亮 / 标题 / 列表 / 行内 …),见 ui/markdown.ts
|
|
121
|
+
if (visible) {
|
|
122
|
+
lastChar = visible[visible.length - 1];
|
|
123
|
+
if (visible.trim().length > 0) {
|
|
124
|
+
hasPendingTextBoundary = true;
|
|
125
|
+
textBoundaryNewlines = 0;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
88
128
|
},
|
|
89
129
|
onToolCall: (name) => {
|
|
90
130
|
// 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
|
|
91
131
|
// 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
|
|
92
|
-
if (
|
|
93
|
-
|
|
132
|
+
if (hasPendingTextBoundary) {
|
|
133
|
+
if (textBoundaryNewlines < 1) {
|
|
134
|
+
layout.contentWrite('\n');
|
|
135
|
+
}
|
|
94
136
|
lastChar = '\n';
|
|
137
|
+
textBoundaryNewlines = 1;
|
|
138
|
+
hasPendingTextBoundary = false;
|
|
95
139
|
}
|
|
96
140
|
if (name)
|
|
97
141
|
spinner.start(`生成 ${name}`);
|
|
@@ -102,6 +146,8 @@ onContextUpdate) {
|
|
|
102
146
|
if (lastChar && lastChar !== '\n') {
|
|
103
147
|
layout.contentWrite('\n');
|
|
104
148
|
lastChar = '\n';
|
|
149
|
+
if (hasPendingTextBoundary)
|
|
150
|
+
textBoundaryNewlines = 1;
|
|
105
151
|
}
|
|
106
152
|
},
|
|
107
153
|
onToolHeader: (tc) => writeToolHeader(tc),
|
|
@@ -109,27 +155,31 @@ onContextUpdate) {
|
|
|
109
155
|
onToolDone: () => spinner.stop(),
|
|
110
156
|
onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine),
|
|
111
157
|
onToolBatchEnd: () => {
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
158
|
+
// 一次工具轮次结束不再切 UI batch;下一轮若仍无正文,继续复用 currentBatchId。
|
|
159
|
+
},
|
|
160
|
+
onNoReply: () => {
|
|
161
|
+
flushToolBatch();
|
|
162
|
+
layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`);
|
|
163
|
+
},
|
|
164
|
+
onMaxSteps: () => {
|
|
165
|
+
flushToolBatch();
|
|
166
|
+
layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${config.maxSteps}),本轮停止。${ui.reset}\n`);
|
|
120
167
|
},
|
|
121
|
-
onNoReply: () => layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`),
|
|
122
|
-
onMaxSteps: () => layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${config.maxSteps}),本轮停止。${ui.reset}\n`),
|
|
123
168
|
onAbort: () => {
|
|
124
169
|
spinner.stop();
|
|
125
170
|
if (lastChar && lastChar !== '\n')
|
|
126
171
|
layout.contentWrite('\n');
|
|
172
|
+
flushToolBatch();
|
|
127
173
|
layout.contentWrite(`${ui.dim}(已中断)${ui.reset}\n`);
|
|
128
|
-
currentBatchId = null; // 丢弃未收尾 batch
|
|
129
174
|
},
|
|
130
175
|
onDone: (elapsedMs, usage) => {
|
|
176
|
+
flushToolBatch();
|
|
131
177
|
const tok = formatTurnTokens(usage);
|
|
132
178
|
layout.contentWrite(` ${ui.dim}✻ Worked for ${fmtElapsed(elapsedMs)}${tok}${ui.reset}\n`);
|
|
179
|
+
// 内容区触底时,DECSTBM 增量滚屏可能只推进物理终端,未把 Worked 前已在
|
|
180
|
+
// buffer 中的空行完整画出来;用户滚动/点击触发 repaint 后才“突然”出现。
|
|
181
|
+
// 轮次收尾立即按 buffer 原子重画,使未满屏与触底滚屏的布局一致。
|
|
182
|
+
layout.repaintViewport();
|
|
133
183
|
},
|
|
134
184
|
};
|
|
135
185
|
// 桌宠状态广播:与 TUI hooks 并列注入,互不干扰(petHooks 只调 bridge.sendState,不写屏;
|
package/dist/context/budget.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// 开关(MOCODE_BUDGET_SCHEDULER):默认 true。false 时 agent/core.ts 走老路径
|
|
26
26
|
// (直接 maybeCompact),完全跳过本模块,零行为变化。
|
|
27
27
|
import { estimateMessagesTokens, estimateTokens } from '../llm/index.js';
|
|
28
|
+
import { toText } from './utils.js';
|
|
28
29
|
/** 五区分账(占比对齐 CONTEXT_WINDOW)。顺序固定,便于遍历。 */
|
|
29
30
|
export const BUDGET_LAYERS = [
|
|
30
31
|
'system',
|
|
@@ -52,19 +53,6 @@ export const HOT_TURN_WINDOW = 4;
|
|
|
52
53
|
* Cold 区内:age ≥ TOOL_OLD_AGE 的非观察类工具结果可被调度器就地 stub。
|
|
53
54
|
* 默认 2 = 跨过 2 个消费者 push 仍未被消费,等同 lifecycle 的 DEFAULT_AGE_THRESHOLD。 */
|
|
54
55
|
export const TOOL_OLD_AGE = 2;
|
|
55
|
-
/** 把每条 token 拍平成字符串(只估 token,不深解析工具调用)。 */
|
|
56
|
-
function toText(content) {
|
|
57
|
-
if (content == null)
|
|
58
|
-
return '';
|
|
59
|
-
if (typeof content === 'string')
|
|
60
|
-
return content;
|
|
61
|
-
try {
|
|
62
|
-
return JSON.stringify(content);
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
return String(content);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
56
|
function msgTokens(m) {
|
|
69
57
|
const c = m.content;
|
|
70
58
|
const tcs = m.tool_calls;
|
|
@@ -75,14 +63,6 @@ function msgTokens(m) {
|
|
|
75
63
|
// 与 llm.estimateTokens 同公式(CJK 1/字,ASCII 1/4字);保证调度器评估与系统估算口径一致。
|
|
76
64
|
return 4 + estimateTokens(extra);
|
|
77
65
|
}
|
|
78
|
-
/** 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。 */
|
|
79
|
-
export function lastUserIndex(history) {
|
|
80
|
-
for (let i = history.length - 1; i >= 1; i--) {
|
|
81
|
-
if (history[i].role === 'user')
|
|
82
|
-
return i;
|
|
83
|
-
}
|
|
84
|
-
return -1;
|
|
85
|
-
}
|
|
86
66
|
/** 从 idx 处向前数第 N 个 user turn 的边界 index(含该 user 之后的内容)。
|
|
87
67
|
* 用于把 history 切成 Hot 区(tail 一段,endExclusive=history.length)与 Cold 区(0..endExclusive)。
|
|
88
68
|
* 若 N 个 user 不足,Hot 区 = history.length(全保护);Cold 区空,无压缩目标。 */
|
|
@@ -98,16 +78,19 @@ export function userTurnBoundary(history, window) {
|
|
|
98
78
|
return 1; // 没攒够 N 个 user 之前的全归 Cold(history[0] system 不动)
|
|
99
79
|
}
|
|
100
80
|
/** 评估当前 history 的五区预算(纯函数,改不动 history)。
|
|
101
|
-
* 传入 step 是当前所在 step 编号(agent 循环 step 变量),用于日志/调试。
|
|
102
|
-
|
|
81
|
+
* 传入 step 是当前所在 step 编号(agent 循环 step 变量),用于日志/调试。
|
|
82
|
+
* correction:API 实测 / 估算的校正系数(默认 1);>1 表示粗估偏低,乘以系数后 actual 更接近真实值。 */
|
|
83
|
+
export function evaluateBudget(history, window, step = 0, correction = 1) {
|
|
103
84
|
const layers = {};
|
|
104
85
|
for (const k of BUDGET_LAYERS) {
|
|
105
86
|
const budget = Math.floor(BUDGET_RATIO[k] * window);
|
|
106
87
|
layers[k] = { actual: 0, budget, overBudget: false, overRatio: 0 };
|
|
107
88
|
}
|
|
89
|
+
// 校正后的 token 数:raw * correction,最小 1(raw > 0 时)。
|
|
90
|
+
const adj = (raw) => (raw > 0 ? Math.max(1, Math.round(raw * correction)) : 0);
|
|
108
91
|
const sysMsg = history[0];
|
|
109
92
|
if (sysMsg)
|
|
110
|
-
layers.system.actual = msgTokens(sysMsg);
|
|
93
|
+
layers.system.actual = adj(msgTokens(sysMsg));
|
|
111
94
|
// Summary 检测:role:'system' 且不是 history[0] 的,视为摘要(compact.ts 摘要插 index 1)。
|
|
112
95
|
// 简单启发:若 history[1]?.role === 'system' 且 content 含「# 会话摘要」特征串,计入 summary。
|
|
113
96
|
// 命中时循环跳过 i=1;不命中时当作普通 message(罕见,落到下方 user/assistant 分支)。
|
|
@@ -115,7 +98,7 @@ export function evaluateBudget(history, window, step = 0) {
|
|
|
115
98
|
if (history.length > 1 && history[1].role === 'system') {
|
|
116
99
|
const c1 = toText(history[1].content);
|
|
117
100
|
if (c1.startsWith('# 会话摘要') || c1.includes('会话摘要')) {
|
|
118
|
-
layers.summary.actual = msgTokens(history[1]);
|
|
101
|
+
layers.summary.actual = adj(msgTokens(history[1]));
|
|
119
102
|
summaryHit = true;
|
|
120
103
|
}
|
|
121
104
|
}
|
|
@@ -127,7 +110,7 @@ export function evaluateBudget(history, window, step = 0) {
|
|
|
127
110
|
if (i === 1 && summaryHit)
|
|
128
111
|
continue; // summary 已单独算过
|
|
129
112
|
if (m.role === 'tool') {
|
|
130
|
-
const t = msgTokens(m);
|
|
113
|
+
const t = adj(msgTokens(m));
|
|
131
114
|
if (i >= hotStart)
|
|
132
115
|
layers.toolRecent.actual += t;
|
|
133
116
|
else
|
|
@@ -135,7 +118,7 @@ export function evaluateBudget(history, window, step = 0) {
|
|
|
135
118
|
}
|
|
136
119
|
else if (m.role !== 'system') {
|
|
137
120
|
// user / assistant 全部计入 history(对话轨迹)
|
|
138
|
-
layers.history.actual += msgTokens(m);
|
|
121
|
+
layers.history.actual += adj(msgTokens(m));
|
|
139
122
|
}
|
|
140
123
|
// 其它 system(几乎不存在)跳过
|
|
141
124
|
}
|
|
@@ -152,7 +135,8 @@ export function evaluateBudget(history, window, step = 0) {
|
|
|
152
135
|
// 按 overRatio 降序
|
|
153
136
|
triggers.sort((a, b) => layers[b].overRatio - layers[a].overRatio);
|
|
154
137
|
const total = BUDGET_LAYERS.reduce((s, k) => s + (k === 'reserve' ? 0 : layers[k].actual), 0);
|
|
155
|
-
|
|
138
|
+
// 安全裕量:用 0.82 而非 0.85,预留 3% 给 correction 波动与新消息增量。
|
|
139
|
+
const totalOver = total >= 0.82 * window;
|
|
156
140
|
return {
|
|
157
141
|
step,
|
|
158
142
|
total,
|
|
@@ -161,19 +145,24 @@ export function evaluateBudget(history, window, step = 0) {
|
|
|
161
145
|
triggers,
|
|
162
146
|
totalOver,
|
|
163
147
|
hotBoundary: hotStart,
|
|
148
|
+
correction,
|
|
164
149
|
};
|
|
165
150
|
}
|
|
166
|
-
/** 根据 BudgetReport 生成调度动作(
|
|
151
|
+
/** 根据 BudgetReport 生成调度动作(从轻到重,直至总占用回落到阈值以下)。
|
|
167
152
|
* 规则:
|
|
168
153
|
* - system 超 → warn(不压,配置问题不是内容问题)
|
|
169
154
|
* - toolOld 超 → 先 L1(中截超大)→ L2(same-path 已有 relevance)→ L3(age stub,新增)
|
|
170
155
|
* - toolRecent 超 → cap(只降低单条上限,不 stub)
|
|
171
156
|
* - history 超 或 totalOver → compact_history(调 maybeCompact / compactHistory)
|
|
172
|
-
* - summary 超 → 不动(摘要本身就压缩产物,删它等于丢历史,只能放任或扩 Recent 预算)
|
|
157
|
+
* - summary 超 → 不动(摘要本身就压缩产物,删它等于丢历史,只能放任或扩 Recent 预算)
|
|
158
|
+
*
|
|
159
|
+
* 安全裕量:headroom 按 0.80 * window - total * 1.05 计算(预留 5% 应对 correction 误差
|
|
160
|
+
* 与新消息增量),避免估算偏差导致被 API 硬截断。 */
|
|
173
161
|
export function scheduleActions(report) {
|
|
174
162
|
const actions = [];
|
|
175
163
|
const { layers, totalOver, total } = report;
|
|
176
|
-
|
|
164
|
+
// 收紧:0.80 阈值(原 0.85)+ total * 1.05 放大(估算不确定性缓冲)
|
|
165
|
+
const headroom = 0.80 * report.window - total * 1.05;
|
|
177
166
|
// system 超 → warn,不是 schedule 目标
|
|
178
167
|
if (layers.system.overBudget) {
|
|
179
168
|
actions.push({
|
|
@@ -196,8 +185,8 @@ export function scheduleActions(report) {
|
|
|
196
185
|
if (layers.toolRecent.overBudget && layers.toolRecent.overRatio > 0.15) {
|
|
197
186
|
actions.push({ kind: 'cap_hot_tools', aggressive: layers.toolRecent.overRatio > 0.5 });
|
|
198
187
|
}
|
|
199
|
-
// History / total 超 → 摘要(最贵);headroom < -
|
|
200
|
-
if ((layers.history.overBudget || totalOver) && headroom < -
|
|
188
|
+
// History / total 超 → 摘要(最贵);headroom < -1500 真正触发(原 -2000,裕量收紧后同步调低),让 cold tools 先动
|
|
189
|
+
if ((layers.history.overBudget || totalOver) && headroom < -1500) {
|
|
201
190
|
actions.push({ kind: 'compact_history' });
|
|
202
191
|
}
|
|
203
192
|
// 排序(同 kind 已在上面排好):warn → cold L1→L2→L3 → cap_hot → compact_history
|
package/dist/context/index.js
CHANGED
|
@@ -10,4 +10,4 @@ export { optimizeToolResult } from './pipeline.js';
|
|
|
10
10
|
export { classify, knownToolKinds } from './classifier.js';
|
|
11
11
|
export { registerEncoder, registerAll, getEncoder, registeredKinds, } from './registry.js';
|
|
12
12
|
// ── Context Budget Scheduler ───────────────────────────────────────────────
|
|
13
|
-
export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary,
|
|
13
|
+
export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, BUDGET_LAYERS, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// - mutation 分支额外调 pushMutation 通知(也走 observeMutation 同语义)。
|
|
27
27
|
//
|
|
28
28
|
// 开关:`config.contextLifecycle`(默认 true;MOCODE_LIFECYCLE=false 回退)。
|
|
29
|
+
import { extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
|
|
29
30
|
/** 观察类工具(永远只到 REFERENCED,不参与自动 STUB)。 */
|
|
30
31
|
const OBSERVER_TOOLS = new Set([
|
|
31
32
|
'grep',
|
|
@@ -45,61 +46,6 @@ const STUB_PREFIX_NO_CONSUMER = '⌦[无消费者:观察结果已无引用价值
|
|
|
45
46
|
* ≥ 这个值且仍为 LIVE 且非观察类 → 视为 OBSOLETE → STUB。
|
|
46
47
|
* 默认 2:等价于「跨过两个消费者 push 仍无人引用」= 跨过整轮最末尾的工具调用。 */
|
|
47
48
|
const DEFAULT_AGE_THRESHOLD = 2;
|
|
48
|
-
/** 复用 drop.ts 的取 path 思路,但本层要支持更多字段名(read_file/edit_file/write_file 都有 path;
|
|
49
|
-
* edit_file 还可能有 file_path,但这里只看 path,保持单一)。 */
|
|
50
|
-
function extractPath(argsRaw) {
|
|
51
|
-
if (!argsRaw)
|
|
52
|
-
return null;
|
|
53
|
-
let parsed;
|
|
54
|
-
try {
|
|
55
|
-
parsed = JSON.parse(argsRaw);
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
if (!parsed || typeof parsed !== 'object')
|
|
61
|
-
return null;
|
|
62
|
-
const p = parsed.path;
|
|
63
|
-
return typeof p === 'string' && p ? p : null;
|
|
64
|
-
}
|
|
65
|
-
/** 从 tool 消息往前找匹配的 assistant.tool_calls 拿 tool 名。找不到返 null(保守跳过)。 */
|
|
66
|
-
function toolNameOf(history, idx) {
|
|
67
|
-
const tcId = history[idx].tool_call_id;
|
|
68
|
-
if (!tcId)
|
|
69
|
-
return null;
|
|
70
|
-
for (let j = idx - 1; j >= 1; j--) {
|
|
71
|
-
const m = history[j];
|
|
72
|
-
if (m.role !== 'assistant')
|
|
73
|
-
continue;
|
|
74
|
-
const tcs = m.tool_calls;
|
|
75
|
-
if (!tcs)
|
|
76
|
-
continue;
|
|
77
|
-
const hit = tcs.find((tc) => tc?.id === tcId);
|
|
78
|
-
if (hit)
|
|
79
|
-
return hit.function?.name ?? null;
|
|
80
|
-
}
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
/** 找最后一个 user 消息索引;无 user 返 -1。 */
|
|
84
|
-
function lastUserIndex(history) {
|
|
85
|
-
for (let i = history.length - 1; i >= 1; i--) {
|
|
86
|
-
if (history[i].role === 'user')
|
|
87
|
-
return i;
|
|
88
|
-
}
|
|
89
|
-
return -1;
|
|
90
|
-
}
|
|
91
|
-
function toText(content) {
|
|
92
|
-
if (content == null)
|
|
93
|
-
return '';
|
|
94
|
-
if (typeof content === 'string')
|
|
95
|
-
return content;
|
|
96
|
-
try {
|
|
97
|
-
return JSON.stringify(content);
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
return String(content);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
49
|
/** 从 tool 结果的 content 中提取「生产者命中过的 path 列表」。
|
|
104
50
|
* - read_file:没有 path 列表(本身就是单 path 消费者,无需再追生产者)。
|
|
105
51
|
* - grep:content 是 `file:line: ...` 行,提取每行的 file 段(只保留绝对路径形态或与 pattern 匹配的)。
|
|
@@ -22,71 +22,10 @@
|
|
|
22
22
|
// - TUI 渲染(hooks.onToolResult)用原始 output,与本层解耦——屏上看全量,LLM 看裁剪后版。
|
|
23
23
|
//
|
|
24
24
|
// 零行为变化兜底:开关 `config.contextRelprune` 关闭时,pipeline 路径完全不调本模块。
|
|
25
|
+
import { extractPath, lastUserIndex, toText, toolNameOf } from './utils.js';
|
|
25
26
|
/** stub 标记前缀(供幂等判定)。drop_context 用的是「⌦[已剔除:与当前任务无关]」,
|
|
26
27
|
* 本层用「⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]」,区分两类剔除来源。 */
|
|
27
28
|
const STUB_PREFIX = '⌦[已过时:同 path 已有新 read / 已被 mutation 覆写]';
|
|
28
|
-
/** 解析工具 arguments(只关心 path);非法返 null。 */
|
|
29
|
-
function extractPath(argsRaw) {
|
|
30
|
-
if (!argsRaw)
|
|
31
|
-
return null;
|
|
32
|
-
let parsed;
|
|
33
|
-
try {
|
|
34
|
-
parsed = JSON.parse(argsRaw);
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
if (!parsed || typeof parsed !== 'object')
|
|
40
|
-
return null;
|
|
41
|
-
const p = parsed.path;
|
|
42
|
-
if (typeof p !== 'string' || !p)
|
|
43
|
-
return null;
|
|
44
|
-
return p;
|
|
45
|
-
}
|
|
46
|
-
/** 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。
|
|
47
|
-
* 复用 drop.ts 的思路:user 及其之后的 tool 结果视为当前轮保护区。
|
|
48
|
-
* 在本层里,read_file tool 消息若落在 user 之后,本轮还在用,不能 stub。
|
|
49
|
-
* 注:history[0] 是 system,user 不会落在 0;若 user 就在末尾(即 0 user 之后),
|
|
50
|
-
* protectedFrom=0 时整段历史都不可 stub(实际不会发生:pushToolResult 必在 user 之后)。 */
|
|
51
|
-
function lastUserIndex(history) {
|
|
52
|
-
for (let i = history.length - 1; i >= 1; i--) {
|
|
53
|
-
if (history[i].role === 'user')
|
|
54
|
-
return i;
|
|
55
|
-
}
|
|
56
|
-
return -1;
|
|
57
|
-
}
|
|
58
|
-
/** 取 tool 消息对应的工具名(从紧邻的前导 assistant.tool_calls 按 tool_call_id 配对找)。
|
|
59
|
-
* 返回 null 表示找不到(孤儿 tool,极少见),本层保守不动。 */
|
|
60
|
-
function toolNameOf(history, idx) {
|
|
61
|
-
const tcId = history[idx].tool_call_id;
|
|
62
|
-
if (!tcId)
|
|
63
|
-
return null;
|
|
64
|
-
for (let j = idx - 1; j >= 1; j--) {
|
|
65
|
-
const m = history[j];
|
|
66
|
-
if (m.role !== 'assistant')
|
|
67
|
-
continue;
|
|
68
|
-
const tcs = m.tool_calls;
|
|
69
|
-
if (!tcs)
|
|
70
|
-
continue;
|
|
71
|
-
const hit = tcs.find((tc) => tc?.id === tcId);
|
|
72
|
-
if (hit)
|
|
73
|
-
return hit.function?.name ?? null;
|
|
74
|
-
}
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
/** 把 content 拍平成字符串(对齐 drop.ts)。 */
|
|
78
|
-
function toText(content) {
|
|
79
|
-
if (content == null)
|
|
80
|
-
return '';
|
|
81
|
-
if (typeof content === 'string')
|
|
82
|
-
return content;
|
|
83
|
-
try {
|
|
84
|
-
return JSON.stringify(content);
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
return String(content);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
29
|
/**
|
|
91
30
|
* 维护「path → 该 path 所有 read_file tool 消息的 history index」映射。
|
|
92
31
|
* - observePush:把刚 push 的 read_file tool 消息登记,并把同 path 的"更早" read 全部 stub。
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 跨上下文模块共享的小工具函数。
|
|
2
|
+
//
|
|
3
|
+
// 目的:消除 `relevance.ts` / `lifecycle.ts` / `budget.ts` / `compact.ts` / `session/drop.ts`
|
|
4
|
+
// 之间的 copy-paste。这些函数逻辑完全一致,统一维护于此,避免行为漂移。
|
|
5
|
+
//
|
|
6
|
+
// 不变量:
|
|
7
|
+
// - 永不抛错(对齐「调度器永不抛错」契约)。
|
|
8
|
+
// - 仅依赖 `ChatMessage` 的最小形状,不反向 import agent / session / tools。
|
|
9
|
+
/** 把消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。
|
|
10
|
+
* 用于估算 token、内容匹配、stub 拼接等场景。 */
|
|
11
|
+
export function toText(content) {
|
|
12
|
+
if (content == null)
|
|
13
|
+
return '';
|
|
14
|
+
if (typeof content === 'string')
|
|
15
|
+
return content;
|
|
16
|
+
try {
|
|
17
|
+
return JSON.stringify(content);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return String(content);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。
|
|
24
|
+
* 用于划定「当前轮保护区」——该 user 及其之后的消息(agent 本轮还在用)不动。
|
|
25
|
+
* 注:history[0] 是 system,故下界为 1。 */
|
|
26
|
+
export function lastUserIndex(history) {
|
|
27
|
+
for (let i = history.length - 1; i >= 1; i--) {
|
|
28
|
+
if (history[i].role === 'user')
|
|
29
|
+
return i;
|
|
30
|
+
}
|
|
31
|
+
return -1;
|
|
32
|
+
}
|
|
33
|
+
/** 取 tool 消息对应的工具名(从紧邻的前导 assistant.tool_calls 按 tool_call_id 配对找)。
|
|
34
|
+
* 返回 null 表示找不到(孤儿 tool 消息,极少见),调用方保守跳过。 */
|
|
35
|
+
export function toolNameOf(history, idx) {
|
|
36
|
+
const tcId = history[idx].tool_call_id;
|
|
37
|
+
if (!tcId)
|
|
38
|
+
return null;
|
|
39
|
+
for (let j = idx - 1; j >= 1; j--) {
|
|
40
|
+
const m = history[j];
|
|
41
|
+
if (m.role !== 'assistant')
|
|
42
|
+
continue;
|
|
43
|
+
const tcs = m.tool_calls;
|
|
44
|
+
if (!tcs)
|
|
45
|
+
continue;
|
|
46
|
+
const hit = tcs.find((tc) => tc?.id === tcId);
|
|
47
|
+
if (hit)
|
|
48
|
+
return hit.function?.name ?? null;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/** 解析工具 arguments JSON,只取 `path` 字段;非法 / 无 path 返 null。
|
|
53
|
+
* 用于 read_file / edit_file / write_file 等以 path 为关键字的工具,供跨消息关联使用。 */
|
|
54
|
+
export function extractPath(argsRaw) {
|
|
55
|
+
if (!argsRaw)
|
|
56
|
+
return null;
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(argsRaw);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (!parsed || typeof parsed !== 'object')
|
|
65
|
+
return null;
|
|
66
|
+
const p = parsed.path;
|
|
67
|
+
return typeof p === 'string' && p ? p : null;
|
|
68
|
+
}
|
package/dist/repl/index.js
CHANGED
|
@@ -455,14 +455,15 @@ function textOf(c) {
|
|
|
455
455
|
* 回放默认全折叠;用户可鼠标点击摘要行展开(由 BatchRenderer 接管,见 ui/batch.ts)。
|
|
456
456
|
*/
|
|
457
457
|
export function renderHistory(history) {
|
|
458
|
-
const
|
|
459
|
-
//
|
|
460
|
-
let
|
|
458
|
+
const idToEntry = new Map();
|
|
459
|
+
// 普通工具可跨 assistant 步聚合;mutation 各自占一个 group,并切断前后普通工具。
|
|
460
|
+
let pendingBatches = [];
|
|
461
|
+
let normalBatch = null;
|
|
461
462
|
const flushBatch = () => {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
463
|
+
for (const entries of pendingBatches)
|
|
464
|
+
batch.writeSummaryOnly(entries, layout);
|
|
465
|
+
pendingBatches = [];
|
|
466
|
+
normalBatch = null;
|
|
466
467
|
};
|
|
467
468
|
for (let idx = 0; idx < history.length; idx++) {
|
|
468
469
|
const m = history[idx];
|
|
@@ -505,14 +506,25 @@ export function renderHistory(history) {
|
|
|
505
506
|
for (const tc of tcs) {
|
|
506
507
|
const name = tc?.function?.name ?? '';
|
|
507
508
|
const args = tc?.function?.arguments ?? '';
|
|
508
|
-
|
|
509
|
-
idToName.set(tc.id, name);
|
|
510
|
-
pendingBatch.push({
|
|
509
|
+
const entry = {
|
|
511
510
|
name,
|
|
512
511
|
callSummary: summarizeToolCall(name, args),
|
|
513
512
|
resultSummary: '',
|
|
514
513
|
diffBlock: null,
|
|
515
|
-
}
|
|
514
|
+
};
|
|
515
|
+
if (batch.isMutationToolName(name)) {
|
|
516
|
+
normalBatch = null;
|
|
517
|
+
pendingBatches.push([entry]);
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
if (!normalBatch) {
|
|
521
|
+
normalBatch = [];
|
|
522
|
+
pendingBatches.push(normalBatch);
|
|
523
|
+
}
|
|
524
|
+
normalBatch.push(entry);
|
|
525
|
+
}
|
|
526
|
+
if (tc?.id)
|
|
527
|
+
idToEntry.set(tc.id, entry);
|
|
516
528
|
}
|
|
517
529
|
continue; // 跳过后续 tool 消息处理循环(由下一分支填 result)
|
|
518
530
|
}
|
|
@@ -522,18 +534,10 @@ export function renderHistory(history) {
|
|
|
522
534
|
}
|
|
523
535
|
if (m.role === 'tool') {
|
|
524
536
|
const id = m.tool_call_id ?? '';
|
|
525
|
-
const
|
|
537
|
+
const target = idToEntry.get(id);
|
|
538
|
+
const name = target?.name ?? '';
|
|
526
539
|
const output = textOf(m.content);
|
|
527
540
|
const preview = summarizeToolResult(name, output);
|
|
528
|
-
// 匹配 pendingBatch 中尚未填 result 的同名 entry;同名前缀 tool 较罕见(并行工具同 id 不同名)
|
|
529
|
-
let target;
|
|
530
|
-
for (let i = pendingBatch.length - 1; i >= 0; i--) {
|
|
531
|
-
const e = pendingBatch[i];
|
|
532
|
-
if (e.name === name && !e.resultSummary) {
|
|
533
|
-
target = e;
|
|
534
|
-
break;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
541
|
if (target) {
|
|
538
542
|
target.resultSummary = preview;
|
|
539
543
|
target.fullOutput = output;
|
|
@@ -853,6 +857,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
853
857
|
if (!loadSnapshots(loaded.id))
|
|
854
858
|
rebuildFromHistory(history);
|
|
855
859
|
contextState.lastUsage = undefined;
|
|
860
|
+
contextState.correction = 1;
|
|
856
861
|
lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
|
|
857
862
|
layout.clearContent();
|
|
858
863
|
renderHistory(history);
|
|
@@ -985,6 +990,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
985
990
|
currentSessionId = undefined; // 下轮起新会话文件
|
|
986
991
|
turnCount = 0; // 反思 cadence 重新计数
|
|
987
992
|
contextState.lastUsage = undefined;
|
|
993
|
+
contextState.correction = 1;
|
|
988
994
|
lastTurnUsage = undefined; // 清空旧轮的 token 累计
|
|
989
995
|
pendingAttachments = []; // 一并清空待发图片
|
|
990
996
|
layout.clearContent();
|
package/dist/rollback/index.js
CHANGED
|
@@ -2,24 +2,12 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { config } from '../config/index.js';
|
|
4
4
|
import { truncateDisplay } from '../ui/render.js';
|
|
5
|
+
import { toText } from '../context/utils.js';
|
|
5
6
|
let turnIdCounter = 0;
|
|
6
7
|
let currentTurnId = 0;
|
|
7
8
|
let turns = [];
|
|
8
9
|
let snapshots = [];
|
|
9
10
|
const MUTATION_TOOLS = new Set(['write_file', 'edit_file']);
|
|
10
|
-
/** 把任意 content 拍平成字符串(OpenAI 可能是 string / 多模态数组)。 */
|
|
11
|
-
function toText(content) {
|
|
12
|
-
if (content == null)
|
|
13
|
-
return '';
|
|
14
|
-
if (typeof content === 'string')
|
|
15
|
-
return content;
|
|
16
|
-
try {
|
|
17
|
-
return JSON.stringify(content);
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
return String(content);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
11
|
/** 规整成 cwd 相对路径(快照存相对,跨 /resume 同项目可识别;resolve 已归一 ./ 和 ..)。 */
|
|
24
12
|
function toRel(p) {
|
|
25
13
|
try {
|
package/dist/session/compact.js
CHANGED
|
@@ -5,10 +5,14 @@ import { ui } from '../ui/theme.js';
|
|
|
5
5
|
import { Spinner } from '../ui/spinner.js';
|
|
6
6
|
import * as layout from '../ui/layout.js';
|
|
7
7
|
import { pruneAfterCompaction } from '../rollback/index.js';
|
|
8
|
+
import { toText } from '../context/utils.js';
|
|
8
9
|
/** 跨模块共享的上下文状态:agent 写 lastUsage,compact 写 lastEstimate,repl 的 /context 读。
|
|
9
|
-
* scheduler.ts 写最近一次调度日志(可选,repl 可读不到时 no-op)。
|
|
10
|
+
* scheduler.ts 写最近一次调度日志(可选,repl 可读不到时 no-op)。
|
|
11
|
+
* correction:API 实测 token / 估算 token 的校正系数(1.0 = 无偏差;>1 = 低估;<1 = 高估)。
|
|
12
|
+
* 由 agent/core.ts 在每次 chat 响应后更新;compact/repl 在 usage 失效时同步重置。 */
|
|
10
13
|
export const contextState = {
|
|
11
14
|
lastEstimate: 0,
|
|
15
|
+
correction: 1,
|
|
12
16
|
};
|
|
13
17
|
/** 中截:text 太长时保 head + 标记 + tail,总长 ≤ max。 */
|
|
14
18
|
export function truncateMid(text, max) {
|
|
@@ -97,19 +101,7 @@ export function capToolResultForHistory(name, output) {
|
|
|
97
101
|
return output;
|
|
98
102
|
return truncateMid(output, MAX_HISTORY_RESULT);
|
|
99
103
|
}
|
|
100
|
-
// ──
|
|
101
|
-
function toText(content) {
|
|
102
|
-
if (content == null)
|
|
103
|
-
return '';
|
|
104
|
-
if (typeof content === 'string')
|
|
105
|
-
return content;
|
|
106
|
-
try {
|
|
107
|
-
return JSON.stringify(content);
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
return String(content);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
104
|
+
// ── 内部:group 划分(toText 已移至 context/utils.ts 统一维护) ────────────
|
|
113
105
|
/**
|
|
114
106
|
* 把多模态 content 拍平成纯文本(供摘要 transcript 用):text parts 拼接;image_url parts
|
|
115
107
|
* 替换为 `[图片已剥离: <mime>]` stub,避免 base64 进摘要 prompt(LLM 看到也无意义,反而撑爆 token)。
|
|
@@ -323,6 +315,7 @@ export async function compactHistory(history, opts) {
|
|
|
323
315
|
const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
|
|
324
316
|
contextState.lastEstimate = estimateAfter;
|
|
325
317
|
contextState.lastUsage = undefined;
|
|
318
|
+
contextState.correction = 1;
|
|
326
319
|
layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}强制压缩(focus on early history)${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
|
|
327
320
|
return {
|
|
328
321
|
compacted: true,
|
|
@@ -409,6 +402,7 @@ export async function compactHistory(history, opts) {
|
|
|
409
402
|
const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
|
|
410
403
|
contextState.lastEstimate = estimateAfter;
|
|
411
404
|
contextState.lastUsage = undefined; // 压缩后旧 usage 失效,/context 改用估算
|
|
405
|
+
contextState.correction = 1;
|
|
412
406
|
layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}压缩上下文${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
|
|
413
407
|
// 抖动保护:压缩后仍超阈 → 提示 /clear,不死循环
|
|
414
408
|
if (estimateAfter >= opts.threshold * opts.window) {
|
|
@@ -426,6 +420,7 @@ export async function compactHistory(history, opts) {
|
|
|
426
420
|
const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
|
|
427
421
|
contextState.lastEstimate = estimateAfter;
|
|
428
422
|
contextState.lastUsage = undefined; // 结构虽未变,但 token 数已变,旧 usage 失效
|
|
423
|
+
contextState.correction = 1;
|
|
429
424
|
if (microcompactDone) {
|
|
430
425
|
layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}微压缩旧工具结果${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
|
|
431
426
|
return {
|
package/dist/session/drop.js
CHANGED
|
@@ -11,49 +11,7 @@
|
|
|
11
11
|
// - 原地修改 history(同 compact:length=0;push 重建,repl 持有同一引用)。
|
|
12
12
|
// - 永不抛错(对齐「调度器永不抛错」契约);无匹配 / 无可剔除 → 返 dropped=0。
|
|
13
13
|
import { messageTokens, estimateTokens, } from '../llm/index.js';
|
|
14
|
-
|
|
15
|
-
function toText(content) {
|
|
16
|
-
if (content == null)
|
|
17
|
-
return '';
|
|
18
|
-
if (typeof content === 'string')
|
|
19
|
-
return content;
|
|
20
|
-
try {
|
|
21
|
-
return JSON.stringify(content);
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
return String(content);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* 从 history 末尾向前找最后一个 user 消息的索引;无 user 返 -1。
|
|
29
|
-
* 用于划定"当前轮保护区"——该 user 及其之后的消息一律不剔除。
|
|
30
|
-
*/
|
|
31
|
-
function lastUserIndex(history) {
|
|
32
|
-
for (let i = history.length - 1; i >= 1; i--) {
|
|
33
|
-
if (history[i].role === 'user')
|
|
34
|
-
return i;
|
|
35
|
-
}
|
|
36
|
-
return -1;
|
|
37
|
-
}
|
|
38
|
-
/** 取 tool 消息对应的工具名(从紧邻的前导 assistant.tool_calls 按 tool_call_id 配对找)。 */
|
|
39
|
-
function toolNameOf(history, idx) {
|
|
40
|
-
const tcId = history[idx].tool_call_id;
|
|
41
|
-
if (!tcId)
|
|
42
|
-
return null;
|
|
43
|
-
for (let j = idx - 1; j >= 1; j--) {
|
|
44
|
-
const m = history[j];
|
|
45
|
-
if (m.role !== 'assistant')
|
|
46
|
-
continue;
|
|
47
|
-
const tcs = m
|
|
48
|
-
.tool_calls;
|
|
49
|
-
if (!tcs)
|
|
50
|
-
continue;
|
|
51
|
-
const hit = tcs.find((tc) => tc?.id === tcId);
|
|
52
|
-
if (hit)
|
|
53
|
-
return hit.function?.name ?? null;
|
|
54
|
-
}
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
14
|
+
import { lastUserIndex, toText, toolNameOf } from '../context/utils.js';
|
|
57
15
|
/**
|
|
58
16
|
* 剔除历史里命中的旧 tool 结果(原地修改 history)。
|
|
59
17
|
*
|
|
@@ -45,7 +45,7 @@ export function createBudgetScheduler() {
|
|
|
45
45
|
// 占位:push-time 三闸(cap / pruner / lifecycle)已自动跑;此接缝供将来演进。
|
|
46
46
|
},
|
|
47
47
|
async runStep(history, step) {
|
|
48
|
-
const report = evaluateBudget(history, config.contextWindowTokens, step);
|
|
48
|
+
const report = evaluateBudget(history, config.contextWindowTokens, step, contextState.correction);
|
|
49
49
|
const actions = scheduleActions(report);
|
|
50
50
|
let compactHistoryCalled = false;
|
|
51
51
|
for (const a of actions) {
|
|
@@ -104,7 +104,7 @@ export async function manualCompact(history, focus, opts) {
|
|
|
104
104
|
}));
|
|
105
105
|
return {
|
|
106
106
|
step: -1,
|
|
107
|
-
report: evaluateBudget(history, config.contextWindowTokens, -1),
|
|
107
|
+
report: evaluateBudget(history, config.contextWindowTokens, -1, contextState.correction),
|
|
108
108
|
actions: [{ kind: 'compact_history', focus }],
|
|
109
109
|
compactHistoryCalled: true,
|
|
110
110
|
ts: Date.now(),
|
|
@@ -118,7 +118,7 @@ export async function manualCompact(history, focus, opts) {
|
|
|
118
118
|
},
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
|
-
const report = evaluateBudget(history, config.contextWindowTokens, -1);
|
|
121
|
+
const report = evaluateBudget(history, config.contextWindowTokens, -1, contextState.correction);
|
|
122
122
|
let actions = scheduleActions(report);
|
|
123
123
|
// 用户显式说「要压」:即使 report 不含 history 触发,仍追加 compact_history
|
|
124
124
|
const hasCompact = actions.some(a => a.kind === 'compact_history');
|
package/dist/ui/batch.js
CHANGED
|
@@ -17,27 +17,26 @@ const batches = new Map();
|
|
|
17
17
|
* buffer 行数变化时本表可能漂移——但只在 insertAfter/deleteFrom 后由本模块同步更新,
|
|
18
18
|
* 并保持 buffer 当前状态对应。 */
|
|
19
19
|
const absLineToBatchId = new Map();
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/** 第一层展开后,工具概要行的绝对索引 → 对应 entry。 */
|
|
21
|
+
const absLineToEntry = new Map();
|
|
22
|
+
/** 已展开第一层工具列表的 batch id。 */
|
|
22
23
|
const expandedBatches = new Set();
|
|
23
|
-
/** mutation 工具名集合(写盘操作);与 src/agent/core.ts 的 isMutationTool 同步,本模块独立持有
|
|
24
|
-
* 避免 ui → agent 反向依赖。 */
|
|
25
|
-
const MUTATION_TOOLS = new Set(['write_file', 'edit_file']);
|
|
26
|
-
function isMutationTool(name) {
|
|
27
|
-
return MUTATION_TOOLS.has(name);
|
|
28
|
-
}
|
|
29
24
|
/** 展开时完整输出的最大行数;超出截断,避免巨型输出撑爆 viewport。 */
|
|
30
25
|
const MAX_EXPAND_LINES = 200;
|
|
26
|
+
export function isMutationToolName(name) {
|
|
27
|
+
return name === 'write_file' || name === 'edit_file';
|
|
28
|
+
}
|
|
31
29
|
/** 通知 buffer 整体清空(clearContent / exitAltScreen / 新一轮 turn)——本模块状态同步归零。 */
|
|
32
30
|
export function reset() {
|
|
33
31
|
batches.clear();
|
|
34
32
|
absLineToBatchId.clear();
|
|
33
|
+
absLineToEntry.clear();
|
|
35
34
|
expandedBatches.clear();
|
|
36
35
|
}
|
|
37
36
|
/** 新建一个 batch(在 agent 拿到第一条 onToolHeader 时调)。返回 id。 */
|
|
38
37
|
export function beginBatch() {
|
|
39
38
|
const id = `b${++_idCounter}`;
|
|
40
|
-
batches.set(id, { id, summaryAbsIdx: -1, entries: [],
|
|
39
|
+
batches.set(id, { id, summaryAbsIdx: -1, entries: [], expandedEntries: new Set() });
|
|
41
40
|
return id;
|
|
42
41
|
}
|
|
43
42
|
/** 记一条工具调用(在 onToolHeader 时调,与 setEntryResult 配对;entries 顺序 = agent 调用顺序)。 */
|
|
@@ -78,7 +77,9 @@ function buildSummaryLine(entries) {
|
|
|
78
77
|
}
|
|
79
78
|
if (entries.length === 1) {
|
|
80
79
|
const e = entries[0];
|
|
81
|
-
|
|
80
|
+
// 实时摘要必须稳定保持单行;完整参数放在第一层工具概要中,避免长 JSON 自动折行后
|
|
81
|
+
// 原地刷新只能覆盖最后一条物理行、残留旧摘要前半段。
|
|
82
|
+
return ` ${ui.bold}${ui.accent}●${ui.reset} ${ui.dim}Ran 1 tool · ${e.name} 1${ui.reset}`;
|
|
82
83
|
}
|
|
83
84
|
// N>1:同类合并 "read_file 3, glob 1, grep 1"
|
|
84
85
|
const counts = new Map();
|
|
@@ -93,56 +94,53 @@ function buildSummaryLine(entries) {
|
|
|
93
94
|
/** 把 batch 的详情行展开成自洽行数组(供 layout.contentInsertAfter 走 mid-buffer 插入)。
|
|
94
95
|
* 每行末尾必须以 \x1B[0m 收尾(SGR 自洽模型),行内允许含 SGR(行末 reset 不影响行内样式),
|
|
95
96
|
* 但**绝不**带 \n——rows[] 是行数组,不是流输出。 */
|
|
96
|
-
function
|
|
97
|
+
function buildEntryDetailLines(e, indent = ' ') {
|
|
97
98
|
const lines = [];
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
continue; // 跳过首尾空行(diff 头/尾换行)
|
|
109
|
-
lines.push(line.endsWith('\x1B[0m') ? line : line + '\x1B[0m');
|
|
110
|
-
}
|
|
99
|
+
if (e.diffBlock) {
|
|
100
|
+
// diff 块多行文本(由 renderFileChange 渲染);按 \n 拆成物理行,
|
|
101
|
+
// 每行单独入 rows[]。行末 reset 由本函数统一追加(若原行已带 reset,终端合并即可)。
|
|
102
|
+
const block = e.diffBlock.endsWith('\n') ? e.diffBlock : e.diffBlock + '\n';
|
|
103
|
+
for (const line of block.split('\n')) {
|
|
104
|
+
if (line === '' && lines.length > 0 && lines[lines.length - 1] === '')
|
|
105
|
+
continue; // 折叠连续空行
|
|
106
|
+
if (line === '' && lines.length > 0)
|
|
107
|
+
continue; // 跳过首尾空行(diff 头/尾换行)
|
|
108
|
+
lines.push(line.endsWith('\x1B[0m') ? line : line + '\x1B[0m');
|
|
111
109
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
if (truncated) {
|
|
121
|
-
lines.push(`${indent}${ui.dim}… (${rawLines.length - MAX_EXPAND_LINES} more lines)${ui.reset}\x1B[0m`);
|
|
122
|
-
}
|
|
110
|
+
}
|
|
111
|
+
else if (e.fullOutput) {
|
|
112
|
+
// 完整工具输出(纯文本):按行展开,每行缩进 + dim 样式;长输出截断到 MAX_EXPAND_LINES 行
|
|
113
|
+
const rawLines = e.fullOutput.split('\n');
|
|
114
|
+
const truncated = rawLines.length > MAX_EXPAND_LINES;
|
|
115
|
+
const displayLines = truncated ? rawLines.slice(0, MAX_EXPAND_LINES) : rawLines;
|
|
116
|
+
for (const line of displayLines) {
|
|
117
|
+
lines.push(`${indent}${ui.gray}${line}${ui.reset}\x1B[0m`);
|
|
123
118
|
}
|
|
124
|
-
|
|
125
|
-
lines.push(`${indent}${ui.
|
|
119
|
+
if (truncated) {
|
|
120
|
+
lines.push(`${indent}${ui.dim}… (${rawLines.length - MAX_EXPAND_LINES} more lines)${ui.reset}\x1B[0m`);
|
|
126
121
|
}
|
|
127
122
|
}
|
|
123
|
+
else if (e.resultSummary) {
|
|
124
|
+
lines.push(`${indent}${ui.gray}↳ ${e.resultSummary}${ui.reset}\x1B[0m`);
|
|
125
|
+
}
|
|
128
126
|
return lines;
|
|
129
127
|
}
|
|
128
|
+
/** 第一层只展示有哪些调用及其简短结果,不展开完整输出。 */
|
|
129
|
+
function buildExpandedLines(entries, indent = ' ') {
|
|
130
|
+
return entries.map((e) => {
|
|
131
|
+
const result = e.resultSummary ? ` ${ui.gray}↳ ${e.resultSummary}${ui.reset}` : '';
|
|
132
|
+
return `${indent}${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}${result}\x1B[0m`;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
130
135
|
/** 在 batch 收尾时(onToolBatchEnd):写摘要行 + 登记 summaryAbsIdx;若已展开(回放场景)立即插详情。 */
|
|
131
136
|
export function endBatch(id, layout) {
|
|
132
137
|
const b = batches.get(id);
|
|
133
|
-
if (!b
|
|
134
|
-
return;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// (N>1 时摘要行是聚合信息 "Ran N tools · ...",与详情不重复,两者照常都写)。
|
|
139
|
-
if (b.forceExpanded && b.entries.length === 1) {
|
|
140
|
-
// 无摘要行可当父行,详情头改用顶层 2 空格缩进(与 buildSummaryLine/diff head 对齐,而非嵌套的 4 空格)
|
|
141
|
-
const lines = buildExpandedLines(b.entries, ' ');
|
|
142
|
-
layout.contentWrite(lines.join('\n') + '\n');
|
|
143
|
-
b.summaryAbsIdx = Math.max(0, layout.totalRows() - 1 - lines.length);
|
|
138
|
+
if (!b)
|
|
139
|
+
return;
|
|
140
|
+
if (b.summaryAbsIdx >= 0) {
|
|
141
|
+
layout.contentReplaceLine?.(b.summaryAbsIdx, buildSummaryLine(b.entries));
|
|
142
|
+
// 执行阶段只展示实时摘要;到 endBatch 才开放点击,避免未完成 batch 的第一层列表失步。
|
|
144
143
|
absLineToBatchId.set(b.summaryAbsIdx, b.id);
|
|
145
|
-
expandedBatches.add(b.id); // 已展开;防止 toggleBatch 再次 expand() 造成重复插入
|
|
146
144
|
return;
|
|
147
145
|
}
|
|
148
146
|
const summary = buildSummaryLine(b.entries);
|
|
@@ -151,9 +149,25 @@ export function endBatch(id, layout) {
|
|
|
151
149
|
// 摘要行绝对索引 = totalRows - 2(hasCurrent 那行是新空行)
|
|
152
150
|
b.summaryAbsIdx = Math.max(0, layout.totalRows() - 2);
|
|
153
151
|
absLineToBatchId.set(b.summaryAbsIdx, b.id);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* 工具执行中立即显示/刷新摘要,但暂不登记点击命中;endBatch 收尾后才开放两层展开。
|
|
155
|
+
*/
|
|
156
|
+
export function showLiveBatch(id, layout) {
|
|
157
|
+
const b = batches.get(id);
|
|
158
|
+
if (!b)
|
|
159
|
+
return;
|
|
160
|
+
const summary = buildSummaryLine(b.entries);
|
|
161
|
+
if (b.summaryAbsIdx < 0) {
|
|
162
|
+
layout.contentWrite(summary + '\n');
|
|
163
|
+
b.summaryAbsIdx = Math.max(0, layout.totalRows() - 2);
|
|
164
|
+
// 首条摘要通过增量 contentWrite 落屏时,markdown→普通内容的边界可能只更新了
|
|
165
|
+
// buffer/续写位;直到第二个 header 的 contentReplaceLine 或后续正文重绘才完全可见。
|
|
166
|
+
// 立即按 buffer 原子重画,确保慢工具执行期间摘要前的空行已经显示。
|
|
167
|
+
layout.repaintViewport?.();
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
layout.contentReplaceLine(b.summaryAbsIdx, summary);
|
|
157
171
|
}
|
|
158
172
|
}
|
|
159
173
|
/** 绝对行索引 → 命中 batch id(用于鼠标 release 反查);非摘要行返 null。 */
|
|
@@ -170,18 +184,11 @@ export function isExpanded(id) {
|
|
|
170
184
|
* 展开:把详情行插入摘要行下方(mid-buffer insert)。
|
|
171
185
|
* 同步更新 absLineToBatchId 中所有受影响的索引:
|
|
172
186
|
* - 删除/插入点之后的 batch 摘要行索引相应平移。
|
|
173
|
-
* 含 mutation(write_file/edit_file)的 batch 强制展开——不允许折叠(写盘操作必须始终可见)。
|
|
174
187
|
*/
|
|
175
188
|
export function toggleBatch(id, layout) {
|
|
176
189
|
const b = batches.get(id);
|
|
177
190
|
if (!b)
|
|
178
191
|
return;
|
|
179
|
-
if (b.forceExpanded) {
|
|
180
|
-
// 写盘操作的 batch 强制展开,toggle 拒绝折叠(用户能看到完整 diff 即用)
|
|
181
|
-
if (!expandedBatches.has(id))
|
|
182
|
-
expand(b, layout);
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
192
|
if (expandedBatches.has(id)) {
|
|
186
193
|
collapse(b, layout);
|
|
187
194
|
}
|
|
@@ -193,11 +200,63 @@ function expand(b, layout) {
|
|
|
193
200
|
const lines = buildExpandedLines(b.entries);
|
|
194
201
|
layout.contentInsertAfter(b.summaryAbsIdx, lines);
|
|
195
202
|
expandedBatches.add(b.id);
|
|
203
|
+
for (let i = 0; i < b.entries.length; i++) {
|
|
204
|
+
absLineToEntry.set(b.summaryAbsIdx + 1 + i, { batchId: b.id, entryIndex: i });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** mutation 独占 batch 收尾后立即展示其调用概要和 diff。 */
|
|
208
|
+
export function expandSingleEntryFully(id, layout) {
|
|
209
|
+
const b = batches.get(id);
|
|
210
|
+
if (!b || b.entries.length !== 1 || expandedBatches.has(id))
|
|
211
|
+
return;
|
|
212
|
+
const lines = [
|
|
213
|
+
...buildExpandedLines(b.entries),
|
|
214
|
+
...buildEntryDetailLines(b.entries[0]),
|
|
215
|
+
];
|
|
216
|
+
layout.contentInsertAfter(b.summaryAbsIdx, lines);
|
|
217
|
+
expandedBatches.add(id);
|
|
218
|
+
b.expandedEntries.add(0);
|
|
219
|
+
absLineToEntry.set(b.summaryAbsIdx + 1, { batchId: id, entryIndex: 0 });
|
|
196
220
|
}
|
|
197
221
|
function collapse(b, layout) {
|
|
198
|
-
|
|
199
|
-
|
|
222
|
+
let lineCount = b.entries.length;
|
|
223
|
+
for (const i of b.expandedEntries)
|
|
224
|
+
lineCount += buildEntryDetailLines(b.entries[i]).length;
|
|
225
|
+
layout.contentDeleteFrom(b.summaryAbsIdx + 1, lineCount);
|
|
200
226
|
expandedBatches.delete(b.id);
|
|
227
|
+
b.expandedEntries.clear();
|
|
228
|
+
for (const [idx, target] of absLineToEntry) {
|
|
229
|
+
if (target.batchId === b.id)
|
|
230
|
+
absLineToEntry.delete(idx);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** 绝对行索引 → 第一层中的具体工具调用。 */
|
|
234
|
+
export function findEntryByAbsLine(absLine) {
|
|
235
|
+
return absLineToEntry.get(absLine) ?? null;
|
|
236
|
+
}
|
|
237
|
+
/** 第二层:只展开/折叠某一个工具的完整输出。 */
|
|
238
|
+
export function toggleEntry(batchId, entryIndex, layout) {
|
|
239
|
+
const b = batches.get(batchId);
|
|
240
|
+
if (!b || !expandedBatches.has(batchId))
|
|
241
|
+
return;
|
|
242
|
+
let headerIdx = -1;
|
|
243
|
+
for (const [idx, target] of absLineToEntry) {
|
|
244
|
+
if (target.batchId === batchId && target.entryIndex === entryIndex)
|
|
245
|
+
headerIdx = idx;
|
|
246
|
+
}
|
|
247
|
+
if (headerIdx < 0)
|
|
248
|
+
return;
|
|
249
|
+
const details = buildEntryDetailLines(b.entries[entryIndex]);
|
|
250
|
+
if (details.length === 0)
|
|
251
|
+
return;
|
|
252
|
+
if (b.expandedEntries.has(entryIndex)) {
|
|
253
|
+
layout.contentDeleteFrom(headerIdx + 1, details.length);
|
|
254
|
+
b.expandedEntries.delete(entryIndex);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
layout.contentInsertAfter(headerIdx, details);
|
|
258
|
+
b.expandedEntries.add(entryIndex);
|
|
259
|
+
}
|
|
201
260
|
}
|
|
202
261
|
/**
|
|
203
262
|
* 当 buffer 中段插/删 N 行后,所有受影响 batch 的 summaryAbsIdx 需平移。
|
|
@@ -222,6 +281,21 @@ export function shiftBatchesAfter(absIdx, delta) {
|
|
|
222
281
|
absLineToBatchId.clear();
|
|
223
282
|
for (const [k, v] of next)
|
|
224
283
|
absLineToBatchId.set(k, v);
|
|
284
|
+
const nextEntries = new Map();
|
|
285
|
+
// expand() 先插入整组概要行、再登记其命中位置;layout 随后的异步 shift 通知不应把
|
|
286
|
+
// 这批“刚插入”的概要行再次平移。插入单个工具详情时 absIdx 不是 summary,仍正常平移。
|
|
287
|
+
const insertedOverviewBatch = delta > 0
|
|
288
|
+
? [...batches.values()].find((b) => b.summaryAbsIdx === absIdx && expandedBatches.has(b.id))?.id
|
|
289
|
+
: undefined;
|
|
290
|
+
for (const [idx, target] of absLineToEntry) {
|
|
291
|
+
const isNewOverviewLine = target.batchId === insertedOverviewBatch;
|
|
292
|
+
const newIdx = idx > absIdx && !isNewOverviewLine ? idx + delta : idx;
|
|
293
|
+
if (newIdx >= 0)
|
|
294
|
+
nextEntries.set(newIdx, target);
|
|
295
|
+
}
|
|
296
|
+
absLineToEntry.clear();
|
|
297
|
+
for (const [k, v] of nextEntries)
|
|
298
|
+
absLineToEntry.set(k, v);
|
|
225
299
|
// 同步每个 batch 的 summaryAbsIdx
|
|
226
300
|
for (const b of batches.values()) {
|
|
227
301
|
if (b.summaryAbsIdx > absIdx)
|
|
@@ -232,21 +306,14 @@ export function shiftBatchesAfter(absIdx, delta) {
|
|
|
232
306
|
/** 把已构造好的 BatchEntry[] 直接落成摘要行(用于 renderHistory 回放;不记录 id 也不需可切换)。
|
|
233
307
|
* 含 mutation(write_file/edit_file)时整批展开——与实时 endBatch 行为一致。 */
|
|
234
308
|
export function writeSummaryOnly(entries, layout) {
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
if (
|
|
238
|
-
const lines = buildExpandedLines(entries, ' ');
|
|
239
|
-
layout.contentWrite(lines.join('\n') + '\n');
|
|
309
|
+
const id = beginBatch();
|
|
310
|
+
const b = batches.get(id);
|
|
311
|
+
if (!b)
|
|
240
312
|
return;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
const summaryIdx = Math.max(0, layout.totalRows() - 2);
|
|
246
|
-
const lines = buildExpandedLines(entries);
|
|
247
|
-
layout.contentInsertAfter(summaryIdx, lines);
|
|
248
|
-
// 不入 batches/absLineToBatchId/expandedBatches——回放行不支持点击 toggle(设计取舍:
|
|
249
|
-
// 简化模型;若需要支持回放也可展开/折叠,可在 alt-screen 启动时建一张临时映射)
|
|
313
|
+
b.entries = entries;
|
|
314
|
+
endBatch(id, layout);
|
|
315
|
+
if (entries.length === 1 && isMutationToolName(entries[0].name)) {
|
|
316
|
+
expandSingleEntryFully(id, layout);
|
|
250
317
|
}
|
|
251
318
|
}
|
|
252
319
|
let _idCounter = 0;
|
package/dist/ui/content.js
CHANGED
|
@@ -141,6 +141,12 @@ export function lineAt(abs) {
|
|
|
141
141
|
const all = snapshot();
|
|
142
142
|
return abs >= 0 && abs < all.length ? all[abs] : null;
|
|
143
143
|
}
|
|
144
|
+
/** 等长替换一条已提交物理行,供运行中的工具 batch 原地刷新摘要。 */
|
|
145
|
+
export function replaceLine(abs, line) {
|
|
146
|
+
if (abs < 0 || abs >= rows.length)
|
|
147
|
+
return;
|
|
148
|
+
rows[abs] = line.endsWith('\x1B[0m') ? line : line + '\x1B[0m';
|
|
149
|
+
}
|
|
144
150
|
/**
|
|
145
151
|
* 找「绝对行索引 < absStart 的最近一条用户消息」的文本(用于滚动回看时的「我刚发的
|
|
146
152
|
* 请求」sticky banner)。算法:从 absStart - 1 往上扫,识别「用户气泡行」(由 repl
|
package/dist/ui/layout.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stdin, stdout } from 'node:process';
|
|
2
|
-
import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, stripAnsi, sliceByDisplayCol, } from './render.js';
|
|
2
|
+
import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, truncateAnsi, ansiDisplayWidth, wrapByDisplayWidth, fmtElapsed, stripAnsi, sliceByDisplayCol, } from './render.js';
|
|
3
3
|
import { ui } from './theme.js';
|
|
4
4
|
import * as content from './content.js';
|
|
5
5
|
import * as mouse from './mouse.js';
|
|
@@ -578,6 +578,16 @@ export function contentDeleteFrom(startIdx, n) {
|
|
|
578
578
|
export function totalRows() {
|
|
579
579
|
return content.totalRows();
|
|
580
580
|
}
|
|
581
|
+
/** 原地刷新一条内容行(行数不变),用于运行中的工具 batch 更新计数。 */
|
|
582
|
+
export function contentReplaceLine(absIdx, line) {
|
|
583
|
+
if (!active)
|
|
584
|
+
return;
|
|
585
|
+
// 原地更新的 batch 摘要可能随追加工具而变长。缓冲区仍把它当作一行,但若直接
|
|
586
|
+
// 交给终端超过 cols,终端会自动折行,造成视觉上多出空行且续写位与缓冲失步。
|
|
587
|
+
// 在写回缓冲前按 ANSI 可见宽度截断,确保“一条逻辑行 = 一条物理行”。
|
|
588
|
+
content.replaceLine(absIdx, truncateAnsi(line, getGeo().cols));
|
|
589
|
+
repaintViewport();
|
|
590
|
+
}
|
|
581
591
|
/** 清空内容区时通知 batch 渲染器重置(摘要行映射与展开态)。 */
|
|
582
592
|
export function notifyContentReset() {
|
|
583
593
|
// 动态 import 避免循环;模块级 reset() 只清映射,不动 batch 内部数据(id 与 entries 仍可重用)
|
|
@@ -1135,14 +1145,25 @@ function handleMouseEvent(e) {
|
|
|
1135
1145
|
void (async () => {
|
|
1136
1146
|
try {
|
|
1137
1147
|
const m = await import('./batch.js');
|
|
1148
|
+
const entry = m.findEntryByAbsLine(absClick);
|
|
1149
|
+
if (entry) {
|
|
1150
|
+
// 先清选区再改 buffer:contentInsert/Delete 内会立即 repaintViewport;若此时仍保留
|
|
1151
|
+
// 旧绝对行选区,会短暂画出一帧错位高亮,随后二次重画,视觉上就是抖动。
|
|
1152
|
+
selection = null;
|
|
1153
|
+
m.toggleEntry(entry.batchId, entry.entryIndex, {
|
|
1154
|
+
contentInsertAfter: (after, lines) => contentInsertAfter(after, lines),
|
|
1155
|
+
contentDeleteFrom: (start, n) => contentDeleteFrom(start, n),
|
|
1156
|
+
});
|
|
1157
|
+
repaint();
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1138
1160
|
const id = m.findBatchByAbsLine(absClick);
|
|
1139
1161
|
if (id) {
|
|
1162
|
+
selection = null;
|
|
1140
1163
|
m.toggleBatch(id, {
|
|
1141
1164
|
contentInsertAfter: (after, lines) => contentInsertAfter(after, lines),
|
|
1142
1165
|
contentDeleteFrom: (start, n) => contentDeleteFrom(start, n),
|
|
1143
1166
|
});
|
|
1144
|
-
selection = null;
|
|
1145
|
-
repaintViewport();
|
|
1146
1167
|
repaint();
|
|
1147
1168
|
return;
|
|
1148
1169
|
}
|
package/dist/ui/markdown.js
CHANGED
|
@@ -574,6 +574,11 @@ function renderMarkdownImpl(text, cols) {
|
|
|
574
574
|
flushPara();
|
|
575
575
|
if (inFence)
|
|
576
576
|
flushCode(); // EOF 仍 inFence:流式中未闭合 fence → 照常 emit 进行中代码块
|
|
577
|
+
// 独立 markdown 段的外部间距由 agent/batch 边界统一管理。流式后端可能把
|
|
578
|
+
// "\n\n" 与首段正文拆成不同 chunk;仅清洗首个正文 chunk 不够,因为 mdBuf
|
|
579
|
+
// 累积重渲染时这些换行仍会变成段首空行。这里最终兜底,正文段永不自带前导空行。
|
|
580
|
+
while (out.length > 0 && out[0] === '')
|
|
581
|
+
out.shift();
|
|
577
582
|
// 末尾不留空行:agent onText 后接 onToolCall 的 contentWrite('\n') 会补 1 空行分隔正文与 ● 行;
|
|
578
583
|
// 若 md 末尾自带空行(段落/代码块后)则叠成 2 空行。裁掉末尾连续空行,让 onToolCall / 轮末
|
|
579
584
|
// contentWrite('\n') 恰好补 1 行(与改造前 raw 文本行为一致)。块间空行(flushPara/flushCode 中段
|