mocode-ai 1.6.5 → 1.6.7
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 +46 -31
- package/README.zh-CN.md +57 -30
- package/dist/agent/run-coordinator.js +2 -1
- package/dist/agent/tool-turn.js +2 -0
- package/dist/bots/bus.js +117 -0
- package/dist/bots/cli.js +102 -0
- package/dist/bots/store.js +100 -0
- package/dist/config/index.js +2 -1
- package/dist/headless.js +302 -0
- package/dist/index.js +171 -3
- package/dist/jobs/approval.js +153 -0
- package/dist/jobs/approve-cli.js +30 -0
- package/dist/jobs/attach.js +61 -0
- package/dist/jobs/checkpoint.js +33 -0
- package/dist/jobs/launch.js +107 -0
- package/dist/jobs/runner.js +59 -0
- package/dist/jobs/store.js +101 -0
- package/dist/jobs/worktree.js +63 -0
- package/dist/notify/index.js +144 -0
- package/dist/repl/commands/bots.js +32 -0
- package/dist/repl/commands/jobs.js +109 -0
- package/dist/repl/commands/model.js +5 -20
- package/dist/repl/commands/registry.js +6 -0
- package/dist/repl/commands/schedule.js +37 -0
- package/dist/repl/commands.js +3 -58
- package/dist/schedule/cli.js +114 -0
- package/dist/schedule/cron.js +79 -0
- package/dist/schedule/daemon.js +166 -0
- package/dist/schedule/store.js +98 -0
- package/dist/session/scheduler.js +0 -3
- package/dist/session/store.js +1 -1
- package/dist/tools/builtins/arena.js +156 -0
- package/dist/tools/builtins/glob.js +64 -16
- package/dist/tools/builtins/grep.js +176 -122
- package/dist/tools/builtins/index.js +13 -2
- package/dist/tools/builtins/message-bus.js +98 -0
- package/dist/tools/builtins/run-command.js +2 -1
- package/dist/tools/constants.js +11 -0
- package/dist/tools/ripgrep-client.js +180 -0
- package/dist/ui/batch.js +31 -1
- package/dist/ui/composer.js +4 -18
- package/dist/ui/fuzzy-picker.js +1 -3
- package/dist/ui/intervention.js +6 -0
- package/dist/ui/prompt-internal/editor.js +10 -27
- package/package.json +3 -2
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import fg from 'fast-glob';
|
|
2
2
|
import { IGNORE } from '../constants.js';
|
|
3
3
|
import { getSandboxRoot, isInsideRoot } from '../../sandbox/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* glob 超时墙。纯文件名枚举正常是毫秒~秒级;超时只发生在网络盘/断开的 UNC 挂载等病态
|
|
6
|
+
* 场景。此时杀掉枚举报错,而不是让 spinner 无限期挂住。
|
|
7
|
+
*/
|
|
8
|
+
export const GLOB_TIMEOUT_MS = 30_000;
|
|
4
9
|
// ---------- glob ----------
|
|
5
10
|
export const globTool = {
|
|
6
11
|
name: 'glob',
|
|
@@ -13,23 +18,66 @@ export const globTool = {
|
|
|
13
18
|
},
|
|
14
19
|
required: ['pattern'],
|
|
15
20
|
},
|
|
16
|
-
async execute(args) {
|
|
21
|
+
async execute(args, ctx) {
|
|
17
22
|
const pattern = String(args.pattern);
|
|
18
23
|
const cwd = getSandboxRoot() ?? process.cwd();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
24
|
+
// 组合外部 Ctrl+C 信号与超时墙:fast-glob 3.3 原生支持 AbortSignal,中止后枚举立即失败。
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const external = ctx?.signal;
|
|
27
|
+
const onExternal = () => controller.abort();
|
|
28
|
+
if (external) {
|
|
29
|
+
if (external.aborted)
|
|
30
|
+
onExternal();
|
|
31
|
+
else
|
|
32
|
+
external.addEventListener('abort', onExternal, { once: true });
|
|
33
|
+
}
|
|
34
|
+
let timedOut = false;
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
timedOut = true;
|
|
37
|
+
controller.abort();
|
|
38
|
+
}, GLOB_TIMEOUT_MS);
|
|
39
|
+
try {
|
|
40
|
+
// fast-glob 3.3 的 signal 选项运行时不生效(实测),用 Promise.race 做超时/中断墙:
|
|
41
|
+
// 触发后不再等待枚举——纯只读 readdir 会自行跑完,用户面即时解锁。
|
|
42
|
+
const fgPromise = fg(pattern, {
|
|
43
|
+
cwd,
|
|
44
|
+
onlyFiles: true,
|
|
45
|
+
dot: true,
|
|
46
|
+
ignore: IGNORE,
|
|
47
|
+
followSymbolicLinks: false, // 不跟随软链目录,防经软链列出牢外文件
|
|
48
|
+
throwErrorOnBrokenSymbolicLink: false,
|
|
49
|
+
});
|
|
50
|
+
const cancelled = new Promise((resolve) => {
|
|
51
|
+
if (controller.signal.aborted)
|
|
52
|
+
resolve('cancelled');
|
|
53
|
+
else
|
|
54
|
+
controller.signal.addEventListener('abort', () => resolve('cancelled'), { once: true });
|
|
55
|
+
});
|
|
56
|
+
const files = await Promise.race([fgPromise, cancelled]);
|
|
57
|
+
if (files === 'cancelled') {
|
|
58
|
+
if (timedOut) {
|
|
59
|
+
return {
|
|
60
|
+
status: 'error',
|
|
61
|
+
code: 'TIMEOUT',
|
|
62
|
+
retryable: false,
|
|
63
|
+
output: `错误:glob 超过 ${GLOB_TIMEOUT_MS / 1000}s 超时墙已被中止(确认无网络盘挂载,或收窄 pattern 后重试)`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// 外部 Ctrl+C:中断串由 tool-runtime 见 signal.aborted 归一为 aborted。
|
|
67
|
+
return '已中断';
|
|
68
|
+
}
|
|
69
|
+
const safeFiles = files.filter((f) => isInsideRoot(f)); // 后置兜底:仅留牢内
|
|
70
|
+
if (safeFiles.length === 0)
|
|
71
|
+
return '无匹配文件';
|
|
72
|
+
const shown = safeFiles.slice(0, 200);
|
|
73
|
+
let out = shown.join('\n');
|
|
74
|
+
if (safeFiles.length > 200)
|
|
75
|
+
out += `\n... (共 ${safeFiles.length} 个,仅显示前 200)`;
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
external?.removeEventListener('abort', onExternal);
|
|
81
|
+
}
|
|
34
82
|
},
|
|
35
83
|
};
|
|
@@ -2,69 +2,168 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import fg from 'fast-glob';
|
|
3
3
|
import { IGNORE, MAX_RESULTS } from '../constants.js';
|
|
4
4
|
import { isProbablyBinary } from '../../attachments/image.js';
|
|
5
|
-
// 二进制文件探测统一走 attachments/image.ts 的 isProbablyBinary(与 read_file 同源同口径,
|
|
6
|
-
// 避免两处正则漂移):头部含 C0 控制字符即跳过。否则 grep 扫到 SQLite/压缩文件等会产出
|
|
7
|
-
// 「单行数 KB + 控制字符」的匹配行,这类行进 TUI 展开后被终端 auto-wrap,物理行与缓冲行
|
|
8
|
-
// 失配导致整屏错乱。
|
|
9
5
|
import { getSandboxRoot, isInsideRoot, jailResolve } from '../../sandbox/index.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
6
|
+
import { runRipgrep } from '../ripgrep-client.js';
|
|
7
|
+
/**
|
|
8
|
+
* grep 超时墙。
|
|
9
|
+
*
|
|
10
|
+
* ripgrep 正常仓库都是秒级,超时只发生在网络盘挂死这类病态场景——此时与其让 spinner
|
|
11
|
+
* 无限期冻住,不如杀掉报错让模型换路径(收窄 glob)。比 run_command 默认 120s 更严:
|
|
12
|
+
* 全库搜索不该有 rg 跑满 2 分钟还出不来的结果。
|
|
13
|
+
*/
|
|
14
|
+
export const GREP_TIMEOUT_MS = 120_000;
|
|
15
|
+
/** Node 兜底路径的单文件读取上限(ripgrep 流式读取不需要这个闸门,仅兜底用)。 */
|
|
13
16
|
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
14
|
-
/** 单条 body 行渲染上限(字符):minified JS / 单行 JSON 截断加
|
|
15
|
-
* 既防 TUI auto-wrap 错乱,也防一行吃掉整个 MAX_RESULTS 预算。 */
|
|
17
|
+
/** 单条 body 行渲染上限(字符):minified JS / 单行 JSON 截断加 …,防 TUI auto-wrap 错乱。 */
|
|
16
18
|
const MAX_BODY_LINE_CHARS = 400;
|
|
17
|
-
/** context 参数上限:邻居行是 ×(1+2C) 放大,给到 10
|
|
19
|
+
/** context 参数上限:邻居行是 ×(1+2C) 放大,给到 10 已远超需要。 */
|
|
18
20
|
const MAX_CONTEXT_LINES = 10;
|
|
19
|
-
/** 超长行截断(保留头部:grep 的价值在行首标识与缩进)。 */
|
|
20
21
|
function clipLine(line) {
|
|
21
22
|
if (line.length <= MAX_BODY_LINE_CHARS)
|
|
22
23
|
return line;
|
|
23
24
|
return `${line.slice(0, MAX_BODY_LINE_CHARS)}…(+${line.length - MAX_BODY_LINE_CHARS} 字符)`;
|
|
24
25
|
}
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* 输出格式契约(context/encoders/search.ts 依赖前两个前缀做 Cold 折叠):
|
|
27
|
+
* 把命中行 + context 行渲染成 body。输出格式契约(context/encoders/search 依赖前缀做折叠):
|
|
29
28
|
* ` L<n>: <原文>` 命中行(冒号)
|
|
30
|
-
* ` L<n>- <原文>` 上下文行(
|
|
31
|
-
* ` --` 不相邻分块之间的分隔
|
|
32
|
-
*
|
|
33
|
-
* read_file 去确认——那正是 context 参数要消灭的往返。
|
|
29
|
+
* ` L<n>- <原文>` 上下文行(连字符)
|
|
30
|
+
* ` --` 不相邻分块之间的分隔
|
|
31
|
+
* 原文不 trim:缩进是代码结构信息。
|
|
34
32
|
*/
|
|
35
|
-
function
|
|
36
|
-
const shown =
|
|
33
|
+
function renderFileBodies(file, maxPerFile) {
|
|
34
|
+
const shown = file.matches.slice(0, maxPerFile);
|
|
37
35
|
if (shown.length === 0)
|
|
38
36
|
return [];
|
|
39
|
-
const matched = new Set(shown);
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
wanted.add(i);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
37
|
+
const matched = new Set(shown.map((e) => e.line));
|
|
38
|
+
const byLine = new Map();
|
|
39
|
+
for (const e of shown)
|
|
40
|
+
byLine.set(e.line, e);
|
|
41
|
+
for (const e of file.contexts)
|
|
42
|
+
byLine.set(e.line, e);
|
|
48
43
|
const out = [];
|
|
49
44
|
let previous = 0;
|
|
50
|
-
for (const n of [...
|
|
45
|
+
for (const n of [...byLine.keys()].sort((a, b) => a - b)) {
|
|
51
46
|
if (previous !== 0 && n !== previous + 1)
|
|
52
47
|
out.push(' --');
|
|
53
48
|
previous = n;
|
|
54
|
-
|
|
49
|
+
const event = byLine.get(n);
|
|
50
|
+
out.push(` L${n}${matched.has(n) ? ':' : '-'} ${clipLine(event.text)}`);
|
|
55
51
|
}
|
|
56
52
|
return out;
|
|
57
53
|
}
|
|
54
|
+
/** 把聚合后的文件事件渲染成工具输出(配额/截断口径与旧实现一致)。 */
|
|
55
|
+
function renderResults(files, maxPerFile, context, truncated) {
|
|
56
|
+
const hits = [];
|
|
57
|
+
let budgetTruncated = truncated;
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
const bodyCost = Math.min(file.matches.length, maxPerFile) * (1 + 2 * context);
|
|
60
|
+
const totalCost = file.matches.length + bodyCost;
|
|
61
|
+
if (hits.length >= MAX_RESULTS || totalCost > MAX_RESULTS * 4) {
|
|
62
|
+
if (hits.length < MAX_RESULTS)
|
|
63
|
+
hits.push({ file, bodies: [] });
|
|
64
|
+
budgetTruncated = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
hits.push({ file, bodies: renderFileBodies(file, maxPerFile) });
|
|
68
|
+
}
|
|
69
|
+
const out = [];
|
|
70
|
+
let totalShown = 0;
|
|
71
|
+
for (const h of hits) {
|
|
72
|
+
out.push(`${h.file.path}: ${h.file.matches.length} 处匹配,行号 [${h.file.matches.map((e) => e.line).join(', ')}]`);
|
|
73
|
+
if (h.bodies.length > 0)
|
|
74
|
+
out.push(...h.bodies);
|
|
75
|
+
else
|
|
76
|
+
out.push(' (body 已折叠,见上方行号列表 → read_file 精读)');
|
|
77
|
+
totalShown += h.file.matches.length + h.bodies.length;
|
|
78
|
+
if (totalShown >= MAX_RESULTS) {
|
|
79
|
+
out.push(`...(结果达到 ${MAX_RESULTS} 条上限${context > 0 ? ',context 行同样计入;要更全就减小 context 或收窄 glob' : ''})`);
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (budgetTruncated)
|
|
84
|
+
out.push('...(仍有更多匹配未展示,缩小 glob 或收窄正则)');
|
|
85
|
+
return out.join('\n');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Node 兜底:rg 二进制不可用(可选依赖缺失且 PATH 无 rg)时走旧路径。
|
|
89
|
+
* 与旧实现的唯一差异——每两个文件之间检查 abort 信号,使 Ctrl+C / 超时墙能即时打断。
|
|
90
|
+
*/
|
|
91
|
+
async function nodeFallback(pattern, glob, maxPerFile, context, signal, externalSignal) {
|
|
92
|
+
let re;
|
|
93
|
+
try {
|
|
94
|
+
re = new RegExp(pattern);
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
return `错误:非法正则 ${pattern}: ${e instanceof Error ? e.message : String(e)}`;
|
|
98
|
+
}
|
|
99
|
+
const cwd = getSandboxRoot() ?? process.cwd();
|
|
100
|
+
const entries = (await fg(glob, {
|
|
101
|
+
cwd,
|
|
102
|
+
onlyFiles: true,
|
|
103
|
+
dot: true,
|
|
104
|
+
ignore: IGNORE,
|
|
105
|
+
stats: true,
|
|
106
|
+
followSymbolicLinks: false,
|
|
107
|
+
throwErrorOnBrokenSymbolicLink: false,
|
|
108
|
+
})).filter((entry) => isInsideRoot(entry.path)); // 后置兜底:仅留牢内
|
|
109
|
+
const files = [];
|
|
110
|
+
let scanned = 0;
|
|
111
|
+
let skippedTooLarge = 0;
|
|
112
|
+
const abortNote = () => externalSignal?.aborted
|
|
113
|
+
? { status: 'aborted', code: 'ABORTED', output: '已中断' }
|
|
114
|
+
: {
|
|
115
|
+
status: 'error',
|
|
116
|
+
code: 'TIMEOUT',
|
|
117
|
+
retryable: false,
|
|
118
|
+
output: `错误:grep 超过 ${GREP_TIMEOUT_MS / 1000}s 超时墙已被中止(收窄 glob 后重试)`,
|
|
119
|
+
};
|
|
120
|
+
for (let fi = 0; fi < entries.length; fi++) {
|
|
121
|
+
const entry = entries[fi];
|
|
122
|
+
if ((fi & 31) === 0 && signal.aborted)
|
|
123
|
+
return abortNote();
|
|
124
|
+
if (entry.stats && entry.stats.size > MAX_FILE_BYTES) {
|
|
125
|
+
skippedTooLarge++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
let content;
|
|
129
|
+
try {
|
|
130
|
+
content = await readFile(jailResolve(entry.path), 'utf8');
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
scanned++;
|
|
136
|
+
if (isProbablyBinary(content))
|
|
137
|
+
continue;
|
|
138
|
+
const lines = content.split(/\r?\n/);
|
|
139
|
+
const matches = [];
|
|
140
|
+
for (let i = 0; i < lines.length; i++) {
|
|
141
|
+
if (re.test(lines[i]))
|
|
142
|
+
matches.push({ line: i + 1, text: lines[i] });
|
|
143
|
+
}
|
|
144
|
+
if (matches.length > 0)
|
|
145
|
+
files.push({ path: entry.path, matches, contexts: [] });
|
|
146
|
+
}
|
|
147
|
+
if (signal.aborted)
|
|
148
|
+
return abortNote();
|
|
149
|
+
if (files.length === 0) {
|
|
150
|
+
const note = skippedTooLarge ? `,跳过 ${skippedTooLarge} 个超过 2MiB 的文件` : '';
|
|
151
|
+
return `无匹配(扫描了 ${scanned} 个文件${note})`;
|
|
152
|
+
}
|
|
153
|
+
// 兜底路径无 context 事件来源(rg 缺失),context 参数退化为只给命中行。
|
|
154
|
+
const out = renderResults(files, maxPerFile, 0, false);
|
|
155
|
+
return skippedTooLarge ? `${out}\n...(跳过 ${skippedTooLarge} 个超过 2MiB 的文件)` : out;
|
|
156
|
+
}
|
|
58
157
|
// ---------- grep ----------
|
|
59
158
|
export const grepTool = {
|
|
60
159
|
name: 'grep',
|
|
61
|
-
description: 'Search file contents by regex (recursive,
|
|
160
|
+
description: 'Search file contents by regex (recursive; powered by ripgrep: respects .gitignore, skips binaries).\n' +
|
|
62
161
|
'Output: per-file header "<path>: N matches, lines [l1, l2, ...]" plus rendered lines with ORIGINAL INDENTATION kept.\n' +
|
|
63
|
-
'Pass context=2..5 to include neighbouring lines inline (like ripgrep -C) instead of following each hit with a read_file round-trip. Use read_file when you need a whole region or exact edit text.',
|
|
162
|
+
'Pass context=2..5 to include neighbouring lines inline (like ripgrep -C) instead of following each hit with a read_file round-trip. Hard wall 120s; narrow the glob if a tree contains network mounts. Use read_file when you need a whole region or exact edit text.',
|
|
64
163
|
parameters: {
|
|
65
164
|
type: 'object',
|
|
66
165
|
properties: {
|
|
67
|
-
pattern: { type: 'string', description: 'Regular expression' },
|
|
166
|
+
pattern: { type: 'string', description: 'Regular expression (ripgrep/Rust regex syntax)' },
|
|
68
167
|
glob: { type: 'string', description: 'Optional, restrict to a file glob, e.g. *.ts' },
|
|
69
168
|
max_per_file: {
|
|
70
169
|
type: 'integer',
|
|
@@ -73,106 +172,61 @@ export const grepTool = {
|
|
|
73
172
|
context: {
|
|
74
173
|
type: 'integer',
|
|
75
174
|
description: 'Neighbouring lines to include around each match, like ripgrep -C (default 0, max 10). ' +
|
|
76
|
-
'Output grows ~x(1+2*context)
|
|
175
|
+
'Output grows ~x(1+2*context), so keep it small.',
|
|
77
176
|
},
|
|
78
177
|
},
|
|
79
178
|
required: ['pattern'],
|
|
80
179
|
},
|
|
81
|
-
async execute(args) {
|
|
180
|
+
async execute(args, ctx) {
|
|
82
181
|
const pattern = String(args.pattern);
|
|
83
|
-
const
|
|
182
|
+
const glob = String(args.glob ?? '**/*');
|
|
84
183
|
const maxPerFile = Math.min(Math.max(Number(args.max_per_file ?? 15), 1), 50);
|
|
85
184
|
const contextRaw = Number(args.context ?? 0);
|
|
86
185
|
const context = Number.isFinite(contextRaw) ? Math.min(Math.max(Math.trunc(contextRaw), 0), MAX_CONTEXT_LINES) : 0;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
186
|
+
// 组合外部 Ctrl+C 信号与超时墙:任一触发都中止 rg 子进程。
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
const external = ctx?.signal;
|
|
189
|
+
const onExternal = () => controller.abort();
|
|
190
|
+
if (external) {
|
|
191
|
+
if (external.aborted)
|
|
192
|
+
onExternal();
|
|
193
|
+
else
|
|
194
|
+
external.addEventListener('abort', onExternal, { once: true });
|
|
93
195
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
let skippedTooLarge = 0;
|
|
108
|
-
let truncated = false;
|
|
109
|
-
for (const entry of entries) {
|
|
110
|
-
const f = entry.path;
|
|
111
|
-
// 体积闸门:压缩产物 / 数据 dump 读进来只会撑爆内存并产出无意义超长行。
|
|
112
|
-
if (entry.stats && entry.stats.size > MAX_FILE_BYTES) {
|
|
113
|
-
skippedTooLarge++;
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
let content;
|
|
117
|
-
try {
|
|
118
|
-
// jailResolve:realpath 化,防「牢内文件软链→牢外」的内容泄露;越界/不可读均 catch 跳过
|
|
119
|
-
content = await readFile(jailResolve(f), 'utf8');
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
continue; // 跳过无法读的文件(二进制/权限/沙箱越界)
|
|
123
|
-
}
|
|
124
|
-
scanned++;
|
|
125
|
-
// 二进制文件(如 .codegraph/codegraph.db 这类 SQLite)跳过:其「行」是数 KB 的
|
|
126
|
-
// 序列化记录 + 控制字符,匹配行对 LLM 无意义,还会污染 TUI 展开渲染。
|
|
127
|
-
if (isProbablyBinary(content))
|
|
128
|
-
continue;
|
|
129
|
-
const lines = content.split(/\r?\n/);
|
|
130
|
-
const lineNos = [];
|
|
131
|
-
for (let i = 0; i < lines.length; i++) {
|
|
132
|
-
if (re.test(lines[i]))
|
|
133
|
-
lineNos.push(i + 1);
|
|
134
|
-
}
|
|
135
|
-
if (lineNos.length === 0)
|
|
136
|
-
continue;
|
|
137
|
-
// 全局配额:行号列表总是计入,body 成本按「命中行数 ×(1+2×context)」计入 ——
|
|
138
|
-
// context 是乘法放大,不按实际渲染量计就会让带 ±5 邻居的 grep 静默撑爆预算。
|
|
139
|
-
const bodyCost = Math.min(lineNos.length, maxPerFile) * (1 + 2 * context);
|
|
140
|
-
const totalCost = lineNos.length + bodyCost;
|
|
141
|
-
if (hits.length >= MAX_RESULTS || totalCost > MAX_RESULTS * 4) {
|
|
142
|
-
// 文件过多 / 配额爆:仅追加该文件行号列表,不再展开 body
|
|
143
|
-
if (hits.length < MAX_RESULTS) {
|
|
144
|
-
hits.push({ path: f, lineNos, bodies: [] });
|
|
196
|
+
let timedOut = false;
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
timedOut = true;
|
|
199
|
+
controller.abort();
|
|
200
|
+
}, GREP_TIMEOUT_MS);
|
|
201
|
+
try {
|
|
202
|
+
const result = await runRipgrep({ pattern, glob, context }, controller.signal);
|
|
203
|
+
switch (result.status) {
|
|
204
|
+
case 'regex_error':
|
|
205
|
+
return `错误:非法正则 ${pattern}: ${result.message}`;
|
|
206
|
+
case 'no-match': {
|
|
207
|
+
const trunc = result.truncated ? '\n...(输出截断,缩小 glob 或收窄正则后重试)' : '';
|
|
208
|
+
return `无匹配(扫描了 ${result.scanned} 个文件)${trunc}`;
|
|
145
209
|
}
|
|
146
|
-
|
|
147
|
-
|
|
210
|
+
case 'ok':
|
|
211
|
+
return renderResults(result.files, maxPerFile, context, result.truncated);
|
|
212
|
+
case 'unavailable':
|
|
213
|
+
return nodeFallback(pattern, glob, maxPerFile, context, controller.signal, external);
|
|
214
|
+
case 'aborted':
|
|
215
|
+
if (timedOut) {
|
|
216
|
+
return {
|
|
217
|
+
status: 'error',
|
|
218
|
+
code: 'TIMEOUT',
|
|
219
|
+
retryable: false,
|
|
220
|
+
output: `错误:grep 超过 ${GREP_TIMEOUT_MS / 1000}s 超时墙已被中止(收窄 glob 或确认无网络盘挂载后重试)`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// 外部 Ctrl+C:返回中断串,tool-runtime 见 signal.aborted 会归一为 aborted。
|
|
224
|
+
return '已中断';
|
|
148
225
|
}
|
|
149
|
-
hits.push({ path: f, lineNos, bodies: renderBodies(lines, lineNos, maxPerFile, context) });
|
|
150
226
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (hits.length === 0)
|
|
155
|
-
return `无匹配(扫描了 ${scanned} 个文件${skippedNote})`;
|
|
156
|
-
const out = [];
|
|
157
|
-
let totalShown = 0;
|
|
158
|
-
for (const h of hits) {
|
|
159
|
-
out.push(`${h.path}: ${h.lineNos.length} 处匹配,行号 [${h.lineNos.join(', ')}]`);
|
|
160
|
-
if (h.bodies.length > 0) {
|
|
161
|
-
out.push(...h.bodies);
|
|
162
|
-
}
|
|
163
|
-
else {
|
|
164
|
-
out.push(` (body 已折叠,见上方行号列表 → read_file 精读)`);
|
|
165
|
-
}
|
|
166
|
-
totalShown += h.lineNos.length + h.bodies.length;
|
|
167
|
-
if (totalShown >= MAX_RESULTS) {
|
|
168
|
-
out.push(`...(结果达到 ${MAX_RESULTS} 条上限${context > 0 ? ',context 行同样计入;要更全就减小 context 或收窄 glob' : ''})`);
|
|
169
|
-
break;
|
|
170
|
-
}
|
|
227
|
+
finally {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
external?.removeEventListener('abort', onExternal);
|
|
171
230
|
}
|
|
172
|
-
if (truncated)
|
|
173
|
-
out.push(`...(仍有更多匹配文件未展示,缩小 glob 或收窄正则)`);
|
|
174
|
-
if (skippedTooLarge)
|
|
175
|
-
out.push(`...(跳过 ${skippedTooLarge} 个超过 ${MAX_FILE_BYTES / 1024 / 1024}MiB 的文件)`);
|
|
176
|
-
return out.join('\n');
|
|
177
231
|
},
|
|
178
232
|
};
|
|
@@ -22,6 +22,8 @@ import { memoryUpdateTool } from './memory-update.js';
|
|
|
22
22
|
import { memoryForgetTool } from './memory-forget.js';
|
|
23
23
|
import { memoryGraphTool } from './memory-graph.js';
|
|
24
24
|
import { subAgentTool } from './task.js';
|
|
25
|
+
import { arenaTool } from './arena.js';
|
|
26
|
+
import { messageBusTool } from './message-bus.js';
|
|
25
27
|
import { computerTool } from './computer.js';
|
|
26
28
|
import { sessionSearchTool } from './session-search.js';
|
|
27
29
|
/**
|
|
@@ -50,8 +52,8 @@ export const CAPABILITIES = {
|
|
|
50
52
|
// 后台进程与浏览器会话跨调用存活;串行执行避免同一页面/服务被并发操作。
|
|
51
53
|
dev_server: { effect: 'process', concurrency: 'serial', resources: workspaceResource, supportsAbort: true },
|
|
52
54
|
browser: { effect: 'process', concurrency: 'serial', resources: workspaceResource },
|
|
53
|
-
glob: { effect: 'read', concurrency: 'parallel', resources: workspaceResource },
|
|
54
|
-
grep: { effect: 'read', concurrency: 'parallel', resources: workspaceResource },
|
|
55
|
+
glob: { effect: 'read', concurrency: 'parallel', resources: workspaceResource, supportsAbort: true },
|
|
56
|
+
grep: { effect: 'read', concurrency: 'parallel', resources: workspaceResource, supportsAbort: true },
|
|
55
57
|
// 网络只读查询:无副作用,可安全重复执行 → idempotent 让 runtime 对瞬时失败(429/5xx/超时/
|
|
56
58
|
// 网络抖动)自动退避重试,省掉模型「再发一轮 tool call 自救」的往返。写/进程类工具绝不置此位。
|
|
57
59
|
web_search: { effect: 'network', concurrency: 'parallel', supportsAbort: true, idempotent: true },
|
|
@@ -88,6 +90,13 @@ export const CAPABILITIES = {
|
|
|
88
90
|
parallelOrchestration: true,
|
|
89
91
|
supportsAbort: true,
|
|
90
92
|
},
|
|
93
|
+
// arena:一次调用内部并行 spawnAgent 出多个候选 + 一次 judge(见 ./arena.ts)。与 sub-agent 同理:
|
|
94
|
+
// 资源锁委派给内层 spawn 出的工具,否则 arena 自持 workspace 写锁会与候选内 run_command 自锁;
|
|
95
|
+
// 候选直接写共享工作区 → effect=write;它是单个工具调用,不置 parallelOrchestration(那是跨调用批)。
|
|
96
|
+
arena: { effect: 'write', concurrency: 'serial', delegatesResourceLocks: true, supportsAbort: true },
|
|
97
|
+
// message_bus:读写 .mocode/bus 下的内部消息文件(send 写 / inbox·ack·history 多为读),不触碰
|
|
98
|
+
// 项目代码;用独立 message-bus 资源键串行(类比 memory-store),不作项目 mutation 追踪/回滚。
|
|
99
|
+
message_bus: { effect: 'write', concurrency: 'serial', resources: () => ['message-bus'] },
|
|
91
100
|
};
|
|
92
101
|
const rawBuiltinTools = [
|
|
93
102
|
readFileTool,
|
|
@@ -113,6 +122,8 @@ const rawBuiltinTools = [
|
|
|
113
122
|
memoryForgetTool,
|
|
114
123
|
memoryGraphTool,
|
|
115
124
|
subAgentTool,
|
|
125
|
+
arenaTool,
|
|
126
|
+
messageBusTool,
|
|
116
127
|
computerTool,
|
|
117
128
|
sessionSearchTool,
|
|
118
129
|
];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { sendMessage, inbox, markRead, messageHistory, getIdentity } from '../../bots/bus.js';
|
|
2
|
+
// ---------- message_bus ----------
|
|
3
|
+
// D1:通过持久消息总线与其他具名 bot 异步通信。消息落盘,对方不必同时在线——
|
|
4
|
+
// worker bot 之后被 scheduler / bg 唤起时拉 inbox、处理、回结果(再 send 一条 to=原发送者)。
|
|
5
|
+
//
|
|
6
|
+
// action:
|
|
7
|
+
// send {to, body, inReplyTo?} 给某 bot 发消息
|
|
8
|
+
// inbox 拉取当前身份的未读消息(默认拉取后自动 ack)
|
|
9
|
+
// ack {ids:[...]} 手动标记已读
|
|
10
|
+
// history 与当前身份相关的全部消息
|
|
11
|
+
export const messageBusTool = {
|
|
12
|
+
name: 'message_bus',
|
|
13
|
+
description: [
|
|
14
|
+
'Asynchronous persistent messaging between named bots (and the main user). Messages are stored; the recipient need not be running and reads its inbox later.',
|
|
15
|
+
'Actions: send {to, body, inReplyTo?}, inbox (unread, auto-ack by default), ack {ids}, history. Use for durable hand-offs rather than synchronous sub-agent calls.',
|
|
16
|
+
].join(''),
|
|
17
|
+
parameters: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
action: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
enum: ['send', 'inbox', 'ack', 'history'],
|
|
23
|
+
description: 'Bus operation.',
|
|
24
|
+
},
|
|
25
|
+
to: { type: 'string', description: 'Recipient bot name (send).' },
|
|
26
|
+
body: { type: 'string', description: 'Message body (send).' },
|
|
27
|
+
inReplyTo: { type: 'string', description: 'Id of the message being replied to.' },
|
|
28
|
+
ids: {
|
|
29
|
+
type: 'array',
|
|
30
|
+
items: { type: 'string' },
|
|
31
|
+
description: 'Message ids to acknowledge (ack).',
|
|
32
|
+
},
|
|
33
|
+
autoAck: {
|
|
34
|
+
type: 'boolean',
|
|
35
|
+
description: 'With inbox: mark pulled messages read (default true).',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ['action'],
|
|
39
|
+
},
|
|
40
|
+
async execute(args) {
|
|
41
|
+
const action = String(args.action ?? '');
|
|
42
|
+
const identity = getIdentity();
|
|
43
|
+
const formatMsg = (m) => `[${m.id}] ${m.from} -> ${m.to} @ ${m.createdAt}\n${m.body}`;
|
|
44
|
+
let output;
|
|
45
|
+
try {
|
|
46
|
+
if (action === 'send') {
|
|
47
|
+
const to = String(args.to ?? '');
|
|
48
|
+
const body = String(args.body ?? '');
|
|
49
|
+
if (!to || !body)
|
|
50
|
+
return 'message_bus send requires to and body';
|
|
51
|
+
const msg = sendMessage({
|
|
52
|
+
to,
|
|
53
|
+
body,
|
|
54
|
+
...(typeof args.inReplyTo === 'string' ? { inReplyTo: args.inReplyTo } : {}),
|
|
55
|
+
});
|
|
56
|
+
output = `sent ${msg.id} to ${msg.to}`;
|
|
57
|
+
}
|
|
58
|
+
else if (action === 'inbox') {
|
|
59
|
+
const msgs = inbox(identity);
|
|
60
|
+
if (msgs.length === 0) {
|
|
61
|
+
output = `(no unread messages for ${identity})`;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
output = [`inbox for ${identity}: ${msgs.length} unread`, '', ...msgs.map(formatMsg)].join('\n');
|
|
65
|
+
if (args.autoAck !== false) {
|
|
66
|
+
const marked = markRead(msgs.map((m) => m.id), identity);
|
|
67
|
+
output += `\n\n(acked ${marked.length})`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else if (action === 'ack') {
|
|
72
|
+
const ids = Array.isArray(args.ids) ? args.ids.filter((x) => typeof x === 'string') : [];
|
|
73
|
+
const marked = markRead(ids, identity);
|
|
74
|
+
output = `acked ${marked.length} message(s)`;
|
|
75
|
+
}
|
|
76
|
+
else if (action === 'history') {
|
|
77
|
+
const msgs = messageHistory(identity);
|
|
78
|
+
output = msgs.length
|
|
79
|
+
? [`history for ${identity}: ${msgs.length}`, '', ...msgs.map(formatMsg)].join('\n')
|
|
80
|
+
: `(no messages for ${identity})`;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
return `unknown message_bus action: ${action}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
const outcome = {
|
|
88
|
+
status: 'error',
|
|
89
|
+
code: 'EXECUTION_ERROR',
|
|
90
|
+
retryable: false,
|
|
91
|
+
output: e instanceof Error ? e.message : String(e),
|
|
92
|
+
};
|
|
93
|
+
return outcome;
|
|
94
|
+
}
|
|
95
|
+
const outcome = { status: 'success', code: 'OK', retryable: false, output };
|
|
96
|
+
return outcome;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -73,6 +73,7 @@ export async function runCommandRaw(command, timeout = DEFAULT_COMMAND_TIMEOUT_M
|
|
|
73
73
|
// cmd.exe 需要 verbatim:否则 Node 重新引号化参数,`node -e "..."` 会退化成
|
|
74
74
|
// 字符串字面量并 exit 0,造成 Windows 上的假阳性验证。其它 shell 必须关。
|
|
75
75
|
windowsVerbatimArguments: spec.windowsVerbatimArguments,
|
|
76
|
+
windowsHide: true,
|
|
76
77
|
});
|
|
77
78
|
const output = new BoundedCommandOutput();
|
|
78
79
|
let finished = false;
|
|
@@ -80,7 +81,7 @@ export async function runCommandRaw(command, timeout = DEFAULT_COMMAND_TIMEOUT_M
|
|
|
80
81
|
try {
|
|
81
82
|
if (isWin) {
|
|
82
83
|
if (child.pid != null) {
|
|
83
|
-
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
84
|
+
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
else {
|
package/dist/tools/constants.js
CHANGED
|
@@ -88,6 +88,17 @@ export const IGNORE = [
|
|
|
88
88
|
'**/rust/**',
|
|
89
89
|
'**/dist/**',
|
|
90
90
|
'**/dist-tests/**',
|
|
91
|
+
// Python 生态:虚拟环境与缓存目录动辄上万文件,ripgrep 路径下主要靠 .gitignore 自动跳过,
|
|
92
|
+
// 这里是「非 git 仓库 / 未写进 .gitignore」时的兜底;Node 兜底路径更依赖这份硬名单。
|
|
93
|
+
'**/.venv/**',
|
|
94
|
+
'**/venv/**',
|
|
95
|
+
'**/__pycache__/**',
|
|
96
|
+
'**/.mypy_cache/**',
|
|
97
|
+
'**/.pytest_cache/**',
|
|
98
|
+
'**/.tox/**',
|
|
99
|
+
'**/htmlcov/**',
|
|
100
|
+
'**/.ipynb_checkpoints/**',
|
|
101
|
+
'**/*.egg-info/**',
|
|
91
102
|
];
|
|
92
103
|
// ── legacy profile fallback(官方 TUI/stdio 已由 ToolPolicy 控制)────────────────
|
|
93
104
|
// 保留导出名以兼容未传 ToolPolicy 的嵌入调用与旧测试。
|