@oh-my-pi/pi-coding-agent 14.3.0 → 14.4.0
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 +84 -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 +735 -19
- package/src/edit/modes/apply-patch.ts +0 -9
- package/src/edit/modes/atom.ts +658 -0
- package/src/edit/modes/chunk.ts +14 -24
- package/src/edit/modes/hashline.ts +188 -136
- 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/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/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 +15 -19
- package/src/prompts/tools/ast-grep.md +18 -24
- package/src/prompts/tools/atom.md +96 -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/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 +115 -130
- 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 +9 -8
- 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 -21
- 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
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
* Hashline edit mode — a line-addressable edit format using text hashes.
|
|
3
3
|
*
|
|
4
4
|
* Each line in a file is identified by its 1-indexed line number and a short
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* The combined `LINE
|
|
5
|
+
* BPE-bigram hash derived from the normalized line text (xxHash32 mod 647,
|
|
6
|
+
* mapped through HASHLINE_BIGRAMS).
|
|
7
|
+
* The combined `LINE+ID` reference acts as both an address and a staleness check:
|
|
8
8
|
* if the file has changed since the caller last read it, hash mismatches are caught
|
|
9
9
|
* before any mutation occurs.
|
|
10
10
|
*
|
|
11
|
-
* Displayed format: `
|
|
12
|
-
* Reference format: `"
|
|
11
|
+
* Displayed format: `LINE+ID:TEXT`
|
|
12
|
+
* Reference format: `"LINE+ID"` (e.g. `"1ab"`)
|
|
13
|
+
*
|
|
14
|
+
* In tool JSON, each edit's `content` is `string[]` (one string per logical line) or
|
|
15
|
+
* `null` to delete the targeted range.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
|
-
import * as fs from "node:fs/promises";
|
|
16
|
-
import * as nodePath from "node:path";
|
|
17
18
|
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
18
19
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
19
20
|
import { type Static, Type } from "@sinclair/typebox";
|
|
@@ -21,16 +22,13 @@ import type { BunFile } from "bun";
|
|
|
21
22
|
import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
|
22
23
|
import type { ToolSession } from "../../tools";
|
|
23
24
|
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
24
|
-
import {
|
|
25
|
-
invalidateFsScanAfterDelete,
|
|
26
|
-
invalidateFsScanAfterRename,
|
|
27
|
-
invalidateFsScanAfterWrite,
|
|
28
|
-
} from "../../tools/fs-cache-invalidation";
|
|
25
|
+
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
|
29
26
|
import { outputMeta } from "../../tools/output-meta";
|
|
30
27
|
import { resolveToCwd } from "../../tools/path-utils";
|
|
31
28
|
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
|
|
29
|
+
import { formatCodeFrameLine } from "../../tools/render-utils";
|
|
32
30
|
import { generateDiffString } from "../diff";
|
|
33
|
-
import { computeLineHash, formatLineHash } from "../line-hash";
|
|
31
|
+
import { computeLineHash, formatLineHash, HASHLINE_BIGRAM_RE_SRC, HASHLINE_CONTENT_SEPARATOR } from "../line-hash";
|
|
34
32
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
|
|
35
33
|
import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
36
34
|
|
|
@@ -49,8 +47,16 @@ export type HashlineEdit =
|
|
|
49
47
|
| { op: "append_file"; lines: string[] }
|
|
50
48
|
| { op: "prepend_file"; lines: string[] };
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
// Tight prefix matchers for the new format `LINE+ID:content`. Hard
|
|
51
|
+
// cutover — do not accept legacy `LINENUM#BIGRAM:content` or tab separators.
|
|
52
|
+
// The terminator must be a literal colon; line-number digits are mandatory.
|
|
53
|
+
const HASHLINE_CONTENT_SEPARATOR_RE = HASHLINE_CONTENT_SEPARATOR.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
54
|
+
const HASHLINE_PREFIX_RE = new RegExp(
|
|
55
|
+
`^\\s*(?:>>>|>>)?\\s*(?:\\+\\s*)?\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
|
|
56
|
+
);
|
|
57
|
+
const HASHLINE_PREFIX_PLUS_RE = new RegExp(
|
|
58
|
+
`^\\s*(?:>>>|>>)?\\s*\\+\\s*\\d+${HASHLINE_BIGRAM_RE_SRC}${HASHLINE_CONTENT_SEPARATOR_RE}`,
|
|
59
|
+
);
|
|
54
60
|
const DIFF_PLUS_RE = /^[+](?![+])/;
|
|
55
61
|
const READ_TRUNCATION_NOTICE_RE = /^\[(?:Showing lines \d+-\d+ of \d+|\d+ more lines? in (?:file|\S+))\b.*\bsel=L\d+/;
|
|
56
62
|
|
|
@@ -129,11 +135,7 @@ export function stripHashlinePrefixes(lines: string[]): string[] {
|
|
|
129
135
|
return lines.filter(line => !READ_TRUNCATION_NOTICE_RE.test(line)).map(line => stripLeadingHashlinePrefixes(line));
|
|
130
136
|
}
|
|
131
137
|
|
|
132
|
-
const linesSchema = Type.Union([
|
|
133
|
-
Type.Array(Type.String(), { description: "content (preferred format)" }),
|
|
134
|
-
Type.String(),
|
|
135
|
-
Type.Null(),
|
|
136
|
-
]);
|
|
138
|
+
const linesSchema = Type.Union([Type.Array(Type.String()), Type.Null()]);
|
|
137
139
|
|
|
138
140
|
const locSchema = Type.Union(
|
|
139
141
|
[
|
|
@@ -153,17 +155,16 @@ const locSchema = Type.Union(
|
|
|
153
155
|
|
|
154
156
|
export const hashlineEditSchema = Type.Object(
|
|
155
157
|
{
|
|
156
|
-
path: Type.String({ description: "File path" }),
|
|
158
|
+
path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
|
|
157
159
|
loc: Type.Optional(locSchema),
|
|
158
160
|
content: Type.Optional(linesSchema),
|
|
159
|
-
delete: Type.Optional(Type.Boolean({ description: "Delete the file" })),
|
|
160
|
-
move: Type.Optional(Type.String({ description: "Move/rename the file to this path" })),
|
|
161
161
|
},
|
|
162
162
|
{ additionalProperties: false },
|
|
163
163
|
);
|
|
164
164
|
|
|
165
165
|
export const hashlineEditParamsSchema = Type.Object(
|
|
166
166
|
{
|
|
167
|
+
path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
|
|
167
168
|
edits: Type.Array(hashlineEditSchema, { description: "edits" }),
|
|
168
169
|
},
|
|
169
170
|
{ additionalProperties: false },
|
|
@@ -182,6 +183,11 @@ export interface ExecuteHashlineSingleOptions {
|
|
|
182
183
|
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Normalize line payloads for apply: strip read/grep line prefixes. The tool schema
|
|
188
|
+
* supplies `string[]` (one element per line). `null` / `undefined` yield `[]`.
|
|
189
|
+
* A single multiline `string` is still split on `\n` for the same normalization path.
|
|
190
|
+
*/
|
|
185
191
|
export function hashlineParseText(edit: string[] | string | null | undefined): string[] {
|
|
186
192
|
if (edit == null) return [];
|
|
187
193
|
if (typeof edit === "string") {
|
|
@@ -191,15 +197,6 @@ export function hashlineParseText(edit: string[] | string | null | undefined): s
|
|
|
191
197
|
return stripNewLinePrefixes(edit);
|
|
192
198
|
}
|
|
193
199
|
|
|
194
|
-
export function isHashlineParams(params: unknown): params is HashlineParams {
|
|
195
|
-
if (typeof params !== "object" || params === null || !("edits" in params) || !Array.isArray(params.edits))
|
|
196
|
-
return false;
|
|
197
|
-
if (params.edits.length === 0) return true;
|
|
198
|
-
const first = params.edits[0];
|
|
199
|
-
if (typeof first !== "object" || first === null) return false;
|
|
200
|
-
return "loc" in first || "delete" in first || "move" in first;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
200
|
function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
204
201
|
return edits.map(resolveEditAnchor);
|
|
205
202
|
}
|
|
@@ -224,6 +221,15 @@ function resolveHashlineEditsForDiff(edits: HashlineEditInput[]): HashlineEdit[]
|
|
|
224
221
|
});
|
|
225
222
|
}
|
|
226
223
|
|
|
224
|
+
export function formatFullAnchorRequirement(raw?: string): string {
|
|
225
|
+
const suffix = typeof raw === "string" ? raw.trim() : "";
|
|
226
|
+
const hashOnlyHint = /^[A-Za-z]{2}$/.test(suffix)
|
|
227
|
+
? ` It looks like you supplied only the 2-letter suffix (${JSON.stringify(suffix)}). Copy the full anchor exactly as shown (for example, "160${suffix}").`
|
|
228
|
+
: "";
|
|
229
|
+
const received = raw === undefined ? "" : ` Received ${JSON.stringify(raw)}.`;
|
|
230
|
+
return `the full anchor exactly as shown by read/grep (line number + 2-letter suffix, for example "160sr")${received}${hashOnlyHint}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
227
233
|
function tryParseTag(raw: string): Anchor | undefined {
|
|
228
234
|
try {
|
|
229
235
|
return parseTag(raw);
|
|
@@ -234,14 +240,24 @@ function tryParseTag(raw: string): Anchor | undefined {
|
|
|
234
240
|
|
|
235
241
|
function requireParsedAnchor(raw: string, op: "append" | "prepend"): Anchor {
|
|
236
242
|
const anchor = tryParseTag(raw);
|
|
237
|
-
if (!anchor) throw new Error(`${op} requires
|
|
243
|
+
if (!anchor) throw new Error(`${op} requires ${formatFullAnchorRequirement(raw)}.`);
|
|
238
244
|
return anchor;
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
function requireParsedRange(range: { pos: string; end: string }): { pos: Anchor; end: Anchor } {
|
|
242
248
|
const pos = tryParseTag(range.pos);
|
|
243
249
|
const end = tryParseTag(range.end);
|
|
244
|
-
if (!pos || !end)
|
|
250
|
+
if (!pos || !end) {
|
|
251
|
+
const invalid = [
|
|
252
|
+
!pos ? `pos=${JSON.stringify(range.pos)}` : null,
|
|
253
|
+
!end ? `end=${JSON.stringify(range.end)}` : null,
|
|
254
|
+
]
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
.join(", ");
|
|
257
|
+
throw new Error(
|
|
258
|
+
`range requires valid pos and end anchors. Use ${formatFullAnchorRequirement()}. Invalid: ${invalid}.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
245
261
|
return { pos, end };
|
|
246
262
|
}
|
|
247
263
|
|
|
@@ -358,7 +374,7 @@ function createHashlineChunkEmitter(
|
|
|
358
374
|
}
|
|
359
375
|
|
|
360
376
|
function formatHashlineStreamLine(lineNumber: number, line: string): string {
|
|
361
|
-
return `${formatLineHash(lineNumber, line)}
|
|
377
|
+
return `${formatLineHash(lineNumber, line)}${HASHLINE_CONTENT_SEPARATOR}${line}`;
|
|
362
378
|
}
|
|
363
379
|
|
|
364
380
|
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
|
|
@@ -484,20 +500,18 @@ export async function* streamHashLinesFromLines(
|
|
|
484
500
|
}
|
|
485
501
|
|
|
486
502
|
/**
|
|
487
|
-
* Parse a line reference string like `"
|
|
503
|
+
* Parse a line reference string like `"5th"` into structured form.
|
|
488
504
|
*
|
|
489
|
-
* @throws Error if the format is invalid (not `
|
|
505
|
+
* @throws Error if the format is invalid (not `NUMBERBIGRAM`)
|
|
490
506
|
*/
|
|
491
507
|
export function parseTag(ref: string): { line: number; hash: string } {
|
|
492
|
-
//
|
|
493
|
-
// 1. optional leading "
|
|
508
|
+
// Captures:
|
|
509
|
+
// 1. optional leading ">+-" markers and whitespace
|
|
494
510
|
// 2. line number (1+ digits)
|
|
495
|
-
// 3.
|
|
496
|
-
|
|
497
|
-
// 5. optional trailing display suffix (":..." or " ...")
|
|
498
|
-
const match = ref.match(/^\s*[>+-]*\s*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})/);
|
|
511
|
+
// 3. hash (one BPE bigram from HASHLINE_BIGRAMS) directly adjacent (no separator)
|
|
512
|
+
const match = ref.match(new RegExp(`^\\s*[>+-]*\\s*(\\d+)(${HASHLINE_BIGRAM_RE_SRC})`));
|
|
499
513
|
if (!match) {
|
|
500
|
-
throw new Error(`Invalid line reference
|
|
514
|
+
throw new Error(`Invalid line reference. Expected ${formatFullAnchorRequirement(ref)}.`);
|
|
501
515
|
}
|
|
502
516
|
const line = Number.parseInt(match[1], 10);
|
|
503
517
|
if (line < 1) {
|
|
@@ -516,8 +530,8 @@ const MISMATCH_CONTEXT = 2;
|
|
|
516
530
|
/**
|
|
517
531
|
* Error thrown when one or more hashline references have stale hashes.
|
|
518
532
|
*
|
|
519
|
-
* Displays grep-style output with
|
|
520
|
-
* showing the correct `LINE
|
|
533
|
+
* Displays grep-style output with `:` separator on mismatched lines and `-` on
|
|
534
|
+
* surrounding context, showing the correct `LINE+ID` so the caller can fix all refs at once.
|
|
521
535
|
*/
|
|
522
536
|
export class HashlineMismatchError extends Error {
|
|
523
537
|
readonly remaps: ReadonlyMap<string, string>;
|
|
@@ -530,11 +544,50 @@ export class HashlineMismatchError extends Error {
|
|
|
530
544
|
const remaps = new Map<string, string>();
|
|
531
545
|
for (const m of mismatches) {
|
|
532
546
|
const actual = computeLineHash(m.line, fileLines[m.line - 1]);
|
|
533
|
-
remaps.set(`${m.line}
|
|
547
|
+
remaps.set(`${m.line}${m.expected}`, `${m.line}${actual}`);
|
|
534
548
|
}
|
|
535
549
|
this.remaps = remaps;
|
|
536
550
|
}
|
|
537
551
|
|
|
552
|
+
/**
|
|
553
|
+
* User-visible variant of {@link formatMessage} — omits the bigram fingerprint
|
|
554
|
+
* and uses a `│` gutter so TUI rendering is clean. The model still receives
|
|
555
|
+
* the full `LINE+ID:content` form via {@link Error.message}.
|
|
556
|
+
*/
|
|
557
|
+
get displayMessage(): string {
|
|
558
|
+
return HashlineMismatchError.formatDisplayMessage(this.mismatches, this.fileLines);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
static formatDisplayMessage(mismatches: HashMismatch[], fileLines: string[]): string {
|
|
562
|
+
const mismatchSet = new Set<number>();
|
|
563
|
+
for (const m of mismatches) mismatchSet.add(m.line);
|
|
564
|
+
|
|
565
|
+
const displayLines = new Set<number>();
|
|
566
|
+
for (const m of mismatches) {
|
|
567
|
+
const lo = Math.max(1, m.line - MISMATCH_CONTEXT);
|
|
568
|
+
const hi = Math.min(fileLines.length, m.line + MISMATCH_CONTEXT);
|
|
569
|
+
for (let i = lo; i <= hi; i++) displayLines.add(i);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const sorted = [...displayLines].sort((a, b) => a - b);
|
|
573
|
+
const out: string[] = [
|
|
574
|
+
`Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
|
|
575
|
+
"Realign your edit to the file state shown below. Copy the full anchors exactly as shown (for example `160sr`, not just `sr`).",
|
|
576
|
+
"",
|
|
577
|
+
];
|
|
578
|
+
|
|
579
|
+
const lineNumberWidth = sorted.reduce((width, lineNum) => Math.max(width, String(lineNum).length), 0);
|
|
580
|
+
let prevLine = -1;
|
|
581
|
+
for (const lineNum of sorted) {
|
|
582
|
+
if (prevLine !== -1 && lineNum > prevLine + 1) out.push("...");
|
|
583
|
+
prevLine = lineNum;
|
|
584
|
+
const text = fileLines[lineNum - 1];
|
|
585
|
+
const marker = mismatchSet.has(lineNum) ? "*" : " ";
|
|
586
|
+
out.push(formatCodeFrameLine(marker, lineNum, text ?? "", lineNumberWidth));
|
|
587
|
+
}
|
|
588
|
+
return out.join("\n");
|
|
589
|
+
}
|
|
590
|
+
|
|
538
591
|
static formatMessage(mismatches: HashMismatch[], fileLines: string[]): string {
|
|
539
592
|
const mismatchSet = new Map<number, HashMismatch>();
|
|
540
593
|
for (const m of mismatches) {
|
|
@@ -555,7 +608,8 @@ export class HashlineMismatchError extends Error {
|
|
|
555
608
|
const lines: string[] = [];
|
|
556
609
|
|
|
557
610
|
lines.push(
|
|
558
|
-
`Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied
|
|
611
|
+
`Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read. The edit was NOT applied.`,
|
|
612
|
+
"Use the updated anchors shown below (`:` marks changed lines, `-` marks context) and retry the edit.",
|
|
559
613
|
);
|
|
560
614
|
lines.push("");
|
|
561
615
|
|
|
@@ -563,18 +617,18 @@ export class HashlineMismatchError extends Error {
|
|
|
563
617
|
for (const lineNum of sorted) {
|
|
564
618
|
// Gap separator between non-contiguous regions
|
|
565
619
|
if (prevLine !== -1 && lineNum > prevLine + 1) {
|
|
566
|
-
lines.push("
|
|
620
|
+
lines.push("...");
|
|
567
621
|
}
|
|
568
622
|
prevLine = lineNum;
|
|
569
623
|
|
|
570
624
|
const text = fileLines[lineNum - 1];
|
|
571
625
|
const hash = computeLineHash(lineNum, text);
|
|
572
|
-
const prefix = `${lineNum}
|
|
626
|
+
const prefix = `${lineNum}${hash}`;
|
|
573
627
|
|
|
574
628
|
if (mismatchSet.has(lineNum)) {
|
|
575
|
-
lines.push(
|
|
629
|
+
lines.push(`${prefix}:${text}`);
|
|
576
630
|
} else {
|
|
577
|
-
lines.push(
|
|
631
|
+
lines.push(`${prefix}-${text}`);
|
|
578
632
|
}
|
|
579
633
|
}
|
|
580
634
|
return lines.join("\n");
|
|
@@ -599,6 +653,39 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
|
|
|
599
653
|
}
|
|
600
654
|
}
|
|
601
655
|
|
|
656
|
+
/**
|
|
657
|
+
* Default search window for {@link tryRebaseAnchor} (lines on each side of the requested anchor).
|
|
658
|
+
*/
|
|
659
|
+
export const ANCHOR_REBASE_WINDOW = 2;
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Look for the requested hash within ±`window` lines of `anchor.line`.
|
|
663
|
+
*
|
|
664
|
+
* Returns the new line number when exactly one nearby line matches the hash;
|
|
665
|
+
* otherwise `null` (genuine mismatch or ambiguous). The caller is expected to
|
|
666
|
+
* mutate `anchor.line` in place and surface a warning so the model knows the
|
|
667
|
+
* edit was retargeted.
|
|
668
|
+
*
|
|
669
|
+
* The exact-position match (anchor.line itself) is intentionally skipped: the
|
|
670
|
+
* caller has already determined the requested line's hash does not match.
|
|
671
|
+
*/
|
|
672
|
+
export function tryRebaseAnchor(
|
|
673
|
+
anchor: { line: number; hash: string },
|
|
674
|
+
fileLines: string[],
|
|
675
|
+
window: number = ANCHOR_REBASE_WINDOW,
|
|
676
|
+
): number | null {
|
|
677
|
+
const lo = Math.max(1, anchor.line - window);
|
|
678
|
+
const hi = Math.min(fileLines.length, anchor.line + window);
|
|
679
|
+
let found: number | null = null;
|
|
680
|
+
for (let line = lo; line <= hi; line++) {
|
|
681
|
+
if (line === anchor.line) continue;
|
|
682
|
+
if (computeLineHash(line, fileLines[line - 1]) !== anchor.hash) continue;
|
|
683
|
+
if (found !== null) return null; // ambiguous: more than one match in window
|
|
684
|
+
found = line;
|
|
685
|
+
}
|
|
686
|
+
return found;
|
|
687
|
+
}
|
|
688
|
+
|
|
602
689
|
function isEscapedTabAutocorrectEnabled(): boolean {
|
|
603
690
|
switch (Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS) {
|
|
604
691
|
case "0":
|
|
@@ -754,7 +841,7 @@ function applyHashlineEditToLines(
|
|
|
754
841
|
if (origLines.length === newLines.length && origLines.every((line, i) => line === newLines[i])) {
|
|
755
842
|
noopEdits.push({
|
|
756
843
|
editIndex,
|
|
757
|
-
loc: `${edit.pos.line}
|
|
844
|
+
loc: `${edit.pos.line}${edit.pos.hash}`,
|
|
758
845
|
current: origLines.join("\n"),
|
|
759
846
|
});
|
|
760
847
|
break;
|
|
@@ -765,6 +852,15 @@ function applyHashlineEditToLines(
|
|
|
765
852
|
}
|
|
766
853
|
case "replace_range": {
|
|
767
854
|
const count = edit.end.line - edit.pos.line + 1;
|
|
855
|
+
const origRange = originalFileLines.slice(edit.pos.line - 1, edit.pos.line - 1 + count);
|
|
856
|
+
if (count === edit.lines.length && origRange.every((line, i) => line === edit.lines[i])) {
|
|
857
|
+
noopEdits.push({
|
|
858
|
+
editIndex,
|
|
859
|
+
loc: `${edit.pos.line}${edit.pos.hash}-${edit.end.line}${edit.end.hash}`,
|
|
860
|
+
current: origRange.join("\n"),
|
|
861
|
+
});
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
768
864
|
fileLines.splice(edit.pos.line - 1, count, ...edit.lines);
|
|
769
865
|
trackFirstChanged(edit.pos.line);
|
|
770
866
|
break;
|
|
@@ -774,7 +870,7 @@ function applyHashlineEditToLines(
|
|
|
774
870
|
if (inserted.length === 0) {
|
|
775
871
|
noopEdits.push({
|
|
776
872
|
editIndex,
|
|
777
|
-
loc: `${edit.pos.line}
|
|
873
|
+
loc: `${edit.pos.line}${edit.pos.hash}`,
|
|
778
874
|
current: originalFileLines[edit.pos.line - 1],
|
|
779
875
|
});
|
|
780
876
|
break;
|
|
@@ -788,7 +884,7 @@ function applyHashlineEditToLines(
|
|
|
788
884
|
if (inserted.length === 0) {
|
|
789
885
|
noopEdits.push({
|
|
790
886
|
editIndex,
|
|
791
|
-
loc: `${edit.pos.line}
|
|
887
|
+
loc: `${edit.pos.line}${edit.pos.hash}`,
|
|
792
888
|
current: originalFileLines[edit.pos.line - 1],
|
|
793
889
|
});
|
|
794
890
|
break;
|
|
@@ -849,7 +945,7 @@ function buildHashlineEditResult(params: {
|
|
|
849
945
|
};
|
|
850
946
|
}
|
|
851
947
|
|
|
852
|
-
function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): HashMismatch[] {
|
|
948
|
+
function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[], warnings: string[]): HashMismatch[] {
|
|
853
949
|
const mismatches: HashMismatch[] = [];
|
|
854
950
|
for (const edit of edits) {
|
|
855
951
|
switch (edit.op) {
|
|
@@ -884,6 +980,15 @@ function validateHashlineEditRefs(edits: HashlineEdit[], fileLines: string[]): H
|
|
|
884
980
|
if (actualHash === ref.hash) {
|
|
885
981
|
return;
|
|
886
982
|
}
|
|
983
|
+
const rebased = tryRebaseAnchor(ref, fileLines);
|
|
984
|
+
if (rebased !== null) {
|
|
985
|
+
const original = `${ref.line}${ref.hash}`;
|
|
986
|
+
ref.line = rebased;
|
|
987
|
+
warnings.push(
|
|
988
|
+
`Auto-rebased anchor ${original} → ${rebased}${ref.hash} (line shifted within ±${ANCHOR_REBASE_WINDOW}; hash matched).`,
|
|
989
|
+
);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
887
992
|
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
|
|
888
993
|
}
|
|
889
994
|
}
|
|
@@ -922,7 +1027,7 @@ export function applyHashlineEdits(
|
|
|
922
1027
|
const noopEdits: Array<{ editIndex: number; loc: string; current: string }> = [];
|
|
923
1028
|
const warnings: string[] = [];
|
|
924
1029
|
|
|
925
|
-
const mismatches = validateHashlineEditRefs(edits, fileLines);
|
|
1030
|
+
const mismatches = validateHashlineEditRefs(edits, fileLines, warnings);
|
|
926
1031
|
if (mismatches.length > 0) {
|
|
927
1032
|
throw new HashlineMismatchError(mismatches, fileLines);
|
|
928
1033
|
}
|
|
@@ -1022,14 +1127,12 @@ function syncNewLineCounters(counters: CompactPreviewCounters, lineNumber: numbe
|
|
|
1022
1127
|
counters.newLine = lineNumber;
|
|
1023
1128
|
}
|
|
1024
1129
|
|
|
1025
|
-
function formatCompactHashlineLine(kind: " " | "+", lineNumber: number,
|
|
1026
|
-
|
|
1027
|
-
return `${kind}${padded}#${computeLineHash(lineNumber, content)}|${content}`;
|
|
1130
|
+
function formatCompactHashlineLine(kind: " " | "+", lineNumber: number, content: string): string {
|
|
1131
|
+
return `${kind}${lineNumber}${computeLineHash(lineNumber, content)}${HASHLINE_CONTENT_SEPARATOR}${content}`;
|
|
1028
1132
|
}
|
|
1029
1133
|
|
|
1030
|
-
function formatCompactRemovedLine(lineNumber: number,
|
|
1031
|
-
|
|
1032
|
-
return `-${padded}${HASHLINE_PREVIEW_PLACEHOLDER}|${content}`;
|
|
1134
|
+
function formatCompactRemovedLine(lineNumber: number, content: string): string {
|
|
1135
|
+
return `-${lineNumber}${HASHLINE_PREVIEW_PLACEHOLDER}${HASHLINE_CONTENT_SEPARATOR}${content}`;
|
|
1033
1136
|
}
|
|
1034
1137
|
|
|
1035
1138
|
function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters): { kind: DiffRunKind; text: string } {
|
|
@@ -1050,13 +1153,13 @@ function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters
|
|
|
1050
1153
|
syncNewLineCounters(counters, parsed.lineNumber);
|
|
1051
1154
|
const newLine = counters.newLine;
|
|
1052
1155
|
if (newLine === undefined) return { kind: "+", text: parsed.raw };
|
|
1053
|
-
const text = formatCompactHashlineLine("+", newLine, parsed.
|
|
1156
|
+
const text = formatCompactHashlineLine("+", newLine, parsed.content);
|
|
1054
1157
|
counters.newLine = newLine + 1;
|
|
1055
1158
|
return { kind: "+", text };
|
|
1056
1159
|
}
|
|
1057
1160
|
case "-": {
|
|
1058
1161
|
syncOldLineCounters(counters, parsed.lineNumber);
|
|
1059
|
-
const text = formatCompactRemovedLine(parsed.lineNumber, parsed.
|
|
1162
|
+
const text = formatCompactRemovedLine(parsed.lineNumber, parsed.content);
|
|
1060
1163
|
counters.oldLine = parsed.lineNumber + 1;
|
|
1061
1164
|
return { kind: "-", text };
|
|
1062
1165
|
}
|
|
@@ -1064,7 +1167,7 @@ function formatCompactPreviewLine(line: string, counters: CompactPreviewCounters
|
|
|
1064
1167
|
syncOldLineCounters(counters, parsed.lineNumber);
|
|
1065
1168
|
const newLine = counters.newLine;
|
|
1066
1169
|
if (newLine === undefined) return { kind: " ", text: parsed.raw };
|
|
1067
|
-
const text = formatCompactHashlineLine(" ", newLine, parsed.
|
|
1170
|
+
const text = formatCompactHashlineLine(" ", newLine, parsed.content);
|
|
1068
1171
|
counters.oldLine = parsed.lineNumber + 1;
|
|
1069
1172
|
counters.newLine = newLine + 1;
|
|
1070
1173
|
return { kind: " ", text };
|
|
@@ -1170,7 +1273,7 @@ export function buildCompactHashlineDiffPreview(
|
|
|
1170
1273
|
}
|
|
1171
1274
|
|
|
1172
1275
|
export async function computeHashlineDiff(
|
|
1173
|
-
input: { path: string; edits: HashlineEditInput[]
|
|
1276
|
+
input: { path: string; edits: HashlineEditInput[] },
|
|
1174
1277
|
cwd: string,
|
|
1175
1278
|
): Promise<
|
|
1176
1279
|
| {
|
|
@@ -1181,28 +1284,19 @@ export async function computeHashlineDiff(
|
|
|
1181
1284
|
error: string;
|
|
1182
1285
|
}
|
|
1183
1286
|
> {
|
|
1184
|
-
const { path, edits
|
|
1287
|
+
const { path, edits } = input;
|
|
1185
1288
|
|
|
1186
1289
|
try {
|
|
1187
1290
|
const absolutePath = resolveToCwd(path, cwd);
|
|
1188
|
-
const movePath = move ? resolveToCwd(move, cwd) : undefined;
|
|
1189
|
-
const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
|
|
1190
1291
|
const resolvedEdits = resolveHashlineEditsForDiff(edits);
|
|
1191
1292
|
const file = Bun.file(absolutePath);
|
|
1192
1293
|
|
|
1193
|
-
if (movePath === absolutePath) {
|
|
1194
|
-
return { error: "move path is the same as source path" };
|
|
1195
|
-
}
|
|
1196
|
-
if (isMoveOnly) {
|
|
1197
|
-
return { diff: "", firstChangedLine: undefined };
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
1294
|
const rawContent = await readHashlineFileText(file, path);
|
|
1201
1295
|
|
|
1202
1296
|
const { text: content } = stripBom(rawContent);
|
|
1203
1297
|
const normalizedContent = normalizeToLF(content);
|
|
1204
1298
|
const result = applyHashlineEdits(normalizedContent, resolvedEdits);
|
|
1205
|
-
if (normalizedContent === result.lines
|
|
1299
|
+
if (normalizedContent === result.lines) {
|
|
1206
1300
|
return { error: `No changes would be made to ${path}. The edits produce identical content.` };
|
|
1207
1301
|
}
|
|
1208
1302
|
|
|
@@ -1229,63 +1323,18 @@ export async function executeHashlineSingle(
|
|
|
1229
1323
|
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
|
1230
1324
|
const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
|
1231
1325
|
|
|
1232
|
-
// Extract file-level ops from edits
|
|
1233
|
-
const deleteFile = edits.some(e => e.delete);
|
|
1234
|
-
const move = edits.find(e => e.move)?.move;
|
|
1235
|
-
// Filter to content edits only (those with loc)
|
|
1236
1326
|
const contentEdits = edits.filter(e => e.loc != null);
|
|
1237
1327
|
|
|
1238
|
-
enforcePlanModeWrite(session, path, { op:
|
|
1328
|
+
enforcePlanModeWrite(session, path, { op: "update" });
|
|
1239
1329
|
|
|
1240
1330
|
if (path.endsWith(".ipynb") && contentEdits.length > 0) {
|
|
1241
1331
|
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1242
1332
|
}
|
|
1243
1333
|
|
|
1244
1334
|
const absolutePath = resolvePlanPath(session, path);
|
|
1245
|
-
const resolvedMove = move ? resolvePlanPath(session, move) : undefined;
|
|
1246
|
-
if (resolvedMove === absolutePath) {
|
|
1247
|
-
throw new Error("move path is the same as source path");
|
|
1248
|
-
}
|
|
1249
1335
|
|
|
1250
1336
|
const sourceFile = Bun.file(absolutePath);
|
|
1251
1337
|
const sourceExists = await sourceFile.exists();
|
|
1252
|
-
const isMoveOnly = Boolean(resolvedMove) && contentEdits.length === 0;
|
|
1253
|
-
|
|
1254
|
-
if (deleteFile) {
|
|
1255
|
-
if (sourceExists) {
|
|
1256
|
-
await sourceFile.unlink();
|
|
1257
|
-
}
|
|
1258
|
-
invalidateFsScanAfterDelete(absolutePath);
|
|
1259
|
-
return {
|
|
1260
|
-
content: [{ type: "text", text: `Deleted ${path}` }],
|
|
1261
|
-
details: {
|
|
1262
|
-
diff: "",
|
|
1263
|
-
op: "delete",
|
|
1264
|
-
meta: outputMeta().get(),
|
|
1265
|
-
},
|
|
1266
|
-
};
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
if (isMoveOnly && resolvedMove) {
|
|
1270
|
-
if (!sourceExists) {
|
|
1271
|
-
throw new Error(`File not found: ${path}`);
|
|
1272
|
-
}
|
|
1273
|
-
const parentDir = nodePath.dirname(resolvedMove);
|
|
1274
|
-
if (parentDir && parentDir !== ".") {
|
|
1275
|
-
await fs.mkdir(parentDir, { recursive: true });
|
|
1276
|
-
}
|
|
1277
|
-
await fs.rename(absolutePath, resolvedMove);
|
|
1278
|
-
invalidateFsScanAfterRename(absolutePath, resolvedMove);
|
|
1279
|
-
return {
|
|
1280
|
-
content: [{ type: "text", text: `Moved ${path} to ${move}` }],
|
|
1281
|
-
details: {
|
|
1282
|
-
diff: "",
|
|
1283
|
-
op: "update",
|
|
1284
|
-
move,
|
|
1285
|
-
meta: outputMeta().get(),
|
|
1286
|
-
},
|
|
1287
|
-
};
|
|
1288
|
-
}
|
|
1289
1338
|
|
|
1290
1339
|
if (!sourceExists) {
|
|
1291
1340
|
const lines: string[] = [];
|
|
@@ -1329,7 +1378,7 @@ export async function executeHashlineSingle(
|
|
|
1329
1378
|
warnings: anchorResult.warnings,
|
|
1330
1379
|
noopEdits: anchorResult.noopEdits,
|
|
1331
1380
|
};
|
|
1332
|
-
if (originalNormalized === result.text
|
|
1381
|
+
if (originalNormalized === result.text) {
|
|
1333
1382
|
let diagnostic = `No changes made to ${path}. The edits produced identical content.`;
|
|
1334
1383
|
if (result.noopEdits && result.noopEdits.length > 0) {
|
|
1335
1384
|
const details = result.noopEdits
|
|
@@ -1345,28 +1394,32 @@ export async function executeHashlineSingle(
|
|
|
1345
1394
|
diagnostic += `\nThe file currently contains these lines:\n${preview}\nYour edits were normalized back to the original content (whitespace-only differences are preserved as-is). Ensure your replacement changes actual code, not just formatting.`;
|
|
1346
1395
|
}
|
|
1347
1396
|
}
|
|
1397
|
+
if (result.noopEdits.some(e => e.loc.includes("-"))) {
|
|
1398
|
+
diagnostic +=
|
|
1399
|
+
"\nHint: a `range` loc replaces the entire span inclusive of both endpoints. " +
|
|
1400
|
+
"If your replacement repeats the existing content, narrow the range or change the replacement.";
|
|
1401
|
+
}
|
|
1348
1402
|
}
|
|
1349
1403
|
throw new Error(diagnostic);
|
|
1350
1404
|
}
|
|
1351
1405
|
|
|
1352
|
-
const writePath = resolvedMove ?? absolutePath;
|
|
1353
1406
|
const finalContent = bom + restoreLineEndings(result.text, originalEnding);
|
|
1354
|
-
const diagnostics = await writethrough(
|
|
1355
|
-
|
|
1407
|
+
const diagnostics = await writethrough(
|
|
1408
|
+
absolutePath,
|
|
1409
|
+
finalContent,
|
|
1410
|
+
signal,
|
|
1411
|
+
Bun.file(absolutePath),
|
|
1412
|
+
batchRequest,
|
|
1413
|
+
dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
|
|
1356
1414
|
);
|
|
1357
|
-
|
|
1358
|
-
await sourceFile.unlink();
|
|
1359
|
-
invalidateFsScanAfterRename(absolutePath, resolvedMove);
|
|
1360
|
-
} else {
|
|
1361
|
-
invalidateFsScanAfterWrite(absolutePath);
|
|
1362
|
-
}
|
|
1415
|
+
invalidateFsScanAfterWrite(absolutePath);
|
|
1363
1416
|
|
|
1364
1417
|
const diffResult = generateDiffString(originalNormalized, result.text);
|
|
1365
1418
|
const meta = outputMeta()
|
|
1366
1419
|
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
|
|
1367
1420
|
.get();
|
|
1368
1421
|
|
|
1369
|
-
const resultText =
|
|
1422
|
+
const resultText = `Updated ${path}`;
|
|
1370
1423
|
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
|
|
1371
1424
|
const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`;
|
|
1372
1425
|
const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
|
@@ -1384,7 +1437,6 @@ export async function executeHashlineSingle(
|
|
|
1384
1437
|
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
|
|
1385
1438
|
diagnostics,
|
|
1386
1439
|
op: "update",
|
|
1387
|
-
move,
|
|
1388
1440
|
meta,
|
|
1389
1441
|
},
|
|
1390
1442
|
};
|
package/src/edit/modes/patch.ts
CHANGED
|
@@ -1577,7 +1577,7 @@ export async function computePatchDiff(
|
|
|
1577
1577
|
}
|
|
1578
1578
|
|
|
1579
1579
|
export const patchEditEntrySchema = Type.Object({
|
|
1580
|
-
path: Type.String({ description: "File path" }),
|
|
1580
|
+
path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
|
|
1581
1581
|
op: Type.Optional(
|
|
1582
1582
|
StringEnum(["create", "delete", "update"], {
|
|
1583
1583
|
description: "Operation (default: update)",
|
|
@@ -1588,6 +1588,7 @@ export const patchEditEntrySchema = Type.Object({
|
|
|
1588
1588
|
});
|
|
1589
1589
|
|
|
1590
1590
|
export const patchEditSchema = Type.Object({
|
|
1591
|
+
path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
|
|
1591
1592
|
edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }),
|
|
1592
1593
|
});
|
|
1593
1594
|
|
|
@@ -1605,14 +1606,6 @@ export interface ExecutePatchSingleOptions {
|
|
|
1605
1606
|
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
|
1606
1607
|
}
|
|
1607
1608
|
|
|
1608
|
-
export function isPatchParams(params: unknown): params is PatchParams {
|
|
1609
|
-
if (typeof params !== "object" || params === null) return false;
|
|
1610
|
-
if (!("edits" in params) || !Array.isArray((params as any).edits)) return false;
|
|
1611
|
-
const first = (params as any).edits[0];
|
|
1612
|
-
if (!first || typeof first !== "object") return false;
|
|
1613
|
-
return "path" in first && !("old_text" in first) && !("new_text" in first);
|
|
1614
|
-
}
|
|
1615
|
-
|
|
1616
1609
|
class LspFileSystem implements FileSystem {
|
|
1617
1610
|
#lastDiagnostics: FileDiagnosticsResult | undefined;
|
|
1618
1611
|
#fileCache: Record<string, Bun.BunFile> = {};
|
|
@@ -1710,6 +1703,9 @@ export async function executePatchSingle(
|
|
|
1710
1703
|
beginDeferredDiagnosticsForPath,
|
|
1711
1704
|
} = options;
|
|
1712
1705
|
const { path, op: rawOp, rename, diff } = params;
|
|
1706
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
1707
|
+
throw new Error("patch edit: missing `path`. Provide `path` on the edit or supply a top-level `path`.");
|
|
1708
|
+
}
|
|
1713
1709
|
|
|
1714
1710
|
const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update";
|
|
1715
1711
|
|