@oh-my-pi/pi-coding-agent 14.3.0 → 14.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -1
- package/package.json +7 -7
- package/src/autoresearch/prompt.md +1 -1
- package/src/commit/agentic/prompts/analyze-file.md +1 -1
- package/src/config/model-registry.ts +67 -15
- package/src/config/prompt-templates.ts +5 -5
- package/src/config/settings-schema.ts +4 -4
- package/src/cursor.ts +3 -8
- package/src/discovery/helpers.ts +3 -3
- package/src/edit/diff.ts +50 -47
- package/src/edit/index.ts +86 -57
- package/src/edit/line-hash.ts +743 -24
- package/src/edit/modes/apply-patch.ts +0 -9
- package/src/edit/modes/atom.ts +893 -0
- package/src/edit/modes/chunk.ts +14 -24
- package/src/edit/modes/hashline.ts +193 -146
- package/src/edit/modes/patch.ts +5 -9
- package/src/edit/modes/replace.ts +6 -11
- package/src/edit/renderer.ts +14 -10
- package/src/edit/streaming.ts +50 -16
- package/src/exec/bash-executor.ts +2 -4
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +4 -12
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/custom-tools/wrapper.ts +2 -1
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/lsp/defaults.json +142 -652
- package/src/lsp/index.ts +1 -1
- package/src/mcp/render.ts +1 -8
- package/src/modes/components/assistant-message.ts +4 -0
- package/src/modes/components/diff.ts +23 -14
- package/src/modes/components/footer.ts +21 -16
- package/src/modes/components/session-selector.ts +3 -3
- package/src/modes/components/settings-defs.ts +6 -1
- package/src/modes/components/todo-reminder.ts +1 -8
- package/src/modes/components/tool-execution.ts +1 -4
- package/src/modes/controllers/selector-controller.ts +1 -1
- package/src/modes/print-mode.ts +8 -0
- package/src/prompts/agents/librarian.md +1 -1
- package/src/prompts/agents/reviewer.md +4 -4
- package/src/prompts/ci-green-request.md +1 -1
- package/src/prompts/review-request.md +1 -1
- package/src/prompts/system/subagent-system-prompt.md +3 -3
- package/src/prompts/system/subagent-yield-reminder.md +11 -0
- package/src/prompts/system/system-prompt.md +3 -0
- package/src/prompts/tools/ask.md +3 -2
- package/src/prompts/tools/ast-edit.md +16 -20
- package/src/prompts/tools/ast-grep.md +19 -24
- package/src/prompts/tools/atom.md +87 -0
- package/src/prompts/tools/chunk-edit.md +37 -161
- package/src/prompts/tools/debug.md +4 -5
- package/src/prompts/tools/exit-plan-mode.md +4 -5
- package/src/prompts/tools/find.md +4 -8
- package/src/prompts/tools/github.md +18 -0
- package/src/prompts/tools/grep.md +4 -5
- package/src/prompts/tools/hashline.md +22 -89
- package/src/prompts/tools/{gemini-image.md → image-gen.md} +1 -1
- package/src/prompts/tools/inspect-image.md +6 -6
- package/src/prompts/tools/lsp.md +1 -1
- package/src/prompts/tools/patch.md +12 -19
- package/src/prompts/tools/python.md +3 -2
- package/src/prompts/tools/read-chunk.md +2 -3
- package/src/prompts/tools/read.md +2 -2
- package/src/prompts/tools/ssh.md +8 -17
- package/src/prompts/tools/todo-write.md +54 -41
- package/src/sdk.ts +14 -9
- package/src/session/agent-session.ts +25 -2
- package/src/session/session-manager.ts +4 -1
- package/src/task/executor.ts +43 -48
- package/src/task/render.ts +11 -13
- package/src/tools/ask.ts +7 -7
- package/src/tools/ast-edit.ts +45 -41
- package/src/tools/ast-grep.ts +77 -85
- package/src/tools/bash.ts +8 -9
- package/src/tools/browser.ts +32 -30
- package/src/tools/calculator.ts +4 -4
- package/src/tools/cancel-job.ts +1 -1
- package/src/tools/checkpoint.ts +2 -2
- package/src/tools/debug.ts +41 -37
- package/src/tools/exit-plan-mode.ts +1 -1
- package/src/tools/find.ts +4 -4
- package/src/tools/gh-renderer.ts +12 -4
- package/src/tools/gh.ts +509 -697
- package/src/tools/grep.ts +116 -131
- package/src/tools/{gemini-image.ts → image-gen.ts} +459 -60
- package/src/tools/index.ts +14 -32
- package/src/tools/inspect-image.ts +3 -3
- package/src/tools/json-tree.ts +114 -114
- package/src/tools/match-line-format.ts +8 -7
- package/src/tools/notebook.ts +8 -7
- package/src/tools/poll-tool.ts +2 -1
- package/src/tools/python.ts +9 -23
- package/src/tools/read.ts +32 -25
- package/src/tools/render-mermaid.ts +1 -1
- package/src/tools/render-utils.ts +18 -0
- package/src/tools/renderers.ts +2 -2
- package/src/tools/report-tool-issue.ts +3 -2
- package/src/tools/resolve.ts +1 -1
- package/src/tools/review.ts +12 -10
- package/src/tools/search-tool-bm25.ts +2 -4
- package/src/tools/ssh.ts +4 -4
- package/src/tools/todo-write.ts +172 -147
- package/src/tools/vim.ts +14 -15
- package/src/tools/write.ts +4 -4
- package/src/tools/{submit-result.ts → yield.ts} +11 -13
- package/src/utils/edit-mode.ts +2 -1
- package/src/utils/file-display-mode.ts +10 -5
- package/src/utils/git.ts +9 -5
- package/src/utils/shell-snapshot.ts +2 -3
- package/src/vim/render.ts +4 -4
- package/src/prompts/system/subagent-submit-reminder.md +0 -11
- package/src/prompts/tools/gh-issue-view.md +0 -11
- package/src/prompts/tools/gh-pr-checkout.md +0 -12
- package/src/prompts/tools/gh-pr-diff.md +0 -12
- package/src/prompts/tools/gh-pr-push.md +0 -12
- package/src/prompts/tools/gh-pr-view.md +0 -11
- package/src/prompts/tools/gh-repo-view.md +0 -11
- package/src/prompts/tools/gh-run-watch.md +0 -12
- package/src/prompts/tools/gh-search-issues.md +0 -11
- package/src/prompts/tools/gh-search-prs.md +0 -11
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* Flat locator + verb edit mode backed by hashline anchors. Each entry carries
|
|
4
|
+
* one shared `loc` selector plus one or more verbs (`pre`, `set`, `post`).
|
|
5
|
+
* The runtime resolves those verbs into internal anchor-scoped edits and still
|
|
6
|
+
* reuses hashline's staleness scheme (`computeLineHash`) verbatim.
|
|
7
|
+
*
|
|
8
|
+
* External shapes (one entry):
|
|
9
|
+
* { path, loc: "5th", set: ["..."] }
|
|
10
|
+
* { path, loc: "5th", pre: ["..."] }
|
|
11
|
+
* { path, loc: "5th", post: ["..."] }
|
|
12
|
+
* { path, loc: "5th", pre: [...], set: [...], post: [...] }
|
|
13
|
+
* { path, loc: "^", pre: [...] } // prepend to BOF
|
|
14
|
+
* { path, loc: "$", post: [...] } // append to EOF
|
|
15
|
+
*
|
|
16
|
+
* `set: []` on a single-anchor locator deletes that line. `set:[""]` preserves
|
|
17
|
+
* a blank line. Line ranges are not supported.
|
|
18
|
+
* in the same entry.
|
|
19
|
+
*
|
|
20
|
+
* For deleting or moving files, the agent should use bash.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
24
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
25
|
+
import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
|
26
|
+
import type { ToolSession } from "../../tools";
|
|
27
|
+
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
28
|
+
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
|
29
|
+
import { outputMeta } from "../../tools/output-meta";
|
|
30
|
+
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
31
|
+
import { generateDiffString } from "../diff";
|
|
32
|
+
import { computeLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
|
|
33
|
+
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
34
|
+
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
35
|
+
import {
|
|
36
|
+
ANCHOR_REBASE_WINDOW,
|
|
37
|
+
type Anchor,
|
|
38
|
+
buildCompactHashlineDiffPreview,
|
|
39
|
+
formatFullAnchorRequirement,
|
|
40
|
+
HashlineMismatchError,
|
|
41
|
+
type HashMismatch,
|
|
42
|
+
hashlineParseText,
|
|
43
|
+
parseTag,
|
|
44
|
+
tryRebaseAnchor,
|
|
45
|
+
} from "./hashline";
|
|
46
|
+
|
|
47
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
48
|
+
// Schema
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
50
|
+
const textSchema = Type.Array(Type.String());
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Flat entry shape with shared locator fields and verb-specific payloads.
|
|
54
|
+
* The runtime validator (`resolveAtomToolEdit`) enforces legal locator/verb
|
|
55
|
+
* combinations. Keeping the schema flat reduces tool-definition size and gives
|
|
56
|
+
* weaker models fewer branching shapes to sample from.
|
|
57
|
+
*/
|
|
58
|
+
export const atomEditSchema = Type.Object(
|
|
59
|
+
{
|
|
60
|
+
path: Type.Optional(Type.String({ description: "file path override", examples: ["src/foo.ts"] })),
|
|
61
|
+
loc: Type.String({
|
|
62
|
+
description: 'edit location: "1ab", "^", "$", or path override like "a.ts:1ab"',
|
|
63
|
+
examples: ["1ab", "^", "$", "src/foo.ts:1ab"],
|
|
64
|
+
}),
|
|
65
|
+
set: Type.Optional(textSchema),
|
|
66
|
+
pre: Type.Optional(textSchema),
|
|
67
|
+
post: Type.Optional(textSchema),
|
|
68
|
+
sed: Type.Optional(
|
|
69
|
+
Type.String({
|
|
70
|
+
description: "sed-style substitution applied to the anchored line",
|
|
71
|
+
examples: ["s/foo/bar/", "s|api|API|g", "s/<pat>/<rep>/F"],
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
},
|
|
75
|
+
{ additionalProperties: false },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export const atomEditParamsSchema = Type.Object(
|
|
79
|
+
{
|
|
80
|
+
path: Type.Optional(Type.String({ description: "default file path for edits" })),
|
|
81
|
+
edits: Type.Array(atomEditSchema, { description: "edit ops" }),
|
|
82
|
+
},
|
|
83
|
+
{ additionalProperties: false },
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
export type AtomToolEdit = Static<typeof atomEditSchema>;
|
|
87
|
+
export type AtomParams = Static<typeof atomEditParamsSchema>;
|
|
88
|
+
|
|
89
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
90
|
+
// Internal resolved op shapes
|
|
91
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
92
|
+
|
|
93
|
+
export type AtomEdit =
|
|
94
|
+
| { op: "set"; pos: Anchor; lines: string[] }
|
|
95
|
+
| { op: "pre"; pos: Anchor; lines: string[] }
|
|
96
|
+
| { op: "post"; pos: Anchor; lines: string[] }
|
|
97
|
+
| { op: "del"; pos: Anchor }
|
|
98
|
+
| { op: "append_file"; lines: string[] }
|
|
99
|
+
| { op: "prepend_file"; lines: string[] }
|
|
100
|
+
| { op: "sed"; pos: Anchor; spec: SedSpec; expression: string };
|
|
101
|
+
|
|
102
|
+
export interface SedSpec {
|
|
103
|
+
pattern: string;
|
|
104
|
+
replacement: string;
|
|
105
|
+
global: boolean;
|
|
106
|
+
ignoreCase: boolean;
|
|
107
|
+
literal: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
111
|
+
// Param guards
|
|
112
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
113
|
+
|
|
114
|
+
const ATOM_VERB_KEYS = ["set", "pre", "post", "sed"] as const;
|
|
115
|
+
type AtomOptionalKey = "path" | "loc" | (typeof ATOM_VERB_KEYS)[number];
|
|
116
|
+
const ATOM_OPTIONAL_KEYS = ["path", "loc", ...ATOM_VERB_KEYS] as const satisfies readonly AtomOptionalKey[];
|
|
117
|
+
|
|
118
|
+
// Matches just the LINE+BIGRAM prefix of an anchor reference. Used to detect
|
|
119
|
+
// optional `|content` suffixes (e.g. `82zu| for (...)`) so the suffix can be
|
|
120
|
+
// captured as a content hint for anchor disambiguation.
|
|
121
|
+
const ANCHOR_PREFIX_RE = new RegExp(`^\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`);
|
|
122
|
+
|
|
123
|
+
// Splits `path:loc` references where the right side starts with a valid anchor
|
|
124
|
+
// (single `\d+<bigram>` or `<anchor>-<anchor>` range, optionally followed by a
|
|
125
|
+
// content suffix using `|` or `:`). The non-greedy `(.+?)` picks the leftmost
|
|
126
|
+
// colon whose RHS is a real anchor, so colons inside the loc's content suffix
|
|
127
|
+
// (TS type annotations, etc.) don't break the split. Drive-letter prefixes like
|
|
128
|
+
// `C:\path\a.ts:160sr` still resolve correctly because the first colon's RHS
|
|
129
|
+
// fails the anchor pattern.
|
|
130
|
+
const ANCHOR_TAG_RE_SRC = `\\s*[>+-]*\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}`;
|
|
131
|
+
const PATH_LOC_SPLIT_RE = new RegExp(`^(.+?):(${ANCHOR_TAG_RE_SRC}(?:-${ANCHOR_TAG_RE_SRC})?(?:[|:].*)?)$`);
|
|
132
|
+
|
|
133
|
+
function stripNullAtomFields(edit: AtomToolEdit): AtomToolEdit {
|
|
134
|
+
let next: Record<string, unknown> | undefined;
|
|
135
|
+
const fields = edit as Record<string, unknown>;
|
|
136
|
+
for (const key of ATOM_OPTIONAL_KEYS) {
|
|
137
|
+
if (fields[key] !== null) continue;
|
|
138
|
+
next ??= { ...fields };
|
|
139
|
+
delete next[key];
|
|
140
|
+
}
|
|
141
|
+
return (next ?? fields) as AtomToolEdit;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type ParsedAtomLoc = { kind: "anchor"; pos: Anchor } | { kind: "bof" } | { kind: "eof" };
|
|
145
|
+
|
|
146
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
147
|
+
// Resolution
|
|
148
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Parse an anchor reference like `"5th"`.
|
|
152
|
+
*
|
|
153
|
+
* Tolerant: on a malformed reference we still try to extract a 1-indexed line
|
|
154
|
+
* number from the leading digits so the validator can surface the *correct*
|
|
155
|
+
* `LINEHASH|content` for the user. The bogus hash is preserved in the returned
|
|
156
|
+
* anchor so the validator emits a content-rich mismatch error.
|
|
157
|
+
*
|
|
158
|
+
* If we cannot recover even a line number, throw a usage-style error with the
|
|
159
|
+
* raw reference quoted.
|
|
160
|
+
*/
|
|
161
|
+
function parseAnchor(raw: string, opName: string): Anchor {
|
|
162
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
163
|
+
throw new Error(`${opName} requires ${formatFullAnchorRequirement()}.`);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
return parseTag(raw);
|
|
167
|
+
} catch {
|
|
168
|
+
const lineMatch = /^\s*[>+-]*\s*(\d+)/.exec(raw);
|
|
169
|
+
if (lineMatch) {
|
|
170
|
+
const line = Number.parseInt(lineMatch[1], 10);
|
|
171
|
+
if (line >= 1) {
|
|
172
|
+
// Sentinel hash that will never match a real line, forcing the validator
|
|
173
|
+
// to report a mismatch with the actual hash + line content.
|
|
174
|
+
return { line, hash: "??" };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
throw new Error(
|
|
178
|
+
`${opName} requires ${formatFullAnchorRequirement(raw)} Could not find a line number in the anchor.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function tryParseAtomTag(raw: string): Anchor | undefined {
|
|
184
|
+
try {
|
|
185
|
+
return parseTag(raw);
|
|
186
|
+
} catch {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function resolveAtomEntryPath(
|
|
192
|
+
edit: AtomToolEdit,
|
|
193
|
+
topLevelPath: string | undefined,
|
|
194
|
+
editIndex: number,
|
|
195
|
+
): AtomToolEdit & { path: string } {
|
|
196
|
+
const entry = stripNullAtomFields(edit);
|
|
197
|
+
let loc = entry.loc;
|
|
198
|
+
let pathOverride: string | undefined;
|
|
199
|
+
if (typeof loc === "string") {
|
|
200
|
+
const split = loc.match(PATH_LOC_SPLIT_RE);
|
|
201
|
+
if (split) {
|
|
202
|
+
pathOverride = split[1];
|
|
203
|
+
loc = split[2]!;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const path = pathOverride || entry.path || topLevelPath;
|
|
207
|
+
if (!path) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Edit ${editIndex}: missing path. Provide a top-level path, per-entry path, or prefix loc with a file path (for example "a.ts:160sr").`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return { ...entry, path, ...(loc !== entry.loc ? { loc } : {}) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function resolveAtomEntryPaths(
|
|
216
|
+
edits: readonly AtomToolEdit[],
|
|
217
|
+
topLevelPath: string | undefined,
|
|
218
|
+
): (AtomToolEdit & { path: string })[] {
|
|
219
|
+
return edits.map((edit, i) => resolveAtomEntryPath(edit, topLevelPath, i));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseLoc(raw: string, editIndex: number): ParsedAtomLoc {
|
|
223
|
+
if (raw === "^") return { kind: "bof" };
|
|
224
|
+
if (raw === "$") return { kind: "eof" };
|
|
225
|
+
// Detect range syntax explicitly: "<anchor>-<anchor>". A bare `-` inside the
|
|
226
|
+
// loc (e.g. line content like `i--`) should not trigger the range error.
|
|
227
|
+
const dash = raw.indexOf("-");
|
|
228
|
+
if (dash > 0) {
|
|
229
|
+
const left = raw.slice(0, dash);
|
|
230
|
+
const right = raw.slice(dash + 1);
|
|
231
|
+
if (tryParseAtomTag(left) !== undefined && tryParseAtomTag(right) !== undefined) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Edit ${editIndex}: atom loc does not support line ranges. Use a single anchor like "160sr", "^", or "$".`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const pos = parseAnchor(raw, "loc");
|
|
238
|
+
// Capture an optional content suffix after the anchor: `82zu| for (...)`.
|
|
239
|
+
// The suffix acts as a hint for anchor disambiguation when the model's hash
|
|
240
|
+
// is wrong but the content reveals the intended line.
|
|
241
|
+
const hint = extractAnchorContentHint(raw);
|
|
242
|
+
if (hint !== undefined) {
|
|
243
|
+
pos.contentHint = hint;
|
|
244
|
+
}
|
|
245
|
+
return { kind: "anchor", pos };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function extractAnchorContentHint(raw: string): string | undefined {
|
|
249
|
+
const match = raw.match(ANCHOR_PREFIX_RE);
|
|
250
|
+
if (!match) return undefined;
|
|
251
|
+
const rest = raw.slice(match[0].length);
|
|
252
|
+
// Accept either the canonical `|` (HASHLINE_CONTENT_SEPARATOR) or the legacy
|
|
253
|
+
// `:` separator. Models trained on older docs still emit `82zu: for (...)`.
|
|
254
|
+
const sep = rest[0];
|
|
255
|
+
if (sep !== HASHLINE_CONTENT_SEPARATOR && sep !== ":") return undefined;
|
|
256
|
+
const hint = rest.slice(1);
|
|
257
|
+
if (hint.trim().length === 0) return undefined;
|
|
258
|
+
return hint;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseSedExpression(raw: string, editIndex: number): SedSpec {
|
|
262
|
+
if (typeof raw !== "string" || raw.length < 3) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Edit ${editIndex}: sed expression must start with "s" followed by a delimiter, e.g. "s/foo/bar/".`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
// Tolerate a missing leading `s`: models occasionally emit `/foo/bar/` directly.
|
|
268
|
+
// As long as the first character is a valid delimiter, treat the expression as
|
|
269
|
+
// if `s` was prepended.
|
|
270
|
+
let bodyStart = 0;
|
|
271
|
+
if (raw[0] === "s") {
|
|
272
|
+
bodyStart = 1;
|
|
273
|
+
}
|
|
274
|
+
const delim = raw[bodyStart]!;
|
|
275
|
+
if (/[\sA-Za-z0-9\\]/.test(delim)) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Edit ${editIndex}: sed delimiter must be a non-alphanumeric, non-whitespace, non-backslash character (got ${JSON.stringify(delim)}).`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const parts: [string, string] = ["", ""];
|
|
281
|
+
let bucket: 0 | 1 = 0;
|
|
282
|
+
let i = bodyStart + 1;
|
|
283
|
+
while (i < raw.length) {
|
|
284
|
+
const c = raw[i]!;
|
|
285
|
+
if (c === "\\" && raw[i + 1] === delim) {
|
|
286
|
+
parts[bucket] += delim;
|
|
287
|
+
i += 2;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (c === delim) {
|
|
291
|
+
if (bucket === 0) {
|
|
292
|
+
bucket = 1;
|
|
293
|
+
i += 1;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
i += 1;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
parts[bucket] += c;
|
|
300
|
+
i += 1;
|
|
301
|
+
}
|
|
302
|
+
if (bucket !== 1) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Edit ${editIndex}: malformed sed expression ${JSON.stringify(raw)}. Expected three ${JSON.stringify(delim)} separators.`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
const flagsStr = raw.slice(i);
|
|
308
|
+
let global = false;
|
|
309
|
+
let ignoreCase = false;
|
|
310
|
+
let literal = false;
|
|
311
|
+
for (const f of flagsStr) {
|
|
312
|
+
if (f === "g") global = true;
|
|
313
|
+
else if (f === "i") ignoreCase = true;
|
|
314
|
+
else if (f === "F") literal = true;
|
|
315
|
+
else {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`Edit ${editIndex}: unknown sed flag ${JSON.stringify(f)}. Supported flags: g (all), i (case-insensitive), F (literal).`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (parts[0] === "") {
|
|
322
|
+
throw new Error(`Edit ${editIndex}: sed expression has empty pattern.`);
|
|
323
|
+
}
|
|
324
|
+
return { pattern: parts[0], replacement: parts[1], global, ignoreCase, literal };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function applyLiteralSed(currentLine: string, spec: SedSpec): { result: string; matched: boolean } {
|
|
328
|
+
const idx = currentLine.indexOf(spec.pattern);
|
|
329
|
+
if (idx === -1) return { result: currentLine, matched: false };
|
|
330
|
+
if (spec.global) {
|
|
331
|
+
return { result: currentLine.split(spec.pattern).join(spec.replacement), matched: true };
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
result: currentLine.slice(0, idx) + spec.replacement + currentLine.slice(idx + spec.pattern.length),
|
|
335
|
+
matched: true,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function applySedToLine(
|
|
340
|
+
currentLine: string,
|
|
341
|
+
spec: SedSpec,
|
|
342
|
+
): { result: string; matched: boolean; error?: string; literalFallback?: boolean } {
|
|
343
|
+
if (spec.literal) {
|
|
344
|
+
return applyLiteralSed(currentLine, spec);
|
|
345
|
+
}
|
|
346
|
+
let flags = "";
|
|
347
|
+
if (spec.global) flags += "g";
|
|
348
|
+
if (spec.ignoreCase) flags += "i";
|
|
349
|
+
let re: RegExp | undefined;
|
|
350
|
+
let compileError: string | undefined;
|
|
351
|
+
try {
|
|
352
|
+
re = new RegExp(spec.pattern, flags);
|
|
353
|
+
} catch (e) {
|
|
354
|
+
compileError = (e as Error).message;
|
|
355
|
+
}
|
|
356
|
+
if (re?.test(currentLine)) {
|
|
357
|
+
re.lastIndex = 0;
|
|
358
|
+
return { result: currentLine.replace(re, spec.replacement), matched: true };
|
|
359
|
+
}
|
|
360
|
+
// Fall back to literal substring match. Models frequently send sed patterns
|
|
361
|
+
// containing unescaped regex metacharacters (parentheses, `?`, `.`) that they
|
|
362
|
+
// intend as literal code. Trying a literal match before reporting failure
|
|
363
|
+
// recovers the obvious intent without changing semantics for patterns that
|
|
364
|
+
// already match as regex.
|
|
365
|
+
const literal = applyLiteralSed(currentLine, spec);
|
|
366
|
+
if (literal.matched) {
|
|
367
|
+
return { ...literal, literalFallback: true };
|
|
368
|
+
}
|
|
369
|
+
if (compileError !== undefined) {
|
|
370
|
+
return { result: currentLine, matched: false, error: compileError };
|
|
371
|
+
}
|
|
372
|
+
return { result: currentLine, matched: false };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function classifyAtomEdit(edit: AtomToolEdit): string {
|
|
376
|
+
const entry = stripNullAtomFields(edit);
|
|
377
|
+
const verbs = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
|
|
378
|
+
return verbs.length > 0 ? verbs.join("+") : "unknown";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit[] {
|
|
382
|
+
const entry = stripNullAtomFields(edit);
|
|
383
|
+
const verbKeysPresent = ATOM_VERB_KEYS.filter(k => entry[k] !== undefined);
|
|
384
|
+
if (verbKeysPresent.length === 0) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Edit ${editIndex}: missing verb. Each entry must include at least one of: ${ATOM_VERB_KEYS.join(", ")}.`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (typeof entry.loc !== "string") {
|
|
390
|
+
throw new Error(`Edit ${editIndex}: missing loc. Use a selector like "160sr", "^", or "$".`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const loc = parseLoc(entry.loc, editIndex);
|
|
394
|
+
const resolved: AtomEdit[] = [];
|
|
395
|
+
|
|
396
|
+
if (loc.kind === "bof") {
|
|
397
|
+
if (entry.set !== undefined || entry.post !== undefined || entry.sed !== undefined) {
|
|
398
|
+
throw new Error(`Edit ${editIndex}: loc "^" only supports pre.`);
|
|
399
|
+
}
|
|
400
|
+
if (entry.pre !== undefined) {
|
|
401
|
+
resolved.push({ op: "prepend_file", lines: hashlineParseText(entry.pre) });
|
|
402
|
+
}
|
|
403
|
+
return resolved;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (loc.kind === "eof") {
|
|
407
|
+
if (entry.set !== undefined || entry.pre !== undefined || entry.sed !== undefined) {
|
|
408
|
+
throw new Error(`Edit ${editIndex}: loc "$" only supports post.`);
|
|
409
|
+
}
|
|
410
|
+
if (entry.post !== undefined) {
|
|
411
|
+
resolved.push({ op: "append_file", lines: hashlineParseText(entry.post) });
|
|
412
|
+
}
|
|
413
|
+
return resolved;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (entry.pre !== undefined) {
|
|
417
|
+
resolved.push({ op: "pre", pos: loc.pos, lines: hashlineParseText(entry.pre) });
|
|
418
|
+
}
|
|
419
|
+
if (entry.set !== undefined) {
|
|
420
|
+
if (Array.isArray(entry.set) && entry.set.length === 0) {
|
|
421
|
+
// Models often default `set: []` alongside other verbs (notably `sed`).
|
|
422
|
+
// Treating that combination as an explicit `del` produces a confusing
|
|
423
|
+
// `Conflicting ops` error. When another mutating verb is present, drop
|
|
424
|
+
// the empty `set` instead of treating it as a deletion.
|
|
425
|
+
if (entry.sed === undefined) {
|
|
426
|
+
resolved.push({ op: "del", pos: loc.pos });
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
resolved.push({ op: "set", pos: loc.pos, lines: hashlineParseText(entry.set) });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (entry.post !== undefined) {
|
|
433
|
+
resolved.push({ op: "post", pos: loc.pos, lines: hashlineParseText(entry.post) });
|
|
434
|
+
}
|
|
435
|
+
if (entry.sed !== undefined) {
|
|
436
|
+
const setIsExplicitReplacement = Array.isArray(entry.set) && entry.set.length > 0;
|
|
437
|
+
// Models often duplicate intent by sending both an explicit `set` and a
|
|
438
|
+
// matching `sed`. The explicit replacement wins; the redundant `sed` would
|
|
439
|
+
// otherwise trigger a confusing `Conflicting ops` rejection.
|
|
440
|
+
if (!setIsExplicitReplacement) {
|
|
441
|
+
const spec = parseSedExpression(entry.sed, editIndex);
|
|
442
|
+
resolved.push({ op: "sed", pos: loc.pos, spec, expression: entry.sed });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return resolved;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
449
|
+
// Validation
|
|
450
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
451
|
+
|
|
452
|
+
function* getAtomAnchors(edit: AtomEdit): Iterable<Anchor> {
|
|
453
|
+
switch (edit.op) {
|
|
454
|
+
case "set":
|
|
455
|
+
case "pre":
|
|
456
|
+
case "post":
|
|
457
|
+
case "del":
|
|
458
|
+
case "sed":
|
|
459
|
+
yield edit.pos;
|
|
460
|
+
return;
|
|
461
|
+
default:
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Search for a line near `anchor.line` whose trimmed content equals the
|
|
468
|
+
* anchor's content hint. Returns the closest match (preferring lines below the
|
|
469
|
+
* requested anchor on ties) or `null` when no line matches. Strict equality on
|
|
470
|
+
* trimmed content keeps this conservative \u2014 we only retarget when there is no
|
|
471
|
+
* ambiguity about the model's intent.
|
|
472
|
+
*/
|
|
473
|
+
function findLineByContentHint(anchor: Anchor, fileLines: string[]): number | null {
|
|
474
|
+
const hint = anchor.contentHint?.trim();
|
|
475
|
+
if (!hint) return null;
|
|
476
|
+
const lo = Math.max(1, anchor.line - ANCHOR_REBASE_WINDOW);
|
|
477
|
+
const hi = Math.min(fileLines.length, anchor.line + ANCHOR_REBASE_WINDOW);
|
|
478
|
+
let best: { line: number; distance: number } | null = null;
|
|
479
|
+
for (let line = lo; line <= hi; line++) {
|
|
480
|
+
if (fileLines[line - 1].trim() !== hint) continue;
|
|
481
|
+
const distance = Math.abs(line - anchor.line);
|
|
482
|
+
if (best === null || distance < best.distance) {
|
|
483
|
+
best = { line, distance };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return best?.line ?? null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function validateAtomAnchors(edits: AtomEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
|
|
490
|
+
const mismatches: HashMismatch[] = [];
|
|
491
|
+
for (const edit of edits) {
|
|
492
|
+
for (const anchor of getAtomAnchors(edit)) {
|
|
493
|
+
if (anchor.line < 1 || anchor.line > fileLines.length) {
|
|
494
|
+
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
|
|
495
|
+
}
|
|
496
|
+
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
|
|
497
|
+
if (actualHash === anchor.hash) continue;
|
|
498
|
+
// When the model supplied a content hint after the anchor (e.g.
|
|
499
|
+
// `82zu| for (...)`), prefer rebasing to the line that actually matches
|
|
500
|
+
// that content. This avoids false positives from hash-only rebasing where
|
|
501
|
+
// a coincidentally matching hash on a nearby line silently retargets the
|
|
502
|
+
// edit to the wrong line.
|
|
503
|
+
const hinted = findLineByContentHint(anchor, fileLines);
|
|
504
|
+
if (hinted !== null) {
|
|
505
|
+
const original = `${anchor.line}${anchor.hash}`;
|
|
506
|
+
const hintedHash = computeLineHash(hinted, fileLines[hinted - 1]);
|
|
507
|
+
anchor.line = hinted;
|
|
508
|
+
anchor.hash = hintedHash;
|
|
509
|
+
warnings.push(
|
|
510
|
+
`Auto-rebased anchor ${original} → ${hinted}${hintedHash} (matched the content hint provided after the anchor).`,
|
|
511
|
+
);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const rebased = tryRebaseAnchor(anchor, fileLines);
|
|
515
|
+
if (rebased !== null) {
|
|
516
|
+
const original = `${anchor.line}${anchor.hash}`;
|
|
517
|
+
anchor.line = rebased;
|
|
518
|
+
warnings.push(
|
|
519
|
+
`Auto-rebased anchor ${original} → ${rebased}${anchor.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
|
|
520
|
+
);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return mismatches;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
|
|
530
|
+
// For each anchor line, at most one mutating op (set/del).
|
|
531
|
+
// `pre`/`post` (insert ops) may coexist with them — they don't mutate the anchor line.
|
|
532
|
+
const mutatingPerLine = new Map<number, string>();
|
|
533
|
+
for (const edit of edits) {
|
|
534
|
+
if (edit.op !== "set" && edit.op !== "del" && edit.op !== "sed") continue;
|
|
535
|
+
const existing = mutatingPerLine.get(edit.pos.line);
|
|
536
|
+
if (existing) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
|
|
539
|
+
`At most one of set/del/sed is allowed per anchor.`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
mutatingPerLine.set(edit.pos.line, edit.op);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
547
|
+
// Apply
|
|
548
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
549
|
+
|
|
550
|
+
function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: string[]): void {
|
|
551
|
+
const enabled = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "0";
|
|
552
|
+
if (!enabled) return;
|
|
553
|
+
for (const edit of edits) {
|
|
554
|
+
if (edit.op !== "set" && edit.op !== "pre" && edit.op !== "post") continue;
|
|
555
|
+
if (edit.lines.length === 0) continue;
|
|
556
|
+
const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
|
|
557
|
+
if (!hasEscapedTabs) continue;
|
|
558
|
+
const hasRealTabs = edit.lines.some(line => line.includes("\t"));
|
|
559
|
+
if (hasRealTabs) continue;
|
|
560
|
+
let correctedCount = 0;
|
|
561
|
+
const corrected = edit.lines.map(line =>
|
|
562
|
+
line.replace(/^((?:\\t)+)/, escaped => {
|
|
563
|
+
correctedCount += escaped.length / 2;
|
|
564
|
+
return "\t".repeat(escaped.length / 2);
|
|
565
|
+
}),
|
|
566
|
+
);
|
|
567
|
+
if (correctedCount === 0) continue;
|
|
568
|
+
edit.lines = corrected;
|
|
569
|
+
warnings.push(
|
|
570
|
+
`Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export interface AtomNoopEdit {
|
|
576
|
+
editIndex: number;
|
|
577
|
+
loc: string;
|
|
578
|
+
reason: string;
|
|
579
|
+
current: string;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function applyAtomEdits(
|
|
583
|
+
text: string,
|
|
584
|
+
edits: AtomEdit[],
|
|
585
|
+
): {
|
|
586
|
+
lines: string;
|
|
587
|
+
firstChangedLine: number | undefined;
|
|
588
|
+
warnings?: string[];
|
|
589
|
+
noopEdits?: AtomNoopEdit[];
|
|
590
|
+
} {
|
|
591
|
+
if (edits.length === 0) {
|
|
592
|
+
return { lines: text, firstChangedLine: undefined };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const fileLines = text.split("\n");
|
|
596
|
+
const warnings: string[] = [];
|
|
597
|
+
let firstChangedLine: number | undefined;
|
|
598
|
+
const noopEdits: AtomNoopEdit[] = [];
|
|
599
|
+
|
|
600
|
+
const mismatches = validateAtomAnchors(edits, fileLines, warnings);
|
|
601
|
+
if (mismatches.length > 0) {
|
|
602
|
+
throw new HashlineMismatchError(mismatches, fileLines);
|
|
603
|
+
}
|
|
604
|
+
validateNoConflictingAnchorOps(edits);
|
|
605
|
+
maybeAutocorrectEscapedTabIndentation(edits, warnings);
|
|
606
|
+
|
|
607
|
+
const trackFirstChanged = (line: number) => {
|
|
608
|
+
if (firstChangedLine === undefined || line < firstChangedLine) {
|
|
609
|
+
firstChangedLine = line;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
// Partition: anchor-scoped vs file-scoped. Preserve original order via the
|
|
614
|
+
// captured idx so multiple pre/post on the same target are emitted in the order
|
|
615
|
+
// the model produced them.
|
|
616
|
+
type Indexed<T> = { edit: T; idx: number };
|
|
617
|
+
type AnchorEdit = Exclude<AtomEdit, { op: "append_file" } | { op: "prepend_file" }>;
|
|
618
|
+
const anchorEdits: Indexed<AnchorEdit>[] = [];
|
|
619
|
+
const appendEdits: Indexed<Extract<AtomEdit, { op: "append_file" }>>[] = [];
|
|
620
|
+
const prependEdits: Indexed<Extract<AtomEdit, { op: "prepend_file" }>>[] = [];
|
|
621
|
+
edits.forEach((edit, idx) => {
|
|
622
|
+
if (edit.op === "append_file") appendEdits.push({ edit, idx });
|
|
623
|
+
else if (edit.op === "prepend_file") prependEdits.push({ edit, idx });
|
|
624
|
+
else anchorEdits.push({ edit, idx });
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Group anchor edits by line so all ops on the same line are applied as a
|
|
628
|
+
// single splice. This makes the per-anchor outcome independent of index
|
|
629
|
+
// shifts caused by sibling ops (e.g. `post` paired with `del` on the same
|
|
630
|
+
// anchor, or repeated `pre`/`post` inserts that previously reversed).
|
|
631
|
+
const byLine = new Map<number, Indexed<AnchorEdit>[]>();
|
|
632
|
+
for (const entry of anchorEdits) {
|
|
633
|
+
const line = entry.edit.pos.line;
|
|
634
|
+
let bucket = byLine.get(line);
|
|
635
|
+
if (!bucket) {
|
|
636
|
+
bucket = [];
|
|
637
|
+
byLine.set(line, bucket);
|
|
638
|
+
}
|
|
639
|
+
bucket.push(entry);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const anchorLines = [...byLine.keys()].sort((a, b) => b - a);
|
|
643
|
+
for (const line of anchorLines) {
|
|
644
|
+
const bucket = byLine.get(line);
|
|
645
|
+
if (!bucket) continue;
|
|
646
|
+
bucket.sort((a, b) => a.idx - b.idx);
|
|
647
|
+
|
|
648
|
+
const idx = line - 1;
|
|
649
|
+
const currentLine = fileLines[idx];
|
|
650
|
+
let replacement: string[] = [currentLine];
|
|
651
|
+
let replacementSet = false;
|
|
652
|
+
let anchorMutated = false;
|
|
653
|
+
let anchorDeleted = false;
|
|
654
|
+
const beforeLines: string[] = [];
|
|
655
|
+
const afterLines: string[] = [];
|
|
656
|
+
|
|
657
|
+
for (const { edit } of bucket) {
|
|
658
|
+
switch (edit.op) {
|
|
659
|
+
case "pre":
|
|
660
|
+
beforeLines.push(...edit.lines);
|
|
661
|
+
break;
|
|
662
|
+
case "post":
|
|
663
|
+
afterLines.push(...edit.lines);
|
|
664
|
+
break;
|
|
665
|
+
case "del":
|
|
666
|
+
replacement = [];
|
|
667
|
+
replacementSet = true;
|
|
668
|
+
anchorDeleted = true;
|
|
669
|
+
break;
|
|
670
|
+
case "set":
|
|
671
|
+
replacement = edit.lines.length === 0 ? [""] : [...edit.lines];
|
|
672
|
+
replacementSet = true;
|
|
673
|
+
anchorMutated = true;
|
|
674
|
+
break;
|
|
675
|
+
case "sed": {
|
|
676
|
+
const { result, matched, error, literalFallback } = applySedToLine(currentLine, edit.spec);
|
|
677
|
+
if (error) {
|
|
678
|
+
throw new Error(`Edit sed expression ${JSON.stringify(edit.expression)} failed to compile: ${error}`);
|
|
679
|
+
}
|
|
680
|
+
if (!matched) {
|
|
681
|
+
throw new Error(
|
|
682
|
+
`Edit sed expression ${JSON.stringify(edit.expression)} did not match line ${edit.pos.line}: ${JSON.stringify(currentLine)}`,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
if (literalFallback) {
|
|
686
|
+
warnings.push(
|
|
687
|
+
`sed expression ${JSON.stringify(edit.expression)} did not match as a regex on line ${edit.pos.line}; applied literal substring substitution instead. Use the \`F\` flag (e.g. \`s/.../.../F\`) for literal patterns or escape regex metacharacters.`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
replacement = [result];
|
|
691
|
+
replacementSet = true;
|
|
692
|
+
anchorMutated = true;
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const noOp = !replacementSet && beforeLines.length === 0 && afterLines.length === 0;
|
|
699
|
+
if (noOp) continue;
|
|
700
|
+
|
|
701
|
+
const originalLine = fileLines[idx];
|
|
702
|
+
const replacementProducesNoChange =
|
|
703
|
+
beforeLines.length === 0 &&
|
|
704
|
+
afterLines.length === 0 &&
|
|
705
|
+
replacement.length === 1 &&
|
|
706
|
+
replacement[0] === originalLine;
|
|
707
|
+
if (replacementProducesNoChange) {
|
|
708
|
+
const firstEdit = bucket[0]?.edit;
|
|
709
|
+
const loc = firstEdit ? `${firstEdit.pos.line}${firstEdit.pos.hash}` : `${line}`;
|
|
710
|
+
const reason = "replacement is identical to the current line content";
|
|
711
|
+
noopEdits.push({
|
|
712
|
+
editIndex: bucket[0]?.idx ?? 0,
|
|
713
|
+
loc,
|
|
714
|
+
reason,
|
|
715
|
+
current: originalLine,
|
|
716
|
+
});
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const combined = [...beforeLines, ...replacement, ...afterLines];
|
|
721
|
+
fileLines.splice(idx, 1, ...combined);
|
|
722
|
+
|
|
723
|
+
if (beforeLines.length > 0 || anchorMutated || anchorDeleted) {
|
|
724
|
+
trackFirstChanged(line);
|
|
725
|
+
} else if (afterLines.length > 0) {
|
|
726
|
+
trackFirstChanged(line + 1);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Apply prepend_file ops in original order so the first one ends up at the
|
|
731
|
+
// very top of the file.
|
|
732
|
+
prependEdits.sort((a, b) => a.idx - b.idx);
|
|
733
|
+
for (const { edit } of prependEdits) {
|
|
734
|
+
if (edit.lines.length === 0) continue;
|
|
735
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
736
|
+
fileLines.splice(0, 1, ...edit.lines);
|
|
737
|
+
} else {
|
|
738
|
+
// Insert in reverse cumulative order so later splices push earlier
|
|
739
|
+
// content further down, preserving the original op order.
|
|
740
|
+
fileLines.splice(0, 0, ...edit.lines);
|
|
741
|
+
}
|
|
742
|
+
trackFirstChanged(1);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Apply append_file ops in original order. When the file ends with a
|
|
746
|
+
// trailing newline (last split element is the empty sentinel), insert
|
|
747
|
+
// before that sentinel so the trailing newline is preserved.
|
|
748
|
+
appendEdits.sort((a, b) => a.idx - b.idx);
|
|
749
|
+
for (const { edit } of appendEdits) {
|
|
750
|
+
if (edit.lines.length === 0) continue;
|
|
751
|
+
if (fileLines.length === 1 && fileLines[0] === "") {
|
|
752
|
+
fileLines.splice(0, 1, ...edit.lines);
|
|
753
|
+
trackFirstChanged(1);
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
const hasTrailingNewline = fileLines.length > 0 && fileLines[fileLines.length - 1] === "";
|
|
757
|
+
const insertIdx = hasTrailingNewline ? fileLines.length - 1 : fileLines.length;
|
|
758
|
+
fileLines.splice(insertIdx, 0, ...edit.lines);
|
|
759
|
+
trackFirstChanged(insertIdx + 1);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return {
|
|
763
|
+
lines: fileLines.join("\n"),
|
|
764
|
+
firstChangedLine,
|
|
765
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
766
|
+
...(noopEdits.length > 0 ? { noopEdits } : {}),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
771
|
+
// Executor
|
|
772
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
773
|
+
|
|
774
|
+
export interface ExecuteAtomSingleOptions {
|
|
775
|
+
session: ToolSession;
|
|
776
|
+
path: string;
|
|
777
|
+
edits: AtomToolEdit[];
|
|
778
|
+
signal?: AbortSignal;
|
|
779
|
+
batchRequest?: LspBatchRequest;
|
|
780
|
+
writethrough: WritethroughCallback;
|
|
781
|
+
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export async function executeAtomSingle(
|
|
785
|
+
options: ExecuteAtomSingleOptions,
|
|
786
|
+
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
|
|
787
|
+
const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
|
788
|
+
|
|
789
|
+
const contentEdits = edits.flatMap((edit, i) => resolveAtomToolEdit(edit, i));
|
|
790
|
+
|
|
791
|
+
enforcePlanModeWrite(session, path, { op: "update" });
|
|
792
|
+
|
|
793
|
+
if (path.endsWith(".ipynb") && contentEdits.length > 0) {
|
|
794
|
+
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const absolutePath = resolvePlanPath(session, path);
|
|
798
|
+
|
|
799
|
+
const sourceFile = Bun.file(absolutePath);
|
|
800
|
+
const sourceExists = await sourceFile.exists();
|
|
801
|
+
|
|
802
|
+
if (!sourceExists) {
|
|
803
|
+
const lines: string[] = [];
|
|
804
|
+
for (const edit of contentEdits) {
|
|
805
|
+
if (edit.op === "append_file") {
|
|
806
|
+
lines.push(...edit.lines);
|
|
807
|
+
} else if (edit.op === "prepend_file") {
|
|
808
|
+
lines.unshift(...edit.lines);
|
|
809
|
+
} else {
|
|
810
|
+
throw new Error(`File not found: ${path}`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
await Bun.write(absolutePath, lines.join("\n"));
|
|
815
|
+
invalidateFsScanAfterWrite(absolutePath);
|
|
816
|
+
return {
|
|
817
|
+
content: [{ type: "text", text: `Created ${path}` }],
|
|
818
|
+
details: {
|
|
819
|
+
diff: "",
|
|
820
|
+
op: "create",
|
|
821
|
+
meta: outputMeta().get(),
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const rawContent = await sourceFile.text();
|
|
827
|
+
assertEditableFileContent(rawContent, path);
|
|
828
|
+
|
|
829
|
+
const { bom, text } = stripBom(rawContent);
|
|
830
|
+
const originalEnding = detectLineEnding(text);
|
|
831
|
+
const originalNormalized = normalizeToLF(text);
|
|
832
|
+
|
|
833
|
+
const result = applyAtomEdits(originalNormalized, contentEdits);
|
|
834
|
+
if (originalNormalized === result.lines) {
|
|
835
|
+
let diagnostic = `Edits to ${path} resulted in no changes being made.`;
|
|
836
|
+
if (result.noopEdits && result.noopEdits.length > 0) {
|
|
837
|
+
const details = result.noopEdits
|
|
838
|
+
.map(e => {
|
|
839
|
+
const preview =
|
|
840
|
+
e.current.length > 0
|
|
841
|
+
? `\n current: ${JSON.stringify(e.current.length > 200 ? `${e.current.slice(0, 200)}…` : e.current)}`
|
|
842
|
+
: "";
|
|
843
|
+
return `Edit ${e.editIndex} (${e.loc}): ${e.reason}.${preview}`;
|
|
844
|
+
})
|
|
845
|
+
.join("\n");
|
|
846
|
+
diagnostic += `\n${details}`;
|
|
847
|
+
}
|
|
848
|
+
throw new Error(diagnostic);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
|
|
852
|
+
const diagnostics = await writethrough(
|
|
853
|
+
absolutePath,
|
|
854
|
+
finalContent,
|
|
855
|
+
signal,
|
|
856
|
+
Bun.file(absolutePath),
|
|
857
|
+
batchRequest,
|
|
858
|
+
dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
|
|
859
|
+
);
|
|
860
|
+
invalidateFsScanAfterWrite(absolutePath);
|
|
861
|
+
|
|
862
|
+
const diffResult = generateDiffString(originalNormalized, result.lines);
|
|
863
|
+
const meta = outputMeta()
|
|
864
|
+
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
865
|
+
.get();
|
|
866
|
+
|
|
867
|
+
const resultText = `Updated ${path}`;
|
|
868
|
+
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
|
869
|
+
const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${
|
|
870
|
+
preview.preview ? "" : " (no textual diff preview)"
|
|
871
|
+
}`;
|
|
872
|
+
const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
|
873
|
+
const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : "";
|
|
874
|
+
|
|
875
|
+
return {
|
|
876
|
+
content: [
|
|
877
|
+
{
|
|
878
|
+
type: "text",
|
|
879
|
+
text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
|
|
880
|
+
},
|
|
881
|
+
],
|
|
882
|
+
details: {
|
|
883
|
+
diff: diffResult.diff,
|
|
884
|
+
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
|
885
|
+
diagnostics,
|
|
886
|
+
op: "update",
|
|
887
|
+
meta,
|
|
888
|
+
},
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Helpers exposed for tests / external dispatch.
|
|
893
|
+
export { classifyAtomEdit, parseAnchor, resolveAtomToolEdit };
|