Address the review of the search render card: - The search result view carries no `content`: it was a no-op for every consumer and serialized the whole search text twice. A UI without a search card falls back to the raw tool/result content; the TUI stays byte-identical to the pre-search-card generic fallback. - Bound the serialized presentationMeta with a configurable searchMetaMaxBytes (default 64 KiB): the inline item cap does not bound bytes, and spill-policy only shrinks content, never meta. capMetaBytes drops trailing groups/paths. - Share one retention pass (retainGrepMatches/retainGlobPaths in search-core) between the model-facing render and the meta projection; remove the second cap/preview implementation and the presentation<->grep module cycle by moving GrepMatch/previewLine to search-core. - Rename the result-view discriminant kind -> shape so it no longer collides with GenericCallView.kind (ToolCallKind, whose values include 'search'). - Narrow the entry export surface to consumed symbols. - Sync the three bilingual ToolResultView doc pairs and the Agent Note pair; document the deliberate empty-card acceptance vs diffsFromMeta. - Regenerate config/tool/cordis catalogs for the new config field.
362 lines
16 KiB
TypeScript
362 lines
16 KiB
TypeScript
/**
|
|
* The model-facing `grep` tool: search file contents with a ripgrep regular
|
|
* expression. Execution goes through the bash seam (`ctx.bash`) with a fixed
|
|
* line-oriented `rg --json` command so file path, line number, and line text
|
|
* parse without colon-splitting ambiguity — this module owns the model-facing
|
|
* schema, argument validation, shell-safe command construction, `--json`
|
|
* record parsing, per-line preview retention, match retention, grouping, and
|
|
* formatting; process concerns stay behind `ctx.bash`.
|
|
*
|
|
* @module @deepseek-ai/dsh-tool-fs-search/grep
|
|
*/
|
|
|
|
import type { Context } from 'cordis'
|
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
|
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
|
|
import type { SpillRef } from '@deepseek-ai/dsh-spill'
|
|
import type {} from '@deepseek-ai/dsh-bash'
|
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
import type { GrepMatch } from './search-core.ts'
|
|
import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
|
import { grepSearchMeta, searchViewFromMeta } from './presentation.ts'
|
|
import { singleQuote } from './shell-quote.ts'
|
|
import { acceptedSurfaceValue } from './surface.ts'
|
|
|
|
/**
|
|
* Default cap on flat matches retained inline by one `grep` call (the
|
|
* `grepMaxMatches` config), matching Claude Code's default `GrepTool`
|
|
* `head_limit`.
|
|
*/
|
|
export const GREP_MAX_MATCHES = 250
|
|
|
|
/**
|
|
* Default cap in bytes on one matched-line preview (the `grepMaxLineBytes`
|
|
* config); the cut preserves UTF-8 boundaries.
|
|
*/
|
|
export const GREP_MAX_LINE_BYTES = 2000
|
|
|
|
/** Resolved grep-tool caps — plugin config after defaulting (see `Config` in index.ts). */
|
|
export interface GrepToolCaps {
|
|
/** Max flat matches retained inline; later matches go to the formatted spill file. */
|
|
maxMatches: number
|
|
/** Max bytes retained per matched-line preview. */
|
|
maxLineBytes: number
|
|
/** Max bytes of serialized `presentationMeta`; trailing file groups drop past it. */
|
|
maxMetaBytes: number
|
|
/** Cap on the complete raw `rg` stdout the tool will parse. */
|
|
rawOutputMaxBytes: number
|
|
/** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */
|
|
timeoutMs: number
|
|
}
|
|
|
|
/** Validated `grep` arguments. */
|
|
export interface GrepInput {
|
|
pattern: string
|
|
path?: string
|
|
include?: string
|
|
}
|
|
|
|
/**
|
|
* Reject an `include` that is not ONE positive glob filter: blank strings,
|
|
* negated patterns (`!…`), and comma-separated lists. A comma inside a brace
|
|
* group is fine — `*.{ts,tsx}` is one glob with alternation, not a list.
|
|
*/
|
|
function validateInclude(include: string): void {
|
|
if (include.trim().length === 0) throw new Error('include must be a non-empty glob when given')
|
|
if (include.startsWith('!')) throw new Error('include must be a positive glob filter; negated patterns ("!…") are not supported')
|
|
let braceDepth = 0
|
|
for (const char of include) {
|
|
if (char === '{') braceDepth++
|
|
else if (char === '}') braceDepth = Math.max(0, braceDepth - 1)
|
|
else if (char === ',' && braceDepth === 0) {
|
|
throw new Error('include must be one glob, not a comma-separated list (use {a,b} alternation instead)')
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate value constraints the schema DSL can't express: a non-EMPTY
|
|
* `pattern` (whitespace is a legitimate regex), a non-blank `path` when given,
|
|
* and a single positive `include` glob ({@link GrepInput}). Throws a plain
|
|
* `Error` (an ordinary tool argument error) otherwise.
|
|
*
|
|
* @param args - the schema-validated `grep` arguments.
|
|
* @returns the accepted input, unchanged.
|
|
*/
|
|
export function parseGrepArgs(args: { pattern: string; path?: string; include?: string }): GrepInput {
|
|
if (args.pattern.length === 0) throw new Error('pattern must be a non-empty string')
|
|
if (args.path !== undefined && args.path.trim().length === 0) throw new Error('path must be a non-empty string when given')
|
|
if (args.include !== undefined) validateInclude(args.include)
|
|
return {
|
|
pattern: args.pattern,
|
|
...args.path !== undefined ? { path: args.path } : {},
|
|
...args.include !== undefined ? { include: args.include } : {},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the fixed line-oriented `rg --json` command for one `grep` call. Every
|
|
* model-controlled value ({@link GrepInput.pattern}, {@link GrepInput.path},
|
|
* {@link GrepInput.include}) passes through {@link singleQuote}; the pattern
|
|
* and include ride in `--flag=value` form and the target behind `--`, so a
|
|
* leading-dash value can never be parsed as a flag.
|
|
*
|
|
* @param input - the validated arguments.
|
|
* @returns the complete, shell-safe command string.
|
|
*/
|
|
export function buildGrepCommand(input: GrepInput): string {
|
|
const parts = ['rg --json', `--regexp=${singleQuote(input.pattern)}`]
|
|
if (input.include !== undefined) parts.push(`--glob=${singleQuote(input.include)}`)
|
|
if (input.path !== undefined) parts.push('--', singleQuote(input.path))
|
|
return parts.join(' ')
|
|
}
|
|
|
|
/**
|
|
* The uniform malformed-output failure: raw `rg --json` is an internal
|
|
* transport, so a shape surprise is a search failure, not a partial result.
|
|
*/
|
|
function malformedRecord(detail: string, cause?: unknown): SearchError {
|
|
return new SearchError(`grep received malformed ripgrep --json output (${detail})`, 'SEARCH_FAILED', cause !== undefined ? { cause } : undefined)
|
|
}
|
|
|
|
/**
|
|
* Parse one `rg --json` NDJSON line into a match, `undefined` for the
|
|
* non-match record types (`begin`/`end`/`context`/`summary`). A line that is
|
|
* not JSON, or a `match` record missing its path / line number / line content,
|
|
* throws {@link SearchError} `SEARCH_FAILED`. A match whose line is not valid
|
|
* UTF-8 (ripgrep sends base64 `bytes` instead of `text`) yields a placeholder
|
|
* preview rather than failing the whole search.
|
|
*/
|
|
function parseRecord(line: string): GrepMatch | undefined {
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(line)
|
|
} catch (error: unknown) {
|
|
throw malformedRecord('a line is not JSON', error)
|
|
}
|
|
if (typeof parsed !== 'object' || parsed === null) throw malformedRecord('a record is not an object')
|
|
const record = parsed as { type?: unknown; data?: unknown }
|
|
// Non-match record types (begin/end/context/summary — and any future type)
|
|
// are transport framing, not results: skipped, not malformed.
|
|
if (record.type !== 'match') return undefined
|
|
if (typeof record.data !== 'object' || record.data === null) throw malformedRecord('a match record has no data')
|
|
const data = record.data as { path?: unknown; line_number?: unknown; lines?: unknown }
|
|
const pathText = typeof data.path === 'object' && data.path !== null ? (data.path as { text?: unknown }).text : undefined
|
|
if (typeof pathText !== 'string') throw malformedRecord('a match record has no path text')
|
|
if (typeof data.line_number !== 'number') throw malformedRecord('a match record has no line number')
|
|
if (typeof data.lines !== 'object' || data.lines === null) throw malformedRecord('a match record has no line content')
|
|
const lines = data.lines as { text?: unknown; bytes?: unknown }
|
|
if (typeof lines.text === 'string') {
|
|
return { path: pathText, lineNumber: data.line_number, line: lines.text.replace(/\r?\n$/, '') }
|
|
}
|
|
if (typeof lines.bytes === 'string') {
|
|
return { path: pathText, lineNumber: data.line_number, line: '(line is not valid UTF-8)' }
|
|
}
|
|
throw malformedRecord('a match record has neither line text nor bytes')
|
|
}
|
|
|
|
/**
|
|
* Parse complete `rg --json` stdout into flat matches, in output order (ripgrep
|
|
* emits one file's matches contiguously). Only `match` records are consumed.
|
|
*
|
|
* @param stdout - the complete raw `rg --json` stdout.
|
|
* @returns the flat matches; empty for output with no match records.
|
|
*/
|
|
export function parseGrepMatches(stdout: string): GrepMatch[] {
|
|
const matches: GrepMatch[] = []
|
|
for (const line of stdout.split('\n')) {
|
|
if (line.length === 0) continue
|
|
const match = parseRecord(line)
|
|
if (match !== undefined) matches.push(match)
|
|
}
|
|
return matches
|
|
}
|
|
|
|
/** `match` / `matches` for a count. */
|
|
function matchNoun(count: number): string {
|
|
return count === 1 ? 'match' : 'matches'
|
|
}
|
|
|
|
/**
|
|
* Group flat matches by file (first-seen order) into the model-facing body:
|
|
* each file's display path, then one `Line N: <text>` row per match.
|
|
*
|
|
* @param matches - the flat matches to render.
|
|
* @returns the grouped body text.
|
|
*/
|
|
export function formatGrepMatches(matches: GrepMatch[]): string {
|
|
const byFile = new Map<string, GrepMatch[]>()
|
|
for (const match of matches) {
|
|
const group = byFile.get(match.path)
|
|
if (group !== undefined) group.push(match)
|
|
else byFile.set(match.path, [match])
|
|
}
|
|
const sections: string[] = []
|
|
for (const [path, group] of byFile) {
|
|
sections.push(`${path}\n${group.map(m => `Line ${m.lineNumber}: ${m.line}`).join('\n')}`)
|
|
}
|
|
return sections.join('\n\n')
|
|
}
|
|
|
|
/**
|
|
* Format the model-facing `grep` result: a found-count header, the retained
|
|
* matches grouped by file, then — when the result was capped — a footer
|
|
* carrying either the formatted-spill recovery locator or the could-not-save
|
|
* explanation. The omitted count is a budget fact: the search itself completed.
|
|
*
|
|
* @param retained - the retention outcome over every parsed match.
|
|
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
|
|
* @returns the model-facing text.
|
|
*/
|
|
export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: SpillRef | undefined): string {
|
|
const header = retained.truncated
|
|
? `Found ${retained.kept} of ${retained.seen} matches`
|
|
: `Found ${retained.seen} ${matchNoun(retained.seen)}`
|
|
const body = formatGrepMatches(retained.items)
|
|
if (!retained.truncated) return `${header}\n\n${body}`
|
|
const recovery = spillRef !== undefined
|
|
? `Full grep result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}`
|
|
: 'The complete result could not be saved; narrow pattern, path, or include to see more.'
|
|
return `${header}\n\n${body}\n\n(${recovery})`
|
|
}
|
|
|
|
/** Format one already-retained match list for the Native surface. */
|
|
function formatRetainedGrep(retained: RetainedItems<GrepMatch>, spillRef?: SpillRef): string {
|
|
if (retained.seen === 0) return 'No matches found'
|
|
return formatGrepOutput(retained, spillRef)
|
|
}
|
|
|
|
/**
|
|
* Pending-call presentation: a search card titled by the pattern (and target /
|
|
* include filter).
|
|
*
|
|
* @param args - the raw tool arguments; `pattern`, `path`, and `include` feed the title.
|
|
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
|
|
*/
|
|
export function presentGrepCall(args: { pattern: string; path?: string; include?: string }): GenericCallView {
|
|
const where = args.path !== undefined ? ` in ${args.path}` : ''
|
|
const filter = args.include !== undefined ? ` (${args.include})` : ''
|
|
return { card: 'generic', title: `Grep ${args.pattern}${where}${filter}`, kind: 'search', rawInput: args.pattern }
|
|
}
|
|
|
|
/**
|
|
* Completed-call presentation: the search card projected from the result's
|
|
* `presentationMeta` (matches grouped by file, with the truncation signal). A UI
|
|
* without a search card falls back to the raw `tool/result` content, so the view
|
|
* carries no result text of its own. Malformed or absent metadata (an obsolete or
|
|
* hand-edited replayed log) falls back to the generic card.
|
|
*
|
|
* @param _args - the raw tool arguments; unused, the view derives from the result.
|
|
* @param result - the final model-facing tool result carrying the projected metadata.
|
|
* @returns the search card view, or `undefined` for the generic fallback.
|
|
*/
|
|
export function presentGrepResult(
|
|
_args: { pattern: string; path?: string; include?: string },
|
|
result: ToolResult,
|
|
): SearchResultView | undefined {
|
|
if (result.isError) return undefined
|
|
const view = searchViewFromMeta(result.meta)
|
|
if (view === undefined || view.shape !== 'matches') return undefined
|
|
return view
|
|
}
|
|
|
|
/**
|
|
* Register the `grep` tool and its system-prompt guidance.
|
|
*
|
|
* @param ctx - the plugin context; registrations are effects scoped to it, and
|
|
* execution uses its `bash` service.
|
|
* @param caps - the deployment's resolved grep caps (plugin config after defaulting).
|
|
*/
|
|
export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
|
ctx.systemPrompt.section({
|
|
name: 'tool:grep',
|
|
order: 104,
|
|
text: 'Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.',
|
|
})
|
|
|
|
const tool = defineTool({
|
|
name: 'grep',
|
|
description: 'Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. '
|
|
+ `Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. `
|
|
+ 'Use read on a matched file for surrounding context.',
|
|
parameters: {
|
|
pattern: { type: 'string', required: true, description: 'Regular expression to search for (ripgrep syntax).' },
|
|
path: { type: 'string', description: 'File or directory to search. Defaults to the session workspace; a relative path resolves against it.' },
|
|
include: { type: 'string', description: 'One glob filter for which files to search (e.g. "*.ts", "*.{js,jsx}"). Not a list; negation is not supported.' },
|
|
},
|
|
timeoutMs: caps.timeoutMs,
|
|
output: {
|
|
schema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
matches: {
|
|
type: 'array',
|
|
required: true,
|
|
items: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
path: { type: 'string', required: true },
|
|
lineNumber: { type: 'integer', required: true },
|
|
line: { type: 'string', required: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
render: (_args, value) => [{
|
|
type: 'text',
|
|
text: formatRetainedGrep(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes)),
|
|
}],
|
|
presentationMeta: (_args, value) =>
|
|
grepSearchMeta(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), caps.maxMetaBytes),
|
|
},
|
|
async execute(args, exec) {
|
|
const input = parseGrepArgs(args)
|
|
const run = await runRipgrep(ctx, exec, 'grep', buildGrepCommand(input), caps.rawOutputMaxBytes)
|
|
if (run.noMatches) return { matches: [] }
|
|
|
|
const all: GrepMatch[] = []
|
|
for (const raw of parseGrepMatches(run.stdout)) {
|
|
const match: GrepMatch = {
|
|
path: toWorkdirRelative(raw.path, run.workdir),
|
|
lineNumber: raw.lineNumber,
|
|
line: raw.line,
|
|
}
|
|
all.push(match)
|
|
}
|
|
return { matches: all }
|
|
},
|
|
presentCall: presentGrepCall,
|
|
presentResult: presentGrepResult,
|
|
})
|
|
ctx.tools.register(tool)
|
|
|
|
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
const decision = await next()
|
|
const value = acceptedSurfaceValue(ctx, tool, exec, result, decision) as { matches: GrepMatch[] } | undefined
|
|
if (value === undefined) return decision
|
|
const matches = value.matches
|
|
if (matches.length <= caps.maxMatches) return decision
|
|
// The spill artifact holds the COMPLETE result: preview each line, but keep
|
|
// every match (no inline cap), so the recovery file is the full search.
|
|
const previewedAll = matches.map(match => ({ ...match, line: previewLine(match.line, caps.maxLineBytes) }))
|
|
const spillRef = await trySaveFormattedResult(
|
|
ctx,
|
|
exec,
|
|
'grep-results.txt',
|
|
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewedAll)}`,
|
|
)
|
|
return {
|
|
kind: 'accept',
|
|
content: [{
|
|
type: 'text',
|
|
text: formatRetainedGrep(retainGrepMatches(matches, caps.maxMatches, caps.maxLineBytes), spillRef),
|
|
}],
|
|
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
|
|
}
|
|
})
|
|
}
|