Files
goodbuddy/src/main/agent/direct-model-ripgrep.ts
T
mesalogo ec100f8176 feat: improve direct model workspace tools
Direct models now use bundled ripgrep for workspace discovery, read files through resumable pages, and edit multiple files with apply_patch. Ask can search and read while Execute retains write access, and fixed total input limits no longer constrain these tools.

Release note: 直连模型现可使用随包 ripgrep 搜索项目、分页读取任意大小的文本,并通过多文件补丁修改代码;设置中的相关能力统一显示为工作区文件和开发工具。
2026-09-10 00:45:03 +08:00

240 lines
6.0 KiB
TypeScript

import { execFile } from 'node:child_process'
import type { WorkspaceAccess } from '../workspace'
const MAXIMUM_CAPTURE_BYTES = 4 * 1024 * 1024
const MAXIMUM_RESULT_BYTES = 240 * 1024
export type WorkspaceRipgrepInput = {
pattern?: string
path: string
glob: string[]
fixedStrings: boolean
ignoreCase: boolean
filesOnly: boolean
maxResults: number
}
type RipgrepProcessResult = {
stdout: string
stderr: string
code: number | string | null | undefined
truncated: boolean
}
function runRipgrep(
executablePath: string,
args: string[],
cwd: string,
signal: AbortSignal
): Promise<RipgrepProcessResult> {
return new Promise((resolve, reject) => {
execFile(
executablePath,
args,
{
cwd,
encoding: 'utf8',
maxBuffer: MAXIMUM_CAPTURE_BYTES,
signal,
windowsHide: true
},
(error, stdout, stderr) => {
if (signal.aborted) {
reject(signal.reason ?? error)
return
}
const processError = error as (Error & {
code?: number | string
killed?: boolean
}) | null
const output = stdout
const errors = stderr
if (
processError &&
processError.code !== 1 &&
processError.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
) {
reject(
new Error(
errors.trim().slice(0, 2_000) ||
processError.message ||
'ripgrep 执行失败',
{ cause: processError }
)
)
return
}
resolve({
stdout: output,
stderr: errors,
code: processError?.code ?? 0,
truncated:
processError?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
})
}
)
})
}
function normalizeResultPath(value: string): string {
return value.replaceAll('\\', '/').replace(/^\.\//u, '')
}
function appendBounded(
lines: string[],
line: string,
state: { bytes: number }
): boolean {
const bytes = Buffer.byteLength(line) + 1
if (state.bytes + bytes > MAXIMUM_RESULT_BYTES) {
return false
}
lines.push(line)
state.bytes += bytes
return true
}
function formatFileResults(
stdout: string,
maximumResults: number,
initiallyTruncated: boolean
): string {
const paths = stdout
.split('\0')
.filter(Boolean)
.map(normalizeResultPath)
const lines: string[] = []
const state = { bytes: 0 }
let truncated = initiallyTruncated || paths.length > maximumResults
for (const path of paths.slice(0, maximumResults)) {
if (!appendBounded(lines, path, state)) {
truncated = true
break
}
}
if (truncated) {
lines.push(`...[truncated after ${lines.length} files]`)
}
return lines.join('\n') || '[no files found]'
}
function textField(value: unknown): string | undefined {
if (!value || typeof value !== 'object' || !('text' in value)) {
return undefined
}
return typeof value.text === 'string' ? value.text : undefined
}
function formatMatchResults(
stdout: string,
maximumResults: number,
initiallyTruncated: boolean
): string {
const lines: string[] = []
const state = { bytes: 0 }
let truncated = initiallyTruncated
for (const rawLine of stdout.split('\n')) {
if (!rawLine) continue
let event: unknown
try {
event = JSON.parse(rawLine)
} catch {
continue
}
if (
!event ||
typeof event !== 'object' ||
!('type' in event) ||
event.type !== 'match' ||
!('data' in event) ||
!event.data ||
typeof event.data !== 'object'
) {
continue
}
const data = event.data as Record<string, unknown>
const path = textField(data.path)
const matchedLines = textField(data.lines)
const lineNumber = data.line_number
const submatches = data.submatches
if (
path === undefined ||
matchedLines === undefined ||
typeof lineNumber !== 'number' ||
!Array.isArray(submatches)
) {
continue
}
const first = submatches[0]
const byteColumn =
first &&
typeof first === 'object' &&
'start' in first &&
typeof first.start === 'number'
? first.start + 1
: 1
const linePrefix = Buffer.from(matchedLines).subarray(0, byteColumn - 1)
const column = [...linePrefix.toString('utf8')].length + 1
const preview = matchedLines
.replaceAll('\r', '')
.replaceAll('\n', '\\n')
.slice(0, 1_000)
const formatted = `${normalizeResultPath(path)}:${lineNumber}:${column}:${preview}`
if (
lines.length >= maximumResults ||
!appendBounded(lines, formatted, state)
) {
truncated = true
break
}
}
if (truncated) {
lines.push(`...[truncated after ${lines.length} matches]`)
}
return lines.join('\n') || '[no matches found]'
}
export async function searchWorkspaceWithRipgrep(
executablePath: string,
input: WorkspaceRipgrepInput,
workspace: WorkspaceAccess,
signal: AbortSignal
): Promise<string> {
const identity = await workspace.getIdentity()
if (identity.kind !== 'local') {
throw new Error('内置 ripgrep 当前只支持本机工作区')
}
if (input.path !== '.') {
await workspace.stat({ path: input.path, signal })
}
const args = ['--no-config', '--hidden', '--glob', '!.git/**']
for (const glob of input.glob) {
args.push('--glob', glob)
}
if (input.filesOnly) {
args.push('--files', '--null', '--', input.path)
} else {
args.push('--json')
if (input.fixedStrings) args.push('--fixed-strings')
if (input.ignoreCase) args.push('--ignore-case')
args.push('--regexp', input.pattern!, '--', input.path)
}
const result = await runRipgrep(
executablePath,
args,
identity.canonicalDisplayPath,
signal
)
return input.filesOnly
? formatFileResults(
result.stdout,
input.maxResults,
result.truncated
)
: formatMatchResults(
result.stdout,
input.maxResults,
result.truncated
)
}