mocode-ai 1.1.7 → 1.1.8

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.
Files changed (51) hide show
  1. package/README.md +23 -37
  2. package/README.zh-CN.md +46 -38
  3. package/dist/agent/core.js +100 -446
  4. package/dist/agent/index.js +2 -21
  5. package/dist/agent/spawn.js +3 -5
  6. package/dist/agent/work-discipline.js +16 -70
  7. package/dist/config/index.js +6 -7
  8. package/dist/context/age-aware.js +18 -48
  9. package/dist/context/artifacts.js +19 -17
  10. package/dist/context/budget.js +27 -28
  11. package/dist/context/classifier.js +0 -1
  12. package/dist/context/encoders/index.js +4 -11
  13. package/dist/context/index.js +4 -7
  14. package/dist/context/lifecycle.js +115 -483
  15. package/dist/context/pipeline.js +8 -15
  16. package/dist/context/relevance.js +77 -55
  17. package/dist/host/stdio.js +0 -6
  18. package/dist/i18n/index.js +0 -6
  19. package/dist/index.js +11 -1
  20. package/dist/llm/index.js +72 -3
  21. package/dist/mcp/index.js +0 -1
  22. package/dist/repl/index.js +21 -15
  23. package/dist/runtime/browser-manager.js +299 -0
  24. package/dist/runtime/dev-server-manager.js +354 -0
  25. package/dist/runtime/shutdown.js +26 -0
  26. package/dist/session/compact.js +86 -102
  27. package/dist/session/index.js +0 -1
  28. package/dist/session/scheduler.js +88 -92
  29. package/dist/session/trace-metrics.js +5 -92
  30. package/dist/session/trace.js +1 -10
  31. package/dist/tools/builtins/browser.js +199 -0
  32. package/dist/tools/builtins/dev-server.js +99 -0
  33. package/dist/tools/builtins/index.js +28 -19
  34. package/dist/tools/builtins/screenshot.js +173 -0
  35. package/dist/tools/builtins/view-image.js +49 -0
  36. package/dist/tools/constants.js +3 -0
  37. package/dist/tools/registry.js +5 -29
  38. package/dist/ui/layout.js +28 -5
  39. package/dist/ui/render.js +11 -0
  40. package/package.json +2 -2
  41. package/dist/agent/middleware/checklist.js +0 -59
  42. package/dist/session/drop.d.ts +0 -19
  43. package/dist/session/drop.js +0 -93
  44. package/dist/tools/builtins/drop-context.d.ts +0 -18
  45. package/dist/tools/builtins/drop-context.js +0 -68
  46. package/dist/verification/diagnostics.js +0 -108
  47. package/dist/verification/fingerprint.js +0 -54
  48. package/dist/verification/index.js +0 -333
  49. package/dist/verification/postconditions.js +0 -98
  50. package/dist/verification/targeted-tests.js +0 -96
  51. package/dist/verification/types.js +0 -1
@@ -1,19 +1,12 @@
1
- // Context Optimization Pipeline 单一入口。
1
+ // Typed Context Optimization Pipeline.
2
2
  //
3
- // 接管"工具结果进 LLM 前"的表示优化(C1 收口,agent/core.ts pushToolResult 调)。
4
- // 流程:
5
- // 1) 解析 argsRaw(失败返 null,encoder 据此降级)。
6
- // 2) classify(name, output, args) → ContextKind。
7
- // 3) getEncoder(kind) ?? passthrough → encode(保不变量压缩,纯函数)。
8
- // 4) capToolResultForHistory(name, text) 作末尾长度裁剪兜底(保 head+标记+tail,与改造前一致)。
3
+ // Normal tool insertion does not call this module: agent/core stores the raw
4
+ // result after only capToolResultForHistory(). The pressure scheduler invokes
5
+ // this encoder for Cold logs and retrievable searches when the opt-in switch is
6
+ // enabled. Encoder failures always fall back to the raw hard-capped result.
9
7
  //
10
- // 不抛错:encoder 报错 → catch 回落原 output + capToolResultForHistory(对齐「调度器永不抛错」)。
11
- // 兜底零行为变化:未注册 encoder / pipeline 关闭 → passthrough identity → 末尾 cap 与改造前逐字节一致。
12
- //
13
- // 兼容:不改 Tool Calling JSON schema、不改 executeTool、不改 tool_call_id 配对、不改 TUI 渲染
14
- // (hooks.onToolResult 用原始 output,本函数只管进 history 的 content)。
15
- //
16
- // 依赖方向:context → {tools/constants, session/compact 的 cap, config};叶子,不反向依赖 llm/agent/tools。
8
+ // This does not alter tool schemas, execution, tool_call_id pairing, or TUI
9
+ // rendering; it only provides a pressure-stage representation transform.
17
10
  import { classify } from './classifier.js';
18
11
  import { getEncoder, registerAll } from './registry.js';
19
12
  import { builtinEncoders } from './encoders/index.js';
@@ -60,7 +53,7 @@ function budgetFor(name) {
60
53
  */
61
54
  export function optimizeToolResult(name, output, argsRaw, context = {}) {
62
55
  boot();
63
- // 总开关关闭:完全走老路径,零行为变化(Phase 1 默认 true,但保留紧急回退开关)。
56
+ // Disabled by default: normal history is raw apart from the hard cap.
64
57
  if (!config.contextOptimize) {
65
58
  return capToolResultForHistory(name, output);
66
59
  }
@@ -1,6 +1,6 @@
1
1
  // Relevance Pruner: statically removes tool results that a newer observation supersedes.
2
2
  // It never deletes messages or changes tool_call_id pairing; only tool content is stubbed.
3
- import { canonicalizePath, extractPath, isToolResultSuccess, lastUserIndex, toText, } from './utils.js';
3
+ import { canonicalizePath, extractPath, isToolResultSuccess, toText, } from './utils.js';
4
4
  /** Shared prefix lets /context count read and observation supersession together. */
5
5
  const STUB_PREFIX = '⌦[已过时:';
6
6
  const READ_STUB_REASON = '同 path 已有新 read / 已被 mutation 覆写';
@@ -72,7 +72,6 @@ export class RelevancePruner {
72
72
  const path = canonicalizePath(extractPath(call.argsRaw));
73
73
  if (!path)
74
74
  return;
75
- this.stubPriorReads(history, path, idx);
76
75
  const list = this.readByPath.get(path) ?? [];
77
76
  list.push(idx);
78
77
  this.readByPath.set(path, list);
@@ -81,7 +80,6 @@ export class RelevancePruner {
81
80
  const key = observationKey(call);
82
81
  if (!key)
83
82
  return;
84
- this.stubPriorObservations(history, call.name, key, idx);
85
83
  const list = this.observationByKey.get(key) ?? [];
86
84
  list.push(idx);
87
85
  this.observationByKey.set(key, list);
@@ -107,85 +105,109 @@ export class RelevancePruner {
107
105
  }
108
106
  return null;
109
107
  }
110
- observeMutation(history, path) {
108
+ observeMutation(_history, _path) {
109
+ // Mutations are retained as provenance. pruneSuperseded() derives their
110
+ // impact only when the scheduler enters real context pressure.
111
+ }
112
+ /**
113
+ * Pressure-only cleanup. Scan the complete history to identify evidence that
114
+ * has an exact newer replacement, but only rewrite messages before the Cold
115
+ * boundary. This keeps the latest four user turns and current work intact.
116
+ */
117
+ pruneSuperseded(history, coldBoundary) {
111
118
  try {
112
- const canonicalPath = canonicalizePath(path);
113
- if (!canonicalPath)
114
- return;
115
- const idx = history.length - 1;
116
- if (idx < 1)
117
- return;
118
- this.stubPriorReads(history, canonicalPath, idx);
119
- this.readByPath.delete(canonicalPath);
119
+ const latestRead = new Map();
120
+ const latestObservation = new Map();
121
+ const mutations = [];
122
+ for (let idx = 1; idx < history.length; idx++) {
123
+ const message = history[idx];
124
+ const content = toText(message.content);
125
+ if (message.role !== 'tool' || content.startsWith('⌦[') || !isToolResultSuccess(content))
126
+ continue;
127
+ const call = this.callAt(history, idx);
128
+ if (!call)
129
+ continue;
130
+ if (call.name === 'read_file') {
131
+ const path = canonicalizePath(extractPath(call.argsRaw));
132
+ if (path)
133
+ latestRead.set(path, idx);
134
+ }
135
+ else if (call.name === 'edit_file' || call.name === 'write_file') {
136
+ const path = canonicalizePath(extractPath(call.argsRaw));
137
+ if (path)
138
+ mutations.push({ path, index: idx });
139
+ }
140
+ const key = observationKey(call);
141
+ if (key)
142
+ latestObservation.set(key, { tool: call.name, index: idx });
143
+ }
144
+ let pruned = 0;
145
+ for (const [path, index] of latestRead) {
146
+ pruned += this.stubPriorReads(history, path, index, coldBoundary);
147
+ }
148
+ for (const mutation of mutations) {
149
+ pruned += this.stubPriorReads(history, mutation.path, mutation.index, coldBoundary);
150
+ }
151
+ for (const [key, latest] of latestObservation) {
152
+ pruned += this.stubPriorObservations(history, latest.tool, key, latest.index, coldBoundary);
153
+ }
154
+ return pruned;
120
155
  }
121
156
  catch {
122
- // Never throw from mutation cleanup.
157
+ return 0;
123
158
  }
124
159
  }
125
- stubPriorReads(history, path, beforeIdx) {
160
+ stubPriorReads(history, path, beforeIdx, coldBoundary) {
126
161
  const targetPath = canonicalizePath(path);
127
162
  if (!targetPath)
128
- return;
129
- const protectedFrom = Math.max(0, lastUserIndex(history));
130
- const stubOne = (idx) => {
131
- if (idx >= beforeIdx || (protectedFrom > 0 && idx >= protectedFrom))
132
- return;
163
+ return 0;
164
+ let pruned = 0;
165
+ for (let idx = 1; idx < Math.min(beforeIdx, coldBoundary); idx++) {
133
166
  const message = history[idx];
134
167
  if (!message || message.role !== 'tool')
135
- return;
168
+ continue;
136
169
  const content = toText(message.content);
137
- if (content.startsWith(STUB_PREFIX))
138
- return;
170
+ if (content.startsWith('⌦['))
171
+ continue;
139
172
  const call = this.callAt(history, idx);
140
173
  if (call?.name !== 'read_file')
141
- return;
142
- if (canonicalizePath(extractPath(call.argsRaw)) !== targetPath)
143
- return;
144
- if (!message.tool_call_id)
145
- return;
174
+ continue;
175
+ if (canonicalizePath(extractPath(call.argsRaw)) !== targetPath || !message.tool_call_id)
176
+ continue;
146
177
  message.content =
147
178
  `${STUB_PREFIX}${READ_STUB_REASON}] read_file(${targetPath}) ${content.length} 字符 ` +
148
179
  `→ 已被新 read / mutation 替代 · id …${message.tool_call_id.slice(-6)}⌫`;
149
- };
150
- for (const idx of this.readByPath.get(targetPath) ?? [])
151
- stubOne(idx);
152
- const scanEnd = Math.min(beforeIdx, protectedFrom > 0 ? protectedFrom : beforeIdx);
153
- for (let idx = 1; idx < scanEnd; idx++)
154
- stubOne(idx);
180
+ pruned++;
181
+ }
182
+ return pruned;
155
183
  }
156
- stubPriorObservations(history, toolName, key, beforeIdx) {
157
- const protectedFrom = Math.max(0, lastUserIndex(history));
158
- const stubOne = (idx) => {
159
- if (idx >= beforeIdx || (protectedFrom > 0 && idx >= protectedFrom))
160
- return;
184
+ stubPriorObservations(history, toolName, key, beforeIdx, coldBoundary) {
185
+ let pruned = 0;
186
+ for (let idx = 1; idx < Math.min(beforeIdx, coldBoundary); idx++) {
161
187
  const message = history[idx];
162
188
  if (!message || message.role !== 'tool')
163
- return;
189
+ continue;
164
190
  const content = toText(message.content);
165
- if (content.startsWith(STUB_PREFIX) || !isToolResultSuccess(content))
166
- return;
191
+ if (content.startsWith('⌦[') || !isToolResultSuccess(content))
192
+ continue;
167
193
  const call = this.callAt(history, idx);
168
- if (!call || call.name !== toolName || observationKey(call) !== key)
169
- return;
170
- if (!message.tool_call_id)
171
- return;
172
- const reason = '相同 grep 查询已有更新结果';
194
+ if (!call || call.name !== toolName || observationKey(call) !== key || !message.tool_call_id)
195
+ continue;
173
196
  message.content =
174
- `${STUB_PREFIX}${reason}] ${observationLabel(call)} ${content.length} 字符 ` +
197
+ `${STUB_PREFIX}相同 grep 查询已有更新结果] ${observationLabel(call)} ${content.length} 字符 ` +
175
198
  `→ 已被更新查询替代 · id …${message.tool_call_id.slice(-6)}⌫`;
176
- };
177
- for (const idx of this.observationByKey.get(key) ?? [])
178
- stubOne(idx);
179
- // The fallback scan restores correctness after resume/compact when this instance
180
- // has no index for older messages.
181
- const scanEnd = Math.min(beforeIdx, protectedFrom > 0 ? protectedFrom : beforeIdx);
182
- for (let idx = 1; idx < scanEnd; idx++)
183
- stubOne(idx);
199
+ pruned++;
200
+ }
201
+ return pruned;
184
202
  }
185
203
  }
186
204
  export function createRelevancePruner() {
187
205
  return new RelevancePruner();
188
206
  }
207
+ /** Pressure-only convenience entry point for scheduler-owned pruning. */
208
+ export function pruneSuperseded(history, coldBoundary) {
209
+ return new RelevancePruner().pruneSuperseded(history, coldBoundary);
210
+ }
189
211
  /** Parse the original content length recorded by any relevance stub. */
190
212
  function parseStubOriginalLen(stub) {
191
213
  const match = /\) (\d+) 字符 →/.exec(stub);
@@ -8,7 +8,6 @@ import { initializeAllMcp, getMcpTools, getMcpWarnings, closeAllMcp } from '../m
8
8
  import { setSandboxRoot } from '../sandbox/index.js';
9
9
  import { createContextState, loadSession, newSessionId, saveSession } from '../session/index.js';
10
10
  import { setCurrentSessionId } from '../session/state.js';
11
- import { buildActiveNotesPlanReminder } from '../session/notes-plan.js';
12
11
  import { manualCompact } from '../session/scheduler.js';
13
12
  import { effectiveSystemPrompt } from '../skills/index.js';
14
13
  import { registerToolsExtension } from '../tools/registry.js';
@@ -91,8 +90,6 @@ function hooksFor(runId) {
91
90
  onToolHeader: (tool) => emit('tool_started', { id: tool.id, name: tool.name, arguments: tool.arguments }, runId),
92
91
  onToolStart: (name) => emit('status', { value: 'running_tool', tool: name }, runId),
93
92
  onToolResult: (tool, output) => emit('tool_completed', { id: tool.id, name: tool.name, output }, runId),
94
- onValidationStart: (command) => emit('validation_started', { command }, runId),
95
- onValidationResult: (result) => emit('validation_completed', { result }, runId),
96
93
  onAbort: () => emit('run_aborted', {}, runId),
97
94
  onDone: (elapsedMs, usage) => emit('run_finished', { elapsedMs, usage }, runId),
98
95
  };
@@ -126,10 +123,8 @@ async function run(command) {
126
123
  history,
127
124
  userInput,
128
125
  signal: controller.signal,
129
- dynamicSystemSuffix: buildActiveNotesPlanReminder,
130
126
  hooks: hooksFor(command.id),
131
127
  contextState,
132
- autoValidate: config.autoValidate,
133
128
  permissionPrompt: (request) => waitForApproval(command.id, request),
134
129
  });
135
130
  saveSession(history, sessionId, queryHistory);
@@ -138,7 +133,6 @@ async function run(command) {
138
133
  completed: result.completed,
139
134
  terminationReason: result.terminationReason,
140
135
  changedFiles: result.changedFiles ?? [],
141
- validation: result.validation,
142
136
  usage: result.usage,
143
137
  usagePercent: Math.round(contextUsagePercent() * 100),
144
138
  contextWindow: config.contextWindowTokens,
@@ -156,9 +156,6 @@ const zhCN = {
156
156
  'agent.noReply': '(无回复)',
157
157
  'agent.maxSteps': '达到最大步数({count}),本轮停止。',
158
158
  'agent.aborted': '(已中断)',
159
- 'agent.validating': '自动验证 {command}',
160
- 'agent.validationNoCommand': '未发现验证命令',
161
- 'agent.validationResult': '自动验证 {command} → {status}',
162
159
  'agent.workedFor': '耗时 {elapsed}',
163
160
  'agent.toolsRunning': '正在探索',
164
161
  'agent.toolsComplete': '探索',
@@ -401,9 +398,6 @@ const en = {
401
398
  'agent.noReply': '(no reply)',
402
399
  'agent.maxSteps': 'Maximum steps reached ({count}); this turn has stopped.',
403
400
  'agent.aborted': '(aborted)',
404
- 'agent.validating': 'Validating {command}',
405
- 'agent.validationNoCommand': 'no validation command',
406
- 'agent.validationResult': 'Automatic validation {command} → {status}',
407
401
  'agent.workedFor': 'Worked for {elapsed}',
408
402
  'agent.toolsRunning': 'Exploring',
409
403
  'agent.toolsComplete': 'Exploration',
package/dist/index.js CHANGED
@@ -1,16 +1,24 @@
1
1
  import { exitAltScreen } from './ui/layout.js';
2
2
  import { readConfigFile } from './config/file.js';
3
3
  import { detectLanguage, setLanguage, t } from './i18n/index.js';
4
+ import { shutdownRuntime, shutdownRuntimeSync } from './runtime/shutdown.js';
4
5
  // 终端恢复兜底:任一退出 / 中断 / 未捕获异常路径都要恢复 alt screen,避免残留备用屏 + 滚动区域。
5
6
  // exitAltScreen 幂等(未激活时空操作),故全局注册安全——进 alt screen 前的路径(如 --resume 列表、缺环境变量、`mocode config`)调用它无副作用。
6
7
  // 仅 layout 是叶子(不依赖 config),故静态导入安全;repl / session 依赖 config(模块加载触发 loadEnvFiles + config 单例初始化),
7
8
  // 改动态按需加载——`mocode config` 向导只需读写文件(走 config/file.ts 叶子),不经 config 单例初始化,零配置也能跑。
8
- process.on('exit', () => exitAltScreen());
9
+ // dev_server 拉起的后台进程不随父进程退出而消失(Windows 无 job object),故每条退出路径
10
+ // 都同步树杀一次;shutdownRuntimeSync 幂等。
11
+ process.on('exit', () => {
12
+ shutdownRuntimeSync();
13
+ exitAltScreen();
14
+ });
9
15
  process.on('SIGINT', () => {
16
+ shutdownRuntimeSync();
10
17
  exitAltScreen();
11
18
  process.exit(130);
12
19
  });
13
20
  process.on('uncaughtException', (e) => {
21
+ shutdownRuntimeSync();
14
22
  try {
15
23
  process.stderr.write(`\n[uncaught] ${e instanceof Error ? e.stack || e.message : String(e)}\n`);
16
24
  }
@@ -85,6 +93,8 @@ async function main() {
85
93
  const { startRepl } = await import('./repl/index.js');
86
94
  await startRepl(undefined, undefined, sandboxRootOverride);
87
95
  }
96
+ // 正常退出:优雅关闭浏览器与后台进程(同步兜底仍在 exit 钩子里)。
97
+ await shutdownRuntime();
88
98
  process.exit(0);
89
99
  }
90
100
  main();
package/dist/llm/index.js CHANGED
@@ -286,6 +286,42 @@ toolsOverride) {
286
286
  // 循环要么 return 要么 throw,理论上走不到这里;写出来让 TS 控制流分析满意。
287
287
  throw lastErr;
288
288
  }
289
+ /**
290
+ * 发送前规范化多模态 image_url:去掉 `detail:"auto"`。
291
+ *
292
+ * `auto` 是 OpenAI 的合法枚举,但 MiniMax 等兼容后端只认 low/default/high,收到 auto 直接
293
+ * 400(invalid image detail: auto, 2013)。省略该字段时各家都会用自己的默认值,是唯一
294
+ * 在所有后端都安全的写法;显式 low/high 属通用取值,原样保留。
295
+ *
296
+ * 放在 transport 边界而非构造点:历史里可能已经存着旧版本(或续接会话 / 外部注入)写下的
297
+ * `auto`,那种消息每轮都会被重发,只修构造点无法自愈。
298
+ *
299
+ * 无需改写时返回原数组引用 —— 图片消息含大段 base64,不能无条件深拷贝。
300
+ */
301
+ export function normalizeImageDetail(messages) {
302
+ let changed = false;
303
+ const next = messages.map((message) => {
304
+ const content = message.content;
305
+ if (!Array.isArray(content))
306
+ return message;
307
+ let messageChanged = false;
308
+ const parts = content.map((part) => {
309
+ const image = part.image_url;
310
+ if (part.type !== 'image_url' || !image)
311
+ return part;
312
+ if (image.detail !== 'auto')
313
+ return part;
314
+ messageChanged = true;
315
+ const { detail: _dropped, ...rest } = image;
316
+ return { ...part, image_url: rest };
317
+ });
318
+ if (!messageChanged)
319
+ return message;
320
+ changed = true;
321
+ return { ...message, content: parts };
322
+ });
323
+ return changed ? next : messages;
324
+ }
289
325
  /** 单次流式 LLM 请求(无重试);chat() 的内部实现,可被 __setChatCreateImpl 注入桩以做单测。 */
290
326
  async function chatOnce(messages, handlers, signal, toolsOverride) {
291
327
  // signal 透传给 SDK 第二参(RequestOptions);abort 后 for await 抛错,chat 不 catch,透传 runAgent 处理。
@@ -296,7 +332,7 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
296
332
  const activeTools = toolsOverride ?? chatTools;
297
333
  const stream = await create({
298
334
  model: config.model,
299
- messages,
335
+ messages: normalizeImageDetail(messages),
300
336
  tools: activeTools,
301
337
  stream: true,
302
338
  // 显式声明允许一次响应携带多个 tool_call(OpenAI 兼容协议标准字段)。
@@ -319,6 +355,24 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
319
355
  handlers.onText?.(text);
320
356
  consumedAny = true;
321
357
  };
358
+ // 实时 completion 估算(onProgress 提供时才统计,零开销兜底):
359
+ // 按 chunk 累加 CJK/other 字符数、汇总时才 ceil——逐片段 ceil 会把大量小 chunk 各向上
360
+ // 取整造成严重过估。统计口径 = raw content(含 think 段)+ tool_call 参数,与后端真实
361
+ // completion 计费范围一致(reasoning 也计费)。
362
+ let liveCjk = 0;
363
+ let liveOther = 0;
364
+ const countLive = (text) => {
365
+ for (const ch of text) {
366
+ const cp = ch.codePointAt(0) ?? 0;
367
+ if (isCJK(cp))
368
+ liveCjk++;
369
+ else
370
+ liveOther++;
371
+ }
372
+ };
373
+ const reportProgress = () => {
374
+ handlers.onProgress?.({ completionTokens: Math.ceil(liveCjk + liveOther / 4) });
375
+ };
322
376
  for await (const chunk of stream) {
323
377
  // usage:末尾 chunk(choices 可能为空)在 include_usage 时携带;先读再 continue。
324
378
  if (chunk.usage) {
@@ -330,12 +384,21 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
330
384
  cachedTokens: extras.cachedTokens,
331
385
  reasoningTokens: extras.reasoningTokens,
332
386
  };
387
+ // 末尾 chunk 把实测 prompt / completion / cache 命中即时推给实时 chip(无 delta,下方 continue 不会再报)。
388
+ handlers.onProgress?.({
389
+ completionTokens: usage.completionTokens,
390
+ promptTokens: usage.promptTokens,
391
+ cachedTokens: usage.cachedTokens,
392
+ });
333
393
  }
334
394
  const delta = chunk.choices?.[0]?.delta;
335
395
  if (!delta)
336
396
  continue; // 末尾 usage-only chunk 等无 delta
337
- if (delta.content)
397
+ if (delta.content) {
398
+ if (handlers.onProgress)
399
+ countLive(delta.content);
338
400
  emitVisible(thinkFilter.push(delta.content));
401
+ }
339
402
  if (delta.tool_calls) {
340
403
  // 不在这里 flush thinkFilter:其内部若有残留,只可能是 `<th` / `</thi` 一类
341
404
  // 潜在标签前缀。旧实现把这段在工具转折点强制送进 onText,正是 `k>` 等残片
@@ -355,10 +418,16 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
355
418
  handlers.onToolCall?.(fname); // 首次得知工具名:通知调用方启生成中 spinner
356
419
  entry.name += fname;
357
420
  }
358
- if (tc.function?.arguments)
421
+ if (tc.function?.arguments) {
422
+ if (handlers.onProgress)
423
+ countLive(tc.function.arguments);
359
424
  entry.arguments += tc.function.arguments;
425
+ }
360
426
  }
361
427
  }
428
+ // 流式实时用量:每个带 delta 的 chunk 后回调累计估算,驱动底栏实时 chip。
429
+ if (handlers.onProgress)
430
+ reportProgress();
362
431
  }
363
432
  // 流结束后只释放普通态下真实的文本尾;未闭合思考段继续丢弃。
364
433
  emitVisible(thinkFilter.finish());
package/dist/mcp/index.js CHANGED
@@ -46,7 +46,6 @@ export function getMcpTools() {
46
46
  capabilities: {
47
47
  effect: 'unknown',
48
48
  concurrency: 'serial',
49
- retry: 'never',
50
49
  resources: () => ['workspace'],
51
50
  supportsAbort: true,
52
51
  },
@@ -1,8 +1,9 @@
1
1
  import readline from 'node:readline/promises';
2
2
  import { emitKeypressEvents } from 'node:readline';
3
3
  import { stdin, stdout } from 'node:process';
4
- import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, } from '../config/index.js';
4
+ import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, DEFAULT_CONTEXT_WINDOW_TOKENS, } from '../config/index.js';
5
5
  import { getLanguage, normalizeLanguage, t, } from '../i18n/index.js';
6
+ import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
6
7
  import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
7
8
  import { deletePreset, getPreset, isValidPresetName, listPresets, migrateCurrentToPreset, savePreset, } from '../config/presets.js';
8
9
  import { runAgent } from '../agent/index.js';
@@ -142,15 +143,15 @@ function themeDescription(name) {
142
143
  }
143
144
  /** /model 预设后端:选一个预填 baseURL,仍可逐项改。base_url 取自 README 常见表。 */
144
145
  const MODEL_PRESETS = [
145
- { label: 'GLM(智谱)', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: 128000 },
146
- { label: 'DeepSeek', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: 64000 },
147
- { label: 'Qwen(阿里)', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: 128000 },
146
+ { label: 'GLM(智谱)', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
147
+ { label: 'DeepSeek', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
148
+ { label: 'Qwen(阿里)', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
148
149
  // MiniMax OpenAI 兼容端点(https://platform.minimax.io/docs/api-reference/text-openai-api)。
149
150
  // MiniMax-M3 为唯一支持图片/视频输入的模型;M2 系列纯文本(见 llm/capabilities.ts KNOWN_TEXT_ONLY_PREFIXES)。
150
- { label: 'MiniMax', baseURL: 'https://api.minimax.io/v1', model: 'MiniMax-M3', window: 1000000 },
151
- { label: '本地 Ollama', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: 32768 },
152
- { label: '本地 vLLM', baseURL: 'http://localhost:8000/v1', model: 'default', window: 32768 },
153
- { label: '自定义 base_url', baseURL: '', model: '', window: 128000 },
151
+ { label: 'MiniMax', baseURL: 'https://api.minimax.io/v1', model: 'MiniMax-M3', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
152
+ { label: '本地 Ollama', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
153
+ { label: '本地 vLLM', baseURL: 'http://localhost:8000/v1', model: 'default', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
154
+ { label: '自定义 base_url', baseURL: '', model: '', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
154
155
  ];
155
156
  /** apiKey 脱敏:只露末 4 位,前面打星号(显示用,绝不把明文 key 写进内容区)。 */
156
157
  function maskKey(k) {
@@ -217,7 +218,7 @@ function renderContextBar(history) {
217
218
  const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
218
219
  const src = contextState.lastUsage ? t('status.measured') : t('status.estimated');
219
220
  const k = (n) => `${Math.round(n / 1000)}k`;
220
- const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.accent;
221
+ const pctCol = pct >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio ? ui.yellow : ui.accent;
221
222
  const lifecycle = contextState.lifecycleStats;
222
223
  const archived = computePruneStats(history);
223
224
  const artifactStats = contextState.artifactStats;
@@ -242,7 +243,7 @@ function renderContextBarInline(history) {
242
243
  const filled = Math.round(pct * W);
243
244
  const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
244
245
  const k = (n) => `${Math.round(n / 1000)}k`;
245
- const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.accent;
246
+ const pctCol = pct >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio ? ui.yellow : ui.accent;
246
247
  return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
247
248
  }
248
249
  // 宿主侧记录已结束轮次最后看到的 plan。notes.md 仍完整保留,只抑制未变化的旧 plan 状态栏,
@@ -1031,10 +1032,14 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1031
1032
  // 直接给原文对中文用户不友好。这里翻译成中文 + 提示 /model 换视觉模型。
1032
1033
  const msg = e instanceof Error ? e.message : String(e);
1033
1034
  const lower = msg.toLowerCase();
1034
- const looksLikeImageError = /\b(image|vision|multimodal|vision[-_ ]?capable|unsupported (media|image))\b/i.test(lower) ||
1035
- /不支持(视觉|图片|图像|多模态)/.test(msg) ||
1036
- (/图片|图像|视觉|多模态/.test(msg) && /不支持|invalid|reject|fail/i.test(lower));
1037
- if (looksLikeImageError) {
1035
+ // 只有明确的「能力不支持」才显示换模型提示。不能仅因错误里出现 image/vision
1036
+ // 就下结论:例如 MiniMax 会因 `image_url.detail=auto` 返 400 invalid params,
1037
+ // 模型本身仍支持视觉。参数/格式错误应保留原始 provider 诊断,方便准确修复。
1038
+ const isImageParameterError = /\b(?:invalid|unsupported)\s+(?:image\s+)?(?:detail|parameter|param|format|url)\b/i.test(lower);
1039
+ const isVisionUnsupportedError = !isImageParameterError && (/\b(?:does not support|doesn't support|not supported|unsupported)\b.{0,48}\b(?:image|vision|multimodal|media)\b/i.test(lower) ||
1040
+ /\b(?:image|vision|multimodal|media)\b.{0,48}\b(?:is not supported|not supported|unsupported)\b/i.test(lower) ||
1041
+ /(?:不支持|不具备).{0,12}(?:视觉|图片|图像|多模态)|(?:视觉|图片|图像|多模态).{0,12}(?:不支持|不可用)/.test(msg));
1042
+ if (isVisionUnsupportedError) {
1038
1043
  layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${t('repl.visionUnsupported', { model: `${ui.accent}${config.model}${ui.reset}` })}${ui.dim}${t('repl.originalError', { message: msg })}${ui.reset}\n`);
1039
1044
  layout.contentWrite(`${ui.dim}${t('repl.visionHint')}${ui.reset}\n`);
1040
1045
  }
@@ -1049,6 +1054,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1049
1054
  const waitingForPlanApproval = ok && planMode && getAgentMode() === 'plan';
1050
1055
  if (!waitingForPlanApproval)
1051
1056
  settlePlanStatus();
1057
+ layout.setLiveUsage(undefined); // 轮末清实时 chip,回 INPUT 态不再显示
1052
1058
  refreshStatusBase(history, lastTurnUsage);
1053
1059
  layout.drawStatusBar();
1054
1060
  }
@@ -1915,7 +1921,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1915
1921
  const res = await promptIntervention({
1916
1922
  type: 'input',
1917
1923
  title: 'CONTEXT_WINDOW_TOKENS',
1918
- detail: '模型上下文窗口(须对齐真实模型;GLM≈128k,DeepSeek-V3≈64k)。回车采纳预填值。',
1924
+ detail: '模型上下文窗口,全局默认 256k;如需不同窗口可手动覆盖。回车采纳预填值。',
1919
1925
  seed: String(preset.window || config.contextWindowTokens),
1920
1926
  });
1921
1927
  if (res.action === 'cancelled') {