mocode-ai 1.6.0 → 1.6.2

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 (41) hide show
  1. package/README.md +24 -9
  2. package/README.zh-CN.md +355 -341
  3. package/dist/agent/model-turn.js +7 -8
  4. package/dist/agent/run-coordinator.js +4 -0
  5. package/dist/agent/stages/tool-dispatcher.js +1 -0
  6. package/dist/agent/tool-turn.js +1 -1
  7. package/dist/attachments/image.js +119 -2
  8. package/dist/config/index.js +84 -14
  9. package/dist/config/profiles.js +42 -10
  10. package/dist/context/encoders/search.js +6 -2
  11. package/dist/context/vision-window.js +2 -2
  12. package/dist/i18n/index.js +30 -14
  13. package/dist/repl/commands/image.js +7 -2
  14. package/dist/repl/commands/router.js +13 -1
  15. package/dist/repl/commands/system.js +29 -9
  16. package/dist/repl/commands/tool-group.js +1 -1
  17. package/dist/repl/commands.js +2 -0
  18. package/dist/runtime/dev-server-manager.js +4 -2
  19. package/dist/runtime/shell.js +153 -0
  20. package/dist/skills/builtin-skills.js +1 -1
  21. package/dist/tools/builtins/ask-human.js +2 -3
  22. package/dist/tools/builtins/dev-server.js +23 -6
  23. package/dist/tools/builtins/edit-file.js +5 -13
  24. package/dist/tools/builtins/glob.js +2 -2
  25. package/dist/tools/builtins/grep.js +93 -26
  26. package/dist/tools/builtins/index.js +6 -6
  27. package/dist/tools/builtins/note-append.js +4 -8
  28. package/dist/tools/builtins/plan-update.js +1 -6
  29. package/dist/tools/builtins/read-file.js +135 -18
  30. package/dist/tools/builtins/run-command.js +60 -11
  31. package/dist/tools/builtins/screenshot.js +17 -41
  32. package/dist/tools/builtins/use-skill.js +2 -2
  33. package/dist/tools/builtins/web-fetch.js +148 -35
  34. package/dist/tools/builtins/web-search.js +1 -2
  35. package/dist/tools/builtins/write-file.js +102 -7
  36. package/dist/tools/constants.js +7 -0
  37. package/dist/tools/policy.js +26 -7
  38. package/dist/tools/router.js +20 -4
  39. package/dist/tools/tool-runtime.js +63 -1
  40. package/dist/ui/render.js +20 -2
  41. package/package.json +1 -1
@@ -1,12 +1,9 @@
1
- import { mkdir, readFile } from 'node:fs/promises';
2
- import { basename, dirname, extname } from 'node:path';
3
- import { loadImageAttachment, MAX_INLINE_BYTES_DEFAULT } from '../../attachments/image.js';
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { dirname, extname } from 'node:path';
3
+ import { loadImageAttachmentWithDownscale, MAX_INLINE_BYTES_DEFAULT } from '../../attachments/image.js';
4
4
  import { jailResolve } from '../../sandbox/index.js';
5
5
  // 平台捕获逻辑已抽到 runtime/screen-capture.ts(computer 工具闭环共用),本文件只做薄封装。
6
6
  import { captureDesktop } from '../../runtime/screen-capture.js';
7
- import { decodePng, downscale, encodePng } from '../../runtime/screen-pipeline.js';
8
- /** 原图超过内联上限时降级缩放的长边(对齐主流视觉模型的原生分辨率)。 */
9
- const FALLBACK_MAX_EDGE = 1568;
10
7
  export const screenshotTool = {
11
8
  name: 'screenshot',
12
9
  description: 'Capture the desktop and inspect it as visual model input. Use this to diagnose visible UI state, dialogs, rendering problems, or applications that cannot be understood from source files alone. The user must approve each capture because screenshots may contain sensitive information.',
@@ -68,50 +65,29 @@ export const screenshotTool = {
68
65
  output: `Unable to capture screenshot: ${capture.detail}`,
69
66
  };
70
67
  }
71
- const loaded = await loadImageAttachment(outputPath, {
68
+ // 共享 helper:超内联上限时自动把 PNG 降采样到 DOWNSCALE_MAX_EDGE 再回灌,不让工具失败。
69
+ // 高分屏(尤其 DPI aware 后抓到物理分辨率)的 PNG 常超 4 MiB;原图仍留在磁盘上。
70
+ const loaded = await loadImageAttachmentWithDownscale(outputPath, {
72
71
  maxBytes: MAX_INLINE_BYTES_DEFAULT,
73
72
  });
74
73
  const detail = args.detail === 'low' || args.detail === 'auto' ? args.detail : 'high';
75
- // 高分屏(尤其 DPI aware 后抓到物理分辨率)的 PNG 可能超过内联上限:
76
- // 原图仍留在磁盘上,回灌改用缩放版本,不让工具因此失败。
77
74
  if (!loaded.ok) {
78
- try {
79
- const png = decodePng(await readFile(outputPath));
80
- const { img } = downscale(png, FALLBACK_MAX_EDGE);
81
- const buf = encodePng(img);
82
- return {
83
- status: 'success',
84
- code: 'OK',
85
- retryable: false,
86
- output: `Captured ${target} display to ${requestedPath} (${png.width}×${png.height}); ` +
87
- `it exceeded the inline limit, so the attached copy was resized to ${img.width}×${img.height}.`,
88
- modelAttachments: [
89
- {
90
- type: 'image',
91
- name: basename(outputPath),
92
- mime: 'image/png',
93
- dataUrl: `data:image/png;base64,${buf.toString('base64')}`,
94
- detail,
95
- },
96
- ],
97
- };
98
- }
99
- catch (error) {
100
- const message = error instanceof Error ? error.message : String(error);
101
- return {
102
- status: 'error',
103
- code: 'EXECUTION_ERROR',
104
- retryable: false,
105
- output: `Screenshot saved to ${requestedPath}, but it could not be attached: ${loaded.reason}${message ? ` (resize fallback failed: ${message})` : ''}`,
106
- };
107
- }
75
+ return {
76
+ status: 'error',
77
+ code: 'EXECUTION_ERROR',
78
+ retryable: false,
79
+ output: `Screenshot saved to ${requestedPath}, but it could not be attached: ${loaded.reason}`,
80
+ };
108
81
  }
109
- const { att } = loaded;
82
+ const { att, downscaledFrom } = loaded;
83
+ const resizedNote = downscaledFrom
84
+ ? ` (${downscaledFrom.width}×${downscaledFrom.height}; exceeded the inline limit, so the attached copy was resized)`
85
+ : '';
110
86
  return {
111
87
  status: 'success',
112
88
  code: 'OK',
113
89
  retryable: false,
114
- output: `Captured ${target} display to ${requestedPath} (${att.bytes} bytes). Visual content is attached to the next model request.`,
90
+ output: `Captured ${target} display to ${requestedPath} (${att.bytes} bytes)${resizedNote}. Visual content is attached to the next model request.`,
115
91
  modelAttachments: [
116
92
  {
117
93
  type: 'image',
@@ -13,9 +13,9 @@ import { activateSkill } from '../../skills/activation.js';
13
13
  const MAX_SKILL_FILE = 200_000;
14
14
  export const useSkillTool = {
15
15
  name: 'use_skill',
16
- description: 'Load the full SKILL.md instructions for a given skill. See the skill list in the system prompt for when to use each. ' +
16
+ description: 'Load the full SKILL.md instructions for a given skill (see the skill list in the system prompt for when to use each). ' +
17
17
  'Supports args (renders $ARGUMENTS / $1.. / ${SKILL_DIR}) and file (reads a bundled reference file). ' +
18
- 'For skills marked [fork], this returns a guide to call run_skill instead of loading the body inline.',
18
+ 'Skills marked [fork] return a guide to call run_skill instead of loading the body inline.',
19
19
  parameters: {
20
20
  type: 'object',
21
21
  properties: {
@@ -1,11 +1,83 @@
1
1
  import { MAX_OUTPUT } from '../constants.js';
2
2
  const FETCH_TIMEOUT_MS = 30000;
3
3
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
4
+ /**
5
+ * 浏览器拟真头集合。
6
+ *
7
+ * 只带 UA 挡不住主流反爬:Cloudflare/DataDome 类盾检查 Sec-Fetch-* / Accept-Language 的
8
+ * 组合一致性(真浏览器必带,裸 fetch 必缺)。缺头是 roman.pt / nader.substack.com 403
9
+ * 而 flaviocopes(无盾)成功的直接原因。补全成本为零,收益是把一批「看起来像脚本」的
10
+ * 请求拉回「看起来像浏览器」。
11
+ */
12
+ function browserHeaders() {
13
+ return {
14
+ 'User-Agent': UA,
15
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
16
+ 'Accept-Language': 'en-US,en;q=0.9,zh-CN;q=0.8',
17
+ // sec-ch-ua 是 Chrome 的 client hints:与 UA 里的 Chrome/120 对齐,不一致反而更可疑。
18
+ 'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
19
+ 'sec-ch-ua-mobile': '?0',
20
+ 'sec-ch-ua-platform': '"Windows"',
21
+ 'Sec-Fetch-Dest': 'document',
22
+ 'Sec-Fetch-Mode': 'navigate',
23
+ 'Sec-Fetch-Site': 'none',
24
+ 'Sec-Fetch-User': '?1',
25
+ 'Upgrade-Insecure-Requests': '1',
26
+ // 同源直访不带 Referer;跨域跳转场景由调用方语义决定,这里保持「直接导航」画像。
27
+ 'Cache-Control': 'no-cache',
28
+ };
29
+ }
30
+ /**
31
+ * 纯文本代理回退(opt-in,默认关)。
32
+ *
33
+ * MOCODE_WEB_FETCH_PROXY 设为前缀型代理(如 `https://r.jina.ai/`),直连失败且疑似被盾拦截时
34
+ * 改走 `${PROXY}${原 URL}`。为什么默认关:把目标 URL 交给第三方是隐私/信任决策,必须由用户
35
+ * 显式打开,agent 不能替用户把浏览足迹外包出去。
36
+ */
37
+ function proxyPrefix() {
38
+ const raw = (process.env.MOCODE_WEB_FETCH_PROXY ?? '').trim();
39
+ if (!raw)
40
+ return null;
41
+ try {
42
+ const parsed = new URL(raw);
43
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
44
+ return null;
45
+ return parsed.href.endsWith('/') ? parsed.href : `${parsed.href}/`;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /** 疑似反爬拦截:这些状态码重试直连基本无望,值得走代理(若配置)。 */
52
+ function isBlockedStatus(status) {
53
+ return status === 403 || status === 405 || status === 406 || status === 429 || status === 451 || status === 503;
54
+ }
55
+ async function attemptFetch(target, via, signal) {
56
+ try {
57
+ const resp = await fetch(target, { method: 'GET', headers: browserHeaders(), signal });
58
+ const contentType = resp.headers.get('content-type') ?? '';
59
+ const text = await resp.text();
60
+ return { ok: resp.ok, status: resp.status, statusText: resp.statusText, contentType, text, via };
61
+ }
62
+ catch (e) {
63
+ const msg = e instanceof Error ? e.message : String(e);
64
+ return {
65
+ ok: false,
66
+ status: 0,
67
+ statusText: '',
68
+ contentType: '',
69
+ text: '',
70
+ via,
71
+ error: msg,
72
+ timedOut: signal.aborted,
73
+ networkError: !signal.aborted,
74
+ };
75
+ }
76
+ }
4
77
  // ---------- web_fetch ----------
5
78
  export const webFetchTool = {
6
79
  name: 'web_fetch',
7
- description: 'Fetch a URL and clean HTML to body text. Use to read a link from search results or a URL given by the user.',
8
- parameters: {
80
+ description: 'Fetch a URL and clean HTML to body text. Use to read a link from search results or a URL given by the user.', parameters: {
9
81
  type: 'object',
10
82
  properties: {
11
83
  url: { type: 'string', description: 'Full URL to fetch; must be http/https' },
@@ -38,35 +110,9 @@ export const webFetchTool = {
38
110
  externalSignal.addEventListener('abort', onExternalAbort, { once: true });
39
111
  }
40
112
  try {
41
- const resp = await fetch(url.href, {
42
- method: 'GET',
43
- headers: {
44
- 'User-Agent': UA,
45
- Accept: 'text/html,application/xhtml+xml,text/plain,application/json,*/*',
46
- },
47
- signal: ctrl.signal,
48
- });
49
- const contentType = resp.headers.get('content-type') ?? '';
50
- const text = await resp.text();
51
- if (!resp.ok) {
52
- return {
53
- status: 'error',
54
- code: 'HTTP_ERROR',
55
- retryable: resp.status === 408 || resp.status === 429 || resp.status >= 500,
56
- output: `错误:抓取失败 HTTP ${resp.status} ${resp.statusText}\n${text.slice(0, 500)}`,
57
- };
58
- }
59
- const isHtml = /html/i.test(contentType) || /^\s*<!doctype html/i.test(text) || /<html[\s>]/i.test(text.slice(0, 1000));
60
- const body = isHtml ? htmlToText(text) : text;
61
- const ct = contentType.split(';')[0].trim();
62
- const prefix = `${url.href} (HTTP ${resp.status}${ct ? ', ' + ct : ''})\n\n`;
63
- let out = prefix + body;
64
- if (out.length > MAX_OUTPUT) {
65
- out = out.slice(0, MAX_OUTPUT) + `\n...(已截断,原文 ${body.length} 字符)`;
66
- }
67
- return out;
68
- }
69
- catch (e) {
113
+ const attempt = await attemptFetch(url.href, 'direct', ctrl.signal);
114
+ // abort 优先判定:用户 Ctrl+C 是终态,绝不能被当成「直连失败」去走代理或重试。
115
+ // (放在直连结果之后、任何分支之前 —— 代理路径与 HTTP 错误路径都必须先过这道闸。)
70
116
  if (ctrl.signal.aborted) {
71
117
  if (externalSignal?.aborted) {
72
118
  return { status: 'aborted', code: 'ABORTED', retryable: false, output: `错误:已中断: ${url.href}` };
@@ -78,12 +124,48 @@ export const webFetchTool = {
78
124
  output: `错误:抓取超时(${FETCH_TIMEOUT_MS}ms): ${url.href}`,
79
125
  };
80
126
  }
81
- const msg = e instanceof Error ? e.message : String(e);
127
+ if (attempt.ok)
128
+ return renderBody(attempt, url);
129
+ // 直连被盾拦(403/429/503…)且用户配置了代理 → 走代理再试一次;代理失败回落原始失败,
130
+ // 报「直连 + 代理」两段原因,让模型知道两条路都试过。
131
+ if (isBlockedStatus(attempt.status)) {
132
+ const prefix = proxyPrefix();
133
+ if (prefix) {
134
+ const proxied = await attemptFetch(`${prefix}${url.href}`, 'proxy', ctrl.signal);
135
+ // 代理期间的 abort 同样按终态处理,不降级成「代理失败」。
136
+ if (ctrl.signal.aborted && !proxied.ok) {
137
+ return externalSignal?.aborted
138
+ ? { status: 'aborted', code: 'ABORTED', retryable: false, output: `错误:已中断: ${url.href}` }
139
+ : {
140
+ status: 'error',
141
+ code: 'TIMEOUT',
142
+ retryable: true,
143
+ output: `错误:抓取超时(${FETCH_TIMEOUT_MS}ms): ${url.href}`,
144
+ };
145
+ }
146
+ if (proxied.ok)
147
+ return renderBody(proxied, url);
148
+ return blockedFailure(attempt, proxied, url);
149
+ }
150
+ }
151
+ if (attempt.networkError) {
152
+ return {
153
+ status: 'error',
154
+ code: 'NETWORK_ERROR',
155
+ retryable: true,
156
+ output: `错误:抓取失败: ${attempt.error ?? 'network error'}`,
157
+ };
158
+ }
159
+ // HTTP 错误:429/5xx/408 标 retryable,registry 对幂等工具自动退避重试。
160
+ const retryable = attempt.status === 408 || attempt.status === 429 || attempt.status >= 500;
161
+ const hint = isBlockedStatus(attempt.status)
162
+ ? '\n(疑似反爬拦截;可设 MOCODE_WEB_FETCH_PROXY=<前缀代理> 开启纯文本代理回退)'
163
+ : '';
82
164
  return {
83
165
  status: 'error',
84
- code: 'NETWORK_ERROR',
85
- retryable: true,
86
- output: `错误:抓取失败: ${msg}`,
166
+ code: 'HTTP_ERROR',
167
+ retryable,
168
+ output: `错误:抓取失败 HTTP ${attempt.status} ${attempt.statusText}\n${attempt.text.slice(0, 500)}${hint}`,
87
169
  };
88
170
  }
89
171
  finally {
@@ -93,6 +175,37 @@ export const webFetchTool = {
93
175
  }
94
176
  },
95
177
  };
178
+ /** 成功响应 → 清洗正文 + 截断(直连与代理共用)。 */
179
+ function renderBody(attempt, url) {
180
+ const isHtml = /html/i.test(attempt.contentType) ||
181
+ /^\s*<!doctype html/i.test(attempt.text) ||
182
+ /<html[\s>]/i.test(attempt.text.slice(0, 1000));
183
+ const body = isHtml ? htmlToText(attempt.text) : attempt.text;
184
+ const ct = attempt.contentType.split(';')[0].trim();
185
+ const via = attempt.via === 'proxy' ? ', via proxy' : '';
186
+ const prefix = `${url.href} (HTTP ${attempt.status}${ct ? ', ' + ct : ''}${via})\n\n`;
187
+ let out = prefix + body;
188
+ if (out.length > MAX_OUTPUT) {
189
+ out = out.slice(0, MAX_OUTPUT) + `\n...(已截断,原文 ${body.length} 字符)`;
190
+ }
191
+ return out;
192
+ }
193
+ /** 直连被拦 + 代理也失败:两段原因都报,不掩盖代理尝试。 */
194
+ function blockedFailure(direct, proxied, url) {
195
+ const proxyDetail = proxied.networkError
196
+ ? `代理网络错误: ${proxied.error ?? 'unknown'}`
197
+ : proxied.timedOut
198
+ ? '代理超时'
199
+ : `代理返回 HTTP ${proxied.status}`;
200
+ return {
201
+ status: 'error',
202
+ code: 'HTTP_ERROR',
203
+ retryable: direct.status === 429 || direct.status >= 500,
204
+ output: `错误:抓取失败(直连与代理均被拒): ${url.href}\n` +
205
+ `直连: HTTP ${direct.status} ${direct.statusText}\n${proxyDetail}\n` +
206
+ `直连响应片段: ${direct.text.slice(0, 300)}`,
207
+ };
208
+ }
96
209
  /**
97
210
  * 轻量 HTML→纯文本:优先取 <main>/<article> 正文区,再去 nav/header/footer/aside/form
98
211
  * 等非正文块与脚本样式,块级/列表标签转换行,去剩余标签,解码实体,压缩空白。
@@ -6,8 +6,7 @@ const MAX_CONTENT_CHARS = 800;
6
6
  // ---------- web_search ----------
7
7
  export const webSearchTool = {
8
8
  name: 'web_search',
9
- description: 'Search the web (AnySearch). Returns title/url/snippet/body per result. Optional tag for sub-domain capability.',
10
- parameters: {
9
+ description: 'Search the web (AnySearch). Returns title/url/snippet/body per result. Optional tag for sub-domain capability.', parameters: {
11
10
  type: 'object',
12
11
  properties: {
13
12
  query: { type: 'string', description: 'Search query' },
@@ -1,4 +1,12 @@
1
- import { commitChangeSet, createChangeSet, normalizeContentHash, summarizeChangeSet, } from '../../changeset/index.js';
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { commitChangeSet, contentHash, createChangeSet, normalizeContentHash, summarizeChangeSet, } from '../../changeset/index.js';
3
+ import { jailResolve } from '../../sandbox/index.js';
4
+ import { isProbablyBinary } from '../../attachments/image.js';
5
+ /** append 需要把整个文件读进内存再全量重写(ChangeSet 的 backup + 原子 rename 语义决定),
6
+ * 故与 read_file 同款 32 MiB 闸门:超过就指路 shell 重定向,不把进程内存顶死。 */
7
+ const MAX_APPEND_TARGET_BYTES = 32 * 1024 * 1024;
8
+ /** 二进制嗅探只需文件头,与 read_file 同口径(16KB)。 */
9
+ const SNIFF_BYTES = 16 * 1024;
2
10
  function conflict(path, details) {
3
11
  return {
4
12
  status: 'error',
@@ -9,18 +17,35 @@ function conflict(path, details) {
9
17
  output: `CHANGE_CONFLICT: ${path} was not changed. ${details} Do not retry these arguments. Call read_file on this exact path, then use the returned hash; use expected_hash=null only if read_file reports that the path is missing.`,
10
18
  };
11
19
  }
20
+ function invalid(path, message) {
21
+ return {
22
+ status: 'error',
23
+ code: 'INVALID_ARGUMENTS',
24
+ retryable: false,
25
+ changedFiles: [],
26
+ output: `错误:${path}: ${message}`,
27
+ };
28
+ }
12
29
  export const writeFileTool = {
13
30
  name: 'write_file',
14
- description: 'Create or replace one file transactionally. expected_hash may be omitted (or null) only for create-only writes to a path that must not exist; overwriting requires the hash from a fresh read_file artifact header.',
31
+ description: 'Create or replace one file transactionally. expected_hash may be omitted (or null) only for create-only writes to a path that must not exist; overwriting requires the hash from a fresh read_file.\n' +
32
+ 'To ADD to an existing file, pass append=true — only the new text goes in `content`; NO expected_hash and NO prior read_file needed (lock-protected; creates the file when missing). Appends VERBATIM: if the file does not end with a newline, start your content with "\\n" or the last line will merge with yours.',
15
33
  risk: 'confirm',
16
34
  parameters: {
17
35
  type: 'object',
18
36
  properties: {
19
37
  path: { type: 'string', description: 'File path' },
20
- content: { type: 'string', description: 'Full file content' },
38
+ content: {
39
+ type: 'string',
40
+ description: 'File content. With append=false (default): the FULL new content. With append=true: only the text to add at the end.',
41
+ },
21
42
  expected_hash: {
22
43
  type: ['string', 'null'],
23
- description: 'Optional sha256 hash from read_file. Omit or pass null only when the path must not exist.',
44
+ description: 'Optional sha256 hash from read_file. Omit or pass null when the path must not exist, or when append=true (append reads the current content itself and is lock-protected).',
45
+ },
46
+ append: {
47
+ type: 'boolean',
48
+ description: 'Append `content` to the end of the file instead of replacing it (default false). Creates the file if missing. Text files only; binary targets are rejected.',
24
49
  },
25
50
  },
26
51
  required: ['path', 'content'],
@@ -28,6 +53,7 @@ export const writeFileTool = {
28
53
  async execute(args, ctx) {
29
54
  const file = String(args.path);
30
55
  const content = String(args.content);
56
+ const append = args.append === true;
31
57
  let expectedHash = null;
32
58
  // Missing and explicit null are both safe create-only requests. They never
33
59
  // overwrite: ChangeSet compares expectedHash=null against the current path.
@@ -36,13 +62,80 @@ export const writeFileTool = {
36
62
  if (!expectedHash)
37
63
  return conflict(file, 'expected_hash 必须是 null 或 sha256:<64 hex>。');
38
64
  }
39
- const operation = expectedHash === null ? 'create' : 'update';
65
+ let operation = expectedHash === null ? 'create' : 'update';
66
+ let replacement = content;
67
+ if (append) {
68
+ // append 的实现策略:工具层读现状 + 拼接 + 以 update 提交,**完全复用** ChangeSet 事务机器 ——
69
+ // 于是 rollback/diff/changeSet 追踪、原子 temp+rename 写回全部照旧,不新增第二套事务语义。
70
+ //
71
+ // 并发安全:读取发生在锁外(write_file 声明 delegatesResourceLocks,锁由 commitChangeSet 持有),
72
+ // 所以「读到」与「提交」之间存在窗口。这个窗口由 expectedHash 兜底 —— 下面把读到的内容 hash
73
+ // 填进 expectedHash,dryRun 会拿它与提交瞬间的真实内容比对:期间有人改过就报 CHANGE_CONFLICT
74
+ // (fail-loud,让模型重读重试),**绝不静默覆盖别人的追加**。这是 append 不需要模型先 read_file
75
+ // 的原因:hash 由工具自己算,正确性由冲突检测保证,而非靠模型复述。
76
+ let absolute;
77
+ try {
78
+ absolute = jailResolve(file);
79
+ }
80
+ catch (error) {
81
+ return invalid(file, error instanceof Error ? error.message : String(error));
82
+ }
83
+ let before = null;
84
+ try {
85
+ // 先 stat 再 read:体积闸门必须在字节进内存**之前**生效,否则一个 200MB 的日志
86
+ // 已经分配了 200MB 才被拒绝,闸门形同虚设(read_file 同款顺序)。
87
+ const info = await stat(absolute);
88
+ if (info.isDirectory()) {
89
+ return invalid(file, '是目录,不能追加内容。');
90
+ }
91
+ if (!info.isFile()) {
92
+ return invalid(file, '不是普通文件(设备/套接字/特殊文件),无法追加。');
93
+ }
94
+ if (info.size > MAX_APPEND_TARGET_BYTES) {
95
+ return invalid(file, `有 ${(info.size / 1024 / 1024).toFixed(1)} MB,超过 append 的 32.0 MB 上限` +
96
+ '(追加需整文件载入内存以走事务化写回)。超大日志请用 run_command 的 shell 重定向: `... >> <path>`。');
97
+ }
98
+ before = await readFile(absolute);
99
+ }
100
+ catch (error) {
101
+ const e = error;
102
+ // ENOENT = 文件还不存在:append 语义等同 shell `>>`,直接创建。其余错误如实上报。
103
+ if (e?.code !== 'ENOENT') {
104
+ return {
105
+ status: 'error',
106
+ code: 'EXECUTION_ERROR',
107
+ retryable: false,
108
+ changedFiles: [],
109
+ output: `错误:读取 ${file} 以便追加失败: ${e?.message ?? String(error)}`,
110
+ };
111
+ }
112
+ }
113
+ if (before !== null) {
114
+ // 二进制拒绝是 append 特有的风险:全量覆盖时坏数据只影响这一次写入,
115
+ // 而 append 会把「解码坏掉的旧内容」永久写回原文件,等于静默损毁数据。
116
+ if (isProbablyBinary(before.subarray(0, SNIFF_BYTES))) {
117
+ return invalid(file, '是二进制文件,不能按文本追加(utf8 往返会损毁原有字节)。如需追加二进制请用 run_command。');
118
+ }
119
+ // 模型显式给了 hash 就用它校验(不匹配交给 dryRun 报 conflict);没给则用实际 hash ——
120
+ // append 是日志/分段生成的高频操作,强制「先 read_file 拿 hash」纯属负担,
121
+ // 正确性由提交前的 hash 比对保证(fail-loud),而非靠模型复述。
122
+ if (expectedHash === null)
123
+ expectedHash = contentHash(before);
124
+ operation = 'update';
125
+ // 原样拼接,不自动补换行:尊重 shell `>>` 语义,不擅自改数据。
126
+ // 缺换行的黏行风险在 description 里显式告知模型(让它在 content 开头带 \n)。
127
+ replacement = before.toString('utf8') + content;
128
+ }
129
+ else {
130
+ operation = expectedHash === null ? 'create' : 'update';
131
+ }
132
+ }
40
133
  const result = await commitChangeSet(createChangeSet([
41
134
  {
42
135
  path: file,
43
136
  operation,
44
137
  expectedHash,
45
- replacement: content,
138
+ replacement,
46
139
  },
47
140
  ]), ctx?.signal);
48
141
  if (result.status === 'conflict') {
@@ -59,6 +152,8 @@ export const writeFileTool = {
59
152
  };
60
153
  }
61
154
  const summary = summarizeChangeSet(result.changeSet);
155
+ const verb = append ? '已追加到' : '已事务化写入';
156
+ const sizeNote = append ? `${content.length} 字符追加, 全文 ${replacement.length} 字符` : `${content.length} 字符`;
62
157
  return {
63
158
  status: 'success',
64
159
  code: 'OK',
@@ -67,7 +162,7 @@ export const writeFileTool = {
67
162
  changeSet: summary,
68
163
  output: result.changedFiles.length === 0
69
164
  ? `文件 ${file} 内容未变化 (ChangeSet ${summary.id})。`
70
- : `已事务化写入 ${file} (${content.length} 字符, ChangeSet ${summary.id}, sha256=${summary.changes[0]?.afterHash})。`,
165
+ : `${verb} ${file} (${sizeNote}, ChangeSet ${summary.id}, sha256=${summary.changes[0]?.afterHash})。`,
71
166
  };
72
167
  },
73
168
  };
@@ -5,6 +5,13 @@ import { getActiveSkill } from '../skills/activation.js';
5
5
  export const MAX_FILE_LINES = 2000;
6
6
  export const MAX_OUTPUT = 20000;
7
7
  export const MAX_RESULTS = 100;
8
+ /**
9
+ * read_file 命中图片分支时 output 的前缀。
10
+ *
11
+ * 放在 constants(叶子模块)而不是 builtins/read-file.ts:ui/render.ts 要用它把摘要
12
+ * 从「N 行」改成「图片已附加」,而 render.ts → builtins → registry 会绕出模块循环。
13
+ */
14
+ export const IMAGE_READ_MARKER = '[image]';
8
15
  /** 进 history 的单条工具结果上限(字符)。push-time 第一层裁剪,保 head + 标记 + tail。 */
9
16
  export const MAX_HISTORY_RESULT = 8000;
10
17
  /** use_skill 结果(SKILL.md 正文)的放宽上限:指令须完整,中截会破坏语义。 */
@@ -1,4 +1,4 @@
1
- import { ADD_TOOL_GROUPS_TOOL_NAME, COMMON_TOOL_NAMES, DEFAULT_ROUTE_GROUPS, TOOL_ROUTE_GROUP_NAMES, TOOL_ROUTE_GROUPS, getToolRouteGroupNames, isToolRouteGroupName, } from '../config/profiles.js';
1
+ import { ADD_TOOL_GROUPS_TOOL_NAME, COMMON_TOOL_NAMES, DEFAULT_ROUTE_GROUPS, TOOL_ROUTE_GROUP_NAMES, TOOL_ROUTE_GROUPS, expandRouteImplications, getToolRouteGroupNames, isToolRouteGroupName, } from '../config/profiles.js';
2
2
  import { PLAN_DISABLED_TOOLS } from './constants.js';
3
3
  import { tools } from './registry.js';
4
4
  const clampConfidence = (value) => (Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0);
@@ -106,9 +106,10 @@ export class ToolPolicyController {
106
106
  this.confidence = clampConfidence(init.confidence ?? 0);
107
107
  const available = new Set(getAvailableToolRouteGroups(this.catalog, this.gateAllows));
108
108
  // 常驻簇无条件激活:路由漏判/失败都不会让主 Agent 起手就没有写文件或跑命令的能力。
109
+ // 蕴含簇(browser-debug → background-exec)在此一并展开,弱模型只选浏览器时也拿得到起服务的能力。
109
110
  const requested = process.env.MOCODE_TOOL_POLICY === 'full'
110
111
  ? available
111
- : new Set([...DEFAULT_ROUTE_GROUPS, ...(init.groups ?? [])]);
112
+ : expandRouteImplications([...DEFAULT_ROUTE_GROUPS, ...(init.groups ?? [])]);
112
113
  for (const group of requested) {
113
114
  if (available.has(group))
114
115
  this.selected.add(group);
@@ -176,38 +177,56 @@ export class ToolPolicyController {
176
177
  expand(rawGroups, reason) {
177
178
  const rejected = [];
178
179
  const added = [];
180
+ const implied = [];
179
181
  if (this.expansionCount >= this.maxExpansions) {
180
182
  return {
181
183
  added,
184
+ implied,
182
185
  rejected: ['expansion limit reached'],
183
186
  snapshot: this.snapshot(false),
184
187
  };
185
188
  }
186
189
  const available = new Set(getAvailableToolRouteGroups(this.catalog, this.gateAllows));
190
+ /** 尝试激活一个簇;返回是否真的由本次调用新增。 */
191
+ const activate = (value) => {
192
+ if (!available.has(value))
193
+ return false;
194
+ if (this.selected.has(value))
195
+ return false;
196
+ this.selected.add(value);
197
+ return true;
198
+ };
187
199
  for (const value of rawGroups) {
188
200
  if (!isToolRouteGroupName(value)) {
189
201
  rejected.push(`${String(value)}: unknown group`);
202
+ continue;
190
203
  }
191
- else if (!available.has(value)) {
204
+ if (!available.has(value)) {
192
205
  rejected.push(`${value}: capability disabled or unavailable`);
193
206
  }
194
207
  else if (this.selected.has(value)) {
195
208
  rejected.push(`${value}: already active`);
196
209
  }
197
210
  else {
198
- this.selected.add(value);
211
+ activate(value);
199
212
  added.push(value);
213
+ // 蕴含簇静默带上:被 gate 否决或已激活都属正常,不进 rejected(不是模型的错)。
214
+ for (const impliedGroup of expandRouteImplications([value])) {
215
+ if (impliedGroup !== value && activate(impliedGroup))
216
+ implied.push(impliedGroup);
217
+ }
200
218
  }
201
219
  }
202
220
  if (added.length > 0) {
203
221
  this.expansionCount++;
204
222
  this.version++;
205
- this.reason = reason.trim() || `Main agent added ${added.join(', ')}.`;
223
+ const gained = [...added, ...implied];
224
+ this.reason = reason.trim() || `Main agent added ${gained.join(', ')}.`;
206
225
  this.confidence = Math.max(this.confidence, 0.8);
207
226
  this.autoCache = null;
208
227
  this.planCache = null;
209
228
  }
210
- return { added, rejected, snapshot: this.snapshot(false) };
229
+ return { added, implied, rejected, snapshot: this.snapshot(false) };
211
230
  }
212
231
  /** 注入请求尾部,不改写稳定 system prefix。 */
213
232
  reminder(planMode = false) {
@@ -218,7 +237,7 @@ export class ToolPolicyController {
218
237
  '## Tool route (current turn)',
219
238
  `Policy ${snapshot.id} v${snapshot.version}; active groups: ${active}.`,
220
239
  `Router reason: ${snapshot.reason}`,
221
- 'Use only the tools currently exposed. If a required capability is missing, call add_tool_groups alone; dependent calls must wait until the next step.',
240
+ 'Use only the exposed tools. Missing capability? Call add_tool_groups alone; dependent calls wait for the next step.',
222
241
  ];
223
242
  if (remaining.length)
224
243
  lines.push(`Groups still available: ${remaining.join(', ')}.`);
@@ -1,7 +1,7 @@
1
1
  import { chat } from '../llm/index.js';
2
2
  import { COMMON_TOOL_NAMES, DEFAULT_ROUTE_GROUPS, TOOL_ROUTE_GROUPS, isToolRouteGroupName, } from '../config/profiles.js';
3
3
  import { getRoutableToolRouteGroups, toolRouteCatalog } from './policy.js';
4
- import { getRouterMode, getJevRouterConfig, isJevRouterConfigured } from '../config/index.js';
4
+ import { getRouterMode, getJevRouterConfig, isJevRouterConfigured, isToolRoutingEnabled } from '../config/index.js';
5
5
  import { askJev } from './jev-client.js';
6
6
  const ROUTER_TOOL_NAME = 'select_tool_groups';
7
7
  const MAX_ROUTER_INPUT_CHARS = 12_000;
@@ -89,6 +89,8 @@ function parseDecision(raw, available, previousGroups, startedAt) {
89
89
  * - `jev`:TypeSafe systemone,每组独立出 0~1 概率,按阈值出簇(阈值可调是选它的核心理由)。
90
90
  * 两条路径失败都沿用上一 turn 的簇;主 Agent 仍可通过 add_tool_groups 自救,
91
91
  * 但绝不因路由失败直接暴露 full 工具集。
92
+ * 总开关(/router off,MOCODE_ROUTER_ENABLED=false):跳过路由调用,只保留常驻簇
93
+ * (controller 无条件激活的 DEFAULT_ROUTE_GROUPS)+ 通用工具,不再选任何额外簇。
92
94
  */
93
95
  export async function routeToolGroups(request) {
94
96
  const startedAt = Date.now();
@@ -97,6 +99,18 @@ export async function routeToolGroups(request) {
97
99
  const availableGroups = getRoutableToolRouteGroups(request.tools, request.gateAllows);
98
100
  const available = new Set(availableGroups);
99
101
  const previousGroups = (request.previousGroups ?? []).filter((group) => available.has(group));
102
+ // 总开关关闭:不发任何路由请求。groups 置空 → controller 只保留常驻簇 + 通用工具,
103
+ // 缺的能力模型仍可 add_tool_groups 自救。fallback 标记让 metadata 能区分「真路由」与「开关直通」。
104
+ if (!isToolRoutingEnabled()) {
105
+ return {
106
+ groups: [],
107
+ inheritPrevious: false,
108
+ confidence: 0,
109
+ reason: 'Tool pre-routing is disabled (/router off); common tools plus default groups only.',
110
+ latencyMs: Date.now() - startedAt,
111
+ fallback: true,
112
+ };
113
+ }
100
114
  if (availableGroups.length === 0) {
101
115
  return fallbackDecision(startedAt, [], `No routable tool groups are currently available; using common tools plus always-on ${DEFAULT_ROUTE_GROUPS.join(', ')}.`);
102
116
  }
@@ -191,9 +205,10 @@ ${request.tools ? toolRouteCatalog(availableGroups, request.tools) : toolRouteCa
191
205
 
192
206
  Routing rules:
193
207
  - You MUST call ${ROUTER_TOOL_NAME} exactly once and emit no prose.
194
- - File edits and command execution are always available; do NOT select them. If common tools plus the always-on groups suffice (most coding, testing, and debugging tasks), return an empty groups array.
208
+ - File edits and foreground command execution are always available; do NOT select them. If common tools plus the always-on groups suffice (most coding, testing, and debugging tasks), return an empty groups array.
195
209
  - Select multiple groups when the task genuinely combines capabilities.
196
- - Web UI DOM/console/network/page sessions or local web servers need browser-debug.
210
+ - background-exec is for any process that must keep running after the tool call returns: dev servers, inference/model services, watchers, log tails, or a command you will poll later. Foreground run_command blocks and is killed at its timeout, so it cannot host them.
211
+ - Web UI DOM/console/network/page sessions need browser-debug; a local web server alone only needs background-exec.
197
212
  - Merely observing system dialogs or non-browser windows needs desktop-observe.
198
213
  - computer-control requires explicit real GUI clicking, typing, scrolling, or desktop application operation; never infer it from the word "browser" alone.
199
214
  - memory-write requires explicit intent to remember, update, forget, or link cross-session knowledge.
@@ -205,7 +220,8 @@ Routing rules:
205
220
  Examples (text form; always answer with the ${ROUTER_TOOL_NAME} call):
206
221
  - Task "这个仓库用什么测试框架?该怎么加一个新测试?" → groups: [], inheritPrevious: false, reason: "Pure question; common read/search tools suffice."
207
222
  - Task "修好 auth.ts 里过期的 token 校验并跑一遍相关测试" → groups: [], inheritPrevious: false, reason: "File edits and test runs are always-on groups, never selected."
208
- - Task "本地页面白屏了,帮我看看控制台报错" → groups: [browser-debug], inheritPrevious: false, reason: "Needs DOM/console inspection of a local web page."
223
+ - Task "把 8765 端口的推理服务起起来,然后拿它的 /predict 试几个样本" → groups: [background-exec], inheritPrevious: false, reason: "A long-running service must stay alive across calls so it can be polled and stopped later."
224
+ - Task "本地页面白屏了,帮我看看控制台报错" → groups: [background-exec, browser-debug], inheritPrevious: false, reason: "Needs the dev server running plus DOM/console inspection of a local web page."
209
225
  - Task "记住这条约定:提交前必须跑 lint" → groups: [memory-write], inheritPrevious: false, reason: "Explicit intent to persist cross-session knowledge."`;
210
226
  const user = [
211
227
  `Current mode: ${request.planMode ? 'PLAN (route final task needs; execution will still be read-only)' : 'AUTO'}`,