mocode-ai 1.1.7 → 1.1.9

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 (55) hide show
  1. package/README.md +45 -40
  2. package/README.zh-CN.md +51 -41
  3. package/dist/agent/core.js +137 -447
  4. package/dist/agent/index.js +5 -24
  5. package/dist/agent/spawn.js +5 -5
  6. package/dist/agent/work-discipline.js +16 -70
  7. package/dist/config/index.js +87 -33
  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 +20 -6
  19. package/dist/index.js +11 -1
  20. package/dist/llm/index.js +82 -9
  21. package/dist/mcp/index.js +0 -1
  22. package/dist/repl/index.js +69 -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/notes.js +107 -0
  29. package/dist/session/scheduler.js +88 -92
  30. package/dist/session/trace-metrics.js +5 -92
  31. package/dist/session/trace.js +1 -10
  32. package/dist/tools/builtins/browser.js +199 -0
  33. package/dist/tools/builtins/dev-server.js +99 -0
  34. package/dist/tools/builtins/index.js +33 -19
  35. package/dist/tools/builtins/plan-update.js +144 -0
  36. package/dist/tools/builtins/screenshot.js +173 -0
  37. package/dist/tools/builtins/view-image.js +49 -0
  38. package/dist/tools/constants.js +38 -8
  39. package/dist/tools/registry.js +5 -29
  40. package/dist/ui/batch.js +3 -0
  41. package/dist/ui/content.js +19 -16
  42. package/dist/ui/layout.js +120 -46
  43. package/dist/ui/render.js +11 -0
  44. package/package.json +2 -2
  45. package/dist/agent/middleware/checklist.js +0 -59
  46. package/dist/session/drop.d.ts +0 -19
  47. package/dist/session/drop.js +0 -93
  48. package/dist/tools/builtins/drop-context.d.ts +0 -18
  49. package/dist/tools/builtins/drop-context.js +0 -68
  50. package/dist/verification/diagnostics.js +0 -108
  51. package/dist/verification/fingerprint.js +0 -54
  52. package/dist/verification/index.js +0 -333
  53. package/dist/verification/postconditions.js +0 -98
  54. package/dist/verification/targeted-tests.js +0 -96
  55. 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,
@@ -23,6 +23,10 @@ const zhCN = {
23
23
  'commands.subagentOn': '开启子 Agent',
24
24
  'commands.subagentOff': '关闭子 Agent',
25
25
  'commands.subagentStatus': '查看子 Agent 状态',
26
+ 'commands.fe': '前端工具簇开关 browser/dev_server/screenshot/view_image(默认关闭)',
27
+ 'commands.feOn': '开启前端工具簇',
28
+ 'commands.feOff': '关闭前端工具簇',
29
+ 'commands.feStatus': '查看前端工具簇状态',
26
30
  'commands.theme': '切换颜色主题(↑↓·Enter)',
27
31
  'commands.model': '模型配置与预设管理',
28
32
  'commands.modelConfigure': '配置新模型(向导)',
@@ -156,9 +160,6 @@ const zhCN = {
156
160
  'agent.noReply': '(无回复)',
157
161
  'agent.maxSteps': '达到最大步数({count}),本轮停止。',
158
162
  'agent.aborted': '(已中断)',
159
- 'agent.validating': '自动验证 {command}',
160
- 'agent.validationNoCommand': '未发现验证命令',
161
- 'agent.validationResult': '自动验证 {command} → {status}',
162
163
  'agent.workedFor': '耗时 {elapsed}',
163
164
  'agent.toolsRunning': '正在探索',
164
165
  'agent.toolsComplete': '探索',
@@ -214,6 +215,12 @@ const zhCN = {
214
215
  'subagent.changedOn': '已开启子 Agent;sub-agent 将从下一次模型请求起可用。',
215
216
  'subagent.changedOff': '已关闭子 Agent;sub-agent 已从模型工具表移除。',
216
217
  'subagent.usage': '用法:/subagent on|off|status',
218
+ 'fe.status': '前端工具簇:{state}',
219
+ 'fe.stateOn': '开启',
220
+ 'fe.stateOff': '关闭',
221
+ 'fe.changedOn': '已开启前端工具簇;browser / dev_server / screenshot / view_image 将从下一次模型请求起可用。',
222
+ 'fe.changedOff': '已关闭前端工具簇;browser / dev_server / screenshot / view_image 已从模型工具表移除。',
223
+ 'fe.usage': '用法:/fe on|off|status',
217
224
  'plan.ready': '计划已就绪',
218
225
  'plan.approvalDetail': '切换到 auto 模式按上述计划执行?(plan 模式只读探查,执行需切 auto)',
219
226
  'plan.execute': '切 auto 执行',
@@ -268,6 +275,10 @@ const en = {
268
275
  'commands.subagentOn': 'Enable sub-agents',
269
276
  'commands.subagentOff': 'Disable sub-agents',
270
277
  'commands.subagentStatus': 'Show sub-agent status',
278
+ 'commands.fe': 'Frontend tools: browser/dev_server/screenshot/view_image (disabled by default)',
279
+ 'commands.feOn': 'Enable frontend tools',
280
+ 'commands.feOff': 'Disable frontend tools',
281
+ 'commands.feStatus': 'Show frontend tools status',
271
282
  'commands.theme': 'Switch color theme (↑↓·Enter)',
272
283
  'commands.model': 'Model configuration and presets',
273
284
  'commands.modelConfigure': 'Configure a new model (wizard)',
@@ -401,9 +412,6 @@ const en = {
401
412
  'agent.noReply': '(no reply)',
402
413
  'agent.maxSteps': 'Maximum steps reached ({count}); this turn has stopped.',
403
414
  'agent.aborted': '(aborted)',
404
- 'agent.validating': 'Validating {command}',
405
- 'agent.validationNoCommand': 'no validation command',
406
- 'agent.validationResult': 'Automatic validation {command} → {status}',
407
415
  'agent.workedFor': 'Worked for {elapsed}',
408
416
  'agent.toolsRunning': 'Exploring',
409
417
  'agent.toolsComplete': 'Exploration',
@@ -459,6 +467,12 @@ const en = {
459
467
  'subagent.changedOn': 'Sub-agents enabled; sub-agent will be available from the next model request.',
460
468
  'subagent.changedOff': 'Sub-agents disabled; sub-agent has been removed from the model tool list.',
461
469
  'subagent.usage': 'Usage: /subagent on|off|status',
470
+ 'fe.status': 'Frontend tools: {state}',
471
+ 'fe.stateOn': 'enabled',
472
+ 'fe.stateOff': 'disabled',
473
+ 'fe.changedOn': 'Frontend tools enabled; browser / dev_server / screenshot / view_image will be available from the next model request.',
474
+ 'fe.changedOff': 'Frontend tools disabled; browser / dev_server / screenshot / view_image have been removed from the model tool list.',
475
+ 'fe.usage': 'Usage: /fe on|off|status',
462
476
  'plan.ready': 'Plan ready',
463
477
  'plan.approvalDetail': 'Switch to auto mode and execute the plan above? (Plan mode is read-only; execution requires auto mode.)',
464
478
  'plan.execute': 'Switch to auto and execute',
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
@@ -1,7 +1,7 @@
1
1
  import OpenAI from 'openai';
2
- import { config, isSubAgentEnabled } from '../config/index.js';
2
+ import { config, isSubAgentEnabled, isFrontendToolsEnabled } from '../config/index.js';
3
3
  import { tools } from '../tools/registry.js';
4
- import { getPlanDisabledTools } from '../tools/constants.js';
4
+ import { getPlanDisabledTools, FRONTEND_TOOLS } from '../tools/constants.js';
5
5
  import { ThinkTagFilter } from './think-filter.js';
6
6
  // 强制关闭第三方调试日志泄漏:openai SDK 在 process.env.DEBUG === 'true' 时用裸
7
7
  // console.log 把请求/响应直写 stdout,会污染 TUI 输入框(并泄露 headers/URL)。
@@ -159,10 +159,14 @@ export function __setChatCreateImpl(impl) {
159
159
  export const chatTools = [];
160
160
  export const planChatTools = [];
161
161
  export function refreshChatTools() {
162
- // sub-agent 常驻内部 registry,运行时开关只控制模型可见 schema,因而 on/off 可即时生效。
163
- const visibleTools = isSubAgentEnabled()
164
- ? tools
165
- : tools.filter((tool) => tool.name !== 'sub-agent');
162
+ // sub-agent 与前端工具簇常驻内部 registry,运行时开关只控制模型可见 schema,因而 on/off 可即时生效。
163
+ const visibleTools = tools.filter((tool) => {
164
+ if (tool.name === 'sub-agent')
165
+ return isSubAgentEnabled();
166
+ if (FRONTEND_TOOLS.has(tool.name))
167
+ return isFrontendToolsEnabled();
168
+ return true;
169
+ });
166
170
  const next = visibleTools.map((t) => ({
167
171
  type: 'function',
168
172
  function: {
@@ -286,6 +290,42 @@ toolsOverride) {
286
290
  // 循环要么 return 要么 throw,理论上走不到这里;写出来让 TS 控制流分析满意。
287
291
  throw lastErr;
288
292
  }
293
+ /**
294
+ * 发送前规范化多模态 image_url:去掉 `detail:"auto"`。
295
+ *
296
+ * `auto` 是 OpenAI 的合法枚举,但 MiniMax 等兼容后端只认 low/default/high,收到 auto 直接
297
+ * 400(invalid image detail: auto, 2013)。省略该字段时各家都会用自己的默认值,是唯一
298
+ * 在所有后端都安全的写法;显式 low/high 属通用取值,原样保留。
299
+ *
300
+ * 放在 transport 边界而非构造点:历史里可能已经存着旧版本(或续接会话 / 外部注入)写下的
301
+ * `auto`,那种消息每轮都会被重发,只修构造点无法自愈。
302
+ *
303
+ * 无需改写时返回原数组引用 —— 图片消息含大段 base64,不能无条件深拷贝。
304
+ */
305
+ export function normalizeImageDetail(messages) {
306
+ let changed = false;
307
+ const next = messages.map((message) => {
308
+ const content = message.content;
309
+ if (!Array.isArray(content))
310
+ return message;
311
+ let messageChanged = false;
312
+ const parts = content.map((part) => {
313
+ const image = part.image_url;
314
+ if (part.type !== 'image_url' || !image)
315
+ return part;
316
+ if (image.detail !== 'auto')
317
+ return part;
318
+ messageChanged = true;
319
+ const { detail: _dropped, ...rest } = image;
320
+ return { ...part, image_url: rest };
321
+ });
322
+ if (!messageChanged)
323
+ return message;
324
+ changed = true;
325
+ return { ...message, content: parts };
326
+ });
327
+ return changed ? next : messages;
328
+ }
289
329
  /** 单次流式 LLM 请求(无重试);chat() 的内部实现,可被 __setChatCreateImpl 注入桩以做单测。 */
290
330
  async function chatOnce(messages, handlers, signal, toolsOverride) {
291
331
  // signal 透传给 SDK 第二参(RequestOptions);abort 后 for await 抛错,chat 不 catch,透传 runAgent 处理。
@@ -296,7 +336,7 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
296
336
  const activeTools = toolsOverride ?? chatTools;
297
337
  const stream = await create({
298
338
  model: config.model,
299
- messages,
339
+ messages: normalizeImageDetail(messages),
300
340
  tools: activeTools,
301
341
  stream: true,
302
342
  // 显式声明允许一次响应携带多个 tool_call(OpenAI 兼容协议标准字段)。
@@ -319,6 +359,24 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
319
359
  handlers.onText?.(text);
320
360
  consumedAny = true;
321
361
  };
362
+ // 实时 completion 估算(onProgress 提供时才统计,零开销兜底):
363
+ // 按 chunk 累加 CJK/other 字符数、汇总时才 ceil——逐片段 ceil 会把大量小 chunk 各向上
364
+ // 取整造成严重过估。统计口径 = raw content(含 think 段)+ tool_call 参数,与后端真实
365
+ // completion 计费范围一致(reasoning 也计费)。
366
+ let liveCjk = 0;
367
+ let liveOther = 0;
368
+ const countLive = (text) => {
369
+ for (const ch of text) {
370
+ const cp = ch.codePointAt(0) ?? 0;
371
+ if (isCJK(cp))
372
+ liveCjk++;
373
+ else
374
+ liveOther++;
375
+ }
376
+ };
377
+ const reportProgress = () => {
378
+ handlers.onProgress?.({ completionTokens: Math.ceil(liveCjk + liveOther / 4) });
379
+ };
322
380
  for await (const chunk of stream) {
323
381
  // usage:末尾 chunk(choices 可能为空)在 include_usage 时携带;先读再 continue。
324
382
  if (chunk.usage) {
@@ -330,12 +388,21 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
330
388
  cachedTokens: extras.cachedTokens,
331
389
  reasoningTokens: extras.reasoningTokens,
332
390
  };
391
+ // 末尾 chunk 把实测 prompt / completion / cache 命中即时推给实时 chip(无 delta,下方 continue 不会再报)。
392
+ handlers.onProgress?.({
393
+ completionTokens: usage.completionTokens,
394
+ promptTokens: usage.promptTokens,
395
+ cachedTokens: usage.cachedTokens,
396
+ });
333
397
  }
334
398
  const delta = chunk.choices?.[0]?.delta;
335
399
  if (!delta)
336
400
  continue; // 末尾 usage-only chunk 等无 delta
337
- if (delta.content)
401
+ if (delta.content) {
402
+ if (handlers.onProgress)
403
+ countLive(delta.content);
338
404
  emitVisible(thinkFilter.push(delta.content));
405
+ }
339
406
  if (delta.tool_calls) {
340
407
  // 不在这里 flush thinkFilter:其内部若有残留,只可能是 `<th` / `</thi` 一类
341
408
  // 潜在标签前缀。旧实现把这段在工具转折点强制送进 onText,正是 `k>` 等残片
@@ -355,10 +422,16 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
355
422
  handlers.onToolCall?.(fname); // 首次得知工具名:通知调用方启生成中 spinner
356
423
  entry.name += fname;
357
424
  }
358
- if (tc.function?.arguments)
425
+ if (tc.function?.arguments) {
426
+ if (handlers.onProgress)
427
+ countLive(tc.function.arguments);
359
428
  entry.arguments += tc.function.arguments;
429
+ }
360
430
  }
361
431
  }
432
+ // 流式实时用量:每个带 delta 的 chunk 后回调累计估算,驱动底栏实时 chip。
433
+ if (handlers.onProgress)
434
+ reportProgress();
362
435
  }
363
436
  // 流结束后只释放普通态下真实的文本尾;未闭合思考段继续丢弃。
364
437
  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
  },