mocode-ai 1.5.9 → 1.5.10

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.
@@ -1,4 +1,5 @@
1
1
  import { estimatePromptTokens, estimateTokens, isContextLengthError, } from '../llm/index.js';
2
+ import { visionBatch, visionKeep } from '../config/index.js';
2
3
  /**
3
4
  * 沿 cause 链(≤3 层)取第一个 errno,供 trace 取证。
4
5
  * undici 把底层 errno 挂在 cause 上(`TypeError: fetch failed` → cause `read ECONNRESET`),
@@ -33,6 +34,14 @@ export async function runModelTurn(input) {
33
34
  onContextUpdate?.();
34
35
  let historyRebuilt = false;
35
36
  let overflowRetried = false;
37
+ // 视觉滑动窗口**必须剪在 trim 之前**:contextTrimmer.trim() 拿的是 historyManager.snapshot(),
38
+ // 若先 trim,预算口径还是未剪的旧数组,80% 压力线照旧被图像撑爆(design-notes/vision-window.md §2.1)。
39
+ // 只在这一个地方剪;下面的 overflow 重试路径读同一个 snapshot,会自动受益。
40
+ const visionKeepN = visionKeep();
41
+ if (visionKeepN > 0 && historyManager.pruneVisionWindow({ keep: visionKeepN, batch: visionBatch(), step })) {
42
+ rebuildHistoryIndexes();
43
+ onContextUpdate?.();
44
+ }
36
45
  const compactStartedAt = Date.now();
37
46
  const trimResult = await contextTrimmer.trim({
38
47
  mode: scheduler ? 'scheduled' : 'fallback',
@@ -98,7 +107,7 @@ export async function runModelTurn(input) {
98
107
  ? '## Post-compaction recovery\n' +
99
108
  'Context was compacted before this request. Recover before doing anything else, in this order:\n' +
100
109
  '1. Read the session summary at the top of the history: `## Completed` is already done — do not redo or re-verify it. `## In Progress` / `## Next Steps` tell you exactly where work stopped and what is next.\n' +
101
- '2. Read `## Session state` below (from notes.md, refreshed every step): the active plan is authoritative — `[x]` steps are finished, resume from the first `[ ]`. A `## Compaction Snapshot` section there is the progress checkpoint written at this compaction.\n' +
110
+ '2. Read `## Session state` below (refreshed every step from notes.md / gui-actions.log): the active plan is authoritative — `[x]` steps are finished, resume from the first `[ ]`. A `## Compaction Snapshot` section there is the progress checkpoint written at this compaction. A `## GUI actions` section lists every GUI action already performed with its observed result: do not repeat an action that appears there, unless the latest screenshot contradicts it (then the screenshot wins — treat that line as attempted but unverified).\n' +
102
111
  (sessionStateText
103
112
  ? ''
104
113
  : '(No active plan or snapshot was found in notes.md — reconstruct what is done purely from the summary and treat its `## Completed` as ground truth.)\n') +
@@ -11,7 +11,7 @@ import { defaultAgentRuntimeContext } from './runtime-context.js';
11
11
  import { runModelTurn } from './model-turn.js';
12
12
  import { runToolTurn } from './tool-turn.js';
13
13
  import { createTurnLifecycle } from './turn-lifecycle.js';
14
- import { parseArgs, argumentErrorHint, isParallelTool, isResourceLockedCall, deniedOutcome, readDiffContext, pushToolResult, } from './tool-helpers.js';
14
+ import { parseArgs, argumentErrorHint, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, deniedOutcome, readDiffContext, pushToolResult, } from './tool-helpers.js';
15
15
  import { contextState, summarizeToolArguments } from '../session/index.js';
16
16
  import { createBudgetScheduler } from '../session/scheduler.js';
17
17
  import { invalidateArtifacts, rehydrateArtifacts } from '../context/index.js';
@@ -522,6 +522,98 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
522
522
  hooks.onToolDone?.();
523
523
  i = j;
524
524
  }
525
+ else if (isParallelOrchestrationCall(currentCall.name, ctx.toolRuntime) &&
526
+ !(ctx.getAgentMode() === 'plan' && planDisabledTools.has(currentCall.name))) {
527
+ // 连续编排调用(sub-agent):按 subAgentConcurrency 分块,块内并发。
528
+ // 权限确认仍严格按原序进行;全部 header 必须先于首个 execute 发出——渲染侧
529
+ // 靠 header 建「组容器批」并逐条追加 └─ 子 agent 行,若边启动边发 header,
530
+ // 先完成的 entry 会让组提前收口,后续 header 会另起一个新组。
531
+ let j = i;
532
+ while (j < calls.length &&
533
+ isParallelOrchestrationCall(calls[j].name, ctx.toolRuntime) &&
534
+ !isToolDeniedForStep(calls[j].name) &&
535
+ !(ctx.getAgentMode() === 'plan' && planDisabledTools.has(calls[j].name)))
536
+ j++;
537
+ const batch = calls.slice(i, j);
538
+ const entries = [];
539
+ for (let k = 0; k < batch.length; k++) {
540
+ const tc = batch[k];
541
+ const parsed = parseArgs(tc.arguments);
542
+ const tool = ctx.toolRuntime.findTool(tc.name);
543
+ const argumentsValid = tool && parsed !== null ? validateToolArguments(tool, parsed).valid : false;
544
+ let denied;
545
+ if (tool && argumentsValid) {
546
+ const perm = await ctx.checkPermission(tool, parsed ?? {}, signal, {
547
+ prompt: opts.permissionPrompt,
548
+ });
549
+ emitTrace('permission', {
550
+ source: 'agent_tool',
551
+ tool: tc.name,
552
+ decision: perm,
553
+ argumentHash: tracedCalls[i + k].args.sha256,
554
+ }, {
555
+ toolCallId: tracedCalls[i + k].toolCallId,
556
+ ...(tc.id ? { providerToolCallId: tc.id } : {}),
557
+ });
558
+ if (perm === 'deny')
559
+ denied = deniedOutcome(tc.name);
560
+ }
561
+ entries.push({
562
+ tc,
563
+ parsed,
564
+ diff: { preWriteOld: null, editStartLine: 1 },
565
+ ...(denied ? { denied } : {}),
566
+ });
567
+ }
568
+ for (const entry of entries)
569
+ hooks.onToolHeader?.(entry.tc);
570
+ const firstAllowed = entries.find((entry) => !entry.denied);
571
+ if (firstAllowed)
572
+ hooks.onToolStart?.(firstAllowed.tc.name);
573
+ const concurrency = Math.max(1, ctx.config.subAgentConcurrency);
574
+ for (let chunkStart = 0; chunkStart < entries.length; chunkStart += concurrency) {
575
+ const chunk = entries.slice(chunkStart, chunkStart + concurrency);
576
+ // 块内同时启动(executeToolOutcome 调用即开始 I/O),再按原序 await + 回灌:
577
+ // 完成顺序任意,history 与 trace 顺序始终是原调用序。
578
+ const started = chunk.map((entry) => {
579
+ if (entry.denied)
580
+ return Promise.resolve(entry.denied);
581
+ const hint = argumentErrorHint(entry.tc.name, runtimeContextState);
582
+ return ctx.toolRuntime.executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
583
+ callId: entry.tc.id,
584
+ allowedToolNames: currentAllowedToolNames(),
585
+ delegation: delegationForOrchestrator(),
586
+ ...(hint ? { argumentErrorHint: hint } : {}),
587
+ onLockAcquired: (lockedArgs) => {
588
+ entry.diff = readDiffContext(entry.tc, lockedArgs, ctx.jailResolve);
589
+ },
590
+ });
591
+ });
592
+ for (let k = 0; k < chunk.length; k++) {
593
+ const entry = chunk[k];
594
+ const outcome = await started[k];
595
+ usageMeter.add(outcome.usage);
596
+ opts.onToolOutcome?.(entry.tc.name, entry.parsed ?? {}, outcome);
597
+ traceToolEnd(entry.tc, i + chunkStart + k, outcome);
598
+ hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
599
+ pushToolResult(history, entry.tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
600
+ const invalidatedFiles = [
601
+ ...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])]),
602
+ ];
603
+ if (invalidatedFiles.length > 0) {
604
+ for (const changedFile of invalidatedFiles) {
605
+ relprune?.observeMutation(history, changedFile);
606
+ lifecycle?.pushMutation(history, history.length - 1, changedFile);
607
+ }
608
+ invalidateArtifacts(runtimeContextState, history, invalidatedFiles);
609
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
610
+ }
611
+ }
612
+ }
613
+ if (firstAllowed)
614
+ hooks.onToolDone?.();
615
+ i = j;
616
+ }
525
617
  else if (isResourceLockedCall(currentCall, ctx.toolRuntime) &&
526
618
  !(ctx.getAgentMode() === 'plan' && planDisabledTools.has(currentCall.name))) {
527
619
  // 连续文件 mutation:权限确认仍严格按原序进行;全部 preflight 完成后再启动。
@@ -1,3 +1,4 @@
1
+ import { applyVisionWindow, recordVisionWindowPrune } from '../../context/vision-window.js';
1
2
  function replaceMessages(backing, messages) {
2
3
  const replacement = messages.slice();
3
4
  backing.length = 0;
@@ -147,6 +148,18 @@ class DefaultHistoryManager {
147
148
  replaceMessages(this.backing, result.messages);
148
149
  this.revision++;
149
150
  }
151
+ pruneVisionWindow(opts) {
152
+ this.assertNoActiveBatch('prune vision window');
153
+ const result = applyVisionWindow(this.backing, opts);
154
+ if (!result.changed)
155
+ return false;
156
+ replaceMessages(this.backing, result.messages);
157
+ // 累积埋点放在纯函数模块外部(HistoryManager 这层),单测可用 reset 隔离。
158
+ recordVisionWindowPrune(result.dropped);
159
+ // revision++ 必须:snapshot() 用 revision 标识版本,下游(compact / checkpoint)据此判断是否重建。
160
+ this.revision++;
161
+ return true;
162
+ }
150
163
  createCheckpoint() {
151
164
  return { revision: this.revision, messages: this.backing.slice() };
152
165
  }
@@ -1,4 +1,5 @@
1
1
  import { ADD_TOOL_GROUPS_TOOL_NAME } from '../../config/profiles.js';
2
+ import { config } from '../../config/index.js';
2
3
  import { t } from '../../i18n/index.js';
3
4
  import { checkPermission as defaultCheckPermission } from '../../permissions/index.js';
4
5
  import { jailResolve as defaultJailResolve } from '../../sandbox/index.js';
@@ -6,7 +7,7 @@ import { summarizeToolArguments } from '../../session/index.js';
6
7
  import { getPlanDisabledTools } from '../../tools/constants.js';
7
8
  import { defaultToolRuntime } from '../../tools/registry.js';
8
9
  import { validateToolArguments } from '../../tools/validation.js';
9
- import { deniedOutcome, isParallelTool, isResourceLockedCall, parseArgs, readDiffContext } from '../tool-helpers.js';
10
+ import { deniedOutcome, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, parseArgs, readDiffContext, } from '../tool-helpers.js';
10
11
  const DEFAULT_DEPENDENCIES = {
11
12
  toolRuntime: defaultToolRuntime,
12
13
  checkPermission: defaultCheckPermission,
@@ -205,6 +206,70 @@ class LegacyCompatibleToolDispatcher {
205
206
  index = end;
206
207
  continue;
207
208
  }
209
+ if (isParallelOrchestrationCall(current.name, toolRuntime) &&
210
+ !(request.policy.mode === 'plan' && getPlanDisabledTools().has(current.name))) {
211
+ // 连续编排调用(sub-agent):按并发上限分块,块内并发。权限确认按原序;全部 header
212
+ // 必须先于首个 execute——渲染侧靠 header 建组容器批,先收口会让后续 header 另起新组。
213
+ const concurrency = Math.max(1, request.orchestrationConcurrency ?? config.subAgentConcurrency);
214
+ let end = index;
215
+ while (end < calls.length &&
216
+ isParallelOrchestrationCall(calls[end].name, toolRuntime) &&
217
+ !request.isDenied(calls[end].name) &&
218
+ !(request.policy.mode === 'plan' && getPlanDisabledTools().has(calls[end].name)))
219
+ end++;
220
+ const batch = calls.slice(index, end);
221
+ const entries = [];
222
+ for (let offset = 0; offset < batch.length; offset++) {
223
+ const call = batch[offset];
224
+ const parsed = parseArgs(call.arguments);
225
+ const tool = toolRuntime.findTool(call.name);
226
+ const argumentsValid = tool && parsed !== null ? validateToolArguments(tool, parsed).valid : false;
227
+ let denied;
228
+ if (tool && argumentsValid) {
229
+ const decision = await checkPermission(tool, parsed ?? {}, request.signal, {
230
+ prompt: request.permissionPrompt,
231
+ });
232
+ request.onEvent({ type: 'permission', call, callIndex: index + offset, decision });
233
+ if (decision === 'deny')
234
+ denied = deniedOutcome(call.name);
235
+ }
236
+ entries.push({
237
+ call,
238
+ parsed,
239
+ diff: { preWriteOld: null, editStartLine: 1 },
240
+ ...(denied ? { denied } : {}),
241
+ });
242
+ }
243
+ for (const entry of entries)
244
+ request.onEvent({ type: 'header', call: entry.call });
245
+ const firstAllowed = entries.find((entry) => !entry.denied);
246
+ if (firstAllowed)
247
+ request.onEvent({ type: 'start', tool: firstAllowed.call.name });
248
+ for (let chunkStart = 0; chunkStart < entries.length; chunkStart += concurrency) {
249
+ const chunk = entries.slice(chunkStart, chunkStart + concurrency);
250
+ // 块内同时启动,再按原序 await + 回灌:完成顺序任意,history/trace 始终是原调用序。
251
+ const started = chunk.map((entry) => {
252
+ if (entry.denied)
253
+ return Promise.resolve(entry.denied);
254
+ return execute(entry.call, request.argumentErrorHint(entry.call.name), (lockedArgs) => {
255
+ entry.diff = readDiffContext(entry.call, lockedArgs, jailResolve);
256
+ });
257
+ });
258
+ for (let k = 0; k < chunk.length; k++) {
259
+ const callIndex = index + chunkStart + k;
260
+ const entry = chunk[k];
261
+ const outcome = await started[k];
262
+ record(callIndex, outcome);
263
+ executionEvents(callIndex, entry.parsed, outcome);
264
+ resultEvent(callIndex, outcome, entry.denied ? null : entry.parsed, entry.diff);
265
+ invalidate(outcome);
266
+ }
267
+ }
268
+ if (firstAllowed)
269
+ request.onEvent({ type: 'done' });
270
+ index = end;
271
+ continue;
272
+ }
208
273
  if (isResourceLockedCall(current, toolRuntime) &&
209
274
  !(request.policy.mode === 'plan' && getPlanDisabledTools().has(current.name))) {
210
275
  let end = index;
@@ -15,6 +15,7 @@ import { contextState } from '../session/compact.js';
15
15
  import { capToolResultForHistory } from '../session/compact.js';
16
16
  import { recordArtifact, knownEditTargets } from '../context/index.js';
17
17
  import { isToolResultSuccess } from '../context/utils.js';
18
+ import { appendGuiAction } from '../session/gui-actions.js';
18
19
  /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
19
20
  export function parseArgs(raw) {
20
21
  try {
@@ -57,9 +58,15 @@ export function isResourceLockedTool(name, toolRuntime = defaultToolRuntime) {
57
58
  }
58
59
  export function isResourceLockedCall(call, toolRuntime = defaultToolRuntime) {
59
60
  // sub-agent 是长时全域操作(嵌套 agent 与主 agent 同权,可写任意文件/跑任意命令),
60
- // 不进 mutation 并发批:逐个串行执行,避免两个子 agent 同时改工作区。
61
+ // 不进 mutation 并发批:那个批的语义是「同文件排队、异文件并发」。
61
62
  return call.name !== 'sub-agent' && isResourceLockedTool(call.name, toolRuntime);
62
63
  }
64
+ /** 编排类工具(如 sub-agent):同一轮内连续派发的多个调用按上限成批并行。
65
+ * 写冲突仍由子 agent 内层工具各自获取的资源锁保护(编排器本身不持锁)。 */
66
+ export function isParallelOrchestrationCall(name, toolRuntime = defaultToolRuntime) {
67
+ const tool = toolRuntime.findTool(name);
68
+ return !!tool && toolRuntime.getToolCapabilities(tool).parallelOrchestration === true;
69
+ }
63
70
  /** 权限拒绝时的结构化 ToolOutcome(供调度器统一回灌,不抛错中断循环)。 */
64
71
  export function deniedOutcome(name) {
65
72
  return {
@@ -131,4 +138,9 @@ export function pushToolResult(history, tc, output, pruner, lifecycle, _schedule
131
138
  if (lifecycle)
132
139
  lifecycle.pushTool(history, messageIndex, succeeded);
133
140
  runtimeContextState.lifecycleStats = lifecycle?.stats();
141
+ // GUI 动作台账(L2):每个 computer 动作往会话目录的 gui-actions.log 追加一行。
142
+ // 放这里而不是工具内部,是因为工具不该持有会话目录与文件 I/O;写失败静默,
143
+ // 台账是可观测性,绝不能反过来改变动作结果。
144
+ if (tc.name === 'computer')
145
+ appendGuiAction(output);
134
146
  }
@@ -3,6 +3,12 @@ const PLAN_NAG_THRESHOLD = 3;
3
3
  const PLAN_NAG_TEXT = '[mocode] Reminder: you have an active plan in notes.md but have not updated it recently. ' +
4
4
  'If you finished a step, call plan_update to check it off (keep at most one in_progress); ' +
5
5
  'if the whole plan is done, let plan_update settle it to ## Done:. If the plan changed scope, update it to match reality.';
6
+ /**
7
+ * 工具附件消息的前言。单点常量:视觉滑动窗口(src/context/vision-window.ts)靠它区分
8
+ * 「工具回灌的屏幕帧」与「用户粘贴的图」——两边各写一份字符串会让识别规则悄悄失效。
9
+ * 新增附件产出点**必须**复用这一个前言。
10
+ */
11
+ export const ATTACHMENT_PREAMBLE = 'The view_image tool loaded the following visual input: ';
6
12
  /** Owns tool-turn history publication, transaction settlement, plan nag, attachments and checkpoint ordering. */
7
13
  export async function runToolTurn(input) {
8
14
  const { opts, ctx, historyManager, result, stream, step, maxSteps, planState, turnLifecycle, cancellationLifecycle, terminationPolicy, rebuildHistoryIndexes, dispatch, } = input;
@@ -53,7 +59,7 @@ export async function runToolTurn(input) {
53
59
  const content = [
54
60
  {
55
61
  type: 'text',
56
- text: `The view_image tool loaded the following visual input: ${names}. Analyze the attached image content directly.`,
62
+ text: `${ATTACHMENT_PREAMBLE}${names}. Analyze the attached image content directly.`,
57
63
  },
58
64
  ...modelAttachments.map((attachment) => ({
59
65
  type: 'image_url',
@@ -4,11 +4,14 @@ import path from 'node:path';
4
4
  import dotenv from 'dotenv';
5
5
  import { getCurrentSessionId } from '../session/state.js';
6
6
  import { getNotesFilePath, extractActiveNotesSections } from '../session/notes.js';
7
+ import { buildGuiActionsSection } from '../session/gui-actions.js';
7
8
  import { buildWorkDisciplineSection, inferModelFamily } from '../agent/work-discipline.js';
8
9
  import { buildValidationCommandsSection } from '../verification/prompt.js';
9
10
  import { getActivePresetName, readPreset } from './presets.js';
10
11
  import { detectLanguage, setLanguage, t } from '../i18n/index.js';
11
12
  import { isProfileName, profileHasGroup } from './profiles.js';
13
+ // 端点常量归协议实现方(jev-client.ts,纯叶子无 import,不引入环);此处只引用不重定义。
14
+ import { DEFAULT_JEV_BASE_URL } from '../tools/jev-client.js';
12
15
  /**
13
16
  * 按优先级加载配置文件并回填 process.env:
14
17
  * 候选(后者覆盖前者,优先级升序):<cwd>/.env(兼容旧用法,最低)→ ~/.mocode/config(全局)→ <cwd>/.mocode/config(项目级覆盖,最高)。
@@ -286,26 +289,32 @@ export function reinjectSessionStateIntoSystem(history) {
286
289
  return planChanged || notesChanged;
287
290
  }
288
291
  /**
289
- * 构造"会话状态提醒"正文(活跃 `## Plan:` 段 + 活跃笔记段正文),供 agent/core 每步
290
- * 拼进 requestHistory **末尾**的 ephemeral system 消息。
292
+ * 构造"会话状态提醒"正文(活跃 `## Plan:` 段 + 活跃笔记段 + GUI 动作台账),供 agent/core
293
+ * 每步拼进 requestHistory **末尾**的 ephemeral system 消息。
291
294
  *
292
295
  * 为什么在尾部而不是 history[0](prompt 缓存):plan_update / note_append 是设计上鼓励
293
296
  * 高频调用的工具,一旦它们改写系统提示,支持自动前缀缓存的后端(OpenAI / DeepSeek /
294
297
  * GLM / Qwen)就会从第一个 token 起全部 miss。放到历史末尾后,前面整段(系统提示 + 全部
295
- * 已有对话)保持逐字节稳定,只有尾部这一小条随 notes.md 变化。
298
+ * 已有对话)保持逐字节稳定,只有尾部这一小条随来源文件变化。
296
299
  *
297
- * 纯读函数:不改 history,也不写文件。notes.md 不存在 / 无活跃内容时返回 ''(零开销)。
300
+ * 台账段同样在尾部 → 抖动免费(anthropic provider 明确"动态 session reminder 不参与缓存
301
+ * 断点"),所以它可以每步都重写一遍,不需要像图片窗口那样成批淘汰。
302
+ *
303
+ * 纯读函数:不改 history,也不写文件。三者皆空时返回 ''(零开销)。
298
304
  */
299
305
  export function buildSessionStateReminder(sessionId = getCurrentSessionId()) {
300
306
  const plan = extractActivePlanSection(sessionId);
301
307
  const notes = extractActiveNotesSections(undefined, sessionId);
302
- if (!plan && !notes)
308
+ const guiActions = buildGuiActionsSection(sessionId);
309
+ if (!plan && !notes && !guiActions)
303
310
  return '';
311
+ const sources = [notes || plan ? 'notes.md' : '', guiActions ? 'gui-actions.log' : ''].filter(Boolean).join(' + ');
304
312
  const parts = [
305
- '## Session state (current, from notes.md)',
306
- 'This block mirrors the live session notepad and is refreshed every step; treat it as the authoritative plan/notes state, and ignore any older copy earlier in this conversation.',
313
+ `## Session state (current, from ${sources})`,
314
+ 'This block mirrors the live session state and is refreshed every step; treat it as authoritative, and ignore any older copy earlier in this conversation.',
307
315
  ...(plan ? [plan] : []),
308
316
  ...(notes ? [notes] : []),
317
+ ...(guiActions ? [guiActions] : []),
309
318
  ];
310
319
  return parts.join('\n\n');
311
320
  }
@@ -533,6 +542,7 @@ export const config = {
533
542
  maxSteps: Number(process.env.MAX_STEPS) || 1000,
534
543
  subAgentEnabled: process.env.MOCODE_SUBAGENT_ENABLED === 'true',
535
544
  subAgentMaxSteps: Number(process.env.SUB_AGENT_MAX_STEPS) || Number(process.env.MAX_STEPS) || 1000,
545
+ subAgentConcurrency: Math.max(1, Number(process.env.SUB_AGENT_CONCURRENCY) || 5),
536
546
  frontendToolsEnabled: process.env.MOCODE_FRONTEND_TOOLS_ENABLED === 'true',
537
547
  computerUseEnabled: process.env.MOCODE_COMPUTER_USE_ENABLED === 'true',
538
548
  mcpEnabled: process.env.MOCODE_MCP_ENABLED !== 'false',
@@ -681,6 +691,51 @@ export function updateComputerUseConfig(enabled) {
681
691
  config.computerUseEnabled = enabled;
682
692
  process.env.MOCODE_COMPUTER_USE_ENABLED = enabled ? 'true' : 'false';
683
693
  }
694
+ // ── 视觉历史滑动窗口(Computer Use)───────────────────────────────────────
695
+ //
696
+ // 屏幕帧永驻 history 会让图像 token 二次增长(每次请求都要重发整个 history):
697
+ // 20 步 ≈387k tk、50 步 ≈2.35M tk,长 GUI 任务必然中途 compact 并丢掉视觉 grounding。
698
+ // 滑动窗口按「最近 keep 条 + 成批淘汰 batch 条」把旧帧换成文本占位。详见
699
+ // design-notes/vision-window.md。
700
+ //
701
+ // 这里是 process.env 直读而非 Config 单例:与其它 CU 调优项(MOCODE_CU_MAX_EDGE 等)同款,
702
+ // 每步都在调用点求值,改 .env 后立即生效,不需要重启 REPL。
703
+ /** 窗口保留的屏幕帧条数默认值。 */
704
+ export const DEFAULT_VISION_KEEP = 6;
705
+ /** 单次淘汰条数默认值。1 = 严格窗口(token 最省 / 前缀最不稳)。 */
706
+ export const DEFAULT_VISION_BATCH = 4;
707
+ /**
708
+ * 保留最近多少条屏幕帧。**0 = 完全关闭窗口**(回退到现状,一键回滚)。
709
+ *
710
+ * 空串语义与 `MOCODE_CU_DIFF_THRESHOLD`(src/tools/builtins/computer.ts:49)一致:
711
+ * **未配置 / 空串 → 回落默认**;显式写 0 才是 0。`Number('') === 0`,直接 Number 判数值
712
+ * 会让"注释掉这个变量"静默变成关闭窗口。
713
+ */
714
+ export function visionKeep() {
715
+ const raw = process.env.MOCODE_CU_VISION_KEEP;
716
+ if (raw === undefined || raw.trim() === '')
717
+ return DEFAULT_VISION_KEEP;
718
+ const parsed = Number(raw);
719
+ if (!Number.isFinite(parsed) || parsed < 0)
720
+ return DEFAULT_VISION_KEEP;
721
+ return Math.round(parsed);
722
+ }
723
+ /**
724
+ * 一次淘汰多少条。>=1;非法值回落默认。
725
+ *
726
+ * batch 是 **token 与 prompt cache 的权衡**:batch 越大,"两次淘汰之间 history 前缀字节不变"
727
+ * 的窗口越长,前缀命中率越高,代价是在途多留几张图。判据见文档 §7.2:
728
+ * 显式 prompt cache(Anthropic 断点)取 6-8;隐式前缀缓存取 4;无缓存取 1。
729
+ */
730
+ export function visionBatch() {
731
+ const raw = process.env.MOCODE_CU_VISION_BATCH;
732
+ if (raw === undefined || raw.trim() === '')
733
+ return DEFAULT_VISION_BATCH;
734
+ const parsed = Number(raw);
735
+ if (!Number.isFinite(parsed) || parsed < 1)
736
+ return DEFAULT_VISION_BATCH;
737
+ return Math.round(parsed);
738
+ }
684
739
  /** MCP 总开关;关闭时下次启动跳过 MCP 配置读取与服务连接。 */
685
740
  export function isMcpEnabled() {
686
741
  return config.mcpEnabled;
@@ -740,3 +795,48 @@ export function updateLanguageConfig(language) {
740
795
  setLanguage(language);
741
796
  process.env.MOCODE_LANGUAGE = language;
742
797
  }
798
+ /** 当前路由模式;未知值一律回退 llm(保守:保证有可用的路由后端)。 */
799
+ export function getRouterMode() {
800
+ return process.env.MOCODE_ROUTER_MODE === 'jev' ? 'jev' : 'llm';
801
+ }
802
+ /** /router 的写入口;持久化由调用方写 MOCODE_ROUTER_MODE(见 config/file.ts)。 */
803
+ export function updateRouterMode(mode) {
804
+ process.env.MOCODE_ROUTER_MODE = mode;
805
+ }
806
+ function readNumberEnv(name, fallback) {
807
+ const raw = process.env[name];
808
+ if (raw === undefined || raw.trim() === '')
809
+ return fallback;
810
+ const value = Number(raw);
811
+ return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback;
812
+ }
813
+ /** 读取 Jev 路由配置(每次调用实时读 env,支持 /router 即时改)。 */
814
+ export function getJevRouterConfig() {
815
+ return {
816
+ baseUrl: (process.env.MOCODE_ROUTER_JEV_BASE_URL || DEFAULT_JEV_BASE_URL).replace(/\/+$/, ''),
817
+ apiKey: process.env.MOCODE_ROUTER_JEV_API_KEY || '',
818
+ model: process.env.MOCODE_ROUTER_JEV_MODEL || 'jev-latest',
819
+ confidenceMin: readNumberEnv('MOCODE_ROUTER_CONFIDENCE_MIN', 0.65),
820
+ confidenceMinMcp: readNumberEnv('MOCODE_ROUTER_CONFIDENCE_MIN_MCP', 0.85),
821
+ };
822
+ }
823
+ /**
824
+ * /router 的写入口:把 patch 中的字段写进对应环境变量。
825
+ * 只改内存/进程环境;持久化由调用方写 ~/.mocode/config(见 config/file.ts)。
826
+ */
827
+ export function updateJevRouterConfig(patch) {
828
+ if (patch.baseUrl !== undefined)
829
+ process.env.MOCODE_ROUTER_JEV_BASE_URL = patch.baseUrl;
830
+ if (patch.apiKey !== undefined)
831
+ process.env.MOCODE_ROUTER_JEV_API_KEY = patch.apiKey;
832
+ if (patch.model !== undefined)
833
+ process.env.MOCODE_ROUTER_JEV_MODEL = patch.model;
834
+ if (patch.confidenceMin !== undefined)
835
+ process.env.MOCODE_ROUTER_CONFIDENCE_MIN = String(patch.confidenceMin);
836
+ if (patch.confidenceMinMcp !== undefined)
837
+ process.env.MOCODE_ROUTER_CONFIDENCE_MIN_MCP = String(patch.confidenceMinMcp);
838
+ }
839
+ /** Jev 后端是否已具备最小可用配置(有 key 才可能成功)。 */
840
+ export function isJevRouterConfigured() {
841
+ return getJevRouterConfig().apiKey.trim() !== '';
842
+ }