refactor(fs): minimize and cap search card meta; keep TUI byte-identical
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.
This commit is contained in:
@@ -13,12 +13,12 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention'
|
||||
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 { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts'
|
||||
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'
|
||||
@@ -42,6 +42,8 @@ export interface GrepToolCaps {
|
||||
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`. */
|
||||
@@ -55,13 +57,6 @@ export interface GrepInput {
|
||||
include?: string
|
||||
}
|
||||
|
||||
/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */
|
||||
export interface GrepMatch {
|
||||
path: string
|
||||
lineNumber: number
|
||||
line: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an `include` that is not ONE positive glob filter: blank strings,
|
||||
* negated patterns (`!…`), and comma-separated lists. A comma inside a brace
|
||||
@@ -178,22 +173,6 @@ export function parseGrepMatches(stdout: string): GrepMatch[] {
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and
|
||||
* mark the cut. The cap is a per-line budget fact; the complete line stays in
|
||||
* the searched file for `read`.
|
||||
*
|
||||
* @param line - the matched line text (trailing newline already stripped).
|
||||
* @param maxBytes - the preview budget in bytes.
|
||||
* @returns the preview, suffixed with ` (line truncated)` when bytes were cut.
|
||||
*/
|
||||
export function previewLine(line: string, maxBytes: number): string {
|
||||
const retainer = new TextRetainer({ kind: 'head', maxBytes })
|
||||
retainer.push(line)
|
||||
const kept = retainer.finish()
|
||||
return kept.truncated ? `${kept.text} (line truncated)` : kept.text
|
||||
}
|
||||
|
||||
/** `match` / `matches` for a count. */
|
||||
function matchNoun(count: number): string {
|
||||
return count === 1 ? 'match' : 'matches'
|
||||
@@ -242,18 +221,10 @@ export function formatGrepOutput(retained: RetainedItems<GrepMatch>, spillRef: S
|
||||
return `${header}\n\n${body}\n\n(${recovery})`
|
||||
}
|
||||
|
||||
/** Apply the Native per-line preview budget without changing the canonical matches. */
|
||||
function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] {
|
||||
return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) }))
|
||||
}
|
||||
|
||||
/** Retain and format one canonical match list for the Native surface. */
|
||||
function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string {
|
||||
if (matches.length === 0) return 'No matches found'
|
||||
const previewed = previewGrepMatches(matches, maxLineBytes)
|
||||
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
|
||||
for (const match of previewed) retainer.push(match)
|
||||
return formatGrepOutput(retainer.finish(), spillRef)
|
||||
/** 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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,10 +242,10 @@ export function presentGrepCall(args: { pattern: string; path?: string; include?
|
||||
|
||||
/**
|
||||
* Completed-call presentation: the search card projected from the result's
|
||||
* `presentationMeta` (matches grouped by file, with the truncation signal), with
|
||||
* the model-facing result text attached as `content` for a UI without a search
|
||||
* card. Malformed or absent metadata (an obsolete or hand-edited replayed log)
|
||||
* falls back to the generic card.
|
||||
* `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.
|
||||
@@ -286,8 +257,8 @@ export function presentGrepResult(
|
||||
): SearchResultView | undefined {
|
||||
if (result.isError) return undefined
|
||||
const view = searchViewFromMeta(result.meta)
|
||||
if (view === undefined || view.kind !== 'matches') return undefined
|
||||
return { ...view, content: result.content }
|
||||
if (view === undefined || view.shape !== 'matches') return undefined
|
||||
return view
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,9 +308,10 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes),
|
||||
text: formatRetainedGrep(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes)),
|
||||
}],
|
||||
presentationMeta: (_args, value) => grepSearchMeta(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)
|
||||
@@ -368,17 +340,20 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void {
|
||||
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(previewGrepMatches(matches, caps.maxLineBytes))}`,
|
||||
`Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewedAll)}`,
|
||||
)
|
||||
return {
|
||||
kind: 'accept',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef),
|
||||
text: formatRetainedGrep(retainGrepMatches(matches, caps.maxMatches, caps.maxLineBytes), spillRef),
|
||||
}],
|
||||
...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user