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:
Chinesezjc
2026-07-30 21:57:49 +08:00
parent 74060dfb86
commit 7b6f33f872
23 changed files with 403 additions and 228 deletions

View File

@@ -1,7 +1,7 @@
/**
* Result-time search-card presentation for `grep` and `glob`. Both tools land on
* one `card: 'search'` render intent ({@link SearchResultView}) with two
* `kind`-discriminated shapes: `grep` projects its matches grouped by file
* `shape`-discriminated variants: `grep` projects its matches grouped by file
* ({@link SearchMatchesResultView}), `glob` projects a flat path list
* ({@link SearchPathsResultView}). This module owns the value→`presentationMeta`
* projection each tool declares and the defensive `meta`→view narrowing each
@@ -9,11 +9,19 @@
*
* The canonical value never crosses the wire — only the model-facing render text
* and this JSON `meta` do — so the structured shape a UI renders MUST ride in
* `meta`. Each projection applies the SAME inline cap the model-facing render
* applies ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} `grepMaxMatches`,
* {@link module:@deepseek-ai/dsh-tool-fs-search/glob} `globMaxResults`) and reports
* `total` (every result found) and `truncated`, so a UI never presents a capped
* result as complete.
* `meta`. Each projection consumes the SAME retained matches/paths the
* model-facing render consumes ({@link module:@deepseek-ai/dsh-tool-fs-search/search-core}
* `retainGrepMatches`/`retainGlobPaths`), so text and card agree about which
* results survived the inline cap, and reports `total` (every result found) and
* `truncated`, so a UI never presents a capped result as complete.
*
* A second, independent cap bounds the JSON `meta` itself: the retained matches
* of a broad search (hundreds of long lines) can still serialize to hundreds of
* kilobytes, and `meta` is persisted with the session log and re-sent on every
* request. {@link capMetaBytes} drops trailing groups/paths until the serialized
* `meta` fits `maxMetaBytes` and marks the result `truncated`; a deployment's
* final output budget (`dsh-spill-policy`) only shrinks `content`, never `meta`,
* so this projection owns keeping `meta` bounded.
*
* @module @deepseek-ai/dsh-tool-fs-search/presentation
*/
@@ -23,9 +31,8 @@ import type {
SearchLineMatch,
SearchResultView,
} from '@deepseek-ai/dsh-tools'
import { ItemRetainer } from '@deepseek-ai/dsh-retention'
import type { GrepMatch } from './grep.ts'
import { previewLine } from './grep.ts'
import type { RetainedItems } from '@deepseek-ai/dsh-retention'
import type { GrepMatch } from './search-core.ts'
/**
* The `grep`/`glob` tools' private `tool/result` `meta` payload: the capped,
@@ -42,8 +49,8 @@ import { previewLine } from './grep.ts'
* back as a {@link SearchResultView}.
*/
export type SearchMeta =
| { kind: 'matches'; files: MetaFileMatches[]; truncated: boolean; total: number }
| { kind: 'paths'; paths: string[]; truncated: boolean; total: number }
| { shape: 'matches'; files: MetaFileMatches[]; truncated: boolean; total: number }
| { shape: 'paths'; paths: string[]; truncated: boolean; total: number }
/** One matched line in {@link SearchMeta} (the JSON-assignable form of {@link SearchLineMatch}). */
type MetaLineMatch = { lineNumber: number; line: string }
@@ -72,38 +79,73 @@ export function groupMatchesByFile(matches: GrepMatch[]): MetaFileMatches[] {
return Array.from(byFile, ([path, fileMatches]) => ({ path, matches: fileMatches }))
}
/**
* Project the canonical `grep` matches into {@link SearchMeta} for the search
* card. Applies the per-line preview budget and the inline match cap exactly as
* the model-facing render does, groups the retained matches by file, and reports
* `total` (every parsed match) and `truncated`.
*
* @param matches - every match the search parsed (the canonical value's matches).
* @param maxMatches - the inline match cap (the `grepMaxMatches` config).
* @param maxLineBytes - the per-matched-line preview budget in bytes.
* @returns the `matches`-shaped search metadata.
*/
export function grepSearchMeta(matches: GrepMatch[], maxMatches: number, maxLineBytes: number): SearchMeta {
const retainer = new ItemRetainer<GrepMatch>({ kind: 'head', maxItems: maxMatches })
for (const match of matches) retainer.push({ ...match, line: previewLine(match.line, maxLineBytes) })
const retained = retainer.finish()
return { kind: 'matches', files: groupMatchesByFile(retained.items), truncated: retained.truncated, total: retained.seen }
/** The serialized UTF-8 byte size of one meta payload (the size persisted and re-sent). */
function metaBytes(meta: SearchMeta): number {
return Buffer.byteLength(JSON.stringify(meta), 'utf8')
}
/**
* Project the canonical `glob` paths into {@link SearchMeta} for the search card.
* Applies the inline path cap exactly as the model-facing render does and reports
* `total` (every discovered path) and `truncated`.
* Drop trailing top-level items (file groups or paths) until the serialized meta
* fits `maxMetaBytes`, marking the result `truncated` when anything was dropped.
* `total` is preserved (it counts what the search found, not what meta retains).
* A single item too large to fit on its own is kept: the invariant is a bounded
* payload wherever droppable, never an empty card that hides a real result.
*
* @param paths - every path the search discovered (the canonical value's paths).
* @param maxResults - the inline path cap (the `globMaxResults` config).
* @param meta - the projected meta, already capped to the inline item count.
* @param maxMetaBytes - the serialized-meta byte budget.
* @returns the same meta when it fits, else a byte-bounded copy marked `truncated`.
*/
function capMetaBytes(meta: SearchMeta, maxMetaBytes: number): SearchMeta {
if (metaBytes(meta) <= maxMetaBytes) return meta
if (meta.shape === 'matches') {
const files = [...meta.files]
while (files.length > 1 && metaBytes({ ...meta, files, truncated: true }) > maxMetaBytes) files.pop()
return { ...meta, files, truncated: true }
}
const paths = [...meta.paths]
while (paths.length > 1 && metaBytes({ ...meta, paths, truncated: true }) > maxMetaBytes) paths.pop()
return { ...meta, paths, truncated: true }
}
/**
* Project the retained `grep` matches into {@link SearchMeta} for the search
* card. Consumes the same {@link RetainedItems} the model-facing render consumes
* (preview budget and inline match cap already applied), groups the retained
* matches by file, reports `total` (every parsed match) and `truncated`, then
* bounds the serialized meta to `maxMetaBytes`.
*
* @param retained - the retention outcome over every parsed match (previewed, capped).
* @param maxMetaBytes - the serialized-meta byte budget.
* @returns the `matches`-shaped search metadata.
*/
export function grepSearchMeta(retained: RetainedItems<GrepMatch>, maxMetaBytes: number): SearchMeta {
const meta: SearchMeta = {
shape: 'matches',
files: groupMatchesByFile(retained.items),
truncated: retained.truncated,
total: retained.seen,
}
return capMetaBytes(meta, maxMetaBytes)
}
/**
* Project the retained `glob` paths into {@link SearchMeta} for the search card.
* Consumes the same {@link RetainedItems} the model-facing render consumes (inline
* path cap already applied), reports `total` (every discovered path) and
* `truncated`, then bounds the serialized meta to `maxMetaBytes`.
*
* @param retained - the retention outcome over every discovered path (capped).
* @param maxMetaBytes - the serialized-meta byte budget.
* @returns the `paths`-shaped search metadata.
*/
export function globSearchMeta(paths: string[], maxResults: number): SearchMeta {
const retainer = new ItemRetainer<string>({ kind: 'head', maxItems: maxResults })
for (const path of paths) retainer.push(path)
const retained = retainer.finish()
return { kind: 'paths', paths: retained.items, truncated: retained.truncated, total: retained.seen }
export function globSearchMeta(retained: RetainedItems<string>, maxMetaBytes: number): SearchMeta {
const meta: SearchMeta = {
shape: 'paths',
paths: retained.items,
truncated: retained.truncated,
total: retained.seen,
}
return capMetaBytes(meta, maxMetaBytes)
}
/** Whether `value` is a valid {@link SearchLineMatch} (defensive narrowing from opaque `meta`). */
@@ -124,8 +166,13 @@ function isSearchFileMatches(value: unknown): value is SearchFileMatches {
* Narrow opaque live or replayed result metadata to a {@link SearchResultView}.
* Malformed metadata returns `undefined` so `presentResult` can fall back to the
* generic card instead of throwing during replay of an older or hand-edited log.
* The returned view carries no `content`; the caller attaches the model-facing
* result text so a UI without a search card renders it as text.
* The view carries no result text: a UI without a search card falls back to the
* raw `tool/result` content.
*
* A zero-result meta (`files: []` / `paths: []`) narrows to a valid empty card —
* unlike the mirrored `diffsFromMeta`, which rejects empty diffs, because a
* zero-match grep is a legitimate result a UI shows as "no matches", not an
* absent projection.
*
* @param meta - result metadata (the {@link SearchMeta} the tool projected).
* @returns the search view, or `undefined` for absent or malformed metadata.
@@ -135,15 +182,15 @@ export function searchViewFromMeta(meta: unknown): SearchResultView | undefined
const record = meta as Record<string, unknown>
const { truncated, total } = record
if (typeof truncated !== 'boolean' || typeof total !== 'number') return undefined
if (record.kind === 'matches') {
if (record.shape === 'matches') {
const { files } = record
if (!Array.isArray(files) || !files.every(isSearchFileMatches)) return undefined
return { card: 'search', kind: 'matches', files: files, truncated, total }
return { card: 'search', shape: 'matches', files: files, truncated, total }
}
if (record.kind === 'paths') {
if (record.shape === 'paths') {
const { paths } = record
if (!Array.isArray(paths) || !paths.every((path): path is string => typeof path === 'string')) return undefined
return { card: 'search', kind: 'paths', paths, truncated, total }
return { card: 'search', shape: 'paths', paths, truncated, total }
}
return undefined
}