mocode-ai 1.6.6 → 1.6.8

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.
@@ -5,12 +5,21 @@ export const OPENAI_DEFAULT_BASE_URL = 'https://api.openai.com/v1';
5
5
  * 不带 /v1——anthropic.ts 的 endpoint() 会在后面拼 `/v1/messages`。
6
6
  */
7
7
  export const ANTHROPIC_DEFAULT_BASE_URL = 'https://api.anthropic.com';
8
+ /** Gemini Developer API 的模型目录。 */
9
+ export const GEMINI_DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
10
+ /**
11
+ * Vertex Express API 全局端点(不含区域前缀):模型目录最全,API key 可直接直发。
12
+ * Google GenAI SDK 的 `genai.Client(vertexai=True, api_key=...)` 默认就是这个端点。
13
+ * 网络出口国家由本机代理决定,与 Vertex 区域无关。
14
+ */
15
+ export const VERTEX_DEFAULT_BASE_URL = 'https://aiplatform.googleapis.com/v1/publishers/google/models';
8
16
  /**
9
17
  * 判定该 provider 能否用 mocode 现有 fetch 直发。
10
18
  * - 官方 anthropic 包 → Anthropic Messages 路径
11
19
  * - openai-compatible 且带端点 → OpenAI Chat 路径
12
20
  * - 官方 openai 包 → OpenAI Chat 路径(端点缺省补官方 /v1)
13
- * - 其余(google/bedrock/azure/vertex/各 gateway…)→ 二期,UI 置灰
21
+ * - Google / Vertex Gemini → Gemini Contents 路径
22
+ * - 其余(bedrock/azure/Vertex Anthropic/各 gateway…)→ 暂不支持
14
23
  */
15
24
  export function classifyProvider(provider) {
16
25
  switch (provider.npm) {
@@ -20,25 +29,61 @@ export function classifyProvider(provider) {
20
29
  return provider.api && provider.api.trim() ? 'openai-chat' : 'unsupported';
21
30
  case '@ai-sdk/openai':
22
31
  return 'openai-chat';
32
+ case '@ai-sdk/google':
33
+ case '@ai-sdk/google-vertex':
34
+ return 'gemini-contents';
23
35
  default:
24
36
  return 'unsupported';
25
37
  }
26
38
  }
27
- /** provider 实际要用的 baseURL:官方 openai/anthropic 缺 api 时补默认,其余原样返回。 */
28
- export function resolveBaseURL(provider) {
39
+ function vertexBaseURL(env = process.env) {
40
+ // 默认走全局 Express 端点;仅当显式设置 GOOGLE_VERTEX_LOCATION(需要数据驻留)时用区域端点。
41
+ // 注意:区域端点的模型上线晚于全局端点,部分新模型在区域端点可能 404。
42
+ const raw = env.GOOGLE_VERTEX_LOCATION?.trim().toLowerCase() ?? '';
43
+ if (/^[a-z][a-z0-9-]*[a-z0-9]$/.test(raw)) {
44
+ return `https://${raw}-aiplatform.googleapis.com/v1/publishers/google/models`;
45
+ }
46
+ return VERTEX_DEFAULT_BASE_URL;
47
+ }
48
+ /** 模型级协议覆盖优先;未覆盖时使用 provider 顶层协议。 */
49
+ export function classifyModel(provider, model) {
50
+ const npm = model?.provider?.npm;
51
+ if (npm === '@ai-sdk/google-vertex/anthropic')
52
+ return 'unsupported';
53
+ if (npm === '@ai-sdk/openai-compatible') {
54
+ const api = model?.provider?.api?.trim();
55
+ // Vertex MaaS 模板需要 project/location/ADC,不属于本次 Express API key 直连路径。
56
+ return api && !api.includes('${') ? 'openai-chat' : 'unsupported';
57
+ }
58
+ return classifyProvider(provider);
59
+ }
60
+ /** 模型级 baseURL 覆盖优先;未覆盖时使用 provider 顶层端点。 */
61
+ export function resolveModelBaseURL(provider, model, env = process.env) {
62
+ if (model?.provider?.api?.trim())
63
+ return model.provider.api.trim();
64
+ return resolveBaseURL(provider, env);
65
+ }
66
+ /** provider 实际要用的 baseURL:显式 api 优先,否则按协议补官方默认值。 */
67
+ export function resolveBaseURL(provider, env = process.env) {
29
68
  if (provider.api && provider.api.trim())
30
69
  return provider.api.trim();
31
70
  if (provider.npm === '@ai-sdk/openai')
32
71
  return OPENAI_DEFAULT_BASE_URL;
33
72
  if (provider.npm === '@ai-sdk/anthropic')
34
73
  return ANTHROPIC_DEFAULT_BASE_URL;
74
+ if (provider.npm === '@ai-sdk/google')
75
+ return GEMINI_DEFAULT_BASE_URL;
76
+ if (provider.npm === '@ai-sdk/google-vertex')
77
+ return vertexBaseURL(env);
35
78
  return '';
36
79
  }
37
- /** 映射到 ModelPreset.provider(presets.ts 的两值)。 */
80
+ /** 映射到 ModelPreset.provider。 */
38
81
  export function toPresetProvider(proto) {
39
82
  if (proto === 'openai-chat')
40
83
  return 'openai';
41
84
  if (proto === 'anthropic-messages')
42
85
  return 'anthropic';
86
+ if (proto === 'gemini-contents')
87
+ return 'google';
43
88
  return null;
44
89
  }
@@ -1,11 +1,11 @@
1
- import { classifyProvider } from './protocol.js';
1
+ import { classifyModel } from './protocol.js';
2
2
  /** 把目录拍平成条目列表。 */
3
3
  export function flattenCatalog(providers) {
4
4
  const out = [];
5
5
  for (const [providerId, provider] of Object.entries(providers)) {
6
- const supported = classifyProvider(provider) !== 'unsupported';
7
6
  const providerName = provider.name || providerId;
8
7
  for (const [modelId, model] of Object.entries(provider.models ?? {})) {
8
+ const supported = classifyModel(provider, model) !== 'unsupported';
9
9
  out.push({ providerId, providerName, modelId, model, supported });
10
10
  }
11
11
  }
@@ -23,7 +23,7 @@ const LEVEL_LABELS = {
23
23
  /** 当前目标模型是否在白名单内(未识别则给提示)。 */
24
24
  function currentModelRecognized() {
25
25
  return recognizesReasoning({
26
- provider: config.provider === 'anthropic' ? 'anthropic' : 'openai',
26
+ provider: config.provider,
27
27
  model: config.model,
28
28
  maxTokens: config.maxTokens,
29
29
  });
@@ -14,7 +14,7 @@ import { reconfigureClient } from '../../llm/index.js';
14
14
  import { promptIntervention } from '../../ui/intervention.js';
15
15
  import { renderHistory } from '../message-format.js';
16
16
  import { refreshCatalog } from '../../models/catalog.js';
17
- import { buildPreset, defaultPresetName, resolveApiKey } from '../../models/map-preset.js';
17
+ import { buildPreset, defaultPresetName, preferredKeyEnvName, resolveApiKey } from '../../models/map-preset.js';
18
18
  /** 应用预设到 config + 落 config 文件 + 重建 client + 重显。 */
19
19
  export function applyPresetConfig(ctx, p) {
20
20
  updateModelConfig({
@@ -51,7 +51,7 @@ export async function addCatalogModel(ctx, entry, providers) {
51
51
  if (!catalogProvider)
52
52
  return 'cancelled';
53
53
  // key:无论环境变量是否找到,都弹一次确认——回车沿用预填值、粘贴即覆盖。
54
- const envName = catalogProvider.env?.[0] ?? 'API_KEY';
54
+ const envName = preferredKeyEnvName(catalogProvider);
55
55
  const detectedKey = resolveApiKey(catalogProvider);
56
56
  const kres = await promptIntervention({
57
57
  type: 'input',
@@ -178,7 +178,7 @@ export const modelCommands = [
178
178
  layout.contentWrite(` ${ui.accent}apiKey ${ui.reset} ${maskKey(config.apiKey)}\n`);
179
179
  layout.contentWrite(` ${ui.accent}model ${ui.reset} ${config.model}\n`);
180
180
  const recognized = recognizesReasoning({
181
- provider: config.provider === 'anthropic' ? 'anthropic' : 'openai',
181
+ provider: config.provider,
182
182
  model: config.model,
183
183
  maxTokens: config.maxTokens,
184
184
  });
@@ -7,9 +7,6 @@ import { pruneStaleArtifacts, refreshArtifactFreshness } from '../context/artifa
7
7
  import { pruneSuperseded } from '../context/relevance.js';
8
8
  import { clearRetrievableResults } from '../context/clearing.js';
9
9
  import { createAgeAwareEncodingState } from '../context/age-aware.js';
10
- function atPressure(report) {
11
- return Math.max(report.rawTotal, report.total) >= report.window * DEFAULT_BUDGET_POLICY.pressureTriggerRatio;
12
- }
13
10
  function emptyPressure(report) {
14
11
  return {
15
12
  triggered: false,
@@ -235,7 +235,7 @@ export class SessionStore {
235
235
  load(id) {
236
236
  const currentPath = this.sessionPath(id);
237
237
  const legacyPath = path.join(this.sessionsRoot, `${id}.json`);
238
- let source = existsSync(currentPath) ? currentPath : legacyPath;
238
+ const source = existsSync(currentPath) ? currentPath : legacyPath;
239
239
  // 活会话与 legacy 单文件都没有:回退从归档 gz 取回(30-90 天的会话仍可 resume)。
240
240
  if (!existsSync(source)) {
241
241
  const archived = loadArchivedSession(this.sessionsRoot, id);
@@ -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
- const files = (await fg(pattern, {
20
- cwd,
21
- onlyFiles: true,
22
- dot: true,
23
- ignore: IGNORE,
24
- followSymbolicLinks: false, // 不跟随软链目录,防经软链列出牢外文件
25
- throwErrorOnBrokenSymbolicLink: false,
26
- })).filter((f) => isInsideRoot(f)); // 后置兜底:仅留牢内
27
- if (files.length === 0)
28
- return '无匹配文件';
29
- const shown = files.slice(0, 200);
30
- let out = shown.join('\n');
31
- if (files.length > 200)
32
- out += `\n... (共 ${files.length} 个,仅显示前 200)`;
33
- return out;
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
- /** 单文件读取上限:超过即跳过(压缩产物 / 生成物 / 数据 dump)。
11
- * 逐文件全量 readFile 进内存是 grep 的固有成本,不设上限时一个 200MB 的 bundle
12
- * 就能把整轮 grep 拖死并撑爆内存。跳过的文件数会在结果尾部报出来,不静默吞。 */
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
- * 把命中行号 + context 半径渲染成 body 行。
27
- *
28
- * 输出格式契约(context/encoders/search.ts 依赖前两个前缀做 Cold 折叠):
27
+ * 把命中行 + context 行渲染成 body。输出格式契约(context/encoders/search 依赖前缀做折叠):
29
28
  * ` L<n>: <原文>` 命中行(冒号)
30
- * ` L<n>- <原文>` 上下文行(连字符,对齐 ripgrep 的 `:` / `-`)
31
- * ` --` 不相邻分块之间的分隔(对齐 ripgrep)
32
- * 原文**不 trim**:缩进是代码结构信息,trim 掉后模型看不出嵌套层级,只能再发一次
33
- * read_file 去确认——那正是 context 参数要消灭的往返。
29
+ * ` L<n>- <原文>` 上下文行(连字符)
30
+ * ` --` 不相邻分块之间的分隔
31
+ * 原文不 trim:缩进是代码结构信息。
34
32
  */
35
- function renderBodies(lines, lineNos, maxPerFile, context) {
36
- const shown = lineNos.slice(0, maxPerFile);
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
- // 命中行 ± 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
- }
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 [...wanted].sort((a, b) => a - b)) {
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
- out.push(` L${n}${matched.has(n) ? ':' : '-'} ${clipLine(lines[n - 1])}`);
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, excludes node_modules/.git/dist).\n' +
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) and counts against the same result budget, so keep it small.',
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 g = String(args.glob ?? '**/*');
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
- let re;
88
- try {
89
- re = new RegExp(pattern);
90
- }
91
- catch (e) {
92
- return `错误:非法正则 ${pattern}: ${e instanceof Error ? e.message : String(e)}`;
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
- const cwd = getSandboxRoot() ?? process.cwd();
95
- // stats:true 让 fast-glob 顺手带回 size,免为体积闸门再付一次 stat 系统调用。
96
- const entries = (await fg(g, {
97
- cwd,
98
- onlyFiles: true,
99
- dot: true,
100
- ignore: IGNORE,
101
- stats: true,
102
- followSymbolicLinks: false, // 不跟随软链目录,防经软链扫到牢外文件
103
- throwErrorOnBrokenSymbolicLink: false,
104
- })).filter((entry) => isInsideRoot(entry.path)); // 后置兜底:仅留牢内
105
- const hits = [];
106
- let scanned = 0;
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
- truncated = true;
147
- continue;
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
- const skippedNote = skippedTooLarge
152
- ? `,跳过 ${skippedTooLarge} 个超过 ${MAX_FILE_BYTES / 1024 / 1024}MiB 的文件`
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
  };
@@ -52,8 +52,8 @@ export const CAPABILITIES = {
52
52
  // 后台进程与浏览器会话跨调用存活;串行执行避免同一页面/服务被并发操作。
53
53
  dev_server: { effect: 'process', concurrency: 'serial', resources: workspaceResource, supportsAbort: true },
54
54
  browser: { effect: 'process', concurrency: 'serial', resources: workspaceResource },
55
- glob: { effect: 'read', concurrency: 'parallel', resources: workspaceResource },
56
- 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 },
57
57
  // 网络只读查询:无副作用,可安全重复执行 → idempotent 让 runtime 对瞬时失败(429/5xx/超时/
58
58
  // 网络抖动)自动退避重试,省掉模型「再发一轮 tool call 自救」的往返。写/进程类工具绝不置此位。
59
59
  web_search: { effect: 'network', concurrency: 'parallel', supportsAbort: true, idempotent: true },
@@ -90,6 +90,13 @@ export const CAPABILITIES = {
90
90
  parallelOrchestration: true,
91
91
  supportsAbort: true,
92
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'] },
93
100
  };
94
101
  const rawBuiltinTools = [
95
102
  readFileTool,
@@ -14,6 +14,7 @@ export const subAgentTool = {
14
14
  risk: 'dangerous',
15
15
  description: [
16
16
  'Spawn a worker with your own full capability set and conversation context for an isolated sub-task; only its structured result returns.',
17
+ 'Use it for exploration-heavy work (unfamiliar subsystems, multi-file research, audits) and for independent sub-tasks; emit several calls in one message to run them concurrently.',
17
18
  'The worker sees everything you have already read and concluded, so it does not repeat exploration.',
18
19
  ].join(''),
19
20
  parameters: {