pi-hashline-edit-pro 0.3.1 → 0.3.2
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/package.json +1 -1
- package/src/edit-diff.ts +0 -5
- package/src/edit-normalize.ts +0 -29
- package/src/edit-response.ts +0 -24
- package/src/edit.ts +0 -46
- package/src/hashline/apply.ts +2 -74
- package/src/hashline/hash.ts +1 -104
- package/src/hashline/index.ts +0 -32
- package/src/hashline/parse.ts +0 -49
- package/src/hashline/resolve.ts +3 -111
- package/src/read.ts +0 -6
package/package.json
CHANGED
package/src/edit-diff.ts
CHANGED
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
ANCHOR_LENGTH,
|
|
5
5
|
} from "./hashline";
|
|
6
6
|
|
|
7
|
-
// ─── Line ending normalization ──────────────────────────────────────────
|
|
8
7
|
|
|
9
8
|
export function detectLineEnding(content: string): "\r\n" | "\n" {
|
|
10
9
|
const crlfIdx = content.indexOf("\r\n");
|
|
@@ -30,7 +29,6 @@ export function stripBom(content: string): { bom: string; text: string } {
|
|
|
30
29
|
: { bom: "", text: content };
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
// ─── Diff generation ────────────────────────────────────────────────────
|
|
34
32
|
|
|
35
33
|
function formatDiffPreviewLine(
|
|
36
34
|
prefix: " " | "+" | "-",
|
|
@@ -38,9 +36,6 @@ function formatDiffPreviewLine(
|
|
|
38
36
|
hash: string | undefined,
|
|
39
37
|
): string {
|
|
40
38
|
if (hash === undefined) {
|
|
41
|
-
// Removed lines have no hash, but they still need column alignment with
|
|
42
|
-
// the hash-prefixed lines (` HASH:`, `+HASH:`). Pad with `HASH_LENGTH`
|
|
43
|
-
// spaces so the `:` lines up in the same column.
|
|
44
39
|
return `${prefix}${" ".repeat(ANCHOR_LENGTH)}:${line}`;
|
|
45
40
|
}
|
|
46
41
|
return `${prefix}${hash}:${line}`;
|
package/src/edit-normalize.ts
CHANGED
|
@@ -1,29 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Single normalization layer that maps the dialects a model may emit onto the
|
|
3
|
-
* canonical hashline edit request before validation runs.
|
|
4
|
-
*
|
|
5
|
-
* The only dialect we still absorb is the `file_path` → `path` alias and the
|
|
6
|
-
* JSON-stringified `edits` array. Pi's native legacy `oldText`/`newText`
|
|
7
|
-
* shape (whether top-level or as `op: "replace_text"`) is no longer
|
|
8
|
-
* supported: the hashline protocol requires hash-anchored edits, and the
|
|
9
|
-
* legacy text-matching path is what produces the
|
|
10
|
-
* `[E_NO_MATCH] replace_text found no exact unique match` failure mode the
|
|
11
|
-
* model hits on whitespace/Unicode drift. Any model that still emits the
|
|
12
|
-
* legacy shape is rejected with a clear error in `assertEditItem` /
|
|
13
|
-
* `assertEditRequest` so it learns the correct shape on the next turn.
|
|
14
|
-
*
|
|
15
|
-
* This runs as the tool's `prepareArguments` hook, which Pi executes before AJV
|
|
16
|
-
* schema validation and before `execute()`. The output is plain enumerable data
|
|
17
|
-
* (an `edits` array), so Pi's `structuredClone` of prepareArguments output keeps
|
|
18
|
-
* every field.
|
|
19
|
-
*/
|
|
20
1
|
|
|
21
2
|
import { isRecord, hasOwn } from "./utils";
|
|
22
3
|
|
|
23
|
-
/**
|
|
24
|
-
* Parse `edits` when a model serializes it as a JSON string instead of an array
|
|
25
|
-
* (observed with some models, mirrors Pi's built-in edit handling).
|
|
26
|
-
*/
|
|
27
4
|
function coerceEditsArray(edits: unknown): unknown {
|
|
28
5
|
if (typeof edits !== "string") {
|
|
29
6
|
return edits;
|
|
@@ -37,12 +14,6 @@ function coerceEditsArray(edits: unknown): unknown {
|
|
|
37
14
|
}
|
|
38
15
|
|
|
39
16
|
|
|
40
|
-
/**
|
|
41
|
-
* Normalize a raw edit-tool request into the canonical hashline shape.
|
|
42
|
-
*
|
|
43
|
-
* Returns the input unchanged when it is not an object, so malformed payloads
|
|
44
|
-
* still reach validation and surface a precise error there.
|
|
45
|
-
*/
|
|
46
17
|
export function normalizeEditRequest(input: unknown): unknown {
|
|
47
18
|
if (!isRecord(input)) {
|
|
48
19
|
return input;
|
package/src/edit-response.ts
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Edit response builders.
|
|
3
|
-
*
|
|
4
|
-
* Pulled out of `src/edit.ts` execute() so each returnMode branch
|
|
5
|
-
* (noop / full / ranges / changed) is independently testable and the
|
|
6
|
-
* top-level execute path stays narrative.
|
|
7
|
-
*
|
|
8
|
-
* No behaviour change: outputs are byte-identical to the previous inline
|
|
9
|
-
* implementation. The only additive surface is `details.metrics` (Phase 2 C
|
|
10
|
-
* — observability for hosts; the LLM-visible text is unchanged).
|
|
11
|
-
*/
|
|
12
1
|
|
|
13
2
|
import { generateDiffString } from "./edit-diff";
|
|
14
3
|
import {
|
|
@@ -18,10 +7,6 @@ import {
|
|
|
18
7
|
} from "./hashline";
|
|
19
8
|
import { formatHashlineReadPreview } from "./read";
|
|
20
9
|
|
|
21
|
-
// Local shape — pi-coding-agent does not export a public `ToolResult`. The
|
|
22
|
-
// builders return `details` as `any` so callers can keep their own per-tool
|
|
23
|
-
// details type without re-asserting it here. This file intentionally does
|
|
24
|
-
// not import the agent's tool-result type to stay decoupled from internals.
|
|
25
10
|
type ToolResult = {
|
|
26
11
|
content: Array<{ type: "text"; text: string }>;
|
|
27
12
|
isError?: boolean;
|
|
@@ -30,7 +15,6 @@ type ToolResult = {
|
|
|
30
15
|
|
|
31
16
|
const CHANGED_ANCHOR_TEXT_BUDGET_BYTES = 50 * 1024;
|
|
32
17
|
|
|
33
|
-
// ─── Public types ───────────────────────────────────────────────────────
|
|
34
18
|
|
|
35
19
|
export type ReturnMode = "changed" | "full" | "ranges";
|
|
36
20
|
|
|
@@ -52,14 +36,6 @@ export type FullContentPreview = {
|
|
|
52
36
|
nextOffset?: number;
|
|
53
37
|
};
|
|
54
38
|
|
|
55
|
-
/**
|
|
56
|
-
* Host-visible, opt-in observability surface (Phase 2 C). The LLM never sees
|
|
57
|
-
* this — it lives in `details` only. Hosts can use it for dashboards,
|
|
58
|
-
* adoption metrics, or regression alarms (e.g. "noop rate spiking").
|
|
59
|
-
*
|
|
60
|
-
* snake_case is intentional: most observability backends prefer it and
|
|
61
|
-
* avoiding camelCase saves a transform on the host side.
|
|
62
|
-
*/
|
|
63
39
|
export type EditMetrics = {
|
|
64
40
|
edits_attempted: number;
|
|
65
41
|
edits_noop: number;
|
package/src/edit.ts
CHANGED
|
@@ -127,11 +127,6 @@ export const hashlineEditToolSchema = Type.Object(
|
|
|
127
127
|
edits: Type.Optional(
|
|
128
128
|
Type.Array(hashlineEditItemSchema, { description: "edits over $path" }),
|
|
129
129
|
),
|
|
130
|
-
// File-path alias and JSON-stringified edits are still absorbed by
|
|
131
|
-
// normalizeEditRequest in the prepareArguments hook, which runs before
|
|
132
|
-
// this schema is validated. The legacy native top-level oldText/newText
|
|
133
|
-
// dialect is NOT folded — it is rejected outright with [E_LEGACY_SHAPE]
|
|
134
|
-
// in assertEditRequest, so it never reaches the schema validator.
|
|
135
130
|
},
|
|
136
131
|
{ additionalProperties: false },
|
|
137
132
|
);
|
|
@@ -205,15 +200,6 @@ const EDIT_PROMPT_SNIPPET = readFileSync(
|
|
|
205
200
|
|
|
206
201
|
const ROOT_KEYS = new Set(["path", "returnMode", "returnRanges", "edits"]);
|
|
207
202
|
|
|
208
|
-
// Validates the canonical edit request envelope after normalizeEditRequest has
|
|
209
|
-
// converged any model dialects. Per-edit structural validation is delegated to
|
|
210
|
-
// resolveEditAnchors (src/hashline.ts), which is the single source of truth for
|
|
211
|
-
// edit-item shape + op constraints. This function validates only the root-level
|
|
212
|
-
// request fields: path, returnMode, returnRanges, and that edits is an array.
|
|
213
|
-
//
|
|
214
|
-
// Intentional overlap with the published TypeBox schema: pi normally runs AJV
|
|
215
|
-
// validation before execute(), but that can be disabled in environments without
|
|
216
|
-
// runtime code generation support, so the semantic checks here are the backstop.
|
|
217
203
|
export function assertEditRequest(
|
|
218
204
|
request: unknown,
|
|
219
205
|
): asserts request is EditRequestParams {
|
|
@@ -221,12 +207,6 @@ export function assertEditRequest(
|
|
|
221
207
|
throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
|
|
222
208
|
}
|
|
223
209
|
|
|
224
|
-
// The legacy native top-level oldText/newText dialect (with or without the
|
|
225
|
-
// snake_case aliases) is no longer supported. Hash-anchored edits are the
|
|
226
|
-
// only path — the legacy shape is what produces
|
|
227
|
-
// `[E_NO_MATCH] replace_text found no exact unique match` on real-world
|
|
228
|
-
// whitespace/Unicode drift. Reject early with a clear error so the model
|
|
229
|
-
// learns the right shape on the next turn.
|
|
230
210
|
for (const legacyKey of ["oldText", "newText", "old_text", "new_text"]) {
|
|
231
211
|
if (hasOwn(request, legacyKey)) {
|
|
232
212
|
throw new Error(
|
|
@@ -310,16 +290,8 @@ export function assertEditRequest(
|
|
|
310
290
|
);
|
|
311
291
|
}
|
|
312
292
|
|
|
313
|
-
// Per-edit validation lives in resolveEditAnchors — the single source of
|
|
314
|
-
// truth for edit-item shape, op constraints, and anchor parsing.
|
|
315
293
|
}
|
|
316
294
|
|
|
317
|
-
/**
|
|
318
|
-
* Shared edit pipeline: normalize, validate, read file, resolve anchors,
|
|
319
|
-
* and apply edits. Both `computeEditPreview` (dry-run) and `execute()`
|
|
320
|
-
* (real) call this; the access mode parameter controls whether the file
|
|
321
|
-
* must be writable.
|
|
322
|
-
*/
|
|
323
295
|
async function executeEditPipeline(
|
|
324
296
|
request: unknown,
|
|
325
297
|
cwd: string,
|
|
@@ -392,10 +364,6 @@ async function executeEditPipeline(
|
|
|
392
364
|
const originalEnding = detectLineEnding(rawContent);
|
|
393
365
|
const originalNormalized = normalizeToLF(rawContent);
|
|
394
366
|
|
|
395
|
-
// Pre-compute hashes for the original file once. The same array is passed
|
|
396
|
-
// into applyHashlineEdits so validation and the stale-anchor retry block
|
|
397
|
-
// agree on what each line's hash is, and so we can return updated
|
|
398
|
-
// occurrence-aware anchors in the response without recomputing.
|
|
399
367
|
const originalHashes = computeLineHashes(originalNormalized);
|
|
400
368
|
|
|
401
369
|
const resolved = resolveEditAnchors(toolEdits);
|
|
@@ -458,16 +426,8 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
458
426
|
description: EDIT_DESC,
|
|
459
427
|
parameters: hashlineEditToolSchema,
|
|
460
428
|
promptSnippet: EDIT_PROMPT_SNIPPET,
|
|
461
|
-
// Converge model dialects (JSON-string edits, file_path alias) onto the
|
|
462
|
-
// canonical hashline shape before Pi validates and before execute(). The
|
|
463
|
-
// legacy top-level oldText/newText dialect is NOT folded — it is rejected
|
|
464
|
-
// outright with [E_LEGACY_SHAPE] in assertEditRequest. See
|
|
465
|
-
// src/edit-normalize.ts.
|
|
466
429
|
prepareArguments: (args: unknown) =>
|
|
467
430
|
normalizeEditRequest(args) as EditRequestParams,
|
|
468
|
-
// Force the default tool shell (Box with pending/success/error background) so
|
|
469
|
-
// we don't inherit renderShell: "self" from the built-in edit tool of the
|
|
470
|
-
// same name, which would drop the shared background color block.
|
|
471
431
|
renderShell: "default",
|
|
472
432
|
renderCall(args, theme, context) {
|
|
473
433
|
const previewInput = getRenderablePreviewInput(args);
|
|
@@ -588,8 +548,6 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
588
548
|
},
|
|
589
549
|
|
|
590
550
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
591
|
-
// normalizeEditRequest is re-applied here so execute does not depend on
|
|
592
|
-
// prepareArguments having run. Idempotent on canonical input.
|
|
593
551
|
const normalized = normalizeEditRequest(params);
|
|
594
552
|
assertEditRequest(normalized);
|
|
595
553
|
const normalizedParams = normalized;
|
|
@@ -667,10 +625,6 @@ const editToolDefinition: EditToolDefinition = {
|
|
|
667
625
|
requestedReturnRanges,
|
|
668
626
|
originalNormalized,
|
|
669
627
|
result,
|
|
670
|
-
// Hash the post-edit file once. The result builders will use
|
|
671
|
-
// these for the per-line anchors in the full / ranges / changed
|
|
672
|
-
// response blocks; computing once here is cheaper than letting
|
|
673
|
-
// each builder recompute.
|
|
674
628
|
resultHashes: computeLineHashes(result),
|
|
675
629
|
warnings,
|
|
676
630
|
snapshotId: updatedSnapshotId,
|
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[],
|
|
@@ -580,13 +514,7 @@ export function formatHashlineRegion(
|
|
|
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,74 +1,27 @@
|
|
|
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
|
-
/** Prefix marker for hash anchors. Every anchor starts with `#` so the hash */
|
|
27
|
-
/** format is `#` + HASH_LENGTH base64 chars (e.g. `#aB3x`, `#4yN-`). */
|
|
28
7
|
export const HASH_PREFIX = "#";
|
|
29
8
|
|
|
30
|
-
/** Total wire-format length of an anchor: prefix + hash body. */
|
|
31
9
|
export const ANCHOR_LENGTH = HASH_PREFIX.length + HASH_LENGTH;
|
|
32
10
|
|
|
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
|
-
*/
|
|
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
18
|
|
|
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
19
|
function hashToString(h: number): string {
|
|
65
20
|
const totalBits = HASH_LENGTH * HASH_ALPHABET_BITS;
|
|
66
21
|
const shift = 32 - totalBits;
|
|
67
22
|
let n = h >>> shift;
|
|
68
23
|
let out = "";
|
|
69
24
|
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
25
|
out +=
|
|
73
26
|
HASH_ALPHABET[
|
|
74
27
|
(n >>> ((HASH_LENGTH - 1 - j) * HASH_ALPHABET_BITS)) &
|
|
@@ -78,11 +31,6 @@ function hashToString(h: number): string {
|
|
|
78
31
|
return HASH_PREFIX + out;
|
|
79
32
|
}
|
|
80
33
|
|
|
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
34
|
export const HASHLINE_PREFIX_RE = new RegExp(
|
|
87
35
|
`^\\s*(?:>>>|>>)?\\s*${HASH_CHARS_CLASS}:`,
|
|
88
36
|
);
|
|
@@ -91,40 +39,14 @@ export const HASHLINE_PREFIX_PLUS_RE = new RegExp(
|
|
|
91
39
|
);
|
|
92
40
|
export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
|
|
93
41
|
|
|
94
|
-
/**
|
|
95
|
-
* Bare hashline prefix: a `#` + HASH_LENGTH-char hash followed by ":" with
|
|
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
42
|
export const HASHLINE_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CHARS_CLASS}):`);
|
|
107
43
|
|
|
108
|
-
/** Lines containing no alphanumeric characters (only punctuation/symbols/whitespace). */
|
|
109
44
|
const RE_SIGNIFICANT = /[\p{L}\p{N}]/u;
|
|
110
45
|
|
|
111
46
|
function xxh32(input: string, seed = 0): number {
|
|
112
47
|
return XXH.h32(seed).update(input).digest().toNumber() >>> 0;
|
|
113
48
|
}
|
|
114
49
|
|
|
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
50
|
const SYMBOL_DISCRIMINATOR = (lineNumber: number): string => `S${lineNumber}`;
|
|
129
51
|
const CONTENT_DISCRIMINATOR = (occurrence: number): string => `C${occurrence}`;
|
|
130
52
|
|
|
@@ -136,19 +58,6 @@ function isSymbolOnly(canonical: string): boolean {
|
|
|
136
58
|
return !RE_SIGNIFICANT.test(canonical);
|
|
137
59
|
}
|
|
138
60
|
|
|
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
61
|
export function computeLineHashes(content: string): string[] {
|
|
153
62
|
const lines = content.split("\n");
|
|
154
63
|
const hashes = new Array<string>(lines.length);
|
|
@@ -169,16 +78,6 @@ export function computeLineHashes(content: string): string[] {
|
|
|
169
78
|
return hashes;
|
|
170
79
|
}
|
|
171
80
|
|
|
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
81
|
export function computeLineHash(idx: number, line: string): string {
|
|
183
82
|
const canonical = canonicalizeLine(line);
|
|
184
83
|
const discriminator = isSymbolOnly(canonical)
|
|
@@ -187,7 +86,6 @@ export function computeLineHash(idx: number, line: string): string {
|
|
|
187
86
|
return hashToString(xxh32(`${discriminator}:${canonical}`));
|
|
188
87
|
}
|
|
189
88
|
|
|
190
|
-
/** Exported for tests and for downstream tools that want to mirror the format. */
|
|
191
89
|
export const HASH_FORMAT = {
|
|
192
90
|
prefix: HASH_PREFIX,
|
|
193
91
|
length: HASH_LENGTH,
|
|
@@ -197,5 +95,4 @@ export const HASH_FORMAT = {
|
|
|
197
95
|
};
|
|
198
96
|
|
|
199
97
|
|
|
200
|
-
/** Re-export HASH_ALPHABET_RE for parse module */
|
|
201
98
|
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,9 +1,3 @@
|
|
|
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,
|
|
@@ -14,16 +8,9 @@ import {
|
|
|
14
8
|
DIFF_MINUS_RE,
|
|
15
9
|
} from "./hash";
|
|
16
10
|
|
|
17
|
-
// ─── Types ──────────────────────────────────────────────────────────────
|
|
18
11
|
|
|
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
12
|
export type Anchor = { hash: string };
|
|
25
13
|
|
|
26
|
-
// ─── Parsing ────────────────────────────────────────────────────────────
|
|
27
14
|
|
|
28
15
|
function diagnoseHashRef(ref: string): string {
|
|
29
16
|
const trimmed = ref.trim();
|
|
@@ -32,8 +19,6 @@ function diagnoseHashRef(ref: string): string {
|
|
|
32
19
|
return `[E_BAD_REF] Invalid anchor. Expected a hash anchor like "#aB3x" (prefix "#" + 4 base64 chars).`;
|
|
33
20
|
}
|
|
34
21
|
|
|
35
|
-
// Detect the legacy "LINE#HASH" form (5#aB3x, 12#MQ, etc.) so we can
|
|
36
|
-
// give a clear error pointing at the new format.
|
|
37
22
|
if (/^\d+\s*#/.test(trimmed)) {
|
|
38
23
|
return `[E_BAD_REF] Invalid anchor. Use the hash alone (e.g. "#aB3x") — no line numbers or trailing content.`;
|
|
39
24
|
}
|
|
@@ -44,13 +29,6 @@ function diagnoseHashRef(ref: string): string {
|
|
|
44
29
|
function parseAnchorRef(ref: string): Anchor {
|
|
45
30
|
const trimmed = ref.trim();
|
|
46
31
|
|
|
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
32
|
if (
|
|
55
33
|
trimmed.length === ANCHOR_LENGTH &&
|
|
56
34
|
trimmed.startsWith(HASH_PREFIX) &&
|
|
@@ -62,28 +40,9 @@ function parseAnchorRef(ref: string): Anchor {
|
|
|
62
40
|
throw new Error(diagnoseHashRef(ref));
|
|
63
41
|
}
|
|
64
42
|
|
|
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
43
|
export const parseHashRef = parseAnchorRef;
|
|
74
44
|
|
|
75
|
-
// ─── Content preprocessing ──────────────────────────────────────────────
|
|
76
45
|
|
|
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
46
|
function assertNoDisplayPrefixes(lines: string[]): void {
|
|
88
47
|
for (const line of lines) {
|
|
89
48
|
if (!line.length) continue;
|
|
@@ -98,14 +57,6 @@ function assertNoDisplayPrefixes(lines: string[]): void {
|
|
|
98
57
|
}
|
|
99
58
|
}
|
|
100
59
|
|
|
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
60
|
export function hashlineParseText(edit: string[] | string | null): string[] {
|
|
110
61
|
if (edit === null) return [];
|
|
111
62
|
const lines =
|
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[],
|
|
@@ -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]!;
|
|
@@ -379,8 +286,6 @@ export function assertNoBareHashPrefixLines(
|
|
|
379
286
|
const matchedCount = matched.length;
|
|
380
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.`
|
|
@@ -398,18 +303,6 @@ export function assertNoBareHashPrefixLines(
|
|
|
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[],
|
|
@@ -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);
|