pi-hashline-edit-pro 6.4.2 → 6.4.4
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/README.md +5 -5
- package/index.ts +11 -6
- package/package.json +1 -1
- package/src/anchor-registry.ts +23 -1
- package/src/edit-common.ts +1 -1
- package/src/hash-store.ts +4 -4
- package/src/hashline/apply.ts +1 -1
- package/src/hashline/resolve.ts +2 -0
- package/src/payload-contract.ts +1 -1
- package/src/replace-match.ts +42 -13
- package/src/replace.ts +4 -2
- package/tool-prompts/read.md +1 -1
- package/tool-prompts/replace-match-guidelines.md +3 -3
- package/tool-prompts/replace-match.md +1 -1
package/README.md
CHANGED
|
@@ -159,7 +159,7 @@ The extension registers eight tools: `read`, `replace`, `replace_match`, `insert
|
|
|
159
159
|
|
|
160
160
|
### read
|
|
161
161
|
|
|
162
|
-
`read` returns a text file with every line prefixed by `anchor│content`. The anchor is the line's address. `offset` accepts a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return
|
|
162
|
+
`read` returns a text file with every line prefixed by `anchor│content`. The anchor is the line's address. `offset` accepts a 1-indexed line number or a served anchor; with an anchor, `limit` counts the lines to return starting at that anchor row. Anchor offsets are stable across edits and return the same editable rows.
|
|
163
163
|
|
|
164
164
|
| Parameter | Description |
|
|
165
165
|
| --- | --- |
|
|
@@ -207,7 +207,7 @@ A deletion keeps blank lines at the edges of the removed range, so the separator
|
|
|
207
207
|
|
|
208
208
|
The extension checks the request before any file I/O, so a bad request never touches the file.
|
|
209
209
|
|
|
210
|
-
Auto-fixable slips fall into two groups. Fixed silently:
|
|
210
|
+
Auto-fixable slips fall into two groups. Fixed silently: embedded newlines and a legacy array payload (a single-element array that holds stringified array text, even with a trailing JS method call, for example `[…].map(s => s)`, is unwrapped). Fixed with a warning or a hint: a reversed `remove_from`/`remove_to` range is applied in document order with an `[H_RANGE_REVERSED]` hint; a leftover `anchor│` prefix in `text` or the anchor fields (a prefix of 4 to 5 letters before `│`, for example `abde│`) and diff-preview rows pasted into the replacement are stripped with a warning.
|
|
211
211
|
|
|
212
212
|
Content containing a NUL byte (`U+0000`) is rejected with `[E_BAD_SHAPE]` before any file I/O: writing it would make the file binary, so use an empty replacement to delete. This applies to the `text` field of both `replace` and `insert`.
|
|
213
213
|
|
|
@@ -219,13 +219,13 @@ After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as
|
|
|
219
219
|
|
|
220
220
|
### replace_match
|
|
221
221
|
|
|
222
|
-
`replace_match` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `old_string` is the exact text to find inside that range, and `new_string` replaces every occurrence of it; every other character stays untouched. Send `old_string` as only the fragment that changes — never include text that `new_string` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines or the whole range. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the line, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
|
|
222
|
+
`replace_match` changes part of a line (or a range of lines) without retyping the rest. `replace_from` and `replace_to` are bare anchors marking the first and last line of the range; use the same anchor for a single line. `old_string` is the exact text to find inside that range, and `new_string` replaces every occurrence of it; every other character stays untouched. Send `old_string` as only the fragment that changes — never include text that `new_string` leaves unchanged — and use `replace` with the same anchors when the change covers whole lines or the whole range; if `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line change. That makes it the tool for a change the request quotes as a substring: a whole-line `replace` has to reproduce the rest of the line, so a slipped character becomes a wrong byte, while `replace_match` leaves everything the request did not name untouched. It is enabled by default; turn Replace match off in `/hashline-config` to remove the tool.
|
|
223
223
|
|
|
224
224
|
`remove_from` and `remove_to` are accepted as aliases for `replace_from` and `replace_to`.
|
|
225
225
|
|
|
226
226
|
`old_string` is matched against the range's text (LF line breaks, no final terminator) and every non-overlapping occurrence is replaced, left to right. A missing match is refused with `[E_SUBSTRING_NOT_FOUND]` and the current `anchor│content` rows, so the retry needs no `read`. The two boundary anchors are verified against what was last shown; lines strictly inside the range are matched against the file as it currently stands on disk.
|
|
227
227
|
|
|
228
|
-
`replace_match` reports `[W_REPLACE_MATCH_WHOLE_RANGE]` when `old_string` is the entire selected range, `[W_REPLACE_MATCH_RETYPE]` when `old_string` retypes a long run of text that `new_string` leaves unchanged
|
|
228
|
+
`replace_match` reports `[W_REPLACE_MATCH_WHOLE_RANGE]` when `old_string` is the entire selected range, and `[W_REPLACE_MATCH_RETYPE]` when `old_string` retypes a long run of text that `new_string` leaves unchanged. Both are refused in strict-input mode with `[E_BAD_SHAPE]`.
|
|
229
229
|
|
|
230
230
|
In a same-message batch it joins the other calls on its file: the batch validates everything against the pre-batch state and commits once, with one undo. A missing `old_string` fails that call on its own and is left out of the batch; the rest of the batch still commits. The post-edit diff carries fresh anchors, and the edit is undoable with `undo_last_change`.
|
|
231
231
|
|
|
@@ -465,7 +465,6 @@ Full reference:
|
|
|
465
465
|
| `[W_BARE_HASH_PREFIX]` | A `text` line starts with an `anchor│` prefix. The prefix is stripped automatically with a warning. |
|
|
466
466
|
| `[W_REPLACE_MATCH_WHOLE_RANGE]` | `replace_match` `old_string` is the entire selected range. The anchors already identify it; use `replace` with the same anchors, or send only the changed fragment. Refused in strict-input mode. |
|
|
467
467
|
| `[W_REPLACE_MATCH_RETYPE]` | `replace_match` `old_string` retypes a long run of text that `new_string` leaves unchanged. Send only the fragment that changes; refused in strict-input mode. |
|
|
468
|
-
| `[W_REPLACE_MATCH_MULTI]` | `replace_match` `old_string` matched more than one occurrence in the range and every one was replaced. Narrow the range or send a longer fragment if you meant only one. |
|
|
469
468
|
| `[W_ANCHOR_RECLAIMED]` | The session's anchor quota was exhausted, so all anchors of the listed files (the least recently read or edited) were freed to make room. Read those files again before editing them. |
|
|
470
469
|
| `[H_LITERAL_ESCAPE]` | A payload field contains literal escaped text such as `\uXXXX`, `\n`, `\t`, `\r`, or `\"` (one hint per distinct escape, up to three). The file receives those backslash characters as written, because escapes in the tool call are decoded once when the call is parsed (`\uXXXX` → the character), so a doubled escape (`\\uXXXX`) lands literally. The hint is one line: `text: "\u200b" written as literal text (Kq3f│ col 31); resend with U+200B if unintended.` names the field, the escape, and up to three affected anchors with their columns, then the fix; when more than three rows carry it, it gives `on 10 rows; undo_last_change + resend with U+200B if unintended.` instead of the anchor list. |
|
|
471
470
|
| `[H_UNICODE_LOST]` | The new text is missing an invisible or look-alike character (for example `U+200B`, `U+2060`, `U+00A0`, or a smart quote) that a replaced row has, or that a context row sharing a long run with an inserted line has. The edit applied as sent; the one-line hint names the character, its column, and the reference anchor, for example `[H_UNICODE_LOST] U+2060 missing at col 31; Kq3f│ has it; resend with U+2060 if unintended.` When another look-alike character (including `U+FFFD`) takes its place, the hint reports the substitute with `[H_UNICODE_SWAPPED]` wording. |
|
|
@@ -474,6 +473,7 @@ Full reference:
|
|
|
474
473
|
| `[H_INDENT_MISMATCH]` | The new line has fewer leading whitespace characters than a structurally similar row (a reference row near the anchor line for an insert, or the replaced line). The edit applied as sent; the one-line hint names both counts and the reference anchor, for example `[H_INDENT_MISMATCH] new line has 0 leading whitespace characters; Kq3f│ has 2.` Copied or moved blocks are not checked, because their indentation comes from the source lines. |
|
|
475
474
|
| `[H_SEPARATOR_MOVED]` | An insert landed its text directly against the anchor line, and the blank line that separated the anchor from its neighbor was displaced to the other side of the inserted text. The edit applied as sent; the one-line hint names the anchor and which side lost the blank line, for example `[H_SEPARATOR_MOVED] blank separator above Kq3f│ was displaced; add a blank line before Kq3f│ if unintended.` |
|
|
476
475
|
| `[H_SEPARATOR_LOST]` | A pure deletion removed a run of blank lines that sat between two content lines. The edit applied as sent; the one-line hint names the two surviving anchors and the removed count, for example `[H_SEPARATOR_LOST] deletion removed 2 blank lines between Aaaa│ and Dddd│.` |
|
|
476
|
+
| `[H_RANGE_REVERSED]` | The `remove_from` anchor resolved to a line after `remove_to`. The call applied the range in document order; the one-line hint names the line range used. |
|
|
477
477
|
| `[E_NOT_FOUND]` | The path does not exist. |
|
|
478
478
|
| `[E_ACCESS]` | The file is not readable or writable. |
|
|
479
479
|
| `[E_NOT_TEXT]` | The path is a directory, binary file, image, or UTF-16/UTF-32 encoded text; hashline editing only supports text files. |
|
package/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
setDisableOnModelsFromText,
|
|
34
34
|
} from "./src/config";
|
|
35
35
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
36
|
-
import { initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
36
|
+
import { type AnchorSessionContext, initRegistry, gcRegistrySidecars, clearRegistry, freeAnchors, freeAnchorsForPaths, sessionKeyFor, withAnchorSession, releaseRegistrySession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./src/anchor-registry";
|
|
37
37
|
import { serveRows } from "./src/served";
|
|
38
38
|
import { finalizeTurn, planAssistantMessage } from "./src/batch";
|
|
39
39
|
import { currentEditFlags } from "./src/edit-common";
|
|
@@ -72,6 +72,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
72
72
|
let grepFlavorCwd: string | undefined;
|
|
73
73
|
const baseAnchorTools = new Set<string>();
|
|
74
74
|
let gateApplied = false;
|
|
75
|
+
const pendingRegistrySessionKeys: Array<string | undefined> = [];
|
|
75
76
|
|
|
76
77
|
async function refreshEditTools(): Promise<void> {
|
|
77
78
|
try {
|
|
@@ -144,7 +145,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
144
145
|
pi.setActiveTools([...new Set([...base, ...enabled])]);
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
|
|
148
|
+
async function withRegistrySession<T>(ctx: AnchorSessionContext | undefined, fn: () => Promise<T> | T): Promise<T> {
|
|
149
|
+
pendingRegistrySessionKeys.push(sessionKeyFor(ctx));
|
|
150
|
+
return withAnchorSession(ctx, fn);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pi.on("session_start", async (_event, ctx) => withRegistrySession(ctx, async () => {
|
|
148
154
|
const active = pi.getActiveTools();
|
|
149
155
|
baseAnchorTools.clear();
|
|
150
156
|
for (const tool of ANCHOR_TOOL_NAMES) {
|
|
@@ -154,8 +160,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
154
160
|
await initHasher();
|
|
155
161
|
loadHashStore()
|
|
156
162
|
.then(async store => {
|
|
157
|
-
|
|
158
|
-
for (const path of missing) freeAnchors(path);
|
|
163
|
+
freeAnchorsForPaths(await pruneMissing(store));
|
|
159
164
|
})
|
|
160
165
|
.catch(err => {
|
|
161
166
|
console.error("Failed to load hash store:", err);
|
|
@@ -192,9 +197,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
192
197
|
}
|
|
193
198
|
}));
|
|
194
199
|
|
|
195
|
-
pi.on("session_shutdown", async (
|
|
200
|
+
pi.on("session_shutdown", async () => {
|
|
196
201
|
try {
|
|
197
|
-
const key =
|
|
202
|
+
const key = pendingRegistrySessionKeys.shift();
|
|
198
203
|
if (key !== undefined) releaseRegistrySession(key);
|
|
199
204
|
} catch (error) {
|
|
200
205
|
console.error("Failed to release anchor registry session:", error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "6.4.
|
|
3
|
+
"version": "6.4.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -512,7 +512,7 @@ function hasOwnedPath(state: SessionState, path: string): boolean {
|
|
|
512
512
|
|
|
513
513
|
function evictPath(state: SessionState, path: string): string[] {
|
|
514
514
|
const anchors: string[] = [];
|
|
515
|
-
for (const [anchor, entry] of
|
|
515
|
+
for (const [anchor, entry] of state.owned) {
|
|
516
516
|
if (entry.path === path) {
|
|
517
517
|
anchors.push(anchor);
|
|
518
518
|
state.owned.delete(anchor);
|
|
@@ -617,6 +617,28 @@ export function freeAnchors(path: string, anchors?: string[]): void {
|
|
|
617
617
|
}
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
+
export function freeAnchorsForPaths(paths: Iterable<string>): void {
|
|
621
|
+
const state = current();
|
|
622
|
+
if (!state) return;
|
|
623
|
+
const targets = new Set(paths);
|
|
624
|
+
if (targets.size === 0) return;
|
|
625
|
+
const freedByPath = new Map<string, string[]>();
|
|
626
|
+
for (const [anchor, entry] of state.owned) {
|
|
627
|
+
if (!targets.has(entry.path)) continue;
|
|
628
|
+
state.owned.delete(anchor);
|
|
629
|
+
const freed = freedByPath.get(entry.path);
|
|
630
|
+
if (freed === undefined) freedByPath.set(entry.path, [anchor]);
|
|
631
|
+
else freed.push(anchor);
|
|
632
|
+
}
|
|
633
|
+
for (const path of targets) {
|
|
634
|
+
state.served.delete(path);
|
|
635
|
+
state.lastUsedAt.delete(path);
|
|
636
|
+
}
|
|
637
|
+
for (const [path, anchors] of freedByPath) {
|
|
638
|
+
appendEvent({ kind: "free", path, anchors });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
620
642
|
export function clearRegistry(): void {
|
|
621
643
|
const state = current();
|
|
622
644
|
if (!state) return;
|
package/src/edit-common.ts
CHANGED
|
@@ -47,7 +47,7 @@ export async function currentEditFlags(codemode = false): Promise<EditToolFlags>
|
|
|
47
47
|
|
|
48
48
|
function preferenceGuideline(flags: EditToolFlags): string {
|
|
49
49
|
const tools = gatedEditOps(["read", "replace", "replace_match", "insert", "copy", "move", "undo_last_change"], flags);
|
|
50
|
-
return `
|
|
50
|
+
return `Remember to use the hashline tools for every file read and edit: ${joinOps(tools, { backtick: true })}.`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const SHARED_EDIT_OPS = ["replace", "replace_match", "insert", "copy", "move"];
|
package/src/hash-store.ts
CHANGED
|
@@ -128,7 +128,7 @@ function requireEngine(): EngineSelection {
|
|
|
128
128
|
interface Prepared {
|
|
129
129
|
get: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
130
130
|
getState: (...params: SqlParams) => Record<string, unknown> | undefined;
|
|
131
|
-
|
|
131
|
+
snapshotPaths: (...params: SqlParams) => Record<string, unknown>[];
|
|
132
132
|
deleteOne: (...params: SqlParams) => void;
|
|
133
133
|
upsert: (...params: SqlParams) => void;
|
|
134
134
|
undoUpsert: (...params: SqlParams) => void;
|
|
@@ -226,7 +226,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
226
226
|
).run(String(HASH_STORE_VERSION));
|
|
227
227
|
const getStmt = db.prepare("SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?");
|
|
228
228
|
const getStateStmt = db.prepare("SELECT checksum, hashes, line_checksums FROM snapshots WHERE path = ?");
|
|
229
|
-
const
|
|
229
|
+
const snapshotPathsStmt = db.prepare("SELECT path FROM snapshots");
|
|
230
230
|
const delStmt = db.prepare("DELETE FROM snapshots WHERE path = ?");
|
|
231
231
|
const upsertStmt = db.prepare(
|
|
232
232
|
"INSERT INTO snapshots (path, checksum, line_count, hashes, line_checksums, updated_at) VALUES (?, ?, ?, ?, ?, ?) " +
|
|
@@ -245,7 +245,7 @@ function buildStore(db: RawDb): { db: RawDb; stmts: Prepared } {
|
|
|
245
245
|
const stmts: Prepared = {
|
|
246
246
|
get: (...params) => getStmt.get(...params) as Record<string, unknown> | undefined,
|
|
247
247
|
getState: (...params) => getStateStmt.get(...params) as Record<string, unknown> | undefined,
|
|
248
|
-
|
|
248
|
+
snapshotPaths: (...params) => withBusyRetry(() => snapshotPathsStmt.all(...params) as Record<string, unknown>[]),
|
|
249
249
|
deleteOne: retriedWrite(delStmt),
|
|
250
250
|
upsert: retriedWrite(upsertStmt),
|
|
251
251
|
undoUpsert: retriedWrite(undoUpsertStmt),
|
|
@@ -640,7 +640,7 @@ async function statMissing(rows: { path: string }[]): Promise<string[]> {
|
|
|
640
640
|
}
|
|
641
641
|
|
|
642
642
|
export async function pruneMissing(store: HashStore): Promise<string[]> {
|
|
643
|
-
const rows = store.stmts.
|
|
643
|
+
const rows = store.stmts.snapshotPaths() as { path: string }[];
|
|
644
644
|
const missing = await statMissing(rows);
|
|
645
645
|
if (missing.length === 0) return [];
|
|
646
646
|
withStore(() => {
|
package/src/hashline/apply.ts
CHANGED
|
@@ -166,7 +166,7 @@ export function planEdit(
|
|
|
166
166
|
const fileHashes = precomputedHashes;
|
|
167
167
|
const warnings: string[] = [];
|
|
168
168
|
|
|
169
|
-
const rangeFixed = swapReversedRanges(edit, fileHashes);
|
|
169
|
+
const rangeFixed = swapReversedRanges(edit, fileHashes, warnings);
|
|
170
170
|
const prefixFixed = stripDiffPrefixes(
|
|
171
171
|
stripBarePrefixes(rangeFixed, warnings, options?.stripWarning),
|
|
172
172
|
warnings,
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -265,6 +265,7 @@ export function stripDiffPrefixes(edit: HEdit, warnings: string[], location: Str
|
|
|
265
265
|
export function swapReversedRanges(
|
|
266
266
|
edit: HEdit,
|
|
267
267
|
fileHashes: string[],
|
|
268
|
+
warnings?: string[],
|
|
268
269
|
): HEdit {
|
|
269
270
|
const lineByHash = new Map<string, number>();
|
|
270
271
|
for (let i = 0; i < fileHashes.length; i++) {
|
|
@@ -280,6 +281,7 @@ export function swapReversedRanges(
|
|
|
280
281
|
) {
|
|
281
282
|
return edit;
|
|
282
283
|
}
|
|
284
|
+
warnings?.push(`[H_RANGE_REVERSED] "remove_from" refers to line ${startLine} after "remove_to" (line ${endLine}); lines ${endLine}-${startLine} were replaced in document order.`);
|
|
283
285
|
return { ...edit, hash_bounds: [endRef, startRef] as [Anchor, Anchor] };
|
|
284
286
|
}
|
|
285
287
|
|
package/src/payload-contract.ts
CHANGED
|
@@ -234,7 +234,7 @@ const replaceMatchToSchema = Type.String({
|
|
|
234
234
|
});
|
|
235
235
|
const replaceMatchOldStringSchema = Type.String({
|
|
236
236
|
description:
|
|
237
|
-
"The exact text to find inside the selected line(s), copied from the served row: only the fragment that changes, never text that `new_string` leaves unchanged. Every occurrence inside the range is replaced; the text around each match is left untouched. Matching uses LF line breaks and excludes the last line's terminator.
|
|
237
|
+
"The exact text to find inside the selected line(s), copied from the served row: only the fragment that changes, never text that `new_string` leaves unchanged. If `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line match: use `replace` instead. Every occurrence inside the range is replaced; the text around each match is left untouched. Matching uses LF line breaks and excludes the last line's terminator.",
|
|
238
238
|
});
|
|
239
239
|
const replaceMatchNewStringSchema = Type.String({
|
|
240
240
|
description:
|
package/src/replace-match.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as Diff from "diff";
|
|
|
4
4
|
import { execPipeline, noteAnchorError, previewFromPipe, previewError, type ReplaceDetails } from "./replace";
|
|
5
5
|
import { commitEdit } from "./commit";
|
|
6
6
|
import { readNormFile, type NormFile } from "./file-reader";
|
|
7
|
-
import { fmtRegion, MAX_HASH_LINES, parseHashRef, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
|
|
7
|
+
import { fmtRegion, MAX_HASH_LINES, parseHashRef, parsePayloadText, planEdit, preserveDeletionSeparators, resEdit, resolveAnchorLine, stripAnchorRow, type Anchor } from "./hashline";
|
|
8
8
|
import { servedForPath, withAnchorSession } from "./anchor-registry";
|
|
9
9
|
import { batchMemberFor, executeBatchMember, noteBatchFailure, registerBatchPlanner, type BatchMemberPlan, type BatchPlannerInput } from "./batch";
|
|
10
10
|
import { loadP, loadGuidelines } from "./prompts";
|
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
type ReplaceMatchReq,
|
|
17
17
|
} from "./payload-contract";
|
|
18
18
|
import { literalEscapeHints, makePrepareArguments, splitLines } from "./utils";
|
|
19
|
-
import { toLF } from "./normalize";
|
|
19
|
+
import { toLF, type LineEnding } from "./normalize";
|
|
20
20
|
import { isFidelitySensitiveChar } from "./edit-fidelity";
|
|
21
|
-
import { countNewlines } from "./line-endings";
|
|
21
|
+
import { countNewlines, splitWithEndings } from "./line-endings";
|
|
22
22
|
import { MAX_RANGE_STALE_LINES } from "./constants";
|
|
23
23
|
import {
|
|
24
24
|
assertBoundaryLinesServed,
|
|
@@ -44,6 +44,7 @@ interface MatchRefs {
|
|
|
44
44
|
export interface MatchPlan {
|
|
45
45
|
editParams: { remove_from: string; remove_to: string; text: string };
|
|
46
46
|
warnings: string[];
|
|
47
|
+
contentSeparators: (LineEnding | undefined)[];
|
|
47
48
|
servedOverride?: ReadonlyMap<string, string>;
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -51,18 +52,46 @@ function formatMatchRange(start: number, end: number): string {
|
|
|
51
52
|
return start === end ? `line ${start}` : `lines ${start}-${end}`;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function replaceAllOccurrences(
|
|
55
|
-
|
|
55
|
+
function replaceAllOccurrences(
|
|
56
|
+
text: string,
|
|
57
|
+
oldText: string,
|
|
58
|
+
newText: string,
|
|
59
|
+
): { text: string; separators: (LineEnding | undefined)[]; count: number } {
|
|
60
|
+
const parts: string[] = [];
|
|
61
|
+
const inserted: Array<{ start: number; end: number }> = [];
|
|
62
|
+
let length = 0;
|
|
63
|
+
const append = (chunk: string, fromNewText: boolean): void => {
|
|
64
|
+
if (fromNewText) inserted.push({ start: length, end: length + chunk.length });
|
|
65
|
+
parts.push(chunk);
|
|
66
|
+
length += chunk.length;
|
|
67
|
+
};
|
|
56
68
|
let cursor = 0;
|
|
57
69
|
let count = 0;
|
|
58
70
|
for (;;) {
|
|
59
71
|
const found = text.indexOf(oldText, cursor);
|
|
60
72
|
if (found < 0) break;
|
|
61
|
-
|
|
73
|
+
append(text.slice(cursor, found), false);
|
|
74
|
+
append(newText, true);
|
|
62
75
|
cursor = found + oldText.length;
|
|
63
76
|
count += 1;
|
|
64
77
|
}
|
|
65
|
-
|
|
78
|
+
if (count === 0) return { text, separators: [], count: 0 };
|
|
79
|
+
append(text.slice(cursor), false);
|
|
80
|
+
const replacement = parts.join("");
|
|
81
|
+
const parsed = splitWithEndings(replacement);
|
|
82
|
+
const separators: (LineEnding | undefined)[] = [];
|
|
83
|
+
let offset = 0;
|
|
84
|
+
let insertedIndex = 0;
|
|
85
|
+
for (let index = 0; index < parsed.lines.length; index++) {
|
|
86
|
+
offset += parsed.lines[index]!.length;
|
|
87
|
+
const ending = parsed.endings[index];
|
|
88
|
+
if (ending === undefined) continue;
|
|
89
|
+
while (insertedIndex < inserted.length && inserted[insertedIndex]!.end <= offset) insertedIndex += 1;
|
|
90
|
+
const fromNewText = insertedIndex < inserted.length && offset >= inserted[insertedIndex]!.start && offset < inserted[insertedIndex]!.end;
|
|
91
|
+
separators.push(fromNewText ? ending : undefined);
|
|
92
|
+
offset += ending.length;
|
|
93
|
+
}
|
|
94
|
+
return { text: replacement, separators, count };
|
|
66
95
|
}
|
|
67
96
|
|
|
68
97
|
const RETYPE_DIFF_MAX_CHARS = 20_000;
|
|
@@ -150,10 +179,6 @@ function wholeRangeWarning(oldText: string, rangeText: string, lineCount: number
|
|
|
150
179
|
return `[W_REPLACE_MATCH_WHOLE_RANGE] "old_string" is the entire selected range (${formatMatchRange(start, end)}); the anchors already identify it. Use replace with remove_from "${startHash}", remove_to "${endHash}" and the new text, or send only the fragment that changes.`;
|
|
151
180
|
}
|
|
152
181
|
|
|
153
|
-
function multiMatchWarning(count: number, start: number, end: number, displayPath: string): string {
|
|
154
|
-
return `[W_REPLACE_MATCH_MULTI] "old_string" matched ${count} occurrences in ${formatMatchRange(start, end)} of ${displayPath}; every one was replaced. Narrow the range or send a longer fragment if you meant only one.`;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
182
|
function describeChar(char: string | undefined): string {
|
|
158
183
|
if (char === undefined) return "the end of the text";
|
|
159
184
|
const codePoint = char.codePointAt(0)!;
|
|
@@ -210,10 +235,11 @@ export function buildReplaceMatchEdit(
|
|
|
210
235
|
const rangeText = `${rangeLines.join("\n")}${rangeLines[rangeLines.length - 1] === "" ? "\n" : ""}`;
|
|
211
236
|
const oldText = toLF(req.old_string);
|
|
212
237
|
const newText = toLF(req.new_string);
|
|
213
|
-
const { text: replacement, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
|
|
238
|
+
const { text: replacement, separators: replacementSeparators, count } = replaceAllOccurrences(rangeText, oldText, req.new_string);
|
|
214
239
|
if (count === 0) {
|
|
215
240
|
throw new Error(notFoundMessage(displayPath, start, end, preload.fileHashes, fileLines, rangeText, oldText));
|
|
216
241
|
}
|
|
242
|
+
const contentSeparators = parsePayloadText(replacement).lines.map((_, index) => replacementSeparators[index]);
|
|
217
243
|
const startRef = fromLine <= toLine ? refs.from : refs.to;
|
|
218
244
|
const endRef = fromLine <= toLine ? refs.to : refs.from;
|
|
219
245
|
const servedOverride = trustRangeServed(fileLines, preload.fileHashes, served, start, end);
|
|
@@ -225,7 +251,6 @@ export function buildReplaceMatchEdit(
|
|
|
225
251
|
const wholeRange = wholeRangeWarning(oldText, rangeText, rangeLines.length, start, end, startRef.hash, endRef.hash);
|
|
226
252
|
if (wholeRange !== undefined) warnings.push(wholeRange);
|
|
227
253
|
}
|
|
228
|
-
if (count > 1) warnings.push(multiMatchWarning(count, start, end, displayPath));
|
|
229
254
|
return {
|
|
230
255
|
editParams: {
|
|
231
256
|
remove_from: startRef.hash,
|
|
@@ -233,6 +258,7 @@ export function buildReplaceMatchEdit(
|
|
|
233
258
|
text: replacement,
|
|
234
259
|
},
|
|
235
260
|
warnings,
|
|
261
|
+
contentSeparators,
|
|
236
262
|
...(servedOverride !== undefined ? { servedOverride } : {}),
|
|
237
263
|
};
|
|
238
264
|
}
|
|
@@ -256,6 +282,7 @@ function planReplaceMatchBatchMember(input: BatchPlannerInput): BatchMemberPlan
|
|
|
256
282
|
return {
|
|
257
283
|
hedit,
|
|
258
284
|
warnings: [...warnings, ...hints, ...resWarnings, ...matchPlan.warnings],
|
|
285
|
+
contentSeparators: matchPlan.contentSeparators,
|
|
259
286
|
planned,
|
|
260
287
|
foldedLines: 0,
|
|
261
288
|
...(matchPlan.servedOverride !== undefined ? { servedOverride: matchPlan.servedOverride } : {}),
|
|
@@ -291,6 +318,7 @@ export async function replaceMatchPreview(request: unknown, cwd: string, signal?
|
|
|
291
318
|
noPersist: true,
|
|
292
319
|
preloadedNorm: preload,
|
|
293
320
|
served: plan.servedOverride,
|
|
321
|
+
endingOverrides: plan.contentSeparators,
|
|
294
322
|
signal,
|
|
295
323
|
});
|
|
296
324
|
return previewFromPipe(pipe);
|
|
@@ -372,6 +400,7 @@ export function buildReplaceMatchToolDef(flags: EditToolFlags = DEFAULT_EDIT_FLA
|
|
|
372
400
|
signal,
|
|
373
401
|
preloadedNorm: preload,
|
|
374
402
|
served: plan.servedOverride,
|
|
403
|
+
endingOverrides: plan.contentSeparators,
|
|
375
404
|
});
|
|
376
405
|
return commitEdit(pipe, {
|
|
377
406
|
path: pipe.path,
|
package/src/replace.ts
CHANGED
|
@@ -158,9 +158,11 @@ function withEndingOverrides(edit: HEdit, overrides: (LineEnding | undefined)[]
|
|
|
158
158
|
const length = Math.max(edit.content_separators?.length ?? 0, overrides.length);
|
|
159
159
|
const separators: (LineEnding | undefined)[] = new Array(length);
|
|
160
160
|
for (let index = 0; index < length; index++) {
|
|
161
|
-
separators[index] = overrides[index]
|
|
161
|
+
separators[index] = index < overrides.length ? overrides[index] : edit.content_separators?.[index];
|
|
162
|
+
}
|
|
163
|
+
if (separators.every((ending) => ending === undefined)) {
|
|
164
|
+
return edit.content_separators === undefined ? edit : { ...edit, content_separators: undefined };
|
|
162
165
|
}
|
|
163
|
-
if (separators.every((ending) => ending === undefined)) return edit;
|
|
164
166
|
return { ...edit, content_separators: separators };
|
|
165
167
|
}
|
|
166
168
|
|
package/tool-prompts/read.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Read a text file as `anchor│content` rows: a 4-character anchor (letters only, case-sensitive), `│`, then the line. Target edits by anchor, never by content or line number. `offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts lines
|
|
1
|
+
Read a text file as `anchor│content` rows: a 4-character anchor (letters only, case-sensitive), `│`, then the line. Target edits by anchor, never by content or line number. `offset` takes a 1-indexed line number or a served anchor; with an anchor, `limit` counts lines from that anchor row onward — no line-number math. Page long files with `offset` and `limit`; when output says truncated, continue from the hinted `offset`. A stale anchor is refused with the current rows and fresh anchors. Images attach visually; binary, directory, and UTF-16/UTF-32 text are rejected; an empty file returns one empty row you can replace to seed content.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
- `replace_match`: `old_string` is only the fragment
|
|
2
|
-
- `replace_match`:
|
|
3
|
-
- `replace_match`: when the change covers whole lines or the whole range, use `replace` with the same anchors and the
|
|
1
|
+
- `replace_match`: `old_string` is only the fragment that changes — never text that `new_string` leaves unchanged; every occurrence inside the range is replaced, so scope the range when only some lines should change.
|
|
2
|
+
- `replace_match`: if `old_string` is everything on the line except indentation and trailing punctuation, that match is the whole line — use `replace`. A shorter fragment repeated on several lines (a value, a name) is the intended multi-line use.
|
|
3
|
+
- `replace_match`: when the change covers whole lines or the whole range, use `replace` with the same anchors and the exact text.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
Replace part of a line (or a range of lines) without retyping the rest. `replace_from`/`replace_to` are bare anchors from served `anchor│content` rows marking the range's first and last line (the same anchor for one line), and `old_string` is the exact text to find inside that range. Send only the
|
|
1
|
+
Replace part of a line (or a range of lines) without retyping the rest. `replace_from`/`replace_to` are bare anchors from served `anchor│content` rows marking the range's first and last line (the same anchor for one line), and `old_string` is the exact text to find inside that range. Send only the fragment that changes — never text that `new_string` leaves unchanged. If `old_string` is everything on the line except indentation and trailing punctuation, that is a whole-line rewrite: use `replace` with the same anchors. The same short fragment on several lines (a value, a name) may be replaced in one call. `remove_from`/`remove_to` are accepted as aliases.
|
|
2
2
|
|
|
3
3
|
The two boundary anchors must still match what was last shown; lines strictly inside the range are matched against the file as it is on disk.
|