feat(coding-agent): implemented shared single-path edit request handling
- Required top-level `path` in edit requests, removed per-entry `path` fields, and updated docs/tests to match. - Updated patch/hashline/atom streaming preview generation to return a single request-level path diff preview. - Changed edit execution to route patch/replace/atom/hashline through single-path handlers sharing `path`. - Added `scripts/analyze-edit-formats` Go CLI with reports to audit edit-tool usage from session JSONL logs.
This commit is contained in:
@@ -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_<server>_<tool>` format to `mcp__<server>_<tool>` 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
|
||||
|
||||
@@ -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<T extends { path?: string }>(
|
||||
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<T, K>(items: T[], key: (item: T) => K): Map<K, T[]> {
|
||||
const map = new Map<K, T[]>();
|
||||
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<AgentToolResult<EditToolDetails, any>>;
|
||||
run: (batchRequest: LspBatchRequest | undefined) => Promise<AgentToolResult<EditToolDetails>>;
|
||||
}[],
|
||||
outerBatchRequest: LspBatchRequest | undefined,
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
@@ -230,6 +190,58 @@ async function executePerFile(
|
||||
};
|
||||
}
|
||||
|
||||
async function executeSinglePathEntries(
|
||||
path: string,
|
||||
runs: ((batchRequest: LspBatchRequest | undefined) => Promise<AgentToolResult<EditToolDetails>>)[],
|
||||
outerBatchRequest: LspBatchRequest | undefined,
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
): Promise<AgentToolResult<EditToolDetails, TInput>> {
|
||||
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<TInput> {
|
||||
readonly name = "edit";
|
||||
readonly label = "Edit";
|
||||
@@ -316,13 +328,12 @@ export class EditTool implements AgentTool<TInput> {
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => 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<TInput> {
|
||||
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<TInput> {
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => 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<TInput> {
|
||||
params: EditParams,
|
||||
signal: AbortSignal | undefined,
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
_onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => 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<TInput> {
|
||||
params: EditParams,
|
||||
signal: AbortSignal | undefined,
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
||||
_onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => 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<TInput> {
|
||||
batchRequest: LspBatchRequest | undefined,
|
||||
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => 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<TInput> {
|
||||
writethrough: tool.#writethrough,
|
||||
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
|
||||
}),
|
||||
}));
|
||||
return executePerFile(entries, batchRequest, onUpdate);
|
||||
);
|
||||
return executeSinglePathEntries(path, runs, batchRequest, onUpdate);
|
||||
},
|
||||
},
|
||||
vim: {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,17 +22,19 @@ export const applyPatchSchema = Type.Object({
|
||||
|
||||
export type ApplyPatchParams = Static<typeof applyPatchSchema>;
|
||||
|
||||
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,
|
||||
|
||||
@@ -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+<bigram>`, 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<string, unknown> | undefined;
|
||||
const fields = edit as Record<string, unknown>;
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<typeof patchEditEntrySchema>;
|
||||
export type PatchParams = Static<typeof patchEditSchema>;
|
||||
|
||||
export interface ExecutePatchSingleOptions {
|
||||
session: ToolSession;
|
||||
path: string;
|
||||
params: PatchEditEntry;
|
||||
signal?: AbortSignal;
|
||||
batchRequest?: LspBatchRequest;
|
||||
@@ -1694,6 +1700,7 @@ export async function executePatchSingle(
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof patchEditEntrySchema>> {
|
||||
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";
|
||||
|
||||
|
||||
@@ -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<typeof replaceEditEntrySchema>;
|
||||
export type ReplaceParams = Static<typeof replaceEditSchema>;
|
||||
|
||||
export interface ExecuteReplaceSingleOptions {
|
||||
session: ToolSession;
|
||||
path: string;
|
||||
params: ReplaceEditEntry;
|
||||
signal?: AbortSignal;
|
||||
batchRequest?: LspBatchRequest;
|
||||
@@ -1007,6 +1013,7 @@ export async function executeReplaceSingle(
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof replaceEditEntrySchema>> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T>(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<string, ApplyPatchEntry[]> {
|
||||
const groups = new Map<string, ApplyPatchEntry[]>();
|
||||
|
||||
/**
|
||||
* 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<T extends { path?: string }>(
|
||||
edits: readonly T[],
|
||||
fallbackPath: string | undefined,
|
||||
): Map<string, Array<T & { path: string }>> {
|
||||
const groups = new Map<string, Array<T & { path: string }>>();
|
||||
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<ReplaceArgs> = {
|
||||
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<PatchArgs> = {
|
||||
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<HashlineArgs> = {
|
||||
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<ApplyPatchArgs> = {
|
||||
},
|
||||
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<ApplyPatchArgs> = {
|
||||
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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
Applies precise file edits using anchors (line+hash).
|
||||
|
||||
<ops>
|
||||
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.
|
||||
|
||||
@@ -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 <<EOF`, `echo > 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
|
||||
|
||||
@@ -5,10 +5,9 @@ Read the file first. Copy the full anchors exactly as shown by `read`.
|
||||
<operations>
|
||||
**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();","}",""]}]}`
|
||||
</examples>
|
||||
|
||||
<critical>
|
||||
|
||||
@@ -18,19 +18,19 @@ When editing structured blocks (nested braces, tags, indented regions), include
|
||||
|
||||
<parameters>
|
||||
```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 }
|
||||
```
|
||||
</parameters>
|
||||
|
||||
@@ -52,15 +52,15 @@ Returns success/failure; on failure, error message indicates:
|
||||
|
||||
<examples>
|
||||
# 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.
|
||||
</examples>
|
||||
|
||||
<avoid>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
Performs string replacements in files with fuzzy whitespace matching.
|
||||
|
||||
<instruction>
|
||||
- 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
|
||||
</instruction>
|
||||
|
||||
|
||||
@@ -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.
|
||||
</critical>
|
||||
</critical>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 `<session-id>/<n>-<name>.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.
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/oh-my-pi/scripts/analyze-edit-formats
|
||||
|
||||
go 1.26.2
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user