pi-hashline-edit-pro 0.12.1 → 0.12.3
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 +16 -13
- package/index.ts +2 -2
- package/package.json +1 -1
- package/prompts/read.md +1 -0
- package/prompts/replace-guidelines.md +1 -1
- package/prompts/replace.md +4 -4
- package/src/constants.ts +21 -0
- package/src/file-reader.ts +11 -0
- package/src/hashline/apply.ts +61 -12
- package/src/hashline/hash.ts +7 -0
- package/src/hashline/index.ts +1 -0
- package/src/hashline/parse.ts +1 -1
- package/src/hashline/resolve.ts +2 -2
- package/src/read.ts +21 -5
- package/src/replace-diff.ts +3 -2
- package/src/replace.ts +2 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The original uses 2-character hashes of a 16-character alphabet, with the hash b
|
|
|
13
13
|
This fork makes two changes that compound:
|
|
14
14
|
|
|
15
15
|
1. **Bump hash length to 3 characters** of the 64-char URL-safe base64 alphabet. That gives 18 bits / 262,144 buckets, up from 8 bits / 256 in the upstream.
|
|
16
|
-
2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter:
|
|
16
|
+
2. **Perfect hashing (collision resolution).** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter: `:R{retry}`) until a unique hash is found. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space. Two byte-identical lines (e.g. repeated `import` statements, repeated `}`) get different hashes automatically.
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
19
19
|
|
|
@@ -65,16 +65,20 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
|
|
|
65
65
|
```json
|
|
66
66
|
{
|
|
67
67
|
"path": "src/main.ts",
|
|
68
|
-
"
|
|
69
|
-
{ "hash_range_incl": ["ve7", "ve7"], "
|
|
68
|
+
"changes": [
|
|
69
|
+
{ "hash_range_incl": ["ve7", "ve7"], "content_lines": [" console.log('hashline');"] }
|
|
70
70
|
]
|
|
71
71
|
}
|
|
72
72
|
```
|
|
73
|
+
|
|
74
|
+
| Field | Description |
|
|
75
|
+
| --- | --- |
|
|
73
76
|
| `hash_range_incl` | Inclusive line range `[start_hash, end_hash]` (required). |
|
|
77
|
+
| `content_lines` | Literal replacement content, one string per line (use `[]` to delete the range). |
|
|
74
78
|
|
|
75
|
-
- **Request structure validation.** The request envelope (`path`, `
|
|
76
|
-
- **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_incl: ["<START>", "<END>"],
|
|
77
|
-
All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so the hashes from a single `read` call remain valid across all edits in the batch.
|
|
79
|
+
- **Request structure validation.** The request envelope (`path`, `changes`) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_REF]`.
|
|
80
|
+
- **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_incl: ["<START>", "<END>"], content_lines: [...]}`.
|
|
81
|
+
- **Batched atomicity.** All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so the hashes from a single `read` call remain valid across all edits in the batch.
|
|
78
82
|
|
|
79
83
|
### Chained edits
|
|
80
84
|
|
|
@@ -97,13 +101,12 @@ The post-edit diff (with `+`/`-` markers) is exposed to the host UI via `details
|
|
|
97
101
|
|
|
98
102
|
## Design Decisions
|
|
99
103
|
|
|
100
|
-
- **Stale anchors fail.** A hash mismatch means
|
|
104
|
+
- **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_incl` of the next replace call. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
|
|
101
105
|
- **No fallback relocation.** Mismatched anchors are never silently relocated to a "close enough" line. This trades convenience for correctness.
|
|
102
|
-
- **Strict patch content.** If `
|
|
103
|
-
- **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_incl: ["<START>", "<END>"], new_lines: [...]}`.
|
|
106
|
+
- **Strict patch content.** If `content_lines` contains `+HASH│` display prefixes (or `-N ` numbered deletion rows), the edit is rejected with `[E_INVALID_PATCH]`. This narrowly guards against pasting the tool's own diff-preview rows back as content; standard unified-diff lines (`+x`, `-x`, ` x`, `@@ … @@`) are **not** rejected — they are written literally, since literal content must never be silently altered. Bare `HASH│` content (the first 4 chars of a `content_lines` entry looking like 3 base64 chars + `│`) is rejected with `[E_BARE_HASH_PREFIX]`. When the suspect's prefix happens to match a real file-line anchor, the error message flags that as strong evidence the model copied an anchor from the read output.
|
|
104
107
|
- **Atomic writes.** Files are written via temp-file-then-rename to avoid corruption from interrupted writes. Symlink chains are resolved so the target file is updated without replacing the symlink. Hard-linked files are updated in place to preserve the shared inode. File permissions are preserved across atomic renames.
|
|
105
108
|
- **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
|
|
106
|
-
- **Boundary duplication warnings.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), a warning is emitted. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The warning
|
|
109
|
+
- **Boundary duplication warnings.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), a warning is emitted. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The warning is a short header followed by a hashline-anchored window: the duplicated pair plus 2 lines of context before and after, shown as plain `HASH│content` rows with post-edit hashes. Seeing the duplication in context (rather than just being told a hash) is what prompts the model to act on it — and since the hashes are current and staleness is per-line, the model can remove the duplicate in one follow-up `replace` without re-reading. No autocorrection is applied — the duplicate stays in the file and the model decides whether to remove it. Raw line comparison (not trimmed) avoids false positives when indentation differs.
|
|
107
110
|
|
|
108
111
|
## Hashing
|
|
109
112
|
|
|
@@ -111,11 +114,11 @@ Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (
|
|
|
111
114
|
|
|
112
115
|
The alphabet is sized for an LLM consumer. The model tokenizes, it doesn't squint at pixel glyphs, so the human-readability heuristics used by smaller hand-curated alphabets (no G/L/I/O because they look like digits, no vowels so the hash doesn't accidentally spell a word, no hex digits so it can't be confused with `0xFF`) don't apply. The full 64 chars give maximum entropy per character, with case and digits included.
|
|
113
116
|
|
|
114
|
-
**Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter:
|
|
117
|
+
**Perfect hashing (collision resolution):** When computing hashes for a file, if a line's base hash collides with an already-assigned hash, the hash is incremented (using a retry counter: `:R{retry}`) until a unique hash is found. This ensures every line in a file gets a unique anchor, even with the shorter 3-character hash space. Two byte-identical lines (e.g. repeated `}` or repeated `import` statements) get different hashes automatically.
|
|
115
118
|
|
|
116
|
-
The runtime always precomputes the full per-line hash array for a file via `
|
|
119
|
+
The runtime always precomputes the full per-line hash array for a file via `lineHashes(content)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read.
|
|
117
120
|
|
|
118
|
-
`
|
|
121
|
+
`HASH_LEN` in `src/hashline/hash.ts` sets the hash body length; bump it to 4 if you need even more entropy without collision resolution.
|
|
119
122
|
|
|
120
123
|
### Bare-prefix detector
|
|
121
124
|
|
package/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { readFile } from "fs/promises";
|
|
|
3
3
|
import { join, isAbsolute } from "path";
|
|
4
4
|
import { lineHashes, initHasher, fmtRegion } from "./src/hashline";
|
|
5
5
|
import { regReplace } from "./src/replace";
|
|
6
|
-
import { regRead } from "./src/read";
|
|
6
|
+
import { regRead, formatPaginationHint } from "./src/read";
|
|
7
7
|
import { toLF } from "./src/replace-diff";
|
|
8
8
|
import { visLines } from "./src/utils";
|
|
9
9
|
import { AUTO_READ_MAX } from "./src/constants";
|
|
@@ -54,7 +54,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
54
54
|
const hashlineOutput = fmtRegion(selectedHashes, displayLines);
|
|
55
55
|
|
|
56
56
|
const paginationHint = truncated
|
|
57
|
-
? `\n\n
|
|
57
|
+
? `\n\n${formatPaginationHint(1, AUTO_READ_MAX, visibleLines.length, AUTO_READ_MAX + 1)}`
|
|
58
58
|
: "";
|
|
59
59
|
|
|
60
60
|
if (hashlineOutput) {
|
package/package.json
CHANGED
package/prompts/read.md
CHANGED
|
@@ -7,6 +7,7 @@ HASH format:
|
|
|
7
7
|
|
|
8
8
|
Pagination:
|
|
9
9
|
- Large files return a truncated preview with a pagination hint (e.g. `[Showing lines 1-100 of 500. Use offset=101 to continue.]`). Call `read` again with `offset=N` to continue.
|
|
10
|
+
- Default cap: {{DEFAULT_MAX_LINES}} lines or {{DEFAULT_MAX_BYTES}}; output exceeding either is truncated. Pass `limit` to read fewer lines.
|
|
10
11
|
|
|
11
12
|
File kinds:
|
|
12
13
|
- Text files are returned as `HASH│content` lines.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
- Use `replace` with HASH anchors for all file changes; batch every change to one file into a single `replace` call.
|
|
2
2
|
- After a successful `replace`, the response text is empty (warnings only). Call `read` to get fresh anchors for follow-up edits.
|
|
3
|
-
- On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH
|
|
3
|
+
- On `[E_STALE_ANCHOR]`, call `read` to get fresh anchors, copy the 3-character HASH of the start and end of the range you are replacing into `hash_range_incl`, and retry.
|
package/prompts/replace.md
CHANGED
|
@@ -87,10 +87,10 @@ Right:
|
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
Rules:
|
|
90
|
-
- `hash_range_incl` is a pair `[start, end]`. A single-line replace is `hash_range_incl: ["X", "X"]
|
|
90
|
+
- `hash_range_incl` is a pair `[start, end]`. A single-line replace is `hash_range_incl: ["X", "X"]`.
|
|
91
91
|
- To delete a range, use `content_lines: []`.
|
|
92
92
|
- `hash_range_incl` elements are HASH anchors only (e.g. `aB3`). Do not include `│` or line content.
|
|
93
|
-
- `content_lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix
|
|
93
|
+
- `content_lines` is literal file content — each string becomes exactly one line in the file. No `HASH│` prefix. A line that happens to start with `+` or `-` is written as-is; the only rejected form is the diff preview's `+HASH│…` row (see `[E_INVALID_PATCH]`).
|
|
94
94
|
- Don't add `""` for spacing unless you actually want a new blank line.
|
|
95
95
|
- Copy anchors from the most recent `read` of the file. Do not guess or construct them.
|
|
96
96
|
- All changes in one call must be non-conflicting. The runtime rejects with `[E_EDIT_CONFLICT]` if two ranges overlap.
|
|
@@ -99,7 +99,7 @@ Rules:
|
|
|
99
99
|
On success, the response text is empty (or contains only warnings if present). Call `read` to get fresh anchors for follow-up edits.
|
|
100
100
|
|
|
101
101
|
Error recovery:
|
|
102
|
-
- `[E_STALE_ANCHOR]` —
|
|
102
|
+
- `[E_STALE_ANCHOR]` — the anchored line's content changed since the last read. Call `read` to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into `hash_range_incl` and retry. (Staleness is per-line: editing or appending lines does not invalidate anchors for lines whose content is unchanged, so anchors for untouched regions stay valid across edits.)
|
|
103
103
|
- `[E_BAD_REF]` — malformed HASH. Re-read and try again.
|
|
104
104
|
- `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
|
|
105
105
|
- `[E_BAD_SHAPE]` — malformed request or change item (missing fields, wrong types, unknown fields).
|
|
@@ -107,5 +107,5 @@ Error recovery:
|
|
|
107
107
|
- `[E_EDIT_CONFLICT]` — two changes overlap on the same line range. Make changes non-overlapping.
|
|
108
108
|
- `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
|
|
109
109
|
- `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_incl` uses hashes, `content_lines` does not.
|
|
110
|
-
- `[E_INVALID_PATCH]` — diff
|
|
110
|
+
- `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
|
|
111
111
|
- `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
|
package/src/constants.ts
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
export const AUTO_READ_MAX = 2000;
|
|
2
2
|
export const ANCHOR_BUDGET = 50 * 1024;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Context lines of post-edit anchor surfacing. Intentionally 0: after a
|
|
6
|
+
* successful `replace` the response text is empty by design (the model calls
|
|
7
|
+
* `read` for fresh anchors — see "Chained edits" in the README). With 0,
|
|
8
|
+
* `affRange` returns null, so the anchor-block branch in `buildChanged`
|
|
9
|
+
* (`src/replace-response.ts`) is dormant. Set this > 0 (and likely bump
|
|
10
|
+
* MAX_OUT) to revive surfacing a small anchor window after each edit.
|
|
11
|
+
*/
|
|
3
12
|
export const CTX_LINES = 0;
|
|
4
13
|
export const MAX_OUT = 12;
|
|
5
14
|
export const SNIFF_BYTES = 8192;
|
|
6
15
|
export const MAX_BYTES = 100 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hard ceiling on the number of lines the edit/hash path will process. Guards
|
|
19
|
+
* `lineHashes` (which holds a per-line array plus a Set of every hash) and the
|
|
20
|
+
* host diff against pathological inputs such as a multi-tens-of-MB generated
|
|
21
|
+
* bundle or data dump. Only the `replace` path enforces it (`readNormFile` is
|
|
22
|
+
* called with this limit only from `replace.ts`); `read` keeps its
|
|
23
|
+
* truncate-and-preview behavior. ~1M lines is well above any realistic source
|
|
24
|
+
* file, so this only fires on genuine pathologies. Tunable: lower it to bound
|
|
25
|
+
* hashing cost more tightly.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_HASH_LINES = 1_000_000;
|
package/src/file-reader.ts
CHANGED
|
@@ -21,6 +21,7 @@ export async function readNormFile(
|
|
|
21
21
|
signal: AbortSignal | undefined,
|
|
22
22
|
accessMode: number = constants.R_OK,
|
|
23
23
|
preloadedFile?: LFile,
|
|
24
|
+
maxLines?: number,
|
|
24
25
|
): Promise<NormFile> {
|
|
25
26
|
const absolutePath = toCwd(path, cwd);
|
|
26
27
|
|
|
@@ -35,6 +36,16 @@ export async function readNormFile(
|
|
|
35
36
|
const { bom, text: rawContent } = stripBOM(file.text);
|
|
36
37
|
const originalEnding = detectEnding(rawContent);
|
|
37
38
|
const normalized = toLF(rawContent);
|
|
39
|
+
|
|
40
|
+
if (maxLines !== undefined) {
|
|
41
|
+
const lineCount = normalized.split("\n").length;
|
|
42
|
+
if (lineCount > maxLines) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${maxLines}-line edit limit. Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
const fileHashes = lineHashes(normalized);
|
|
39
50
|
|
|
40
51
|
return {
|
package/src/hashline/apply.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { abortIf } from "../runtime";
|
|
2
|
-
import { lineHashes } from "./hash";
|
|
2
|
+
import { lineHashes, HASH_SEP } from "./hash";
|
|
3
3
|
import {
|
|
4
4
|
valEdits,
|
|
5
5
|
assertNoBarePrefix,
|
|
@@ -211,6 +211,56 @@ function assemble(
|
|
|
211
211
|
return result;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Builds the boundary-duplication warning shown after an edit. A minimal header
|
|
216
|
+
* is followed by a hashline-anchored window: 2 lines of context before the
|
|
217
|
+
* duplicated pair, the pair itself, and 2 lines after. The window carries the
|
|
218
|
+
* post-edit hashes the model needs to remove the duplicate in a follow-up
|
|
219
|
+
* `replace` (no `read` round-trip required, since the hashes are current and
|
|
220
|
+
* staleness is per-line). Rows are plain `HASH│content` — no annotations.
|
|
221
|
+
*/
|
|
222
|
+
function fmtBoundaryWarning(params: {
|
|
223
|
+
kind: "trailing" | "leading";
|
|
224
|
+
survivingContent: string;
|
|
225
|
+
matchIndex: number;
|
|
226
|
+
resultLines: string[];
|
|
227
|
+
resultHashes: string[];
|
|
228
|
+
}): string {
|
|
229
|
+
const header =
|
|
230
|
+
params.kind === "trailing"
|
|
231
|
+
? "Boundary duplication (trailing): the last replacement line duplicated the next line. Delete the duplicate."
|
|
232
|
+
: "Boundary duplication (leading): the first replacement line duplicated the previous line. Delete the duplicate.";
|
|
233
|
+
|
|
234
|
+
// Locate the adjacent duplicated pair (two identical neighboring lines). The
|
|
235
|
+
// occurrence-based matchIndex usually lands inside the pair; when a file has
|
|
236
|
+
// several identical lines we pick the pair nearest matchIndex so the window
|
|
237
|
+
// frames the duplication this edit introduced, not an unrelated one.
|
|
238
|
+
let pairStart = -1;
|
|
239
|
+
let bestDist = Infinity;
|
|
240
|
+
for (let i = 0; i < params.resultLines.length - 1; i++) {
|
|
241
|
+
if (
|
|
242
|
+
params.resultLines[i] === params.survivingContent &&
|
|
243
|
+
params.resultLines[i + 1] === params.survivingContent
|
|
244
|
+
) {
|
|
245
|
+
const dist = Math.abs(i - params.matchIndex);
|
|
246
|
+
if (dist < bestDist) {
|
|
247
|
+
bestDist = dist;
|
|
248
|
+
pairStart = i;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (pairStart < 0) pairStart = params.matchIndex;
|
|
253
|
+
|
|
254
|
+
const winStart = Math.max(0, pairStart - 2);
|
|
255
|
+
const winEnd = Math.min(params.resultLines.length - 1, pairStart + 3);
|
|
256
|
+
|
|
257
|
+
const rows: string[] = [];
|
|
258
|
+
for (let i = winStart; i <= winEnd; i++) {
|
|
259
|
+
rows.push(`${params.resultHashes[i]}${HASH_SEP}${params.resultLines[i]}`);
|
|
260
|
+
}
|
|
261
|
+
return `${header}\n\n${rows.join("\n")}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
214
264
|
export function applyEdits(
|
|
215
265
|
content: string,
|
|
216
266
|
edits: import("./resolve").HEdit[],
|
|
@@ -284,16 +334,15 @@ export function applyEdits(
|
|
|
284
334
|
}
|
|
285
335
|
}
|
|
286
336
|
if (matchIndex >= 0) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
337
|
+
warnings.push(
|
|
338
|
+
fmtBoundaryWarning({
|
|
339
|
+
kind: bw.kind,
|
|
340
|
+
survivingContent: bw.survivingLineContent,
|
|
341
|
+
matchIndex,
|
|
342
|
+
resultLines,
|
|
343
|
+
resultHashes,
|
|
344
|
+
}),
|
|
345
|
+
);
|
|
297
346
|
}
|
|
298
347
|
}
|
|
299
348
|
|
|
@@ -357,7 +406,7 @@ export function fmtRegion(
|
|
|
357
406
|
);
|
|
358
407
|
}
|
|
359
408
|
return lines
|
|
360
|
-
.map((line, index) => `${hashes[index]}
|
|
409
|
+
.map((line, index) => `${hashes[index]}${HASH_SEP}${line}`)
|
|
361
410
|
.join("\n");
|
|
362
411
|
}
|
|
363
412
|
|
package/src/hashline/hash.ts
CHANGED
|
@@ -3,6 +3,13 @@ import xxhash from "xxhash-wasm";
|
|
|
3
3
|
export const HASH_LEN = 3;
|
|
4
4
|
export const ANCHOR_LEN = HASH_LEN;
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The `│` (U+2502) delimiter between a hash anchor and its line content. This
|
|
8
|
+
* is the wire-format separator in `HASH│content` rows. Kept as a constant so
|
|
9
|
+
* every construction site resolves the same delimiter.
|
|
10
|
+
*/
|
|
11
|
+
export const HASH_SEP = "│";
|
|
12
|
+
|
|
6
13
|
const ALPH =
|
|
7
14
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
8
15
|
const ALPH_BITS = 6;
|
package/src/hashline/index.ts
CHANGED
package/src/hashline/parse.ts
CHANGED
|
@@ -49,7 +49,7 @@ function assertNoPrefixes(lines: string[]): void {
|
|
|
49
49
|
DIFF_MINUS_RE.test(line)
|
|
50
50
|
) {
|
|
51
51
|
throw new Error(
|
|
52
|
-
`[E_INVALID_PATCH] "
|
|
52
|
+
`[E_INVALID_PATCH] "content_lines" must contain literal file content. Offending line looks like the diff preview's +HASH│ row: ${JSON.stringify(line)}. Use literal file content only — plain + or - lines are written literally.`
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
}
|
package/src/hashline/resolve.ts
CHANGED
|
@@ -90,13 +90,13 @@ export function fmtMismatch(
|
|
|
90
90
|
const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
|
|
91
91
|
if (notFound.length > 0) {
|
|
92
92
|
out.push(
|
|
93
|
-
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH
|
|
93
|
+
`[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_incl of your next replace call.`
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
if (ambiguous.length > 0) {
|
|
97
97
|
if (out.length > 0) out.push("");
|
|
98
98
|
out.push(
|
|
99
|
-
`[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH
|
|
99
|
+
`[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_incl of your next replace call.`
|
|
100
100
|
);
|
|
101
101
|
for (const m of ambiguous) {
|
|
102
102
|
const sample = (m.candidates ?? []).slice(0, 5);
|
package/src/read.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { Type } from "typebox";
|
|
11
11
|
import { loadFileKindAndText } from "./file-kind";
|
|
12
12
|
import { readNormFile } from "./file-reader";
|
|
13
|
-
import { lineHashes, fmtRegion } from "./hashline";
|
|
13
|
+
import { lineHashes, fmtRegion, HASH_SEP } from "./hashline";
|
|
14
14
|
import { toCwd } from "./path-utils";
|
|
15
15
|
import { abortIf } from "./runtime";
|
|
16
16
|
import { fileSnap } from "./snapshot";
|
|
@@ -41,6 +41,22 @@ function normPosInt(
|
|
|
41
41
|
return value;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Builds the `[Showing lines A-B of N. Use offset=M to continue.]` hint shared by
|
|
46
|
+
* `fmtReadPreview` and the auto-read-after-write handler. `byteLimit` adds the
|
|
47
|
+
* `(size limit)` suffix used when truncation is byte-driven.
|
|
48
|
+
*/
|
|
49
|
+
export function formatPaginationHint(
|
|
50
|
+
startLine: number,
|
|
51
|
+
endLine: number,
|
|
52
|
+
totalLines: number,
|
|
53
|
+
nextOffset: number,
|
|
54
|
+
byteLimit?: number,
|
|
55
|
+
): string {
|
|
56
|
+
const sizeSuffix = byteLimit !== undefined ? ` (${formatSize(byteLimit)} limit)` : "";
|
|
57
|
+
return `[Showing lines ${startLine}-${endLine} of ${totalLines}${sizeSuffix}. Use offset=${nextOffset} to continue.]`;
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
export function fmtReadPreview(
|
|
45
61
|
text: string,
|
|
46
62
|
options: { offset?: number; limit?: number },
|
|
@@ -54,7 +70,7 @@ export function fmtReadPreview(
|
|
|
54
70
|
const allHashes = precomputedHashes ?? lineHashes(text);
|
|
55
71
|
const emptyLineHash = allHashes[0] ?? "";
|
|
56
72
|
return {
|
|
57
|
-
text: `${emptyLineHash}
|
|
73
|
+
text: `${emptyLineHash}${HASH_SEP}\n[File is empty. Use replace to insert content.]`,
|
|
58
74
|
};
|
|
59
75
|
}
|
|
60
76
|
return {
|
|
@@ -91,13 +107,13 @@ export function fmtReadPreview(
|
|
|
91
107
|
const endLineDisplay = startLine + truncation.outputLines - 1;
|
|
92
108
|
nextOffset = endLineDisplay + 1;
|
|
93
109
|
if (truncation.truncatedBy === "lines") {
|
|
94
|
-
preview += `\n\n
|
|
110
|
+
preview += `\n\n${formatPaginationHint(startLine, endLineDisplay, totalLines, nextOffset)}`;
|
|
95
111
|
} else {
|
|
96
|
-
preview += `\n\n
|
|
112
|
+
preview += `\n\n${formatPaginationHint(startLine, endLineDisplay, totalLines, nextOffset, truncation.maxBytes)}`;
|
|
97
113
|
}
|
|
98
114
|
} else if (endIdx < totalLines) {
|
|
99
115
|
nextOffset = endIdx + 1;
|
|
100
|
-
preview += `\n\n
|
|
116
|
+
preview += `\n\n${formatPaginationHint(startLine, endIdx, totalLines, nextOffset)}`;
|
|
101
117
|
}
|
|
102
118
|
|
|
103
119
|
return {
|
package/src/replace-diff.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as Diff from "diff";
|
|
|
2
2
|
import {
|
|
3
3
|
lineHashes,
|
|
4
4
|
ANCHOR_LEN,
|
|
5
|
+
HASH_SEP,
|
|
5
6
|
} from "./hashline";
|
|
6
7
|
|
|
7
8
|
export function detectEnding(content: string): "\r\n" | "\n" {
|
|
@@ -34,9 +35,9 @@ function fmtDiffLine(
|
|
|
34
35
|
hash: string | undefined,
|
|
35
36
|
): string {
|
|
36
37
|
if (hash === undefined) {
|
|
37
|
-
return `${prefix}${" ".repeat(ANCHOR_LEN)}
|
|
38
|
+
return `${prefix}${" ".repeat(ANCHOR_LEN)}${HASH_SEP}${line}`;
|
|
38
39
|
}
|
|
39
|
-
return `${prefix}${hash}
|
|
40
|
+
return `${prefix}${hash}${HASH_SEP}${line}`;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export function genDiff(
|
package/src/replace.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { readNormFile } from "./file-reader";
|
|
14
14
|
import { normReq } from "./replace-normalize";
|
|
15
15
|
import { isRec, has, rejectUnknownFields } from "./utils";
|
|
16
|
+
import { MAX_HASH_LINES } from "./constants";
|
|
16
17
|
import { resolveTarget, writeAtomic } from "./fs-write";
|
|
17
18
|
import {
|
|
18
19
|
applyEdits,
|
|
@@ -146,7 +147,7 @@ async function execPipeline(
|
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors } = await readNormFile(
|
|
149
|
-
path, cwd, signal, accessMode,
|
|
150
|
+
path, cwd, signal, accessMode, undefined, MAX_HASH_LINES,
|
|
150
151
|
);
|
|
151
152
|
|
|
152
153
|
const resolved = resEdits(toolEdits);
|