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/apply.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Application — edit span resolution, conflict detection, and assembly.
|
|
3
|
-
*
|
|
4
|
-
* This module owns the pipeline that turns resolved edits into character-level
|
|
5
|
-
* spans, detects conflicts, and applies the spans back-to-front to produce
|
|
6
|
-
* the final file content. It also owns the changed-line-range computation
|
|
7
|
-
* and the hashline region formatting used by read and edit responses.
|
|
8
|
-
*/
|
|
9
1
|
|
|
10
2
|
import { throwIfAborted } from "../runtime";
|
|
11
3
|
import { computeLineHashes } from "./hash";
|
|
@@ -19,7 +11,6 @@ import {
|
|
|
19
11
|
type HashlineEdit,
|
|
20
12
|
} from "./resolve";
|
|
21
13
|
|
|
22
|
-
// ─── Line index ─────────────────────────────────────────────────────────
|
|
23
14
|
|
|
24
15
|
type LineIndex = {
|
|
25
16
|
fileLines: string[];
|
|
@@ -47,7 +38,6 @@ export function buildLineIndex(content: string): LineIndex {
|
|
|
47
38
|
};
|
|
48
39
|
}
|
|
49
40
|
|
|
50
|
-
// ─── Edit span resolution ───────────────────────────────────────────────
|
|
51
41
|
|
|
52
42
|
type ResolvedEditSpan = {
|
|
53
43
|
kind: "replace" | "insert";
|
|
@@ -319,13 +309,6 @@ function assertNoConflictingSpans(spans: ResolvedEditSpan[]): void {
|
|
|
319
309
|
}
|
|
320
310
|
}
|
|
321
311
|
|
|
322
|
-
/**
|
|
323
|
-
* Resolve validated edits into ordered, conflict-free character-level spans.
|
|
324
|
-
*
|
|
325
|
-
* Each edit is mapped through resolveEditToSpan (which may produce a noop),
|
|
326
|
-
* duplicate spans are deduplicated, conflicts are rejected, and the remaining
|
|
327
|
-
* spans are sorted back-to-front for safe in-place assembly.
|
|
328
|
-
*/
|
|
329
312
|
function resolveEditSpans(
|
|
330
313
|
edits: ResolvedHashlineEdit[],
|
|
331
314
|
content: string,
|
|
@@ -378,10 +361,6 @@ function resolveEditSpans(
|
|
|
378
361
|
});
|
|
379
362
|
}
|
|
380
363
|
|
|
381
|
-
/**
|
|
382
|
-
* Apply ordered spans to content in reverse (back-to-front) order so earlier
|
|
383
|
-
* spans' offsets stay valid.
|
|
384
|
-
*/
|
|
385
364
|
function assembleEditResult(
|
|
386
365
|
content: string,
|
|
387
366
|
spans: ResolvedEditSpan[],
|
|
@@ -406,25 +385,7 @@ function assembleEditResult(
|
|
|
406
385
|
return result;
|
|
407
386
|
}
|
|
408
387
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* Apply hashline-anchored edits to file content.
|
|
413
|
-
*
|
|
414
|
-
* Three-phase pipeline:
|
|
415
|
-
* 1. validateAnchorEdits — resolve each hash to a line; mismatches are
|
|
416
|
-
* rejected with `[E_STALE_ANCHOR]` and collisions with
|
|
417
|
-
* `[E_AMBIGUOUS_ANCHOR]`
|
|
418
|
-
* 2. resolveEditSpans — map edits to character spans, dedup, conflict-detect, sort
|
|
419
|
-
* 3. assembleEditResult — apply spans back-to-front, compute changed range
|
|
420
|
-
*
|
|
421
|
-
* `precomputedHashes` is an optional per-line hash array from
|
|
422
|
-
* `computeLineHashes(content)`. When provided, the same array is used for
|
|
423
|
-
* validation AND for the stale-anchor retry block in mismatch errors, so
|
|
424
|
-
* the hashes the model sees on a stale-anchor failure match the hashes the
|
|
425
|
-
* runtime actually validated against. When omitted, hashes are computed
|
|
426
|
-
* once at the top of this function and threaded through all phases.
|
|
427
|
-
*/
|
|
388
|
+
|
|
428
389
|
export function applyHashlineEdits(
|
|
429
390
|
content: string,
|
|
430
391
|
edits: import("./resolve").HashlineEdit[],
|
|
@@ -446,13 +407,7 @@ export function applyHashlineEdits(
|
|
|
446
407
|
lastChangedLine: undefined,
|
|
447
408
|
};
|
|
448
409
|
|
|
449
|
-
// Normalize
|
|
450
|
-
// to `lines: []` (deletion). The "non-empty lines" span branch preserves
|
|
451
|
-
// the trailing newline of the last replaced line, which would leave an
|
|
452
|
-
// extra blank line behind when the user meant to delete. Models commonly
|
|
453
|
-
// emit `[""]` to mean "delete this", and the deletion branch handles the
|
|
454
|
-
// trailing newline correctly. (`append`/`prepend` are unaffected — there
|
|
455
|
-
// `[""]` legitimately means "insert a blank line".)
|
|
410
|
+
// Normalize lines: [""] to lines: [] for deletion.
|
|
456
411
|
edits = edits.map((edit) =>
|
|
457
412
|
edit.op === "replace" &&
|
|
458
413
|
edit.lines.length === 1 &&
|
|
@@ -466,7 +421,6 @@ export function applyHashlineEdits(
|
|
|
466
421
|
const noopEdits: NoopEdit[] = [];
|
|
467
422
|
const warnings: string[] = [];
|
|
468
423
|
|
|
469
|
-
// Phase 1: validate anchors (and resolve to line numbers)
|
|
470
424
|
const { resolved, mismatches } = validateAnchorEdits(
|
|
471
425
|
edits,
|
|
472
426
|
lineIndex.fileLines,
|
|
@@ -484,7 +438,6 @@ export function applyHashlineEdits(
|
|
|
484
438
|
warnings.push(...barePrefixWarnings);
|
|
485
439
|
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
|
|
486
440
|
|
|
487
|
-
// Phase 2: resolve edits to ordered spans
|
|
488
441
|
const orderedSpans = resolveEditSpans(
|
|
489
442
|
resolved,
|
|
490
443
|
content,
|
|
@@ -493,7 +446,6 @@ export function applyHashlineEdits(
|
|
|
493
446
|
signal,
|
|
494
447
|
);
|
|
495
448
|
|
|
496
|
-
// Phase 3: assemble result
|
|
497
449
|
const result = assembleEditResult(content, orderedSpans, signal);
|
|
498
450
|
assertDoesNotEmptyFile(content, result);
|
|
499
451
|
const changedRange = computeChangedLineRange(content, result);
|
|
@@ -507,17 +459,10 @@ export function applyHashlineEdits(
|
|
|
507
459
|
};
|
|
508
460
|
}
|
|
509
461
|
|
|
510
|
-
// ─── Affected-line computation (for returning anchors after edit) ───────
|
|
511
462
|
|
|
512
463
|
const ANCHOR_CONTEXT_LINES = 2;
|
|
513
464
|
const ANCHOR_MAX_OUTPUT_LINES = 12;
|
|
514
465
|
|
|
515
|
-
/**
|
|
516
|
-
* Compute the post-edit line range covering changed lines plus context.
|
|
517
|
-
* Uses `firstChangedLine` and `lastChangedLine` from the edit result for
|
|
518
|
-
* precise bounds. Returns null if the range (with context) exceeds the
|
|
519
|
-
* output budget, signalling that the LLM should re-read instead.
|
|
520
|
-
*/
|
|
521
466
|
export function computeAffectedLineRange(params: {
|
|
522
467
|
firstChangedLine: number | undefined;
|
|
523
468
|
lastChangedLine: number | undefined;
|
|
@@ -537,7 +482,6 @@ export function computeAffectedLineRange(params: {
|
|
|
537
482
|
return null;
|
|
538
483
|
}
|
|
539
484
|
|
|
540
|
-
// Empty file after edit: no meaningful anchor block.
|
|
541
485
|
if (resultLineCount === 0) {
|
|
542
486
|
return null;
|
|
543
487
|
}
|
|
@@ -545,7 +489,6 @@ export function computeAffectedLineRange(params: {
|
|
|
545
489
|
const start = Math.max(1, firstChangedLine - contextLines);
|
|
546
490
|
const end = Math.min(resultLineCount, lastChangedLine + contextLines);
|
|
547
491
|
|
|
548
|
-
// Guard against inverted range (can happen when context pushes end below start).
|
|
549
492
|
if (end < start) {
|
|
550
493
|
return null;
|
|
551
494
|
}
|
|
@@ -557,15 +500,6 @@ export function computeAffectedLineRange(params: {
|
|
|
557
500
|
return { start, end };
|
|
558
501
|
}
|
|
559
502
|
|
|
560
|
-
/**
|
|
561
|
-
* Format a list of lines as `#HASH:content` rows.
|
|
562
|
-
*
|
|
563
|
-
* Used by the read tool's preview and the changed-mode anchor block. The
|
|
564
|
-
* hashes must be the precomputed per-line hashes for the file — see
|
|
565
|
-
* `computeLineHashes`. The line number is no longer part of the wire
|
|
566
|
-
* format; callers that need line numbers for pagination or context can
|
|
567
|
-
* compute them separately.
|
|
568
|
-
*/
|
|
569
503
|
export function formatHashlineRegion(
|
|
570
504
|
hashes: string[],
|
|
571
505
|
lines: string[],
|
|
@@ -576,17 +510,11 @@ export function formatHashlineRegion(
|
|
|
576
510
|
);
|
|
577
511
|
}
|
|
578
512
|
return lines
|
|
579
|
-
.map((line, index) => `${hashes[index]}
|
|
513
|
+
.map((line, index) => `${hashes[index]}│${line}`)
|
|
580
514
|
.join("\n");
|
|
581
515
|
}
|
|
582
516
|
|
|
583
|
-
// ─── Changed line range computation ─────────────────────────────────
|
|
584
517
|
|
|
585
|
-
/**
|
|
586
|
-
* Compute first/last changed line numbers between two document versions.
|
|
587
|
-
* Uses character-level diff to locate the changed span, then maps to line
|
|
588
|
-
* numbers in the result document so downstream anchor chaining works.
|
|
589
|
-
*/
|
|
590
518
|
export function computeChangedLineRange(
|
|
591
519
|
original: string,
|
|
592
520
|
result: string,
|
package/src/hashline/hash.ts
CHANGED
|
@@ -1,130 +1,51 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hash computation — xxHash32-based line hashing with occurrence-aware
|
|
3
|
-
* discriminators.
|
|
4
|
-
*
|
|
5
|
-
* This module owns the hash constants, the xxHash32 wrapper, and the
|
|
6
|
-
* per-line hash computation functions. Every other module that needs
|
|
7
|
-
* line hashes goes through `computeLineHashes` (full-file) or
|
|
8
|
-
* `computeLineHash` (single-line helper).
|
|
9
|
-
*/
|
|
10
1
|
|
|
11
2
|
import * as XXH from "xxhashjs";
|
|
12
3
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Hash length in characters. The original `pi-hashline-edit` uses 2 chars of
|
|
17
|
-
* a 16-char alphabet (8 bits / 256 buckets); this fork uses 4 chars of a
|
|
18
|
-
* 64-char alphabet (24 bits / 16 777 216 buckets). With HASH_LENGTH=4, the
|
|
19
|
-
* birthday paradox stays out of practical concern for any realistic file
|
|
20
|
-
* size. Bumping to 5 is a one-line change here if you want to push the
|
|
21
|
-
* threshold further; the cost is one more char per anchor in the `read`
|
|
22
|
-
* output.
|
|
23
|
-
*/
|
|
4
|
+
|
|
24
5
|
export const HASH_LENGTH = 4;
|
|
25
6
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export const
|
|
29
|
-
|
|
30
|
-
/** Total wire-format length of an anchor: prefix + hash body. */
|
|
31
|
-
export const ANCHOR_LENGTH = HASH_PREFIX.length + HASH_LENGTH;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* URL-safe base64 alphabet: A–Z, a–z, 0–9, `-`, `_`. 64 distinct chars
|
|
35
|
-
* giving 6 bits per hash character. No exclusions, no human-readability
|
|
36
|
-
* heuristics — the consumer is an LLM that tokenizes, not a human that
|
|
37
|
-
* squints at pixel glyphs. The `-` and `_` are at the end of the string
|
|
38
|
-
* so any character class built from this alphabet (e.g. `[${HASH_ALPHABET}]`)
|
|
39
|
-
* treats them as literal rather than as range operators.
|
|
40
|
-
*/
|
|
7
|
+
export const HASH_PREFIX = "";
|
|
8
|
+
|
|
9
|
+
export const ANCHOR_LENGTH = HASH_LENGTH;
|
|
10
|
+
|
|
41
11
|
const HASH_ALPHABET =
|
|
42
12
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
43
13
|
const HASH_ALPHABET_BITS = 6;
|
|
44
14
|
const HASH_ALPHABET_MASK = (1 << HASH_ALPHABET_BITS) - 1;
|
|
45
|
-
// `-` must be escaped when used inside a regex character class — otherwise it
|
|
46
|
-
// forms a range with the preceding char (`9-_` spans ASCII 57–95, which
|
|
47
|
-
// silently swallows the literal `-`). The `_` is always literal.
|
|
48
15
|
const HASH_ALPHABET_REGEX_SAFE = HASH_ALPHABET.replace(/-/g, "\\-");
|
|
49
16
|
const HASH_ALPHABET_RE = new RegExp(`^[${HASH_ALPHABET_REGEX_SAFE}]+$`);
|
|
50
17
|
export const HASH_CHARS_CLASS = `${HASH_PREFIX}[${HASH_ALPHABET_REGEX_SAFE}]{${HASH_LENGTH}}`;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Encode the top `HASH_LENGTH * 6` bits of a 32-bit hash value as a
|
|
54
|
-
* `HASH_LENGTH`-char string in the URL-safe base64 alphabet.
|
|
55
|
-
*
|
|
56
|
-
* The 0.2.0/0.3.0 releases pre-computed this mapping as a `DICT` lookup
|
|
57
|
-
* table. At 3 chars that was 262 144 entries × 3 chars = ~1 MB of static
|
|
58
|
-
* memory; at 4 chars it would be 16 777 216 entries × 4 chars = ~450 MB
|
|
59
|
-
* and a multi-second module load. So we now compute the string inline.
|
|
60
|
-
* The per-line cost is one xxHash32 call plus `HASH_LENGTH` small string
|
|
61
|
-
* concatenations, which is still nanoseconds — this is called once per
|
|
62
|
-
* line in `computeLineHashes`, not on a hot path.
|
|
63
|
-
*/
|
|
64
18
|
function hashToString(h: number): string {
|
|
65
19
|
const totalBits = HASH_LENGTH * HASH_ALPHABET_BITS;
|
|
66
20
|
const shift = 32 - totalBits;
|
|
67
21
|
let n = h >>> shift;
|
|
68
22
|
let out = "";
|
|
69
23
|
for (let j = 0; j < HASH_LENGTH; j++) {
|
|
70
|
-
// Build left-to-right: the first iteration writes the high-order
|
|
71
|
-
// 6 bits, the last writes the low-order 6 bits.
|
|
72
24
|
out +=
|
|
73
25
|
HASH_ALPHABET[
|
|
74
26
|
(n >>> ((HASH_LENGTH - 1 - j) * HASH_ALPHABET_BITS)) &
|
|
75
27
|
HASH_ALPHABET_MASK
|
|
76
28
|
]!;
|
|
77
29
|
}
|
|
78
|
-
return
|
|
30
|
+
return out;
|
|
79
31
|
}
|
|
80
32
|
|
|
81
|
-
/**
|
|
82
|
-
* Patterns used to detect (and reject) hashline display prefixes inside edit
|
|
83
|
-
* payloads. The runtime no longer strips them — the model must send literal
|
|
84
|
-
* file content. Matching any of these triggers `[E_INVALID_PATCH]`.
|
|
85
|
-
*/
|
|
86
33
|
export const HASHLINE_PREFIX_RE = new RegExp(
|
|
87
|
-
`^\\s*(?:>>>|>>)?\\s*${HASH_CHARS_CLASS}
|
|
34
|
+
`^\\s*(?:>>>|>>)?\\s*${HASH_CHARS_CLASS}│`,
|
|
88
35
|
);
|
|
89
36
|
export const HASHLINE_PREFIX_PLUS_RE = new RegExp(
|
|
90
|
-
`^\\+\\s*${HASH_CHARS_CLASS}
|
|
37
|
+
`^\\+\\s*${HASH_CHARS_CLASS}│`,
|
|
91
38
|
);
|
|
92
39
|
export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
93
40
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* no "LINE#" part (e.g. "#KKZ:### heading", "#TPN:text", "#TJZ:"). Capture
|
|
97
|
-
* group 1 is the full anchor (including `#` prefix).
|
|
98
|
-
*
|
|
99
|
-
* This is the partial-hash failure mode from issue #24: the model copies a
|
|
100
|
-
* hash it saw in `read` output into the line content but drops the rest
|
|
101
|
-
* of the rendered `#HASH:content` form. The anchor (prefix + HASH_LENGTH chars
|
|
102
|
-
* + ":") is matched by this regex, then `assertNoBareHashPrefixLines` rejects
|
|
103
|
-
* the edit with `[E_BARE_HASH_PREFIX]` so the model gets actionable feedback
|
|
104
|
-
* instead of a silent correctness bug.
|
|
105
|
-
*/
|
|
106
|
-
export const HASHLINE_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CHARS_CLASS}):`);
|
|
107
|
-
|
|
108
|
-
/** Lines containing no alphanumeric characters (only punctuation/symbols/whitespace). */
|
|
41
|
+
export const HASHLINE_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CHARS_CLASS})│`);
|
|
42
|
+
|
|
109
43
|
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
|
|
110
44
|
|
|
111
45
|
function xxh32(input: string, seed = 0): number {
|
|
112
46
|
return XXH.h32(seed).update(input).digest().toNumber() >>> 0;
|
|
113
47
|
}
|
|
114
48
|
|
|
115
|
-
/**
|
|
116
|
-
* Discriminator prefixes for the occurrence-aware hash space.
|
|
117
|
-
*
|
|
118
|
-
* `S${lineNumber}` puts symbol-only lines (lone `}`, etc.) into a namespace
|
|
119
|
-
* keyed by line number, so the same `}` on different lines never collides.
|
|
120
|
-
*
|
|
121
|
-
* `C${occurrence}` puts content lines into a namespace keyed by the running
|
|
122
|
-
* occurrence count of that canonical content, so the same `import {...}` on
|
|
123
|
-
* different lines never collides either. This is the key behavioural change
|
|
124
|
-
* from the upstream 2-char hash: identical content now hashes to different
|
|
125
|
-
* values at different positions, so the model can target a specific
|
|
126
|
-
* occurrence without resorting to `offset` + a small `limit` window.
|
|
127
|
-
*/
|
|
128
49
|
const SYMBOL_DISCRIMINATOR = (lineNumber: number): string => `S${lineNumber}`;
|
|
129
50
|
const CONTENT_DISCRIMINATOR = (occurrence: number): string => `C${occurrence}`;
|
|
130
51
|
|
|
@@ -136,19 +57,6 @@ function isSymbolOnly(canonical: string): boolean {
|
|
|
136
57
|
return !RE_SIGNIFICANT.test(canonical);
|
|
137
58
|
}
|
|
138
59
|
|
|
139
|
-
/**
|
|
140
|
-
* Compute hashes for every line of the file.
|
|
141
|
-
*
|
|
142
|
-
* Returns an array of length `lines.length`, where index `i` is the hash of
|
|
143
|
-
* line `i + 1` (1-indexed). Two lines with the same canonical content get
|
|
144
|
-
* different hashes based on which occurrence they are.
|
|
145
|
-
*
|
|
146
|
-
* The runtime always works from a precomputed array so that all validation,
|
|
147
|
-
* formatting, and error-message code paths see the same hash for a given line.
|
|
148
|
-
* The standalone `computeLineHash(idx, line)` helper below is kept for
|
|
149
|
-
* single-line use (e.g. diff-preview formatting) where occurrence context
|
|
150
|
-
* is not available; it treats the input as a 1st-occurrence content line.
|
|
151
|
-
*/
|
|
152
60
|
export function computeLineHashes(content: string): string[] {
|
|
153
61
|
const lines = content.split("\n");
|
|
154
62
|
const hashes = new Array<string>(lines.length);
|
|
@@ -169,16 +77,6 @@ export function computeLineHashes(content: string): string[] {
|
|
|
169
77
|
return hashes;
|
|
170
78
|
}
|
|
171
79
|
|
|
172
|
-
/**
|
|
173
|
-
* Single-line hash for callers that don't have the full file context.
|
|
174
|
-
*
|
|
175
|
-
* This treats the input as a 1st-occurrence content line (or, for symbol-only
|
|
176
|
-
* lines, as the line at index `idx`). It is the right answer for diff-preview
|
|
177
|
-
* formatting and for tests that build anchors one line at a time, but it is
|
|
178
|
-
* NOT the same as the hash that `computeLineHashes` would produce for the
|
|
179
|
-
* same line in a file with duplicate content. Production validation always
|
|
180
|
-
* uses `computeLineHashes` + per-line lookup.
|
|
181
|
-
*/
|
|
182
80
|
export function computeLineHash(idx: number, line: string): string {
|
|
183
81
|
const canonical = canonicalizeLine(line);
|
|
184
82
|
const discriminator = isSymbolOnly(canonical)
|
|
@@ -187,7 +85,6 @@ export function computeLineHash(idx: number, line: string): string {
|
|
|
187
85
|
return hashToString(xxh32(`${discriminator}:${canonical}`));
|
|
188
86
|
}
|
|
189
87
|
|
|
190
|
-
/** Exported for tests and for downstream tools that want to mirror the format. */
|
|
191
88
|
export const HASH_FORMAT = {
|
|
192
89
|
prefix: HASH_PREFIX,
|
|
193
90
|
length: HASH_LENGTH,
|
|
@@ -197,5 +94,4 @@ export const HASH_FORMAT = {
|
|
|
197
94
|
};
|
|
198
95
|
|
|
199
96
|
|
|
200
|
-
/** Re-export HASH_ALPHABET_RE for parse module */
|
|
201
97
|
export { HASH_ALPHABET_RE };
|
package/src/hashline/index.ts
CHANGED
|
@@ -1,35 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hashline engine — hash-anchored line editing.
|
|
3
|
-
*
|
|
4
|
-
* Forked from pi-hashline-edit (MIT, github.com/RimuruW/pi-hashline-edit),
|
|
5
|
-
* which was vendored & adapted from oh-my-pi (MIT, github.com/can1357/oh-my-pi).
|
|
6
|
-
*
|
|
7
|
-
* This fork preserves the strict semantics of the original (no silent
|
|
8
|
-
* relocation, no autocorrection heuristics, no fuzzy fallback) and uses a
|
|
9
|
-
* `#`-prefixed hash over a 64-character URL-safe base64 alphabet, giving
|
|
10
|
-
* 24 bits of entropy (16 777 216 buckets) per anchor. Birthday-paradox
|
|
11
|
-
* collisions become effectively zero for any realistic file size. The
|
|
12
|
-
* alphabet is sized for an LLM consumer, not a human reader — the model
|
|
13
|
-
* tokenizes, it does not squint at pixel glyphs.
|
|
14
|
-
*
|
|
15
|
-
* Anchor format: `#` prefix + 4 base64 chars (e.g. `#aB3x`). The line number
|
|
16
|
-
* is no longer part of the wire format, and no content may follow the anchor
|
|
17
|
-
* either. The model never has to type a line number; the runtime resolves each
|
|
18
|
-
* anchor to a line via the file's precomputed hash array.
|
|
19
|
-
* a line via the file's precomputed hash array.
|
|
20
|
-
*
|
|
21
|
-
* On a hash collision (two different lines happen to have the same hash
|
|
22
|
-
* — extremely rare at 24 bits) the anchor is rejected with
|
|
23
|
-
* `[E_AMBIGUOUS_ANCHOR]`. The model is expected to disambiguate by calling
|
|
24
|
-
* `read` again to get fresh hashes.
|
|
25
|
-
*/
|
|
26
1
|
|
|
27
|
-
// Re-export everything from sub-modules to preserve the public API surface.
|
|
28
|
-
// Consumers should import from "./hashline" (this index) and get the same
|
|
29
|
-
// symbols as before the split.
|
|
30
2
|
|
|
31
3
|
export {
|
|
32
|
-
// Hash computation
|
|
33
4
|
HASH_LENGTH,
|
|
34
5
|
HASH_PREFIX,
|
|
35
6
|
ANCHOR_LENGTH,
|
|
@@ -44,14 +15,12 @@ export {
|
|
|
44
15
|
} from "./hash";
|
|
45
16
|
|
|
46
17
|
export {
|
|
47
|
-
// Parsing
|
|
48
18
|
parseHashRef,
|
|
49
19
|
hashlineParseText,
|
|
50
20
|
type Anchor,
|
|
51
21
|
} from "./parse";
|
|
52
22
|
|
|
53
23
|
export {
|
|
54
|
-
// Resolution and validation
|
|
55
24
|
type ResolvedAnchor,
|
|
56
25
|
type HashlineEdit,
|
|
57
26
|
type ResolvedHashlineEdit,
|
|
@@ -64,7 +33,6 @@ export {
|
|
|
64
33
|
} from "./resolve";
|
|
65
34
|
|
|
66
35
|
export {
|
|
67
|
-
// Application
|
|
68
36
|
buildLineIndex,
|
|
69
37
|
applyHashlineEdits,
|
|
70
38
|
computeAffectedLineRange,
|
package/src/hashline/parse.ts
CHANGED
|
@@ -1,60 +1,36 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parsing — anchor parsing and edit content preprocessing.
|
|
3
|
-
*
|
|
4
|
-
* This module owns the wire-format parsing for hash anchors and the
|
|
5
|
-
* content preprocessing that rejects display prefixes in edit payloads.
|
|
6
|
-
*/
|
|
7
1
|
|
|
8
2
|
import {
|
|
9
3
|
ANCHOR_LENGTH,
|
|
10
|
-
HASH_PREFIX,
|
|
11
4
|
HASH_ALPHABET_RE,
|
|
12
5
|
HASH_CHARS_CLASS,
|
|
13
6
|
HASHLINE_PREFIX_PLUS_RE,
|
|
14
7
|
DIFF_MINUS_RE,
|
|
15
8
|
} from "./hash";
|
|
16
9
|
|
|
17
|
-
// ─── Types ──────────────────────────────────────────────────────────────
|
|
18
10
|
|
|
19
|
-
/**
|
|
20
|
-
* An anchor is just a hash. The hash is the entire wire format for `pos`
|
|
21
|
-
* and `end` — the runtime looks it up in the file's precomputed hash array
|
|
22
|
-
* to find the line. No content may follow the hash.
|
|
23
|
-
*/
|
|
24
11
|
export type Anchor = { hash: string };
|
|
25
12
|
|
|
26
|
-
// ─── Parsing ────────────────────────────────────────────────────────────
|
|
27
13
|
|
|
28
14
|
function diagnoseHashRef(ref: string): string {
|
|
29
15
|
const trimmed = ref.trim();
|
|
30
16
|
|
|
31
17
|
if (!trimmed.length) {
|
|
32
|
-
return `[E_BAD_REF] Invalid anchor. Expected a
|
|
18
|
+
return `[E_BAD_REF] Invalid anchor. Expected a 4-character base64 anchor (e.g. \"aB3x\").`;
|
|
33
19
|
}
|
|
34
20
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (/^\d+\s*#/.test(trimmed)) {
|
|
38
|
-
return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. "#aB3x") — no line numbers or trailing content.`;
|
|
21
|
+
if (/^\d+/.test(trimmed)) {
|
|
22
|
+
return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. \"aB3x\") — no line numbers or trailing content.`;
|
|
39
23
|
}
|
|
40
24
|
|
|
41
|
-
return `[E_BAD_REF] Invalid anchor "${trimmed}". Expected a
|
|
25
|
+
return `[E_BAD_REF] Invalid anchor \"${trimmed}\". Expected a 4-character base64 anchor (e.g. \"aB3x\").`;
|
|
42
26
|
}
|
|
43
27
|
|
|
44
28
|
function parseAnchorRef(ref: string): Anchor {
|
|
45
29
|
const trimmed = ref.trim();
|
|
46
30
|
|
|
47
|
-
// Strict: the wire format is `#` + 4-character hash from the URL-safe base64
|
|
48
|
-
// alphabet (A-Za-z0-9-_), copied verbatim from `read` output. The first
|
|
49
|
-
// character of the hash body can be `-` (a valid alphabet char), so an anchor
|
|
50
|
-
// like `#-qkl` is taken literally. No other form is tolerated: `+`/`-`/`>>>`
|
|
51
|
-
// markers from diff contexts or stale-anchor retry blocks are rejected. The
|
|
52
|
-
// model must copy just the anchor (prefix + 4 chars) with no surrounding
|
|
53
|
-
// characters.
|
|
54
31
|
if (
|
|
55
32
|
trimmed.length === ANCHOR_LENGTH &&
|
|
56
|
-
|
|
57
|
-
HASH_ALPHABET_RE.test(trimmed.slice(HASH_PREFIX.length))
|
|
33
|
+
HASH_ALPHABET_RE.test(trimmed)
|
|
58
34
|
) {
|
|
59
35
|
return { hash: trimmed };
|
|
60
36
|
}
|
|
@@ -62,28 +38,9 @@ function parseAnchorRef(ref: string): Anchor {
|
|
|
62
38
|
throw new Error(diagnoseHashRef(ref));
|
|
63
39
|
}
|
|
64
40
|
|
|
65
|
-
/**
|
|
66
|
-
* Parse a hash anchor. Accepts `#HASH` (e.g. `"#aB3x"`) only. The
|
|
67
|
-
* `#HASH:content` disambiguator from earlier versions is gone — the anchor
|
|
68
|
-
* is the entire wire format for `pos` and `end`, and no content may
|
|
69
|
-
* follow it.
|
|
70
|
-
*
|
|
71
|
-
* Throws `[E_BAD_REF]` for malformed input.
|
|
72
|
-
*/
|
|
73
41
|
export const parseHashRef = parseAnchorRef;
|
|
74
42
|
|
|
75
|
-
// ─── Content preprocessing ──────────────────────────────────────────────
|
|
76
43
|
|
|
77
|
-
/**
|
|
78
|
-
* Reject hashline display prefixes in edit payloads. Strict semantics: the
|
|
79
|
-
* model must send literal file content for `lines`, not the rendered read /
|
|
80
|
-
* diff form. Silent stripping is no longer performed — see AGENTS.md.
|
|
81
|
-
*
|
|
82
|
-
* This covers the unambiguous `+HASH:` / diff `+/-` forms, rejectable on
|
|
83
|
-
* shape alone. The bare `HHHH:` variant (issue #24) is context-dependent and
|
|
84
|
-
* lives in `assertNoBareHashPrefixLines`.
|
|
85
|
-
*
|
|
86
|
-
*/
|
|
87
44
|
function assertNoDisplayPrefixes(lines: string[]): void {
|
|
88
45
|
for (const line of lines) {
|
|
89
46
|
if (!line.length) continue;
|
|
@@ -92,20 +49,12 @@ function assertNoDisplayPrefixes(lines: string[]): void {
|
|
|
92
49
|
DIFF_MINUS_RE.test(line)
|
|
93
50
|
) {
|
|
94
51
|
throw new Error(
|
|
95
|
-
`[E_INVALID_PATCH] "lines" must contain literal file content, not HASH
|
|
52
|
+
`[E_INVALID_PATCH] \"lines\" must contain literal file content, not HASH| or diff prefixes. Offending line: ${JSON.stringify(line)}`
|
|
96
53
|
);
|
|
97
54
|
}
|
|
98
55
|
}
|
|
99
56
|
}
|
|
100
57
|
|
|
101
|
-
/**
|
|
102
|
-
* Parse replacement text into lines.
|
|
103
|
-
*
|
|
104
|
-
* String input is normalized to LF and drops exactly one trailing newline,
|
|
105
|
-
* matching read-preview style content. Array input is preserved verbatim so
|
|
106
|
-
* explicitly provided blank lines remain intact. Display prefixes are
|
|
107
|
-
* rejected by `assertNoDisplayPrefixes`, never silently stripped.
|
|
108
|
-
*/
|
|
109
58
|
export function hashlineParseText(edit: string[] | string | null): string[] {
|
|
110
59
|
if (edit === null) return [];
|
|
111
60
|
const lines =
|