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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Strict hashline read/edit tool override for pi-coding-agent with hash-anchored edits (4-char, 24-bit)",
5
5
  "repository": {
6
6
  "type": "git",
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}`;
@@ -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;
@@ -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,
@@ -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
- // ─── Main edit engine ───────────────────────────────────────────────────
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 `replace` edits: a single-element `lines: [""]` is equivalent
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,
@@ -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
- // ─── Constants ──────────────────────────────────────────────────────────
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 };
@@ -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,
@@ -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 =
@@ -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
- // ─── Anchor resolution ──────────────────────────────────────────────────
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
- // ─── Edit structure validation ──────────────────────────────────────────
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 `lines: [""]` (a single empty string) to `lines: []`
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);