@shawnstack/quickforge 1.3.14 → 1.3.15
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 +348 -348
- package/dist/assets/{anthropic-DGgbbhP2.js → anthropic-BT3d4VOP.js} +1 -1
- package/dist/assets/{azure-openai-responses-iVkKls8h.js → azure-openai-responses-48tpKUXk.js} +1 -1
- package/dist/assets/{google-CfWayb6J.js → google-BYapAGwA.js} +1 -1
- package/dist/assets/{google-gemini-cli-BKFbEcDj.js → google-gemini-cli-BnkyGSNu.js} +1 -1
- package/dist/assets/{google-vertex-Di6pCCaT.js → google-vertex-E_DV02Ii.js} +1 -1
- package/dist/assets/{index-CgTJgJ5U.js → index-BLH5UHZ2.js} +472 -453
- package/dist/assets/{mistral-DGp-bWeK.js → mistral-CTSjsKBm.js} +1 -1
- package/dist/assets/{openai-codex-responses-CFSjwMXz.js → openai-codex-responses-Db9Q_SPJ.js} +1 -1
- package/dist/assets/{openai-completions-DlNb8Upk.js → openai-completions-CL6yyDHL.js} +1 -1
- package/dist/assets/{openai-responses-DulHAAeh.js → openai-responses-BBcr7NqZ.js} +1 -1
- package/dist/assets/{openai-responses-shared-BWikZIo_.js → openai-responses-shared-BMUh_Lci.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/agent-manager.mjs +97 -3
- package/server/auto-compaction.mjs +21 -1
- package/server/routes/agent.mjs +27 -0
- package/server/tools/definitions.mjs +1 -1
- package/server/tools/index.mjs +745 -712
package/server/tools/index.mjs
CHANGED
|
@@ -1,481 +1,481 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import { spawn } from 'node:child_process'
|
|
4
|
-
import { createRequire } from 'node:module'
|
|
5
|
-
import { resolveWorkspacePath, toWorkspaceRelative, assertSafeWorkspacePath, truncateText, splitLines, walkFiles } from '../utils/workspace.mjs'
|
|
6
|
-
import { createTextDiff } from '../utils/text-diff.mjs'
|
|
7
|
-
import {
|
|
8
|
-
formatSkillActivation,
|
|
9
|
-
loadSelectedGlobalSkills,
|
|
10
|
-
loadSelectedProjectSkills,
|
|
11
|
-
mergeSkills,
|
|
12
|
-
readSkillResource,
|
|
13
|
-
} from '../skills.mjs'
|
|
14
|
-
import { getToolWorkspaceRoot } from '../utils/workspace.mjs'
|
|
15
|
-
|
|
16
|
-
const require = createRequire(import.meta.url)
|
|
17
|
-
|
|
18
|
-
// --- read_file ---
|
|
19
|
-
export async function toolReadFile(params, context) {
|
|
20
|
-
const file = resolveWorkspacePath(params?.path, context)
|
|
21
|
-
await assertSafeWorkspacePath(file, context)
|
|
22
|
-
|
|
23
|
-
const text = await fs.readFile(file, 'utf8')
|
|
24
|
-
const lines = splitLines(text)
|
|
25
|
-
const offset = Math.max(1, Number(params?.offset || 1))
|
|
26
|
-
const limit = Math.min(2000, Math.max(1, Number(params?.limit || 200)))
|
|
27
|
-
const selected = lines.slice(offset - 1, offset - 1 + limit)
|
|
28
|
-
const content = selected.map((line, index) => `${offset + index}: ${line}`).join('\n')
|
|
29
|
-
const suffix = offset - 1 + limit < lines.length ? `\n\n[showing ${selected.length} of ${lines.length} lines]` : ''
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
content: truncateText(`${content}${suffix}`),
|
|
33
|
-
details: { path: toWorkspaceRelative(file, context), project: context?.project, totalLines: lines.length, offset, limit },
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// --- grep_files ---
|
|
38
|
-
|
|
39
|
-
const RIPGREP_MAX_FILESIZE = '1M'
|
|
40
|
-
const RIPGREP_TIMEOUT_MS = 60 * 1000
|
|
41
|
-
const DEFAULT_EXCLUDE_GLOBS = [
|
|
42
|
-
'!.git/**',
|
|
43
|
-
'!node_modules/**',
|
|
44
|
-
'!dist/**',
|
|
45
|
-
'!dist-ssr/**',
|
|
46
|
-
'!.vite/**',
|
|
47
|
-
'!**/*.png',
|
|
48
|
-
'!**/*.jpg',
|
|
49
|
-
'!**/*.jpeg',
|
|
50
|
-
'!**/*.gif',
|
|
51
|
-
'!**/*.webp',
|
|
52
|
-
'!**/*.ico',
|
|
53
|
-
'!**/*.pdf',
|
|
54
|
-
'!**/*.zip',
|
|
55
|
-
'!**/*.gz',
|
|
56
|
-
'!**/*.7z',
|
|
57
|
-
'!**/*.exe',
|
|
58
|
-
'!**/*.dll',
|
|
59
|
-
'!**/*.woff',
|
|
60
|
-
'!**/*.woff2',
|
|
61
|
-
'!**/*.ttf',
|
|
62
|
-
]
|
|
63
|
-
const SENSITIVE_EXCLUDE_GLOBS = [
|
|
64
|
-
'!.env',
|
|
65
|
-
'!**/.env',
|
|
66
|
-
'!.env.*',
|
|
67
|
-
'!**/.env.*',
|
|
68
|
-
'!**/*.pem',
|
|
69
|
-
'!**/*.key',
|
|
70
|
-
'!**/*.p12',
|
|
71
|
-
'!**/*.pfx',
|
|
72
|
-
'!**/*.crt',
|
|
73
|
-
'!**/*.cer',
|
|
74
|
-
'!**/*.token',
|
|
75
|
-
'!credentials.json',
|
|
76
|
-
'!**/credentials.json',
|
|
77
|
-
'!secrets.json',
|
|
78
|
-
'!**/secrets.json',
|
|
79
|
-
'!id_rsa',
|
|
80
|
-
'!**/id_rsa',
|
|
81
|
-
'!id_ed25519',
|
|
82
|
-
'!**/id_ed25519',
|
|
83
|
-
]
|
|
84
|
-
|
|
85
|
-
let cachedRipgrepExecutable
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Process items with bounded concurrency. Returns results in input order.
|
|
89
|
-
* @template T, R
|
|
90
|
-
* @param {T[]} items
|
|
91
|
-
* @param {(item: T, index: number) => Promise<R>} fn
|
|
92
|
-
* @param {number} concurrency
|
|
93
|
-
* @returns {Promise<R[]>}
|
|
94
|
-
*/
|
|
95
|
-
async function poolMap(items, fn, concurrency = 20) {
|
|
96
|
-
const results = new Array(items.length)
|
|
97
|
-
let cursor = 0
|
|
98
|
-
|
|
99
|
-
async function worker() {
|
|
100
|
-
while (cursor < items.length) {
|
|
101
|
-
const index = cursor++
|
|
102
|
-
results[index] = await fn(items[index], index)
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
|
|
107
|
-
await Promise.all(workers)
|
|
108
|
-
return results
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function clampNumber(value, defaultValue, min, max) {
|
|
112
|
-
const number = Number(value)
|
|
113
|
-
if (!Number.isFinite(number)) return defaultValue
|
|
114
|
-
return Math.min(max, Math.max(min, Math.trunc(number)))
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function escapeRegExp(value) {
|
|
118
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function normalizeGlobList(value) {
|
|
122
|
-
const values = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []
|
|
123
|
-
return values
|
|
124
|
-
.map((item) => String(item || '').trim())
|
|
125
|
-
.filter(Boolean)
|
|
126
|
-
.slice(0, 50)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function normalizeGrepParams(params, context) {
|
|
130
|
-
const root = resolveWorkspacePath(params?.path || '.', context)
|
|
131
|
-
const query = String(params?.query || '')
|
|
132
|
-
if (!query) {
|
|
133
|
-
const error = new Error('query is required')
|
|
134
|
-
error.statusCode = 400
|
|
135
|
-
throw error
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const flags = params?.caseSensitive ? 'g' : 'gi'
|
|
139
|
-
try {
|
|
140
|
-
params?.regex
|
|
141
|
-
? new RegExp(query, flags)
|
|
142
|
-
: new RegExp(escapeRegExp(query), flags)
|
|
143
|
-
} catch {
|
|
144
|
-
const error = new Error('Invalid regular expression')
|
|
145
|
-
error.statusCode = 400
|
|
146
|
-
throw error
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return {
|
|
150
|
-
root,
|
|
151
|
-
query,
|
|
152
|
-
regex: Boolean(params?.regex),
|
|
153
|
-
caseSensitive: Boolean(params?.caseSensitive),
|
|
154
|
-
limit: clampNumber(params?.limit, 200, 1, 1000),
|
|
155
|
-
glob: normalizeGlobList(params?.glob),
|
|
156
|
-
context: clampNumber(params?.context, 0, 0, 20),
|
|
157
|
-
beforeContext: clampNumber(params?.beforeContext, 0, 0, 20),
|
|
158
|
-
afterContext: clampNumber(params?.afterContext, 0, 0, 20),
|
|
159
|
-
filesWithMatches: Boolean(params?.filesWithMatches),
|
|
160
|
-
respectGitIgnore: Boolean(params?.respectGitIgnore),
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function isRegexLikelyRipgrepCompatible(query) {
|
|
165
|
-
return !(/\(\?[=!<]/.test(query) || /\\[1-9]/.test(query))
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function ripgrepCandidatePath() {
|
|
169
|
-
try {
|
|
170
|
-
return require('@vscode/ripgrep').rgPath || null
|
|
171
|
-
} catch {
|
|
172
|
-
return null
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async function verifyRipgrepExecutable(command) {
|
|
177
|
-
return new Promise((resolve) => {
|
|
178
|
-
const child = spawn(command, ['--version'], { shell: false, windowsHide: true })
|
|
179
|
-
child.once('error', () => resolve(false))
|
|
180
|
-
child.once('close', (code) => resolve(code === 0))
|
|
181
|
-
})
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
async function resolveRipgrepExecutable() {
|
|
185
|
-
if (cachedRipgrepExecutable !== undefined) return cachedRipgrepExecutable
|
|
186
|
-
|
|
187
|
-
const bundled = ripgrepCandidatePath()
|
|
188
|
-
if (bundled && await verifyRipgrepExecutable(bundled)) {
|
|
189
|
-
cachedRipgrepExecutable = { command: bundled, source: 'bundled' }
|
|
190
|
-
return cachedRipgrepExecutable
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (await verifyRipgrepExecutable('rg')) {
|
|
194
|
-
cachedRipgrepExecutable = { command: 'rg', source: 'system' }
|
|
195
|
-
return cachedRipgrepExecutable
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
cachedRipgrepExecutable = null
|
|
199
|
-
return cachedRipgrepExecutable
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function buildRipgrepArgs(options, context) {
|
|
203
|
-
const args = [
|
|
204
|
-
'--line-number',
|
|
205
|
-
'--color=never',
|
|
206
|
-
'--max-filesize',
|
|
207
|
-
RIPGREP_MAX_FILESIZE,
|
|
208
|
-
]
|
|
209
|
-
|
|
210
|
-
if (options.filesWithMatches) {
|
|
211
|
-
args.push('--files-with-matches')
|
|
212
|
-
} else {
|
|
213
|
-
args.push('--json')
|
|
214
|
-
}
|
|
215
|
-
if (!options.regex) args.push('--fixed-strings')
|
|
216
|
-
if (!options.caseSensitive) args.push('--ignore-case')
|
|
217
|
-
if (!options.respectGitIgnore) args.push('--hidden', '--no-ignore')
|
|
218
|
-
if (options.context > 0) args.push('-C', String(options.context))
|
|
219
|
-
if (options.beforeContext > 0) args.push('-B', String(options.beforeContext))
|
|
220
|
-
if (options.afterContext > 0) args.push('-A', String(options.afterContext))
|
|
221
|
-
|
|
222
|
-
for (const pattern of options.glob) args.push('--glob', pattern)
|
|
223
|
-
for (const pattern of DEFAULT_EXCLUDE_GLOBS) args.push('--glob', pattern)
|
|
224
|
-
for (const pattern of SENSITIVE_EXCLUDE_GLOBS) args.push('--glob', pattern)
|
|
225
|
-
|
|
226
|
-
args.push('--', options.query, toWorkspaceRelative(options.root, context) || '.')
|
|
227
|
-
return args
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function cleanRipgrepLine(value) {
|
|
231
|
-
return String(value || '').replace(/[\r\n]+$/, '')
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function ripgrepRelativePath(value) {
|
|
235
|
-
return String(value || '').replace(/\\/g, '/')
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function formatRipgrepJsonEvent(event) {
|
|
239
|
-
if (event?.type !== 'match' && event?.type !== 'context') return null
|
|
240
|
-
const data = event.data || {}
|
|
241
|
-
const file = ripgrepRelativePath(data.path?.text)
|
|
242
|
-
const lineNumber = data.line_number
|
|
243
|
-
const line = cleanRipgrepLine(data.lines?.text)
|
|
244
|
-
if (!file || !lineNumber) return null
|
|
245
|
-
const separator = event.type === 'match' ? ':' : '-'
|
|
246
|
-
return `${file}:${lineNumber}${separator} ${line}`
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function fallbackDetails(extra = {}) {
|
|
250
|
-
return Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== undefined && value !== null && value !== ''))
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
async function grepFilesWithRipgrep(executable, options, context, runtime = {}) {
|
|
254
|
-
if (options.regex && !isRegexLikelyRipgrepCompatible(options.query)) {
|
|
255
|
-
throw new Error('regex uses JavaScript-only features that ripgrep does not support')
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const cwd = getToolWorkspaceRoot(context)
|
|
259
|
-
const args = buildRipgrepArgs(options, context)
|
|
260
|
-
const matches = []
|
|
261
|
-
let stderr = ''
|
|
262
|
-
let buffer = ''
|
|
263
|
-
let killedForLimit = false
|
|
264
|
-
let settled = false
|
|
265
|
-
|
|
266
|
-
await new Promise((resolve, reject) => {
|
|
267
|
-
if (runtime.signal?.aborted) {
|
|
268
|
-
reject(new Error('Search aborted'))
|
|
269
|
-
return
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const child = spawn(executable.command, args, {
|
|
273
|
-
cwd,
|
|
274
|
-
shell: false,
|
|
275
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
276
|
-
windowsHide: true,
|
|
277
|
-
})
|
|
278
|
-
|
|
279
|
-
const cleanup = () => {
|
|
280
|
-
clearTimeout(timer)
|
|
281
|
-
runtime.signal?.removeEventListener?.('abort', onAbort)
|
|
282
|
-
}
|
|
283
|
-
const finish = (error) => {
|
|
284
|
-
if (settled) return
|
|
285
|
-
settled = true
|
|
286
|
-
cleanup()
|
|
287
|
-
if (error) reject(error)
|
|
288
|
-
else resolve()
|
|
289
|
-
}
|
|
290
|
-
const stopForLimit = () => {
|
|
291
|
-
if (killedForLimit) return
|
|
292
|
-
killedForLimit = true
|
|
293
|
-
killProcessTree(child, 'SIGTERM')
|
|
294
|
-
}
|
|
295
|
-
const processLine = (line) => {
|
|
296
|
-
if (!line || matches.length >= options.limit) return
|
|
297
|
-
if (options.filesWithMatches) {
|
|
298
|
-
matches.push(ripgrepRelativePath(line))
|
|
299
|
-
} else {
|
|
300
|
-
try {
|
|
301
|
-
const formatted = formatRipgrepJsonEvent(JSON.parse(line))
|
|
302
|
-
if (formatted) matches.push(formatted)
|
|
303
|
-
} catch {
|
|
304
|
-
// Ignore malformed partial output and let process exit handling decide fallback.
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
if (matches.length >= options.limit) stopForLimit()
|
|
308
|
-
}
|
|
309
|
-
const flushLines = (chunk) => {
|
|
310
|
-
buffer += chunk.toString()
|
|
311
|
-
const lines = buffer.split(/\r?\n/)
|
|
312
|
-
buffer = lines.pop() || ''
|
|
313
|
-
for (const line of lines) processLine(line)
|
|
314
|
-
}
|
|
315
|
-
function onAbort() {
|
|
316
|
-
killProcessTree(child, 'SIGTERM')
|
|
317
|
-
finish(new Error('Search aborted'))
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const timer = setTimeout(() => {
|
|
321
|
-
killProcessTree(child, 'SIGTERM')
|
|
322
|
-
finish(new Error('ripgrep search timed out'))
|
|
323
|
-
}, RIPGREP_TIMEOUT_MS)
|
|
324
|
-
|
|
325
|
-
runtime.signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
326
|
-
child.stdout.on('data', flushLines)
|
|
327
|
-
child.stderr.on('data', (chunk) => {
|
|
328
|
-
stderr = truncateText(stderr + chunk.toString(), 2000)
|
|
329
|
-
})
|
|
330
|
-
child.once('error', finish)
|
|
331
|
-
child.once('close', (code) => {
|
|
332
|
-
if (buffer) processLine(buffer)
|
|
333
|
-
if (killedForLimit || code === 0 || code === 1) {
|
|
334
|
-
finish()
|
|
335
|
-
} else {
|
|
336
|
-
finish(new Error(stderr.trim() || `ripgrep exited with code ${code}`))
|
|
337
|
-
}
|
|
338
|
-
})
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
return {
|
|
342
|
-
content: matches.length ? truncateText(matches.slice(0, options.limit).join('\n')) : 'No matches found.',
|
|
343
|
-
details: {
|
|
344
|
-
path: toWorkspaceRelative(options.root, context),
|
|
345
|
-
project: context?.project,
|
|
346
|
-
query: options.query,
|
|
347
|
-
count: matches.length,
|
|
348
|
-
limit: options.limit,
|
|
349
|
-
backend: 'ripgrep',
|
|
350
|
-
ripgrepSource: executable.source,
|
|
351
|
-
},
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
async function grepFilesWithNode(options, context, extraDetails = {}) {
|
|
356
|
-
const flags = options.caseSensitive ? 'g' : 'gi'
|
|
357
|
-
const matcher = options.regex
|
|
358
|
-
? new RegExp(options.query, flags)
|
|
359
|
-
: new RegExp(escapeRegExp(options.query), flags)
|
|
360
|
-
|
|
361
|
-
const files = await walkFiles(options.root, [], context)
|
|
362
|
-
const matches = []
|
|
363
|
-
|
|
364
|
-
// Stat and filter files in parallel, then grep in parallel batches.
|
|
365
|
-
const candidateResults = await poolMap(files, async (file) => {
|
|
366
|
-
try {
|
|
367
|
-
const stat = await fs.stat(file)
|
|
368
|
-
if (stat.size > 1024 * 1024) return { file, skip: true }
|
|
369
|
-
return { file, skip: false }
|
|
370
|
-
} catch {
|
|
371
|
-
return { file, skip: true }
|
|
372
|
-
}
|
|
373
|
-
})
|
|
374
|
-
|
|
375
|
-
const candidates = candidateResults.filter((r) => !r.skip).map((r) => r.file)
|
|
376
|
-
|
|
377
|
-
// Grep with bounded concurrency — short-circuit when limit reached.
|
|
378
|
-
let matchCount = 0
|
|
379
|
-
const filesWithMatches = new Set()
|
|
380
|
-
for (let batchStart = 0; batchStart < candidates.length && matchCount < options.limit; batchStart += 20) {
|
|
381
|
-
const batch = candidates.slice(batchStart, batchStart + 20)
|
|
382
|
-
const batchMatches = await Promise.all(
|
|
383
|
-
batch.map(async (file) => {
|
|
384
|
-
if (matchCount >= options.limit) return []
|
|
385
|
-
try {
|
|
386
|
-
const text = await fs.readFile(file, 'utf8')
|
|
387
|
-
const lines = splitLines(text)
|
|
388
|
-
const fileMatches = []
|
|
389
|
-
for (let index = 0; index < lines.length && (matchCount + fileMatches.length) < options.limit; index++) {
|
|
390
|
-
matcher.lastIndex = 0
|
|
391
|
-
if (matcher.test(lines[index])) {
|
|
392
|
-
const relative = toWorkspaceRelative(file, context)
|
|
393
|
-
if (options.filesWithMatches) {
|
|
394
|
-
if (!filesWithMatches.has(relative)) {
|
|
395
|
-
filesWithMatches.add(relative)
|
|
396
|
-
fileMatches.push(relative)
|
|
397
|
-
}
|
|
398
|
-
} else {
|
|
399
|
-
fileMatches.push(`${relative}:${index + 1}: ${lines[index]}`)
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
return fileMatches
|
|
404
|
-
} catch {
|
|
405
|
-
return []
|
|
406
|
-
}
|
|
407
|
-
}),
|
|
408
|
-
)
|
|
409
|
-
for (const fm of batchMatches) {
|
|
410
|
-
if (matchCount >= options.limit) break
|
|
411
|
-
for (const m of fm) {
|
|
412
|
-
if (matchCount >= options.limit) break
|
|
413
|
-
matches.push(m)
|
|
414
|
-
matchCount++
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
return {
|
|
420
|
-
content: matches.length ? truncateText(matches.join('\n')) : 'No matches found.',
|
|
421
|
-
details: {
|
|
422
|
-
path: toWorkspaceRelative(options.root, context),
|
|
423
|
-
project: context?.project,
|
|
424
|
-
query: options.query,
|
|
425
|
-
count: matches.length,
|
|
426
|
-
limit: options.limit,
|
|
427
|
-
backend: 'node',
|
|
428
|
-
...fallbackDetails(extraDetails),
|
|
429
|
-
},
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
export async function toolGrepFiles(params, context, runtime = {}) {
|
|
434
|
-
const options = normalizeGrepParams(params, context)
|
|
435
|
-
await assertSafeWorkspacePath(options.root, context)
|
|
436
|
-
|
|
437
|
-
const executable = await resolveRipgrepExecutable()
|
|
438
|
-
if (executable) {
|
|
439
|
-
try {
|
|
440
|
-
return await grepFilesWithRipgrep(executable, options, context, runtime)
|
|
441
|
-
} catch (error) {
|
|
442
|
-
if (runtime.signal?.aborted) throw error
|
|
443
|
-
return grepFilesWithNode(options, context, {
|
|
444
|
-
fallbackFrom: 'ripgrep',
|
|
445
|
-
fallbackReason: error?.message || 'ripgrep unavailable',
|
|
446
|
-
})
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
return grepFilesWithNode(options, context, { fallbackReason: 'ripgrep unavailable' })
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// --- write_file ---
|
|
454
|
-
export async function toolWriteFile(params, context) {
|
|
455
|
-
const file = resolveWorkspacePath(params?.path, context)
|
|
456
|
-
await assertSafeWorkspacePath(file, context, { forWrite: true })
|
|
457
|
-
|
|
458
|
-
const content = String(params?.content ?? '')
|
|
459
|
-
const relativePath = toWorkspaceRelative(file, context)
|
|
460
|
-
let oldText = ''
|
|
461
|
-
let existed = true
|
|
462
|
-
try {
|
|
463
|
-
oldText = await fs.readFile(file, 'utf8')
|
|
464
|
-
} catch (error) {
|
|
465
|
-
if (error?.code !== 'ENOENT') throw error
|
|
466
|
-
existed = false
|
|
467
|
-
}
|
|
468
|
-
const diff = createTextDiff(oldText, content, relativePath, { oldExists: existed })
|
|
469
|
-
|
|
470
|
-
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
471
|
-
await fs.writeFile(file, content, 'utf8')
|
|
472
|
-
|
|
473
|
-
return {
|
|
474
|
-
content: `${existed ? 'Wrote' : 'Created'} ${relativePath} (+${diff.addedLines} -${diff.removedLines})`,
|
|
475
|
-
details: { path: relativePath, project: context?.project, bytes: Buffer.byteLength(content, 'utf8'), created: !existed, diff },
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { resolveWorkspacePath, toWorkspaceRelative, assertSafeWorkspacePath, truncateText, splitLines, walkFiles } from '../utils/workspace.mjs'
|
|
6
|
+
import { createTextDiff } from '../utils/text-diff.mjs'
|
|
7
|
+
import {
|
|
8
|
+
formatSkillActivation,
|
|
9
|
+
loadSelectedGlobalSkills,
|
|
10
|
+
loadSelectedProjectSkills,
|
|
11
|
+
mergeSkills,
|
|
12
|
+
readSkillResource,
|
|
13
|
+
} from '../skills.mjs'
|
|
14
|
+
import { getToolWorkspaceRoot } from '../utils/workspace.mjs'
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url)
|
|
17
|
+
|
|
18
|
+
// --- read_file ---
|
|
19
|
+
export async function toolReadFile(params, context) {
|
|
20
|
+
const file = resolveWorkspacePath(params?.path, context)
|
|
21
|
+
await assertSafeWorkspacePath(file, context)
|
|
22
|
+
|
|
23
|
+
const text = await fs.readFile(file, 'utf8')
|
|
24
|
+
const lines = splitLines(text)
|
|
25
|
+
const offset = Math.max(1, Number(params?.offset || 1))
|
|
26
|
+
const limit = Math.min(2000, Math.max(1, Number(params?.limit || 200)))
|
|
27
|
+
const selected = lines.slice(offset - 1, offset - 1 + limit)
|
|
28
|
+
const content = selected.map((line, index) => `${offset + index}: ${line}`).join('\n')
|
|
29
|
+
const suffix = offset - 1 + limit < lines.length ? `\n\n[showing ${selected.length} of ${lines.length} lines]` : ''
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
content: truncateText(`${content}${suffix}`),
|
|
33
|
+
details: { path: toWorkspaceRelative(file, context), project: context?.project, totalLines: lines.length, offset, limit },
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- grep_files ---
|
|
38
|
+
|
|
39
|
+
const RIPGREP_MAX_FILESIZE = '1M'
|
|
40
|
+
const RIPGREP_TIMEOUT_MS = 60 * 1000
|
|
41
|
+
const DEFAULT_EXCLUDE_GLOBS = [
|
|
42
|
+
'!.git/**',
|
|
43
|
+
'!node_modules/**',
|
|
44
|
+
'!dist/**',
|
|
45
|
+
'!dist-ssr/**',
|
|
46
|
+
'!.vite/**',
|
|
47
|
+
'!**/*.png',
|
|
48
|
+
'!**/*.jpg',
|
|
49
|
+
'!**/*.jpeg',
|
|
50
|
+
'!**/*.gif',
|
|
51
|
+
'!**/*.webp',
|
|
52
|
+
'!**/*.ico',
|
|
53
|
+
'!**/*.pdf',
|
|
54
|
+
'!**/*.zip',
|
|
55
|
+
'!**/*.gz',
|
|
56
|
+
'!**/*.7z',
|
|
57
|
+
'!**/*.exe',
|
|
58
|
+
'!**/*.dll',
|
|
59
|
+
'!**/*.woff',
|
|
60
|
+
'!**/*.woff2',
|
|
61
|
+
'!**/*.ttf',
|
|
62
|
+
]
|
|
63
|
+
const SENSITIVE_EXCLUDE_GLOBS = [
|
|
64
|
+
'!.env',
|
|
65
|
+
'!**/.env',
|
|
66
|
+
'!.env.*',
|
|
67
|
+
'!**/.env.*',
|
|
68
|
+
'!**/*.pem',
|
|
69
|
+
'!**/*.key',
|
|
70
|
+
'!**/*.p12',
|
|
71
|
+
'!**/*.pfx',
|
|
72
|
+
'!**/*.crt',
|
|
73
|
+
'!**/*.cer',
|
|
74
|
+
'!**/*.token',
|
|
75
|
+
'!credentials.json',
|
|
76
|
+
'!**/credentials.json',
|
|
77
|
+
'!secrets.json',
|
|
78
|
+
'!**/secrets.json',
|
|
79
|
+
'!id_rsa',
|
|
80
|
+
'!**/id_rsa',
|
|
81
|
+
'!id_ed25519',
|
|
82
|
+
'!**/id_ed25519',
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
let cachedRipgrepExecutable
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Process items with bounded concurrency. Returns results in input order.
|
|
89
|
+
* @template T, R
|
|
90
|
+
* @param {T[]} items
|
|
91
|
+
* @param {(item: T, index: number) => Promise<R>} fn
|
|
92
|
+
* @param {number} concurrency
|
|
93
|
+
* @returns {Promise<R[]>}
|
|
94
|
+
*/
|
|
95
|
+
async function poolMap(items, fn, concurrency = 20) {
|
|
96
|
+
const results = new Array(items.length)
|
|
97
|
+
let cursor = 0
|
|
98
|
+
|
|
99
|
+
async function worker() {
|
|
100
|
+
while (cursor < items.length) {
|
|
101
|
+
const index = cursor++
|
|
102
|
+
results[index] = await fn(items[index], index)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
|
|
107
|
+
await Promise.all(workers)
|
|
108
|
+
return results
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function clampNumber(value, defaultValue, min, max) {
|
|
112
|
+
const number = Number(value)
|
|
113
|
+
if (!Number.isFinite(number)) return defaultValue
|
|
114
|
+
return Math.min(max, Math.max(min, Math.trunc(number)))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function escapeRegExp(value) {
|
|
118
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeGlobList(value) {
|
|
122
|
+
const values = Array.isArray(value) ? value : typeof value === 'string' ? [value] : []
|
|
123
|
+
return values
|
|
124
|
+
.map((item) => String(item || '').trim())
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.slice(0, 50)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeGrepParams(params, context) {
|
|
130
|
+
const root = resolveWorkspacePath(params?.path || '.', context)
|
|
131
|
+
const query = String(params?.query || '')
|
|
132
|
+
if (!query) {
|
|
133
|
+
const error = new Error('query is required')
|
|
134
|
+
error.statusCode = 400
|
|
135
|
+
throw error
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const flags = params?.caseSensitive ? 'g' : 'gi'
|
|
139
|
+
try {
|
|
140
|
+
params?.regex
|
|
141
|
+
? new RegExp(query, flags)
|
|
142
|
+
: new RegExp(escapeRegExp(query), flags)
|
|
143
|
+
} catch {
|
|
144
|
+
const error = new Error('Invalid regular expression')
|
|
145
|
+
error.statusCode = 400
|
|
146
|
+
throw error
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
root,
|
|
151
|
+
query,
|
|
152
|
+
regex: Boolean(params?.regex),
|
|
153
|
+
caseSensitive: Boolean(params?.caseSensitive),
|
|
154
|
+
limit: clampNumber(params?.limit, 200, 1, 1000),
|
|
155
|
+
glob: normalizeGlobList(params?.glob),
|
|
156
|
+
context: clampNumber(params?.context, 0, 0, 20),
|
|
157
|
+
beforeContext: clampNumber(params?.beforeContext, 0, 0, 20),
|
|
158
|
+
afterContext: clampNumber(params?.afterContext, 0, 0, 20),
|
|
159
|
+
filesWithMatches: Boolean(params?.filesWithMatches),
|
|
160
|
+
respectGitIgnore: Boolean(params?.respectGitIgnore),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isRegexLikelyRipgrepCompatible(query) {
|
|
165
|
+
return !(/\(\?[=!<]/.test(query) || /\\[1-9]/.test(query))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ripgrepCandidatePath() {
|
|
169
|
+
try {
|
|
170
|
+
return require('@vscode/ripgrep').rgPath || null
|
|
171
|
+
} catch {
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function verifyRipgrepExecutable(command) {
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
const child = spawn(command, ['--version'], { shell: false, windowsHide: true })
|
|
179
|
+
child.once('error', () => resolve(false))
|
|
180
|
+
child.once('close', (code) => resolve(code === 0))
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function resolveRipgrepExecutable() {
|
|
185
|
+
if (cachedRipgrepExecutable !== undefined) return cachedRipgrepExecutable
|
|
186
|
+
|
|
187
|
+
const bundled = ripgrepCandidatePath()
|
|
188
|
+
if (bundled && await verifyRipgrepExecutable(bundled)) {
|
|
189
|
+
cachedRipgrepExecutable = { command: bundled, source: 'bundled' }
|
|
190
|
+
return cachedRipgrepExecutable
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (await verifyRipgrepExecutable('rg')) {
|
|
194
|
+
cachedRipgrepExecutable = { command: 'rg', source: 'system' }
|
|
195
|
+
return cachedRipgrepExecutable
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
cachedRipgrepExecutable = null
|
|
199
|
+
return cachedRipgrepExecutable
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildRipgrepArgs(options, context) {
|
|
203
|
+
const args = [
|
|
204
|
+
'--line-number',
|
|
205
|
+
'--color=never',
|
|
206
|
+
'--max-filesize',
|
|
207
|
+
RIPGREP_MAX_FILESIZE,
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
if (options.filesWithMatches) {
|
|
211
|
+
args.push('--files-with-matches')
|
|
212
|
+
} else {
|
|
213
|
+
args.push('--json')
|
|
214
|
+
}
|
|
215
|
+
if (!options.regex) args.push('--fixed-strings')
|
|
216
|
+
if (!options.caseSensitive) args.push('--ignore-case')
|
|
217
|
+
if (!options.respectGitIgnore) args.push('--hidden', '--no-ignore')
|
|
218
|
+
if (options.context > 0) args.push('-C', String(options.context))
|
|
219
|
+
if (options.beforeContext > 0) args.push('-B', String(options.beforeContext))
|
|
220
|
+
if (options.afterContext > 0) args.push('-A', String(options.afterContext))
|
|
221
|
+
|
|
222
|
+
for (const pattern of options.glob) args.push('--glob', pattern)
|
|
223
|
+
for (const pattern of DEFAULT_EXCLUDE_GLOBS) args.push('--glob', pattern)
|
|
224
|
+
for (const pattern of SENSITIVE_EXCLUDE_GLOBS) args.push('--glob', pattern)
|
|
225
|
+
|
|
226
|
+
args.push('--', options.query, toWorkspaceRelative(options.root, context) || '.')
|
|
227
|
+
return args
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function cleanRipgrepLine(value) {
|
|
231
|
+
return String(value || '').replace(/[\r\n]+$/, '')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function ripgrepRelativePath(value) {
|
|
235
|
+
return String(value || '').replace(/\\/g, '/')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function formatRipgrepJsonEvent(event) {
|
|
239
|
+
if (event?.type !== 'match' && event?.type !== 'context') return null
|
|
240
|
+
const data = event.data || {}
|
|
241
|
+
const file = ripgrepRelativePath(data.path?.text)
|
|
242
|
+
const lineNumber = data.line_number
|
|
243
|
+
const line = cleanRipgrepLine(data.lines?.text)
|
|
244
|
+
if (!file || !lineNumber) return null
|
|
245
|
+
const separator = event.type === 'match' ? ':' : '-'
|
|
246
|
+
return `${file}:${lineNumber}${separator} ${line}`
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function fallbackDetails(extra = {}) {
|
|
250
|
+
return Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== undefined && value !== null && value !== ''))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function grepFilesWithRipgrep(executable, options, context, runtime = {}) {
|
|
254
|
+
if (options.regex && !isRegexLikelyRipgrepCompatible(options.query)) {
|
|
255
|
+
throw new Error('regex uses JavaScript-only features that ripgrep does not support')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const cwd = getToolWorkspaceRoot(context)
|
|
259
|
+
const args = buildRipgrepArgs(options, context)
|
|
260
|
+
const matches = []
|
|
261
|
+
let stderr = ''
|
|
262
|
+
let buffer = ''
|
|
263
|
+
let killedForLimit = false
|
|
264
|
+
let settled = false
|
|
265
|
+
|
|
266
|
+
await new Promise((resolve, reject) => {
|
|
267
|
+
if (runtime.signal?.aborted) {
|
|
268
|
+
reject(new Error('Search aborted'))
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const child = spawn(executable.command, args, {
|
|
273
|
+
cwd,
|
|
274
|
+
shell: false,
|
|
275
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
276
|
+
windowsHide: true,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const cleanup = () => {
|
|
280
|
+
clearTimeout(timer)
|
|
281
|
+
runtime.signal?.removeEventListener?.('abort', onAbort)
|
|
282
|
+
}
|
|
283
|
+
const finish = (error) => {
|
|
284
|
+
if (settled) return
|
|
285
|
+
settled = true
|
|
286
|
+
cleanup()
|
|
287
|
+
if (error) reject(error)
|
|
288
|
+
else resolve()
|
|
289
|
+
}
|
|
290
|
+
const stopForLimit = () => {
|
|
291
|
+
if (killedForLimit) return
|
|
292
|
+
killedForLimit = true
|
|
293
|
+
killProcessTree(child, 'SIGTERM')
|
|
294
|
+
}
|
|
295
|
+
const processLine = (line) => {
|
|
296
|
+
if (!line || matches.length >= options.limit) return
|
|
297
|
+
if (options.filesWithMatches) {
|
|
298
|
+
matches.push(ripgrepRelativePath(line))
|
|
299
|
+
} else {
|
|
300
|
+
try {
|
|
301
|
+
const formatted = formatRipgrepJsonEvent(JSON.parse(line))
|
|
302
|
+
if (formatted) matches.push(formatted)
|
|
303
|
+
} catch {
|
|
304
|
+
// Ignore malformed partial output and let process exit handling decide fallback.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (matches.length >= options.limit) stopForLimit()
|
|
308
|
+
}
|
|
309
|
+
const flushLines = (chunk) => {
|
|
310
|
+
buffer += chunk.toString()
|
|
311
|
+
const lines = buffer.split(/\r?\n/)
|
|
312
|
+
buffer = lines.pop() || ''
|
|
313
|
+
for (const line of lines) processLine(line)
|
|
314
|
+
}
|
|
315
|
+
function onAbort() {
|
|
316
|
+
killProcessTree(child, 'SIGTERM')
|
|
317
|
+
finish(new Error('Search aborted'))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const timer = setTimeout(() => {
|
|
321
|
+
killProcessTree(child, 'SIGTERM')
|
|
322
|
+
finish(new Error('ripgrep search timed out'))
|
|
323
|
+
}, RIPGREP_TIMEOUT_MS)
|
|
324
|
+
|
|
325
|
+
runtime.signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
326
|
+
child.stdout.on('data', flushLines)
|
|
327
|
+
child.stderr.on('data', (chunk) => {
|
|
328
|
+
stderr = truncateText(stderr + chunk.toString(), 2000)
|
|
329
|
+
})
|
|
330
|
+
child.once('error', finish)
|
|
331
|
+
child.once('close', (code) => {
|
|
332
|
+
if (buffer) processLine(buffer)
|
|
333
|
+
if (killedForLimit || code === 0 || code === 1) {
|
|
334
|
+
finish()
|
|
335
|
+
} else {
|
|
336
|
+
finish(new Error(stderr.trim() || `ripgrep exited with code ${code}`))
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
content: matches.length ? truncateText(matches.slice(0, options.limit).join('\n')) : 'No matches found.',
|
|
343
|
+
details: {
|
|
344
|
+
path: toWorkspaceRelative(options.root, context),
|
|
345
|
+
project: context?.project,
|
|
346
|
+
query: options.query,
|
|
347
|
+
count: matches.length,
|
|
348
|
+
limit: options.limit,
|
|
349
|
+
backend: 'ripgrep',
|
|
350
|
+
ripgrepSource: executable.source,
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function grepFilesWithNode(options, context, extraDetails = {}) {
|
|
356
|
+
const flags = options.caseSensitive ? 'g' : 'gi'
|
|
357
|
+
const matcher = options.regex
|
|
358
|
+
? new RegExp(options.query, flags)
|
|
359
|
+
: new RegExp(escapeRegExp(options.query), flags)
|
|
360
|
+
|
|
361
|
+
const files = await walkFiles(options.root, [], context)
|
|
362
|
+
const matches = []
|
|
363
|
+
|
|
364
|
+
// Stat and filter files in parallel, then grep in parallel batches.
|
|
365
|
+
const candidateResults = await poolMap(files, async (file) => {
|
|
366
|
+
try {
|
|
367
|
+
const stat = await fs.stat(file)
|
|
368
|
+
if (stat.size > 1024 * 1024) return { file, skip: true }
|
|
369
|
+
return { file, skip: false }
|
|
370
|
+
} catch {
|
|
371
|
+
return { file, skip: true }
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
const candidates = candidateResults.filter((r) => !r.skip).map((r) => r.file)
|
|
376
|
+
|
|
377
|
+
// Grep with bounded concurrency — short-circuit when limit reached.
|
|
378
|
+
let matchCount = 0
|
|
379
|
+
const filesWithMatches = new Set()
|
|
380
|
+
for (let batchStart = 0; batchStart < candidates.length && matchCount < options.limit; batchStart += 20) {
|
|
381
|
+
const batch = candidates.slice(batchStart, batchStart + 20)
|
|
382
|
+
const batchMatches = await Promise.all(
|
|
383
|
+
batch.map(async (file) => {
|
|
384
|
+
if (matchCount >= options.limit) return []
|
|
385
|
+
try {
|
|
386
|
+
const text = await fs.readFile(file, 'utf8')
|
|
387
|
+
const lines = splitLines(text)
|
|
388
|
+
const fileMatches = []
|
|
389
|
+
for (let index = 0; index < lines.length && (matchCount + fileMatches.length) < options.limit; index++) {
|
|
390
|
+
matcher.lastIndex = 0
|
|
391
|
+
if (matcher.test(lines[index])) {
|
|
392
|
+
const relative = toWorkspaceRelative(file, context)
|
|
393
|
+
if (options.filesWithMatches) {
|
|
394
|
+
if (!filesWithMatches.has(relative)) {
|
|
395
|
+
filesWithMatches.add(relative)
|
|
396
|
+
fileMatches.push(relative)
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
fileMatches.push(`${relative}:${index + 1}: ${lines[index]}`)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return fileMatches
|
|
404
|
+
} catch {
|
|
405
|
+
return []
|
|
406
|
+
}
|
|
407
|
+
}),
|
|
408
|
+
)
|
|
409
|
+
for (const fm of batchMatches) {
|
|
410
|
+
if (matchCount >= options.limit) break
|
|
411
|
+
for (const m of fm) {
|
|
412
|
+
if (matchCount >= options.limit) break
|
|
413
|
+
matches.push(m)
|
|
414
|
+
matchCount++
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
content: matches.length ? truncateText(matches.join('\n')) : 'No matches found.',
|
|
421
|
+
details: {
|
|
422
|
+
path: toWorkspaceRelative(options.root, context),
|
|
423
|
+
project: context?.project,
|
|
424
|
+
query: options.query,
|
|
425
|
+
count: matches.length,
|
|
426
|
+
limit: options.limit,
|
|
427
|
+
backend: 'node',
|
|
428
|
+
...fallbackDetails(extraDetails),
|
|
429
|
+
},
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function toolGrepFiles(params, context, runtime = {}) {
|
|
434
|
+
const options = normalizeGrepParams(params, context)
|
|
435
|
+
await assertSafeWorkspacePath(options.root, context)
|
|
436
|
+
|
|
437
|
+
const executable = await resolveRipgrepExecutable()
|
|
438
|
+
if (executable) {
|
|
439
|
+
try {
|
|
440
|
+
return await grepFilesWithRipgrep(executable, options, context, runtime)
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (runtime.signal?.aborted) throw error
|
|
443
|
+
return grepFilesWithNode(options, context, {
|
|
444
|
+
fallbackFrom: 'ripgrep',
|
|
445
|
+
fallbackReason: error?.message || 'ripgrep unavailable',
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return grepFilesWithNode(options, context, { fallbackReason: 'ripgrep unavailable' })
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// --- write_file ---
|
|
454
|
+
export async function toolWriteFile(params, context) {
|
|
455
|
+
const file = resolveWorkspacePath(params?.path, context)
|
|
456
|
+
await assertSafeWorkspacePath(file, context, { forWrite: true })
|
|
457
|
+
|
|
458
|
+
const content = String(params?.content ?? '')
|
|
459
|
+
const relativePath = toWorkspaceRelative(file, context)
|
|
460
|
+
let oldText = ''
|
|
461
|
+
let existed = true
|
|
462
|
+
try {
|
|
463
|
+
oldText = await fs.readFile(file, 'utf8')
|
|
464
|
+
} catch (error) {
|
|
465
|
+
if (error?.code !== 'ENOENT') throw error
|
|
466
|
+
existed = false
|
|
467
|
+
}
|
|
468
|
+
const diff = createTextDiff(oldText, content, relativePath, { oldExists: existed })
|
|
469
|
+
|
|
470
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
471
|
+
await fs.writeFile(file, content, 'utf8')
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
content: `${existed ? 'Wrote' : 'Created'} ${relativePath} (+${diff.addedLines} -${diff.removedLines})`,
|
|
475
|
+
details: { path: relativePath, project: context?.project, bytes: Buffer.byteLength(content, 'utf8'), created: !existed, diff },
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
479
|
// --- edit_file ---
|
|
480
480
|
function countOccurrences(text, needle) {
|
|
481
481
|
if (!needle) return 0
|
|
@@ -488,17 +488,35 @@ function countOccurrences(text, needle) {
|
|
|
488
488
|
return count
|
|
489
489
|
}
|
|
490
490
|
|
|
491
|
+
function detectLineEnding(text) {
|
|
492
|
+
return text.includes('\r\n') ? '\r\n' : '\n'
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function normalizeLineEndings(text) {
|
|
496
|
+
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function convertToLineEnding(text, ending) {
|
|
500
|
+
const normalized = normalizeLineEndings(text)
|
|
501
|
+
return ending === '\r\n' ? normalized.replaceAll('\n', '\r\n') : normalized
|
|
502
|
+
}
|
|
503
|
+
|
|
491
504
|
export async function toolEditFile(params, context) {
|
|
492
505
|
const file = resolveWorkspacePath(params?.path, context)
|
|
493
506
|
await assertSafeWorkspacePath(file, context)
|
|
494
507
|
|
|
495
|
-
const
|
|
496
|
-
const
|
|
508
|
+
const rawOldText = String(params?.oldText ?? '')
|
|
509
|
+
const rawNewText = String(params?.newText ?? '')
|
|
497
510
|
const text = await fs.readFile(file, 'utf8')
|
|
511
|
+
const lineEnding = detectLineEnding(text)
|
|
512
|
+
const oldText = convertToLineEnding(rawOldText, lineEnding)
|
|
513
|
+
const newText = convertToLineEnding(rawNewText, lineEnding)
|
|
498
514
|
const count = countOccurrences(text, oldText)
|
|
499
515
|
|
|
500
516
|
if (count !== 1) {
|
|
501
|
-
const
|
|
517
|
+
const rawCount = oldText === rawOldText ? count : countOccurrences(text, rawOldText)
|
|
518
|
+
const suffix = rawCount !== count ? ` after normalizing line endings; raw match count was ${rawCount}` : ''
|
|
519
|
+
const error = new Error(`oldText must match exactly once; found ${count} matches${suffix}`)
|
|
502
520
|
error.statusCode = 400
|
|
503
521
|
throw error
|
|
504
522
|
}
|
|
@@ -515,234 +533,249 @@ export async function toolEditFile(params, context) {
|
|
|
515
533
|
}
|
|
516
534
|
}
|
|
517
535
|
|
|
518
|
-
// --- run_command ---
|
|
519
|
-
function activeSkillsForContext(context) {
|
|
520
|
-
return mergeSkills(context?.globalSkills, context?.projectSkills)
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
function activeSkillByName(context, name) {
|
|
524
|
-
const skillName = String(name || '')
|
|
525
|
-
return activeSkillsForContext(context).find((skill) => skill.name === skillName)
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
export async function loadSkillToolContext(config = {}) {
|
|
529
|
-
const globalSkills = await loadSelectedGlobalSkills(config.globalSkillNames)
|
|
530
|
-
const projectSkills = config.workspaceRoot
|
|
531
|
-
? await loadSelectedProjectSkills(config.projectSkillNames, config.workspaceRoot)
|
|
532
|
-
: []
|
|
533
|
-
return { globalSkills, projectSkills }
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// --- activate_skill ---
|
|
537
|
-
export async function toolActivateSkill(params, context) {
|
|
538
|
-
const skill = activeSkillByName(context, params?.name)
|
|
539
|
-
if (!skill) {
|
|
540
|
-
const error = new Error(`Unknown or disabled skill: ${params?.name || ''}`)
|
|
541
|
-
error.statusCode = 404
|
|
542
|
-
throw error
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
return {
|
|
546
|
-
content: truncateText(await formatSkillActivation(skill)),
|
|
547
|
-
details: {
|
|
548
|
-
skill: skill.name,
|
|
549
|
-
source: skill.source,
|
|
550
|
-
directory: skill.rootDir,
|
|
551
|
-
},
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// --- read_skill_resource ---
|
|
556
|
-
export async function toolReadSkillResource(params, context) {
|
|
557
|
-
const skill = activeSkillByName(context, params?.skill)
|
|
558
|
-
if (!skill) {
|
|
559
|
-
const error = new Error(`Unknown or disabled skill: ${params?.skill || ''}`)
|
|
560
|
-
error.statusCode = 404
|
|
561
|
-
throw error
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
const result = await readSkillResource(skill, params?.path, params)
|
|
565
|
-
return {
|
|
566
|
-
content: truncateText(result.content),
|
|
567
|
-
details: result.details,
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// --- run_command ---
|
|
572
|
-
function commandStatus(meta = {}) {
|
|
573
|
-
if (meta.running) return 'Status: running'
|
|
574
|
-
const flags = [
|
|
575
|
-
meta.timedOut ? 'timed out' : null,
|
|
576
|
-
meta.aborted ? 'aborted' : null,
|
|
577
|
-
].filter(Boolean)
|
|
578
|
-
const suffix = flags.length ? ` (${flags.join(', ')})` : ''
|
|
579
|
-
return `Exit code: ${meta.code ?? 'unknown'}${meta.signal ? `, signal: ${meta.signal}` : ''}${suffix}`
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
function formatCommandOutput(command, stdout, stderr, meta = {}) {
|
|
583
|
-
return [
|
|
584
|
-
`Command: ${command}`,
|
|
585
|
-
commandStatus(meta),
|
|
586
|
-
'',
|
|
587
|
-
'STDOUT:',
|
|
588
|
-
stdout || '(empty)',
|
|
589
|
-
'',
|
|
590
|
-
'STDERR:',
|
|
591
|
-
stderr || '(empty)',
|
|
592
|
-
].join('\n')
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
function killProcessTree(child, signal = 'SIGTERM') {
|
|
596
|
-
if (!child?.pid) return
|
|
597
|
-
|
|
598
|
-
if (process.platform === 'win32') {
|
|
599
|
-
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
600
|
-
stdio: 'ignore',
|
|
601
|
-
windowsHide: true,
|
|
602
|
-
})
|
|
603
|
-
killer.on('error', () => {
|
|
604
|
-
try { child.kill(signal) } catch { /* ignore */ }
|
|
605
|
-
})
|
|
606
|
-
return
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
try {
|
|
610
|
-
process.kill(-child.pid, signal)
|
|
611
|
-
} catch {
|
|
612
|
-
try { child.kill(signal) } catch { /* ignore */ }
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if (
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
if (
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
}
|
|
536
|
+
// --- run_command ---
|
|
537
|
+
function activeSkillsForContext(context) {
|
|
538
|
+
return mergeSkills(context?.globalSkills, context?.projectSkills)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function activeSkillByName(context, name) {
|
|
542
|
+
const skillName = String(name || '')
|
|
543
|
+
return activeSkillsForContext(context).find((skill) => skill.name === skillName)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export async function loadSkillToolContext(config = {}) {
|
|
547
|
+
const globalSkills = await loadSelectedGlobalSkills(config.globalSkillNames)
|
|
548
|
+
const projectSkills = config.workspaceRoot
|
|
549
|
+
? await loadSelectedProjectSkills(config.projectSkillNames, config.workspaceRoot)
|
|
550
|
+
: []
|
|
551
|
+
return { globalSkills, projectSkills }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// --- activate_skill ---
|
|
555
|
+
export async function toolActivateSkill(params, context) {
|
|
556
|
+
const skill = activeSkillByName(context, params?.name)
|
|
557
|
+
if (!skill) {
|
|
558
|
+
const error = new Error(`Unknown or disabled skill: ${params?.name || ''}`)
|
|
559
|
+
error.statusCode = 404
|
|
560
|
+
throw error
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
content: truncateText(await formatSkillActivation(skill)),
|
|
565
|
+
details: {
|
|
566
|
+
skill: skill.name,
|
|
567
|
+
source: skill.source,
|
|
568
|
+
directory: skill.rootDir,
|
|
569
|
+
},
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// --- read_skill_resource ---
|
|
574
|
+
export async function toolReadSkillResource(params, context) {
|
|
575
|
+
const skill = activeSkillByName(context, params?.skill)
|
|
576
|
+
if (!skill) {
|
|
577
|
+
const error = new Error(`Unknown or disabled skill: ${params?.skill || ''}`)
|
|
578
|
+
error.statusCode = 404
|
|
579
|
+
throw error
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const result = await readSkillResource(skill, params?.path, params)
|
|
583
|
+
return {
|
|
584
|
+
content: truncateText(result.content),
|
|
585
|
+
details: result.details,
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// --- run_command ---
|
|
590
|
+
function commandStatus(meta = {}) {
|
|
591
|
+
if (meta.running) return 'Status: running'
|
|
592
|
+
const flags = [
|
|
593
|
+
meta.timedOut ? 'timed out' : null,
|
|
594
|
+
meta.aborted ? 'aborted' : null,
|
|
595
|
+
].filter(Boolean)
|
|
596
|
+
const suffix = flags.length ? ` (${flags.join(', ')})` : ''
|
|
597
|
+
return `Exit code: ${meta.code ?? 'unknown'}${meta.signal ? `, signal: ${meta.signal}` : ''}${suffix}`
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function formatCommandOutput(command, stdout, stderr, meta = {}) {
|
|
601
|
+
return [
|
|
602
|
+
`Command: ${command}`,
|
|
603
|
+
commandStatus(meta),
|
|
604
|
+
'',
|
|
605
|
+
'STDOUT:',
|
|
606
|
+
stdout || '(empty)',
|
|
607
|
+
'',
|
|
608
|
+
'STDERR:',
|
|
609
|
+
stderr || '(empty)',
|
|
610
|
+
].join('\n')
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function killProcessTree(child, signal = 'SIGTERM') {
|
|
614
|
+
if (!child?.pid) return
|
|
615
|
+
|
|
616
|
+
if (process.platform === 'win32') {
|
|
617
|
+
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
|
|
618
|
+
stdio: 'ignore',
|
|
619
|
+
windowsHide: true,
|
|
620
|
+
})
|
|
621
|
+
killer.on('error', () => {
|
|
622
|
+
try { child.kill(signal) } catch { /* ignore */ }
|
|
623
|
+
})
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
process.kill(-child.pid, signal)
|
|
629
|
+
} catch {
|
|
630
|
+
try { child.kill(signal) } catch { /* ignore */ }
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const runningCommands = new Map()
|
|
635
|
+
|
|
636
|
+
export function abortRunningCommand(toolCallId) {
|
|
637
|
+
if (!toolCallId) return false
|
|
638
|
+
const stop = runningCommands.get(toolCallId)
|
|
639
|
+
if (!stop) return false
|
|
640
|
+
stop('abort')
|
|
641
|
+
return true
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export async function toolRunCommand(params, context, runtime = {}) {
|
|
645
|
+
const command = String(params?.command || '')
|
|
646
|
+
if (!command.trim()) {
|
|
647
|
+
const error = new Error('command is required')
|
|
648
|
+
error.statusCode = 400
|
|
649
|
+
throw error
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const timeoutMs = Math.min(10 * 60, Math.max(1, Number(params?.timeoutSeconds || 600))) * 1000
|
|
653
|
+
const cwd = getToolWorkspaceRoot(context)
|
|
654
|
+
|
|
655
|
+
if (runtime.signal?.aborted) {
|
|
656
|
+
const content = formatCommandOutput(command, '', 'Command aborted before start.', { aborted: true })
|
|
657
|
+
return { content: truncateText(content), details: { command, project: context?.project, cwd, aborted: true } }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return new Promise((resolve) => {
|
|
661
|
+
const child = spawn(command, {
|
|
662
|
+
cwd,
|
|
663
|
+
shell: true,
|
|
664
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
665
|
+
windowsHide: true,
|
|
666
|
+
detached: process.platform !== 'win32',
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
let stdout = ''
|
|
670
|
+
let stderr = ''
|
|
671
|
+
let timedOut = false
|
|
672
|
+
let aborted = false
|
|
673
|
+
let settled = false
|
|
674
|
+
let updateTimer = null
|
|
675
|
+
let updatePending = false
|
|
676
|
+
let forceKillTimer = null
|
|
677
|
+
|
|
678
|
+
const cleanup = () => {
|
|
679
|
+
clearTimeout(timer)
|
|
680
|
+
if (forceKillTimer) clearTimeout(forceKillTimer)
|
|
681
|
+
if (updateTimer) clearTimeout(updateTimer)
|
|
682
|
+
if (runtime.toolCallId) runningCommands.delete(runtime.toolCallId)
|
|
683
|
+
runtime.signal?.removeEventListener?.('abort', onAbort)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const finish = ({ code = null, signal = null, error = null } = {}) => {
|
|
687
|
+
if (settled) return
|
|
688
|
+
flushUpdate()
|
|
689
|
+
settled = true
|
|
690
|
+
cleanup()
|
|
691
|
+
if (error) {
|
|
692
|
+
resolve({
|
|
693
|
+
isError: true,
|
|
694
|
+
content: truncateText(`Error running command: ${error.message}`),
|
|
695
|
+
details: { command, project: context?.project, cwd, error: error.message, aborted, timedOut },
|
|
696
|
+
})
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
const content = formatCommandOutput(command, stdout, stderr, { code, signal, timedOut, aborted })
|
|
700
|
+
resolve({ content: truncateText(content), details: { command, project: context?.project, cwd, code, signal, timedOut, aborted } })
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const stopChild = (reason) => {
|
|
704
|
+
if (reason === 'timeout') timedOut = true
|
|
705
|
+
if (reason === 'abort') aborted = true
|
|
706
|
+
killProcessTree(child, 'SIGTERM')
|
|
707
|
+
forceKillTimer = setTimeout(() => {
|
|
708
|
+
killProcessTree(child, 'SIGKILL')
|
|
709
|
+
}, 1500)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (runtime.toolCallId) runningCommands.set(runtime.toolCallId, stopChild)
|
|
713
|
+
|
|
714
|
+
function onAbort() {
|
|
715
|
+
stopChild('abort')
|
|
716
|
+
finish({ signal: 'SIGTERM' })
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const runningDetails = () => ({ command, project: context?.project, cwd, running: true, stdout, stderr, toolCallId: runtime.toolCallId })
|
|
720
|
+
|
|
721
|
+
const emitUpdate = () => {
|
|
722
|
+
updateTimer = null
|
|
723
|
+
if (settled || !updatePending) return
|
|
724
|
+
updatePending = false
|
|
725
|
+
runtime.onUpdate?.({
|
|
726
|
+
content: [{ type: 'text', text: truncateText(formatCommandOutput(command, stdout, stderr, { running: true })) }],
|
|
727
|
+
details: runningDetails(),
|
|
728
|
+
})
|
|
729
|
+
}
|
|
730
|
+
const flushUpdate = () => {
|
|
731
|
+
if (updateTimer) {
|
|
732
|
+
clearTimeout(updateTimer)
|
|
733
|
+
updateTimer = null
|
|
734
|
+
}
|
|
735
|
+
if (!updatePending) return
|
|
736
|
+
updatePending = false
|
|
737
|
+
runtime.onUpdate?.({
|
|
738
|
+
content: [{ type: 'text', text: truncateText(formatCommandOutput(command, stdout, stderr, { running: true })) }],
|
|
739
|
+
details: runningDetails(),
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
const scheduleUpdate = () => {
|
|
743
|
+
if (settled) return
|
|
744
|
+
updatePending = true
|
|
745
|
+
if (!updateTimer) updateTimer = setTimeout(emitUpdate, 150)
|
|
746
|
+
}
|
|
747
|
+
const timer = setTimeout(() => {
|
|
748
|
+
stopChild('timeout')
|
|
749
|
+
finish({ signal: 'SIGTERM' })
|
|
750
|
+
}, timeoutMs)
|
|
751
|
+
|
|
752
|
+
runtime.signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
753
|
+
|
|
754
|
+
child.stdout.on('data', (chunk) => {
|
|
755
|
+
if (settled) return
|
|
756
|
+
stdout = truncateText(stdout + chunk.toString())
|
|
757
|
+
scheduleUpdate()
|
|
758
|
+
})
|
|
759
|
+
child.stderr.on('data', (chunk) => {
|
|
760
|
+
if (settled) return
|
|
761
|
+
stderr = truncateText(stderr + chunk.toString())
|
|
762
|
+
scheduleUpdate()
|
|
763
|
+
})
|
|
764
|
+
child.on('close', (code, signal) => {
|
|
765
|
+
finish({ code, signal })
|
|
766
|
+
})
|
|
767
|
+
child.on('error', (err) => {
|
|
768
|
+
finish({ error: err })
|
|
769
|
+
})
|
|
770
|
+
})
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export const toolHandlers = {
|
|
774
|
+
read_file: toolReadFile,
|
|
775
|
+
grep_files: toolGrepFiles,
|
|
776
|
+
write_file: toolWriteFile,
|
|
777
|
+
edit_file: toolEditFile,
|
|
778
|
+
run_command: toolRunCommand,
|
|
779
|
+
activate_skill: toolActivateSkill,
|
|
780
|
+
read_skill_resource: toolReadSkillResource,
|
|
781
|
+
}
|