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.
- package/README.md +24 -9
- package/README.zh-CN.md +355 -341
- package/dist/agent/model-turn.js +7 -8
- package/dist/agent/run-coordinator.js +4 -0
- package/dist/agent/stages/tool-dispatcher.js +1 -0
- package/dist/agent/tool-turn.js +1 -1
- package/dist/attachments/image.js +119 -2
- package/dist/config/index.js +84 -14
- package/dist/config/profiles.js +42 -10
- package/dist/context/encoders/search.js +6 -2
- package/dist/context/vision-window.js +2 -2
- package/dist/i18n/index.js +30 -14
- package/dist/repl/commands/image.js +7 -2
- package/dist/repl/commands/router.js +13 -1
- package/dist/repl/commands/system.js +29 -9
- package/dist/repl/commands/tool-group.js +1 -1
- package/dist/repl/commands.js +2 -0
- package/dist/runtime/dev-server-manager.js +4 -2
- package/dist/runtime/shell.js +153 -0
- package/dist/skills/builtin-skills.js +1 -1
- package/dist/tools/builtins/ask-human.js +2 -3
- package/dist/tools/builtins/dev-server.js +23 -6
- package/dist/tools/builtins/edit-file.js +5 -13
- package/dist/tools/builtins/glob.js +2 -2
- package/dist/tools/builtins/grep.js +93 -26
- package/dist/tools/builtins/index.js +6 -6
- package/dist/tools/builtins/note-append.js +4 -8
- package/dist/tools/builtins/plan-update.js +1 -6
- package/dist/tools/builtins/read-file.js +135 -18
- package/dist/tools/builtins/run-command.js +60 -11
- package/dist/tools/builtins/screenshot.js +17 -41
- package/dist/tools/builtins/use-skill.js +2 -2
- package/dist/tools/builtins/web-fetch.js +148 -35
- package/dist/tools/builtins/web-search.js +1 -2
- package/dist/tools/builtins/write-file.js +102 -7
- package/dist/tools/constants.js +7 -0
- package/dist/tools/policy.js +26 -7
- package/dist/tools/router.js +20 -4
- package/dist/tools/tool-runtime.js +63 -1
- package/dist/ui/render.js +20 -2
- package/package.json +1 -1
|
@@ -1,19 +1,68 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import fg from 'fast-glob';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
3
|
+
import { IGNORE, MAX_RESULTS } from '../constants.js';
|
|
4
|
+
import { isProbablyBinary } from '../../attachments/image.js';
|
|
5
|
+
// 二进制文件探测统一走 attachments/image.ts 的 isProbablyBinary(与 read_file 同源同口径,
|
|
6
|
+
// 避免两处正则漂移):头部含 C0 控制字符即跳过。否则 grep 扫到 SQLite/压缩文件等会产出
|
|
7
|
+
// 「单行数 KB + 控制字符」的匹配行,这类行进 TUI 展开后被终端 auto-wrap,物理行与缓冲行
|
|
8
|
+
// 失配导致整屏错乱。
|
|
8
9
|
import { getSandboxRoot, isInsideRoot, jailResolve } from '../../sandbox/index.js';
|
|
10
|
+
/** 单文件读取上限:超过即跳过(压缩产物 / 生成物 / 数据 dump)。
|
|
11
|
+
* 逐文件全量 readFile 进内存是 grep 的固有成本,不设上限时一个 200MB 的 bundle
|
|
12
|
+
* 就能把整轮 grep 拖死并撑爆内存。跳过的文件数会在结果尾部报出来,不静默吞。 */
|
|
13
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
/** 单条 body 行渲染上限(字符):minified JS / 单行 JSON 截断加 …,
|
|
15
|
+
* 既防 TUI auto-wrap 错乱,也防一行吃掉整个 MAX_RESULTS 预算。 */
|
|
16
|
+
const MAX_BODY_LINE_CHARS = 400;
|
|
17
|
+
/** context 参数上限:邻居行是 ×(1+2C) 放大,给到 10 已远超「看清一段逻辑」的需要。 */
|
|
18
|
+
const MAX_CONTEXT_LINES = 10;
|
|
19
|
+
/** 超长行截断(保留头部:grep 的价值在行首标识与缩进)。 */
|
|
20
|
+
function clipLine(line) {
|
|
21
|
+
if (line.length <= MAX_BODY_LINE_CHARS)
|
|
22
|
+
return line;
|
|
23
|
+
return `${line.slice(0, MAX_BODY_LINE_CHARS)}…(+${line.length - MAX_BODY_LINE_CHARS} 字符)`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 把命中行号 + context 半径渲染成 body 行。
|
|
27
|
+
*
|
|
28
|
+
* 输出格式契约(context/encoders/search.ts 依赖前两个前缀做 Cold 折叠):
|
|
29
|
+
* ` L<n>: <原文>` 命中行(冒号)
|
|
30
|
+
* ` L<n>- <原文>` 上下文行(连字符,对齐 ripgrep 的 `:` / `-`)
|
|
31
|
+
* ` --` 不相邻分块之间的分隔(对齐 ripgrep)
|
|
32
|
+
* 原文**不 trim**:缩进是代码结构信息,trim 掉后模型看不出嵌套层级,只能再发一次
|
|
33
|
+
* read_file 去确认——那正是 context 参数要消灭的往返。
|
|
34
|
+
*/
|
|
35
|
+
function renderBodies(lines, lineNos, maxPerFile, context) {
|
|
36
|
+
const shown = lineNos.slice(0, maxPerFile);
|
|
37
|
+
if (shown.length === 0)
|
|
38
|
+
return [];
|
|
39
|
+
const matched = new Set(shown);
|
|
40
|
+
// 命中行 ± context 的并集,排序后按连续块渲染。
|
|
41
|
+
const wanted = new Set();
|
|
42
|
+
for (const n of shown) {
|
|
43
|
+
for (let i = n - context; i <= n + context; i++) {
|
|
44
|
+
if (i >= 1 && i <= lines.length)
|
|
45
|
+
wanted.add(i);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const out = [];
|
|
49
|
+
let previous = 0;
|
|
50
|
+
for (const n of [...wanted].sort((a, b) => a - b)) {
|
|
51
|
+
if (previous !== 0 && n !== previous + 1)
|
|
52
|
+
out.push(' --');
|
|
53
|
+
previous = n;
|
|
54
|
+
out.push(` L${n}${matched.has(n) ? ':' : '-'} ${clipLine(lines[n - 1])}`);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
9
58
|
// ---------- grep ----------
|
|
10
59
|
export const grepTool = {
|
|
11
60
|
name: 'grep',
|
|
12
|
-
description: 'Search file contents by regex (recursive, excludes node_modules/.git).\n' +
|
|
13
|
-
'Output: per-file header "<path>: N matches, lines [l1, l2, ...]" +
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
61
|
+
description: 'Search file contents by regex (recursive, excludes node_modules/.git/dist).\n' +
|
|
62
|
+
'Output: per-file header "<path>: N matches, lines [l1, l2, ...]" + matched lines with ORIGINAL INDENTATION kept.\n' +
|
|
63
|
+
'Pass context=2..5 to get neighbouring lines inline (like ripgrep -C) — use it INSTEAD of following every hit with a read_file round-trip.\n' +
|
|
64
|
+
'Still use read_file(offset=X, limit=Y) for a whole region or exact edit text — never reconstruct an edit_file old_string from grep output (long lines are clipped). ' +
|
|
65
|
+
'For call chains across many files, prefer the codegraph skill.',
|
|
17
66
|
parameters: {
|
|
18
67
|
type: 'object',
|
|
19
68
|
properties: {
|
|
@@ -21,7 +70,12 @@ export const grepTool = {
|
|
|
21
70
|
glob: { type: 'string', description: 'Optional, restrict to a file glob, e.g. *.ts' },
|
|
22
71
|
max_per_file: {
|
|
23
72
|
type: 'integer',
|
|
24
|
-
description: 'Max
|
|
73
|
+
description: 'Max MATCHED lines rendered per file (default 15, cap 50); their context lines are extra. Line-number list is always full.',
|
|
74
|
+
},
|
|
75
|
+
context: {
|
|
76
|
+
type: 'integer',
|
|
77
|
+
description: 'Neighbouring lines to include around each match, like ripgrep -C (default 0, max 10). ' +
|
|
78
|
+
'Output grows ~x(1+2*context) and counts against the same result budget, so keep it small.',
|
|
25
79
|
},
|
|
26
80
|
},
|
|
27
81
|
required: ['pattern'],
|
|
@@ -30,6 +84,8 @@ export const grepTool = {
|
|
|
30
84
|
const pattern = String(args.pattern);
|
|
31
85
|
const g = String(args.glob ?? '**/*');
|
|
32
86
|
const maxPerFile = Math.min(Math.max(Number(args.max_per_file ?? 15), 1), 50);
|
|
87
|
+
const contextRaw = Number(args.context ?? 0);
|
|
88
|
+
const context = Number.isFinite(contextRaw) ? Math.min(Math.max(Math.trunc(contextRaw), 0), MAX_CONTEXT_LINES) : 0;
|
|
33
89
|
let re;
|
|
34
90
|
try {
|
|
35
91
|
re = new RegExp(pattern);
|
|
@@ -38,18 +94,27 @@ export const grepTool = {
|
|
|
38
94
|
return `错误:非法正则 ${pattern}: ${e instanceof Error ? e.message : String(e)}`;
|
|
39
95
|
}
|
|
40
96
|
const cwd = getSandboxRoot() ?? process.cwd();
|
|
41
|
-
|
|
97
|
+
// stats:true 让 fast-glob 顺手带回 size,免为体积闸门再付一次 stat 系统调用。
|
|
98
|
+
const entries = (await fg(g, {
|
|
42
99
|
cwd,
|
|
43
100
|
onlyFiles: true,
|
|
44
101
|
dot: true,
|
|
45
102
|
ignore: IGNORE,
|
|
103
|
+
stats: true,
|
|
46
104
|
followSymbolicLinks: false, // 不跟随软链目录,防经软链扫到牢外文件
|
|
47
105
|
throwErrorOnBrokenSymbolicLink: false,
|
|
48
|
-
})).filter((
|
|
106
|
+
})).filter((entry) => isInsideRoot(entry.path)); // 后置兜底:仅留牢内
|
|
49
107
|
const hits = [];
|
|
50
108
|
let scanned = 0;
|
|
109
|
+
let skippedTooLarge = 0;
|
|
51
110
|
let truncated = false;
|
|
52
|
-
for (const
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const f = entry.path;
|
|
113
|
+
// 体积闸门:压缩产物 / 数据 dump 读进来只会撑爆内存并产出无意义超长行。
|
|
114
|
+
if (entry.stats && entry.stats.size > MAX_FILE_BYTES) {
|
|
115
|
+
skippedTooLarge++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
53
118
|
let content;
|
|
54
119
|
try {
|
|
55
120
|
// jailResolve:realpath 化,防「牢内文件软链→牢外」的内容泄露;越界/不可读均 catch 跳过
|
|
@@ -61,7 +126,7 @@ export const grepTool = {
|
|
|
61
126
|
scanned++;
|
|
62
127
|
// 二进制文件(如 .codegraph/codegraph.db 这类 SQLite)跳过:其「行」是数 KB 的
|
|
63
128
|
// 序列化记录 + 控制字符,匹配行对 LLM 无意义,还会污染 TUI 展开渲染。
|
|
64
|
-
if (
|
|
129
|
+
if (isProbablyBinary(content))
|
|
65
130
|
continue;
|
|
66
131
|
const lines = content.split(/\r?\n/);
|
|
67
132
|
const lineNos = [];
|
|
@@ -71,8 +136,10 @@ export const grepTool = {
|
|
|
71
136
|
}
|
|
72
137
|
if (lineNos.length === 0)
|
|
73
138
|
continue;
|
|
74
|
-
// 全局配额:行号列表总是计入,body
|
|
75
|
-
|
|
139
|
+
// 全局配额:行号列表总是计入,body 成本按「命中行数 ×(1+2×context)」计入 ——
|
|
140
|
+
// context 是乘法放大,不按实际渲染量计就会让带 ±5 邻居的 grep 静默撑爆预算。
|
|
141
|
+
const bodyCost = Math.min(lineNos.length, maxPerFile) * (1 + 2 * context);
|
|
142
|
+
const totalCost = lineNos.length + bodyCost;
|
|
76
143
|
if (hits.length >= MAX_RESULTS || totalCost > MAX_RESULTS * 4) {
|
|
77
144
|
// 文件过多 / 配额爆:仅追加该文件行号列表,不再展开 body
|
|
78
145
|
if (hits.length < MAX_RESULTS) {
|
|
@@ -81,19 +148,17 @@ export const grepTool = {
|
|
|
81
148
|
truncated = true;
|
|
82
149
|
continue;
|
|
83
150
|
}
|
|
84
|
-
|
|
85
|
-
const trimmed = lines[n - 1].trim();
|
|
86
|
-
return ` L${n}: ${trimmed}`;
|
|
87
|
-
});
|
|
88
|
-
hits.push({ path: f, lineNos, bodies });
|
|
151
|
+
hits.push({ path: f, lineNos, bodies: renderBodies(lines, lineNos, maxPerFile, context) });
|
|
89
152
|
}
|
|
153
|
+
const skippedNote = skippedTooLarge
|
|
154
|
+
? `,跳过 ${skippedTooLarge} 个超过 ${MAX_FILE_BYTES / 1024 / 1024}MiB 的文件`
|
|
155
|
+
: '';
|
|
90
156
|
if (hits.length === 0)
|
|
91
|
-
return `无匹配(扫描了 ${scanned}
|
|
157
|
+
return `无匹配(扫描了 ${scanned} 个文件${skippedNote})`;
|
|
92
158
|
const out = [];
|
|
93
159
|
let totalShown = 0;
|
|
94
160
|
for (const h of hits) {
|
|
95
|
-
|
|
96
|
-
out.push(header);
|
|
161
|
+
out.push(`${h.path}: ${h.lineNos.length} 处匹配,行号 [${h.lineNos.join(', ')}]`);
|
|
97
162
|
if (h.bodies.length > 0) {
|
|
98
163
|
out.push(...h.bodies);
|
|
99
164
|
}
|
|
@@ -102,12 +167,14 @@ export const grepTool = {
|
|
|
102
167
|
}
|
|
103
168
|
totalShown += h.lineNos.length + h.bodies.length;
|
|
104
169
|
if (totalShown >= MAX_RESULTS) {
|
|
105
|
-
out.push(`...(结果达到 ${MAX_RESULTS}
|
|
170
|
+
out.push(`...(结果达到 ${MAX_RESULTS} 条上限${context > 0 ? ',context 行同样计入;要更全就减小 context 或收窄 glob' : ''})`);
|
|
106
171
|
break;
|
|
107
172
|
}
|
|
108
173
|
}
|
|
109
174
|
if (truncated)
|
|
110
175
|
out.push(`...(仍有更多匹配文件未展示,缩小 glob 或收窄正则)`);
|
|
176
|
+
if (skippedTooLarge)
|
|
177
|
+
out.push(`...(跳过 ${skippedTooLarge} 个超过 ${MAX_FILE_BYTES / 1024 / 1024}MiB 的文件)`);
|
|
111
178
|
return out.join('\n');
|
|
112
179
|
},
|
|
113
180
|
};
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { installBuiltinTools } from '../registry.js';
|
|
2
2
|
import { readFileTool } from './read-file.js';
|
|
3
|
-
import { viewImageTool } from './view-image.js';
|
|
4
3
|
import { screenshotTool } from './screenshot.js';
|
|
5
4
|
import { writeFileTool } from './write-file.js';
|
|
6
5
|
import { editFileTool } from './edit-file.js';
|
|
@@ -35,9 +34,9 @@ import { computerTool } from './computer.js';
|
|
|
35
34
|
const pathResource = (args) => typeof args.path === 'string' && args.path ? [`file:${args.path}`] : ['workspace'];
|
|
36
35
|
const workspaceResource = () => ['workspace'];
|
|
37
36
|
const memoryResource = () => ['memory-store'];
|
|
38
|
-
|
|
37
|
+
/** 导出供审计测试:能力声明是单一事实源,测试直接断言这张表(而非重新枚举一遍工具名)。 */
|
|
38
|
+
export const CAPABILITIES = {
|
|
39
39
|
read_file: { effect: 'read', concurrency: 'parallel', resources: pathResource },
|
|
40
|
-
view_image: { effect: 'read', concurrency: 'parallel', resources: pathResource },
|
|
41
40
|
screenshot: { effect: 'process', concurrency: 'serial', resources: workspaceResource, supportsAbort: true },
|
|
42
41
|
write_file: {
|
|
43
42
|
effect: 'write',
|
|
@@ -52,8 +51,10 @@ const CAPABILITIES = {
|
|
|
52
51
|
browser: { effect: 'process', concurrency: 'serial', resources: workspaceResource },
|
|
53
52
|
glob: { effect: 'read', concurrency: 'parallel', resources: workspaceResource },
|
|
54
53
|
grep: { effect: 'read', concurrency: 'parallel', resources: workspaceResource },
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
// 网络只读查询:无副作用,可安全重复执行 → idempotent 让 runtime 对瞬时失败(429/5xx/超时/
|
|
55
|
+
// 网络抖动)自动退避重试,省掉模型「再发一轮 tool call 自救」的往返。写/进程类工具绝不置此位。
|
|
56
|
+
web_search: { effect: 'network', concurrency: 'parallel', supportsAbort: true, idempotent: true },
|
|
57
|
+
web_fetch: { effect: 'network', concurrency: 'parallel', supportsAbort: true, idempotent: true },
|
|
57
58
|
use_skill: { effect: 'read', concurrency: 'serial' },
|
|
58
59
|
run_skill: { effect: 'process', concurrency: 'serial', delegatesResourceLocks: true, supportsAbort: true },
|
|
59
60
|
ask_human: { effect: 'read', concurrency: 'serial' },
|
|
@@ -87,7 +88,6 @@ const CAPABILITIES = {
|
|
|
87
88
|
};
|
|
88
89
|
const rawBuiltinTools = [
|
|
89
90
|
readFileTool,
|
|
90
|
-
viewImageTool,
|
|
91
91
|
screenshotTool,
|
|
92
92
|
writeFileTool,
|
|
93
93
|
editFileTool,
|
|
@@ -36,14 +36,10 @@ function normalizeSection(raw) {
|
|
|
36
36
|
}
|
|
37
37
|
export const noteAppendTool = {
|
|
38
38
|
name: 'note_append',
|
|
39
|
-
description: 'Append
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
43
|
-
'(that is the plan via `plan_update`) or for stable cross-session facts (that is `memory_save`). ' +
|
|
44
|
-
'Notes you write here persist across compaction within this session and are re-injected into the prompt ' +
|
|
45
|
-
'automatically, so the agent keeps remembering what it found/decided. Call it the moment you make the ' +
|
|
46
|
-
'discovery or decision — do not batch to the end.',
|
|
39
|
+
description: 'Append ONE decision-grade note (finding / decision / open question / risk) to the session notepad (notes.md); it survives compaction and is re-injected into the prompt automatically. ' +
|
|
40
|
+
'Only NON-OBVIOUS, lasting-value discoveries: subtle constraints, decisions with downstream impact, open questions blocking a choice, risks affecting later steps. ' +
|
|
41
|
+
'NOT routine progress (that is plan_update) and NOT stable cross-session facts (that is memory_save). ' +
|
|
42
|
+
'Call the moment you make the discovery or decision — do not batch to the end. One item per call.',
|
|
47
43
|
risk: 'safe',
|
|
48
44
|
parameters: {
|
|
49
45
|
type: 'object',
|
|
@@ -18,12 +18,7 @@ function normalizeStatus(raw) {
|
|
|
18
18
|
}
|
|
19
19
|
export const planUpdateTool = {
|
|
20
20
|
name: 'plan_update',
|
|
21
|
-
description: 'Record and update the session execution plan (the `## Plan:` block in
|
|
22
|
-
'Use for any task with 3+ steps or context-loss risk. This REPLACES the whole plan each call, so always pass the full steps array. ' +
|
|
23
|
-
'Rules: at most one step may be in_progress; mark a step completed as soon as its work is done — do not batch updates to end of turn. ' +
|
|
24
|
-
'Give every step a short `title` (≤20 chars, e.g. "编写测试" / "修 status bar") that shows in the status bar, ' +
|
|
25
|
-
'plus a `content` that is self-contained enough to survive context compaction: name the target file/symbol, the change, and how to verify. ' +
|
|
26
|
-
'When every step is completed the plan auto-settles to `## Done:`. Creates notes.md if missing. Safe to call in PLAN mode (writes only the session notepad, never project files).',
|
|
21
|
+
description: 'Record and update the session execution plan (the `## Plan:` block in notes.md). Use for any task with 3+ steps or context-loss risk. REPLACES the whole plan each call — always pass the full steps array. At most one step in_progress; mark a step completed as soon as its work is done, not batched to end of turn. Each step: short `title` (≤20 chars, shown in the status bar) + `content` self-contained enough to survive compaction (target file/symbol, change, verification). All steps completed → auto-settles to `## Done:`. Creates notes.md if missing. Safe in PLAN mode (writes only the session notepad).',
|
|
27
22
|
risk: 'safe',
|
|
28
23
|
parameters: {
|
|
29
24
|
type: 'object',
|
|
@@ -1,31 +1,59 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { contentHash } from '../../changeset/index.js';
|
|
4
|
-
import { MAX_FILE_LINES } from '../constants.js';
|
|
4
|
+
import { IMAGE_READ_MARKER, MAX_FILE_LINES } from '../constants.js';
|
|
5
|
+
import { MAX_INLINE_BYTES_DEFAULT, isProbablyBinary, loadImageAttachmentWithDownscale, sniffImageMime, } from '../../attachments/image.js';
|
|
5
6
|
/** 默认单次 read_file 拉取的行数。刻意压低,逼 LLM 分块读大文件,
|
|
6
7
|
* 配合 description 中的 PAGINATION IS MANDATORY 引导。
|
|
7
8
|
* 300 行 ≈ 一个屏幕的源码量,够定位一段逻辑而不至于吃光上下文。 */
|
|
8
9
|
const DEFAULT_READ_LIMIT = 300;
|
|
10
|
+
/** 魔数嗅探只需文件头:16KB 足够覆盖 WebP(12 字节)/PNG(8)/GIF(6)/JPEG(SOF 位置不定)
|
|
11
|
+
* 与二进制探测的 4KB 控制字符窗口。 */
|
|
12
|
+
const SNIFF_BYTES = 16 * 1024;
|
|
13
|
+
/** 再导出单一事实源(tools/constants.ts):ui/render.ts 与测试都按此判定图片分支摘要。 */
|
|
14
|
+
export { IMAGE_READ_MARKER };
|
|
15
|
+
/**
|
|
16
|
+
* 单文件体积上限(32 MiB)。
|
|
17
|
+
*
|
|
18
|
+
* 行号分页的实现是「整文件载入内存再按行切片」,所以体积直接等于内存占用:一个 2GB 的
|
|
19
|
+
* 数据 dump / 压缩包能把进程顶死,而模型其实只需要其中一段。上限取得足够高,只拦真正
|
|
20
|
+
* 病态的输入;拦下时明确指路(grep 定位 + run_command 按段取),不给模型留猜谜空间。
|
|
21
|
+
*/
|
|
22
|
+
const MAX_FILE_BYTES = 32 * 1024 * 1024;
|
|
23
|
+
function formatBytes(n) {
|
|
24
|
+
if (n < 1024)
|
|
25
|
+
return `${n} B`;
|
|
26
|
+
if (n < 1024 * 1024)
|
|
27
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
28
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
29
|
+
}
|
|
30
|
+
/** 结构化失败:status=error 让上层不当成「成功读取」;output 以 `错误:` 开头对齐既有约定
|
|
31
|
+
* (context/utils.isToolResultSuccess 靠该前缀判定,artifacts/relevance 据此排除失败结果)。 */
|
|
32
|
+
function failure(code, message) {
|
|
33
|
+
return { status: 'error', code, retryable: false, output: `错误:${message}` };
|
|
34
|
+
}
|
|
9
35
|
// ---------- read_file ----------
|
|
10
36
|
export const readFileTool = {
|
|
11
37
|
name: 'read_file',
|
|
12
|
-
description: 'Read file
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'For files ≤500 lines you may read the whole file in one call. Independent region reads ' +
|
|
19
|
-
'may be issued in the same response — they run concurrently, saving a round-trip each.\n' +
|
|
20
|
-
'For architecture or call-chain questions, prefer loading the `codegraph` skill (use_skill) over reading files one at a time.',
|
|
38
|
+
description: 'Read a file: text with line numbers, images as visual model input.\n' +
|
|
39
|
+
'Text — read before editing. Files >500 lines: grep first, then read_file with offset+limit (e.g. offset=350, limit=120); never read a whole large file in one call. ' +
|
|
40
|
+
'Need several regions of the SAME file? Issue those read_file calls together in one response (they run concurrently) instead of sequential offset+=limit walks. ' +
|
|
41
|
+
'Images — PNG/JPEG/GIF/WebP detected by MAGIC BYTES (extension ignored), attached as visual input; detail=low|high controls resolution, oversized PNGs downscale automatically. ' +
|
|
42
|
+
'Other binaries are REJECTED with an explanation — use run_command with a proper tool (`file`, `strings`, disassembler) if you need their content. ' +
|
|
43
|
+
'Architecture/call-chain questions: prefer the codegraph skill over reading files one at a time.',
|
|
21
44
|
parameters: {
|
|
22
45
|
type: 'object',
|
|
23
46
|
properties: {
|
|
24
47
|
path: { type: 'string', description: 'File path, relative to the working directory' },
|
|
25
|
-
offset: { type: 'integer', description: 'Start line, 1-based (default 1).' },
|
|
48
|
+
offset: { type: 'integer', description: 'Start line, 1-based (default 1). Text files only; ignored for images.' },
|
|
26
49
|
limit: {
|
|
27
50
|
type: 'integer',
|
|
28
|
-
description: 'Max lines to read (default 300, hard cap 2000). Keep ranges modest (e.g. 80-300); for files ≤500 lines you may read the whole file in one call.',
|
|
51
|
+
description: 'Max lines to read (default 300, hard cap 2000). Keep ranges modest (e.g. 80-300); for files ≤500 lines you may read the whole file in one call. Text only; ignored for images.',
|
|
52
|
+
},
|
|
53
|
+
detail: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
enum: ['auto', 'low', 'high'],
|
|
56
|
+
description: 'Vision detail level when the path is an image (default: auto). Ignored for text.',
|
|
29
57
|
},
|
|
30
58
|
},
|
|
31
59
|
required: ['path'],
|
|
@@ -35,7 +63,55 @@ export const readFileTool = {
|
|
|
35
63
|
const offset = Number(args.offset ?? 1);
|
|
36
64
|
// 无论 LLM 传多大,单次硬钳到 MAX_FILE_LINES,杜绝「绕过分页引导一把全拿」。
|
|
37
65
|
const limit = Math.min(Number(args.limit ?? DEFAULT_READ_LIMIT), MAX_FILE_LINES);
|
|
38
|
-
|
|
66
|
+
// enforceSandbox 已把 args.path 重写为牢内绝对路径(sandbox/policy.ts SANDBOX_PATH_TOOLS),
|
|
67
|
+
// resolve 对绝对路径原样返回。
|
|
68
|
+
const absolute = resolve(path);
|
|
69
|
+
// ── 1. stat:目录 / 不存在 / 超大文件在这里分流,不把字节读进来再说 ────────
|
|
70
|
+
let info;
|
|
71
|
+
try {
|
|
72
|
+
info = await stat(absolute);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
const e = error;
|
|
76
|
+
if (e?.code === 'ENOENT')
|
|
77
|
+
return failure('EXECUTION_ERROR', `文件不存在: ${path}`);
|
|
78
|
+
return failure('EXECUTION_ERROR', `无法访问文件 ${path}: ${e?.message ?? String(error)}`);
|
|
79
|
+
}
|
|
80
|
+
if (info.isDirectory()) {
|
|
81
|
+
return failure('INVALID_ARGUMENTS', `${path} 是目录,不是文件。用 glob(pattern="${path}/**") 列目录内容,或用 grep 在目录下搜内容。`);
|
|
82
|
+
}
|
|
83
|
+
if (!info.isFile()) {
|
|
84
|
+
return failure('INVALID_ARGUMENTS', `${path} 不是普通文件(设备/套接字/特殊文件),无法读取。`);
|
|
85
|
+
}
|
|
86
|
+
if (info.size > MAX_FILE_BYTES) {
|
|
87
|
+
return failure('INVALID_ARGUMENTS', `${path} 有 ${formatBytes(info.size)},超过 read_file 的 ${formatBytes(MAX_FILE_BYTES)} 上限` +
|
|
88
|
+
'(行号分页需整文件载入内存)。先用 grep 定位行号,再用 run_command 配合系统工具按段取。');
|
|
89
|
+
}
|
|
90
|
+
let buffer;
|
|
91
|
+
try {
|
|
92
|
+
buffer = await readFile(absolute);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
const e = error;
|
|
96
|
+
return failure(e?.code === 'EACCES' || e?.code === 'EPERM' ? 'SANDBOX_DENIED' : 'EXECUTION_ERROR', `读取 ${path} 失败: ${e?.message ?? String(error)}`);
|
|
97
|
+
}
|
|
98
|
+
// ── 2. 魔数嗅探:图片走视觉通道,其余二进制明确拒绝 ──────────────────────
|
|
99
|
+
// 为什么必须嗅探而不看扩展名:扩展名会说谎(截图缓存 / 构建产物常无扩展名或错扩展名),
|
|
100
|
+
// 而把二进制当 UTF-8 解码的代价是实测一张 42KB PNG 产出 17862 个 U+FFFD(占 45%),
|
|
101
|
+
// 经 history 上限中截后仍有数千字符纯乱码进上下文 —— 既烧 token 又误导模型。
|
|
102
|
+
const head = buffer.subarray(0, SNIFF_BYTES);
|
|
103
|
+
const sniffed = sniffImageMime(head);
|
|
104
|
+
if (sniffed)
|
|
105
|
+
return await readAsImage(path, sniffed, args.detail, info.size);
|
|
106
|
+
if (isProbablyBinary(head)) {
|
|
107
|
+
return failure('INVALID_ARGUMENTS', `${path} 是二进制文件(头部含控制字符),不能按文本读取 —— 强行解码只会得到乱码。` +
|
|
108
|
+
'若它其实是图片,支持的格式为 png/jpg/jpeg/gif/webp;否则用 run_command 配合专用工具' +
|
|
109
|
+
'(如 file / strings / 反汇编器)查看。');
|
|
110
|
+
}
|
|
111
|
+
// ── 3. 文本:沿用既有 artifact header + 行号分页 ────────────────────────
|
|
112
|
+
// hash 仍取「UTF-8 解码后字符串」的摘要(与改动前逐字节一致):会话里已持久化的
|
|
113
|
+
// expected_hash 与 context/artifacts 的 parseReadHash 都依赖这个口径,不能顺手换。
|
|
114
|
+
const data = buffer.toString('utf8');
|
|
39
115
|
const artifactHeader = `[artifact source=read_file path=${path} hash=${contentHash(data)}]`;
|
|
40
116
|
const lines = data.split(/\r?\n/);
|
|
41
117
|
const start = Math.max(0, offset - 1);
|
|
@@ -44,9 +120,50 @@ export const readFileTool = {
|
|
|
44
120
|
.slice(start, end)
|
|
45
121
|
.map((l, i) => `${String(start + i + 1).padStart(6, ' ')}\t${l}`)
|
|
46
122
|
.join('\n');
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
123
|
+
// 空文件(0 字节)显式报「(空文件)」:''.split 得到 [''],行号分页会渲染成 ` 1\t`,
|
|
124
|
+
// 让模型误以为读到了带一行空白的文件。data.length===0 才是真·空的判据。
|
|
125
|
+
if (data.length === 0)
|
|
126
|
+
return { status: 'success', code: 'OK', retryable: false, output: `${artifactHeader}\n(空文件)` };
|
|
127
|
+
const output = end < lines.length
|
|
128
|
+
? `${artifactHeader}\n${body}\n\n... (${lines.length - end} 行未显示,共 ${lines.length} 行)`
|
|
129
|
+
: `${artifactHeader}\n${body}`;
|
|
130
|
+
return { status: 'success', code: 'OK', retryable: false, output };
|
|
51
131
|
},
|
|
52
132
|
};
|
|
133
|
+
/**
|
|
134
|
+
* 图片分支:走视觉通道(modelAttachments),文本 output 只留一句可读摘要。
|
|
135
|
+
*
|
|
136
|
+
* 复用 attachments/image.ts 的加载 + 超限 PNG 降采样逻辑,与 screenshot 同口径
|
|
137
|
+
* (4 MiB 内联上限、detail 语义、越界拒绝)。offset/limit 对图片无意义,显式说明而非静默忽略。
|
|
138
|
+
*/
|
|
139
|
+
async function readAsImage(path, mime, detailArg, bytes) {
|
|
140
|
+
const loaded = await loadImageAttachmentWithDownscale(path, {
|
|
141
|
+
maxBytes: MAX_INLINE_BYTES_DEFAULT,
|
|
142
|
+
sniffedMime: mime,
|
|
143
|
+
});
|
|
144
|
+
if (!loaded.ok) {
|
|
145
|
+
return failure(loaded.reason.startsWith('outside sandbox') ? 'SANDBOX_DENIED' : 'EXECUTION_ERROR', `无法把图片 ${path} 作为视觉输入加载: ${loaded.reason}`);
|
|
146
|
+
}
|
|
147
|
+
const detail = detailArg === 'low' || detailArg === 'high' ? detailArg : 'auto';
|
|
148
|
+
const { att, downscaledFrom } = loaded;
|
|
149
|
+
const resizedNote = downscaledFrom
|
|
150
|
+
? ` Original ${downscaledFrom.width}×${downscaledFrom.height} exceeded the inline limit; the attached copy was downscaled.`
|
|
151
|
+
: '';
|
|
152
|
+
return {
|
|
153
|
+
status: 'success',
|
|
154
|
+
code: 'OK',
|
|
155
|
+
retryable: false,
|
|
156
|
+
output: `${IMAGE_READ_MARKER} Detected an image by magic bytes (${mime}, ${formatBytes(bytes)} on disk) and attached it ` +
|
|
157
|
+
`as visual model input.${resizedNote} Attached ${att.bytes} bytes. ` +
|
|
158
|
+
'offset/limit do not apply to images; pass detail=low to save tokens on the next one.',
|
|
159
|
+
modelAttachments: [
|
|
160
|
+
{
|
|
161
|
+
type: 'image',
|
|
162
|
+
name: att.name,
|
|
163
|
+
mime: att.mime,
|
|
164
|
+
dataUrl: att.dataUrl,
|
|
165
|
+
detail,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -2,8 +2,28 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { MAX_OUTPUT } from '../constants.js';
|
|
3
3
|
import { getSandboxRoot, filterEnv, isCommandDenied, jailResolve } from '../../sandbox/index.js';
|
|
4
4
|
import { t } from '../../i18n/index.js';
|
|
5
|
+
import { shellParamDescription, defaultShellKind, parseShellKind, shellSpawnSpec, } from '../../runtime/shell.js';
|
|
5
6
|
const OUTPUT_HEAD_LIMIT = Math.floor(MAX_OUTPUT * 0.4);
|
|
6
7
|
const OUTPUT_TAIL_LIMIT = MAX_OUTPUT - OUTPUT_HEAD_LIMIT;
|
|
8
|
+
/**
|
|
9
|
+
* 前台命令的超时窗口钳制。
|
|
10
|
+
*
|
|
11
|
+
* 下界 1s:防模型传 0/负数把 timer 变成「立即超时」,命令还没 spawn 就被判 timed_out。
|
|
12
|
+
* 上界 10min:run_command 声明 concurrency:'serial' + resources:['workspace']
|
|
13
|
+
* (builtins/index.ts:54),执行期间持有全局 workspace 锁 —— 一条超时 1 小时的命令会把
|
|
14
|
+
* 所有其它工具调用(含子 agent)一起挂死,且 TUI 只能等或 Ctrl+C。需要长驻的进程走
|
|
15
|
+
* dev_server(跨调用存活 + 日志增量读 + 树杀),不是把前台超时拉长。
|
|
16
|
+
*/
|
|
17
|
+
export const MIN_COMMAND_TIMEOUT_MS = 1_000;
|
|
18
|
+
export const MAX_COMMAND_TIMEOUT_MS = 600_000;
|
|
19
|
+
export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
20
|
+
/** 把模型给的 timeout 钳进 [MIN, MAX];非有限数(NaN/Infinity/非法字符串)回落默认值。 */
|
|
21
|
+
export function clampCommandTimeout(raw) {
|
|
22
|
+
const value = Number(raw);
|
|
23
|
+
if (!Number.isFinite(value))
|
|
24
|
+
return DEFAULT_COMMAND_TIMEOUT_MS;
|
|
25
|
+
return Math.min(Math.max(Math.trunc(value), MIN_COMMAND_TIMEOUT_MS), MAX_COMMAND_TIMEOUT_MS);
|
|
26
|
+
}
|
|
7
27
|
/** 有界采集:短输出逐字保留;超限后保留 head+tail,避免构建/测试错误只出现在尾部时被丢弃。 */
|
|
8
28
|
class BoundedCommandOutput {
|
|
9
29
|
head = '';
|
|
@@ -26,8 +46,10 @@ class BoundedCommandOutput {
|
|
|
26
46
|
}
|
|
27
47
|
}
|
|
28
48
|
/** Execute a command with the same sandbox, output cap and cancellation semantics as run_command. */
|
|
29
|
-
export async function runCommandRaw(command, timeout =
|
|
49
|
+
export async function runCommandRaw(command, timeout = DEFAULT_COMMAND_TIMEOUT_MS, signal, cwd, shell) {
|
|
30
50
|
const startedAt = Date.now();
|
|
51
|
+
// 内部调用方(skill 注入等)也走同一钳制:防止任何路径把前台命令挂成无限期持锁。
|
|
52
|
+
const effectiveTimeout = clampCommandTimeout(timeout);
|
|
31
53
|
const deny = isCommandDenied(command);
|
|
32
54
|
if (deny) {
|
|
33
55
|
return { status: 'denied', exitCode: null, output: `错误:${deny}`, durationMs: 0 };
|
|
@@ -44,12 +66,13 @@ export async function runCommandRaw(command, timeout = 120000, signal, cwd) {
|
|
|
44
66
|
}
|
|
45
67
|
return new Promise((done) => {
|
|
46
68
|
const isWin = process.platform === 'win32';
|
|
47
|
-
const
|
|
69
|
+
const spec = shellSpawnSpec(shell ?? defaultShellKind(), command);
|
|
70
|
+
const child = spawn(spec.file, spec.args, {
|
|
48
71
|
cwd: executionCwd,
|
|
49
72
|
env: filterEnv(process.env),
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
windowsVerbatimArguments:
|
|
73
|
+
// cmd.exe 需要 verbatim:否则 Node 重新引号化参数,`node -e "..."` 会退化成
|
|
74
|
+
// 字符串字面量并 exit 0,造成 Windows 上的假阳性验证。其它 shell 必须关。
|
|
75
|
+
windowsVerbatimArguments: spec.windowsVerbatimArguments,
|
|
53
76
|
});
|
|
54
77
|
const output = new BoundedCommandOutput();
|
|
55
78
|
let finished = false;
|
|
@@ -86,7 +109,7 @@ export async function runCommandRaw(command, timeout = 120000, signal, cwd) {
|
|
|
86
109
|
const timer = setTimeout(() => {
|
|
87
110
|
killTree();
|
|
88
111
|
finish({ status: 'timed_out', exitCode: null, output: output.render().trim() });
|
|
89
|
-
},
|
|
112
|
+
}, effectiveTimeout);
|
|
90
113
|
child.stdout.on('data', onChunk);
|
|
91
114
|
child.stderr.on('data', onChunk);
|
|
92
115
|
child.on('error', (error) => {
|
|
@@ -141,20 +164,46 @@ function commandOutcome(result) {
|
|
|
141
164
|
// ---------- run_command ----------
|
|
142
165
|
export const runCommandTool = {
|
|
143
166
|
name: 'run_command',
|
|
144
|
-
description: 'Run a shell command, merging stdout+stderr. Default timeout 120s.
|
|
145
|
-
|
|
167
|
+
description: 'Run a FOREGROUND shell command, merging stdout+stderr. Default timeout 120s, hard cap 10min; pass shell=cmd|powershell|bash to pick the interpreter (platform default and MOCODE_SHELL override are stated in the system prompt). Non-interactive cmd cannot run `timeout /t` — use shell=powershell (`Start-Sleep`) or shell=bash (`sleep`) for waits.\n' +
|
|
168
|
+
'Anything that must keep running after this call returns — dev server, model service, watcher, log tail — belongs to dev_server (survives across calls; gives an id for incremental logs and process-tree kill). Do NOT detach via `start /b`, `nohup`, `&`: you lose both the logs and the handle.\n' +
|
|
169
|
+
'Multiple independent calls may be issued in one response (they run serially, in order); do not depend one on another\'s output within the same message.',
|
|
146
170
|
risk: 'dangerous',
|
|
147
171
|
parameters: {
|
|
148
172
|
type: 'object',
|
|
149
173
|
properties: {
|
|
150
174
|
command: { type: 'string', description: 'Command to execute (single line)' },
|
|
151
|
-
timeout: {
|
|
175
|
+
timeout: {
|
|
176
|
+
type: 'integer',
|
|
177
|
+
description: `Timeout in milliseconds (default ${DEFAULT_COMMAND_TIMEOUT_MS}, clamped to ${MIN_COMMAND_TIMEOUT_MS}..${MAX_COMMAND_TIMEOUT_MS}). Raise it only for genuinely slow foreground work; use dev_server for long-running processes.`,
|
|
178
|
+
},
|
|
179
|
+
shell: { type: 'string', enum: ['cmd', 'powershell', 'bash'], description: shellParamDescription() },
|
|
152
180
|
},
|
|
153
181
|
required: ['command'],
|
|
154
182
|
},
|
|
155
183
|
async execute(args, ctx) {
|
|
156
184
|
const command = String(args.command);
|
|
157
|
-
const timeout =
|
|
158
|
-
|
|
185
|
+
const timeout = clampCommandTimeout(args.timeout);
|
|
186
|
+
// shell 非法值不静默忽略:明确报错,否则模型以为在 powershell 里跑却落到 cmd,
|
|
187
|
+
// 语法错误难以归因。合法值大小写/别名由 parseShellKind 归一。
|
|
188
|
+
let shell;
|
|
189
|
+
if (args.shell !== undefined) {
|
|
190
|
+
const parsed = parseShellKind(args.shell);
|
|
191
|
+
if (!parsed) {
|
|
192
|
+
return {
|
|
193
|
+
status: 'error',
|
|
194
|
+
code: 'INVALID_ARGUMENTS',
|
|
195
|
+
retryable: false,
|
|
196
|
+
output: `错误:无效的 shell "${String(args.shell)}"。可选值:cmd / powershell / bash。`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
shell = parsed;
|
|
200
|
+
}
|
|
201
|
+
const result = await runCommandRaw(command, timeout, ctx?.signal, undefined, shell);
|
|
202
|
+
const outcome = commandOutcome(result);
|
|
203
|
+
// 钳制要显式告知:模型传了 30min 却按 10min 判超时,不说清它会以为是环境抽风而盲目重试。
|
|
204
|
+
if (args.timeout !== undefined && Number(args.timeout) !== timeout && result.status === 'timed_out') {
|
|
205
|
+
outcome.output = `${outcome.output}\n(请求的 timeout=${Number(args.timeout)}ms 已钳制到 ${timeout}ms;需要长驻进程请改用 dev_server)`;
|
|
206
|
+
}
|
|
207
|
+
return outcome;
|
|
159
208
|
},
|
|
160
209
|
};
|