mocode-ai 0.5.5 → 0.5.6

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.
@@ -138,6 +138,7 @@ function pushToolResult(history, tc, output, pruner, lifecycle, scheduler) {
138
138
  */
139
139
  export async function runAgentCore(opts) {
140
140
  const { history, userInput, signal, onContextUpdate, hooks, skipRollback } = opts;
141
+ const runtimeContextState = opts.contextState ?? contextState;
141
142
  const maxSteps = opts.maxSteps ?? config.maxSteps;
142
143
  // 中断回滚快照:入口(本 turn push 任何消息前)整段浅拷贝。abort 时 length=0;push(...saved) 还原。
143
144
  // 用 slice() 而非 length:maybeCompact 会原地重建(length=0;push(...rebuilt)),savedLen 会失效。
@@ -187,7 +188,7 @@ export async function runAgentCore(opts) {
187
188
  // 预算调度器:每个 runAgentCore 实例一个,步前 evaluateBudget + scheduleActions。
188
189
  // 决策按 ROI 分发(cold tools 优先 / history 摘要最后);contextBudget 开关关闭时为 null。
189
190
  const scheduler = config.contextBudget !== false
190
- ? createBudgetScheduler() // 在 step 循环之外实例化一次,跨步持有 lastRunLog
191
+ ? createBudgetScheduler(runtimeContextState) // 在 step 循环之外实例化一次,跨步持有 lastRunLog
191
192
  : null;
192
193
  // 本轮流式状态:首个正文 token 到达即停 spinner(思考期间 spinner 持续转「思考中…」,不写思考内容)。
193
194
  let mode = 'idle';
@@ -235,7 +236,7 @@ export async function runAgentCore(opts) {
235
236
  await scheduler.runStep(history, step);
236
237
  }
237
238
  else {
238
- await maybeCompact(history);
239
+ await maybeCompact(history, undefined, undefined, runtimeContextState);
239
240
  }
240
241
  hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
241
242
  mode = 'idle';
@@ -260,7 +261,7 @@ export async function runAgentCore(opts) {
260
261
  }
261
262
  throw e;
262
263
  }
263
- contextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
264
+ runtimeContextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
264
265
  addUsage(result.usage); // 本轮累计:onDone 摘要行 + AgentRunResult.usage 透传
265
266
  // 校正系数:API 实测 prompt_tokens / 估算 token。
266
267
  // 每次 chat 响应后刷新,让下次 evaluateBudget 用更接近真实的 actual。
@@ -269,7 +270,7 @@ export async function runAgentCore(opts) {
269
270
  const estimated = estimateMessagesTokens(history) + estimateToolSchemaTokens();
270
271
  if (estimated > 100) {
271
272
  const raw = result.usage.promptTokens / estimated;
272
- contextState.correction = Math.max(0.5, Math.min(2.0, raw));
273
+ runtimeContextState.correction = Math.max(0.5, Math.min(2.0, raw));
273
274
  }
274
275
  }
275
276
  hooks.onChatDone?.(); // 主 agent:spinner.stop()
@@ -66,8 +66,11 @@ function flushToolBatch(expandSingleEntry = false) {
66
66
  batch.endBatch(id, layout);
67
67
  if (expandSingleEntry)
68
68
  batch.expandSingleEntryFully(id, layout);
69
- // 摘要本身已有行尾;再补一行,保持工具与后续正文/状态摘要之间的原有间距。
70
- layout.contentWrite('\n');
69
+ // 普通摘要只有一个“当前空行”,再 break 一次把它提交为分隔空行。
70
+ // mutation 自动展开时 content.insertAfter 已先把该当前空行提交到 rows;若这里仍补 \n
71
+ // diff 后就会固定出现两条空白行。
72
+ if (!expandSingleEntry)
73
+ layout.contentWrite('\n');
71
74
  }
72
75
  /**
73
76
  * agent 核心循环(主 agent,TUI 渲染版):
@@ -104,6 +107,7 @@ onContextUpdate) {
104
107
  // 不能按普通字符串的“两个换行才有一个空行”来计算。
105
108
  let textBoundaryNewlines = 0;
106
109
  let hasPendingTextBoundary = false;
110
+ let toolBatchFollowsText = false;
107
111
  const hooks = {
108
112
  onText: (s) => {
109
113
  // 纯空白 chunk 在视觉上不是正文:既不切 batch,也不写入 markdown 缓冲。
@@ -130,6 +134,7 @@ onContextUpdate) {
130
134
  // 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
131
135
  // 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
132
136
  if (hasPendingTextBoundary) {
137
+ toolBatchFollowsText = true;
133
138
  if (textBoundaryNewlines < 1) {
134
139
  layout.contentWrite('\n');
135
140
  }
@@ -150,7 +155,15 @@ onContextUpdate) {
150
155
  textBoundaryNewlines = 1;
151
156
  }
152
157
  },
153
- onToolHeader: (tc) => writeToolHeader(tc),
158
+ onToolHeader: (tc) => {
159
+ // mutation 的自动展开会把 current/committed 空行状态互相转换;在首摘要真正
160
+ // 落屏前按视觉行归一,避免同样的文本→edit 边界偶发 1 行或 2 行。
161
+ if (toolBatchFollowsText && isMutationTool(tc.name)) {
162
+ layout.normalizeMutationBoundary();
163
+ }
164
+ toolBatchFollowsText = false;
165
+ writeToolHeader(tc);
166
+ },
154
167
  onToolStart: (name) => spinner.start(`执行 ${name}`),
155
168
  onToolDone: () => spinner.stop(),
156
169
  onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine),
@@ -7,8 +7,8 @@
7
7
  // - 独立 history:不共享主对话,避免子任务的工具噪声污染主上下文。
8
8
  // - 系统提示复用主 agent 组装链(config.systemPrompt + memory 段 + skills 段)+ 子 agent 角色后缀。
9
9
  // - 工具子集:按白名单从 chatTools 过滤;无白名单 = 全量(但 task 工具调用方通常会限定只读)。
10
- // - 不调 beginTurn(不进主回滚链);但文件 mutation 仍经 executeTool→recordMutation,
11
- // 归入当前主轮次(语义:子 agent 的改动属于当前主轮,可随 /rollback 一起撤销)。
10
+ // - 不调 beginTurn(不进主回滚链);skipRollback=true 使文件 mutation 跳过 recordMutation,
11
+ // agent 的改动不进主回滚快照链(靠 git 兜底,见下方 skipRollback 注释)。
12
12
  // - 步数上限默认更低(config.subAgentMaxSteps ?? 50),防子任务失控耗尽配额。
13
13
  // - 中断透传:opts.signal(主 agent 的 abort signal)透传给 runAgentCore → chat/executeTool,
14
14
  // 主 Ctrl+C 树杀子 agent(chat 流式 abort + run_command/web_fetch 即时取消)。
@@ -21,6 +21,7 @@ import { buildMemorySection, buildMemoryIndexSection } from '../memory/index.js'
21
21
  import { ui } from '../ui/theme.js';
22
22
  import { runAgentCore } from './core.js';
23
23
  import { summarizeToolCall, summarizeToolResult, truncateDisplay } from '../ui/render.js';
24
+ import { createContextState } from '../session/compact.js';
24
25
  /** 子 agent 系统提示后缀:角色与约束。 */
25
26
  const SUBAGENT_SUFFIX = `
26
27
 
@@ -115,6 +116,9 @@ export async function spawnAgent(opts) {
115
116
  // onStepStart / onChatDone / onToolStart / onToolDone / onAbort:子 agent 静默,无需 spinner / 中断渲染。
116
117
  // abort 还原(history 还原 + 模式还原)由 core 的 abortRestore 处理,hooks 只管展示。
117
118
  };
119
+ // 每个子 agent 独享统计/预算状态。不能保存再恢复模块级单例:多个 task 并发时
120
+ // save/restore 会竞态,且 lastEstimate / schedulerLog 仍会污染主 agent。
121
+ const localContextState = createContextState();
118
122
  const result = await runAgentCore({
119
123
  history,
120
124
  userInput: opts.prompt,
@@ -123,6 +127,7 @@ export async function spawnAgent(opts) {
123
127
  maxSteps,
124
128
  toolsOverride,
125
129
  skipRollback: true, // 逻辑隔离:子 agent 文件改动不进主回滚快照链,主 /rollback 不撤销(靠 git 兜底)
130
+ contextState: localContextState,
126
131
  });
127
132
  return {
128
133
  summary: result.finalText,
@@ -5,63 +5,63 @@ import { spawnAgent } from '../agent/spawn.js';
5
5
  /**
6
6
  * 子 agent 的系统提示后缀:角色与输出格式约束
7
7
  */
8
- const SKILL_INIT_SUFFIX = `You are a project exploration agent. Your task is to deeply understand this project and generate a concise "Project Skill" document.
9
-
10
- ## ⚠️ Complementary to Snapshot — CRITICAL
11
- The following information is ALREADY provided by Project Snapshot — **do NOT repeat**:
12
- - ✗ Project one-liner description (快照已有)
13
- - ✗ Module names and file locations (快照 srcTree 已有)
14
- - ✗ Tech stack names and versions (快照 techStack 已有)
15
- - ✗ Build/test commands (快照 keyCommands 已有)
16
- - ✗ File lists and directory structure (快照 srcTree 已有)
17
-
18
- **Your job is to capture INSIGHTS only:**
19
- - **WHY** — design decisions, trade-offs, reasons for choosing X over Y
20
- - **HOW** — module behaviors, data flows, call chains, non-obvious interactions
21
- - **GOTCHAS** — pitfalls, edge cases, non-intuitive behaviors
22
- - **CONVENTIONS** — naming patterns, code style, unwritten rules
23
-
24
- ## Exploration Strategy
25
- 1. Use \`codegraph\` to trace call chains and understand module interactions
26
- 2. Read core modules to understand **behaviors** (not just names)
27
- 3. Look for complex logic, error handling patterns, unusual configs
28
- 4. Identify naming conventions, common abstractions
29
-
30
- ## Output Format
31
- Output the skill document between these exact delimiters:
32
-
33
- \`\`\`skill-start
34
- ## 架构要点
35
- ### 核心模块及职责
36
- - **\`path/to/module\`** — 行为描述(做什么 + 怎么做)
37
- ...
38
-
39
- ### 关键设计决策
40
- - 决策:原因
41
- ...
42
-
43
- ## 项目约定
44
- - 约定(具体的,有例子的)
45
- ...
46
-
47
- ## 开发流程
48
- - 命令 + 注意事项/坑点(命令本身快照已有,这里只写注意什么)
49
- ...
50
-
51
- ## 常见坑点
52
- - 坑:解法
53
- ...
54
- \`\`\`skill-end
55
-
56
- ## Rules — MUST FOLLOW
57
- 1. 禁止列出文件名清单或目录结构
58
- 2. 禁止列出依赖名和版本
59
- 3. 禁止写项目概述/一句话描述
60
- 4. 禁止只写命令本身(快照已有),只写注意事项
61
- 5. 每个模块描述必须包含行为(做什么 + 怎么协作),不只是名称
62
- 6. **总长度目标 3000-4000 字符,硬上限 4000**
63
- 7. 写可操作的内容,用具体路径和例子
64
- 8. 如果某个 section 没有实质内容,省略它
8
+ const SKILL_INIT_SUFFIX = `You are a project exploration agent. Your task is to deeply understand this project and generate a concise "Project Skill" document.
9
+
10
+ ## ⚠️ Complementary to Snapshot — CRITICAL
11
+ The following information is ALREADY provided by Project Snapshot — **do NOT repeat**:
12
+ - ✗ Project one-liner description (快照已有)
13
+ - ✗ Module names and file locations (快照 srcTree 已有)
14
+ - ✗ Tech stack names and versions (快照 techStack 已有)
15
+ - ✗ Build/test commands (快照 keyCommands 已有)
16
+ - ✗ File lists and directory structure (快照 srcTree 已有)
17
+
18
+ **Your job is to capture INSIGHTS only:**
19
+ - **WHY** — design decisions, trade-offs, reasons for choosing X over Y
20
+ - **HOW** — module behaviors, data flows, call chains, non-obvious interactions
21
+ - **GOTCHAS** — pitfalls, edge cases, non-intuitive behaviors
22
+ - **CONVENTIONS** — naming patterns, code style, unwritten rules
23
+
24
+ ## Exploration Strategy
25
+ 1. Use \`codegraph\` to trace call chains and understand module interactions
26
+ 2. Read core modules to understand **behaviors** (not just names)
27
+ 3. Look for complex logic, error handling patterns, unusual configs
28
+ 4. Identify naming conventions, common abstractions
29
+
30
+ ## Output Format
31
+ Output the skill document between these exact delimiters:
32
+
33
+ \`\`\`skill-start
34
+ ## 架构要点
35
+ ### 核心模块及职责
36
+ - **\`path/to/module\`** — 行为描述(做什么 + 怎么做)
37
+ ...
38
+
39
+ ### 关键设计决策
40
+ - 决策:原因
41
+ ...
42
+
43
+ ## 项目约定
44
+ - 约定(具体的,有例子的)
45
+ ...
46
+
47
+ ## 开发流程
48
+ - 命令 + 注意事项/坑点(命令本身快照已有,这里只写注意什么)
49
+ ...
50
+
51
+ ## 常见坑点
52
+ - 坑:解法
53
+ ...
54
+ \`\`\`skill-end
55
+
56
+ ## Rules — MUST FOLLOW
57
+ 1. 禁止列出文件名清单或目录结构
58
+ 2. 禁止列出依赖名和版本
59
+ 3. 禁止写项目概述/一句话描述
60
+ 4. 禁止只写命令本身(快照已有),只写注意事项
61
+ 5. 每个模块描述必须包含行为(做什么 + 怎么协作),不只是名称
62
+ 6. **总长度目标 3000-4000 字符,硬上限 4000**
63
+ 7. 写可操作的内容,用具体路径和例子
64
+ 8. 如果某个 section 没有实质内容,省略它
65
65
  `;
66
66
  /**
67
67
  * 生成初始项目 Skill(使用子 agent 深度探索)
@@ -72,35 +72,35 @@ export async function generateInitialSkill(existingSkill, signal) {
72
72
  const hasExisting = !!existingSkill;
73
73
  let prompt;
74
74
  if (hasExisting) {
75
- prompt = `以下是当前的项目 Skill 内容,请深度探索项目并优化/完善它:
76
-
77
- \`\`\`markdown
78
- ${existingSkill}
79
- \`\`\`
80
-
81
- ## 优化方向
82
- 1. 补充缺失的信息(架构要点、设计决策、调用链等)
83
- 2. 修正过时或不准确的内容
84
- 3. 让描述更具体(用实际路径和例子)
85
- 4. 删除冗余或空泛的描述
86
- 5. 如果发现新的坑点或约定,添加进去
87
-
75
+ prompt = `以下是当前的项目 Skill 内容,请深度探索项目并优化/完善它:
76
+
77
+ \`\`\`markdown
78
+ ${existingSkill}
79
+ \`\`\`
80
+
81
+ ## 优化方向
82
+ 1. 补充缺失的信息(架构要点、设计决策、调用链等)
83
+ 2. 修正过时或不准确的内容
84
+ 3. 让描述更具体(用实际路径和例子)
85
+ 4. 删除冗余或空泛的描述
86
+ 5. 如果发现新的坑点或约定,添加进去
87
+
88
88
  记住:保持精简,总长度控制在 4000-5000 字符。输出完整的优化后版本。`;
89
89
  }
90
90
  else {
91
- prompt = `请深度探索当前项目,生成一份项目专属 Skill 文档。
92
-
93
- ## 探索步骤
94
- 1. 先用 glob 扫描项目结构(**/*.ts, **/*.json 等)
95
- 2. 读取 package.json、README.md、tsconfig.json 等关键文件
96
- 3. 用 codegraph 或 grep 找到主入口、核心模块、关键接口
97
- 4. 理解架构模式、数据流向、调用关系
98
- 5. 识别命名约定、代码风格、测试策略
99
- 6. 找出可能的坑点(复杂配置、特殊依赖、构建陷阱)
100
-
101
- ## 输出
102
- 探索完成后,将完整的 skill 文档输出在 \`\`\`skill-start 和 \`\`\`skill-end 之间。
103
-
91
+ prompt = `请深度探索当前项目,生成一份项目专属 Skill 文档。
92
+
93
+ ## 探索步骤
94
+ 1. 先用 glob 扫描项目结构(**/*.ts, **/*.json 等)
95
+ 2. 读取 package.json、README.md、tsconfig.json 等关键文件
96
+ 3. 用 codegraph 或 grep 找到主入口、核心模块、关键接口
97
+ 4. 理解架构模式、数据流向、调用关系
98
+ 5. 识别命名约定、代码风格、测试策略
99
+ 6. 找出可能的坑点(复杂配置、特殊依赖、构建陷阱)
100
+
101
+ ## 输出
102
+ 探索完成后,将完整的 skill 文档输出在 \`\`\`skill-start 和 \`\`\`skill-end 之间。
103
+
104
104
  记住:内容要精简、可操作、有具体路径和例子。总长度控制在 4000-5000 字符。`;
105
105
  }
106
106
  try {
@@ -2,64 +2,64 @@ import { spawnAgent } from '../agent/spawn.js';
2
2
  /**
3
3
  * 子 agent 系统提示:让 agent 自主探索项目并生成 markdown 快照
4
4
  */
5
- const SNAPSHOT_LLM_SUFFIX = `You are a project analysis agent. Your task is to explore this project and generate a concise markdown snapshot for an AI coding assistant.
6
-
7
- ## Your Mission
8
- Explore the project using available tools (read_file, glob, grep) and generate a structured markdown snapshot that captures:
9
- - WHAT exists (project description, tech stack, commands, modules, directory structure)
10
- - WHERE it is (paths, locations)
11
-
12
- Do NOT explain WHY or HOW (that's Project Skill's job).
13
-
14
- ## Exploration Strategy
15
- 1. Read package.json, README.md, tsconfig.json (or equivalent for other languages)
16
- 2. List top-level directories to identify modules
17
- 3. Scan src/ directory structure (depth ≤3, directories only)
18
- 4. Extract key commands from package.json scripts
19
- 5. Identify tech stack from dependencies
20
-
21
- ## Output Format
22
- Output a markdown document between these exact delimiters:
23
-
24
- \`\`\`snapshot-md
25
- # Project Snapshot
26
-
27
- ## Description
28
- (一句话描述项目,≤80字,中文)
29
-
30
- ## Tech Stack
31
- (关键技术栈,逗号分隔,含版本,≤15项)
32
-
33
- ## Commands
34
- | Command | Description |
35
- |---------|-------------|
36
- | \`npm run build\` | 构建项目 |
37
- | \`npm test\` | 运行测试 |
38
- | ... | ... |
39
-
40
- ## Modules
41
- - **module-name** — 一句话职责描述(≤20字)
42
- - **module-name** — 一句话职责描述
43
- - ...
44
-
45
- ## Source Tree
46
- \`\`\`
47
- src/
48
- agent/
49
- tools/
50
- ui/
51
- \`\`\`
52
- \`\`\`snapshot-md
53
-
54
- ## Rules
55
- 1. **Description**: ≤80字,中文,从 README 和 package.json description 提炼。不要"一个..."开头。
56
- 2. **Tech Stack**: ≤15项,逗号分隔,含版本。格式如 "TypeScript 5.x, Node.js ≥18, openai@^4"。
57
- 3. **Commands**: 5-8个最重要的命令,从 package.json scripts 提取。Description ≤15字。用表格格式。
58
- 4. **Modules**: 每个顶层模块目录一条。职责≤20字,只写 WHAT(做什么)。
59
- 5. **Source Tree**: 只列目录(不列文件),深度≤3,用缩进表示层级,放在代码块中。
60
- 6. 不要包含:设计决策、注意事项、坑点、约定 → 这些是 Skill 的职责。
61
- 7. 总 markdown ≤ 2500 字符。
62
- 8. 使用中文。
5
+ const SNAPSHOT_LLM_SUFFIX = `You are a project analysis agent. Your task is to explore this project and generate a concise markdown snapshot for an AI coding assistant.
6
+
7
+ ## Your Mission
8
+ Explore the project using available tools (read_file, glob, grep) and generate a structured markdown snapshot that captures:
9
+ - WHAT exists (project description, tech stack, commands, modules, directory structure)
10
+ - WHERE it is (paths, locations)
11
+
12
+ Do NOT explain WHY or HOW (that's Project Skill's job).
13
+
14
+ ## Exploration Strategy
15
+ 1. Read package.json, README.md, tsconfig.json (or equivalent for other languages)
16
+ 2. List top-level directories to identify modules
17
+ 3. Scan src/ directory structure (depth ≤3, directories only)
18
+ 4. Extract key commands from package.json scripts
19
+ 5. Identify tech stack from dependencies
20
+
21
+ ## Output Format
22
+ Output a markdown document between these exact delimiters:
23
+
24
+ \`\`\`snapshot-md
25
+ # Project Snapshot
26
+
27
+ ## Description
28
+ (一句话描述项目,≤80字,中文)
29
+
30
+ ## Tech Stack
31
+ (关键技术栈,逗号分隔,含版本,≤15项)
32
+
33
+ ## Commands
34
+ | Command | Description |
35
+ |---------|-------------|
36
+ | \`npm run build\` | 构建项目 |
37
+ | \`npm test\` | 运行测试 |
38
+ | ... | ... |
39
+
40
+ ## Modules
41
+ - **module-name** — 一句话职责描述(≤20字)
42
+ - **module-name** — 一句话职责描述
43
+ - ...
44
+
45
+ ## Source Tree
46
+ \`\`\`
47
+ src/
48
+ agent/
49
+ tools/
50
+ ui/
51
+ \`\`\`
52
+ \`\`\`snapshot-md
53
+
54
+ ## Rules
55
+ 1. **Description**: ≤80字,中文,从 README 和 package.json description 提炼。不要"一个..."开头。
56
+ 2. **Tech Stack**: ≤15项,逗号分隔,含版本。格式如 "TypeScript 5.x, Node.js ≥18, openai@^4"。
57
+ 3. **Commands**: 5-8个最重要的命令,从 package.json scripts 提取。Description ≤15字。用表格格式。
58
+ 4. **Modules**: 每个顶层模块目录一条。职责≤20字,只写 WHAT(做什么)。
59
+ 5. **Source Tree**: 只列目录(不列文件),深度≤3,用缩进表示层级,放在代码块中。
60
+ 6. 不要包含:设计决策、注意事项、坑点、约定 → 这些是 Skill 的职责。
61
+ 7. 总 markdown ≤ 2500 字符。
62
+ 8. 使用中文。
63
63
  `;
64
64
  /**
65
65
  * 生成 LLM 快照(markdown 格式)
@@ -67,17 +67,17 @@ src/
67
67
  * @param signal AbortSignal
68
68
  */
69
69
  export async function generateLLMSnapshot(root, signal) {
70
- const prompt = `请探索项目 ${root},生成项目快照(markdown 格式)。
71
-
72
- ## 探索步骤
73
- 1. 读取 package.json(或等效配置文件)
74
- 2. 读取 README.md
75
- 3. 列出顶层目录,识别模块
76
- 4. 扫描 src/ 目录结构(深度≤3,只列目录)
77
- 5. 从 package.json scripts 提取关键命令
78
- 6. 从 dependencies 识别技术栈
79
-
80
- ## 输出
70
+ const prompt = `请探索项目 ${root},生成项目快照(markdown 格式)。
71
+
72
+ ## 探索步骤
73
+ 1. 读取 package.json(或等效配置文件)
74
+ 2. 读取 README.md
75
+ 3. 列出顶层目录,识别模块
76
+ 4. 扫描 src/ 目录结构(深度≤3,只列目录)
77
+ 5. 从 package.json scripts 提取关键命令
78
+ 6. 从 dependencies 识别技术栈
79
+
80
+ ## 输出
81
81
  严格按照系统提示的 markdown 格式输出,不要添加额外解释。`;
82
82
  try {
83
83
  const result = await spawnAgent({
@@ -169,7 +169,7 @@ function refreshStatusBase(history, lastTurnUsage) {
169
169
  model: config.model,
170
170
  contextBar: renderContextBarInline(history),
171
171
  cwd: process.cwd(),
172
- modeTag: getAgentMode() === 'plan' ? 'plan' : 'auto',
172
+ modeTag: getAgentMode() === 'plan' ? 'Plan' : 'Auto',
173
173
  planSummary: hasActivePlan() ? getActivePlanSummary(process.stdout.columns ?? 80) : '',
174
174
  lastTurnUsage,
175
175
  });
@@ -6,10 +6,9 @@ import { Spinner } from '../ui/spinner.js';
6
6
  import * as layout from '../ui/layout.js';
7
7
  import { pruneAfterCompaction } from '../rollback/index.js';
8
8
  import { toText } from '../context/utils.js';
9
- /** 跨模块共享的上下文状态:agent lastUsage,compact 写 lastEstimate,repl 的 /context 读。
10
- * scheduler.ts 写最近一次调度日志(可选,repl 可读不到时 no-op)。
11
- * correction:API 实测 token / 估算 token 的校正系数(1.0 = 无偏差;>1 = 低估;<1 = 高估)。
12
- * 由 agent/core.ts 在每次 chat 响应后更新;compact/repl 在 usage 失效时同步重置。 */
9
+ export function createContextState() {
10
+ return { lastEstimate: 0, correction: 1 };
11
+ }
13
12
  export const contextState = {
14
13
  lastEstimate: 0,
15
14
  correction: 1,
@@ -240,9 +239,10 @@ async function defaultSummarize(older, focus) {
240
239
  * 不检查阈值——调用方(maybeCompact)决定是否调;/compact 直接调以强制压缩。
241
240
  */
242
241
  export async function compactHistory(history, opts) {
242
+ const state = opts.contextState ?? contextState;
243
243
  const schemaTokens = estimateToolSchemaTokens();
244
244
  const estimateBefore = estimateMessagesTokens(history) + schemaTokens;
245
- contextState.lastEstimate = estimateBefore;
245
+ state.lastEstimate = estimateBefore;
246
246
  const groups = groupFromEnd(history);
247
247
  // 保近期:从尾向前累积直到预算花完(至少保 1 组),永不劈开 group。
248
248
  const keepBudget = Math.floor(opts.window * 0.4);
@@ -313,9 +313,9 @@ export async function compactHistory(history, opts) {
313
313
  history.push(...rebuilt);
314
314
  pruneAfterCompaction(history);
315
315
  const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
316
- contextState.lastEstimate = estimateAfter;
317
- contextState.lastUsage = undefined;
318
- contextState.correction = 1;
316
+ state.lastEstimate = estimateAfter;
317
+ state.lastUsage = undefined;
318
+ state.correction = 1;
319
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`);
320
320
  return {
321
321
  compacted: true,
@@ -400,9 +400,9 @@ export async function compactHistory(history, opts) {
400
400
  history.push(...rebuilt);
401
401
  pruneAfterCompaction(history); // 摘要删了旧轮次 → 按存活轮次裁剪回滚日志
402
402
  const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
403
- contextState.lastEstimate = estimateAfter;
404
- contextState.lastUsage = undefined; // 压缩后旧 usage 失效,/context 改用估算
405
- contextState.correction = 1;
403
+ state.lastEstimate = estimateAfter;
404
+ state.lastUsage = undefined; // 压缩后旧 usage 失效,/context 改用估算
405
+ state.correction = 1;
406
406
  layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}压缩上下文${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
407
407
  // 抖动保护:压缩后仍超阈 → 提示 /clear,不死循环
408
408
  if (estimateAfter >= opts.threshold * opts.window) {
@@ -418,9 +418,9 @@ export async function compactHistory(history, opts) {
418
418
  }
419
419
  // 摘要失败:回退仅微压缩(tool content 已原地改),结构不动
420
420
  const estimateAfter = estimateMessagesTokens(history) + schemaTokens;
421
- contextState.lastEstimate = estimateAfter;
422
- contextState.lastUsage = undefined; // 结构虽未变,但 token 数已变,旧 usage 失效
423
- contextState.correction = 1;
421
+ state.lastEstimate = estimateAfter;
422
+ state.lastUsage = undefined; // 结构虽未变,但 token 数已变,旧 usage 失效
423
+ state.correction = 1;
424
424
  if (microcompactDone) {
425
425
  layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}微压缩旧工具结果${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
426
426
  return {
@@ -458,10 +458,10 @@ export async function compactHistory(history, opts) {
458
458
  * 强制走 compactHistory(manual/force 参数透传)。返 CompactResult 给 caller 文案展示。
459
459
  * 默认 manual=false 自动路径完全不变。
460
460
  */
461
- export async function maybeCompact(history, report, manualOpts) {
461
+ export async function maybeCompact(history, report, manualOpts, state = contextState) {
462
462
  const schemaTokens = estimateToolSchemaTokens();
463
463
  const est = estimateMessagesTokens(history) + schemaTokens;
464
- contextState.lastEstimate = est;
464
+ state.lastEstimate = est;
465
465
  const isManual = manualOpts?.manual === true;
466
466
  // 手动路径:旁路 autoCompact / report / 总阈三重门
467
467
  if (!isManual) {
@@ -485,6 +485,7 @@ export async function maybeCompact(history, report, manualOpts) {
485
485
  focus: manualOpts?.focus,
486
486
  manual: isManual,
487
487
  force: manualOpts?.force,
488
+ contextState: state,
488
489
  });
489
490
  if (isManual)
490
491
  return r;
@@ -4,7 +4,7 @@
4
4
  * - persist.ts:history 序列化到磁盘 + --resume / /resume
5
5
  * 依赖方向:session → {llm(摘要复用 chat), config, ui};llm 不反向依赖 session。
6
6
  */
7
- export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, contextState, } from './compact.js';
7
+ export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, contextState, createContextState, } from './compact.js';
8
8
  // ── Context Budget Scheduler 接缝 ────────────────────────────────────────
9
9
  // agent/core.ts 步前调 runScheduler(history, step):评估五区预算 → 按 ROI 调度
10
10
  // shrink_cold_tools / cap_hot_tools / compact_history。开关关闭时退化为 maybeCompact。
@@ -38,14 +38,14 @@ import { ui } from '../ui/theme.js';
38
38
  /** 创建 runAgentCore 闭包持有的 scheduler(每次 agent 启动一个新实例)。
39
39
  * observePush 当前只是占位:真正 L1/L2/L3 已由 cap / pruner / lifecycle 在 push 时跑;
40
40
  * 保留接口为后续「调度器注入 hotBoundary 给 lifecycle」演进留接缝。 */
41
- export function createBudgetScheduler() {
41
+ export function createBudgetScheduler(state = contextState) {
42
42
  const obs = {
43
43
  lastRunLog: null,
44
44
  observePush(_history, _idx) {
45
45
  // 占位:push-time 三闸(cap / pruner / lifecycle)已自动跑;此接缝供将来演进。
46
46
  },
47
47
  async runStep(history, step) {
48
- const report = evaluateBudget(history, config.contextWindowTokens, step, contextState.correction);
48
+ const report = evaluateBudget(history, config.contextWindowTokens, step, state.correction);
49
49
  const actions = scheduleActions(report);
50
50
  let compactHistoryCalled = false;
51
51
  for (const a of actions) {
@@ -55,7 +55,7 @@ export function createBudgetScheduler() {
55
55
  }
56
56
  else if (a.kind === 'compact_history') {
57
57
  // 路由到 maybeCompact(history, report)——按 ROI 调度(只有 history 超 / totalOver 才真压)
58
- await maybeCompact(history, report);
58
+ await maybeCompact(history, report, undefined, state);
59
59
  compactHistoryCalled = true;
60
60
  }
61
61
  // shrink_cold_tools L1/L2/L3 与 cap_hot_tools:已由 push-time 闸在每次 push 自动跑
@@ -71,14 +71,14 @@ export function createBudgetScheduler() {
71
71
  };
72
72
  obs.lastRunLog = log;
73
73
  // 暴露给 /context 共享读(repl / context 命令)
74
- contextState.schedulerLog = log;
74
+ state.schedulerLog = log;
75
75
  },
76
76
  };
77
77
  return obs;
78
78
  }
79
79
  /** 便捷:agent/core.ts 不需要每次 createBudgetScheduler,直接 runScheduler(history, step)。 */
80
- export async function runScheduler(history, step) {
81
- const s = createBudgetScheduler();
80
+ export async function runScheduler(history, step, state = contextState) {
81
+ const s = createBudgetScheduler(state);
82
82
  await s.runStep(history, step);
83
83
  }
84
84
  /** 手动 /compact 入口(repl):与自动路径完全一致——五区 ROI 调度,但 history 摘要强制执行。
@@ -136,6 +136,22 @@ export function totalRows() {
136
136
  export function committedRows() {
137
137
  return rows.length;
138
138
  }
139
+ /** 把缓冲尾部的视觉空白行归一化为恰好 count 条,并结束在“无当前行”状态。
140
+ * 供 markdown→mutation 边界使用;ANSI reset/颜色码和空格均按视觉空白处理。 */
141
+ export function normalizeTrailingBlankRows(count) {
142
+ if (hasCurrent) {
143
+ rows.push(rowStartSgr + curRaw + '\x1B[0m');
144
+ }
145
+ const isBlank = (line) => line.replace(/\x1b\[[0-9;]*m/g, '').trim().length === 0;
146
+ while (rows.length > 0 && isBlank(rows[rows.length - 1]))
147
+ rows.pop();
148
+ for (let i = 0; i < Math.max(0, count); i++)
149
+ rows.push('\x1B[0m');
150
+ curSgr = '';
151
+ rowStartSgr = '';
152
+ curRaw = '';
153
+ hasCurrent = false;
154
+ }
139
155
  /** 取绝对行索引(0-based,含当前行)的原始自洽行;越界返 null。供鼠标选区文本提取。 */
140
156
  export function lineAt(abs) {
141
157
  const all = snapshot();
package/dist/ui/layout.js CHANGED
@@ -3,6 +3,7 @@ import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, truncate
3
3
  import { ui } from './theme.js';
4
4
  import * as content from './content.js';
5
5
  import * as mouse from './mouse.js';
6
+ import { shiftBatchesAfter } from './batch.js';
6
7
  import { copyToClipboard, readClipboard } from './clipboard.js';
7
8
  import { renderMarkdown } from './markdown.js';
8
9
  // ── 内部状态 ──
@@ -541,8 +542,9 @@ export function contentInsertAfter(after, lines) {
541
542
  scrollOffset = Math.min(delta, Math.max(0, content.totalRows() - g.contentBottom));
542
543
  }
543
544
  }
544
- // batch 摘要行索引平移
545
- void import('./batch.js').then((m) => m.shiftBatchesAfter(after, delta));
545
+ // 必须同步平移 batch 索引。若延迟到 dynamic import.then,下一条 mutation 可能已经
546
+ // 按插入后的 buffer 创建;旧回调会再平移它一次,导致详情插到空行之后。
547
+ shiftBatchesAfter(after, delta);
546
548
  repaintViewport();
547
549
  }
548
550
  /**
@@ -570,14 +572,32 @@ export function contentDeleteFrom(startIdx, n) {
570
572
  if (scrolled) {
571
573
  scrollOffset = Math.max(0, scrollOffset - delta);
572
574
  }
573
- // batch 摘要行索引平移(用 -delta 表示后段索引前移)
574
- void import('./batch.js').then((m) => m.shiftBatchesAfter(startIdx, -delta));
575
+ // batch 摘要行索引平移(用 -delta 表示后段索引前移),同插入路径必须同步。
576
+ shiftBatchesAfter(startIdx, -delta);
575
577
  repaintViewport();
576
578
  }
577
579
  /** 当前内容物理行总数(含当前未提交行)。供 batch 渲染器在 endBatch 时定位摘要行索引。 */
578
580
  export function totalRows() {
579
581
  return content.totalRows();
580
582
  }
583
+ /** 正文→mutation 首摘要前,把尾部间距强制归一为一条视觉空行。 */
584
+ export function normalizeMutationBoundary() {
585
+ if (!active || !ui.isTTY)
586
+ return;
587
+ if (mdActive)
588
+ commitMd();
589
+ const totalBefore = content.totalRows();
590
+ content.normalizeTrailingBlankRows(1);
591
+ const g = getGeo();
592
+ const delta = content.totalRows() - totalBefore;
593
+ if (scrollOffset > 0 && delta !== 0) {
594
+ scrollOffset = Math.max(0, Math.min(scrollOffset + delta, Math.max(0, content.totalRows() - g.contentBottom)));
595
+ }
596
+ contentRow = Math.min(content.committedRows() + 1, g.contentBottom);
597
+ contentCol = 1;
598
+ if (scrollOffset === 0)
599
+ repaintViewport();
600
+ }
581
601
  /** 原地刷新一条内容行(行数不变),用于运行中的工具 batch 更新计数。 */
582
602
  export function contentReplaceLine(absIdx, line) {
583
603
  if (!active)
@@ -1273,11 +1293,11 @@ function composeModelLine(status, cols) {
1273
1293
  const ctxW = ansiDisplayWidth(ctx);
1274
1294
  // 左段:模式标识 + 切换提示(灰)+ 本轮 token chip。token chip 仅展示总量,用 mid 灰,不抢主色。
1275
1295
  const modeTag = status.modeTag ?? '';
1276
- const modeColor = modeTag === 'plan' ? ui.yellow : ui.accent;
1296
+ const modeColor = modeTag === 'Plan' ? ui.yellow : ui.accent;
1277
1297
  const modePart = modeTag ? `${modeColor}${modeTag}${ui.reset}` : '';
1278
1298
  const modeW = modeTag ? displayWidth(modeTag) : 0;
1279
1299
  // 切换提示:告诉用户怎么切模式。灰(dim)降优先级,不与 modeTag 抢色;只在有 modeTag 时出现。
1280
- const HINT = 'Shift+Tab 切换模式';
1300
+ const HINT = 'Shift+Tab 切换';
1281
1301
  const hintW = modeTag ? displayWidth(HINT) : 0;
1282
1302
  const hintPart = modeTag ? `${ui.dim}${HINT}${ui.reset}` : '';
1283
1303
  const tokChip = formatTurnTokenChip(status.lastTurnUsage);
@@ -13,7 +13,7 @@
13
13
  // clipAnsiLine 兜底,绝不溢出。颜色一律嵌入式 ANSI(ui.*)写进字符串。
14
14
  import { highlight, supportsLanguage } from 'cli-highlight';
15
15
  import { ui, getThemeVersion } from './theme.js';
16
- import { charWidth, displayWidth, ansiDisplayWidth } from './render.js';
16
+ import { charWidth, displayWidth, ansiDisplayWidth, stripAnsi } from './render.js';
17
17
  const RESET = '\x1b[0m';
18
18
  // theme 无 italic/strike,自备(TTY 感知:!isTTY 退化为空串,同 ui.* 契约)。
19
19
  const ITALIC = ui.isTTY ? '\x1b[3m' : '';
@@ -577,13 +577,14 @@ function renderMarkdownImpl(text, cols) {
577
577
  // 独立 markdown 段的外部间距由 agent/batch 边界统一管理。流式后端可能把
578
578
  // "\n\n" 与首段正文拆成不同 chunk;仅清洗首个正文 chunk 不够,因为 mdBuf
579
579
  // 累积重渲染时这些换行仍会变成段首空行。这里最终兜底,正文段永不自带前导空行。
580
- while (out.length > 0 && out[0] === '')
580
+ const isVisualBlank = (line) => stripAnsi(line).trim().length === 0;
581
+ while (out.length > 0 && isVisualBlank(out[0]))
581
582
  out.shift();
582
583
  // 末尾不留空行:agent onText 后接 onToolCall 的 contentWrite('\n') 会补 1 空行分隔正文与 ● 行;
583
584
  // 若 md 末尾自带空行(段落/代码块后)则叠成 2 空行。裁掉末尾连续空行,让 onToolCall / 轮末
584
585
  // contentWrite('\n') 恰好补 1 行(与改造前 raw 文本行为一致)。块间空行(flushPara/flushCode 中段
585
586
  // push 的)不受影响——只裁末尾。
586
- while (out.length > 0 && out[out.length - 1] === '')
587
+ while (out.length > 0 && isVisualBlank(out[out.length - 1]))
587
588
  out.pop();
588
589
  return out;
589
590
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {