mocode-ai 1.3.9 → 1.4.0

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 (134) hide show
  1. package/README.md +78 -70
  2. package/README.zh-CN.md +81 -73
  3. package/bin/mocode-agent-host.js +1 -1
  4. package/dist/agent/core.js +226 -293
  5. package/dist/agent/index.js +14 -10
  6. package/dist/agent/mode.js +3 -3
  7. package/dist/agent/runtime-context.js +45 -0
  8. package/dist/agent/spawn.js +101 -86
  9. package/dist/agent/tool-helpers.js +134 -0
  10. package/dist/agent/trace-state.js +118 -0
  11. package/dist/agents/coordinator.js +6 -1
  12. package/dist/changeset/index.js +9 -8
  13. package/dist/config/index.js +109 -77
  14. package/dist/config/presets.js +3 -1
  15. package/dist/config/profiles.js +125 -0
  16. package/dist/context/age-aware.js +2 -4
  17. package/dist/context/artifacts.js +6 -13
  18. package/dist/context/budget.js +6 -8
  19. package/dist/context/classifier.js +1 -2
  20. package/dist/context/encoders/code.js +2 -6
  21. package/dist/context/encoders/command.js +1 -3
  22. package/dist/context/encoders/search.js +1 -3
  23. package/dist/context/index.js +1 -1
  24. package/dist/context/lifecycle.js +5 -3
  25. package/dist/context/pipeline.js +1 -1
  26. package/dist/context/relevance.js +2 -4
  27. package/dist/context/token-calibration.js +13 -18
  28. package/dist/host/stdio.js +73 -12
  29. package/dist/i18n/index.js +96 -44
  30. package/dist/index.js +1 -1
  31. package/dist/llm/index.js +39 -23
  32. package/dist/llm/provider.js +23 -0
  33. package/dist/llm/providers/anthropic.js +12 -18
  34. package/dist/llm/tool-schema.js +48 -0
  35. package/dist/mcp/client.js +70 -25
  36. package/dist/mcp/config.js +1 -3
  37. package/dist/mcp/index.js +3 -1
  38. package/dist/memory/reflect.js +3 -6
  39. package/dist/memory/store.js +10 -12
  40. package/dist/permissions/index.js +66 -22
  41. package/dist/pet/bridge.js +2 -2
  42. package/dist/repl/commands/appearance.js +72 -0
  43. package/dist/repl/commands/compact.js +114 -0
  44. package/dist/repl/commands/context.js +17 -0
  45. package/dist/repl/commands/image.js +71 -0
  46. package/dist/repl/commands/memory.js +51 -0
  47. package/dist/repl/commands/mode.js +47 -0
  48. package/dist/repl/commands/model.js +435 -0
  49. package/dist/repl/commands/pet.js +70 -0
  50. package/dist/repl/commands/registry.js +46 -0
  51. package/dist/repl/commands/session.js +102 -0
  52. package/dist/repl/commands/skill.js +95 -0
  53. package/dist/repl/commands/system.js +202 -0
  54. package/dist/repl/commands/tool-group.js +233 -0
  55. package/dist/repl/commands/types.js +4 -0
  56. package/dist/repl/commands.js +324 -0
  57. package/dist/repl/index.js +5 -2577
  58. package/dist/repl/message-format.js +151 -0
  59. package/dist/repl/running-input.js +147 -0
  60. package/dist/repl/runtime.js +816 -0
  61. package/dist/repl/status-bar.js +161 -0
  62. package/dist/rollback/index.js +44 -14
  63. package/dist/runtime/browser-manager.js +1 -1
  64. package/dist/runtime/dev-server-manager.js +1 -1
  65. package/dist/runtime/input-injector.js +313 -0
  66. package/dist/runtime/screen-capture.js +89 -0
  67. package/dist/runtime/screen-pipeline.js +223 -0
  68. package/dist/sandbox/index.js +1 -1
  69. package/dist/sandbox/policy.js +8 -2
  70. package/dist/session/compact.js +22 -34
  71. package/dist/session/index.js +2 -2
  72. package/dist/session/notes.js +39 -5
  73. package/dist/session/persist.js +14 -8
  74. package/dist/session/scheduler.js +3 -4
  75. package/dist/session/trace-metrics.js +6 -3
  76. package/dist/skills/activation.js +10 -5
  77. package/dist/skills/discover.js +5 -17
  78. package/dist/skills/index.js +1 -1
  79. package/dist/skills/runner.js +28 -44
  80. package/dist/skills/toolmap.js +5 -2
  81. package/dist/tools/builtins/browser.js +17 -3
  82. package/dist/tools/builtins/computer.js +322 -0
  83. package/dist/tools/builtins/dev-server.js +1 -3
  84. package/dist/tools/builtins/edit-file.js +21 -7
  85. package/dist/tools/builtins/grep.js +1 -1
  86. package/dist/tools/builtins/index.js +28 -25
  87. package/dist/tools/builtins/memory-forget.js +1 -1
  88. package/dist/tools/builtins/memory-graph.js +4 -7
  89. package/dist/tools/builtins/memory-list.js +2 -6
  90. package/dist/tools/builtins/memory-save.js +8 -2
  91. package/dist/tools/builtins/memory-search.js +4 -4
  92. package/dist/tools/builtins/note-append.js +14 -3
  93. package/dist/tools/builtins/plan-update.js +40 -9
  94. package/dist/tools/builtins/run-command.js +8 -8
  95. package/dist/tools/builtins/screenshot.js +11 -87
  96. package/dist/tools/builtins/task.js +29 -40
  97. package/dist/tools/builtins/use-skill.js +1 -1
  98. package/dist/tools/builtins/view-image.js +5 -3
  99. package/dist/tools/builtins/web-fetch.js +1 -3
  100. package/dist/tools/builtins/web-search.js +3 -7
  101. package/dist/tools/builtins/write-file.js +4 -2
  102. package/dist/tools/constants.js +50 -37
  103. package/dist/tools/policy.js +221 -0
  104. package/dist/tools/registry.js +40 -19
  105. package/dist/tools/resource-lock.js +4 -2
  106. package/dist/tools/router.js +143 -0
  107. package/dist/tools/validation.js +6 -5
  108. package/dist/ui/batch.js +63 -23
  109. package/dist/ui/clipboard.js +17 -5
  110. package/dist/ui/content.js +166 -13
  111. package/dist/ui/diff.js +61 -19
  112. package/dist/ui/fuzzy.js +35 -2
  113. package/dist/ui/intervention.js +2 -4
  114. package/dist/ui/layout-internal/content-write.js +587 -0
  115. package/dist/ui/layout-internal/core.js +590 -0
  116. package/dist/ui/layout-internal/input-paint.js +365 -0
  117. package/dist/ui/layout-internal/screen.js +128 -0
  118. package/dist/ui/layout-internal/scroll.js +152 -0
  119. package/dist/ui/layout-internal/selection.js +229 -0
  120. package/dist/ui/layout-internal/state.js +82 -0
  121. package/dist/ui/layout-internal/statusbar.js +351 -0
  122. package/dist/ui/layout-types.js +1 -0
  123. package/dist/ui/layout.js +3 -2288
  124. package/dist/ui/markdown.js +10 -2
  125. package/dist/ui/prompt-internal/editor.js +945 -0
  126. package/dist/ui/prompt-internal/paste.js +52 -0
  127. package/dist/ui/prompt-internal/pickers.js +519 -0
  128. package/dist/ui/prompt-internal/types.js +1 -0
  129. package/dist/ui/prompt.js +4 -1506
  130. package/dist/ui/render.js +87 -2
  131. package/dist/verification/affected.js +11 -5
  132. package/dist/verification/discovery.js +1 -3
  133. package/dist/verification/profile.js +11 -3
  134. package/package.json +11 -2
@@ -5,7 +5,7 @@
5
5
  // 优先级:内置 skill < 用户/系统 skill < 项目级 skill(后注册覆盖先注册,
6
6
  // 项目级最后扫描,自然在 Map 中胜出)。同名用户 skill 完全替换内置版。
7
7
  import { existsSync, readFileSync } from 'node:fs';
8
- import { discoverSkills, parseFrontmatter, skillsScanSignature, } from './discover.js';
8
+ import { discoverSkills, parseFrontmatter, skillsScanSignature } from './discover.js';
9
9
  import { builtinSkills } from './builtin-skills.js';
10
10
  let cache = null;
11
11
  let cacheKey = null;
@@ -13,19 +13,6 @@ import { isSkillTrusted, ensureSkillTrust } from './trust.js';
13
13
  import { mapSkillTools } from './toolmap.js';
14
14
  import { spawnAgent } from '../agent/spawn.js';
15
15
  import { runCommandRaw } from '../tools/builtins/run-command.js';
16
- /** 把 raw ChangeSet 折成 ToolOutcome 需要的 ChangeSetSummary(哈希缺失位填 null,仅用于展示)。 */
17
- function toChangeSetSummary(cs) {
18
- return {
19
- id: cs.id,
20
- changedFiles: cs.changes.map((c) => c.path),
21
- changes: cs.changes.map((c) => ({
22
- path: c.path,
23
- operation: c.operation,
24
- beforeHash: null,
25
- afterHash: null,
26
- })),
27
- };
28
- }
29
16
  const MAX_INJECTIONS = 4;
30
17
  const MAX_INJECTION_OUTPUT = 4096;
31
18
  const INJECT_TIMEOUT = 10_000;
@@ -87,36 +74,44 @@ async function injectCommands(body, skillDir, signal) {
87
74
  * 非 project 或已信任的 project skill 才执行注入;否则先尝试一次性确认,未通过则跳过注入。
88
75
  * 返回 null 表示正文缺失(调用方生成「未找到」错误)。
89
76
  */
90
- export async function renderSkillBody(skill, args, signal) {
77
+ export async function renderSkillBody(skill, args, signal, parentAllowedToolNames) {
91
78
  const raw = getSkillBody(skill.name);
92
79
  if (raw === null)
93
80
  return null;
94
81
  let body = substituteArgs(raw, args, skill.dir);
95
82
  if (/!`[^`]+`/.test(body)) {
96
- // 非 project 恒信任;project 依次查信任记录、再弹一次性确认。
97
- // ensureSkillTrust 的 true 覆盖 'trusted'(已记录)与 'once'(仅本次)两种,直接据此注入。
98
- const trusted = skill.origin !== 'project' || isSkillTrusted(skill) || (await ensureSkillTrust(skill));
99
- if (trusted) {
100
- body = await injectCommands(body, skill.dir, signal);
83
+ const allowed = mapSkillTools(skill.allowedTools).tools;
84
+ const disallowed = new Set(mapSkillTools(skill.disallowedTools).tools ?? []);
85
+ const parentAllowsShell = parentAllowedToolNames?.includes('run_command') === true;
86
+ const skillAllowsShell = (allowed === null || allowed.includes('run_command')) && !disallowed.has('run_command');
87
+ if (!parentAllowsShell || !skillAllowsShell) {
88
+ // Trust 只证明 skill 来源,不授予 capability;隐藏命令必须受产生 use_skill/run_skill
89
+ // 调用的父 step 快照约束,且不能借未声明/未知 allowed-tools 扩权。
90
+ body = body.replace(/!`[^`]+`/g, '_(command injection skipped: run_command not allowed by tool policy)_');
101
91
  }
102
92
  else {
103
- // 未信任:清掉注入标记,避免把未授权命令留在提示里。
104
- body = body.replace(/!`[^`]+`/g, '_(command injection skipped: skill not trusted)_');
93
+ // 非 project 恒信任;project 依次查信任记录、再弹一次性确认。
94
+ const trusted = skill.origin !== 'project' || isSkillTrusted(skill) || (await ensureSkillTrust(skill));
95
+ if (trusted) {
96
+ body = await injectCommands(body, skill.dir, signal);
97
+ }
98
+ else {
99
+ // 未信任:清掉注入标记,避免把未授权命令留在提示里。
100
+ body = body.replace(/!`[^`]+`/g, '_(command injection skipped: skill not trusted)_');
101
+ }
105
102
  }
106
103
  }
107
104
  return body;
108
105
  }
109
- /** 把 SpawnResult 转成 ToolOutcome,汇总/计费/变更集透传,不丢回滚信息。 */
106
+ /** 把 SpawnResult 转成 ToolOutcome。子 agent 与主 agent 同权、直接写工作区,其文件修改已由
107
+ * 主 agent 当前轮次的回滚事务追踪,因此这里不再需要单独透传变更集。 */
110
108
  function toOutcome(res) {
111
109
  const status = res.status === 'completed' ? 'success' : res.status === 'aborted' ? 'aborted' : 'error';
112
110
  return {
113
111
  status,
114
- code: res.status === 'completed' ? 'OK'
115
- : res.status === 'aborted' ? 'ABORTED'
116
- : 'EXECUTION_ERROR',
112
+ code: res.status === 'completed' ? 'OK' : res.status === 'aborted' ? 'ABORTED' : 'EXECUTION_ERROR',
117
113
  retryable: false,
118
114
  output: res.summary ?? (res.status === 'failed' ? '(skill failed with no output)' : ''),
119
- changeSet: res.changeSet ? toChangeSetSummary(res.changeSet) : undefined,
120
115
  usage: res.usage,
121
116
  };
122
117
  }
@@ -138,26 +133,14 @@ export function readSkillFile(skill, file, maxBytes) {
138
133
  return null;
139
134
  }
140
135
  }
141
- /** 能产生副作用的 mocode 工具;用于在未显式声明 agent: 时推断 fork 子 agent 的模式。 */
142
- const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'run_command']);
143
- /**
144
- * fork 子 agent 模式:`agent:` 显式声明优先;否则按工具面推断——
145
- * 未声明 allowed-tools(完整工具集)或白名单含写工具 → 'write',纯只读白名单 → 'read'。
146
- * 避免写类 skill 因缺省字段被静默降级为只读。
147
- */
148
- export function resolveSpawnMode(skill, tools) {
149
- if (skill.agentMode)
150
- return skill.agentMode;
151
- if (tools === null || tools.some((t) => WRITE_TOOLS.has(t)))
152
- return 'write';
153
- return 'read';
154
- }
155
136
  /**
156
- * 执行一个 skill(无论 inline 还是 fork 都走隔离子 agent,保证「可执行」语义统一):
137
+ * 执行一个 skill(无论 inline 还是 fork 都走子 agent,保证「可执行」语义统一):
157
138
  * - 找不到 → UNKNOWN_TOOL 语义 error
158
139
  * - 执行面门禁(ensureSkillTrust)未过 → denied
159
- * - 渲染正文 → spawnAgent(白名单工具 / mode / maxSteps / signal)
140
+ * - 渲染正文 → spawnAgent(白名单工具 / maxSteps / signal)
160
141
  * - 子 agent 摘要回灌为 output,usage / changeSet 透传
142
+ *
143
+ * fork 子 agent 与主 agent 同源同权,只有 allowed-tools 是 skill 作者声明的特化收缩。
161
144
  */
162
145
  export async function runSkill(a, ctx) {
163
146
  const name = String(a.name ?? '').trim();
@@ -176,7 +159,7 @@ export async function runSkill(a, ctx) {
176
159
  output: `拒绝:skill "${name}" 未获信任,已取消执行。`,
177
160
  };
178
161
  }
179
- let body = await renderSkillBody(skill, a.args, ctx?.signal);
162
+ let body = await renderSkillBody(skill, a.args, ctx?.signal, ctx?.allowedToolNames);
180
163
  if (body === null) {
181
164
  return { status: 'error', code: 'UNKNOWN_TOOL', retryable: false, output: `错误:未找到 skill "${name}" 的正文。` };
182
165
  }
@@ -188,13 +171,14 @@ export async function runSkill(a, ctx) {
188
171
  const res = await spawnAgent({
189
172
  prompt: SKILL_PROTOCOL_HEADER + '\n\n' + body,
190
173
  tools: tools ?? undefined,
191
- mode: resolveSpawnMode(skill, tools),
192
174
  maxSteps: skill.maxSteps,
193
175
  signal: ctx?.signal,
194
176
  context: a.context,
195
177
  systemPromptSuffix: `You are executing the "${skill.name}" skill. SKILL_DIR=${skill.dir}`,
196
178
  quiet: true, // fork skill 是 opaque workflow,不产可展开 batch
197
179
  quietLabel: `执行 ${skill.name}…`,
180
+ parentAllowedToolNames: ctx?.allowedToolNames,
181
+ delegation: ctx?.delegation,
198
182
  });
199
183
  return toOutcome(res);
200
184
  }
@@ -38,8 +38,10 @@ export function mapSkillToolName(token) {
38
38
  /** 把 allowed-tools / disallowed-tools 的 token 列表映射为 mocode 工具名集合;
39
39
  * 未知 token 收集到 unknown 返回,供调用方记 warning。返回 null 表示未声明(不约束)。 */
40
40
  export function mapSkillTools(tokens) {
41
- if (!tokens || tokens.length === 0)
41
+ if (tokens === undefined)
42
42
  return { tools: null, unknown: [] };
43
+ if (tokens.length === 0)
44
+ return { tools: [], unknown: [] };
43
45
  const tools = [];
44
46
  const unknown = [];
45
47
  for (const tk of tokens) {
@@ -52,5 +54,6 @@ export function mapSkillTools(tokens) {
52
54
  unknown.push(tk);
53
55
  }
54
56
  }
55
- return { tools: tools.length ? tools : null, unknown };
57
+ // 显式声明但全是未知 token 时保持零工具,绝不回退成 unrestricted。
58
+ return { tools, unknown };
56
59
  }
@@ -36,7 +36,19 @@ export const browserTool = {
36
36
  properties: {
37
37
  action: {
38
38
  type: 'string',
39
- enum: ['open', 'navigate', 'click', 'fill', 'press', 'wait_for', 'text', 'screenshot', 'console', 'list', 'close'],
39
+ enum: [
40
+ 'open',
41
+ 'navigate',
42
+ 'click',
43
+ 'fill',
44
+ 'press',
45
+ 'wait_for',
46
+ 'text',
47
+ 'screenshot',
48
+ 'console',
49
+ 'list',
50
+ 'close',
51
+ ],
40
52
  description: 'Operation to perform',
41
53
  },
42
54
  sessionId: { type: 'string', description: 'Session from action=open; optional when only one session exists' },
@@ -172,13 +184,15 @@ export const browserTool = {
172
184
  ...(savedPath ? { savedPath } : {}),
173
185
  note: 'Rendered page is attached to the next model request.',
174
186
  }, null, 2),
175
- modelAttachments: [{
187
+ modelAttachments: [
188
+ {
176
189
  type: 'image',
177
190
  name: `${meta.sessionId}.png`,
178
191
  mime: 'image/png',
179
192
  dataUrl: `data:image/png;base64,${buffer.toString('base64')}`,
180
193
  detail,
181
- }],
194
+ },
195
+ ],
182
196
  };
183
197
  }
184
198
  case 'close': {
@@ -0,0 +1,322 @@
1
+ /**
2
+ * computer 工具:桌面 GUI 操控闭环(设计 docs/computer-use-design.md)。
3
+ *
4
+ * 结构:校验(纯函数,可单测) → 动作前截屏(拿物理分辨率做 norm1000 换算) →
5
+ * InputInjector 注入动作 → 动作后重截屏 → 缩放 → modelAttachments 回灌。
6
+ * 模型只面对归一化 0-1000 坐标网格,从不感知物理分辨率/DPI/多屏。
7
+ *
8
+ * 安全:risk=dangerous(权限弹窗默认高亮「拒绝」),plan 模式常驻屏蔽,
9
+ * /cu off 时既不进 schema 也被运行时兜底拦截。
10
+ */
11
+ import { mkdir, readFile } from 'node:fs/promises';
12
+ import { dirname } from 'node:path';
13
+ import { jailResolve } from '../../sandbox/index.js';
14
+ import { captureDesktop } from '../../runtime/screen-capture.js';
15
+ import { decodePng, encodePng, downscale, fitToLongEdge, crop, normToPhysical, normRectToPhysical, physicalToNorm, isNormCoord, } from '../../runtime/screen-pipeline.js';
16
+ import { createInputInjector } from '../../runtime/input-injector.js';
17
+ /** 喂模型的截图长边上限(Anthropic 推荐区间)。 */
18
+ const MAX_EDGE = 1280;
19
+ const WAIT_MAX_MS = 10000;
20
+ const SCROLL_MAX = 10;
21
+ const ACTIONS = [
22
+ 'screenshot',
23
+ 'zoom',
24
+ 'mouse_move',
25
+ 'left_click',
26
+ 'right_click',
27
+ 'middle_click',
28
+ 'double_click',
29
+ 'triple_click',
30
+ 'left_mouse_down',
31
+ 'left_mouse_up',
32
+ 'left_click_drag',
33
+ 'type',
34
+ 'key',
35
+ 'scroll',
36
+ 'wait',
37
+ 'cursor_position',
38
+ ];
39
+ const ACTIONS_NEEDING_COORDINATE = new Set([
40
+ 'mouse_move',
41
+ 'left_click',
42
+ 'right_click',
43
+ 'middle_click',
44
+ 'double_click',
45
+ 'triple_click',
46
+ 'left_mouse_down',
47
+ 'left_mouse_up',
48
+ 'left_click_drag',
49
+ 'scroll',
50
+ ]);
51
+ function isPair(v) {
52
+ return Array.isArray(v) && v.length === 2 && isNormCoord(v[0]) && isNormCoord(v[1]);
53
+ }
54
+ /**
55
+ * 校验 computer 调用参数。返回 null 表示通过,否则返回面向模型的错误消息。
56
+ * 纯函数,独立可单测。
57
+ */
58
+ export function validateComputerArgs(args) {
59
+ const action = args.action;
60
+ if (typeof action !== 'string' || !ACTIONS.includes(action)) {
61
+ return `unknown action "${String(args.action)}"; expected one of: ${ACTIONS.join(', ')}`;
62
+ }
63
+ if (ACTIONS_NEEDING_COORDINATE.has(action) && !isPair(args.coordinate)) {
64
+ return `action "${action}" requires coordinate: [x, y] integers in normalized 0-1000 space`;
65
+ }
66
+ if (action === 'left_click_drag' && !isPair(args.coordinate_to)) {
67
+ return 'action "left_click_drag" requires coordinate_to: [x, y] integers in normalized 0-1000 space';
68
+ }
69
+ if (action === 'zoom') {
70
+ const r = args.region;
71
+ if (!Array.isArray(r) || r.length !== 4 || !r.every(isNormCoord)) {
72
+ return 'action "zoom" requires region: [x, y, w, h] integers in normalized 0-1000 space';
73
+ }
74
+ if (r[2] <= 0 || r[3] <= 0) {
75
+ return 'zoom region w/h must be > 0';
76
+ }
77
+ }
78
+ if (action === 'type' || action === 'key') {
79
+ if (typeof args.text !== 'string' || args.text.length === 0) {
80
+ return `action "${action}" requires a non-empty text string`;
81
+ }
82
+ }
83
+ if (action === 'scroll') {
84
+ const dir = args.scroll_direction;
85
+ if (dir !== 'up' && dir !== 'down' && dir !== 'left' && dir !== 'right') {
86
+ return 'action "scroll" requires scroll_direction: up|down|left|right';
87
+ }
88
+ const amount = args.scroll_amount ?? 3;
89
+ if (typeof amount !== 'number' || !Number.isInteger(amount) || amount < 1 || amount > SCROLL_MAX) {
90
+ return `scroll_amount must be an integer in 1-${SCROLL_MAX}`;
91
+ }
92
+ }
93
+ if (action === 'wait') {
94
+ const ms = args.duration_ms;
95
+ if (typeof ms !== 'number' || !Number.isInteger(ms) || ms < 1 || ms > WAIT_MAX_MS) {
96
+ return `action "wait" requires duration_ms: integer in 1-${WAIT_MAX_MS}`;
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+ /** 抓一次主屏并返回物理分辨率。 */
102
+ async function capturePrimary(signal) {
103
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
104
+ const outputPath = jailResolve(`.mocode/screenshots/computer-${ts}-${Math.random().toString(36).slice(2, 8)}.png`);
105
+ await mkdir(dirname(outputPath), { recursive: true });
106
+ const cap = await captureDesktop(outputPath, 'primary', signal);
107
+ if (cap.status !== 'passed') {
108
+ throw new Error(`screenshot capture failed: ${cap.detail}`);
109
+ }
110
+ const png = decodePng(await readFile(outputPath));
111
+ return { path: outputPath, physW: png.width, physH: png.height };
112
+ }
113
+ /** 读 PNG 文件 → 缩放 → 编码 → dataUrl。 */
114
+ async function toAttachment(path) {
115
+ const png = decodePng(await readFile(path));
116
+ const { img } = downscale(png, MAX_EDGE);
117
+ const buf = encodePng(img);
118
+ return {
119
+ dataUrl: `data:image/png;base64,${buf.toString('base64')}`,
120
+ bytes: buf.length,
121
+ dispW: img.width,
122
+ dispH: img.height,
123
+ };
124
+ }
125
+ async function executeAction(injector, action, args, screen) {
126
+ const { physW, physH } = screen;
127
+ const toPhys = (pair) => normToPhysical(pair[0], pair[1], physW, physH);
128
+ switch (action) {
129
+ case 'mouse_move': {
130
+ const [x, y] = toPhys(args.coordinate);
131
+ await injector.moveTo(x, y);
132
+ return `moved cursor to (${x}, ${y})`;
133
+ }
134
+ case 'left_click':
135
+ case 'right_click':
136
+ case 'middle_click':
137
+ case 'double_click':
138
+ case 'triple_click': {
139
+ const [x, y] = toPhys(args.coordinate);
140
+ await injector.moveTo(x, y);
141
+ const button = action.startsWith('right') ? 'right' : action.startsWith('middle') ? 'middle' : 'left';
142
+ const count = action === 'double_click' ? 2 : action === 'triple_click' ? 3 : 1;
143
+ await injector.click(button, count);
144
+ return `${action} at (${x}, ${y})`;
145
+ }
146
+ case 'left_mouse_down':
147
+ case 'left_mouse_up': {
148
+ const [x, y] = toPhys(args.coordinate);
149
+ await injector.moveTo(x, y);
150
+ if (action === 'left_mouse_down')
151
+ await injector.mouseDown('left');
152
+ else
153
+ await injector.mouseUp('left');
154
+ return `${action} at (${x}, ${y})`;
155
+ }
156
+ case 'left_click_drag': {
157
+ const [x0, y0] = toPhys(args.coordinate);
158
+ const [x1, y1] = toPhys(args.coordinate_to);
159
+ await injector.moveTo(x0, y0);
160
+ await injector.mouseDown('left');
161
+ await injector.dragTo(x1, y1);
162
+ await injector.mouseUp('left');
163
+ return `dragged from (${x0}, ${y0}) to (${x1}, ${y1})`;
164
+ }
165
+ case 'type': {
166
+ await injector.typeText(args.text);
167
+ return `typed ${args.text.length} characters`;
168
+ }
169
+ case 'key': {
170
+ await injector.pressKey(args.text);
171
+ return `pressed key combo "${args.text}"`;
172
+ }
173
+ case 'scroll': {
174
+ const [x, y] = toPhys(args.coordinate);
175
+ await injector.moveTo(x, y);
176
+ const amount = args.scroll_amount ?? 3;
177
+ await injector.scroll(args.scroll_direction, amount);
178
+ return `scrolled ${args.scroll_direction} by ${amount} at (${x}, ${y})`;
179
+ }
180
+ case 'wait': {
181
+ await new Promise((r) => setTimeout(r, args.duration_ms));
182
+ return `waited ${args.duration_ms}ms`;
183
+ }
184
+ case 'cursor_position': {
185
+ const pos = await injector.cursorPosition();
186
+ const [nx, ny] = physicalToNorm(pos.x, pos.y, physW, physH);
187
+ return `cursor at physical (${pos.x}, ${pos.y}) = normalized (${nx}, ${ny})`;
188
+ }
189
+ default:
190
+ throw new Error(`no executor for action "${action}"`);
191
+ }
192
+ }
193
+ export const computerTool = {
194
+ name: 'computer',
195
+ description: 'Control the desktop GUI: move/click the mouse, type text, press keys, scroll, and zoom into screen regions. ' +
196
+ 'Every action returns a fresh screenshot as a visual attachment — inspect it before the next action and self-correct. ' +
197
+ 'Coordinates use a normalized 0-1000 grid (x right, y down) over the primary screen; you never need the physical resolution. ' +
198
+ 'Use zoom on a tight region before clicking small or dense UI elements. Destructive or sensitive targets ' +
199
+ '(form submit, payment, credentials, delete/send) require explicit user intent — ask first.',
200
+ risk: 'dangerous',
201
+ parameters: {
202
+ type: 'object',
203
+ properties: {
204
+ action: { type: 'string', enum: [...ACTIONS] },
205
+ coordinate: {
206
+ type: 'array',
207
+ items: { type: 'integer' },
208
+ minItems: 2,
209
+ maxItems: 2,
210
+ description: '[x, y] in normalized 0-1000 space. Required for move/click/scroll/drag start.',
211
+ },
212
+ coordinate_to: {
213
+ type: 'array',
214
+ items: { type: 'integer' },
215
+ minItems: 2,
216
+ maxItems: 2,
217
+ description: 'Drag destination in normalized 0-1000 space (left_click_drag only).',
218
+ },
219
+ text: {
220
+ type: 'string',
221
+ description: 'Text to type (type) or key combo like "ctrl+s" / "Return" / "ctrl+shift+t" (key).',
222
+ },
223
+ scroll_direction: { type: 'string', enum: ['up', 'down', 'left', 'right'] },
224
+ scroll_amount: { type: 'integer', description: `Scroll clicks 1-${SCROLL_MAX} (default 3).` },
225
+ region: {
226
+ type: 'array',
227
+ items: { type: 'integer' },
228
+ minItems: 4,
229
+ maxItems: 4,
230
+ description: 'zoom only: [x, y, w, h] in normalized 0-1000 space.',
231
+ },
232
+ duration_ms: { type: 'integer', description: `wait only: milliseconds (1-${WAIT_MAX_MS}).` },
233
+ target: {
234
+ type: 'string',
235
+ enum: ['primary', 'all'],
236
+ description: 'screenshot only: display scope (default primary). All other actions act on the primary screen.',
237
+ },
238
+ },
239
+ required: ['action'],
240
+ additionalProperties: false,
241
+ },
242
+ async execute(args, ctx) {
243
+ const invalid = validateComputerArgs(args);
244
+ if (invalid) {
245
+ return { status: 'error', code: 'INVALID_ARGUMENTS', retryable: false, output: `Invalid computer action: ${invalid}` };
246
+ }
247
+ const action = args.action;
248
+ let injector;
249
+ try {
250
+ injector = createInputInjector();
251
+ }
252
+ catch (error) {
253
+ const message = error instanceof Error ? error.message : String(error);
254
+ return { status: 'error', code: 'EXECUTION_ERROR', retryable: false, output: message };
255
+ }
256
+ injector.bindSignal(ctx?.signal);
257
+ try {
258
+ // 动作前截屏:拿物理分辨率(norm1000 → 物理像素换算的基础)。
259
+ const before = await capturePrimary(ctx?.signal);
260
+ const screen = { physW: before.physW, physH: before.physH };
261
+ // zoom:不注入输入,裁 region 放大回灌。
262
+ if (action === 'zoom') {
263
+ const rect = normRectToPhysical(args.region, screen.physW, screen.physH);
264
+ const png = decodePng(await readFile(before.path));
265
+ const zoomed = fitToLongEdge(crop(png, rect.x, rect.y, rect.w, rect.h), MAX_EDGE);
266
+ const buf = encodePng(zoomed);
267
+ return {
268
+ status: 'success',
269
+ code: 'OK',
270
+ retryable: false,
271
+ output: `Zoomed into region ${JSON.stringify(args.region)} → physical (${rect.x}, ${rect.y}, ${rect.w}×${rect.h}), ` +
272
+ `magnified to ${zoomed.width}×${zoomed.height}. The attached image shows ONLY this region — ` +
273
+ `coordinates you output still map to the FULL screen, so do not reuse this region's local coordinates for clicks.`,
274
+ modelAttachments: [
275
+ { type: 'image', name: 'computer-zoom.png', mime: 'image/png', dataUrl: `data:image/png;base64,${buf.toString('base64')}` },
276
+ ],
277
+ };
278
+ }
279
+ // screenshot:只截屏回灌(target 可指定 all)。
280
+ if (action === 'screenshot') {
281
+ let shotPath = before.path;
282
+ if (args.target === 'all') {
283
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
284
+ const allPath = jailResolve(`.mocode/screenshots/computer-${ts}-all.png`);
285
+ await mkdir(dirname(allPath), { recursive: true });
286
+ const cap = await captureDesktop(allPath, 'all', ctx?.signal);
287
+ if (cap.status !== 'passed')
288
+ throw new Error(`screenshot capture failed: ${cap.detail}`);
289
+ shotPath = allPath;
290
+ }
291
+ const att = await toAttachment(shotPath);
292
+ return {
293
+ status: 'success',
294
+ code: 'OK',
295
+ retryable: false,
296
+ output: `Screenshot captured (physical ${screen.physW}×${screen.physH}, shown at ${att.dispW}×${att.dispH}). ` +
297
+ 'Coordinates remain normalized 0-1000 over the primary screen. Visual content is attached to the next model request.',
298
+ modelAttachments: [{ type: 'image', name: 'computer-screenshot.png', mime: 'image/png', dataUrl: att.dataUrl }],
299
+ };
300
+ }
301
+ // 输入动作:注入 → 重截屏回灌(wait 也回灌,等待后界面往往已变化)。
302
+ const summary = await executeAction(injector, action, args, screen);
303
+ const after = await capturePrimary(ctx?.signal);
304
+ const att = await toAttachment(after.path);
305
+ return {
306
+ status: 'success',
307
+ code: 'OK',
308
+ retryable: false,
309
+ output: `${summary}. Screen re-captured (physical ${screen.physW}×${screen.physH}, shown at ${att.dispW}×${att.dispH}): ` +
310
+ 'inspect the attached screenshot to verify the result before the next action.',
311
+ modelAttachments: [{ type: 'image', name: 'computer-result.png', mime: 'image/png', dataUrl: att.dataUrl }],
312
+ };
313
+ }
314
+ catch (error) {
315
+ const message = error instanceof Error ? error.message : String(error);
316
+ if (ctx?.signal?.aborted || /aborted/i.test(message)) {
317
+ return { status: 'aborted', code: 'ABORTED', retryable: false, output: message };
318
+ }
319
+ return { status: 'error', code: 'EXECUTION_ERROR', retryable: false, output: `computer action failed: ${message}` };
320
+ }
321
+ },
322
+ };
@@ -11,9 +11,7 @@ function optionalString(args, name) {
11
11
  }
12
12
  function failure(error) {
13
13
  if (error instanceof DevServerManagerError) {
14
- const status = error.code === 'SANDBOX_DENIED'
15
- ? 'denied'
16
- : error.code === 'ABORTED' ? 'aborted' : 'error';
14
+ const status = error.code === 'SANDBOX_DENIED' ? 'denied' : error.code === 'ABORTED' ? 'aborted' : 'error';
17
15
  return result(status, error.code, { error: error.message });
18
16
  }
19
17
  return result('error', 'EXECUTION_ERROR', {
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { jailResolve } from '../../sandbox/index.js';
3
- import { commitChangeSet, createChangeSet, normalizeContentHash, summarizeChangeSet, } from '../../changeset/index.js';
3
+ import { commitChangeSet, createChangeSet, normalizeContentHash, summarizeChangeSet } from '../../changeset/index.js';
4
4
  function conflict(path, details) {
5
5
  return {
6
6
  status: 'error',
@@ -35,11 +35,23 @@ expected_hash is required (sha256 from read_file artifact header) and must match
35
35
  type: 'object',
36
36
  properties: {
37
37
  path: { type: 'string', description: 'Absolute file path.' },
38
- old_string: { type: 'string', description: 'String replacement mode: the exact text to replace (must occur once). Mutually exclusive with line_start/line_end.' },
38
+ old_string: {
39
+ type: 'string',
40
+ description: 'String replacement mode: the exact text to replace (must occur once). Mutually exclusive with line_start/line_end.',
41
+ },
39
42
  new_string: { type: 'string', description: 'The replacement text.' },
40
- line_start: { type: 'integer', description: 'Line-range mode: start line (1-based, inclusive). Mutually exclusive with old_string.' },
41
- line_end: { type: 'integer', description: 'Line-range mode: end line (1-based, inclusive). Mutually exclusive with old_string.' },
42
- expected_hash: { type: 'string', description: 'sha256 hash from the latest read_file artifact header (sha256:<64 hex>).' },
43
+ line_start: {
44
+ type: 'integer',
45
+ description: 'Line-range mode: start line (1-based, inclusive). Mutually exclusive with old_string.',
46
+ },
47
+ line_end: {
48
+ type: 'integer',
49
+ description: 'Line-range mode: end line (1-based, inclusive). Mutually exclusive with old_string.',
50
+ },
51
+ expected_hash: {
52
+ type: 'string',
53
+ description: 'sha256 hash from the latest read_file artifact header (sha256:<64 hex>).',
54
+ },
43
55
  },
44
56
  // 注意:expected_hash 故意不放进 schema 的 required。
45
57
  // 原因:缺失时若被 AJV 在 schema 层拦下,只会得到泛化的
@@ -104,12 +116,14 @@ expected_hash is required (sha256 from read_file artifact header) and must match
104
116
  updated = lines.join('\n');
105
117
  }
106
118
  const replacement = data.includes('\r\n') ? updated.replace(/\n/g, '\r\n') : updated;
107
- const result = await commitChangeSet(createChangeSet([{
119
+ const result = await commitChangeSet(createChangeSet([
120
+ {
108
121
  path: file,
109
122
  operation: 'update',
110
123
  expectedHash,
111
124
  replacement,
112
- }]), ctx?.signal);
125
+ },
126
+ ]), ctx?.signal);
113
127
  if (result.status === 'conflict') {
114
128
  const item = result.conflicts[0];
115
129
  return conflict(file, `expected=${item?.expectedHash ?? 'missing'}, actual=${item?.actualHash ?? 'missing'}。请重新读取后再编辑。`);
@@ -49,7 +49,7 @@ export const grepTool = {
49
49
  const hits = [];
50
50
  let scanned = 0;
51
51
  let truncated = false;
52
- outer: for (const f of files) {
52
+ for (const f of files) {
53
53
  let content;
54
54
  try {
55
55
  // jailResolve:realpath 化,防「牢内文件软链→牢外」的内容泄露;越界/不可读均 catch 跳过