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
@@ -101,18 +101,17 @@ export async function runModelTurn(input) {
101
101
  const ephemeralReminder = [
102
102
  runPolicy.reminder,
103
103
  !opts.suppressOpeningAnalysis && step === 0
104
- ? '## Opening analysis\nBegin your FIRST response of this turn with a brief analysis of the request and your planned approach (1-3 sentences, no filler), THEN start tool calls. This opening is the only place where pre-tool prose is expected; after it, work quietly with no narration between tool calls.'
104
+ ? '## Opening analysis\nStart your first response of this turn with a brief analysis of the request and approach (1-3 sentences, no filler), then start tool calls. This is the only expected pre-tool prose; afterwards work quietly, no narration between calls.'
105
105
  : '',
106
106
  historyRebuilt
107
- ? '## Post-compaction recovery\n' +
108
- 'Context was compacted before this request. Recover before doing anything else, in this order:\n' +
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' +
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' +
107
+ ? '## Post-compaction recovery\nContext was compacted before this request. Recover first, in this order:\n' +
108
+ '1. Read the session summary at the top of the history: `## Completed` is done — do not redo or re-verify it; `## In Progress` / `## Next Steps` say where work stopped and what is next.\n' +
109
+ '2. Read `## Session state` below (refreshed from notes.md / gui-actions.log): the active plan is authoritative — `[x]` steps are done, resume from the first `[ ]`. `## Compaction Snapshot` is the checkpoint written at this compaction. `## GUI actions` lists every GUI action already performed with its result: do not repeat one that appears there, unless the latest screenshot contradicts it (the screenshot wins — treat that line as attempted but unverified).\n' +
111
110
  (sessionStateText
112
111
  ? ''
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') +
114
- '3. Before any file edit, read_file the target fresh to get the current content hash — never edit from memory of pre-compaction content.\n' +
115
- '4. Before re-running a search/read you think you already did, check the summary and notes first: only repeat it if the result is genuinely missing or the target has changed.'
112
+ : '(No active plan or snapshot in notes.md — reconstruct progress from the summary; treat its `## Completed` as ground truth.)\n') +
113
+ '3. Before any file edit, read_file the target fresh for its current hash — never edit from pre-compaction memory.\n' +
114
+ '4. Before re-running a search/read you think you already did, check the summary and notes first; repeat only if the result is genuinely missing or the target changed.'
116
115
  : '',
117
116
  sessionStateText,
118
117
  ]
@@ -429,6 +429,9 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
429
429
  succeeded
430
430
  ? `Tool policy expanded to v${expansion.snapshot.version}; added groups: ${expansion.added.join(', ')}.`
431
431
  : `Tool policy was not expanded (still v${expansion.snapshot.version}).`,
432
+ expansion.implied.length > 0
433
+ ? `Implied groups also activated: ${expansion.implied.join(', ')}.`
434
+ : '',
432
435
  expansion.rejected.length > 0 ? `Rejected: ${expansion.rejected.join('; ')}.` : '',
433
436
  succeeded ? 'The added tool schemas become available on the next model step.' : '',
434
437
  ]
@@ -448,6 +451,7 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
448
451
  toVersion: expansion.snapshot.version,
449
452
  requestedGroups: parsed.groups.map(String),
450
453
  addedGroups: expansion.added,
454
+ impliedGroups: expansion.implied,
451
455
  rejected: expansion.rejected,
452
456
  reason: parsed.reason,
453
457
  status: outcome.status,
@@ -138,6 +138,7 @@ class LegacyCompatibleToolDispatcher {
138
138
  succeeded
139
139
  ? `Tool policy expanded to v${expansion.snapshot.version}; added groups: ${expansion.added.join(', ')}.`
140
140
  : `Tool policy was not expanded (still v${expansion.snapshot.version}).`,
141
+ expansion.implied.length > 0 ? `Implied groups also activated: ${expansion.implied.join(', ')}.` : '',
141
142
  expansion.rejected.length > 0 ? `Rejected: ${expansion.rejected.join('; ')}.` : '',
142
143
  succeeded ? 'The added tool schemas become available on the next model step.' : '',
143
144
  ]
@@ -8,7 +8,7 @@ const PLAN_NAG_TEXT = '[mocode] Reminder: you have an active plan in notes.md bu
8
8
  * 「工具回灌的屏幕帧」与「用户粘贴的图」——两边各写一份字符串会让识别规则悄悄失效。
9
9
  * 新增附件产出点**必须**复用这一个前言。
10
10
  */
11
- export const ATTACHMENT_PREAMBLE = 'The view_image tool loaded the following visual input: ';
11
+ export const ATTACHMENT_PREAMBLE = 'The read_file tool loaded the following visual input: ';
12
12
  /** Owns tool-turn history publication, transaction settlement, plan nag, attachments and checkpoint ordering. */
13
13
  export async function runToolTurn(input) {
14
14
  const { opts, ctx, historyManager, result, stream, step, maxSteps, planState, turnLifecycle, cancellationLifecycle, terminationPolicy, rebuildHistoryIndexes, dispatch, } = input;
@@ -2,6 +2,7 @@ import { readFile, stat } from 'node:fs/promises';
2
2
  import { basename, extname } from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { jailResolve } from '../sandbox/jail.js';
5
+ import { decodePng, downscale, encodePng } from '../runtime/screen-pipeline.js';
5
6
  export const MAX_INLINE_BYTES_DEFAULT = 4 * 1024 * 1024;
6
7
  const MIME_BY_EXT = {
7
8
  '.png': 'image/png',
@@ -13,6 +14,40 @@ const MIME_BY_EXT = {
13
14
  export function detectMime(p) {
14
15
  return MIME_BY_EXT[extname(p).toLowerCase()] ?? null;
15
16
  }
17
+ /**
18
+ * 按**魔数**判定图片类型(不看扩展名)。
19
+ *
20
+ * 为什么必须有这条:扩展名会说谎——`data.bin` 可能是 PNG,`notes.png` 也可能是文本。
21
+ * read_file 要靠它决定「走文本行号分页」还是「走视觉通道」,判错的代价是把二进制
22
+ * 当 UTF-8 解码(实测一张 42KB PNG 解码出 17862 个 U+FFFD,占 45%)灌进 history。
23
+ */
24
+ export function sniffImageMime(buf) {
25
+ if (buf.length >= 8 && buf[0] === 0x89 && buf.toString('ascii', 1, 8) === 'PNG\r\n\x1a\n')
26
+ return 'image/png';
27
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
28
+ return 'image/jpeg';
29
+ if (buf.length >= 6 && buf.toString('ascii', 0, 6) === 'GIF87a')
30
+ return 'image/gif';
31
+ if (buf.length >= 6 && buf.toString('ascii', 0, 6) === 'GIF89a')
32
+ return 'image/gif';
33
+ // WebP: RIFF....WEBP
34
+ if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
35
+ return 'image/webp';
36
+ }
37
+ return null;
38
+ }
39
+ /**
40
+ * 二进制嗅探:头部 4KB 含 C0 控制字符(NUL/BEL/ESC 等,放行 \t\n\r)即视为二进制。
41
+ *
42
+ * 与 grep 的 BINARY_PROBE_RE 同源同口径(集中在此,避免两处正则漂移):SQLite、压缩包、
43
+ * 可执行文件、minified 数据 dump 都会命中。用途是让 read_file 明确拒绝并指路,
44
+ * 而不是把乱码塞进上下文——那既烧 token 又让模型基于垃圾内容做判断。
45
+ */
46
+ export const BINARY_PROBE_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
47
+ export function isProbablyBinary(head) {
48
+ const sample = typeof head === 'string' ? head : head.toString('latin1');
49
+ return BINARY_PROBE_RE.test(sample.slice(0, 4096));
50
+ }
16
51
  function formatBytes(n) {
17
52
  if (n < 1024)
18
53
  return `${n} B`;
@@ -23,11 +58,93 @@ function formatBytes(n) {
23
58
  export function renderChip(att) {
24
59
  return `📷 ${att.name} (${formatBytes(att.bytes)})`;
25
60
  }
61
+ /** 降采样兜底的长边上界:对齐主流视觉模型的原生分辨率(Claude 1568 / OpenAI 高分块同级)。 */
62
+ export const DOWNSCALE_MAX_EDGE = 1568;
63
+ /**
64
+ * 读图 + 超限自动降采样兜底。
65
+ *
66
+ * 为什么要兜底:4 MiB 内联上限对高 DPI 截图偏紧(一张 4K Retina PNG 轻松 5-8 MiB)。
67
+ * 直接拒绝会逼模型去找压缩工具/改用户文件,而**服务端缩一下就能成功**。
68
+ * screenshot 早有这条路径(screenshot.ts 的 FALLBACK_MAX_EDGE 分支),这里抽成共享 helper,
69
+ * 让 read_file 图片通道(原 view_image,已并入)同样受益。
70
+ *
71
+ * 能力边界(诚实声明):`runtime/screen-pipeline.ts` 的 PNG 解码是手写的、只支持
72
+ * **8-bit RGB/RGBA PNG**(项目刻意零原生图像依赖)。所以兜底只覆盖 PNG;
73
+ * JPEG/WebP/GIF 超限仍然拒绝,reason 会写明这一点。
74
+ */
75
+ export async function loadImageAttachmentWithDownscale(input, opts) {
76
+ const loaded = await loadImageAttachment(input, opts);
77
+ if (loaded.ok)
78
+ return loaded;
79
+ // 只有「体积超限」这一种失败值得兜底:路径为空/扩展名不支持/沙箱越界/不是普通文件
80
+ // 都是真错误,缩图解决不了,原样透出让调用方给出准确提示。
81
+ if (!loaded.reason.startsWith('too large'))
82
+ return loaded;
83
+ const mime = detectMime(input) ?? opts.sniffedMime ?? null;
84
+ if (mime !== 'image/png') {
85
+ return {
86
+ ok: false,
87
+ reason: `${loaded.reason} — 自动降采样仅支持 PNG(零原生图像依赖);${extname(input)} 请先转成 PNG 或自行压缩后重试`,
88
+ };
89
+ }
90
+ let abs;
91
+ try {
92
+ abs = jailResolve(input.trim());
93
+ }
94
+ catch (e) {
95
+ return { ok: false, reason: `outside sandbox: ${e instanceof Error ? e.message : String(e)}` };
96
+ }
97
+ try {
98
+ const png = decodePng(await readFile(abs));
99
+ const { img } = downscale(png, DOWNSCALE_MAX_EDGE);
100
+ const buf = encodePng(img);
101
+ if (buf.length > opts.maxBytes) {
102
+ return {
103
+ ok: false,
104
+ reason: `${loaded.reason} — 降采样到 ${img.width}×${img.height} 后仍有 ${formatBytes(buf.length)},超过 ${formatBytes(opts.maxBytes)}`,
105
+ };
106
+ }
107
+ let st;
108
+ try {
109
+ st = await stat(abs);
110
+ }
111
+ catch {
112
+ st = null;
113
+ }
114
+ const id = createHash('sha1')
115
+ .update(abs)
116
+ .update('\0downscaled')
117
+ .update(String(img.width))
118
+ .update('\0')
119
+ .update(String(img.height))
120
+ .update('\0')
121
+ .update(String(st?.mtimeMs ?? 0))
122
+ .digest('hex');
123
+ return {
124
+ ok: true,
125
+ downscaledFrom: { width: png.width, height: png.height },
126
+ att: {
127
+ id,
128
+ path: abs,
129
+ name: basename(abs),
130
+ bytes: buf.length,
131
+ mime: 'image/png',
132
+ dataUrl: `data:image/png;base64,${buf.toString('base64')}`,
133
+ },
134
+ };
135
+ }
136
+ catch (error) {
137
+ const message = error instanceof Error ? error.message : String(error);
138
+ return { ok: false, reason: `${loaded.reason}(降采样兜底失败: ${message})` };
139
+ }
140
+ }
26
141
  export async function loadImageAttachment(input, opts) {
27
142
  const trimmed = input.trim();
28
143
  if (!trimmed)
29
144
  return { ok: false, reason: '路径为空' };
30
- const mime = detectMime(trimmed);
145
+ // 扩展名优先(便宜、无需读文件);不认识时用调用方给的魔数嗅探结果兜底 ——
146
+ // read_file 读到的图片常常没有正确扩展名(截图缓存 / 构建产物 / 无扩展名 blob)。
147
+ const mime = detectMime(trimmed) ?? opts.sniffedMime ?? null;
31
148
  if (!mime) {
32
149
  return { ok: false, reason: `unsupported: ${extname(trimmed) || '(无扩展名)'} — 仅支持 png/jpg/jpeg/gif/webp` };
33
150
  }
@@ -52,7 +169,7 @@ export async function loadImageAttachment(input, opts) {
52
169
  if (st.size > opts.maxBytes) {
53
170
  return {
54
171
  ok: false,
55
- reason: `too large: ${formatBytes(st.size)} (max ${formatBytes(opts.maxBytes)}) — TODO: URL upload not yet supported`,
172
+ reason: `too large: ${formatBytes(st.size)} (max ${formatBytes(opts.maxBytes)})`,
56
173
  };
57
174
  }
58
175
  const buf = await readFile(abs);
@@ -12,6 +12,9 @@ import { detectLanguage, setLanguage, t } from '../i18n/index.js';
12
12
  import { isProfileName, profileHasGroup } from './profiles.js';
13
13
  // 端点常量归协议实现方(jev-client.ts,纯叶子无 import,不引入环);此处只引用不重定义。
14
14
  import { DEFAULT_JEV_BASE_URL } from '../tools/jev-client.js';
15
+ // shell.ts 是纯叶子(只 import node 内置):PLATFORM_NOTE 的措辞必须跟 run_command/dev_server
16
+ // 实际 spawn 的默认 shell 一致,单一事实源,防「文案说 cmd、实际跑 bash」漂移。
17
+ import { defaultShellKind } from '../runtime/shell.js';
15
18
  /**
16
19
  * 按优先级加载配置文件并回填 process.env:
17
20
  * 候选(后者覆盖前者,优先级升序):<cwd>/.env(兼容旧用法,最低)→ ~/.mocode/config(全局)→ <cwd>/.mocode/config(项目级覆盖,最高)。
@@ -95,9 +98,17 @@ export function isModelConfigured() {
95
98
  return !!config.baseURL && !!config.apiKey;
96
99
  }
97
100
  const PLATFORM_NOTE = (() => {
101
+ const shell = defaultShellKind();
102
+ if (process.platform === 'win32' && shell === 'cmd') {
103
+ return `- This is Windows: \`run_command\`/\`dev_server\` default to \`cmd.exe /c\` — use cmd syntax and \`%VAR%\`; Unix builtins and command substitution are unavailable.
104
+ - Non-interactive cmd cannot run \`timeout /t\` (it errors out); pass \`shell: "powershell"\` with \`Start-Sleep\`, or \`shell: "bash"\` with \`sleep\`, when a wait is needed.
105
+ - Prefer read_file/glob/grep for file discovery and reading. When a POSIX shell fits better, pass \`shell: "bash"\` (Git Bash, auto-detected) or \`shell: "powershell"\`; never mix syntaxes within one command.`;
106
+ }
98
107
  if (process.platform === 'win32') {
99
- return `- This is Windows: \`run_command\` uses \`cmd.exe /c\` — use cmd syntax and \`%VAR%\`; Unix builtins and command substitution are unavailable.
100
- - Prefer read_file/glob/grep for file discovery and reading. When shell is necessary, use forward-slash paths or invoke PowerShell explicitly.`;
108
+ // MOCODE_SHELL 翻转了默认:措辞必须跟着变,否则模型按文案写 cmd 语法却落进 bash。
109
+ return `- This is Windows: \`run_command\`/\`dev_server\` default to ${shell} (MOCODE_SHELL override) — use ${shell === 'bash' ? 'POSIX syntax ($VAR, &&, forward-slash paths)' : 'PowerShell syntax ($VAR, Start-Sleep)'}.
110
+ - cmd-only syntax (\`%VAR%\`, \`start /b\`, \`dir\`) needs an explicit \`shell: "cmd"\`; do not mix syntaxes within one command.
111
+ - Prefer read_file/glob/grep for file discovery and reading; reach for the shell only when a dedicated tool does not fit.`;
101
112
  }
102
113
  if (process.platform === 'darwin') {
103
114
  return `- This is macOS: \`run_command\` uses bash with BSD utilities. Prefer read_file/glob/grep; account for BSD/GNU differences when shell commands are necessary.`;
@@ -311,7 +322,7 @@ export function buildSessionStateReminder(sessionId = getCurrentSessionId()) {
311
322
  const sources = [notes || plan ? 'notes.md' : '', guiActions ? 'gui-actions.log' : ''].filter(Boolean).join(' + ');
312
323
  const parts = [
313
324
  `## 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.',
325
+ 'Mirrors live session state, refreshed every step; authoritative — ignore older copies earlier in this conversation.',
315
326
  ...(plan ? [plan] : []),
316
327
  ...(notes ? [notes] : []),
317
328
  ...(guiActions ? [guiActions] : []),
@@ -320,23 +331,69 @@ export function buildSessionStateReminder(sessionId = getCurrentSessionId()) {
320
331
  }
321
332
  /** AGENTS.md 自动导入正文上限:system 位于 history[0] 且 compactHistory 不压缩 system,超长需截断防占窗口(见 memory/README.md)。 */
322
333
  const MAX_AGENTS_IMPORT_CHARS = 20000;
334
+ /**
335
+ * 不常驻注入的章节(压成指针行,read_file 按需取全文):
336
+ * - 目录结构 / Directory structure / Project layout —— 架构探索任务用 codegraph/探查工具现查更准;
337
+ * - 扩展点 / Extension points —— 只在「加新工具/命令/模块」类任务才需要,恰好是 skill 的定义。
338
+ * 常驻价值密度最高的「项目/命令/约定」(市场实证 arXiv 2511.12884:build/run 62.3%、conventions 主流)全文保留。
339
+ * 章节标题大小写不敏感,兼容英文写法的 AGENTS.md。
340
+ */
341
+ const AGENTS_INJECTION_INDEX_SECTIONS = [
342
+ '目录结构',
343
+ '扩展点',
344
+ 'directory structure',
345
+ 'project layout',
346
+ 'extension points',
347
+ ];
348
+ /**
349
+ * AGENTS.md 按章节过滤注入:命中 {@link AGENTS_INJECTION_INDEX_SECTIONS} 的 H2 章节整段压缩成一行指针,
350
+ * 其余章节(preamble、## 项目、## 命令、## 约定及未知章节)逐字保留。
351
+ * H1 及更深层级不动;空文件/无章节文件原样返回。导出供单测直接断言。
352
+ */
353
+ export function filterAgentsSectionsForInjection(content) {
354
+ const lines = content.split('\n');
355
+ const out = [];
356
+ let inIndexedSection = false;
357
+ for (const line of lines) {
358
+ const h2 = /^##\s+(.*)$/.exec(line);
359
+ if (h2) {
360
+ const title = h2[1].trim().toLowerCase();
361
+ // 前缀匹配容忍「目录结构(monorepo)」「Directory Structure — monorepo」等后缀写法;
362
+ // 仅当后缀紧邻(空格/括号/冒号/破折号)时命中,避免误伤「约定与目录结构习惯」这类反向词序。
363
+ inIndexedSection = AGENTS_INJECTION_INDEX_SECTIONS.some((s) => title === s || new RegExp(`^${s}[\\s(:\\u2014\\uff08\\(]`).test(title));
364
+ if (inIndexedSection) {
365
+ out.push(`- ${h2[1].trim()}: (not injected — read_file AGENTS.md on demand)`);
366
+ continue;
367
+ }
368
+ }
369
+ if (!inIndexedSection)
370
+ out.push(line);
371
+ }
372
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
373
+ }
323
374
  /**
324
375
  * 工作区根 AGENTS.md 自动导入段:与 memory 开关完全无关——
325
376
  * 只要 <cwd>/AGENTS.md 存在就把正文直接拼进 prompt(超 {@link MAX_AGENTS_IMPORT_CHARS} 截断+末尾提示),
326
377
  * 不再只指路让模型按需 read_file。读失败静默跳过(返空串)。
378
+ *
379
+ * 瘦身(方案A):目录结构/扩展点两章节不常驻,压成指针行——模型真做架构/扩展任务时
380
+ * 一次 read_file 取全文(渐进披露,与 Skills 清单同构);截断上限作用于过滤后的正文。
327
381
  */
328
382
  function buildAgentsImportSection() {
329
383
  try {
330
384
  const projectAgents = path.join(process.cwd(), 'AGENTS.md');
331
385
  if (!fs.existsSync(projectAgents))
332
386
  return '';
333
- const content = fs.readFileSync(projectAgents, 'utf8').trim();
334
- if (!content)
387
+ const raw = fs.readFileSync(projectAgents, 'utf8').trim();
388
+ if (!raw)
335
389
  return '';
336
- const body = content.length > MAX_AGENTS_IMPORT_CHARS
337
- ? `${content.slice(0, MAX_AGENTS_IMPORT_CHARS)}\n…[AGENTS.md truncated: first ${MAX_AGENTS_IMPORT_CHARS} characters injected]`
338
- : content;
339
- return `\n## Project memory (AGENTS.md, auto-imported)\n${body}\n- AGENTS.md may be stale: current code and the user request override stale memory.`;
390
+ const filtered = filterAgentsSectionsForInjection(raw);
391
+ const body = filtered.length > MAX_AGENTS_IMPORT_CHARS
392
+ ? `${filtered.slice(0, MAX_AGENTS_IMPORT_CHARS)}\n…[AGENTS.md truncated: first ${MAX_AGENTS_IMPORT_CHARS} characters injected]`
393
+ : filtered;
394
+ return (`\n## Project memory (AGENTS.md, auto-imported)\n${body}\n` +
395
+ '- AGENTS.md may be stale: current code and the user request override stale memory.\n' +
396
+ '- Discovered a stable, non-obvious project fact worth persisting? write_file(append=true) one line to `.mocode/agents-draft.md`; the user merges drafts into AGENTS.md via /init.');
340
397
  }
341
398
  catch {
342
399
  return ''; // 读失败静默跳过:不让导入破坏 prompt 构建
@@ -387,14 +444,14 @@ export function buildBasePrompt(sessionId = getCurrentSessionId()) {
387
444
  // 回复语言不写入提示词:模型按用户当轮提问语言自动识别(Voice 段的
388
445
  // "Match the user's style and language" 已覆盖),/language 只切换终端 UI 文案。
389
446
  const staticBody = `## Identity
390
- You are mocode, a terminal coding agent.
447
+ You are mocode, a terminal coding agent created by Wan Engineer.
391
448
 
392
449
  ## Core behavior
393
450
  Complete programming tasks through an "analyze → call tool → observe result → decide next step" loop until solved.
394
451
 
395
452
  ## Modes
396
- - AUTO is the default: investigate and complete the task with the tools currently exposed.
397
- - PLAN is read-only research and design; do not make changes until the user approves and switches back to AUTO.
453
+ - AUTO (default): complete tasks with the tools currently exposed.
454
+ - PLAN: read-only design; no changes until the user approves and switches back.
398
455
 
399
456
  ## Workflow
400
457
  - Understand: use existing conversation and tool evidence before gathering more.
@@ -412,12 +469,12 @@ ${buildWorkDisciplineSection(inferModelFamily(config.model))}
412
469
  ## Tool policy
413
470
  - Silent Execution: invoke tools directly without preamble. Output visible text ONLY for the final answer and critical mid-task findings. Strictly no step-by-step narration (no "let me…", "让我先…", "now checking…" between calls).
414
471
  - Go directly to a known path or symbol; use discovery tools only when the location is unknown.
415
- - Edit against a FRESH read: before any edit_file/write_file, call read_file on the exact path and copy both its latest hash and the exact target text. Never reconstruct old_string from a grep/summary/diff — those lose whitespace and indentation and cause edit failures.
472
+ - Edit against a FRESH read: before any edit_file or a replacing write_file, call read_file on the exact path and copy both its latest hash and the exact target text. Never reconstruct old_string from a grep/summary/diff — those lose whitespace and indentation and cause edit failures. (write_file with append=true is the exception: it reads and hashes the file itself, so no prior read_file and no expected_hash are needed.)
416
473
  - A read_file hash from before a compaction, session resume, edit conflict, or external change is STALE and will be rejected — re-read rather than reuse an old hash.
417
474
  - Emit multiple independent tool calls in ONE assistant message so they run concurrently — e.g. several read_file regions, a grep plus a glob, or several web_fetch calls. One lookup per message wastes a full model round-trip each time. Place parallel-safe calls consecutively; keep any call that depends on their results (e.g. an edit) for the next message.
418
475
  - Never batch a read with an edit that depends on it; do not repeat overlapping reads or unchanged failed calls.
419
476
  - On failure, inspect the full error, change the approach, and retry only with a reason. Drop stale tool output when it no longer supports the task.
420
- - For generated content over roughly 200 lines or 5K tokens, use small staged writes rather than one oversized tool argument.
477
+ - For generated content over roughly 200 lines or 5K tokens, write it in stages: first write_file the initial chunk, then extend it with write_file(append=true, content=<next chunk>) — each call stays small and the file grows transactionally. Appends are VERBATIM: if the file does not already end with a newline, begin your chunk with "\\n" so lines do not merge.
421
478
 
422
479
  ## Environment
423
480
  ${PLATFORM_NOTE}
@@ -803,6 +860,19 @@ export function getRouterMode() {
803
860
  export function updateRouterMode(mode) {
804
861
  process.env.MOCODE_ROUTER_MODE = mode;
805
862
  }
863
+ /**
864
+ * 工具预路由总开关。关闭 = 每个 turn 跳过路由调用,只保留常驻簇
865
+ * (controller 无条件激活的 DEFAULT_ROUTE_GROUPS)+ 通用工具,不选任何额外簇;默认开启。
866
+ * 与 RouterMode 同一理由直接读写 process.env:支持 /router on|off 即时切换,
867
+ * routeToolGroups 在每 turn 调用点实时读,改后下一真实用户 turn 生效。
868
+ */
869
+ export function isToolRoutingEnabled() {
870
+ return process.env.MOCODE_ROUTER_ENABLED !== 'false';
871
+ }
872
+ /** /router 的写入口;持久化由调用方写 MOCODE_ROUTER_ENABLED(见 config/file.ts)。 */
873
+ export function updateToolRoutingEnabled(enabled) {
874
+ process.env.MOCODE_ROUTER_ENABLED = enabled ? 'true' : 'false';
875
+ }
806
876
  function readNumberEnv(name, fallback) {
807
877
  const raw = process.env[name];
808
878
  if (raw === undefined || raw.trim() === '')
@@ -5,25 +5,28 @@
5
5
  */
6
6
  /**
7
7
  * 工具簇 → 工具名。新增工具时归到对应簇;一个工具只属一个簇。
8
- * view_image 放 core-read,保证所有模式都能读取已有本地图片;工具产生的即时视觉结果通过
9
- * modelAttachments 直接回灌,不依赖 view_image。screenshot 留 frontend(抓整个桌面,隐私敏感,
10
- * 主要服务前端联调)。
8
+ * 图片读取由 read_file 的魔数嗅探分支覆盖(原 view_image 已并入):文本/图片分流,
9
+ * 工具产生的即时视觉结果通过 modelAttachments 直接回灌。screenshot 留 frontend(抓整个
10
+ * 桌面,隐私敏感,主要服务前端联调)。
11
+ * dev_server 归 core-write 而非 frontend:它是「后台进程管理」能力(与 run_command 同级),
12
+ * 不是浏览器工具,不该受 MOCODE_FRONTEND_TOOLS_ENABLED 否决。与自动路由的 background-exec
13
+ * 组(无 gateEnv)语义对齐。
11
14
  */
12
15
  export const TOOL_GROUPS = {
13
- 'core-read': ['read_file', 'view_image', 'glob', 'grep'],
14
- 'core-write': ['write_file', 'edit_file', 'run_command'],
16
+ 'core-read': ['read_file', 'glob', 'grep'],
17
+ 'core-write': ['write_file', 'edit_file', 'run_command', 'dev_server'],
15
18
  'agent-meta': ['plan_update', 'note_append', 'ask_human', 'use_skill', 'run_skill'],
16
19
  web: ['web_search', 'web_fetch'],
17
- frontend: ['browser', 'dev_server', 'screenshot'],
20
+ frontend: ['browser', 'screenshot'],
18
21
  computer: ['computer'],
19
22
  memory: ['memory_save', 'memory_search', 'memory_list', 'memory_update', 'memory_forget', 'memory_graph'],
20
23
  subagent: ['sub-agent'],
21
24
  };
22
25
  // ── LLM 自动工具路由 ──────────────────────────────────────────────────────
23
- /** 主 Agent 每一步都可见的低风险、高复用工具。 */
26
+ /** 主 Agent 每一步都可见的低风险、高复用工具。
27
+ * 图片读取并入 read_file(魔数嗅探分流,原 view_image 已移除)。 */
24
28
  export const COMMON_TOOL_NAMES = [
25
29
  'read_file',
26
- 'view_image',
27
30
  'glob',
28
31
  'grep',
29
32
  'web_search',
@@ -52,9 +55,18 @@ export const TOOL_ROUTE_GROUPS = {
52
55
  tools: ['run_command'],
53
56
  description: 'Run tests, builds, linters, Git, package managers, logs, diagnostics, and foreground commands.',
54
57
  },
58
+ // dev_server 从 browser-debug 拆出:它的能力是「跨工具调用存活的后台进程 + 日志 + 树杀」,
59
+ // 服务对象远不止前端联调(推理服务、watcher、log tail、任意长驻命令)。留在 browser-debug 时
60
+ // 受 MOCODE_FRONTEND_TOOLS_ENABLED 否决、且组描述只提 DOM/console,路由 LLM 对「起个服务」
61
+ // 类任务几乎不会选它 —— 模型于是退回 run_command 前台阻塞(120s 超时)或 start /b 脱离启动,
62
+ // 之后既拿不到日志也没法优雅 kill。故独立成组且**无 gateEnv**:与 shell-debug 同级、永远可路由。
63
+ 'background-exec': {
64
+ tools: ['dev_server'],
65
+ description: 'Run any long-running background process that must outlive a single tool call — dev servers, inference/model services, watchers, log tails, message queues. Provides process id, incremental log reads, readiness wait, and process-tree termination.',
66
+ },
55
67
  'browser-debug': {
56
- tools: ['browser', 'dev_server'],
57
- description: 'Start local development servers and debug web UIs through DOM, console, network, and page sessions.',
68
+ tools: ['browser'],
69
+ description: 'Debug web UIs through DOM, console, network, and page sessions in a real browser.',
58
70
  gateEnv: 'MOCODE_FRONTEND_TOOLS_ENABLED',
59
71
  },
60
72
  'desktop-observe': {
@@ -89,6 +101,26 @@ export const TOOL_ROUTE_GROUPS = {
89
101
  },
90
102
  };
91
103
  export const TOOL_ROUTE_GROUP_NAMES = Object.keys(TOOL_ROUTE_GROUPS);
104
+ /**
105
+ * 簇蕴含关系:选中 key 簇时自动带上 value 里的簇(仍受各自 gateEnv 否决)。
106
+ *
107
+ * 存在的理由是「能力半截」比「能力多余」更贵:browser-debug 只给浏览器,而被调试的页面
108
+ * 得先有人把它跑起来。弱模型只选 browser-debug 时,它要么白付一个 step 去 add_tool_groups
109
+ * 扩容 background-exec,要么退回 run_command 前台起服务(120s 超时被杀 / 拿不到日志)。
110
+ * 蕴含在 policy 层解析,router 的 Examples 仍要求显式列出两者(让强模型学会正确归因)。
111
+ */
112
+ export const TOOL_ROUTE_IMPLICATIONS = {
113
+ 'browser-debug': ['background-exec'],
114
+ };
115
+ /** 展开蕴含:传入簇集合 → 并上其蕴含簇。纯函数、幂等(蕴含不再递归展开第二层)。 */
116
+ export function expandRouteImplications(groups) {
117
+ const out = new Set(groups);
118
+ for (const group of [...out]) {
119
+ for (const implied of TOOL_ROUTE_IMPLICATIONS[group] ?? [])
120
+ out.add(implied);
121
+ }
122
+ return out;
123
+ }
92
124
  /**
93
125
  * 常驻工具簇:每个 turn 无条件激活,不经过 LLM 路由(路由只需在「可用簇 − 常驻簇」里挑)。
94
126
  * 入选标准:高频(coding agent 多数 turn 都要)+ 低暴露成本(工具少、schema 短)+ 漏判代价高
@@ -1,6 +1,9 @@
1
1
  const LEGACY_GREP_RE = /^(.*?):(\d+):(.*)$/;
2
2
  const STRUCTURED_HEADER_RE = /^(.*): (\d+) 处匹配,行号 \[([0-9,\s]+)\]$/;
3
- const STRUCTURED_BODY_RE = /^\s{2}L\d+:/;
3
+ /** body 行:命中 ` L12:` / 上下文 ` L11-`(grep.ts renderBodies 的两种前缀)。 */
4
+ const STRUCTURED_BODY_RE = /^\s{2}L\d+[:-]/;
5
+ /** 不相邻分块分隔符 ` --`(对齐 ripgrep)。Cold 折叠时与 body 一并丢弃。 */
6
+ const STRUCTURED_SEPARATOR_RE = /^\s{2}--$/;
4
7
  const STRUCTURED_FOLDED_RE = /^\s{2}\(body 已折叠/;
5
8
  function isAgedCold(input) {
6
9
  return input.phase === 'sweep' && input.isCold === true && (input.age ?? 0) >= 2;
@@ -18,7 +21,8 @@ function collapseStructuredGrep(output) {
18
21
  out.push(line);
19
22
  continue;
20
23
  }
21
- if (inFile && (STRUCTURED_BODY_RE.test(line) || STRUCTURED_FOLDED_RE.test(line))) {
24
+ if (inFile &&
25
+ (STRUCTURED_BODY_RE.test(line) || STRUCTURED_SEPARATOR_RE.test(line) || STRUCTURED_FOLDED_RE.test(line))) {
22
26
  continue;
23
27
  }
24
28
  inFile = false;
@@ -30,7 +30,7 @@ const RECOVERY_HINT = ' Call computer{screenshot} if you need the current screen
30
30
  * computer-zoom.png —— 每次动作回灌的屏幕帧,会被反复收发,是二次增长的来源。
31
31
  * - ❌ `browser`:`src/tools/builtins/browser.ts:235` 的附件名是 `${sessionId}.png`,**没有稳定前缀**,
32
32
  * 按文档 §2.2 的约定移出白名单(收益小一档但不会误剪)。
33
- * - ❌ `screenshot` / `view_image` / 用户粘贴图:读的是"文档图"或用户意图的直接载体,剪掉是净损失。
33
+ * - ❌ `screenshot` / `read_file`(文档图)/ 用户粘贴图:读的是"文档图"或用户意图的直接载体,剪掉是净损失。
34
34
  *
35
35
  * 新增屏幕帧产出点必须沿用 `computer-` 前缀才会自动进窗口;若前缀不同,请在白名单里显式登记
36
36
  * 并同步更新 design-notes/vision-window.md。
@@ -90,7 +90,7 @@ function frameNames(message) {
90
90
  * 1. `role === 'user'` 且 content 是数组;
91
91
  * 2. 数组里至少有 1 个 `image_url` part;
92
92
  * 3. 前言以工具附件前言开头(从而排除用户粘贴图),且**所有**帧名命中白名单
93
- * (混进 view_image / screenshot / 用户图时不整条淘汰 —— 那些剪掉是净损失)。
93
+ * (混进 read_file 文档图 / screenshot / 用户图时不整条淘汰 —— 那些剪掉是净损失)。
94
94
  *
95
95
  * 已剪过的消息不再含 image_url part → 规则 2 自然失效 → 幂等。
96
96
  * **不靠自定义字段打标**:消息对象会被 OpenAI SDK 原样发出,严格兼容后端可能 400。