diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index da26e4ac8..5f6a21d82 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog ## [Unreleased] + ### Breaking Changes +- Required top-level `path` for `edit` tool `atom`, `hashline`, `patch`, and `replace` calls and removed per-entry `path` overrides, so edits to different files now require separate calls - Replaced the atom edit `sed` verb with `replace`, requiring `{ find, with, all? }` instead of `{ pat, rep, g? }` - Removed bracketed atom locators like `(anchor)` and `[anchor]`, so region block rewrites via `splice` are no longer supported and bare anchor `loc` values now target exactly one line - Renamed MCP tool identifiers from the `mcp__` format to `mcp___` so custom tool names, active tool lists, and persisted MCP selections must be updated to the new prefix @@ -15,6 +17,7 @@ ### Changed +- Updated `edit` streaming diff previews for `patch`, `replace`, and `hashline` to produce a single request-level preview for the new single-file `path` mode - Changed atom inline and file-wide replacements to perform literal substring substitution, with `all: true` replacing all matches on the line - Changed `loc` parsing so path-qualified atom edits correctly split `path:loc` when the locator suffix contains colons - Changed `replace` to reject multiline `find`/`with` values and require single-line operands diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts index 980845ca5..bdb3a3f98 100644 --- a/packages/coding-agent/src/edit/index.ts +++ b/packages/coding-agent/src/edit/index.ts @@ -19,13 +19,7 @@ import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit import type { VimToolDetails } from "../vim/types"; import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch"; import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" }; -import { - type AtomParams, - type AtomToolEdit, - atomEditParamsSchema, - executeAtomSingle, - resolveAtomEntryPaths, -} from "./modes/atom"; +import { type AtomParams, type AtomToolEdit, atomEditParamsSchema, executeAtomSingle } from "./modes/atom"; import { executeHashlineSingle, HashlineMismatchError, @@ -122,45 +116,11 @@ function createEditWritethrough(session: ToolSession): WritethroughCallback { return enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop; } -/** - * Resolve per-entry `path` against an optional top-level `path` default. - * If both are absent on an entry, throws a descriptive error. - */ -function resolveEntryPaths( - edits: readonly T[], - topLevelPath: string | undefined, -): (T & { path: string })[] { - return edits.map((edit, i) => { - const path = (edit && typeof edit.path === "string" && edit.path) || topLevelPath; - if (!path) { - throw new Error( - `Edit ${i}: missing \`path\`. Provide \`path\` on this edit or supply a top-level \`path\` for the request.`, - ); - } - return { ...edit, path }; - }); -} - -/** Group items by a key, preserving insertion order. */ -function groupBy(items: T[], key: (item: T) => K): Map { - const map = new Map(); - for (const item of items) { - const k = key(item); - let arr = map.get(k); - if (!arr) { - arr = []; - map.set(k, arr); - } - arr.push(item); - } - return map; -} - -/** Run single-file executors for each file group and aggregate results. */ -async function executePerFile( +/** Run apply_patch file operations and aggregate their multi-file result. */ +async function executeApplyPatchPerFile( fileEntries: { path: string; - run: (batchRequest: LspBatchRequest | undefined) => Promise>; + run: (batchRequest: LspBatchRequest | undefined) => Promise>; }[], outerBatchRequest: LspBatchRequest | undefined, onUpdate?: (partialResult: AgentToolResult) => void, @@ -230,6 +190,58 @@ async function executePerFile( }; } +async function executeSinglePathEntries( + path: string, + runs: ((batchRequest: LspBatchRequest | undefined) => Promise>)[], + outerBatchRequest: LspBatchRequest | undefined, + onUpdate?: (partialResult: AgentToolResult) => void, +): Promise> { + if (runs.length === 1) { + return runs[0](outerBatchRequest); + } + + const contentTexts: string[] = []; + const diffTexts: string[] = []; + let firstChangedLine: number | undefined; + + for (let i = 0; i < runs.length; i++) { + const isLast = i === runs.length - 1; + const batchRequest: LspBatchRequest | undefined = outerBatchRequest + ? { id: outerBatchRequest.id, flush: isLast && outerBatchRequest.flush } + : undefined; + + try { + const result = await runs[i](batchRequest); + const details = result.details; + if (details?.diff) diffTexts.push(details.diff); + firstChangedLine ??= details?.firstChangedLine; + const text = result.content?.find(c => c.type === "text")?.text ?? ""; + if (text) contentTexts.push(text); + } catch (err) { + const errorText = err instanceof Error ? err.message : String(err); + contentTexts.push(`Error editing ${path}: ${errorText}`); + } + + if (!isLast && onUpdate) { + onUpdate({ + content: [{ type: "text", text: contentTexts.join("\n") }], + details: { + diff: diffTexts.join("\n"), + firstChangedLine, + }, + }); + } + } + + return { + content: [{ type: "text", text: contentTexts.join("\n") }], + details: { + diff: diffTexts.join("\n"), + firstChangedLine, + }, + }; +} + export class EditTool implements AgentTool { readonly name = "edit"; readonly label = "Edit"; @@ -316,13 +328,12 @@ export class EditTool implements AgentTool { batchRequest: LspBatchRequest | undefined, onUpdate?: (partialResult: AgentToolResult) => void, ) => { - const { edits, path: topPath } = params as PatchParams & { path?: string }; - const resolved = resolveEntryPaths(edits as PatchEditEntry[], topPath); - const entries = resolved.map(entry => ({ - path: entry.path, - run: (br: LspBatchRequest | undefined) => + const { edits, path } = params as PatchParams; + const runs = (edits as PatchEditEntry[]).map( + entry => (br: LspBatchRequest | undefined) => executePatchSingle({ session: tool.session, + path, params: entry, signal, batchRequest: br, @@ -331,8 +342,8 @@ export class EditTool implements AgentTool { writethrough: tool.#writethrough, beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p), }), - })); - return executePerFile(entries, batchRequest, onUpdate); + ); + return executeSinglePathEntries(path, runs, batchRequest, onUpdate); }, }, apply_patch: { @@ -346,21 +357,25 @@ export class EditTool implements AgentTool { onUpdate?: (partialResult: AgentToolResult) => void, ) => { const entries = expandApplyPatchToEntries(params as ApplyPatchParams); - const perFile = entries.map(entry => ({ - path: entry.path!, - run: (br: LspBatchRequest | undefined) => - executePatchSingle({ - session: tool.session, - params: entry, - signal, - batchRequest: br, - allowFuzzy: tool.#allowFuzzy, - fuzzyThreshold: tool.#fuzzyThreshold, - writethrough: tool.#writethrough, - beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p), - }), - })); - return executePerFile(perFile, batchRequest, onUpdate); + const perFile = entries.map(entry => { + const { path, ...patchParams } = entry; + return { + path, + run: (br: LspBatchRequest | undefined) => + executePatchSingle({ + session: tool.session, + path, + params: patchParams, + signal, + batchRequest: br, + allowFuzzy: tool.#allowFuzzy, + fuzzyThreshold: tool.#fuzzyThreshold, + writethrough: tool.#writethrough, + beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p), + }), + }; + }); + return executeApplyPatchPerFile(perFile, batchRequest, onUpdate); }, }, hashline: { @@ -371,25 +386,18 @@ export class EditTool implements AgentTool { params: EditParams, signal: AbortSignal | undefined, batchRequest: LspBatchRequest | undefined, - onUpdate?: (partialResult: AgentToolResult) => void, + _onUpdate?: (partialResult: AgentToolResult) => void, ) => { - const { edits, path: topPath } = params as HashlineParams & { path?: string }; - const resolved = resolveEntryPaths(edits as HashlineToolEdit[], topPath); - const byFile = groupBy(resolved, e => e.path); - const entries = [...byFile.entries()].map(([path, fileEdits]) => ({ + const { edits, path } = params as HashlineParams; + return executeHashlineSingle({ + session: tool.session, path, - run: (br: LspBatchRequest | undefined) => - executeHashlineSingle({ - session: tool.session, - path, - edits: fileEdits, - signal, - batchRequest: br, - writethrough: tool.#writethrough, - beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p), - }), - })); - return executePerFile(entries, batchRequest, onUpdate); + edits: edits as HashlineToolEdit[], + signal, + batchRequest, + writethrough: tool.#writethrough, + beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p), + }); }, }, atom: { @@ -400,25 +408,18 @@ export class EditTool implements AgentTool { params: EditParams, signal: AbortSignal | undefined, batchRequest: LspBatchRequest | undefined, - onUpdate?: (partialResult: AgentToolResult) => void, + _onUpdate?: (partialResult: AgentToolResult) => void, ) => { - const { edits, path: topPath } = params as AtomParams & { path?: string }; - const resolved = resolveAtomEntryPaths(edits as AtomToolEdit[], topPath); - const byFile = groupBy(resolved, e => e.path); - const entries = [...byFile.entries()].map(([path, fileEdits]) => ({ + const { edits, path } = params as AtomParams; + return executeAtomSingle({ + session: tool.session, path, - run: (br: LspBatchRequest | undefined) => - executeAtomSingle({ - session: tool.session, - path, - edits: fileEdits, - signal, - batchRequest: br, - writethrough: tool.#writethrough, - beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p), - }), - })); - return executePerFile(entries, batchRequest, onUpdate); + edits: edits as AtomToolEdit[], + signal, + batchRequest, + writethrough: tool.#writethrough, + beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p), + }); }, }, replace: { @@ -431,13 +432,12 @@ export class EditTool implements AgentTool { batchRequest: LspBatchRequest | undefined, onUpdate?: (partialResult: AgentToolResult) => void, ) => { - const { edits, path: topPath } = params as ReplaceParams & { path?: string }; - const resolved = resolveEntryPaths(edits as ReplaceEditEntry[], topPath); - const entries = resolved.map(entry => ({ - path: entry.path, - run: (br: LspBatchRequest | undefined) => + const { edits, path } = params as ReplaceParams; + const runs = (edits as ReplaceEditEntry[]).map( + entry => (br: LspBatchRequest | undefined) => executeReplaceSingle({ session: tool.session, + path, params: entry, signal, batchRequest: br, @@ -446,8 +446,8 @@ export class EditTool implements AgentTool { writethrough: tool.#writethrough, beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p), }), - })); - return executePerFile(entries, batchRequest, onUpdate); + ); + return executeSinglePathEntries(path, runs, batchRequest, onUpdate); }, }, vim: { diff --git a/packages/coding-agent/src/edit/line-hash.ts b/packages/coding-agent/src/edit/line-hash.ts index 102e7b909..1ddc1a2d5 100644 --- a/packages/coding-agent/src/edit/line-hash.ts +++ b/packages/coding-agent/src/edit/line-hash.ts @@ -690,10 +690,14 @@ function structuralBigram(line: number): string { const mod100 = line % 100; if (mod100 >= 11 && mod100 <= 13) return "th"; switch (line % 10) { - case 1: return "st"; - case 2: return "nd"; - case 3: return "rd"; - default: return "th"; + case 1: + return "st"; + case 2: + return "nd"; + case 3: + return "rd"; + default: + return "th"; } } diff --git a/packages/coding-agent/src/edit/modes/apply-patch.ts b/packages/coding-agent/src/edit/modes/apply-patch.ts index 95b1149b9..6aaad0b18 100644 --- a/packages/coding-agent/src/edit/modes/apply-patch.ts +++ b/packages/coding-agent/src/edit/modes/apply-patch.ts @@ -22,17 +22,19 @@ export const applyPatchSchema = Type.Object({ export type ApplyPatchParams = Static; +export type ApplyPatchEntry = PatchEditEntry & { path: string }; + /** * Parse the envelope and lower each hunk to a `PatchEditEntry` so it can * be routed through `executePatchSingle`. */ -export function expandApplyPatchToEntries(params: ApplyPatchParams): PatchEditEntry[] { +export function expandApplyPatchToEntries(params: ApplyPatchParams): ApplyPatchEntry[] { const hunks = parseApplyPatch(params.input); if (hunks.length === 0) { throw new ApplyPatchError("No files were modified."); } return hunks.map( - (h): PatchEditEntry => ({ + (h): ApplyPatchEntry => ({ path: h.path, op: h.op, rename: h.rename, @@ -41,10 +43,10 @@ export function expandApplyPatchToEntries(params: ApplyPatchParams): PatchEditEn ); } -export function expandApplyPatchToPreviewEntries(params: ApplyPatchParams): PatchEditEntry[] { +export function expandApplyPatchToPreviewEntries(params: ApplyPatchParams): ApplyPatchEntry[] { const hunks = parseApplyPatchStreaming(params.input); return hunks.map( - (h): PatchEditEntry => ({ + (h): ApplyPatchEntry => ({ path: h.path, op: h.op, rename: h.rename, diff --git a/packages/coding-agent/src/edit/modes/atom.ts b/packages/coding-agent/src/edit/modes/atom.ts index 81b4b1e71..891180191 100644 --- a/packages/coding-agent/src/edit/modes/atom.ts +++ b/packages/coding-agent/src/edit/modes/atom.ts @@ -55,7 +55,7 @@ export const atomEditSchema = Type.Object( { loc: Type.String({ description: "edit location", - examples: ["1ab", "$", "src/foo.ts:1ab"], + examples: ["1ab", "$"], }), splice: Type.Optional(textSchema), pre: Type.Optional(textSchema), @@ -65,7 +65,9 @@ export const atomEditSchema = Type.Object( { find: Type.String({ description: "literal substring to find" }), with: Type.String({ description: "literal substring to substitute" }), - all: Type.Optional(Type.Boolean({ description: "replace every occurrence (default: first only)", default: false })), + all: Type.Optional( + Type.Boolean({ description: "replace every occurrence (default: first only)", default: false }), + ), }, { additionalProperties: false, @@ -78,7 +80,7 @@ export const atomEditSchema = Type.Object( export const atomEditParamsSchema = Type.Object( { - path: Type.Optional(Type.String({ description: "default file path for edits" })), + path: Type.String({ description: "file path for edits" }), edits: Type.Array(atomEditSchema, { description: "edit ops" }), }, { additionalProperties: false }, @@ -120,15 +122,6 @@ const ATOM_OPTIONAL_KEYS = ["loc", ...ATOM_VERB_KEYS] as const satisfies readonl // captured as a content hint for anchor disambiguation. const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`); -// Splits `path:loc` references where the right side starts with a valid anchor -// (single `\d+`, optionally followed by a content suffix using `|` or -// `:`). The non-greedy `(.+?)` picks the leftmost colon whose RHS is a real -// anchor, so colons inside the loc's content suffix (TS type annotations, etc.) -// don't break the split. Drive-letter prefixes like `C:\path\a.ts:160sr` still -// resolve correctly because the first colon's RHS fails the anchor pattern. -const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`; -const PATH_LOC_SPLIT_RE = new RegExp(`^(.+?):(${ANCHOR_TAG_RE_SRC}(?:[|:].*)?)$`); - function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit { let next: Record | undefined; const fields = edit as Record; @@ -179,37 +172,6 @@ function parseAnchor(raw: string, opName: string): Anchor { } } -function resolveAtomEntryPath( - edit: AtomToolEdit, - topLevelPath: string | undefined, - editIndex: number, -): AtomToolEdit & { path: string } { - const entry = stripNullAtomFields(edit); - let loc = entry.loc; - let pathOverride: string | undefined; - if (typeof loc === "string") { - const split = loc.match(PATH_LOC_SPLIT_RE); - if (split) { - pathOverride = split[1]; - loc = split[2]!; - } - } - const path = pathOverride || topLevelPath; - if (!path) { - throw new Error( - `Edit ${editIndex}: missing path. Provide a top-level path or prefix loc with a file path (for example "a.ts:160sr").`, - ); - } - return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) }; -} - -export function resolveAtomEntryPaths( - edits: readonly AtomToolEdit[], - topLevelPath: string | undefined, -): (AtomToolEdit & { path: string })[] { - return edits.map((edit, i) => resolveAtomEntryPath(edit, topLevelPath, i)); -} - function parseLoc(raw: string, editIndex: number): ParsedAtomLoc { const trimmed = raw.trim(); if (trimmed === "$") return { kind: "file" }; @@ -681,7 +643,9 @@ export function applyAtomEdits( } } if (!anyMatched) { - throw new Error(`Edit replace expression ${JSON.stringify(edit.expression)} did not match any line in the file.`); + throw new Error( + `Edit replace expression ${JSON.stringify(edit.expression)} did not match any line in the file.`, + ); } } diff --git a/packages/coding-agent/src/edit/modes/hashline.ts b/packages/coding-agent/src/edit/modes/hashline.ts index 748948a37..43f54c461 100644 --- a/packages/coding-agent/src/edit/modes/hashline.ts +++ b/packages/coding-agent/src/edit/modes/hashline.ts @@ -156,7 +156,6 @@ const locSchema = Type.Union( export const hashlineEditSchema = Type.Object( { - path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })), loc: Type.Optional(locSchema), content: Type.Optional(linesSchema), }, @@ -165,7 +164,7 @@ export const hashlineEditSchema = Type.Object( export const hashlineEditParamsSchema = Type.Object( { - path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })), + path: Type.String({ description: "file path for edits" }), edits: Type.Array(hashlineEditSchema, { description: "edits" }), }, { additionalProperties: false }, diff --git a/packages/coding-agent/src/edit/modes/patch.ts b/packages/coding-agent/src/edit/modes/patch.ts index d8f0c6fa9..ce8782766 100644 --- a/packages/coding-agent/src/edit/modes/patch.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -1576,27 +1576,33 @@ export async function computePatchDiff( } } -export const patchEditEntrySchema = Type.Object({ - path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })), - op: Type.Optional( - StringEnum(["create", "delete", "update"], { - description: "Operation (default: update)", - }), - ), - rename: Type.Optional(Type.String({ description: "New path for move" })), - diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })), -}); +export const patchEditEntrySchema = Type.Object( + { + op: Type.Optional( + StringEnum(["create", "delete", "update"], { + description: "Operation (default: update)", + }), + ), + rename: Type.Optional(Type.String({ description: "New path for move" })), + diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })), + }, + { additionalProperties: false }, +); -export const patchEditSchema = Type.Object({ - path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })), - edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }), -}); +export const patchEditSchema = Type.Object( + { + path: Type.String({ description: "file path for edits" }), + edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }), + }, + { additionalProperties: false }, +); export type PatchEditEntry = Static; export type PatchParams = Static; export interface ExecutePatchSingleOptions { session: ToolSession; + path: string; params: PatchEditEntry; signal?: AbortSignal; batchRequest?: LspBatchRequest; @@ -1694,6 +1700,7 @@ export async function executePatchSingle( ): Promise> { const { session, + path, params, signal, batchRequest, @@ -1702,10 +1709,7 @@ export async function executePatchSingle( writethrough, beginDeferredDiagnosticsForPath, } = options; - const { path, op: rawOp, rename, diff } = params; - if (typeof path !== "string" || path.length === 0) { - throw new Error("patch edit: missing `path`. Provide `path` on the edit or supply a top-level `path`."); - } + const { op: rawOp, rename, diff } = params; const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update"; diff --git a/packages/coding-agent/src/edit/modes/replace.ts b/packages/coding-agent/src/edit/modes/replace.ts index f30c6f43a..0922e41e5 100644 --- a/packages/coding-agent/src/edit/modes/replace.ts +++ b/packages/coding-agent/src/edit/modes/replace.ts @@ -976,23 +976,29 @@ export function findContextLine( return { index: undefined, confidence: bestScore }; } -export const replaceEditEntrySchema = Type.Object({ - path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })), - old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }), - new_text: Type.String({ description: "Replacement text" }), - all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })), -}); +export const replaceEditEntrySchema = Type.Object( + { + old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }), + new_text: Type.String({ description: "Replacement text" }), + all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })), + }, + { additionalProperties: false }, +); -export const replaceEditSchema = Type.Object({ - path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })), - edits: Type.Array(replaceEditEntrySchema, { description: "Replacements", minItems: 1 }), -}); +export const replaceEditSchema = Type.Object( + { + path: Type.String({ description: "file path for edits" }), + edits: Type.Array(replaceEditEntrySchema, { description: "Replacements", minItems: 1 }), + }, + { additionalProperties: false }, +); export type ReplaceEditEntry = Static; export type ReplaceParams = Static; export interface ExecuteReplaceSingleOptions { session: ToolSession; + path: string; params: ReplaceEditEntry; signal?: AbortSignal; batchRequest?: LspBatchRequest; @@ -1007,6 +1013,7 @@ export async function executeReplaceSingle( ): Promise> { const { session, + path, params, signal, batchRequest, @@ -1015,10 +1022,7 @@ export async function executeReplaceSingle( writethrough, beginDeferredDiagnosticsForPath, } = options; - const { path, old_text, new_text, all } = params; - if (typeof path !== "string" || path.length === 0) { - throw new Error("replace edit: missing `path`. Provide `path` on the edit or supply a top-level `path`."); - } + const { old_text, new_text, all } = params; enforcePlanModeWrite(session, path); diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 88767855b..696c87c2a 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -28,8 +28,8 @@ import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../ import type { EditMode } from "../utils/edit-mode"; import type { VimToolDetails } from "../vim/types"; import type { DiffError, DiffResult } from "./diff"; -import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch"; -import type { Operation, PatchEditEntry } from "./modes/patch"; +import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch"; +import type { Operation } from "./modes/patch"; import type { PerFileDiffPreview } from "./streaming"; // ═══════════════════════════════════════════════════════════════════════════ @@ -107,7 +107,7 @@ type EditRenderEntry = { }; interface ApplyPatchRenderSummary { - entries: PatchEditEntry[]; + entries: ApplyPatchEntry[]; error?: string; } diff --git a/packages/coding-agent/src/edit/streaming.ts b/packages/coding-agent/src/edit/streaming.ts index 2a541a211..9faf6456f 100644 --- a/packages/coding-agent/src/edit/streaming.ts +++ b/packages/coding-agent/src/edit/streaming.ts @@ -15,7 +15,7 @@ import type { Theme } from "../modes/theme/theme"; import { type EditMode, resolveEditMode } from "../utils/edit-mode"; import { computeEditDiff, type DiffError, type DiffResult } from "./diff"; -import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch"; +import { type ApplyPatchEntry, expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch"; import { computeHashlineDiff, type HashlineToolEdit } from "./modes/hashline"; import { computePatchDiff, type PatchEditEntry } from "./modes/patch"; import type { ReplaceEditEntry } from "./modes/replace"; @@ -126,33 +126,19 @@ export function dropIncompleteLastEdit(edits: readonly T[], partialJson: stri } // ----------------------------------------------------------------------------- -// Multi-file grouping +// Apply_patch remains multi-file because the Codex envelope carries paths per hunk. // ----------------------------------------------------------------------------- -/** Cap on how many distinct files a streaming preview will render diffs for. */ -const MAX_PREVIEW_FILES = 5; +function groupApplyPatchEntriesByPath(entries: readonly ApplyPatchEntry[]): Map { + const groups = new Map(); -/** - * Group a list of edits by their effective `path` (per-edit `path` falls back - * to the top-level `args.path`). Insertion order is preserved and the number of - * distinct buckets is capped at {@link MAX_PREVIEW_FILES}. - */ -function groupEditsByPath( - edits: readonly T[], - fallbackPath: string | undefined, -): Map> { - const groups = new Map>(); - for (const edit of edits) { - if (!edit) continue; - const editPath = edit.path ?? fallbackPath; - if (!editPath) continue; - let bucket = groups.get(editPath); + for (const entry of entries) { + let bucket = groups.get(entry.path); if (!bucket) { - if (groups.size >= MAX_PREVIEW_FILES) continue; bucket = []; - groups.set(editPath, bucket); + groups.set(entry.path, bucket); } - bucket.push({ ...edit, path: editPath }); + bucket.push(entry); } return groups; } @@ -173,26 +159,21 @@ const replaceStrategy: EditStreamingStrategy = { return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") }; }, async computeDiffPreview(args, ctx) { - const groups = groupEditsByPath(args.edits ?? [], args.path); - if (groups.size === 0) return null; - const previews: PerFileDiffPreview[] = []; - for (const [path, fileEdits] of groups) { - const first = fileEdits[0]; - if (!first || first.old_text === undefined || first.new_text === undefined) continue; - ctx.signal.throwIfAborted(); - const result = await computeEditDiff( - path, - first.old_text, - first.new_text, - ctx.cwd, - ctx.allowFuzzy ?? true, - first.all, - ctx.fuzzyThreshold, - ); - ctx.signal.throwIfAborted(); - previews.push(toPerFilePreview(path, result)); - } - return previews.length > 0 ? previews : null; + if (!args.path) return null; + const first = args.edits?.[0]; + if (!first || first.old_text === undefined || first.new_text === undefined) return null; + ctx.signal.throwIfAborted(); + const result = await computeEditDiff( + args.path, + first.old_text, + first.new_text, + ctx.cwd, + ctx.allowFuzzy ?? true, + first.all, + ctx.fuzzyThreshold, + ); + ctx.signal.throwIfAborted(); + return [toPerFilePreview(args.path, result)]; }, renderStreamingFallback() { return ""; @@ -211,22 +192,17 @@ const patchStrategy: EditStreamingStrategy = { return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") }; }, async computeDiffPreview(args, ctx) { - const groups = groupEditsByPath(args.edits ?? [], args.path); - if (groups.size === 0) return null; - const previews: PerFileDiffPreview[] = []; - for (const [path, fileEdits] of groups) { - const first = fileEdits[0]; - if (!first) continue; - ctx.signal.throwIfAborted(); - const result = await computePatchDiff( - { path, op: first.op ?? "update", rename: first.rename, diff: first.diff }, - ctx.cwd, - { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy }, - ); - ctx.signal.throwIfAborted(); - previews.push(toPerFilePreview(path, result)); - } - return previews.length > 0 ? previews : null; + if (!args.path) return null; + const first = args.edits?.[0]; + if (!first) return null; + ctx.signal.throwIfAborted(); + const result = await computePatchDiff( + { path: args.path, op: first.op ?? "update", rename: first.rename, diff: first.diff }, + ctx.cwd, + { fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy }, + ); + ctx.signal.throwIfAborted(); + return [toPerFilePreview(args.path, result)]; }, renderStreamingFallback() { return ""; @@ -245,16 +221,11 @@ const hashlineStrategy: EditStreamingStrategy = { return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") }; }, async computeDiffPreview(args, ctx) { - const groups = groupEditsByPath(args.edits ?? [], args.path); - if (groups.size === 0) return null; - const previews: PerFileDiffPreview[] = []; - for (const [path, fileEdits] of groups) { - ctx.signal.throwIfAborted(); - const result = await computeHashlineDiff({ path, edits: fileEdits }, ctx.cwd); - ctx.signal.throwIfAborted(); - previews.push(toPerFilePreview(path, result)); - } - return previews; + if (!args.path || !args.edits?.length) return null; + ctx.signal.throwIfAborted(); + const result = await computeHashlineDiff({ path: args.path, edits: args.edits }, ctx.cwd); + ctx.signal.throwIfAborted(); + return [toPerFilePreview(args.path, result)]; }, renderStreamingFallback() { return ""; @@ -272,7 +243,7 @@ const applyPatchStrategy: EditStreamingStrategy = { }, async computeDiffPreview(args, ctx) { if (typeof args.input !== "string" || args.input.length === 0) return null; - let entries: PatchEditEntry[]; + let entries: ApplyPatchEntry[]; try { entries = expandApplyPatchToEntries({ input: args.input }); } catch { @@ -282,7 +253,7 @@ const applyPatchStrategy: EditStreamingStrategy = { return [{ path: "", error: err instanceof Error ? err.message : String(err) }]; } } - const groups = groupEditsByPath(entries, undefined); + const groups = groupApplyPatchEntriesByPath(entries); if (groups.size === 0) return null; const previews: PerFileDiffPreview[] = []; for (const [path, fileEntries] of groups) { diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index baae6d7d6..9df4d7149 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -46,10 +46,10 @@ import type { BashToolInput, FindToolDetails, FindToolInput, - SearchToolDetails, - SearchToolInput, ReadToolDetails, ReadToolInput, + SearchToolDetails, + SearchToolInput, WriteToolInput, } from "../../tools"; import type { TodoItem } from "../../tools/todo-write"; diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index 215c24f35..bb75b74a4 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -301,7 +301,6 @@ These are inviolable. {{#has tools "task"}}- Use tasks or subagents when independent investigations or edits can be split safely.{{/has}} - If you cannot explain why one piece depends on another, they are probably independent. {{#has tools "task"}}- When a plan feels too large for a single turn, parallelize aggressively — do **NOT** abandon phases, silently drop them, or narrate scope cuts. Scope pressure is a signal to delegate, not to shrink the work.{{/has}} - ## 4. Task tracking - Update todos as you progress. - Skip task tracking only for trivial requests. diff --git a/packages/coding-agent/src/prompts/tools/atom.md b/packages/coding-agent/src/prompts/tools/atom.md index 0d8ed802e..15188f4e9 100644 --- a/packages/coding-agent/src/prompts/tools/atom.md +++ b/packages/coding-agent/src/prompts/tools/atom.md @@ -1,13 +1,12 @@ Applies precise file edits using anchors (line+hash). -Each call **MUST** have shape `{path:"a.ts",edits:[…]}`. `path` is the default file; you **MAY** override it per edit with `loc:"b.ts:160sr"`. +Each call **MUST** have shape `{path:"a.ts",edits:[…]}`. `path` is required and applies to every edit in the call; `loc` is anchor-only and **MUST NOT** include a file prefix. Each edit **MUST** have exactly one `loc` and **MUST** include one or more verbs. # Locators - `"A"` targets one anchored line (line number + 2-letter suffix, e.g. `160sr`). - `"$"` targets the whole file: `pre` = BOF, `post` = EOF, `replace` = every line. -- # Verbs - `splice:[…]` replaces the anchored line. `[]` deletes; `[""]` makes a blank line. To replace N lines, anchor the first line and list all replacement lines. - `pre:[…]` inserts before the anchor, or BOF with `loc:"$"`. @@ -44,8 +43,6 @@ Use for tiny inline edits: names, operators, literals. # File edges: `{path:"a.ts",edits:[{loc:"$",pre:["// Copyright (c) 2026",""]}]}` `{path:"a.ts",edits:[{loc:"$",post:["","export { FALLBACK };"]}]}` -# Cross-file override: -`{path:"a.ts",edits:[{loc:{{href 1 "const FALLBACK = \"guest\";" "config.ts:" ""}},splice:["const FALLBACK = \"anonymous\";"]}]}` # Replace several consecutive lines: anchor the first line and list all replacement lines in `splice`. `{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;"}},splice:["\tconst clean = String(name ?? FALLBACK).trim();","\treturn clean.toLowerCase();","}"]}]}` This anchors line 4 and replaces lines 4-6 of the original function body in one splice. The anchor's hash protects against the file having shifted under you. diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index e783ca50a..09717c018 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -43,9 +43,8 @@ You **MUST** use specialized tools instead of bash for any file, directory, or t |`cat <<'EOF' > file`|`write(path="file", content="…")`| |`sed -i 's/old/new/' file`|`edit(path="file", edits=[…])`| {{#if hasAstEdit}}|`sed -i 's/oldFn(/newFn(/' src/*.ts`|`ast_edit({ops:[{pat:"oldFn($$$A)", out:"newFn($$$A)"}], path:"src/"})`|{{/if}} - - You **MUST NOT** create files with `cat < file`, or `printf > file`. Use `write` — heredoc content cannot be cached for permission reuse, every revision triggers a fresh review, and there is no diff. This is the most-violated rule. -- You **MUST NOT** read line ranges with `sed -n 'A,Bp'`, `awk 'NR>=A && NR<=B'`, or `head | tail` pipelines. Use `read` with `offset`/`limit` (or `sel` if available). +- You **MUST NOT** read line ranges with `sed -n 'A,Bp'`, `awk 'NR≥A && NR≤B'`, or `head | tail` pipelines. Use `read` with `offset`/`limit` (or `sel` if available). {{#if hasAstGrep}}- You **MUST** use `ast_grep` for structural code search instead of bash `grep`/`awk`/`perl` pipelines{{/if}} {{#if hasAstEdit}}- You **MUST** use `ast_edit` for structural rewrites instead of bash `sed`/`awk`/`perl` pipelines{{/if}} - You **MUST NOT** use `2>&1` or `2>/dev/null` — stdout and stderr are already merged diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index 590f1af03..7f220c5d5 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -5,10 +5,9 @@ Read the file first. Copy the full anchors exactly as shown by `read`. **Top level** - `edits` — array of edit entries -- `path` (optional) — default file path used when an entry omits its own `path`. Lets you share the path across many edits in one request. +- `path` (required) — file path for all edits in this request -**Edit entry**: `{ path?, loc, content }` -- `path` — file path (omit to fall back to the request-level `path`) +**Edit entry**: `{ loc, content }` - `loc` — where to apply the edit (see below) - `content` — replacement/inserted lines (`string[]`, one element per line; `null` to delete) @@ -44,19 +43,19 @@ All examples below reference the same file: # Replace a block body Replace only the catch body. Do not target the shared boundary line `} catch (err) {`. -`{edits:[{path:"a.ts",loc:{range:{pos:{{href 15 "\t\tconsole.error(err);"}},end:{{href 16 "\t\treturn null;"}}}},content:["\t\tif (isEnoent(err)) return null;","\t\tthrow err;"]}]}` +`{path:"a.ts",edits:[{loc:{range:{pos:{{href 15 "\t\tconsole.error(err);"}},end:{{href 16 "\t\treturn null;"}}}},content:["\t\tif (isEnoent(err)) return null;","\t\tthrow err;"]}]}` # Replace whole block including closing brace Replace `alpha`'s entire body including the closing `}`. `end` **MUST** be {{href 7 "}"}} because `content` includes `}`. -`{edits:[{path:"a.ts",loc:{range:{pos:{{href 6 "\tlog();"}},end:{{href 7 "}"}}}},content:["\tvalidate();","\tlog();","}"]}]}` +`{path:"a.ts",edits:[{loc:{range:{pos:{{href 6 "\tlog();"}},end:{{href 7 "}"}}}},content:["\tvalidate();","\tlog();","}"]}]}` **Wrong**: `end: {{href 6 "\tlog();"}}` — line 7 (`}`) survives AND content emits `}`, producing two closing braces. # Replace one line Single-line replace uses `pos == end`. -`{edits:[{path:"a.ts",loc:{range:{pos:{{href 2 "const timeout = 5000;"}},end:{{href 2 "const timeout = 5000;"}}}},content:["const timeout = 30_000;"]}]}` +`{path:"a.ts",edits:[{loc:{range:{pos:{{href 2 "const timeout = 5000;"}},end:{{href 2 "const timeout = 5000;"}}}},content:["const timeout = 30_000;"]}]}` # Delete a range -`{edits:[{path:"a.ts",loc:{range:{pos:{{href 10 "\t// TODO: remove after migration"}},end:{{href 11 "\tlegacy();"}}}},content:null}]}` +`{path:"a.ts",edits:[{loc:{range:{pos:{{href 10 "\t// TODO: remove after migration"}},end:{{href 11 "\tlegacy();"}}}},content:null}]}` # Insert before a sibling When adding a sibling declaration, prefer `prepend` on the next declaration. -`{edits:[{path:"a.ts",loc:{prepend:{{href 9 "function beta() {"}}},content:["function gamma() {","\tvalidate();","}",""]}]}` +`{path:"a.ts",edits:[{loc:{prepend:{{href 9 "function beta() {"}}},content:["function gamma() {","\tvalidate();","}",""]}]}` diff --git a/packages/coding-agent/src/prompts/tools/patch.md b/packages/coding-agent/src/prompts/tools/patch.md index 821e54768..ec847d442 100644 --- a/packages/coding-agent/src/prompts/tools/patch.md +++ b/packages/coding-agent/src/prompts/tools/patch.md @@ -18,19 +18,19 @@ When editing structured blocks (nested braces, tags, indented regions), include ```ts -// Input is { edits: Entry[] } where Entry is one of: +// Input is { path: string, edits: Entry[] }. `path` is required and applies to every entry. type Entry = - // Diff is one or more hunks in the same file. + // Diff is one or more hunks for the top-level path. // - Each hunk begins with "@@" (anchor optional). // - Each hunk body only has lines starting with ' ' | '+' | '-'. // - Each hunk includes at least one change (+ or -). - | { path: string, op: "update", diff: string } + | { op: "update", diff: string } // Diff is full file content, no prefixes. - | { path: string, op: "create", diff: string } + | { op: "create", diff: string } // No diff for delete. - | { path: string, op: "delete" } - // New path for update+move. - | { path: string, op: "update", rename: string, diff: string } + | { op: "delete" } + // New path for update+move from the top-level path. + | { op: "update", rename: string, diff: string } ``` @@ -52,15 +52,15 @@ Returns success/failure; on failure, error message indicates: # Create -`edit {"edits":[{"path":"hello.txt","op":"create","diff":"Hello\n"}]}` +`edit {"path":"hello.txt","edits":[{"op":"create","diff":"Hello\n"}]}` # Update -`edit {"edits":[{"path":"src/app.py","op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"}]}` +`edit {"path":"src/app.py","edits":[{"op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"}]}` # Rename -`edit {"edits":[{"path":"src/app.py","op":"update","rename":"src/main.py","diff":"@@\n …\n"}]}` +`edit {"path":"src/app.py","edits":[{"op":"update","rename":"src/main.py","diff":"@@\n …\n"}]}` # Delete -`edit {"edits":[{"path":"obsolete.txt","op":"delete"}]}` -# Multi-file -`edit {"edits":[{"path":"src/types.ts","op":"update","diff":"@@\n-old\n+new\n"},{"path":"src/index.ts","op":"update","diff":"@@\n-old\n+new\n"}]}` +`edit {"path":"obsolete.txt","edits":[{"op":"delete"}]}` +# Multiple entries +All entries in one call apply to the top-level `path`; use separate calls for different files. diff --git a/packages/coding-agent/src/prompts/tools/replace.md b/packages/coding-agent/src/prompts/tools/replace.md index eb6b13879..b9882adbe 100644 --- a/packages/coding-agent/src/prompts/tools/replace.md +++ b/packages/coding-agent/src/prompts/tools/replace.md @@ -1,9 +1,9 @@ Performs string replacements in files with fuzzy whitespace matching. -- You **MUST** use the smallest edit that uniquely identifies the change -- If `old_text` not unique, you **MUST** expand to include more context or use `all: true` to replace all occurrences -- Fuzzy matching handles minor whitespace/indentation differences automatically +- Params **MUST** be `{ path, edits }`; `path` is required at the top level and applies to every replacement +- You **MUST** use the smallest `old_text` that uniquely identifies the change +- If `old_text` is not unique, you **MUST** expand it with more context or use `all: true` to replace all occurrences - You **SHOULD** prefer editing existing files over creating new ones diff --git a/packages/coding-agent/src/prompts/tools/search.md b/packages/coding-agent/src/prompts/tools/search.md index 85896bccb..ac6eeb0e4 100644 --- a/packages/coding-agent/src/prompts/tools/search.md +++ b/packages/coding-agent/src/prompts/tools/search.md @@ -21,4 +21,4 @@ Searches files using powerful regex matching. - Bash `grep`/`rg` loses `.gitignore` semantics, bypasses result limits, and wastes tokens. The `search` tool is faster, structured, and already wired into the workspace — there is no scenario where Bash search is preferable. - If you catch yourself typing `grep`, `rg`, or `| grep` in a Bash command, stop and re-issue the lookup through the `search` tool instead. - If the search is open-ended, requiring multiple rounds, you **MUST** use the Task tool with the explore subagent instead of chaining `search` calls yourself. - \ No newline at end of file + diff --git a/packages/coding-agent/src/tools/renderers.ts b/packages/coding-agent/src/tools/renderers.ts index 8647ba7d1..6a4590790 100644 --- a/packages/coding-agent/src/tools/renderers.ts +++ b/packages/coding-agent/src/tools/renderers.ts @@ -18,13 +18,13 @@ import { calculatorToolRenderer } from "./calculator"; import { debugToolRenderer } from "./debug"; import { findToolRenderer } from "./find"; import { githubToolRenderer } from "./gh-renderer"; -import { searchToolRenderer } from "./search"; import { inspectImageToolRenderer } from "./inspect-image-renderer"; import { jobToolRenderer } from "./job"; import { notebookToolRenderer } from "./notebook"; import { pythonToolRenderer } from "./python"; import { readToolRenderer } from "./read"; import { resolveToolRenderer } from "./resolve"; +import { searchToolRenderer } from "./search"; import { searchToolBm25Renderer } from "./search-tool-bm25"; import { sshToolRenderer } from "./ssh"; import { todoWriteToolRenderer } from "./todo-write"; diff --git a/packages/coding-agent/test/edit-diff.test.ts b/packages/coding-agent/test/edit-diff.test.ts index 48d532633..3adc86827 100644 --- a/packages/coding-agent/test/edit-diff.test.ts +++ b/packages/coding-agent/test/edit-diff.test.ts @@ -246,7 +246,7 @@ describe("computeHashlineDiff", () => { await Bun.write(sourcePath, "first\n"); const result = await computeHashlineDiff( - { path: sourcePath, edits: [{ path: sourcePath, loc: "append", content: ["second"] }] }, + { path: sourcePath, edits: [{ loc: "append", content: ["second"] }] }, tempDir, ); expect("diff" in result).toBe(true); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 9da466de5..e5d6af48f 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -12,10 +12,10 @@ import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manage import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash"; import { FindTool } from "@oh-my-pi/pi-coding-agent/tools/find"; -import { SearchTool } from "@oh-my-pi/pi-coding-agent/tools/search"; import { JobTool } from "@oh-my-pi/pi-coding-agent/tools/job"; import { wrapToolWithMetaNotice } from "@oh-my-pi/pi-coding-agent/tools/output-meta"; import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read"; +import { SearchTool } from "@oh-my-pi/pi-coding-agent/tools/search"; import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write"; import * as markitUtils from "@oh-my-pi/pi-coding-agent/utils/markit"; import { $which, Snowflake } from "@oh-my-pi/pi-utils"; @@ -662,7 +662,8 @@ describe("Coding Agent Tools", () => { fs.writeFileSync(testFile, originalContent); const result = await editTool.execute("test-call-5", { - edits: [{ path: testFile, old_text: "world", new_text: "testing" }], + path: testFile, + edits: [{ old_text: "world", new_text: "testing" }], }); const details = result.details as { diff?: string } | undefined; @@ -680,7 +681,8 @@ describe("Coding Agent Tools", () => { await expect( editTool.execute("test-call-6", { - edits: [{ path: testFile, old_text: "nonexistent", new_text: "testing" }], + path: testFile, + edits: [{ old_text: "nonexistent", new_text: "testing" }], }), ).rejects.toThrow(/Could not find/); }); @@ -692,7 +694,8 @@ describe("Coding Agent Tools", () => { await expect( editTool.execute("test-call-7", { - edits: [{ path: testFile, old_text: "foo", new_text: "bar" }], + path: testFile, + edits: [{ old_text: "foo", new_text: "bar" }], }), ).rejects.toThrow(/Found 3 occurrences/); }); @@ -702,7 +705,8 @@ describe("Coding Agent Tools", () => { fs.writeFileSync(testFile, "foo bar foo baz foo"); const result = await editTool.execute("test-all-1", { - edits: [{ path: testFile, old_text: "foo", new_text: "qux", all: true }], + path: testFile, + edits: [{ old_text: "foo", new_text: "qux", all: true }], }); expect(getTextOutput(result)).toContain("Successfully replaced 3 occurrences"); @@ -731,9 +735,9 @@ function b() { // With multiple fuzzy matches, the tool rejects for safety to avoid ambiguous replacements await expect( editTool.execute("test-all-fuzzy", { + path: testFile, edits: [ { - path: testFile, old_text: "if (x) {\n doThing();\n}", new_text: "if (y) {\n doOther();\n}", all: true, @@ -749,7 +753,8 @@ function b() { await expect( editTool.execute("test-all-nomatch", { - edits: [{ path: testFile, old_text: "nonexistent", new_text: "bar", all: true }], + path: testFile, + edits: [{ old_text: "nonexistent", new_text: "bar", all: true }], }), ).rejects.toThrow(/Could not find/); }); @@ -759,7 +764,8 @@ function b() { fs.writeFileSync(testFile, "start\nfoo\nbar\nend\nstart\nfoo\nbar\nend"); const result = await editTool.execute("test-all-multiline", { - edits: [{ path: testFile, old_text: "foo\nbar", new_text: "replaced", all: true }], + path: testFile, + edits: [{ old_text: "foo\nbar", new_text: "replaced", all: true }], }); expect(getTextOutput(result)).toContain("Successfully replaced 2 occurrences"); @@ -772,7 +778,8 @@ function b() { fs.writeFileSync(testFile, "hello world"); const result = await editTool.execute("test-all-single", { - edits: [{ path: testFile, old_text: "world", new_text: "universe", all: true }], + path: testFile, + edits: [{ old_text: "world", new_text: "universe", all: true }], }); expect(getTextOutput(result)).toContain("Successfully replaced text"); @@ -1219,7 +1226,9 @@ function b() { fs.writeFileSync(testFile, content); const contextSettings = Settings.isolated({ "search.contextBefore": 1, "search.contextAfter": 1 }); - const contextSearchTool = wrapToolWithMetaNotice(new SearchTool(createTestToolSession(testDir, contextSettings))); + const contextSearchTool = wrapToolWithMetaNotice( + new SearchTool(createTestToolSession(testDir, contextSettings)), + ); const result = await contextSearchTool.execute("test-call-12", { pattern: "match", path: testFile, @@ -1560,7 +1569,8 @@ describe("edit tool CRLF handling", () => { fs.writeFileSync(testFile, "line one\r\nline two\r\nline three\r\n"); const result = await editTool.execute("test-crlf-1", { - edits: [{ path: testFile, old_text: "line two\n", new_text: "replaced line\n" }], + path: testFile, + edits: [{ old_text: "line two\n", new_text: "replaced line\n" }], }); expect(getTextOutput(result)).toContain("Successfully replaced"); @@ -1571,7 +1581,8 @@ describe("edit tool CRLF handling", () => { fs.writeFileSync(testFile, "first\r\nsecond\r\nthird\r\n"); await editTool.execute("test-crlf-2", { - edits: [{ path: testFile, old_text: "second\n", new_text: "REPLACED\n" }], + path: testFile, + edits: [{ old_text: "second\n", new_text: "REPLACED\n" }], }); const content = await Bun.file(testFile).text(); @@ -1583,7 +1594,8 @@ describe("edit tool CRLF handling", () => { fs.writeFileSync(testFile, "first\nsecond\nthird\n"); await editTool.execute("test-lf-1", { - edits: [{ path: testFile, old_text: "second\n", new_text: "REPLACED\n" }], + path: testFile, + edits: [{ old_text: "second\n", new_text: "REPLACED\n" }], }); const content = await Bun.file(testFile).text(); @@ -1597,7 +1609,8 @@ describe("edit tool CRLF handling", () => { await expect( editTool.execute("test-crlf-dup", { - edits: [{ path: testFile, old_text: "hello\nworld\n", new_text: "replaced\n" }], + path: testFile, + edits: [{ old_text: "hello\nworld\n", new_text: "replaced\n" }], }), ).rejects.toThrow(/Found 2 occurrences/); }); @@ -1608,7 +1621,8 @@ describe("edit tool CRLF handling", () => { fs.writeFileSync(testFile, "\uFEFFfirst\r\nsecond\r\nthird\r\n"); await editTool.execute("test-bom", { - edits: [{ path: testFile, old_text: "second\n", new_text: "REPLACED\n" }], + path: testFile, + edits: [{ old_text: "second\n", new_text: "REPLACED\n" }], }); const content = await Bun.file(testFile).text(); diff --git a/packages/coding-agent/test/tools/search-renderer.test.ts b/packages/coding-agent/test/tools/search-renderer.test.ts index d1185ba59..bedf665ba 100644 --- a/packages/coding-agent/test/tools/search-renderer.test.ts +++ b/packages/coding-agent/test/tools/search-renderer.test.ts @@ -27,9 +27,14 @@ describe("searchToolRenderer", () => { }, }; - const collapsed = searchToolRenderer.renderResult(result as never, { expanded: false, isPartial: false }, uiTheme, { - pattern: "needle", - }); + const collapsed = searchToolRenderer.renderResult( + result as never, + { expanded: false, isPartial: false }, + uiTheme, + { + pattern: "needle", + }, + ); const renderedLines = sanitizeText(collapsed.render(200).join("\n")).split("\n"); const bodyLines = renderedLines.slice(1); diff --git a/scripts/analyze-edit-formats/README.md b/scripts/analyze-edit-formats/README.md new file mode 100644 index 000000000..374b689f3 --- /dev/null +++ b/scripts/analyze-edit-formats/README.md @@ -0,0 +1,48 @@ +# analyze-edit-formats + +Audits how agents have used the `edit` / `ast_edit` / `write` tools across +historical session JSONLs in `~/.omp/agent/sessions/`. + +For each tool call we: + +- detect the **argument-schema family** in use (the edit tool has shipped many + shapes over time: `oldText/newText`, `op+pos+end+lines`, `loc+content`, + `loc+splice/pre/post/sed`, etc.); +- record the locator shape and verb combination (for the current + `loc+splice/pre/post/sed` schema); +- pair the call with its `toolResult` and classify the outcome + (`success` / `truncated` / `aborted` / `fail:anchor-stale` / + `fail:no-match` / `fail:parse` / `fail:no-enclosing-block` / …). + +Output is a markdown-ish report on stdout plus per-call CSV at +`/tmp/edit-analysis/edits.csv` (or your CWD if you set that up differently). + +## Usage + +```sh +# Scan every session jsonl on disk (slow — ~25k files). +go run ./scripts/analyze-edit-formats + +# Scan only files whose path contains the given date prefix(es). +go run ./scripts/analyze-edit-formats 2026-04-28 +go run ./scripts/analyze-edit-formats 2026-04-27 2026-04-28 +``` + +The walk root is `~/.omp/agent/sessions/`. Sub-session files (subagent +trajectories nested under `/-.jsonl`) are picked up +automatically. + +## Why Go + +The session corpus is large (>25k files, >100k edit calls). Go iterates the +JSONL stream with negligible memory overhead and finishes in ~90s. The same +analysis in Bun/TS works but is noticeably slower for ad-hoc runs. + +## What it's good for + +- Comparing reliability across edit-tool argument schemas before changing the + current one. +- Spotting which verb / locator shapes have outsized failure rates so the + prompt can warn against them. +- Sanity-checking that a new edit-tool design isn't regressing the + failure-mode mix versus the previous design. diff --git a/scripts/analyze-edit-formats/go.mod b/scripts/analyze-edit-formats/go.mod new file mode 100644 index 000000000..70e146993 --- /dev/null +++ b/scripts/analyze-edit-formats/go.mod @@ -0,0 +1,3 @@ +module github.com/oh-my-pi/scripts/analyze-edit-formats + +go 1.26.2 diff --git a/scripts/analyze-edit-formats/main.go b/scripts/analyze-edit-formats/main.go new file mode 100644 index 000000000..3713d3f01 --- /dev/null +++ b/scripts/analyze-edit-formats/main.go @@ -0,0 +1,651 @@ +// Analyzes how agents use the `edit` tool across today's session jsonl files +// in ~/.omp/agent/sessions/. +// +// For every edit-family toolCall (edit, ast_edit, write) we: +// - record what shape of arguments was supplied (loc, splice/pre/post/sed, +// bracket form of locator, line vs file targeted, etc.) +// - pair it with its toolResult and classify the result as success or as a +// specific failure category (anchor stale, anchor unknown, no enclosing +// block, parse error, ssr no match, etc.). +// +// Output is a markdown-ish report on stdout plus a CSV of every edit attempt +// to ./edit-analysis.csv (override with $EDIT_ANALYSIS_CSV). +package main + +import ( + "bufio" + "encoding/csv" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +type rawMessage struct { + Type string `json:"type"` + Message json.RawMessage `json:"message"` +} + +type message struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + ToolName string `json:"toolName"` + ToolCallID string `json:"toolCallId"` +} + +type contentItem struct { + Type string `json:"type"` + Text string `json:"text"` + Name string `json:"name"` + ID string `json:"id"` + Arguments json.RawMessage `json:"arguments"` +} + +type editEntry struct { + File string + CallID string + ToolName string + NumEdits int + Verbs []string // splice/pre/post/sed per sub-edit + LocShapes []string // bare/bracket-(/bracket-[/bracket-tail/bracket-head/$pre/$post/$sed + HasNewFile bool + HasGlob bool + HasOps bool + Format string // edit-tool argument schema family + ResultRaw string + Status string // "success" or failure category +} + +// matchDate accepts files whose path contains any of the supplied date +// prefixes (e.g. "2026-04-28"). With no flags it accepts every .jsonl. +var dateFilters []string + +func matchDate(p string) bool { + if len(dateFilters) == 0 { + return true + } + for _, d := range dateFilters { + if strings.Contains(p, d) { + return true + } + } + return false +} + +func main() { + dateFilters = os.Args[1:] + root, err := os.UserHomeDir() + must(err) + base := filepath.Join(root, ".omp", "agent", "sessions") + + var files []string + must(filepath.Walk(base, func(p string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + if !strings.HasSuffix(p, ".jsonl") { + return nil + } + if !matchDate(p) { + return nil + } + files = append(files, p) + return nil + })) + sort.Strings(files) + fmt.Fprintf(os.Stderr, "loaded %d session files for today\n", len(files)) + + var entries []editEntry + for _, f := range files { + entries = append(entries, processFile(f)...) + } + + report(entries) + writeCSV(entries) +} + +func processFile(path string) []editEntry { + fh, err := os.Open(path) + if err != nil { + fmt.Fprintln(os.Stderr, "open:", err) + return nil + } + defer fh.Close() + + calls := map[string]*editEntry{} + var order []string + + sc := bufio.NewScanner(fh) + sc.Buffer(make([]byte, 0, 64*1024), 64*1024*1024) + for sc.Scan() { + var rm rawMessage + if err := json.Unmarshal(sc.Bytes(), &rm); err != nil { + continue + } + if rm.Type != "message" { + continue + } + var m message + if err := json.Unmarshal(rm.Message, &m); err != nil { + continue + } + var items []contentItem + if err := json.Unmarshal(m.Content, &items); err != nil { + continue + } + + switch m.Role { + case "assistant": + for _, it := range items { + if it.Type != "toolCall" { + continue + } + if !isEditTool(it.Name) { + continue + } + e := classifyArgs(it.Name, it.Arguments) + e.File = path + e.CallID = it.ID + e.ToolName = it.Name + calls[it.ID] = &e + order = append(order, it.ID) + } + case "toolResult": + if !isEditTool(m.ToolName) { + continue + } + e, ok := calls[m.ToolCallID] + if !ok { + // orphan result, skip + continue + } + text := joinText(items) + e.ResultRaw = text + e.Status = classifyResult(m.ToolName, text) + } + } + + out := make([]editEntry, 0, len(order)) + for _, id := range order { + if e, ok := calls[id]; ok { + out = append(out, *e) + } + } + return out +} + +func isEditTool(name string) bool { + switch strings.ToLower(name) { + case "edit", "ast_edit", "write": + return true + } + return false +} + +func joinText(items []contentItem) string { + var b strings.Builder + for _, it := range items { + if it.Type == "text" { + b.WriteString(it.Text) + } + } + return b.String() +} + +// ---- argument classification ---- + +type editOp struct { + Loc string `json:"loc"` + Splice json.RawMessage `json:"splice"` + Pre json.RawMessage `json:"pre"` + Post json.RawMessage `json:"post"` + Sed json.RawMessage `json:"sed"` +} + +type editArgs struct { + Path string `json:"path"` + Edits []editOp `json:"edits"` + + // ast_edit + Ops []json.RawMessage `json:"ops"` + + // Write + Content *string `json:"content,omitempty"` +} + +var anchorBare = regexp.MustCompile(`^[a-zA-Z]?\d+[a-z]{2}$`) +var anchorWithFile = regexp.MustCompile(`^[^:]+:\d+[a-z]{2}$`) + +func classifyArgs(name string, raw json.RawMessage) editEntry { + e := editEntry{} + e.Format = detectFormat(name, raw) + switch strings.ToLower(name) { + case "edit": + var a editArgs + _ = json.Unmarshal(raw, &a) + e.NumEdits = len(a.Edits) + for _, op := range a.Edits { + e.LocShapes = append(e.LocShapes, locShape(op.Loc)) + verbs := []string{} + if !isNullOrEmpty(op.Splice) { + verbs = append(verbs, "splice") + } + if !isNullOrEmpty(op.Pre) { + verbs = append(verbs, "pre") + } + if !isNullOrEmpty(op.Post) { + verbs = append(verbs, "post") + } + if !isNullOrEmpty(op.Sed) { + verbs = append(verbs, "sed") + } + if len(verbs) == 0 { + verbs = append(verbs, "none") + } + e.Verbs = append(e.Verbs, strings.Join(verbs, "+")) + } + case "ast_edit": + var a editArgs + _ = json.Unmarshal(raw, &a) + e.HasOps = len(a.Ops) > 0 + e.NumEdits = len(a.Ops) + if strings.ContainsAny(a.Path, "*?,") { + e.HasGlob = true + } + case "write": + e.HasNewFile = true + e.NumEdits = 1 + e.Verbs = []string{"write"} + } + return e +} + +// detectFormat figures out which edit-tool argument schema is in use by +// looking at the top-level argument keys and (for `edit`) the keys of the +// first sub-edit. Older sessions used many incompatible schemas. +func detectFormat(name string, raw json.RawMessage) string { + switch strings.ToLower(name) { + case "write": + return "write" + case "ast_edit": + return "ast_edit" + } + var top map[string]json.RawMessage + if err := json.Unmarshal(raw, &top); err != nil { + return "unknown" + } + has := func(k string) bool { _, ok := top[k]; return ok } + switch { + case has("oldText") && has("newText"): + return "oldText/newText" + case has("old_text") && has("new_text"): + return "old_text/new_text" + case has("diff") && has("op"): + return "diff+op" + case has("diff") && has("operation"): + return "diff+operation" + case has("diff"): + return "diff" + case has("replace") || has("insert"): + return "replace/insert" + } + if edits, ok := top["edits"]; ok { + var list []map[string]json.RawMessage + if err := json.Unmarshal(edits, &list); err == nil && len(list) > 0 { + first := list[0] + fh := func(k string) bool { _, ok := first[k]; return ok } + switch { + case fh("loc") && (fh("splice") || fh("pre") || fh("post") || fh("sed")): + return "loc+splice/pre/post/sed" + case fh("loc") && fh("content"): + return "loc+content" + case fh("set_line"): + return "set_line" + case fh("insert_after"): + return "insert_after" + case fh("op") && fh("pos") && fh("end") && fh("lines"): + return "op+pos+end+lines" + case fh("op") && fh("pos") && fh("lines"): + return "op+pos+lines" + case fh("op") && fh("sel") && fh("content"): + return "op+sel+content" + case fh("all") && (fh("new_text") || fh("old_text")): + return "per-edit:old_text/new_text" + } + keys := make([]string, 0, len(first)) + for k := range first { + keys = append(keys, k) + } + sort.Strings(keys) + return "edits[" + strings.Join(keys, ",") + "]" + } + } + keys := make([]string, 0, len(top)) + for k := range top { + keys = append(keys, k) + } + sort.Strings(keys) + return strings.Join(keys, ",") +} + +func isNullOrEmpty(b json.RawMessage) bool { + s := strings.TrimSpace(string(b)) + return s == "" || s == "null" +} + +func locShape(loc string) string { + if loc == "" { + return "empty" + } + if loc == "$" { + return "$file" + } + // strip optional file: prefix + rest := loc + if i := strings.LastIndex(loc, ":"); i >= 0 && !strings.HasPrefix(loc, "$") { + rest = loc[i+1:] + } + switch { + case strings.HasPrefix(rest, "(") && strings.HasSuffix(rest, ")"): + return "bracket-(body)" + case strings.HasPrefix(rest, "[") && strings.HasSuffix(rest, "]"): + return "bracket-[block]" + case strings.HasPrefix(rest, "(") || strings.HasPrefix(rest, "["): + return "bracket-tail" + case strings.HasSuffix(rest, ")") || strings.HasSuffix(rest, "]"): + return "bracket-head" + case anchorBare.MatchString(rest): + return "bare-anchor" + } + return "other" +} + +// ---- result classification ---- + +var ( + reAnchorStale = regexp.MustCompile(`(?i)(Edit rejected:.*line[s]? .* changed since the last read|line[s]? ha(s|ve) changed since last read)`) + reAnchorMissing = regexp.MustCompile(`(?i)anchor .* (not found|unknown|missing)|loc requires the full anchor`) + reNoEnclosing = regexp.MustCompile(`(?i)No enclosing .* block`) + reParseError = regexp.MustCompile(`(?i)parse|syntax error|unbalanced|unexpected token`) + reSSRNoMatch = regexp.MustCompile(`(?i)0 matches|no replacements|no match found|No replacements made|Failed to find expected lines`) + reFileNotRead = regexp.MustCompile(`(?i)must be read first|has not been read|not yet read`) + reFileChanged = regexp.MustCompile(`(?i)file has been (modified|changed) externally`) + rePermDenied = regexp.MustCompile(`(?i)permission denied|not allowed`) + reGenericRejected = regexp.MustCompile(`(?i)\b(rejected|failed|error|invalid)\b`) + reTruncated = regexp.MustCompile(`(?i)\[Output truncated`) + reAborted = regexp.MustCompile(`(?i)Tool execution was aborted|Request was aborted|cancelled|canceled by user`) + reSuccess = regexp.MustCompile(`(?i)^(Updated|Successfully (wrote|replaced|edited|deleted|inserted)|Replaced|Applied|Deleted|Created|Wrote|edit applied|Edited|Inserted|OK\b)`) +) + +func classifyResult(tool, text string) string { + t := strings.TrimSpace(text) + if t == "" { + return "empty" + } + first := strings.SplitN(t, "\n", 2)[0] + switch { + case reTruncated.MatchString(first): + return "truncated" + case reAborted.MatchString(t): + return "aborted" + case reSuccess.MatchString(first): + return "success" + case reAnchorStale.MatchString(t): + return "fail:anchor-stale" + case reNoEnclosing.MatchString(t): + return "fail:no-enclosing-block" + case reAnchorMissing.MatchString(t): + return "fail:anchor-missing" + case reParseError.MatchString(t): + return "fail:parse" + case reSSRNoMatch.MatchString(t): + return "fail:no-match" + case reFileNotRead.MatchString(t): + return "fail:file-not-read" + case reFileChanged.MatchString(t): + return "fail:file-changed" + case rePermDenied.MatchString(t): + return "fail:perm" + case reGenericRejected.MatchString(first): + return "fail:other" + } + return "unknown" +} + +// ---- reporting ---- + +func report(entries []editEntry) { + if len(entries) == 0 { + fmt.Println("no edit-family tool calls found in today's sessions") + return + } + + byTool := map[string]int{} + byFormat := map[string]int{} + statusByFormat := map[string]map[string]int{} + statusByTool := map[string]map[string]int{} + verbCount := map[string]int{} + locCount := map[string]int{} + failsByVerb := map[string]map[string]int{} + failsByLoc := map[string]map[string]int{} + + for _, e := range entries { + byTool[e.ToolName]++ + if statusByTool[e.ToolName] == nil { + statusByTool[e.ToolName] = map[string]int{} + } + statusByTool[e.ToolName][e.Status]++ + byFormat[e.Format]++ + if statusByFormat[e.Format] == nil { + statusByFormat[e.Format] = map[string]int{} + } + statusByFormat[e.Format][e.Status]++ + + for _, v := range e.Verbs { + verbCount[v]++ + if failsByVerb[v] == nil { + failsByVerb[v] = map[string]int{} + } + failsByVerb[v][e.Status]++ + } + for _, l := range e.LocShapes { + locCount[l]++ + if failsByLoc[l] == nil { + failsByLoc[l] = map[string]int{} + } + failsByLoc[l][e.Status]++ + } + } + + fmt.Println("# Edit-tool usage in today's sessions") + fmt.Printf("\nTotal tool calls: %d (across %d sessions)\n", + len(entries), countSessions(entries)) + + fmt.Println("\n## By tool") + printSorted(byTool) + + fmt.Println("\n## Outcome by tool") + tools := keys(byTool) + sort.Strings(tools) + for _, t := range tools { + fmt.Printf("\n %s (%d calls):\n", t, byTool[t]) + printSortedIndent(statusByTool[t], " ") + } + + fmt.Println("\n## edit verb distribution (per sub-edit)") + printSorted(verbCount) + + fmt.Println("\n## edit locator shape distribution") + printSorted(locCount) + + fmt.Println("\n## Failure rate per verb shape") + for _, v := range sortedKeys(verbCount) { + total, failed := 0, 0 + for status, n := range failsByVerb[v] { + total += n + if strings.HasPrefix(status, "fail") { + failed += n + } + } + fmt.Printf(" %-20s %d/%d failed (%.0f%%)\n", v, failed, total, pct(failed, total)) + } + + fmt.Println("\n## Failure rate per locator shape") + for _, l := range sortedKeys(locCount) { + total, failed := 0, 0 + for status, n := range failsByLoc[l] { + total += n + if strings.HasPrefix(status, "fail") { + failed += n + } + } + fmt.Printf(" %-20s %d/%d failed (%.0f%%)\n", l, failed, total, pct(failed, total)) + } + + fmt.Println("\n## edit-tool argument-format usage") + printSorted(byFormat) + + fmt.Println("\n## Failure rate per argument format") + for _, fname := range sortedKeys(byFormat) { + total, failed := 0, 0 + for status, n := range statusByFormat[fname] { + total += n + if strings.HasPrefix(status, "fail") { + failed += n + } + } + fmt.Printf(" %-32s %6d/%-6d failed (%.0f%%)\n", fname, failed, total, pct(failed, total)) + } + + fmt.Println("\n## Failure breakdown per top format") + cap := 0 + for _, fname := range sortedKeys(byFormat) { + if cap >= 8 { + break + } + cap++ + fmt.Printf("\n %s (%d total)\n", fname, byFormat[fname]) + printSortedIndent(statusByFormat[fname], " ") + } + + fmt.Println("\n## Sample failed edits") + shown := 0 + for _, e := range entries { + if !strings.HasPrefix(e.Status, "fail") { + continue + } + fmt.Printf("\n— %s [%s] verbs=%v loc=%v\n result: %s\n", + e.ToolName, e.Status, e.Verbs, e.LocShapes, + truncate(strings.SplitN(e.ResultRaw, "\n\n", 2)[0], 220)) + shown++ + if shown >= 8 { + break + } + } +} + +func countSessions(es []editEntry) int { + s := map[string]struct{}{} + for _, e := range es { + s[e.File] = struct{}{} + } + return len(s) +} + +func printSorted(m map[string]int) { + for _, k := range sortedKeys(m) { + fmt.Printf(" %-25s %d\n", k, m[k]) + } +} + +func printSortedIndent(m map[string]int, indent string) { + for _, k := range sortedKeys(m) { + fmt.Printf("%s%-25s %d\n", indent, k, m[k]) + } +} + +func sortedKeys(m map[string]int) []string { + type kv struct { + k string + v int + } + pairs := make([]kv, 0, len(m)) + for k, v := range m { + pairs = append(pairs, kv{k, v}) + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].v != pairs[j].v { + return pairs[i].v > pairs[j].v + } + return pairs[i].k < pairs[j].k + }) + out := make([]string, len(pairs)) + for i, p := range pairs { + out[i] = p.k + } + return out +} + +func keys(m map[string]int) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +func pct(a, b int) float64 { + if b == 0 { + return 0 + } + return 100 * float64(a) / float64(b) +} + +func truncate(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " | ") + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +func writeCSV(entries []editEntry) { + csvPath := os.Getenv("EDIT_ANALYSIS_CSV") + if csvPath == "" { + csvPath = "edit-analysis.csv" + } + f, err := os.Create(csvPath) + if err != nil { + fmt.Fprintln(os.Stderr, "csv:", err) + return + } + defer f.Close() + w := csv.NewWriter(f) + defer w.Flush() + _ = w.Write([]string{"session", "tool", "status", "num_edits", "verbs", "loc_shapes", "result_first_line"}) + for _, e := range entries { + first := strings.SplitN(e.ResultRaw, "\n", 2)[0] + _ = w.Write([]string{ + filepath.Base(e.File), + e.ToolName, + e.Status, + fmt.Sprintf("%d", e.NumEdits), + strings.Join(e.Verbs, ","), + strings.Join(e.LocShapes, ","), + truncate(first, 200), + }) + } +} + +func must(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, "fatal:", err) + os.Exit(1) + } +}