Files
dsh-context/src/shared/fileOps.ts
T
Bowen Liang 6faf8f16bf feat: count the searched target alongside the matched files in the op log
A grep/glob with the complete matched-file meta rowed ONLY per hit file —
the searched path (or the workspace-wide pattern) vanished from the File
Activity card entirely. Both now book: the call's target row first (the
searched path, carrying the pattern(+include) detail; a pathless search
rows the pattern itself, marked as a pattern), then one row per matched
file with its hit count. The degenerate single-file search (the target IS
the matched file) keeps one row. Truncated or malformed metas keep the
honest fallback (the call target only).

The fold's op-log semantics change, so stateVersion bumps to 15 and cached
rows refold.

Verified: 1147 tests, per-file 100% coverage; live on a grep-heavy session
the card counts 80 search ops — the searched directories row alongside
their per-file hits (plus a deterministic fold over the real log: 6 target
rows + 107 hit rows).
2026-09-06 12:03:07 +08:00

271 lines
11 KiB
TypeScript

/**
* The file-operation parser — the ONE derivation of "what the agent did to
* files" from a settled file-tool call. Shared by both halves: the host fold
* books the op log from the durable tool lifecycle (tool/call +
* tool/result + tool/code-dispatch), and the client's INLINE-generation
* fallback re-derives ops from the conversation-window join when an older
* host serves no `fileOps`.
*
* Tool coverage matches the harness's built-ins on every supported baseline
* (0.1.2-rc.1): read / read_image / write / edit (tool-fs) and grep / glob
* (tool-fs-search), plus the Anthropic-style `str_replace_editor` (view
* reads, every other command writes). Line deltas are estimates read off the
* call ARGUMENTS (an edit's old/new strings, a write's content), never off
* result payloads. A search resolves further when its result's bounded
* presentation meta names the matched files — the ops then row per real
* file with the match count, and only a capped (`truncated`) or malformed
* meta falls back to the call's own target (the narrowing path, else the
* searched pattern itself).
*/
import type { FileOpRecord } from './types'
/** Parse a call's raw JSON arguments; non-string/malformed/non-record inputs yield null. */
export function parseCallArgs(raw: unknown): Record<string, unknown> | null {
if (typeof raw !== 'string' || raw === '') return null
try {
const parsed: unknown = JSON.parse(raw)
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null
} catch {
return null
}
}
const KIND_BY_TOOL: Record<string, FileOpRecord['kind']> = {
read: 'read',
read_image: 'read',
write: 'write',
edit: 'write',
grep: 'search',
glob: 'search',
}
/** The file purpose of a tool, or null for non-file tools (bash, web_search…). */
export function kindOfTool(tool: string | undefined): FileOpRecord['kind'] | null {
if (tool === undefined) return null
return KIND_BY_TOOL[tool] ?? null
}
/**
* The file purpose of one executed call. Like {@link kindOfTool} except for
* the one file tool whose purpose follows its arguments: `str_replace_editor`
* reads on `view` and writes on every other command (create / str_replace /
* insert — an unknown command writes too; the call failed and the row keeps
* its error flag).
*/
export function kindOfCall(tool: string, args: Record<string, unknown> | null): FileOpRecord['kind'] | null {
if (tool === 'str_replace_editor') return args !== null && args.command === 'view' ? 'read' : 'write'
return kindOfTool(tool)
}
/**
* The operation's target path: the path-ish argument of read/write tools;
* for searches the narrowing `path`, else the pattern itself (a pathless
* grep/glob's target IS the pattern — the workspace-wide search text).
*/
export function pathOfArgs(tool: string, args: Record<string, unknown> | null): string | null {
if (args === null) return null
if (tool === 'grep' || tool === 'glob') {
const p = args.path
if (typeof p === 'string' && p !== '') return p
const pattern = args.pattern
return typeof pattern === 'string' && pattern !== '' ? pattern : null
}
for (const k of ['file_path', 'filePath', 'path']) {
const v = args[k]
if (typeof v === 'string' && v !== '') return v
}
return null
}
/** Rendered line count: '' is 0, a trailing newline closes its own line. */
export function linesOf(s: string): number {
if (s === '') return 0
let n = 0
for (let i = 0; i < s.length; i++) if (s[i] === '\n') n++
return s.endsWith('\n') ? n : n + 1
}
/** The added/removed pair of one content-bearing argument set, or zeros. */
function pairOf(added: unknown, removed: unknown): { added: number; removed: number } {
return {
added: typeof added === 'string' ? linesOf(added) : 0,
removed: typeof removed === 'string' ? linesOf(removed) : 0,
}
}
/**
* The signed line footprint of one call: an edit removes its old string and
* adds its new one; a write adds its content (the pre-existing body, if any,
* is unknowable from the arguments — the estimate stays honest about that);
* `str_replace_editor` splits the same shapes across its commands. Callers
* reach here only with parsed args (a null parse yields no path).
*/
function deltaOf(tool: string, args: Record<string, unknown>): { added: number; removed: number } {
if (tool === 'edit') return pairOf(args.new_string, args.old_string)
if (tool === 'write') return pairOf(args.content, undefined)
if (tool === 'str_replace_editor') {
if (args.command === 'str_replace') return pairOf(args.new_str, args.old_str)
if (args.command === 'insert') return pairOf(args.new_str, undefined)
if (args.command === 'create') return pairOf(args.file_text, undefined)
}
return { added: 0, removed: 0 }
}
/** The exact window a read's result meta reports: `offset` plus the retained
* `lines` array (the same bounded payload the read card renders from). Null
* for a foreign or malformed meta. */
function readWindowOf(meta: unknown): { start: number; count: number } | null {
if (meta === null || typeof meta !== 'object') return null
const m = meta as { path?: unknown; offset?: unknown; lines?: unknown }
if (typeof m.path !== 'string' || m.path === '') return null
if (typeof m.offset !== 'number' || !Number.isFinite(m.offset) || m.offset < 1) return null
if (!Array.isArray(m.lines) || m.lines.length === 0) return null
return { start: m.offset, count: m.lines.length }
}
/** The limit estimate: the tool reads up to `limit` lines from `offset`;
* absent when the call reads unbounded. */
function readEstimateOf(args: Record<string, unknown>): { count: number; est: true } | undefined {
const limit = args.limit
return typeof limit === 'number' && Number.isFinite(limit) && limit > 0
? { count: Math.floor(limit), est: true }
: undefined
}
/** What a read op shows: the exact window off the result meta, else the
* limit estimate, else nothing (an unbounded read names no footprint). */
function readOf(meta: unknown, args: Record<string, unknown>): { start: number; count: number } | { count: number; est: true } | undefined {
const win = readWindowOf(meta)
if (win !== null) return { start: win.start, count: win.count }
return readEstimateOf(args)
}
/**
* A search op's detail: the pattern, with the include filter appended when
* one narrowed the call. A patternless (malformed) search has no detail.
*/
function searchDetailOf(args: Record<string, unknown> | null): string | undefined {
const pattern = args?.pattern
if (typeof pattern !== 'string' || pattern === '') return undefined
const include = args?.include
return typeof include === 'string' && include !== '' ? `${pattern} (${include})` : pattern
}
/**
* The files a search demonstrably reached, read off the result's bounded
* presentation meta (grep groups matched lines by file; glob lists paths).
* Only the COMPLETE list attributes: a capped search (`truncated`) names a
* partial file set, and a malformed meta names none — both fall back to the
* call's own target. Each entry carries the reported match count.
*/
function searchFilesOf(meta: unknown): { path: string; hits: number }[] | null {
if (meta === null || typeof meta !== 'object') return null
const m = meta as { shape?: unknown; truncated?: unknown; files?: unknown; paths?: unknown }
if (m.truncated !== false) return null
const files: { path: string; hits: number }[] = []
if (m.shape === 'matches' && Array.isArray(m.files)) {
for (const f of m.files) {
if (f === null || typeof f !== 'object') continue
const group = f as { path?: unknown; matches?: unknown }
if (typeof group.path === 'string' && group.path !== '' && Array.isArray(group.matches)) {
files.push({ path: group.path, hits: group.matches.length })
}
}
} else if (m.shape === 'paths' && Array.isArray(m.paths)) {
for (const p of m.paths) {
if (typeof p === 'string' && p !== '') files.push({ path: p, hits: 0 })
}
}
return files.length > 0 ? files : null
}
/**
* The one-shot per-call op assembly, uniform across every producer: the
* host's call/result pairing (args off the armed call, meta off the
* result), the nested Code-Mode settle (no meta exists on a dispatch — the
* read window and per-file search attribution degrade to the argument-only
* forms), and the client's inline-generation join fallback. Returns zero to
* N records: a search with the complete matched-file meta rows per file;
* any other file call rows once; a non-file tool (or a call whose arguments
* resolve no target) rows nothing.
*/
export function opsOfCall(input: {
seq: number
tool: string
argsRaw?: unknown
meta?: unknown
err?: boolean
time?: number
gone?: number
parent?: number
program?: string
}): FileOpRecord[] {
const args = parseCallArgs(input.argsRaw)
const kind = kindOfCall(input.tool, args)
if (kind === null) return []
const stamp: FileOpRecord = {
seq: input.seq,
kind,
tool: input.tool,
err: input.err === true,
added: 0,
removed: 0,
path: '',
...(input.time !== undefined ? { time: input.time } : {}),
...(input.gone !== undefined ? { gone: input.gone } : {}),
...(input.parent !== undefined ? { parent: input.parent } : {}),
...(input.program !== undefined ? { program: input.program } : {}),
}
// A search whose result meta carries the COMPLETE matched-file list rows
// its ops per real file — even when the call's own arguments failed to
// parse (the detail line just drops out). The call's own target (the
// searched path, or the pattern for a workspace-wide search) rows TOO —
// "what was searched" and "what got hit" both count; only the degenerate
// single-file search (the target IS the sole matched file) skips the
// duplicate target row.
if (kind === 'search') {
const files = searchFilesOf(input.meta)
if (files !== null) {
const detail = searchDetailOf(args)
const target = args !== null ? pathOfArgs(input.tool, args) : null
const narrowed = args !== null && typeof args.path === 'string' && args.path !== ''
const targetOps: FileOpRecord[] = target !== null && !files.some(f => f.path === target)
? [{
...stamp,
path: target,
...(narrowed && detail !== undefined ? { detail } : {}),
...(narrowed ? {} : { pattern: true as const }),
}]
: []
return [
...targetOps,
...files.map(f => ({
...stamp,
path: f.path,
...(detail !== undefined ? { detail } : {}),
...(f.hits > 0 ? { hits: f.hits } : {}),
})),
]
}
}
if (args === null) return []
const path = pathOfArgs(input.tool, args)
if (path === null) return []
const { added, removed } = deltaOf(input.tool, args)
const narrowed = typeof args.path === 'string' && args.path !== ''
const detail = kind === 'search' && narrowed ? searchDetailOf(args) : undefined
const read = kind === 'read' ? readOf(input.meta, args) : undefined
return [{
...stamp,
path,
added,
removed,
...(detail !== undefined ? { detail } : {}),
...(read !== undefined ? { read } : {}),
...(kind === 'search' && !narrowed ? { pattern: true as const } : {}),
}]
}