mocode-ai 0.4.3 → 0.4.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/README.md CHANGED
@@ -150,6 +150,8 @@ The agent operates in **the working directory it was launched from** — to have
150
150
  | `memory_update` | Edit a memory in place (id unchanged; correct stale facts / update summary / toggle pin) |
151
151
  | `memory_forget` | Forget a memory: archived by default (recoverable), `mode=delete` for a hard delete (pinned memories can't be deleted) |
152
152
 
153
+ The five `memory_*` tools are gated on `MEMORY_ENABLED=true` at startup; toggle at runtime with `/memory_switch` (REPL restart required, by design — see Skills section for the difference between Tier-1 `MOCODE.md` and Tier-2 memory).
154
+
153
155
  ## Slash commands
154
156
 
155
157
  | Command | Purpose |
@@ -162,6 +164,7 @@ The agent operates in **the working directory it was launched from** — to have
162
164
  | `/resume` | Resume a saved session |
163
165
  | `/rollback` | Menu to pick a turn to roll back to (↑↓ · Enter) |
164
166
  | `/memory` | Show memory library: entry count + recent index |
167
+ | `/memory_switch` | Toggle Tier-2 memory on/off (REPL restart required — by design) |
165
168
  | `/reflect` | Manually trigger a background memory reflection pass |
166
169
  | `/model` | Configure the LLM (baseURL / apiKey / model / context window), applied immediately + persisted |
167
170
  | `/init` | Scan the project and generate `MOCODE.md` project memory (dispatched to the agent) |
@@ -198,6 +201,13 @@ MoCode automatically scans the following directories for skills (each skill is a
198
201
 
199
202
  A skill's `description` is injected into the system prompt (progressive disclosure, tier 1); the model calls `use_skill` to load the full body (tier 2) only when the task is relevant. Use `/skills` to see discovered skills.
200
203
 
204
+ ## Project memory (MOCODE.md)
205
+
206
+ MoCode has a **two-tier memory** model distinct from skills:
207
+
208
+ - **Tier-1 — `MOCODE.md` (auto-loaded every session):** Markdown project memory that gets concatenated into the system prompt on every turn. Discovery walks `~/.mocode/MOCODE.md` → every `MOCODE.md` from the cwd up to the filesystem root (far→near, near wins). On overflow the body is truncated with a marker pointing back at the files. Generate or refresh one with `/init`, or write it by hand — it's plain Markdown, no schema. `MOCODE.md` is also where the agent itself persists "next-session facts" it deduces (architecture, conventions, pitfalls).
209
+ - **Tier-2 — `memory_*` tool library (agent-driven, opt-in):** Discrete tagged records (`decision` / `fact` / `pitfall` / `reference` / `feedback`) with recall-count-based decay (30-day → archived; 90-day → GC). The agent saves / searches / updates / forgets via tools; titles go in the system-prompt index (≤50), bodies fetched on demand via `memory_search`. Off by default; toggle with `MEMORY_ENABLED=true` at startup or `/memory_switch` (REPL restart required).
210
+
201
211
  ## Type checking
202
212
 
203
213
  ```bash
package/README.zh-CN.md CHANGED
@@ -150,6 +150,8 @@ agent 工作在**启动时所在的工作目录**——想让它操作某个项
150
150
  | `memory_update` | 原地改一条记忆(id 不变;纠正过时事实 / 改摘要 / 改 pin) |
151
151
  | `memory_forget` | 遗忘记忆:默认归档(可复活),`mode=delete` 硬删(pinned 拒删) |
152
152
 
153
+ 5 个 `memory_*` 工具受启动时 `MEMORY_ENABLED=true` 总开关控制;运行时切换用 `/memory_switch`(需重启 REPL,刻意为之,见下「项目记忆」小节区分 Tier-1 / Tier-2)。
154
+
153
155
  ## 斜杠命令
154
156
 
155
157
  | 命令 | 作用 |
@@ -162,6 +164,7 @@ agent 工作在**启动时所在的工作目录**——想让它操作某个项
162
164
  | `/resume` | 续接已保存的会话 |
163
165
  | `/rollback` | 菜单选轮次回滚(↑↓ · Enter) |
164
166
  | `/memory` | 看记忆库:条目数 + 近期索引 |
167
+ | `/memory_switch` | 切换 Tier-2 记忆开关(需重启 REPL,刻意为之) |
165
168
  | `/reflect` | 手动触发一次后台记忆反思 pass |
166
169
  | `/model` | 配置大模型(baseURL / apiKey / model / 上下文窗口),即时生效 + 持久化 |
167
170
  | `/init` | 扫描项目生成 `MOCODE.md` 项目记忆(发给 agent 执行) |
@@ -198,6 +201,13 @@ mocode 自动扫描以下目录的 skill(每个 skill 是 `<name>/SKILL.md`,带
198
201
 
199
202
  skill 的 `description` 注入系统提示(渐进式披露第①层),模型只在任务相关时调 `use_skill` 加载完整正文(第②层)。用 `/skills` 查看已发现的 skill。
200
203
 
204
+ ## 项目记忆(MOCODE.md)
205
+
206
+ mocode 的**双层记忆**模型,跟 Skills 是两件事:
207
+
208
+ - **Tier-1 — `MOCODE.md`(每轮自动加载):** Markdown 项目记忆,每轮拼进 system prompt。发现路径:`~/.mocode/MOCODE.md` → 从 cwd 往上逐级 `MOCODE.md`(远→近拼接,近的覆盖更突出);超长截断并标注原始文件。运行 `/init` 生成或刷新,纯 Markdown,可手写,无 schema。agent 自己推得的「下次要记住的事实」(架构/约定/坑位)也写在这里。
209
+ - **Tier-2 — `memory_*` 工具库(agent 主导,需启用):** 离散带标签条目(`decision` / `fact` / `pitfall` / `reference` / `feedback`),按召回计数衰减(30 天 → archived,90 天 → 硬删 GC)。agent 用工具存 / 搜 / 改 / 删;索引(标题)进系统提示(≤50 条),正文按需 `memory_search` 取。默认关,启动 `MEMORY_ENABLED=true` 或 REPL 内 `/memory_switch`(需重启 REPL,刻意为之)。
210
+
201
211
  ## 类型检查
202
212
 
203
213
  ```bash
@@ -7,10 +7,13 @@ import { Spinner } from '../ui/spinner.js';
7
7
  import { summarizeToolCall, summarizeToolResult, truncateDisplay, fmtElapsed, } from '../ui/render.js';
8
8
  import { renderFileChange } from '../ui/diff.js';
9
9
  import * as layout from '../ui/layout.js';
10
+ import * as batch from '../ui/batch.js';
10
11
  import { beginTurn } from '../rollback/index.js';
11
12
  import { config } from '../config/index.js';
12
13
  import { runAgentCore, isMutationTool, } from './core.js';
13
14
  import { createPetHooks } from '../pet/state.js';
15
+ /** 当前 turn 的 batch id(runAgent 内闭包变量;一条 turn 一轮 tool batch 结束即清空)。 */
16
+ let currentBatchId = null;
14
17
  /** 取 userInput 的首行:字符串直接 split;多模态 parts 找首个 text part 再 split。 */
15
18
  function firstLineOf(ui) {
16
19
  if (typeof ui === 'string')
@@ -18,15 +21,22 @@ function firstLineOf(ui) {
18
21
  const first = ui.find((p) => p.type === 'text');
19
22
  return first?.text.split('\n')[0] ?? '';
20
23
  }
21
- /** 工具调用 ● 头:工具名 + 参数摘要(按 tool_calls 原顺序打印,让用户看到本轮跑哪些工具)。 */
24
+ /** 工具调用 ● 头:工具名 + 参数摘要(按 tool_calls 原顺序打印,让用户看到本轮跑哪些工具)。
25
+ * 重构后改为累积到 BatchRenderer,onToolBatchEnd 时统一打摘要行;
26
+ * 展开/折叠由 BatchRenderer + 鼠标 release 决定,本函数不再直接写屏。 */
22
27
  function writeToolHeader(tc) {
23
- const summary = summarizeToolCall(tc.name, tc.arguments);
24
- layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}${tc.name}${ui.reset} ${ui.dim}${summary}${ui.reset}\n`);
28
+ if (!currentBatchId)
29
+ currentBatchId = batch.beginBatch();
30
+ batch.recordCall(currentBatchId, tc.name, summarizeToolCall(tc.name, tc.arguments));
25
31
  }
26
- /** 渲染工具结果:mutation 成功走 diff 块(行号 + 语法高亮,仿 Claude Code);其余走一行 preview。 */
32
+ /** 渲染工具结果:mutation 成功走 diff 块(行号 + 语法高亮,仿 Claude Code);其余走一行 preview。
33
+ * 同 writeToolHeader,改为累积到 BatchRenderer(只缓存字符串,不写屏)。 */
27
34
  function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
35
+ if (!currentBatchId)
36
+ return;
37
+ let diff = null;
28
38
  if (isMutationTool(tc.name) && parsed && !output.startsWith('错误')) {
29
- layout.contentWrite(renderFileChange({
39
+ diff = renderFileChange({
30
40
  path: String(parsed.path ?? ''),
31
41
  kind: tc.name === 'edit_file' ? 'edit' : 'write',
32
42
  oldStr: tc.name === 'edit_file'
@@ -34,14 +44,10 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
34
44
  : preWriteOld,
35
45
  newStr: String((tc.name === 'edit_file' ? parsed.new_string : parsed.content) ?? ''),
36
46
  startLine: tc.name === 'edit_file' ? editStartLine : 1,
37
- }));
38
- }
39
- else {
40
- const preview = summarizeToolResult(tc.name, output);
41
- if (preview) {
42
- layout.contentWrite(` ${ui.gray}↳ ${preview}${ui.reset}\n`);
43
- }
47
+ });
44
48
  }
49
+ const preview = diff ? '' : summarizeToolResult(tc.name, output);
50
+ batch.recordResult(currentBatchId, tc.name, preview, diff);
45
51
  }
46
52
  /**
47
53
  * agent 核心循环(主 agent,TUI 渲染版):
@@ -63,6 +69,7 @@ onContextUpdate) {
63
69
  // 开新轮次(回滚用):首行截断 40,供 /rollback 轮次菜单展示。
64
70
  beginTurn(truncateDisplay(firstLineOf(userInput), 40));
65
71
  layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
72
+ currentBatchId = null; // 新 turn 清旧 batch id(防上 turn 残留)
66
73
  // spinner:状态行最前面转圈(思考中 / 生成 / 执行 工具时,状态栏 lead 位显帧 + 文字)。
67
74
  // 经 setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
68
75
  // 不画内容区续写位——内容区在等待期间保持干净,首 token 到达即从续写位开始写正文。
@@ -101,7 +108,16 @@ onContextUpdate) {
101
108
  onToolStart: (name) => spinner.start(`执行 ${name}`),
102
109
  onToolDone: () => spinner.stop(),
103
110
  onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine),
104
- onToolBatchEnd: () => layout.contentWrite('\n'),
111
+ onToolBatchEnd: () => {
112
+ // 收尾:把累积的 batch 渲染成单行摘要(批内 N 个 tool 调用共用一行,
113
+ // 鼠标点击该行可展开完整明细——见 ui/batch.ts)。无 batch(模型未调工具)则补空行保持间距。
114
+ if (currentBatchId) {
115
+ const id = currentBatchId;
116
+ currentBatchId = null;
117
+ batch.endBatch(id, layout);
118
+ }
119
+ layout.contentWrite('\n');
120
+ },
105
121
  onNoReply: () => layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`),
106
122
  onMaxSteps: () => layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${config.maxSteps}),本轮停止。${ui.reset}\n`),
107
123
  onAbort: () => {
@@ -109,6 +125,7 @@ onContextUpdate) {
109
125
  if (lastChar && lastChar !== '\n')
110
126
  layout.contentWrite('\n');
111
127
  layout.contentWrite(`${ui.dim}(已中断)${ui.reset}\n`);
128
+ currentBatchId = null; // 丢弃未收尾 batch
112
129
  },
113
130
  onDone: (elapsedMs, usage) => {
114
131
  const tok = formatTurnTokens(usage);
@@ -99,7 +99,7 @@ function buildPlanModeSuffix() {
99
99
  ## ⛯ PLAN MODE (active now)
100
100
  You are in PLAN mode: investigate and design only — do NOT execute or change anything.
101
101
  - Your editing / command tools (write_file, edit_file, run_command) have been REMOVED from your tool list. Use only the read-only tools available to you (read_file, glob, grep, codegraph, web_search, web_fetch, use_skill, ask_human) to investigate.
102
- - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. Your FIRST action for code exploration should be the codegraph tool (explore/node) when a .codegraph/ index existsnot read_file/grep. Use read_file/grep only to fill gaps codegraph leaves.
102
+ - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. (Codegraph is the default first action for code exploration see Workflow in the base prompt. But first check whether this conversation already covers it don't re-explore something already retrieved earlier in this session.)
103
103
  - Then produce a clear, actionable implementation plan: files to change (with paths), what to change in each and why, the ordered steps, edge cases to handle, and how to verify (typecheck / tests / build). Be specific enough to execute against.
104
104
  - When the plan is complete and ready for review, you MUST call the \`ask_human\` tool to surface the plan to the user for approval — do NOT just output the plan as plain text and STOP. ask_human renders a real interactive selection panel inside the TUI; plain-text approval questions in your reply are hard to see and easy to miss.
105
105
  - Pass the \`ask_human\` tool a concise plan summary (goal + files/areas to change + key risks + verification) and these three options so the user can decide in one click:
@@ -115,7 +115,7 @@ You are in PLAN mode: investigate and design only — do NOT execute or change a
115
115
  ## ⛯ PLAN MODE (active now)
116
116
  You are in PLAN mode: investigate and design only — do NOT execute or change anything.
117
117
  - Your editing / command / memory-write tools (write_file, edit_file, run_command, memory_save, memory_update, memory_forget) have been REMOVED from your tool list. Use only the read-only tools available to you (read_file, glob, grep, codegraph, web_search, web_fetch, use_skill, ask_human, memory_search, memory_list) to investigate.
118
- - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. Your FIRST action for code exploration should be the codegraph tool (explore/node) when a .codegraph/ index existsnot read_file/grep. Use read_file/grep only to fill gaps codegraph leaves.
118
+ - Research thoroughly: locate the relevant code, trace call paths, and understand existing patterns and conventions before designing. (Codegraph is the default first action for code exploration see Workflow in the base prompt. But first check whether this conversation already covers it don't re-explore something already retrieved earlier in this session.)
119
119
  - Then produce a clear, actionable implementation plan: files to change (with paths), what to change in each and why, the ordered steps, edge cases to handle, and how to verify (typecheck / tests / build). Be specific enough to execute against.
120
120
  - When the plan is complete and ready for review, you MUST call the \`ask_human\` tool to surface the plan to the user for approval — do NOT just output the plan as plain text and STOP. ask_human renders a real interactive selection panel inside the TUI; plain-text approval questions in your reply are hard to see and easy to miss.
121
121
  - Pass the \`ask_human\` tool a concise plan summary (goal + files/areas to change + key risks + verification) and these three options so the user can decide in one click:
@@ -137,33 +137,44 @@ export function buildBasePrompt() {
137
137
  : '- For complex or multi-step tasks, the user may switch to PLAN mode (Shift+Tab): your editing/command tools are then removed from your tool list, and you must research with read-only tools only and produce a step-by-step plan (no execution). On approval the session returns to auto mode to execute the plan.';
138
138
  return `You are mocode, a terminal coding agent. You complete programming tasks through a "think → call tool → observe result → think again" loop until the problem is solved. Reply to the user in Chinese.
139
139
 
140
+ ## 模式 (Modes)
141
+ ${autoAllToolsLine}
142
+ ${planLine}
143
+
140
144
  ${PLATFORM_NOTE}
141
145
 
142
146
  ## Step / Turn Economy (read this first — saves LLM calls)
143
147
  - **Minimize turns**: each user message costs at least one LLM call, and history grows every step until threshold-triggered compact fires (extra call). If a request contains ≥2 independent sub-goals (e.g. "改 X 然后再优化 Y"), ask the user to split them into separate turns rather than chaining both in one go. State this politely: "这条包含 N 个独立目标,建议拆成 N 次对话,以避免上下文膨胀。"
144
- - **Batch read-only tools in parallel**: in a single assistant turn, emit multiple tool_calls together consecutive read-only tools (read_file, glob, grep, codegraph, web_search, web_fetch) auto-execute in parallel. Do NOT call them serially across turns when you could emit them together in one turn. This is the single biggest step-saver.
148
+ - **Context check before any call — do this before the batching rule below**: before planning tool calls for this turn, first check whether the current conversation, an earlier tool result, or a file/symbol already read in this session already answers it. If it does, skip the call and answer directly. Only call a tool when the info is genuinely missing, may be stale (the underlying file/state changed since you last read it), or was never retrieved. This applies to every tool — codegraph, grep, web_search, run_command — not just read_file.
149
+ - ✅ already have it: user asks "刚才那个函数在哪个文件" after codegraph_explore returned it two turns ago → answer from that result, no new call.
150
+ - ❌ wasteful: re-running grep/codegraph for a symbol whose location this same conversation already returned, "just to be sure".
151
+ - **Plan the full turn, then emit it as one batch — this is the single biggest step-saver**: before emitting anything, enumerate every read / edit / command you'll need for this sub-goal, then return them together as one set of tool_calls (reads run in parallel, writes/commands run in the order given). Don't emit one call, observe, then emit the next in a follow-up turn when you could have planned both upfront.
152
+ - ✅ one turn: \`[read_file A, read_file B, edit_file A, run_command 'npm test']\`
153
+ - ❌ four turns: \`[read_file A]\` → \`[read_file B]\` → \`[edit_file A]\` → \`[run_command 'npm test']\`
154
+ - **Batch read-only tools in parallel**: consecutive read-only tools (read_file, glob, grep, codegraph, web_search, web_fetch) auto-execute in parallel within one turn — this is the concrete read-side case of the rule above. Do NOT call them serially across turns when you could emit them together.
145
155
  - **Decide before reading**: do not read files "just to see"; plan the 2-3 file paths you actually need, then emit them as one batched tool_calls turn.
156
+ - **Chain read→edit→verify in one turn**: when the edit is obvious after a read, call edit_file (and verify with run_command) in the SAME response — don't split into 3 separate turns.
157
+ - **Verify once at the end of an edit chain, not after every edit**: after batching a set of related edits, run a single typecheck / test / build command to verify the whole change together. Running a verify command after each individual edit_file call wastes turns — batch the edits, then verify once.
146
158
  - **Don't repeat failed calls**: if the same tool call fails or returns the same content 3 times in this turn, switch strategy (use a different tool, ask the user, or re-read the tool description) — don't keep retrying the same shape.
159
+ - **Don't re-read a file you already have, unless it may have changed**: if you (or an earlier step in this session) already read a file's relevant content and nothing has touched it since, edit directly from that content instead of calling read_file again "to be safe". This does NOT apply when the file was edited (by you or externally) since your last read, when a prior edit may have shifted line numbers you're about to target, or right after a compact where you're unsure the surviving context is accurate — in those cases re-reading is expected and correct, not wasteful.
147
160
 
148
161
  ## Workflow
149
162
  - Understand before acting: when unsure about requirements or code state, explore first; don't assume.
150
163
  - **Code exploration first action**: before reading files with read_file or searching with grep, check if a .codegraph/ index exists. If it does, use the codegraph tool (explore for questions/features, node for a specific symbol) as your FIRST step — it returns source + call paths in one shot. Only fall back to read_file/grep when codegraph misses, you need just-changed content, or you're editing a known small file. Build the index with \`codegraph init\` if none exists.
151
164
  - Small steps: break tasks into verifiable sub-steps. Before each step, think clearly about what to change and why.
152
165
  - Verify after change: run typecheck / tests / build via run_command to confirm it works. Never claim done without verification.
166
+ - **Web search when freshness matters**: for tasks involving UI/interaction/copy/visual design, new SDKs or APIs, CVE/version upgrades, or anything likely past your training cutoff, web_search FIRST to ground your work in current material — don't fall back on stale templates (gradient+emoji defaults, "I hope this message finds you well" openers, generic AI-flavored phrasing). Routine coding (bug fixes, refactors, tests, internal docs) doesn't need it.
153
167
 
154
168
  ## Tool Guidelines
155
169
  - See each tool's own description for parameters and usage; this section covers selection strategy and pitfalls only.
156
- - **Prefer codegraph for code exploration**: when understanding/locating code, tracing call chains, or assessing impact of changes, if a .codegraph/ index exists, use the codegraph tool first (explore to query by question, node to look up a single symbol) it returns relevant source + call paths in one shot, more accurate and economical than piecing together via read_file/grep. Fall back to read_file / grep / glob only when codegraph is unavailable (no index), misses, you need to see just-changed content, or you're editing a single known small file. Build the index first with \`codegraph init\` if none exists.
157
- - Before editing code, read_file to confirm actual content (with line numbers); don't guess from memory.
170
+ - **If the user gave a precise path or symbol, go directly**: read_file or codegraph node itdon't pre-validate with glob/grep.
171
+ - Before editing code, read_file to confirm actual content (with line numbers); don't guess from memory. (Skip if you already read this exact content earlier in this session and nothing has changed it since — see Step Economy above.)
158
172
  - For local edits use edit_file: old_string must be unique and match exactly (including indentation/newlines); include surrounding context lines to ensure uniqueness. Use write_file for new files or full rewrites.
159
- - Use glob to find file paths, grep to search content. **Don't use run_command for file-level checks** (existence / listing / type) — those have no clean cmd.exe equivalent and Windows path escaping fails often. Use \`glob\` to list, and just call \`read_file\` to test existence (returns ENOENT as a clean error string). The earlier rule against \`run_command\` for cat/sed/find/grep still applies.
160
- - run_command runs per platform (cmd on Windows, bash elsewhere); state intent before running commands with side effects (deleting files, installing packages, git push, resets, etc.).
161
- - Use web_search for information beyond training data (new versions, news, real-time data, latest APIs); don't answer potentially outdated info from memory.
162
- - Use web_fetch to read a specific URL (a link from search results, or a URL given by the user); it only fetches static HTML — if a JS-rendered page yields no body, switch to web_search (its results include cleaned body text).
173
+ - Use glob to find file paths, grep to search content. **Don't use run_command for file-level checks** (existence / listing / type) — those have no clean cmd.exe equivalent and Windows path escaping fails often. Use \`glob\` to list, and just call \`read_file\` to test existence (returns ENOENT as a clean error string).
174
+ - run_command has side effects on the host state intent before invoking (delete, install, push, reset, etc.).
163
175
  - Call ask_human when you hit a decision point requiring user input (multiple implementation approaches, unclear intent, or needing extra info to proceed) — list options for the user to pick (they can also choose "custom input" to answer freely). Don't call it frequently when the task is clear and you can decide yourself; if the user cancels, switch approach or proceed with available info — don't re-ask the same question.
164
- - **Drop irrelevant context proactively**: call drop_context to stub-replace tool results in history that are no longer needed. This is your primary lever for keeping context lean — every step grows history until the threshold-triggered compact fires (which costs an extra LLM call and is more aggressive). Call when any of: (a) you just finished a grep/read sweep and only 1-2 hits mattered; (b) history has >20 tool messages and you are early in the task; (c) you switched sub-goals and the old sub-goal's exploration is dead weight. The call itself adds ~300 tokens, so only call when freed tokens clearly exceed that (≥1 large grep hit or ≥2 medium results). Do NOT call when near completion, when history is short (<10 tool messages), or when the candidate results are still in active use. It preserves tool_call_id pairing (only content changes); the system prompt and current turn are never dropped. Use filters (toolNames / contains) to target precisely.
165
- - **Observation lifecycle is automatic**: behind the scenes every tool result goes LIVE→REFERENCED→OBSOLETE→STUB. grep/glob/codegraph/web_search/web_fetch are always kept as REFERENCED (never auto-stubbed) because they may surface multiple candidates. read/edit/write results that nobody consumes after two more consumer pushes get auto-stubbed. This is zero-cost (static analysis, no LLM call). You don't need to manage lifecycle yourself just trust that stale tool results get pruned.
166
- - **Batch independent tool calls in one turn**: the executor runs ALL returned tool calls before the next LLM call, so emitting [read_file, glob, read_file] together is dramatically cheaper than three separate turns. Default to bundling exploration reads and parallel writes.
176
+ - **Trim context when stale**: when an old tool result is dead weight (sub-goal done, no downstream consumer, or superseded by a later read), call drop_context to stub it; otherwise rely on automatic pruning.
177
+ - **Batch writes and commands too, not just reads**: the executor runs ALL returned tool_calls (reads, writes, commands) before the next LLM call. Emit independent edit_file / write_file / run_command in one response when the chain is clear don't serialize them across turns just because they have side effects. (The read-only batching note in Step Economy applies to writes the same way.)
167
178
  - **Chain shell workflows in a single \`run_command\`**: use \`&&\`, \`;\`, \`|\`, \`>\`, heredocs to fold multi-step scripts (\`mkdir -p x && cat > x/file.ts <<'EOF' ... EOF && npm test\`) into one call. Only emit a follow-up turn when the result forces a decision (error, ambiguous output, branching logic).
168
179
 
169
180
  ## Large file writes (avoid token-cap truncation)
@@ -182,18 +193,14 @@ ${PLATFORM_NOTE}
182
193
  - Operate only within authorized scope; when unsure, ask — don't guess.
183
194
 
184
195
  ${memorySection}
185
- ${autoAllToolsLine}
186
- ${planLine}
187
196
 
188
- ## Working notepad (todolist) — checklist for complex tasks
189
- - For **complex multi-step tasks** (≥3 file changes OR ≥5 tool calls expected OR user says "先计划再执行" / "plan then do" / "按步骤来"), call the \`todolist\` tool FIRST to write a plan to \`.mocode/plans/<id>.md\`, then execute step by step, calling \`todolist update\` to mark progress. For trivial single-step tasks, skip it and just execute.
190
- - The plan is file-backed (survives context compression, user can see/edit). The active plan summary is auto-injected into the system prompt each turn, so you can re-read it via \`todolist read\` whenever you're unsure of your place.
191
- - Single plan per session: \`todolist create\` refuses if an in-progress plan already exists — finish or abandon it first.
192
- - **Lifecycle** (5 actions total): \`create\` / \`read\` / \`update\` / \`add_step\` / \`finish\` for normal flow. \`finish plan_status=finished\` AUTO-ARCHIVES the plan to \`.mocode/plans/archive/<id>.md\` (history preserved, active dir stays clean). To revisit old plans: \`list scope=archived\` (or \`all\`) + \`unarchive id=<id>\` to bring back. \`delete id=<id>\` permanently removes (any location); cannot delete the currently active plan.
193
- - Don't over-use it: for a single edit or a quick lookup, \`todolist\` is overhead. The threshold is "this needs ≥3 steps OR I might forget the plan after context compaction."
197
+ ## Working notepad (todolist) — for multi-step tasks
198
+ - For tasks spanning **≥2 independent modules** OR when the user asks for stepwise progress ("先计划再执行" / "plan then do" / "按步骤来"), call \`todolist create\` first; update as you go. Skip for single-file edits or quick lookups.
199
+ - Plan is file-backed (\`todolist read\` to re-orient). See the tool description for the full action set.
194
200
 
195
201
  ## Termination & Reporting
196
202
  - Stop immediately when no more tools are needed; give conclusions directly.
203
+ - **No flattery / no preamble in conclusions**: skip "Sure", "好的", "我已经完成了" and similar no-information prefixes — jump straight to substance.
197
204
  - Report honestly: say success when successful, say where you're stuck when failing, and mention anything skipped. Reference code in "path:line" format (e.g., src/index.ts:42). Keep it concise.`;
198
205
  }
199
206
  /**
@@ -26,6 +26,9 @@ const KNOWN_TEXT_ONLY_PREFIXES = [
26
26
  'babbage-',
27
27
  'davinci-',
28
28
  'gpt-4o-mini-search', // 搜索专用,无视觉入口
29
+ // MiniMax M2 系列(M2 / M2.1 / M2.5 / M2.7,含各自 -highspeed 变体):纯文本,无视觉输入。
30
+ // 官方文档明确仅 MiniMax-M3 支持 image/video content parts;M2.x 传 image_url 会被拒。
31
+ 'minimax-m2',
29
32
  ];
30
33
  const KNOWN_VISION_FAMILIES = [
31
34
  'gpt-4o',
@@ -54,6 +57,7 @@ const KNOWN_VISION_FAMILIES = [
54
57
  'minicpm-v',
55
58
  'glm-4v',
56
59
  'yi-vl',
60
+ 'minimax-m3', // 官方文档:仅 M3 支持 image_url/video_url content parts
57
61
  ];
58
62
  /** 归一化:小写、去空白;用于前缀比较。 */
59
63
  function normalize(model) {
@@ -11,11 +11,13 @@ import { ui, setTheme, getTheme, listThemes, themeExists } from '../ui/theme.js'
11
11
  import { bannerString, displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
12
12
  import * as layout from '../ui/layout.js';
13
13
  import * as mouse from '../ui/mouse.js';
14
+ import * as batch from '../ui/batch.js';
14
15
  import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptThemePicker, promptRevertChoice, } from '../ui/prompt.js';
15
16
  import { promptIntervention } from '../ui/intervention.js';
16
17
  import { tools } from '../tools/registry.js';
17
18
  import { estimateMessagesTokens, reconfigureClient, } from '../llm/index.js';
18
19
  import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
20
+ import { modelSupportsVision } from '../llm/capabilities.js';
19
21
  import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
20
22
  import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
21
23
  import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
@@ -33,7 +35,8 @@ const SLASH_COMMANDS = [
33
35
  { name: '/context', desc: '显示上下文用量条' },
34
36
  { name: '/skills', desc: '列出已发现的 skill' },
35
37
  { name: '/compact', desc: '压缩历史(可带焦点 /compact …)' },
36
- { name: '/resume', desc: '续接已保存的会话' },
38
+ { name: '/resume', desc: '续接最近 10 个已保存会话(快速)' },
39
+ { name: '/sessions', desc: '浏览全部已保存会话(慢,翻历史用)' },
37
40
  { name: '/rollback', desc: '菜单选轮次回滚(↑↓·Enter)' },
38
41
  { name: '/memory', desc: '记忆库:条目计数与近期索引(关闭时提示先开 /memory_switch)' },
39
42
  { name: '/memory_switch', desc: '切换记忆子系统开关(无参=切换;/on 或 /off 显式;持久化 MEMORY_ENABLED)' },
@@ -62,6 +65,9 @@ const MODEL_PRESETS = [
62
65
  { label: 'GLM(智谱)', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: 128000 },
63
66
  { label: 'DeepSeek', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: 64000 },
64
67
  { label: 'Qwen(阿里)', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: 128000 },
68
+ // MiniMax OpenAI 兼容端点(https://platform.minimax.io/docs/api-reference/text-openai-api)。
69
+ // MiniMax-M3 为唯一支持图片/视频输入的模型;M2 系列纯文本(见 llm/capabilities.ts KNOWN_TEXT_ONLY_PREFIXES)。
70
+ { label: 'MiniMax', baseURL: 'https://api.minimax.io/v1', model: 'MiniMax-M3', window: 1000000 },
65
71
  { label: '本地 Ollama', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: 32768 },
66
72
  { label: '本地 vLLM', baseURL: 'http://localhost:8000/v1', model: 'default', window: 32768 },
67
73
  { label: '自定义 base_url', baseURL: '', model: '', window: 128000 },
@@ -417,19 +423,33 @@ function textOf(c) {
417
423
  }
418
424
  /**
419
425
  * 把会话历史渲染成静态文本进内容区(回滚 / 续接 / --resume 后复显上下文,仿 Claude Code):
420
- * user→❯ 回显、assistant→正文(+ tool_calls )、tool→↳ 结果预览;system 跳过。
426
+ * user→❯ 回显、assistant→正文(+ tool_calls 折叠成摘要行)、tool→↳ 结果预览;system 跳过。
421
427
  * 思考段不持久(history 只存正文),故无思考折叠。渲染后续写位在末尾,紧接 enterInputMode 画输入框。
422
428
  * 内容长于屏时 viewport 显尾(最近轮次),PgUp 可看更早——与流式态一致。
423
429
  * user 多模态:用 textOf 取 text parts;若侧 channel messageAttachments 有原文件名则追加 chip 行
424
430
  * (避免 base64 解码不可逆,旧 session 没侧 channel 时只显文本,文件名 fallback 到 image/* mime)。
431
+ *
432
+ * 折叠策略:遇到 assistant + tool_calls 不立即打 ● 行,而是累积到 batchEntries;
433
+ * 跟随的连续 tool 消息按 tool_call_id 反查填 resultSummary;遇下一个非 tool 消息(或末尾)时,
434
+ * 用 batch.writeSummaryOnly 出单行摘要(与实时 runAgent 同一渲染器,UI 一致)。
435
+ * 回放默认全折叠;用户可鼠标点击摘要行展开(由 BatchRenderer 接管,见 ui/batch.ts)。
425
436
  */
426
437
  export function renderHistory(history) {
427
438
  const idToName = new Map();
439
+ // 当前累积的 batch(assistant.tool_calls + 后续 tool 消息);一旦遇到非 tool 消息即收尾出摘要
440
+ let pendingBatch = [];
441
+ const flushBatch = () => {
442
+ if (pendingBatch.length === 0)
443
+ return;
444
+ batch.writeSummaryOnly(pendingBatch, layout);
445
+ pendingBatch = [];
446
+ };
428
447
  for (let idx = 0; idx < history.length; idx++) {
429
448
  const m = history[idx];
430
449
  if (m.role === 'system')
431
450
  continue;
432
451
  if (m.role === 'user') {
452
+ flushBatch(); // 上一轮 batch(若有)收尾
433
453
  const lines = textOf(m.content).split('\n');
434
454
  layout.contentWrite(formatUserMessage(lines));
435
455
  const atts = messageAttachments.get(idx);
@@ -454,31 +474,53 @@ export function renderHistory(history) {
454
474
  if (m.role === 'assistant') {
455
475
  const text = textOf(m.content);
456
476
  if (text) {
477
+ flushBatch(); // 文本前若有累积 batch 先收尾(罕见:连续两个 assistant tool_calls 文本间)
457
478
  layout.contentWriteMdOnce(text);
458
479
  if (!text.endsWith('\n'))
459
480
  layout.contentWrite('\n');
460
481
  }
461
482
  const tcs = m.tool_calls;
462
- if (Array.isArray(tcs)) {
483
+ if (Array.isArray(tcs) && tcs.length > 0) {
484
+ // 累积到 pendingBatch,顺序 = tool_calls 序
463
485
  for (const tc of tcs) {
464
486
  const name = tc?.function?.name ?? '';
465
487
  const args = tc?.function?.arguments ?? '';
466
488
  if (tc?.id && name)
467
489
  idToName.set(tc.id, name);
468
- layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}${name}${ui.reset} ${ui.dim}${summarizeToolCall(name, args)}${ui.reset}\n`);
490
+ pendingBatch.push({
491
+ name,
492
+ callSummary: summarizeToolCall(name, args),
493
+ resultSummary: '',
494
+ diffBlock: null,
495
+ });
469
496
  }
497
+ continue; // 跳过后续 tool 消息处理循环(由下一分支填 result)
470
498
  }
499
+ // 无 tool_calls:若有 pending batch(文本+无 tool_calls 的 assistant),不常见,先收尾
500
+ flushBatch();
471
501
  continue;
472
502
  }
473
503
  if (m.role === 'tool') {
474
504
  const id = m.tool_call_id ?? '';
475
505
  const name = idToName.get(id) ?? '';
476
- const preview = summarizeToolResult(name, textOf(m.content));
477
- if (preview)
478
- layout.contentWrite(` ${ui.gray}↳ ${preview}${ui.reset}\n`);
506
+ const output = textOf(m.content);
507
+ const preview = summarizeToolResult(name, output);
508
+ // 匹配 pendingBatch 中尚未填 result 的同名 entry;同名前缀 tool 较罕见(并行工具同 id 不同名)
509
+ let target;
510
+ for (let i = pendingBatch.length - 1; i >= 0; i--) {
511
+ const e = pendingBatch[i];
512
+ if (e.name === name && !e.resultSummary) {
513
+ target = e;
514
+ break;
515
+ }
516
+ }
517
+ if (target)
518
+ target.resultSummary = preview;
519
+ // 不直接写屏——等 flushBatch 时出单行摘要
479
520
  continue;
480
521
  }
481
522
  }
523
+ flushBatch(); // 末尾兜底
482
524
  }
483
525
  /**
484
526
  * 交互式 REPL:全屏 TUI(alt screen + 固定底栏)。INPUT 态底栏=状态行+输入框(raw mode 等按键);
@@ -673,9 +715,13 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
673
715
  ? input
674
716
  : [
675
717
  { type: 'text', text: input },
718
+ // detail 故意不设(留 undefined,JSON.stringify 时被丢弃):OpenAI 认 'auto'/'low'/'high',
719
+ // 但 MiniMax 只认 'low'/'default'/'high'——'auto' 不是合法枚举值,某些后端会 400。
720
+ // 不传 detail 让各 provider 用自己的默认值(OpenAI 默认视为 auto,MiniMax 默认 default),
721
+ // 是唯一在两边都不出错的写法。
676
722
  ...imgs.map((a) => ({
677
723
  type: 'image_url',
678
- image_url: { url: a.dataUrl, detail: 'auto' },
724
+ image_url: { url: a.dataUrl },
679
725
  })),
680
726
  ];
681
727
  const msgIndex = history.length; // runAgent push 后 = 这个 index
@@ -738,6 +784,31 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
738
784
  layout.contentWrite('\n'); // 轮次之间空行
739
785
  return ok;
740
786
  };
787
+ /** 把 picker 选中的会话加载进 REPL(刷 history + 重建 snapshots + 重画)。/resume / /sessions 共用。 */
788
+ async function resumeFromPick(pick) {
789
+ if (!pick)
790
+ return; // Esc / Ctrl+D 取消
791
+ const loaded = loadSession(pick.id);
792
+ if (!loaded || !loaded.history.length) {
793
+ layout.contentWrite(`${ui.yellow}(加载失败)${ui.reset}\n`);
794
+ return;
795
+ }
796
+ if (loaded.history[0]?.role === 'system') {
797
+ loaded.history[0] = { role: 'system', content: buildSystemMessage(false) };
798
+ }
799
+ history.length = 0;
800
+ history.push(...loaded.history);
801
+ setAgentMode('auto'); // 续接重置为 auto(mode 不落盘;listener 重写 history[0] 回 auto,与 loaded 幂等)
802
+ currentSessionId = loaded.id;
803
+ // 读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
804
+ if (!loadSnapshots(loaded.id))
805
+ rebuildFromHistory(history);
806
+ contextState.lastUsage = undefined;
807
+ lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
808
+ layout.clearContent();
809
+ renderHistory(history);
810
+ layout.contentWrite(`${ui.dim}(已续接会话 ${loaded.id})${ui.reset}\n`);
811
+ }
741
812
  while (true) {
742
813
  // INPUT 态:画底栏输入框 + 状态行,光标入输入框
743
814
  refreshStatusBase(history);
@@ -913,6 +984,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
913
984
  pendingAttachments.push(r.att);
914
985
  }
915
986
  layout.contentWrite(` ${ui.dim}${renderChip(r.att)} — will attach to next message${ui.reset}\n`);
987
+ // 提前警告(不阻断附加):当前模型已知不支持视觉(如 MiniMax M2.x / gpt-3.5 等)时,
988
+ // 附加时就提示,而不是等发送后才在 catch 块里翻译 API 报错——减少一轮无意义请求。
989
+ if (!modelSupportsVision(config.model)) {
990
+ layout.contentWrite(` ${ui.yellow}⚠ 当前模型 ${config.model} 已知不支持视觉输入,发送图片可能会失败。可用 /model 切换。${ui.reset}\n`);
991
+ }
916
992
  continue;
917
993
  }
918
994
  if (line === '/context') {
@@ -1029,10 +1105,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1029
1105
  }
1030
1106
  continue;
1031
1107
  }
1032
- if (line === '/resume') {
1033
- // /resume:打开会话菜单(↑/↓ 选,Enter 续接,Esc 取消)。默认仅最近 10 条,按 a 展开全部。
1034
- // 仿 /rollback 菜单化(promptSessionPicker);选中项 cyan+bold + ▸ 高亮。
1035
- const sessions = listSessions();
1108
+ if (line === '/sessions') {
1109
+ // /sessions:浏览全部已保存会话(慢路径,readdir+全量 JSON.parse,目录 N 大时会有可感知卡顿)
1110
+ // 默认走 /resume(仅最近 10 条,瞬开);要翻历史续接更早的会话才用这条。
1111
+ // picker 走全显(cap=items.length,无 a 展开提示),靠 picker 自身开窗(以选中为中心分屏)
1112
+ const sessions = listSessions(); // 不传 limit = 全量
1036
1113
  if (sessions.length === 0) {
1037
1114
  layout.contentWrite(`${ui.dim}(没有已保存的会话)${ui.reset}\n`);
1038
1115
  continue;
@@ -1044,33 +1121,37 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1044
1121
  }));
1045
1122
  let pick;
1046
1123
  try {
1047
- pick = await promptSessionPicker(items);
1124
+ pick = await promptSessionPicker(items, items.length);
1048
1125
  }
1049
1126
  catch {
1050
1127
  continue; // Ctrl+C(SIGINT)→ 取消
1051
1128
  }
1052
- if (!pick)
1053
- continue; // Esc / Ctrl+D 取消
1054
- const loaded = loadSession(pick.id);
1055
- if (!loaded || !loaded.history.length) {
1056
- layout.contentWrite(`${ui.yellow}(加载失败)${ui.reset}\n`);
1129
+ await resumeFromPick(pick);
1130
+ continue;
1131
+ }
1132
+ if (line === '/resume') {
1133
+ // /resume:打开会话菜单(↑/↓ 选,Enter 续接,Esc 取消)。只加载最近 10 条,
1134
+ // 避免 sessions 目录堆了几百个会话时 readdir+全量 JSON.parse 卡顿。
1135
+ // 仿 /rollback 菜单化(promptSessionPicker);选中项 cyan+bold + ▸ 高亮。
1136
+ // 要续接更早的会话请用 /sessions 翻全表,或 CLI `mocode --resume <id>`。
1137
+ const sessions = listSessions(10);
1138
+ if (sessions.length === 0) {
1139
+ layout.contentWrite(`${ui.dim}(没有已保存的会话)${ui.reset}\n`);
1057
1140
  continue;
1058
1141
  }
1059
- if (loaded.history[0]?.role === 'system') {
1060
- loaded.history[0] = { role: 'system', content: buildSystemMessage(false) };
1142
+ const items = sessions.map((s) => ({
1143
+ id: s.id,
1144
+ title: s.firstUser || '(无)',
1145
+ subtitle: `${s.id} ${s.model}`,
1146
+ }));
1147
+ let pick;
1148
+ try {
1149
+ pick = await promptSessionPicker(items);
1150
+ }
1151
+ catch {
1152
+ continue; // Ctrl+C(SIGINT)→ 取消
1061
1153
  }
1062
- history.length = 0;
1063
- history.push(...loaded.history);
1064
- setAgentMode('auto'); // /resume 重置为 auto(mode 不落盘;listener 重写 history[0] 回 auto,与 loaded 幂等)
1065
- currentSessionId = loaded.id;
1066
- // 读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
1067
- if (!loadSnapshots(loaded.id))
1068
- rebuildFromHistory(history);
1069
- contextState.lastUsage = undefined;
1070
- lastTurnUsage = undefined; // /resume:旧会话的 token 累计已无意义,清空等下轮覆写
1071
- layout.clearContent();
1072
- renderHistory(history);
1073
- layout.contentWrite(`${ui.dim}(已续接会话 ${loaded.id})${ui.reset}\n`);
1154
+ await resumeFromPick(pick);
1074
1155
  continue;
1075
1156
  }
1076
1157
  if (line === '/theme' || line.startsWith('/theme ')) {
@@ -81,14 +81,24 @@ export function loadSession(id) {
81
81
  return null;
82
82
  }
83
83
  }
84
- /** 列出所有会话,按 createdAt 降序。损坏文件跳过。 */
85
- export function listSessions() {
84
+ /** 列出最近会话,按 createdAt 降序。损坏文件跳过。
85
+ * - limit?: 仅返回前 N 条。会话文件名是 YYYYMMDD-HHmmss.json,字典序=时间序;
86
+ * 先按文件名降序取前 N,再只解析这 N 个文件(history 大字段全部跳过不读),避免
87
+ * /resume 在 sessions 目录堆了几百个文件时 readdirSync + 全量 JSON.parse 慢。
88
+ * - 不传 limit 时读全部(向后兼容,供裸 --resume 列全表用)。
89
+ */
90
+ export function listSessions(limit) {
86
91
  if (!existsSync(config.sessionDir))
87
92
  return [];
93
+ // 过滤掉 .snapshots.json:ASCII 排序里 's'(115) > 'j'(106),后者排在前面,会让
94
+ // slice(0, limit) 取到一堆快照文件(JSON.parse 后 rec.id=undefined 被吞),真会话被挤掉。
95
+ const all = readdirSync(config.sessionDir)
96
+ .filter((f) => f.endsWith('.json') && !f.endsWith('.snapshots.json'))
97
+ .sort() // YYYYMMDD-HHmmss.json 字典序 ≡ 时间序(同 createdAt 升序)
98
+ .reverse(); // 降序:最新在前
99
+ const toRead = typeof limit === 'number' ? all.slice(0, Math.max(0, limit)) : all;
88
100
  const out = [];
89
- for (const f of readdirSync(config.sessionDir)) {
90
- if (!f.endsWith('.json'))
91
- continue;
101
+ for (const f of toRead) {
92
102
  try {
93
103
  const rec = JSON.parse(readFileSync(path.join(config.sessionDir, f), 'utf8'));
94
104
  if (rec && typeof rec.id === 'string') {
@@ -104,6 +114,5 @@ export function listSessions() {
104
114
  // 跳过损坏文件
105
115
  }
106
116
  }
107
- out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
108
117
  return out;
109
118
  }