tokenmaw 0.3.0
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 +150 -0
- package/agents/coordinator.md +13 -0
- package/agents/explorer.md +21 -0
- package/agents/implement.md +23 -0
- package/agents/main.md +25 -0
- package/agents/review.md +21 -0
- package/dist/backend.js +595 -0
- package/dist/cli.js +101 -0
- package/dist/config.js +155 -0
- package/dist/diff.js +45 -0
- package/dist/domain/agent.js +1 -0
- package/dist/fetch.js +110 -0
- package/dist/infra/file-snapshot.js +54 -0
- package/dist/infra/tools.js +1300 -0
- package/dist/markdown.js +274 -0
- package/dist/model-config.js +48 -0
- package/dist/policy.js +80 -0
- package/dist/responses.js +81 -0
- package/dist/runtime/agent-registry.js +139 -0
- package/dist/runtime/agent-runtime.js +993 -0
- package/dist/runtime/agent-store.js +152 -0
- package/dist/runtime/locks.js +46 -0
- package/dist/runtime/session-timeline.js +92 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/registry.js +51 -0
- package/dist/tools/types.js +1 -0
- package/dist/ui/clipboard.js +24 -0
- package/dist/ui/commands.js +20 -0
- package/dist/ui/composer-layout.js +31 -0
- package/dist/ui/fullscreen-tui.js +1405 -0
- package/dist/ui/markdown.js +81 -0
- package/dist/ui/syntax.js +17 -0
- package/dist/ui/tui-design.js +94 -0
- package/dist/ui/welcome.js +24 -0
- package/dist/version.js +4 -0
- package/docs/architecture-revision.md +281 -0
- package/package.json +47 -0
- package/skills/debugging.md +18 -0
- package/skills/git-workflow.md +14 -0
- package/skills/node-express.md +27 -0
- package/skills/python-flask.md +22 -0
- package/skills/react-component.md +24 -0
- package/skills/sql-database.md +18 -0
- package/skills/testing.md +12 -0
|
@@ -0,0 +1,1300 @@
|
|
|
1
|
+
import { readFile, writeFile, readdir, mkdir, stat, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { exec, execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { ToolRegistry } from '../tools/registry.js';
|
|
7
|
+
import { unifiedDiff } from '../diff.js';
|
|
8
|
+
import { resilientFetch } from '../fetch.js';
|
|
9
|
+
import { snapshotBeforeWrite } from './file-snapshot.js';
|
|
10
|
+
import { authorizeToolCall, clonePolicy, defaultPolicy, formatPolicyError, } from '../policy.js';
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
function workspaceRoot(ctx) {
|
|
14
|
+
return resolve(ctx?.workspaceRoot ?? ctx?.policy?.workspaceRoot ?? process.cwd());
|
|
15
|
+
}
|
|
16
|
+
function resolveToolPath(targetPath, ctx) {
|
|
17
|
+
return isAbsolute(targetPath) ? resolve(targetPath) : resolve(workspaceRoot(ctx), targetPath);
|
|
18
|
+
}
|
|
19
|
+
async function gitAutoCommit(filePath, message, ctx) {
|
|
20
|
+
if (process.env.AGENT_AUTO_COMMIT !== '1')
|
|
21
|
+
return;
|
|
22
|
+
try {
|
|
23
|
+
const cwd = workspaceRoot(ctx);
|
|
24
|
+
await execFileAsync('git', ['add', '--', filePath], { timeout: 15_000, cwd, signal: ctx?.signal });
|
|
25
|
+
await execFileAsync('git', ['commit', '-m', message, '--no-verify'], { timeout: 15_000, cwd, signal: ctx?.signal });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Best-effort only.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function artifactRelativePath(targetPath) {
|
|
32
|
+
const normalized = targetPath.replace(/\\/g, '/');
|
|
33
|
+
if (!normalized || /^[a-z][a-z0-9+.-]*:/i.test(normalized) || normalized.startsWith('/') || normalized.startsWith('~/') || normalized.startsWith('../'))
|
|
34
|
+
return undefined;
|
|
35
|
+
if (/^\.[/\\]/.test(targetPath))
|
|
36
|
+
return undefined;
|
|
37
|
+
if (normalized.includes('/../') || normalized === '..')
|
|
38
|
+
return undefined;
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
function resolveWriteTarget(targetPath, ctx) {
|
|
42
|
+
const artifactDir = ctx?.artifactDir;
|
|
43
|
+
const artifactRel = artifactDir ? artifactRelativePath(targetPath) : undefined;
|
|
44
|
+
if (artifactDir && artifactRel)
|
|
45
|
+
return resolve(artifactDir, artifactRel);
|
|
46
|
+
return resolveToolPath(targetPath, ctx);
|
|
47
|
+
}
|
|
48
|
+
function authorizeWithResolvedPath(policy, name, path, ctx) {
|
|
49
|
+
const target = resolveWriteTarget(path, ctx);
|
|
50
|
+
const decision = authorizeToolCall(policy, name, { path: target });
|
|
51
|
+
return decision.ok ? undefined : formatPolicyError(name, decision);
|
|
52
|
+
}
|
|
53
|
+
async function writeViaWorkspace(targetPath, content, ctx) {
|
|
54
|
+
const absoluteTarget = resolveWriteTarget(targetPath, ctx);
|
|
55
|
+
const root = workspaceRoot(ctx);
|
|
56
|
+
const rel = absoluteTarget.startsWith(root)
|
|
57
|
+
? relative(root, absoluteTarget)
|
|
58
|
+
: join('__external__', absoluteTarget.replace(/^([a-zA-Z]:)?[/\\]+/, ''));
|
|
59
|
+
const workspacePath = join(root, '.agent-workspace', rel);
|
|
60
|
+
await mkdir(dirname(workspacePath), { recursive: true });
|
|
61
|
+
await atomicWrite(workspacePath, content);
|
|
62
|
+
await mkdir(dirname(absoluteTarget), { recursive: true });
|
|
63
|
+
await atomicWrite(absoluteTarget, content);
|
|
64
|
+
return absoluteTarget;
|
|
65
|
+
}
|
|
66
|
+
async function atomicWrite(path, content) {
|
|
67
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
68
|
+
await writeFile(temp, content, 'utf8');
|
|
69
|
+
try {
|
|
70
|
+
await rename(temp, path);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const code = error.code;
|
|
74
|
+
if (!['EPERM', 'EEXIST', 'EACCES'].includes(code ?? '')) {
|
|
75
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
await rm(path, { force: true });
|
|
79
|
+
await rename(temp, path);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const SYMBOL_PATTERNS = {
|
|
83
|
+
'.ts': [
|
|
84
|
+
/^export\s+(?:async\s+)?(?:function|class|interface|type|enum|const|let)\s+(\w+)/m,
|
|
85
|
+
/^(?:async\s+)?(?:function|class)\s+(\w+)/m,
|
|
86
|
+
],
|
|
87
|
+
'.tsx': [
|
|
88
|
+
/^export\s+(?:async\s+)?(?:function|class|interface|type|const)\s+(\w+)/m,
|
|
89
|
+
],
|
|
90
|
+
'.js': [
|
|
91
|
+
/^(?:export\s+)?(?:async\s+)?(?:function|class|const)\s+(\w+)/m,
|
|
92
|
+
],
|
|
93
|
+
'.py': [
|
|
94
|
+
/^(?:async\s+)?def\s+(\w+)/m,
|
|
95
|
+
/^class\s+(\w+)/m,
|
|
96
|
+
],
|
|
97
|
+
'.rs': [
|
|
98
|
+
/^pub\s+(?:async\s+)?fn\s+(\w+)/m,
|
|
99
|
+
/^pub\s+struct\s+(\w+)/m,
|
|
100
|
+
/^pub\s+enum\s+(\w+)/m,
|
|
101
|
+
],
|
|
102
|
+
'.go': [
|
|
103
|
+
/^func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)/m,
|
|
104
|
+
/^type\s+(\w+)\s+struct/m,
|
|
105
|
+
],
|
|
106
|
+
'.lua': [
|
|
107
|
+
/^(?:local\s+)?function\s+(\w+)/m,
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
const IGNORE_DIRS = new Set([
|
|
111
|
+
'node_modules',
|
|
112
|
+
'.git',
|
|
113
|
+
'dist',
|
|
114
|
+
'build',
|
|
115
|
+
'.next',
|
|
116
|
+
'__pycache__',
|
|
117
|
+
'target',
|
|
118
|
+
'.cache',
|
|
119
|
+
'coverage',
|
|
120
|
+
'.nyc_output',
|
|
121
|
+
]);
|
|
122
|
+
async function extractFileSymbols(filePath) {
|
|
123
|
+
const ext = extname(filePath);
|
|
124
|
+
const patterns = SYMBOL_PATTERNS[ext];
|
|
125
|
+
if (!patterns)
|
|
126
|
+
return [];
|
|
127
|
+
try {
|
|
128
|
+
const src = await readFile(filePath, 'utf8');
|
|
129
|
+
const symbols = [];
|
|
130
|
+
for (const line of src.split('\n')) {
|
|
131
|
+
for (const pattern of patterns) {
|
|
132
|
+
const match = line.match(pattern);
|
|
133
|
+
if (match?.[1] && !symbols.includes(match[1])) {
|
|
134
|
+
symbols.push(match[1]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return symbols;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function walkDir(dir, maxDepth, depth = 0) {
|
|
145
|
+
if (depth > maxDepth)
|
|
146
|
+
return [];
|
|
147
|
+
const results = [];
|
|
148
|
+
try {
|
|
149
|
+
const entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' });
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
if (entry.name.startsWith('.') || IGNORE_DIRS.has(entry.name))
|
|
152
|
+
continue;
|
|
153
|
+
const full = `${dir}/${entry.name}`;
|
|
154
|
+
if (entry.isDirectory()) {
|
|
155
|
+
results.push(...await walkDir(full, maxDepth, depth + 1));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!entry.isFile() || !SYMBOL_PATTERNS[extname(entry.name)])
|
|
159
|
+
continue;
|
|
160
|
+
results.push({ path: full, symbols: await extractFileSymbols(full) });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
// ── Deterministic edit matching ──────────────────────────────────────────────
|
|
169
|
+
//
|
|
170
|
+
// LLMs frequently produce search strings with minor whitespace or indentation
|
|
171
|
+
// drift. We attempt deterministic normalization strategies in order — never
|
|
172
|
+
// similarity guessing — and require every candidate match to be unique in the
|
|
173
|
+
// file, reporting actionable errors on no-match or ambiguity.
|
|
174
|
+
function countOccurrences(haystack, needle) {
|
|
175
|
+
if (!needle)
|
|
176
|
+
return 0;
|
|
177
|
+
let count = 0;
|
|
178
|
+
for (let idx = haystack.indexOf(needle); idx !== -1; idx = haystack.indexOf(needle, idx + 1)) {
|
|
179
|
+
count += 1;
|
|
180
|
+
}
|
|
181
|
+
return count;
|
|
182
|
+
}
|
|
183
|
+
function lineNumbersOf(haystack, needle, limit) {
|
|
184
|
+
const lines = [];
|
|
185
|
+
for (let idx = haystack.indexOf(needle); idx !== -1 && lines.length < limit; idx = haystack.indexOf(needle, idx + 1)) {
|
|
186
|
+
lines.push(haystack.slice(0, idx).split('\n').length);
|
|
187
|
+
}
|
|
188
|
+
return lines;
|
|
189
|
+
}
|
|
190
|
+
function closestLineHints(content, search) {
|
|
191
|
+
const firstLine = search.split('\n').find((line) => line.trim())?.trim() ?? '';
|
|
192
|
+
if (!firstLine)
|
|
193
|
+
return [];
|
|
194
|
+
const hints = lineNumbersOf(content, firstLine, 3);
|
|
195
|
+
if (hints.length > 0 || firstLine.length <= 24)
|
|
196
|
+
return hints;
|
|
197
|
+
return lineNumbersOf(content, firstLine.slice(0, 24), 3);
|
|
198
|
+
}
|
|
199
|
+
function stripReadFileLineNumbers(search) {
|
|
200
|
+
const lines = search.split('\n');
|
|
201
|
+
const contentLines = lines.filter((line) => !line.startsWith('... (showing lines '));
|
|
202
|
+
if (contentLines.length === 0)
|
|
203
|
+
return undefined;
|
|
204
|
+
const hasLineNumbers = contentLines.every((line) => /^\d{5}\|/.test(line));
|
|
205
|
+
if (!hasLineNumbers)
|
|
206
|
+
return undefined;
|
|
207
|
+
return contentLines.map((line) => line.replace(/^\d{5}\|/, '')).join('\n');
|
|
208
|
+
}
|
|
209
|
+
function stripCommonIndent(s) {
|
|
210
|
+
const lines = s.split('\n');
|
|
211
|
+
const minIndent = lines
|
|
212
|
+
.filter((l) => l.trim())
|
|
213
|
+
.reduce((min, l) => Math.min(min, l.match(/^\s*/)?.[0].length ?? 0), Infinity);
|
|
214
|
+
return lines.map((l) => l.slice(minIndent === Infinity ? 0 : minIndent)).join('\n');
|
|
215
|
+
}
|
|
216
|
+
function unescapeEscapes(s) {
|
|
217
|
+
return s.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
218
|
+
}
|
|
219
|
+
function findUniqueMatch(content, search) {
|
|
220
|
+
const exactCount = countOccurrences(content, search);
|
|
221
|
+
if (exactCount === 1)
|
|
222
|
+
return { kind: 'ok', matched: search, strategy: 'exact' };
|
|
223
|
+
if (exactCount > 1) {
|
|
224
|
+
return { kind: 'ambiguous', matched: search, count: exactCount, lines: lineNumbersOf(content, search, 5) };
|
|
225
|
+
}
|
|
226
|
+
const contentLines = content.split('\n');
|
|
227
|
+
const searchLineCount = search.split('\n').length;
|
|
228
|
+
const searchTrimmed = search.split('\n').map((l) => l.trim()).join('\n');
|
|
229
|
+
const searchNorm = search.replace(/[\t ]+/g, ' ').trim();
|
|
230
|
+
const searchStripped = stripCommonIndent(search);
|
|
231
|
+
const searchEsc = unescapeEscapes(search);
|
|
232
|
+
const searchTrimBound = search.trim();
|
|
233
|
+
const strategies = [
|
|
234
|
+
{ name: 'line-trimmed', matches: (w) => w.split('\n').map((l) => l.trim()).join('\n') === searchTrimmed },
|
|
235
|
+
{ name: 'whitespace-normalized', matches: (w) => w.replace(/[\t ]+/g, ' ').trim() === searchNorm },
|
|
236
|
+
{ name: 'indentation-flexible', matches: (w) => stripCommonIndent(w) === searchStripped },
|
|
237
|
+
{ name: 'escape-normalized', matches: (w) => unescapeEscapes(w) === searchEsc },
|
|
238
|
+
{ name: 'trim-boundaries', matches: (w) => w.trim() === searchTrimBound },
|
|
239
|
+
];
|
|
240
|
+
for (const { name, matches } of strategies) {
|
|
241
|
+
for (let i = 0; i <= contentLines.length - searchLineCount; i++) {
|
|
242
|
+
const window = contentLines.slice(i, i + searchLineCount).join('\n');
|
|
243
|
+
if (!matches(window))
|
|
244
|
+
continue;
|
|
245
|
+
const count = countOccurrences(content, window);
|
|
246
|
+
if (count === 1)
|
|
247
|
+
return { kind: 'ok', matched: window, strategy: name };
|
|
248
|
+
return { kind: 'ambiguous', matched: window, count, lines: lineNumbersOf(content, window, 5) };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { kind: 'none' };
|
|
252
|
+
}
|
|
253
|
+
let defaultToolPolicy = defaultPolicy();
|
|
254
|
+
export function setToolPolicy(policy) {
|
|
255
|
+
defaultToolPolicy = clonePolicy(policy);
|
|
256
|
+
}
|
|
257
|
+
export function getToolPolicy() {
|
|
258
|
+
return clonePolicy(defaultToolPolicy);
|
|
259
|
+
}
|
|
260
|
+
async function withWriteLock(ctx, path, action) {
|
|
261
|
+
const release = await ctx?.acquireWriteLock?.(path);
|
|
262
|
+
try {
|
|
263
|
+
return await action();
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
await release?.();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
export const TOOLS = [
|
|
270
|
+
{
|
|
271
|
+
type: 'function',
|
|
272
|
+
function: {
|
|
273
|
+
name: 'read_file',
|
|
274
|
+
description: 'Read file content from disk. Returns line-numbered output (5-digit padded line numbers). Always call this before writing to an existing file. Use offset/limit to read large files in sections.',
|
|
275
|
+
parameters: {
|
|
276
|
+
type: 'object',
|
|
277
|
+
properties: {
|
|
278
|
+
path: { type: 'string', description: 'Absolute or relative path to the file' },
|
|
279
|
+
offset: { type: 'number', description: '1-based line number to start reading from (optional)' },
|
|
280
|
+
limit: { type: 'number', description: 'Maximum number of lines to read (optional)' },
|
|
281
|
+
},
|
|
282
|
+
required: ['path'],
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
type: 'function',
|
|
288
|
+
function: {
|
|
289
|
+
name: 'edit_file',
|
|
290
|
+
description: `Apply one or more targeted search-replace edits to an existing file.
|
|
291
|
+
Each edit must match the file exactly; every match is required to be unique.
|
|
292
|
+
Use this instead of write_file when modifying an existing file — it is safer
|
|
293
|
+
and preserves surrounding context.
|
|
294
|
+
|
|
295
|
+
Format: provide a JSON array of {search, replace} pairs.
|
|
296
|
+
- "search" must be an exact substring of the current file content (including indentation/newlines).
|
|
297
|
+
Minor whitespace/indentation drift and literal \\n escapes are normalized deterministically,
|
|
298
|
+
but near-miss guesses are never accepted.
|
|
299
|
+
- "replace" is the new content that replaces it.
|
|
300
|
+
- Edits are applied in order; each operates on the result of the previous.
|
|
301
|
+
- To delete a block, set "replace" to "".
|
|
302
|
+
- Multiple matches fail; add surrounding context to disambiguate, or set replaceAll to true.`,
|
|
303
|
+
parameters: {
|
|
304
|
+
type: 'object',
|
|
305
|
+
properties: {
|
|
306
|
+
path: { type: 'string', description: 'Path to the file to edit' },
|
|
307
|
+
edits: { type: 'string', description: 'JSON array of {search, replace} objects' },
|
|
308
|
+
expectedReplacements: { type: 'number', description: 'Expected number of occurrences to replace per edit (default 1). The actual count must equal this or the edit fails with the actual count.' },
|
|
309
|
+
replaceAll: { type: 'boolean', description: 'Replace every occurrence of each search string (default false)' },
|
|
310
|
+
},
|
|
311
|
+
required: ['path', 'edits'],
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
type: 'function',
|
|
317
|
+
function: {
|
|
318
|
+
name: 'repo_map',
|
|
319
|
+
description: `Generate a concise symbol map of the repository — files and their top-level
|
|
320
|
+
exported symbols (functions, classes, types, etc.). Use this at the start of a task to
|
|
321
|
+
understand the codebase structure without reading every file. Returns a compact text outline.`,
|
|
322
|
+
parameters: {
|
|
323
|
+
type: 'object',
|
|
324
|
+
properties: {
|
|
325
|
+
root: { type: 'string', description: 'Root directory to scan (default: ".")' },
|
|
326
|
+
max_depth: { type: 'string', description: 'Max directory depth to walk (default: "6")' },
|
|
327
|
+
},
|
|
328
|
+
required: [],
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
type: 'function',
|
|
334
|
+
function: {
|
|
335
|
+
name: 'write_file',
|
|
336
|
+
description: 'Create or overwrite a file with complete content. Always provide the full file — never a partial diff.',
|
|
337
|
+
parameters: {
|
|
338
|
+
type: 'object',
|
|
339
|
+
properties: {
|
|
340
|
+
path: { type: 'string', description: 'Absolute or relative path to the file' },
|
|
341
|
+
content: { type: 'string', description: 'Complete file content to write' },
|
|
342
|
+
},
|
|
343
|
+
required: ['path', 'content'],
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
type: 'function',
|
|
349
|
+
function: {
|
|
350
|
+
name: 'list_dir',
|
|
351
|
+
description: 'List files and subdirectories inside a directory.',
|
|
352
|
+
parameters: {
|
|
353
|
+
type: 'object',
|
|
354
|
+
properties: {
|
|
355
|
+
path: { type: 'string', description: 'Directory path to list' },
|
|
356
|
+
},
|
|
357
|
+
required: ['path'],
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
type: 'function',
|
|
363
|
+
function: {
|
|
364
|
+
name: 'read_files',
|
|
365
|
+
description: 'Read several files in one call. Returns independently labelled, line-numbered sections and continues when one file is missing.',
|
|
366
|
+
parameters: {
|
|
367
|
+
type: 'object',
|
|
368
|
+
properties: {
|
|
369
|
+
paths: { type: 'array', description: 'File paths to read', items: { type: 'string' } },
|
|
370
|
+
max_lines: { type: 'number', description: 'Maximum lines per file (default 400)' },
|
|
371
|
+
},
|
|
372
|
+
required: ['paths'],
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
type: 'function',
|
|
378
|
+
function: {
|
|
379
|
+
name: 'file_info',
|
|
380
|
+
description: 'Return safe metadata for a file or directory without reading its contents.',
|
|
381
|
+
parameters: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
properties: { path: { type: 'string', description: 'File or directory path' } },
|
|
384
|
+
required: ['path'],
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
type: 'function',
|
|
390
|
+
function: {
|
|
391
|
+
name: 'search_text',
|
|
392
|
+
description: '在仓库中搜索文本或正则表达式,返回紧凑的文件、行号和匹配内容。优先使用本工具而不是 bash rg。',
|
|
393
|
+
parameters: {
|
|
394
|
+
type: 'object',
|
|
395
|
+
properties: {
|
|
396
|
+
query: { type: 'string', description: '搜索文本或正则表达式' },
|
|
397
|
+
path: { type: 'string', description: '搜索根目录,默认当前目录' },
|
|
398
|
+
glob: { type: 'string', description: '可选文件 glob,例如 *.ts' },
|
|
399
|
+
max_results: { type: 'number', description: '最大结果数,默认 100' },
|
|
400
|
+
},
|
|
401
|
+
required: ['query'],
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
type: 'function',
|
|
407
|
+
function: {
|
|
408
|
+
name: 'search_files',
|
|
409
|
+
description: '按 glob 列出仓库文件,自动忽略 .git 和 node_modules。',
|
|
410
|
+
parameters: {
|
|
411
|
+
type: 'object',
|
|
412
|
+
properties: {
|
|
413
|
+
glob: { type: 'string', description: '文件 glob,例如 **/*.ts' },
|
|
414
|
+
path: { type: 'string', description: '搜索根目录,默认当前目录' },
|
|
415
|
+
max_results: { type: 'number', description: '最大结果数,默认 200' },
|
|
416
|
+
},
|
|
417
|
+
required: ['glob'],
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
type: 'function',
|
|
423
|
+
function: {
|
|
424
|
+
name: 'web_search',
|
|
425
|
+
description: '在互联网上搜索网页,返回标题、链接和摘要。用于需要最新信息、外部资料或超出本地仓库知识的问题。',
|
|
426
|
+
parameters: {
|
|
427
|
+
type: 'object',
|
|
428
|
+
properties: {
|
|
429
|
+
query: { type: 'string', description: '搜索关键词或问题' },
|
|
430
|
+
max_results: { type: 'number', description: '最大结果数,默认 8,最大 20' },
|
|
431
|
+
},
|
|
432
|
+
required: ['query'],
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
type: 'function',
|
|
438
|
+
function: {
|
|
439
|
+
name: 'git_status',
|
|
440
|
+
description: '返回当前仓库的紧凑 Git 状态。只读。',
|
|
441
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
type: 'function',
|
|
446
|
+
function: {
|
|
447
|
+
name: 'git_diff',
|
|
448
|
+
description: '返回工作树或暂存区差异,可限制文件。只读。',
|
|
449
|
+
parameters: {
|
|
450
|
+
type: 'object',
|
|
451
|
+
properties: {
|
|
452
|
+
path: { type: 'string', description: '可选文件路径' },
|
|
453
|
+
staged: { type: 'boolean', description: '是否查看暂存区差异' },
|
|
454
|
+
},
|
|
455
|
+
required: [],
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
type: 'function',
|
|
461
|
+
function: {
|
|
462
|
+
name: 'git_log',
|
|
463
|
+
description: 'Show recent commits with subject, author and date. Read-only and optionally scoped to a path.',
|
|
464
|
+
parameters: {
|
|
465
|
+
type: 'object',
|
|
466
|
+
properties: {
|
|
467
|
+
path: { type: 'string', description: 'Optional repository-relative path' },
|
|
468
|
+
max_count: { type: 'number', description: 'Maximum commits (default 20, max 100)' },
|
|
469
|
+
},
|
|
470
|
+
required: [],
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
type: 'function',
|
|
476
|
+
function: {
|
|
477
|
+
name: 'bash',
|
|
478
|
+
description: process.platform === 'win32'
|
|
479
|
+
? 'Execute a shell command and return stdout + stderr. Use for builds, tests, git, installs, etc. NOTE: on Windows this runs cmd.exe — Unix tools like grep/sed/awk/ripgrep are unavailable; use the built-in search_text/read_file tools instead.'
|
|
480
|
+
: 'Execute a shell command and return stdout + stderr. Use for builds, tests, git, installs, etc.',
|
|
481
|
+
parameters: {
|
|
482
|
+
type: 'object',
|
|
483
|
+
properties: {
|
|
484
|
+
command: { type: 'string', description: 'Shell command to execute' },
|
|
485
|
+
cwd: { type: 'string', description: '可选工作目录' },
|
|
486
|
+
timeout_ms: { type: 'number', description: '超时毫秒数,默认 60000,最大 300000' },
|
|
487
|
+
},
|
|
488
|
+
required: ['command'],
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
type: 'function',
|
|
494
|
+
function: {
|
|
495
|
+
name: 'load_skill',
|
|
496
|
+
description: 'Load a reusable skill definition by name. Skills provide domain-specific instructions, conventions, and project structure guidelines.',
|
|
497
|
+
parameters: {
|
|
498
|
+
type: 'object',
|
|
499
|
+
properties: {
|
|
500
|
+
name: { type: 'string', description: 'Skill name to load' },
|
|
501
|
+
},
|
|
502
|
+
required: ['name'],
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
];
|
|
507
|
+
export const WORKER_TOOLS = TOOLS;
|
|
508
|
+
const TOOL_METADATA = {
|
|
509
|
+
read_file: { effect: 'read', category: 'filesystem' },
|
|
510
|
+
read_files: { effect: 'read', category: 'filesystem' },
|
|
511
|
+
file_info: { effect: 'read', category: 'filesystem' },
|
|
512
|
+
edit_file: { effect: 'write', category: 'filesystem' },
|
|
513
|
+
write_file: { effect: 'write', category: 'filesystem' },
|
|
514
|
+
list_dir: { effect: 'read', category: 'filesystem' },
|
|
515
|
+
repo_map: { effect: 'read', category: 'search' },
|
|
516
|
+
search_text: { effect: 'read', category: 'search' },
|
|
517
|
+
search_files: { effect: 'read', category: 'search' },
|
|
518
|
+
web_search: { effect: 'execute', category: 'web' },
|
|
519
|
+
git_status: { effect: 'read', category: 'git' },
|
|
520
|
+
git_diff: { effect: 'read', category: 'git' },
|
|
521
|
+
git_log: { effect: 'read', category: 'git' },
|
|
522
|
+
bash: { effect: 'execute', category: 'shell' },
|
|
523
|
+
load_skill: { effect: 'read', category: 'agent' },
|
|
524
|
+
};
|
|
525
|
+
function parseStringArray(value) {
|
|
526
|
+
if (Array.isArray(value))
|
|
527
|
+
return value.filter((item) => typeof item === 'string');
|
|
528
|
+
if (typeof value !== 'string')
|
|
529
|
+
return undefined;
|
|
530
|
+
try {
|
|
531
|
+
const parsed = JSON.parse(value);
|
|
532
|
+
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : undefined;
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async function readLineRange(filePath, offset = 1, limit) {
|
|
539
|
+
const raw = await readFile(filePath, 'utf8');
|
|
540
|
+
if (raw === '')
|
|
541
|
+
return '';
|
|
542
|
+
const allLines = raw.split('\n');
|
|
543
|
+
const totalLines = allLines.length;
|
|
544
|
+
const startLine = Math.max(1, Math.min(offset, totalLines));
|
|
545
|
+
const endLine = limit !== undefined ? Math.min(startLine + Math.max(1, limit) - 1, totalLines) : totalLines;
|
|
546
|
+
const numbered = allLines.slice(startLine - 1, endLine).map((line, index) => (`${String(startLine + index).padStart(5, '0')}|${line}`)).join('\n');
|
|
547
|
+
return endLine < totalLines
|
|
548
|
+
? `${numbered}\n... (showing lines ${startLine}-${endLine} of ${totalLines}; use offset/limit to read more)`
|
|
549
|
+
: numbered;
|
|
550
|
+
}
|
|
551
|
+
function boundedOutput(value, maxChars = 4 * 1024 * 1024) {
|
|
552
|
+
if (value.length <= maxChars)
|
|
553
|
+
return value;
|
|
554
|
+
return `${value.slice(0, maxChars)}\n... (output truncated at ${maxChars} characters)`;
|
|
555
|
+
}
|
|
556
|
+
// ── Pure-Node search fallback (used when `rg` is not installed) ──────────────
|
|
557
|
+
// Mirrors the rg invocations in `search_text`/`search_files`: hidden files are
|
|
558
|
+
// included, only `.git` and `node_modules` are skipped.
|
|
559
|
+
const SEARCH_IGNORE_DIRS = new Set(['.git', 'node_modules']);
|
|
560
|
+
function forwardSlash(path) {
|
|
561
|
+
return path.replace(/\\/g, '/');
|
|
562
|
+
}
|
|
563
|
+
function trimSnippet(line, max = 300) {
|
|
564
|
+
const text = line.replace(/\s+$/g, '');
|
|
565
|
+
return text.length > max ? `…${text.slice(-max)}` : text;
|
|
566
|
+
}
|
|
567
|
+
/** Convert a common glob (**, *, ?) into an anchored RegExp. */
|
|
568
|
+
function globToRegExp(glob) {
|
|
569
|
+
const sentinel = [
|
|
570
|
+
[/\*\*\//g, '\u0001'],
|
|
571
|
+
[/\*\*/g, '\u0002'],
|
|
572
|
+
[/\*/g, '\u0003'],
|
|
573
|
+
[/\?/g, '\u0004'],
|
|
574
|
+
];
|
|
575
|
+
let source = forwardSlash(glob);
|
|
576
|
+
for (const [re, token] of sentinel)
|
|
577
|
+
source = source.replace(re, token);
|
|
578
|
+
source = source.replace(/([.+^${}()|[\]\\])/g, '\\$&');
|
|
579
|
+
source = source
|
|
580
|
+
.replace(/\u0001/g, '(?:.*/)?')
|
|
581
|
+
.replace(/\u0002/g, '.*')
|
|
582
|
+
.replace(/\u0003/g, '[^/]*')
|
|
583
|
+
.replace(/\u0004/g, '[^/]');
|
|
584
|
+
return new RegExp(`^${source}$`);
|
|
585
|
+
}
|
|
586
|
+
function globMatches(glob, filePath, root) {
|
|
587
|
+
const rel = forwardSlash(relative(root, filePath));
|
|
588
|
+
if (glob.includes('/'))
|
|
589
|
+
return globToRegExp(glob).test(rel);
|
|
590
|
+
const re = globToRegExp(glob);
|
|
591
|
+
return re.test(basename(rel)) || re.test(rel);
|
|
592
|
+
}
|
|
593
|
+
async function walkAllFiles(root, signal, maxFiles = 200_000) {
|
|
594
|
+
const files = [];
|
|
595
|
+
const stack = [root];
|
|
596
|
+
while (stack.length > 0) {
|
|
597
|
+
if (signal?.aborted)
|
|
598
|
+
throw new Error('search aborted');
|
|
599
|
+
const dir = stack.pop();
|
|
600
|
+
let entries;
|
|
601
|
+
try {
|
|
602
|
+
entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' });
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
for (const entry of entries) {
|
|
608
|
+
if (files.length >= maxFiles)
|
|
609
|
+
return files;
|
|
610
|
+
if (SEARCH_IGNORE_DIRS.has(entry.name))
|
|
611
|
+
continue;
|
|
612
|
+
const full = join(dir, entry.name);
|
|
613
|
+
if (entry.isDirectory()) {
|
|
614
|
+
stack.push(full);
|
|
615
|
+
}
|
|
616
|
+
else if (entry.isFile()) {
|
|
617
|
+
files.push(full);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return files;
|
|
622
|
+
}
|
|
623
|
+
export async function searchTextFallback(root, query, glob, max, signal) {
|
|
624
|
+
let pattern;
|
|
625
|
+
try {
|
|
626
|
+
pattern = new RegExp(query, 'm');
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'm');
|
|
630
|
+
}
|
|
631
|
+
const files = await walkAllFiles(root, signal);
|
|
632
|
+
const results = [];
|
|
633
|
+
for (const file of files) {
|
|
634
|
+
if (signal?.aborted)
|
|
635
|
+
throw new Error('search aborted');
|
|
636
|
+
if (results.length >= max)
|
|
637
|
+
break;
|
|
638
|
+
if (glob && !globMatches(glob, file, root))
|
|
639
|
+
continue;
|
|
640
|
+
let statInfo;
|
|
641
|
+
try {
|
|
642
|
+
statInfo = await stat(file);
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (statInfo.size > 8 * 1024 * 1024)
|
|
648
|
+
continue;
|
|
649
|
+
let text;
|
|
650
|
+
try {
|
|
651
|
+
text = await readFile(file, 'utf8');
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const lines = text.split(/\r?\n/);
|
|
657
|
+
for (let index = 0; index < lines.length && results.length < max; index++) {
|
|
658
|
+
if (pattern.test(lines[index])) {
|
|
659
|
+
results.push(`${forwardSlash(relative(root, file))}:${index + 1}:${trimSnippet(lines[index])}`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return results;
|
|
664
|
+
}
|
|
665
|
+
function throwIfAborted(signal) {
|
|
666
|
+
if (signal?.aborted)
|
|
667
|
+
throw new Error('search aborted');
|
|
668
|
+
}
|
|
669
|
+
async function searchFilesFallback(root, glob, max, signal) {
|
|
670
|
+
const files = await walkAllFiles(root, signal);
|
|
671
|
+
const matches = [];
|
|
672
|
+
for (const file of files) {
|
|
673
|
+
throwIfAborted(signal);
|
|
674
|
+
if (matches.length >= max)
|
|
675
|
+
break;
|
|
676
|
+
if (!globMatches(glob, file, root))
|
|
677
|
+
continue;
|
|
678
|
+
matches.push(forwardSlash(relative(root, file)));
|
|
679
|
+
}
|
|
680
|
+
return matches;
|
|
681
|
+
}
|
|
682
|
+
function decodeHtmlEntities(input) {
|
|
683
|
+
const namedEntities = {
|
|
684
|
+
ensp: ' ', emsp: ' ', thinsp: ' ', middot: '·', ndash: '–', mdash: '—',
|
|
685
|
+
hellip: '…', laquo: '«', raquo: '»', lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”',
|
|
686
|
+
copy: '©', reg: '®', trade: '™', bull: '•', deg: '°', plusmn: '±', times: '×', divide: '÷',
|
|
687
|
+
};
|
|
688
|
+
return input
|
|
689
|
+
.replace(/&#x([0-9a-f]+);?/gi, (_match, value) => {
|
|
690
|
+
const codePoint = Number.parseInt(value, 16);
|
|
691
|
+
return Number.isFinite(codePoint) && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : _match;
|
|
692
|
+
})
|
|
693
|
+
.replace(/&#(\d+);?/g, (_match, value) => {
|
|
694
|
+
const codePoint = Number.parseInt(value, 10);
|
|
695
|
+
return Number.isFinite(codePoint) && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : _match;
|
|
696
|
+
})
|
|
697
|
+
.replace(/'/g, "'")
|
|
698
|
+
.replace(/'/g, "'")
|
|
699
|
+
.replace(///g, '/')
|
|
700
|
+
.replace(/"/g, '"')
|
|
701
|
+
.replace(/</g, '<')
|
|
702
|
+
.replace(/>/g, '>')
|
|
703
|
+
.replace(/ /g, ' ')
|
|
704
|
+
.replace(/&([a-z][a-z0-9]+);/gi, (match, name) => namedEntities[name.toLowerCase()] ?? match)
|
|
705
|
+
.replace(/&/g, '&');
|
|
706
|
+
}
|
|
707
|
+
function stripTags(input) {
|
|
708
|
+
return input.replace(/<[^>]*>/g, '');
|
|
709
|
+
}
|
|
710
|
+
function collapseWhitespace(value) {
|
|
711
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
712
|
+
}
|
|
713
|
+
function ddgRealUrl(href) {
|
|
714
|
+
const redirect = /[?&]uddg=([^&]+)/.exec(href ?? '');
|
|
715
|
+
if (redirect?.[1]) {
|
|
716
|
+
try {
|
|
717
|
+
return decodeURIComponent(redirect[1]);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
return href;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return href.startsWith('//') ? `https:${href}` : href;
|
|
724
|
+
}
|
|
725
|
+
function bingRealUrl(href) {
|
|
726
|
+
const value = href ?? '';
|
|
727
|
+
const encoded = /[?&]u=([^&]+)/.exec(value)?.[1];
|
|
728
|
+
if (encoded) {
|
|
729
|
+
try {
|
|
730
|
+
const decoded = decodeURIComponent(encoded);
|
|
731
|
+
if (decoded.startsWith('a1')) {
|
|
732
|
+
const payload = decoded.slice(2).replace(/-/g, '+').replace(/_/g, '/');
|
|
733
|
+
const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, '=');
|
|
734
|
+
const target = Buffer.from(padded, 'base64').toString('utf8');
|
|
735
|
+
if (/^https?:\/\//i.test(target))
|
|
736
|
+
return target;
|
|
737
|
+
}
|
|
738
|
+
if (/^https?:\/\//i.test(decoded))
|
|
739
|
+
return decoded;
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
// Keep the provider URL when redirect decoding fails.
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return value.startsWith('//') ? `https:${value}` : value;
|
|
746
|
+
}
|
|
747
|
+
function parseDdgResults(html, max, signal) {
|
|
748
|
+
const titles = [];
|
|
749
|
+
const titleRe = /<a[^>]*class=["'][^"']*result__a[^"']*["'][^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
750
|
+
for (const match of html.matchAll(titleRe)) {
|
|
751
|
+
throwIfAborted(signal);
|
|
752
|
+
const title = collapseWhitespace(stripTags(decodeHtmlEntities(match[2] ?? '')));
|
|
753
|
+
if (!title)
|
|
754
|
+
continue;
|
|
755
|
+
titles.push({ title, url: ddgRealUrl(decodeHtmlEntities(match[1] ?? '')) });
|
|
756
|
+
if (titles.length >= max)
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
const snippets = [];
|
|
760
|
+
const snippetRe = /<a[^>]*class=["'][^"']*result__snippet[^"']*["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
761
|
+
for (const match of html.matchAll(snippetRe)) {
|
|
762
|
+
snippets.push(collapseWhitespace(stripTags(decodeHtmlEntities(match[1] ?? ''))));
|
|
763
|
+
if (snippets.length >= titles.length)
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
return titles.map((item, index) => ({ ...item, snippet: snippets[index] ?? '' }));
|
|
767
|
+
}
|
|
768
|
+
function parseBingResults(html, max, signal) {
|
|
769
|
+
const results = [];
|
|
770
|
+
const itemRe = /<li[^>]*class=["'][^"']*b_algo[^"']*["'][^>]*>([\s\S]*?)<\/li>/gi;
|
|
771
|
+
for (const item of html.matchAll(itemRe)) {
|
|
772
|
+
throwIfAborted(signal);
|
|
773
|
+
const block = item[1] ?? '';
|
|
774
|
+
const titleMatch = /<h2[^>]*>[\s\S]*?<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>/i.exec(block);
|
|
775
|
+
if (!titleMatch)
|
|
776
|
+
continue;
|
|
777
|
+
const title = collapseWhitespace(stripTags(decodeHtmlEntities(titleMatch[2] ?? '')));
|
|
778
|
+
if (!title)
|
|
779
|
+
continue;
|
|
780
|
+
const snippetMatch = /<p[^>]*>([\s\S]*?)<\/p>/i.exec(block);
|
|
781
|
+
results.push({
|
|
782
|
+
title,
|
|
783
|
+
url: bingRealUrl(decodeHtmlEntities(titleMatch[1] ?? '')),
|
|
784
|
+
snippet: collapseWhitespace(stripTags(decodeHtmlEntities(snippetMatch?.[1] ?? ''))),
|
|
785
|
+
});
|
|
786
|
+
if (results.length >= max)
|
|
787
|
+
break;
|
|
788
|
+
}
|
|
789
|
+
return results;
|
|
790
|
+
}
|
|
791
|
+
const SEARCH_PROVIDERS = [
|
|
792
|
+
{
|
|
793
|
+
name: 'Bing',
|
|
794
|
+
url: 'https://www.bing.com/search?q=',
|
|
795
|
+
parse: parseBingResults,
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
name: 'DuckDuckGo',
|
|
799
|
+
url: 'https://html.duckduckgo.com/html/?q=',
|
|
800
|
+
parse: parseDdgResults,
|
|
801
|
+
},
|
|
802
|
+
];
|
|
803
|
+
async function searchWeb(query, max, signal) {
|
|
804
|
+
const errors = [];
|
|
805
|
+
for (const provider of SEARCH_PROVIDERS) {
|
|
806
|
+
throwIfAborted(signal);
|
|
807
|
+
try {
|
|
808
|
+
const response = await resilientFetch(`${provider.url}${encodeURIComponent(query)}${provider.name === 'Bing' ? `&count=${max}` : ''}`, {
|
|
809
|
+
headers: {
|
|
810
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
|
|
811
|
+
'Accept': 'text/html,application/xhtml+xml',
|
|
812
|
+
'Accept-Language': 'en-US,en;q=0.8',
|
|
813
|
+
},
|
|
814
|
+
retries: 0,
|
|
815
|
+
timeout: 10_000,
|
|
816
|
+
signal,
|
|
817
|
+
});
|
|
818
|
+
const html = await response.text();
|
|
819
|
+
const results = provider.parse(html, max, signal);
|
|
820
|
+
if (results.length > 0)
|
|
821
|
+
return results;
|
|
822
|
+
errors.push(`${provider.name}: no results returned`);
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
if (signal?.aborted)
|
|
826
|
+
throw error;
|
|
827
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
828
|
+
errors.push(`${provider.name}: ${message}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
throw new Error(`all web search providers failed (${errors.join('; ')})`);
|
|
832
|
+
}
|
|
833
|
+
async function executeBuiltinTool(name, args, ctx) {
|
|
834
|
+
const policy = ctx?.policy ?? getToolPolicy();
|
|
835
|
+
const decision = authorizeToolCall(policy, name, args);
|
|
836
|
+
if (!decision.ok)
|
|
837
|
+
return formatPolicyError(name, decision);
|
|
838
|
+
switch (name) {
|
|
839
|
+
case 'search_text': {
|
|
840
|
+
const query = typeof args['query'] === 'string' ? args['query'] : '';
|
|
841
|
+
if (!query)
|
|
842
|
+
return JSON.stringify({ ok: false, error: 'query is required' });
|
|
843
|
+
const root = resolveToolPath(typeof args['path'] === 'string' ? args['path'] : '.', ctx);
|
|
844
|
+
const max = Math.max(1, Math.min(Number(args['max_results'] ?? 100), 500));
|
|
845
|
+
const glob = typeof args['glob'] === 'string' ? args['glob'] : undefined;
|
|
846
|
+
try {
|
|
847
|
+
const rgArgs = ['--line-number', '--no-heading', '--color', 'never', '--hidden', '--glob', '!.git', '--glob', '!node_modules', ...(glob ? ['--glob', glob] : []), query, root];
|
|
848
|
+
const result = await execFileAsync('rg', rgArgs, { cwd: workspaceRoot(ctx), maxBuffer: 1024 * 1024 * 2, timeout: 30_000, signal: ctx?.signal });
|
|
849
|
+
const lines = result.stdout.split(/\r?\n/).filter(Boolean).slice(0, max);
|
|
850
|
+
return JSON.stringify({ ok: true, results: lines, truncated: lines.length >= max });
|
|
851
|
+
}
|
|
852
|
+
catch (error) {
|
|
853
|
+
if (error?.code === 1)
|
|
854
|
+
return JSON.stringify({ ok: true, results: [], truncated: false });
|
|
855
|
+
try {
|
|
856
|
+
const results = await searchTextFallback(root, query, glob, max, ctx?.signal);
|
|
857
|
+
return JSON.stringify({ ok: true, results, truncated: results.length >= max });
|
|
858
|
+
}
|
|
859
|
+
catch (fallbackError) {
|
|
860
|
+
const rgMessage = error?.message ?? String(error);
|
|
861
|
+
const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
|
862
|
+
return JSON.stringify({ ok: false, error: fallbackMessage ? `${fallbackMessage} (rg: ${rgMessage})` : rgMessage });
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
case 'search_files': {
|
|
867
|
+
const glob = typeof args['glob'] === 'string' ? args['glob'] : '*';
|
|
868
|
+
const root = resolveToolPath(typeof args['path'] === 'string' ? args['path'] : '.', ctx);
|
|
869
|
+
const max = Math.max(1, Math.min(Number(args['max_results'] ?? 200), 1000));
|
|
870
|
+
try {
|
|
871
|
+
const result = await execFileAsync('rg', ['--files', '--hidden', '--glob', '!.git', '--glob', '!node_modules', '--glob', glob, root], { cwd: workspaceRoot(ctx), maxBuffer: 1024 * 1024 * 2, timeout: 30_000, signal: ctx?.signal });
|
|
872
|
+
const files = result.stdout.split(/\r?\n/).filter(Boolean).slice(0, max);
|
|
873
|
+
return JSON.stringify({ ok: true, files, truncated: files.length >= max });
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
if (error?.code === 1)
|
|
877
|
+
return JSON.stringify({ ok: true, files: [], truncated: false });
|
|
878
|
+
if (error?.code === 'ENOENT') {
|
|
879
|
+
try {
|
|
880
|
+
const files = await searchFilesFallback(root, glob, max, ctx?.signal);
|
|
881
|
+
return JSON.stringify({ ok: true, files, truncated: files.length >= max });
|
|
882
|
+
}
|
|
883
|
+
catch (fallbackError) {
|
|
884
|
+
return JSON.stringify({ ok: false, error: String(fallbackError instanceof Error ? fallbackError.message : fallbackError) });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return JSON.stringify({ ok: false, error: String(error?.message ?? error) });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
case 'web_search': {
|
|
891
|
+
const query = typeof args['query'] === 'string' ? args['query'] : '';
|
|
892
|
+
if (!query)
|
|
893
|
+
return JSON.stringify({ ok: false, error: 'query is required' });
|
|
894
|
+
const requestedMax = Number(args['max_results'] ?? 8);
|
|
895
|
+
const max = Number.isFinite(requestedMax)
|
|
896
|
+
? Math.max(1, Math.min(Math.floor(requestedMax), 20))
|
|
897
|
+
: 8;
|
|
898
|
+
try {
|
|
899
|
+
const results = await searchWeb(query, max, ctx?.signal);
|
|
900
|
+
return JSON.stringify({ ok: true, results, truncated: results.length >= max });
|
|
901
|
+
}
|
|
902
|
+
catch (error) {
|
|
903
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
904
|
+
const friendly = /fetch failed|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|DNS lookup failed|connection refused|connection reset|timeout|network/i.test(message)
|
|
905
|
+
? '网络不可达,无法完成网页搜索(请检查本机网络连接或代理设置)'
|
|
906
|
+
: message;
|
|
907
|
+
return JSON.stringify({ ok: false, error: friendly });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
case 'git_status': {
|
|
911
|
+
try {
|
|
912
|
+
const result = await execFileAsync('git', ['status', '--short', '--branch'], { cwd: workspaceRoot(ctx), timeout: 15_000, signal: ctx?.signal });
|
|
913
|
+
return JSON.stringify({ ok: true, status: result.stdout.trim() });
|
|
914
|
+
}
|
|
915
|
+
catch (error) {
|
|
916
|
+
return JSON.stringify({ ok: false, error: String(error?.message ?? error) });
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
case 'git_diff': {
|
|
920
|
+
const path = typeof args['path'] === 'string' ? args['path'] : undefined;
|
|
921
|
+
const staged = args['staged'] === true;
|
|
922
|
+
try {
|
|
923
|
+
const result = await execFileAsync('git', ['diff', ...(staged ? ['--cached'] : []), '--', ...(path ? [path] : [])], { cwd: workspaceRoot(ctx), maxBuffer: 1024 * 1024 * 4, timeout: 20_000, signal: ctx?.signal });
|
|
924
|
+
return JSON.stringify({ ok: true, diff: result.stdout, truncated: false });
|
|
925
|
+
}
|
|
926
|
+
catch (error) {
|
|
927
|
+
return JSON.stringify({ ok: false, error: String(error?.message ?? error) });
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
case 'read_file': {
|
|
931
|
+
const path = typeof args['path'] === 'string' ? args['path'] : undefined;
|
|
932
|
+
if (!path)
|
|
933
|
+
return 'Error: read_file requires "path"';
|
|
934
|
+
const offsetArg = typeof args['offset'] === 'number' ? args['offset'] : undefined;
|
|
935
|
+
const limitArg = typeof args['limit'] === 'number' ? args['limit'] : undefined;
|
|
936
|
+
try {
|
|
937
|
+
return await readLineRange(resolveToolPath(path, ctx), offsetArg, limitArg);
|
|
938
|
+
}
|
|
939
|
+
catch (error) {
|
|
940
|
+
return `Error reading file: ${String(error)}`;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
case 'read_files': {
|
|
944
|
+
const paths = parseStringArray(args['paths']);
|
|
945
|
+
if (!paths?.length)
|
|
946
|
+
return 'Error: read_files requires a non-empty "paths" array';
|
|
947
|
+
if (paths.length > 50)
|
|
948
|
+
return 'Error: read_files accepts at most 50 paths';
|
|
949
|
+
const maxLines = Math.max(1, Math.min(Number(args['max_lines'] ?? 400), 5000));
|
|
950
|
+
const sections = [];
|
|
951
|
+
for (const path of paths) {
|
|
952
|
+
const decision = authorizeToolCall(policy, 'read_file', { path });
|
|
953
|
+
if (!decision.ok) {
|
|
954
|
+
sections.push(`===== ${path} =====\n${formatPolicyError('read_files', decision)}`);
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
sections.push(`===== ${path} =====\n${await readLineRange(resolveToolPath(path, ctx), 1, maxLines)}`);
|
|
959
|
+
}
|
|
960
|
+
catch (error) {
|
|
961
|
+
sections.push(`===== ${path} =====\nError reading file: ${String(error)}`);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return boundedOutput(sections.join('\n\n'));
|
|
965
|
+
}
|
|
966
|
+
case 'file_info': {
|
|
967
|
+
const path = typeof args['path'] === 'string' ? args['path'] : '';
|
|
968
|
+
if (!path)
|
|
969
|
+
return 'Error: file_info requires "path"';
|
|
970
|
+
try {
|
|
971
|
+
const info = await stat(resolveToolPath(path, ctx));
|
|
972
|
+
return JSON.stringify({
|
|
973
|
+
ok: true,
|
|
974
|
+
path,
|
|
975
|
+
type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symlink' : 'other',
|
|
976
|
+
size: info.size,
|
|
977
|
+
modifiedAt: info.mtime.toISOString(),
|
|
978
|
+
createdAt: info.birthtime.toISOString(),
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
catch (error) {
|
|
982
|
+
return JSON.stringify({ ok: false, path, error: String(error) });
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
case 'edit_file': {
|
|
986
|
+
const path = typeof args['path'] === 'string' ? args['path'] : undefined;
|
|
987
|
+
const editsArg = args['edits'];
|
|
988
|
+
if (!path)
|
|
989
|
+
return 'Error: edit_file requires "path"';
|
|
990
|
+
if (editsArg === undefined || editsArg === null || editsArg === '')
|
|
991
|
+
return 'Error: edit_file requires "edits"';
|
|
992
|
+
const globalExpected = typeof args['expectedReplacements'] === 'number' ? args['expectedReplacements'] : undefined;
|
|
993
|
+
const globalReplaceAll = args['replaceAll'] === true;
|
|
994
|
+
const targetPath = resolveWriteTarget(path, ctx);
|
|
995
|
+
const readDecision = authorizeToolCall(policy, 'read_file', { path: targetPath });
|
|
996
|
+
if (!readDecision.ok)
|
|
997
|
+
return formatPolicyError('edit_file', readDecision);
|
|
998
|
+
const writeDecision = authorizeToolCall(policy, 'edit_file', { path: targetPath });
|
|
999
|
+
if (!writeDecision.ok)
|
|
1000
|
+
return formatPolicyError('edit_file', writeDecision);
|
|
1001
|
+
return withWriteLock(ctx, targetPath, async () => {
|
|
1002
|
+
let src;
|
|
1003
|
+
try {
|
|
1004
|
+
src = await readFile(targetPath, 'utf8');
|
|
1005
|
+
}
|
|
1006
|
+
catch (error) {
|
|
1007
|
+
return `Error reading file for edit: ${String(error)}`;
|
|
1008
|
+
}
|
|
1009
|
+
let parsed;
|
|
1010
|
+
try {
|
|
1011
|
+
if (Array.isArray(editsArg)) {
|
|
1012
|
+
parsed = editsArg;
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
const raw = String(editsArg).trim();
|
|
1016
|
+
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
1017
|
+
parsed = JSON.parse(fenced?.[1] ?? raw);
|
|
1018
|
+
}
|
|
1019
|
+
if (!Array.isArray(parsed))
|
|
1020
|
+
return 'Error: edits must be a JSON array';
|
|
1021
|
+
}
|
|
1022
|
+
catch (error) {
|
|
1023
|
+
return `Error parsing edits JSON: ${String(error)}`;
|
|
1024
|
+
}
|
|
1025
|
+
const noMatchError = (index, search) => {
|
|
1026
|
+
const hints = closestLineHints(src, search);
|
|
1027
|
+
const hint = hints.length > 0 ? `\nFirst line of the search loosely appears near lines: ${hints.join(', ')}.` : '';
|
|
1028
|
+
return `Error: edit[${index}]: Could not find old text in ${path}. It must match exactly, including whitespace, indentation, and line endings.\nSearch string was:\n${search}${hint}`;
|
|
1029
|
+
};
|
|
1030
|
+
let content = src;
|
|
1031
|
+
const log = [];
|
|
1032
|
+
const applied = [];
|
|
1033
|
+
for (let i = 0; i < parsed.length; i += 1) {
|
|
1034
|
+
const entry = parsed[i];
|
|
1035
|
+
const search = entry.search;
|
|
1036
|
+
const replace = entry.replace ?? '';
|
|
1037
|
+
if (typeof search !== 'string')
|
|
1038
|
+
return `Error: edit[${i}].search must be a string`;
|
|
1039
|
+
if (entry.replace !== undefined && typeof entry.replace !== 'string')
|
|
1040
|
+
return `Error: edit[${i}].replace must be a string`;
|
|
1041
|
+
const replaceAll = entry.replaceAll === true || globalReplaceAll;
|
|
1042
|
+
const expected = typeof entry.expectedReplacements === 'number' ? entry.expectedReplacements : globalExpected;
|
|
1043
|
+
if (search === replace) {
|
|
1044
|
+
log.push(`edit[${i}]: no-op (search equals replace)`);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const numberedSearch = stripReadFileLineNumbers(search);
|
|
1048
|
+
const unescapedSearch = unescapeEscapes(search);
|
|
1049
|
+
const variants = [search];
|
|
1050
|
+
if (numberedSearch !== undefined && numberedSearch !== search && !variants.includes(numberedSearch))
|
|
1051
|
+
variants.push(numberedSearch);
|
|
1052
|
+
if (unescapedSearch !== search && !variants.includes(unescapedSearch))
|
|
1053
|
+
variants.push(unescapedSearch);
|
|
1054
|
+
if (replaceAll) {
|
|
1055
|
+
let usedVariant;
|
|
1056
|
+
let count = 0;
|
|
1057
|
+
for (const variant of variants) {
|
|
1058
|
+
count = countOccurrences(content, variant);
|
|
1059
|
+
if (count > 0) {
|
|
1060
|
+
usedVariant = variant;
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (!usedVariant)
|
|
1065
|
+
return noMatchError(i, search);
|
|
1066
|
+
if (expected !== undefined && count !== expected) {
|
|
1067
|
+
return `Error: edit[${i}]: expected ${expected} occurrence(s) of old text in ${path} but found ${count}.`;
|
|
1068
|
+
}
|
|
1069
|
+
const effectiveReplace = usedVariant === unescapedSearch ? unescapeEscapes(replace) : replace;
|
|
1070
|
+
content = content.split(usedVariant).join(effectiveReplace);
|
|
1071
|
+
applied.push({ search: usedVariant, replace: effectiveReplace, made: count });
|
|
1072
|
+
log.push(`edit[${i}]: replaced ${count} occurrence(s)`);
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
let outcome = findUniqueMatch(content, variants[0]);
|
|
1076
|
+
let matchedVariant = variants[0];
|
|
1077
|
+
for (const variant of variants.slice(1)) {
|
|
1078
|
+
if (outcome.kind !== 'none')
|
|
1079
|
+
break;
|
|
1080
|
+
outcome = findUniqueMatch(content, variant);
|
|
1081
|
+
if (outcome.kind !== 'none')
|
|
1082
|
+
matchedVariant = variant;
|
|
1083
|
+
}
|
|
1084
|
+
if (outcome.kind === 'none') {
|
|
1085
|
+
return noMatchError(i, search);
|
|
1086
|
+
}
|
|
1087
|
+
if (outcome.kind === 'ambiguous') {
|
|
1088
|
+
const expectedNote = expected !== undefined && expected !== outcome.count ? ` (expectedReplacements was ${expected})` : '';
|
|
1089
|
+
return `Error: edit[${i}]: Found ${outcome.count} matches of old text in ${path} at lines ${outcome.lines.join(', ')}. Provide more surrounding context to make the match unique${expectedNote}.`;
|
|
1090
|
+
}
|
|
1091
|
+
if (expected !== undefined && expected !== 1) {
|
|
1092
|
+
return `Error: edit[${i}]: expected ${expected} occurrence(s) of old text in ${path} but found 1. Set replaceAll: true to replace every occurrence.`;
|
|
1093
|
+
}
|
|
1094
|
+
const matched = outcome.matched;
|
|
1095
|
+
const effectiveReplace = matchedVariant === unescapedSearch ? unescapeEscapes(replace) : replace;
|
|
1096
|
+
content = content.replace(matched, effectiveReplace);
|
|
1097
|
+
applied.push({ search: matched, replace: effectiveReplace, made: 1 });
|
|
1098
|
+
const normalizedLineNumbers = numberedSearch !== undefined && matchedVariant === numberedSearch;
|
|
1099
|
+
log.push(`edit[${i}]: replaced ${matched.length} chars via ${outcome.strategy}${normalizedLineNumbers ? ' (line-number normalized)' : ''}`);
|
|
1100
|
+
}
|
|
1101
|
+
if (content === src) {
|
|
1102
|
+
return `OK: no changes made to ${path}${log.length > 0 ? ` (${log.join('; ')})` : ''}`;
|
|
1103
|
+
}
|
|
1104
|
+
try {
|
|
1105
|
+
const writtenPath = await writeViaWorkspace(path, content, ctx);
|
|
1106
|
+
const written = await readFile(writtenPath, 'utf8');
|
|
1107
|
+
for (const edit of applied) {
|
|
1108
|
+
if (edit.replace === '') {
|
|
1109
|
+
if (countOccurrences(written, edit.search) !== 0) {
|
|
1110
|
+
return `Error: readback verification failed for ${path}: deleted text is still present after write.`;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
else if (countOccurrences(written, edit.replace) < edit.made) {
|
|
1114
|
+
return `Error: readback verification failed for ${path}: expected at least ${edit.made} occurrence(s) of the replaced text in the written file.`;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
await gitAutoCommit(writtenPath, `edit: ${path} (${applied.length} change${applied.length === 1 ? '' : 's'})`, ctx);
|
|
1118
|
+
const diff = unifiedDiff(path, src, content);
|
|
1119
|
+
const sha256 = createHash('sha256').update(written).digest('hex').slice(0, 12);
|
|
1120
|
+
const linesBefore = src.split('\n').length;
|
|
1121
|
+
const linesAfter = written.split('\n').length;
|
|
1122
|
+
return [`OK: ${log.join('; ')} (${writtenPath}); ${linesBefore} → ${linesAfter} lines; sha256:${sha256}`, diff].filter(Boolean).join('\n\n');
|
|
1123
|
+
}
|
|
1124
|
+
catch (error) {
|
|
1125
|
+
return `Error writing edited file: ${String(error)}`;
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
case 'repo_map': {
|
|
1130
|
+
const root = typeof args['root'] === 'string' ? args['root'] : '.';
|
|
1131
|
+
const maxDepth = parseInt(typeof args['max_depth'] === 'string' ? args['max_depth'] : '6', 10);
|
|
1132
|
+
try {
|
|
1133
|
+
const files = await walkDir(resolveToolPath(root, ctx), Number.isNaN(maxDepth) ? 6 : maxDepth);
|
|
1134
|
+
if (files.length === 0)
|
|
1135
|
+
return '(no supported source files found)';
|
|
1136
|
+
const lines = [`Repo map — ${files.length} file(s):\n`];
|
|
1137
|
+
for (const { path, symbols } of files) {
|
|
1138
|
+
const base = workspaceRoot(ctx);
|
|
1139
|
+
const rel = path.startsWith(base) ? path.slice(base.length + 1) : path;
|
|
1140
|
+
lines.push(` ${rel}`);
|
|
1141
|
+
if (symbols.length > 0) {
|
|
1142
|
+
lines.push(` ${symbols.join(', ')}`);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return lines.join('\n');
|
|
1146
|
+
}
|
|
1147
|
+
catch (error) {
|
|
1148
|
+
return `Error generating repo map: ${String(error)}`;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
case 'write_file': {
|
|
1152
|
+
const path = typeof args['path'] === 'string' ? args['path'] : undefined;
|
|
1153
|
+
const content = typeof args['content'] === 'string' ? args['content'] : undefined;
|
|
1154
|
+
if (!path)
|
|
1155
|
+
return 'Error: write_file requires "path"';
|
|
1156
|
+
if (content === undefined)
|
|
1157
|
+
return 'Error: write_file requires "content"';
|
|
1158
|
+
const policyError = authorizeWithResolvedPath(policy, 'write_file', path, ctx);
|
|
1159
|
+
if (policyError)
|
|
1160
|
+
return policyError;
|
|
1161
|
+
const targetPath = resolveWriteTarget(path, ctx);
|
|
1162
|
+
return withWriteLock(ctx, targetPath, async () => {
|
|
1163
|
+
try {
|
|
1164
|
+
let previous = '';
|
|
1165
|
+
let existed = false;
|
|
1166
|
+
try {
|
|
1167
|
+
previous = await readFile(targetPath, 'utf8');
|
|
1168
|
+
existed = true;
|
|
1169
|
+
}
|
|
1170
|
+
catch {
|
|
1171
|
+
previous = '';
|
|
1172
|
+
}
|
|
1173
|
+
const snapshot = existed ? await snapshotBeforeWrite(targetPath, workspaceRoot(ctx)) : { path: null };
|
|
1174
|
+
const writtenPath = await writeViaWorkspace(path, content, ctx);
|
|
1175
|
+
await gitAutoCommit(writtenPath, `write: ${path}`, ctx);
|
|
1176
|
+
const diff = unifiedDiff(path, previous, content);
|
|
1177
|
+
let note = 'created new file';
|
|
1178
|
+
if (existed) {
|
|
1179
|
+
const lineCount = previous === '' ? 0 : previous.replace(/\n$/, '').split('\n').length;
|
|
1180
|
+
note = snapshot.path
|
|
1181
|
+
? `overwrote existing file (${lineCount} lines); snapshot saved to ${snapshot.path}`
|
|
1182
|
+
: `overwrote existing file (${lineCount} lines); snapshot unavailable (${snapshot.reason ?? 'unknown'})`;
|
|
1183
|
+
}
|
|
1184
|
+
return [`OK: wrote ${writtenPath} (${content.length} chars); ${note}`, diff].filter(Boolean).join('\n\n');
|
|
1185
|
+
}
|
|
1186
|
+
catch (error) {
|
|
1187
|
+
return `Error writing file: ${String(error)}`;
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
case 'list_dir': {
|
|
1192
|
+
const dir = typeof args['path'] === 'string' ? args['path'] : '.';
|
|
1193
|
+
try {
|
|
1194
|
+
const entries = await readdir(resolveToolPath(dir, ctx), { withFileTypes: true });
|
|
1195
|
+
return entries
|
|
1196
|
+
.map((entry) => (entry.isDirectory() ? `[dir] ${entry.name}` : `[file] ${entry.name}`))
|
|
1197
|
+
.join('\n') || '(empty directory)';
|
|
1198
|
+
}
|
|
1199
|
+
catch (error) {
|
|
1200
|
+
return `Error listing directory: ${String(error)}`;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
case 'git_log': {
|
|
1204
|
+
const path = typeof args['path'] === 'string' ? args['path'] : undefined;
|
|
1205
|
+
const maxCount = Math.max(1, Math.min(Number(args['max_count'] ?? 20), 100));
|
|
1206
|
+
try {
|
|
1207
|
+
const gitArgs = [
|
|
1208
|
+
'log', `--max-count=${maxCount}`,
|
|
1209
|
+
'--date=short', '--pretty=format:%h%x09%ad%x09%an%x09%s',
|
|
1210
|
+
...(path ? ['--', path] : []),
|
|
1211
|
+
];
|
|
1212
|
+
const result = await execFileAsync('git', gitArgs, {
|
|
1213
|
+
cwd: workspaceRoot(ctx), timeout: 20_000, maxBuffer: 1024 * 1024 * 2, signal: ctx?.signal,
|
|
1214
|
+
});
|
|
1215
|
+
return result.stdout.trim() || '(no commits)';
|
|
1216
|
+
}
|
|
1217
|
+
catch (error) {
|
|
1218
|
+
const err = error;
|
|
1219
|
+
return err.stderr?.trim() || `Error: ${err.message ?? String(error)}`;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
case 'bash': {
|
|
1223
|
+
const command = typeof args['command'] === 'string' ? args['command'] : undefined;
|
|
1224
|
+
if (!command)
|
|
1225
|
+
return 'Error: bash requires "command"';
|
|
1226
|
+
const cwd = typeof args['cwd'] === 'string'
|
|
1227
|
+
? resolveToolPath(args['cwd'], ctx)
|
|
1228
|
+
: ctx?.artifactDir
|
|
1229
|
+
? resolve(ctx.artifactDir)
|
|
1230
|
+
: workspaceRoot(ctx);
|
|
1231
|
+
const timeout = Math.max(100, Math.min(Number(args['timeout_ms'] ?? 60_000), 300_000));
|
|
1232
|
+
const cwdDecision = authorizeToolCall(policy, 'bash', { ...args, cwd });
|
|
1233
|
+
if (!cwdDecision.ok)
|
|
1234
|
+
return formatPolicyError('bash', cwdDecision);
|
|
1235
|
+
try {
|
|
1236
|
+
if (ctx?.artifactDir && typeof args['cwd'] !== 'string') {
|
|
1237
|
+
await mkdir(cwd, { recursive: true });
|
|
1238
|
+
}
|
|
1239
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
1240
|
+
cwd,
|
|
1241
|
+
timeout,
|
|
1242
|
+
maxBuffer: 1024 * 1024 * 4,
|
|
1243
|
+
signal: ctx?.signal,
|
|
1244
|
+
});
|
|
1245
|
+
const output = [stdout, stderr].filter(Boolean).join('\n--- stderr ---\n');
|
|
1246
|
+
const win32Note = process.platform === 'win32'
|
|
1247
|
+
? '\n(Note: shell is cmd.exe — grep/ripgrep-like Unix utilities are unavailable; use search_text/read_file instead.)'
|
|
1248
|
+
: '';
|
|
1249
|
+
return boundedOutput(output || '(no output)') + win32Note;
|
|
1250
|
+
}
|
|
1251
|
+
catch (error) {
|
|
1252
|
+
const err = error;
|
|
1253
|
+
const output = [err.stdout, err.stderr].filter(Boolean).join('\n');
|
|
1254
|
+
return `Error: command failed${output ? `\n${output}` : `: ${err.message ?? String(error)}`}`;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
case 'load_skill': {
|
|
1258
|
+
const name = typeof args['name'] === 'string' ? args['name'] : undefined;
|
|
1259
|
+
if (!name)
|
|
1260
|
+
return 'Error: load_skill requires "name"';
|
|
1261
|
+
if (!/^[a-z0-9_-]+$/i.test(name))
|
|
1262
|
+
return 'Error: invalid skill name';
|
|
1263
|
+
const candidates = [resolve(workspaceRoot(ctx), 'skills'), resolve(import.meta.dirname, '..', '..', 'skills')];
|
|
1264
|
+
for (const skillsDir of candidates) {
|
|
1265
|
+
try {
|
|
1266
|
+
return await readFile(resolve(skillsDir, `${name}.md`), 'utf8');
|
|
1267
|
+
}
|
|
1268
|
+
catch { /* try next root */ }
|
|
1269
|
+
}
|
|
1270
|
+
const available = new Set();
|
|
1271
|
+
for (const skillsDir of candidates) {
|
|
1272
|
+
try {
|
|
1273
|
+
for (const file of await readdir(skillsDir))
|
|
1274
|
+
if (file.endsWith('.md'))
|
|
1275
|
+
available.add(file.slice(0, -3));
|
|
1276
|
+
}
|
|
1277
|
+
catch { /* ignore */ }
|
|
1278
|
+
}
|
|
1279
|
+
return `Error: skill "${name}" not found${available.size ? `. Available: ${[...available].sort().join(', ')}` : ''}`;
|
|
1280
|
+
}
|
|
1281
|
+
default:
|
|
1282
|
+
return `Error: unknown tool "${name}"`;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
/** Default built-in registry. Consumers can use ToolRegistry directly for custom sets. */
|
|
1286
|
+
export const toolRegistry = new ToolRegistry();
|
|
1287
|
+
for (const definition of TOOLS) {
|
|
1288
|
+
const name = definition.function.name;
|
|
1289
|
+
toolRegistry.register({
|
|
1290
|
+
definition,
|
|
1291
|
+
metadata: TOOL_METADATA[name] ?? { effect: 'read', category: 'agent' },
|
|
1292
|
+
execute: (args, context) => executeBuiltinTool(name, args, context),
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
export function listTools(options = {}) {
|
|
1296
|
+
return toolRegistry.describe(options);
|
|
1297
|
+
}
|
|
1298
|
+
export async function executeTool(name, args, ctx) {
|
|
1299
|
+
return toolRegistry.execute(name, args, ctx);
|
|
1300
|
+
}
|