pi-hashline-edit-pro 0.3.1 → 0.3.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 -15
- package/package.json +1 -1
- package/prompts/edit-snippet.md +1 -1
- package/prompts/edit.md +12 -12
- package/prompts/read-guidelines.md +2 -2
- package/prompts/read-snippet.md +1 -1
- package/prompts/read.md +8 -8
- package/src/edit-diff.ts +2 -7
- package/src/edit-normalize.ts +0 -29
- package/src/edit-response.ts +2 -26
- package/src/edit.ts +4 -50
- package/src/hashline/apply.ts +3 -75
- package/src/hashline/hash.ts +10 -114
- package/src/hashline/index.ts +0 -32
- package/src/hashline/parse.ts +6 -57
- package/src/hashline/resolve.ts +11 -119
- package/src/read.ts +0 -6
package/src/hashline/resolve.ts
CHANGED
|
@@ -1,23 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolution — anchor resolution, edit validation, and mismatch formatting.
|
|
3
|
-
*
|
|
4
|
-
* This module owns the logic that resolves hash anchors to line numbers,
|
|
5
|
-
* validates edit structure, and formats mismatch errors. It is the bridge
|
|
6
|
-
* between the parsed edit requests and the apply pipeline.
|
|
7
|
-
*/
|
|
8
1
|
|
|
9
2
|
import { throwIfAborted } from "../runtime";
|
|
10
3
|
import { HASHLINE_BARE_PREFIX_RE } from "./hash";
|
|
11
4
|
import { parseHashRef, hashlineParseText, type Anchor } from "./parse";
|
|
12
5
|
|
|
13
|
-
// ─── Types ──────────────────────────────────────────────────────────────
|
|
14
6
|
|
|
15
|
-
/**
|
|
16
|
-
* The internal, post-resolution representation of an anchor. After
|
|
17
|
-
* `validateAnchorEdits` has resolved the hash to a line, the resulting
|
|
18
|
-
* `ResolvedAnchor` carries the line number plus whether the hash matched
|
|
19
|
-
* exactly (vs. falling back to no-anchor-found / not-found).
|
|
20
|
-
*/
|
|
21
7
|
export type ResolvedAnchor = {
|
|
22
8
|
line: number;
|
|
23
9
|
hash: string;
|
|
@@ -29,11 +15,6 @@ export type HashlineEdit =
|
|
|
29
15
|
| { op: "append"; pos?: Anchor; lines: string[] }
|
|
30
16
|
| { op: "prepend"; pos?: Anchor; lines: string[] };
|
|
31
17
|
|
|
32
|
-
/**
|
|
33
|
-
* A `HashlineEdit` with all anchors resolved to line numbers. This is
|
|
34
|
-
* the shape consumed by `resolveEditToSpan` and the rest of the apply
|
|
35
|
-
* pipeline.
|
|
36
|
-
*/
|
|
37
18
|
export type ResolvedHashlineEdit =
|
|
38
19
|
| {
|
|
39
20
|
op: "replace";
|
|
@@ -56,17 +37,6 @@ export interface NoopEdit {
|
|
|
56
37
|
currentContent: string;
|
|
57
38
|
}
|
|
58
39
|
|
|
59
|
-
/**
|
|
60
|
-
* Schema-level edit as received from the tool layer.
|
|
61
|
-
*
|
|
62
|
-
* `pos` is the anchor for `append`/`prepend`; `start` and `end` are the
|
|
63
|
-
* inclusive range anchors for `replace`. `lines` is canonicalized to an array.
|
|
64
|
-
*
|
|
65
|
-
* The `oldText` and `newText` fields are legacy — they exist on this type
|
|
66
|
-
* only because `normalizeEditRequest` may pass them through before
|
|
67
|
-
* `assertEditRequest` rejects them with `[E_LEGACY_SHAPE]`. They are
|
|
68
|
-
* never accepted by the edit pipeline. Do not use them in new code.
|
|
69
|
-
*/
|
|
70
40
|
export type HashlineToolEdit = {
|
|
71
41
|
op: string;
|
|
72
42
|
pos?: string;
|
|
@@ -79,17 +49,7 @@ export type HashlineToolEdit = {
|
|
|
79
49
|
newText?: string;
|
|
80
50
|
};
|
|
81
51
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Resolve an `Anchor` to a specific line in the file.
|
|
86
|
-
*
|
|
87
|
-
* Returns a `ResolvedAnchor` on success. Returns an error object on:
|
|
88
|
-
* - `not_found`: no line in the file has this hash
|
|
89
|
-
* - `ambiguous`: the hash matches multiple lines (the model must
|
|
90
|
-
* re-read to disambiguate; the runtime does not accept a
|
|
91
|
-
* `#HASH:content` disambiguator on the wire)
|
|
92
|
-
*/
|
|
52
|
+
|
|
93
53
|
function resolveAnchor(
|
|
94
54
|
ref: Anchor,
|
|
95
55
|
fileLines: string[],
|
|
@@ -112,7 +72,6 @@ function resolveAnchor(
|
|
|
112
72
|
return { ref, kind: "ambiguous", candidates: hashMatches };
|
|
113
73
|
}
|
|
114
74
|
|
|
115
|
-
// ─── Mismatch formatting ────────────────────────────────────────────────
|
|
116
75
|
|
|
117
76
|
export function formatMismatchError(
|
|
118
77
|
mismatches: HashMismatch[],
|
|
@@ -148,12 +107,12 @@ export function formatMismatchError(
|
|
|
148
107
|
const lines = sample
|
|
149
108
|
.map((line) => {
|
|
150
109
|
const content = fileLines[line - 1] ?? "";
|
|
151
|
-
return ` ${line}: ${fileHashes[line - 1]}
|
|
110
|
+
return ` ${line}: ${fileHashes[line - 1]}│${content}`;
|
|
152
111
|
})
|
|
153
112
|
.join("\n");
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
113
|
+
out.push(
|
|
114
|
+
` Hash "${m.ref.hash}" matches lines ${sample.join(", ")}${more}.\n${lines}`,
|
|
115
|
+
);
|
|
157
116
|
}
|
|
158
117
|
}
|
|
159
118
|
|
|
@@ -161,7 +120,7 @@ export function formatMismatchError(
|
|
|
161
120
|
out.push("Current state (first lines):");
|
|
162
121
|
const sampleSize = Math.min(fileLines.length, 5);
|
|
163
122
|
for (let i = 0; i < sampleSize; i++) {
|
|
164
|
-
out.push(`>>> ${fileHashes[i]}
|
|
123
|
+
out.push(`>>> ${fileHashes[i]}│${fileLines[i]}`);
|
|
165
124
|
}
|
|
166
125
|
if (fileLines.length > sampleSize) {
|
|
167
126
|
out.push(`... ${fileLines.length - sampleSize} more.`);
|
|
@@ -170,26 +129,7 @@ export function formatMismatchError(
|
|
|
170
129
|
return out.join("\n");
|
|
171
130
|
}
|
|
172
131
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Validate + parse flat tool-schema edits into typed internal representations.
|
|
177
|
-
*
|
|
178
|
-
* This is the single source of truth for per-edit structural validation (shape,
|
|
179
|
-
* op constraints, field types) and anchor parsing. `assertEditRequest` validates
|
|
180
|
-
* only the request envelope (path, returnMode, etc.) and delegates here for
|
|
181
|
-
* edit payload validation.
|
|
182
|
-
*
|
|
183
|
-
* Strict: provided anchors must parse successfully. Missing anchors are
|
|
184
|
-
* fine for append (→ EOF) and prepend (→ BOF), but a malformed anchor
|
|
185
|
-
* that was explicitly supplied is always an error.
|
|
186
|
-
*
|
|
187
|
-
* - replace + start + end → range replace (both anchors required; for a
|
|
188
|
-
* single-line replace, set start = end = the line's hash)
|
|
189
|
-
* - append + pos → append after that anchor
|
|
190
|
-
* - prepend + pos → prepend before that anchor
|
|
191
|
-
* - no anchors → file-level append/prepend (only for those ops)
|
|
192
|
-
*/
|
|
132
|
+
|
|
193
133
|
const ITEM_KEYS = new Set(["op", "pos", "start", "end", "lines"]);
|
|
194
134
|
function isStringArray(value: unknown): value is string[] {
|
|
195
135
|
return (
|
|
@@ -269,15 +209,7 @@ export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
|
269
209
|
const op = edit.op;
|
|
270
210
|
switch (op) {
|
|
271
211
|
case "replace": {
|
|
272
|
-
// Normalize
|
|
273
|
-
// (deletion). The "lines.length > 0" branch in resolveEditToSpan
|
|
274
|
-
// preserves the trailing newline of the last replaced line, so a
|
|
275
|
-
// single-element empty array would leave that newline behind and
|
|
276
|
-
// produce an extra blank line. Models commonly emit `[""]` to
|
|
277
|
-
// mean "delete this", and the deletion branch handles the
|
|
278
|
-
// trailing newline correctly. Note: this is `replace`-only;
|
|
279
|
-
// `append`/`prepend` legitimately use `[""]` to insert a blank
|
|
280
|
-
// line.
|
|
212
|
+
// Normalize lines: [""] to lines: [] for deletion.
|
|
281
213
|
const replaceLines = hashlineParseText(edit.lines ?? null);
|
|
282
214
|
const normalizedLines =
|
|
283
215
|
replaceLines.length === 1 && replaceLines[0] === ""
|
|
@@ -312,7 +244,6 @@ export function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
|
312
244
|
return result;
|
|
313
245
|
}
|
|
314
246
|
|
|
315
|
-
// ─── Anchor validation ──────────────────────────────────────────────────
|
|
316
247
|
|
|
317
248
|
function maybeWarnSuspiciousUnicodeEscapePlaceholder(
|
|
318
249
|
edits: HashlineEdit[],
|
|
@@ -327,28 +258,6 @@ function maybeWarnSuspiciousUnicodeEscapePlaceholder(
|
|
|
327
258
|
}
|
|
328
259
|
}
|
|
329
260
|
|
|
330
|
-
/**
|
|
331
|
-
* Reject edit content that starts with a bare hash prefix. Companion to
|
|
332
|
-
* `assertNoDisplayPrefixes`, which rejects the unambiguous `+HASH:` form at
|
|
333
|
-
* the parse stage; this catches the bare `HASH:` form (after optional leading
|
|
334
|
-
* whitespace) at the apply stage. The first 5 characters of every `lines`
|
|
335
|
-
* entry are checked: `#` prefix + 4 alphabet characters (A–Z, a–z, 0–9, `-`, `_`)
|
|
336
|
-
* followed by `:`.
|
|
337
|
-
*
|
|
338
|
-
* Bare `#HASH:` prefixes in `lines` are almost always a model mistake — the
|
|
339
|
-
* model copied the hash prefix from a `read` output but dropped the rest of
|
|
340
|
-
* the rendered `#HASH:content` form. We reject with `[E_BARE_HASH_PREFIX]`
|
|
341
|
-
* rather than warn, because a stray hash in the file content is a silent
|
|
342
|
-
* correctness bug (the line is written verbatim, no autocorrection) and
|
|
343
|
-
* because the cost of a false positive is small: the model can rephrase the
|
|
344
|
-
* line (e.g. quote it, escape the colon, or use a different identifier
|
|
345
|
-
* shape) and retry.
|
|
346
|
-
*
|
|
347
|
-
* The error message lists the offending lines, the suspect hash prefix for
|
|
348
|
-
* each, and whether any of them collide with a real file-line hash. A
|
|
349
|
-
* collision is a strong signal that the model was reading a `#HASH:content`
|
|
350
|
-
* line and copied only the prefix.
|
|
351
|
-
*/
|
|
352
261
|
export function assertNoBareHashPrefixLines(
|
|
353
262
|
edits: HashlineEdit[],
|
|
354
263
|
fileLines: string[],
|
|
@@ -360,8 +269,6 @@ export function assertNoBareHashPrefixLines(
|
|
|
360
269
|
`assertNoBareHashPrefixLines: fileHashes.length (${fileHashes.length}) must match fileLines.length (${fileLines.length}).`,
|
|
361
270
|
);
|
|
362
271
|
}
|
|
363
|
-
// Collect bare-prefix suspects up front: regex only. Almost every edit has
|
|
364
|
-
// none, so this lets the common path bail before paying for file hashes.
|
|
365
272
|
const suspects: { line: string; hash: string; editIndex: number; lineIndex: number }[] = [];
|
|
366
273
|
for (let editIndex = 0; editIndex < edits.length; editIndex++) {
|
|
367
274
|
const edit = edits[editIndex]!;
|
|
@@ -377,10 +284,8 @@ export function assertNoBareHashPrefixLines(
|
|
|
377
284
|
const fileHashSet = new Set(fileHashes);
|
|
378
285
|
const matched = suspects.filter((s) => fileHashSet.has(s.hash));
|
|
379
286
|
const matchedCount = matched.length;
|
|
380
|
-
const exampleLine = `${suspects[0]!.hash}
|
|
287
|
+
const exampleLine = `${suspects[0]!.hash}│${suspects[0]!.line}`;
|
|
381
288
|
|
|
382
|
-
// For Python files, return a warning instead of throwing — Python uses
|
|
383
|
-
// `else:`, `except:`, `elif:` etc. which trigger the bare-prefix detector.
|
|
384
289
|
if (isPython) {
|
|
385
290
|
const hint = matchedCount > 0
|
|
386
291
|
? `${matchedCount} prefix(es) match file line hashes.`
|
|
@@ -394,22 +299,10 @@ export function assertNoBareHashPrefixLines(
|
|
|
394
299
|
: `${matchedCount} match file line hashes — likely a copied hash.`;
|
|
395
300
|
|
|
396
301
|
throw new Error(
|
|
397
|
-
`[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (e.g. ${JSON.stringify(exampleLine)}). ${linesHint} Use literal file content in "lines" — never paste
|
|
302
|
+
`[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (e.g. ${JSON.stringify(exampleLine)}). ${linesHint} Use literal file content in \"lines\" — never paste HASH│content from read output.`
|
|
398
303
|
);
|
|
399
304
|
}
|
|
400
305
|
|
|
401
|
-
/**
|
|
402
|
-
* Validate + resolve hash-anchored edits against the current file content.
|
|
403
|
-
*
|
|
404
|
-
* For each anchor, the runtime:
|
|
405
|
-
* 1. Looks up the hash in the file's precomputed hash array.
|
|
406
|
-
* 2. If the hash uniquely matches a line, use it.
|
|
407
|
-
* 3. If the hash matches multiple lines (rare at 24 bits, but possible),
|
|
408
|
-
* this is `[E_AMBIGUOUS_ANCHOR]` — the model must re-read to refresh.
|
|
409
|
-
* 4. If the hash doesn't match any line, this is `[E_STALE_ANCHOR]`.
|
|
410
|
-
*
|
|
411
|
-
* Boundary / single-anchor / range warnings are appended to `warnings`.
|
|
412
|
-
*/
|
|
413
306
|
export function validateAnchorEdits(
|
|
414
307
|
edits: HashlineEdit[],
|
|
415
308
|
fileLines: string[],
|
|
@@ -451,7 +344,7 @@ export function validateAnchorEdits(
|
|
|
451
344
|
case "replace": {
|
|
452
345
|
const startResolved = tryResolve(edit.start);
|
|
453
346
|
const endResolved = tryResolve(edit.end);
|
|
454
|
-
|
|
347
|
+
if (!startResolved || !endResolved) {
|
|
455
348
|
continue;
|
|
456
349
|
}
|
|
457
350
|
if (startResolved.line > endResolved.line) {
|
|
@@ -548,5 +441,4 @@ export function validateAnchorEdits(
|
|
|
548
441
|
return { resolved, mismatches };
|
|
549
442
|
}
|
|
550
443
|
|
|
551
|
-
// Re-export for apply module
|
|
552
444
|
export { maybeWarnSuspiciousUnicodeEscapePlaceholder };
|
package/src/read.ts
CHANGED
|
@@ -95,12 +95,6 @@ export function formatHashlineReadPreview(
|
|
|
95
95
|
? Math.min(startLine - 1 + limit, totalLines)
|
|
96
96
|
: totalLines;
|
|
97
97
|
const selected = allLines.slice(startLine - 1, endIdx);
|
|
98
|
-
// The runtime precomputes the full per-line hash array so the model sees
|
|
99
|
-
// hashes consistent with what validation will compare against. Callers that
|
|
100
|
-
// already have one (the read tool, chained-edit tests) can pass it in;
|
|
101
|
-
// otherwise we recompute here for the visible window — acceptable for
|
|
102
|
-
// preview formatting because the hashes themselves are identical regardless
|
|
103
|
-
// of which slice of the file we look at.
|
|
104
98
|
const allHashes = precomputedHashes ?? computeLineHashes(text);
|
|
105
99
|
const selectedHashes = allHashes.slice(startLine - 1, endIdx);
|
|
106
100
|
const formatted = formatHashlineRegion(selectedHashes, selected);
|