pi-hashline-edit-pro 4.3.5 → 4.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -176,7 +176,7 @@ All five tools return machine-readable metadata in `details` alongside the model
176
176
  | Tool | `details` |
177
177
  | --- | --- |
178
178
  | `read` | `truncation` (set when output was truncated), `snapshotId` (a `v2\|path\|ino\|mtime\|ctime\|size` fingerprint), `nextOffset` (use as the next `offset`), and `metrics` with `truncated` and `next_offset`. |
179
- | `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+HASH│` and ` HASH│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
179
+ | `replace`, `insert` | `diff` (post-edit diff, capped, with current anchors on `+HASH│` and ` HASH│` rows; a same-message batch reports the combined diff on its last call and an empty diff on earlier calls), `patch` (a standard unified patch for external tools, capped like the diff), `patchTruncated` (true when the patch was cut or skipped for a pair over 1MB and can no longer be applied as-is), `firstChangedLine`, `snapshotId`, `classification` (`"noop"` when nothing changed), `batch` (`{ id, size, last, total }` marking same-message batch membership; earlier members also carry `aborted: true` and `abortMessage` after a batch abort), and `metrics`: `edits_attempted`, `edits_noop`, `warnings`, `classification` (`"applied"` or `"noop"`), `changed_lines` (`{ first, last }`), `added_lines`, `removed_lines`. |
180
180
  | `undo_last_change` | `diff` (the undo diff with restored anchors), `patch`, `patchTruncated`, and `metrics` in the same shape as `replace`. |
181
181
  | `anchor_grep` | `metrics` with `matches` (capped at `limit`), `files`, and `truncated`; `truncation` (the standard pi truncation report) when output was cut; and `linesTruncated` (true when long lines were shown as fragments). |
182
182
 
@@ -261,6 +261,7 @@ Codes starting with `E_` are errors: nothing was written — except `File was wr
261
261
  - Stale anchors. `[E_STALE_ANCHOR]` means an anchor is not owned in this session: it was never shown to you, or its line was edited or the file was rewritten since. Call `read` for fresh anchors and retry.
262
262
  - Range changed on disk. `[E_RANGE_STALE]` means a line inside the replaced range changed after it was last shown to you (or was never shown). Nothing was modified; the error carries the current range with fresh anchors, so retry with those without a `read`.
263
263
  - Multi-conversation hosts. Anchors, served records, and ownership logs are resolved per calling session, so a tool call in one conversation is never answered by another conversation's registry; a foreign anchor fails with `[E_STALE_ANCHOR]`. Interactive previews are the one exception: pi does not pass the session into render callbacks, so when one process serves several conversations at once a preview can fall back to the most recently active session and show a stale or wrong-file diff. Previews never write files or claim anchors; run the call for the authoritative result.
264
+ - Undo scope. `undo_last_change` records are keyed by file path, not by session, so in a multi-conversation host any conversation that names the file can revert its most recent `replace` or `insert`, even one made by another conversation. Anchor ownership remains session-scoped; only undo is shared.
264
265
  - Reset the anchor state. Anchors live in `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (with `-wal`/`-shm` sidecars) and in per-session ownership logs under `~/.config/pi-hashline-edit-pro/sessions/`. Quit pi, delete those files, and everything is rebuilt on the next session. Anchor history is lost, but no project files are touched.
265
266
  - Corrupt store. If the store fails its health check it is renamed to `hash-store.sqlite.corrupt-<timestamp>` and rebuilt automatically.
266
267
  - Config directory moved. If `XDG_CONFIG_HOME` is set on a non-Windows platform, the config directory (and the anchor state inside it) lives at `$XDG_CONFIG_HOME/pi-hashline-edit-pro` instead of `~/.config/pi-hashline-edit-pro`. An existing store is not migrated automatically. To keep anchor and undo history, move the old `hash-store.sqlite` files (plus `-wal`/`-shm` sidecars) into the new directory before the first run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "4.3.5",
3
+ "version": "4.3.7",
4
4
  "type": "module",
5
5
  "description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
6
6
  "main": "index.ts",
@@ -57,11 +57,10 @@
57
57
  "test:watch": "vitest",
58
58
  "test:coverage": "vitest run --coverage --coverage.thresholds.lines=90 --coverage.thresholds.statements=90 --coverage.thresholds.functions=85 --coverage.thresholds.branches=80",
59
59
  "lint": "eslint \"src/**/*.ts\" \"index.ts\" \"test/**/*.ts\"",
60
- "typecheck": "tsc --noEmit",
61
- "prepublishOnly": "npm run typecheck && npm run lint && npm test"
60
+ "typecheck": "tsc --noEmit"
62
61
  },
63
62
  "devDependencies": {
64
- "@earendil-works/pi-coding-agent": "^0.84.0",
63
+ "@earendil-works/pi-coding-agent": "^0.87.0",
65
64
  "@eslint/js": "^10.0.1",
66
65
  "@types/node": "^24",
67
66
  "@vitest/coverage-v8": "^4.1.11",
@@ -388,6 +388,16 @@ export function chunkAutoReadAllSections(sections: string[], maxBytes: number =
388
388
  if (current.length > 0) chunks.push(current.join("\n\n"));
389
389
  return chunks;
390
390
  }
391
+
392
+ async function candidateFileBytes(cwd: string, file: string): Promise<number | undefined> {
393
+ try {
394
+ const stats = await lstat(resolve(cwd, file));
395
+ return stats.isFile() ? stats.size : undefined;
396
+ } catch {
397
+ return undefined;
398
+ }
399
+ }
400
+
391
401
  async function renderFile(file: string, cwd: string): Promise<AutoReadAllSection | undefined> {
392
402
  try {
393
403
  const { normalized, fileHashes, absolutePath } = await readNormFile(file, cwd, { maxLines: MAX_HASH_LINES });
@@ -427,6 +437,13 @@ export async function buildAutoReadAllInjection(cwd: string, budgetBytes: number
427
437
  let bytes = 0;
428
438
  let completeFiles = 0;
429
439
  for (const file of discovery.files) {
440
+ if (sections.length > 0) {
441
+ const size = await candidateFileBytes(cwd, file);
442
+ if (size !== undefined && bytes + size > budgetBytes) {
443
+ omitted.push(file);
444
+ continue;
445
+ }
446
+ }
430
447
  const section = await renderFile(file, cwd);
431
448
  if (section === undefined) {
432
449
  omitted.push(file);
package/src/batch.ts CHANGED
@@ -23,11 +23,11 @@ import { assertInsertReq, assertReq, normReq } from "./payload-contract";
23
23
  import { saveUndo } from "./replace-undo";
24
24
  import { buildChanged, buildNoop, type RMetrics, type TResult } from "./replace-response";
25
25
  import { serveRows, servedHashesFromDiff } from "./served";
26
- import { abortIf, assertLineLimit, errCode, isRec, splitLines } from "./utils";
27
- import { MAX_BYTES } from "./constants";
26
+ import { abortIf, assertByteLimit, assertLineLimit, errCode, isRec, splitLines } from "./utils";
28
27
 
29
28
  export interface PlannedMember {
30
29
  batchKey: number;
30
+ id: string;
31
31
  display: number;
32
32
  total: number;
33
33
  target: string;
@@ -126,12 +126,18 @@ const batches = new Map<number, BatchState>();
126
126
  let nextBatchKey = 1;
127
127
  const abortedMembers = new Map<string, { display: number; message: string }>();
128
128
  const ABORTED_MEMBERS_LIMIT = 1024;
129
+ const placeholderResults = new Map<string, TResult>();
129
130
 
130
131
  function markBatchMembersAborted(runtime: BatchState): void {
131
132
  const message = abortedBatchMessage(runtime);
132
133
  for (const id of runtime.memberIds) {
133
134
  abortedMembers.delete(id);
134
135
  abortedMembers.set(id, { display: runtime.display, message });
136
+ const result = placeholderResults.get(id);
137
+ if (result?.details.batch) {
138
+ result.details.batch.aborted = true;
139
+ result.details.batch.abortMessage = message;
140
+ }
135
141
  }
136
142
  while (abortedMembers.size > ABORTED_MEMBERS_LIMIT) {
137
143
  const oldest = abortedMembers.keys().next().value;
@@ -157,6 +163,7 @@ export function resetBatchStateForTests(): void {
157
163
  plan.clear();
158
164
  batches.clear();
159
165
  abortedMembers.clear();
166
+ placeholderResults.clear();
160
167
  nextBatchKey = 1;
161
168
  }
162
169
 
@@ -203,7 +210,12 @@ function enforceCap(): void {
203
210
  if (oldest === undefined) return;
204
211
  const state = batches.get(oldest);
205
212
  batches.delete(oldest);
206
- if (state) for (const id of state.memberIds) plan.delete(id);
213
+ if (state) {
214
+ for (const id of state.memberIds) {
215
+ plan.delete(id);
216
+ placeholderResults.delete(id);
217
+ }
218
+ }
207
219
  }
208
220
  }
209
221
 
@@ -274,6 +286,7 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
274
286
  group.forEach((item, index) => {
275
287
  plan.set(item.id, {
276
288
  batchKey: key,
289
+ id: item.id,
277
290
  display,
278
291
  total: finalGroups.length,
279
292
  target: item.target,
@@ -521,7 +534,11 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
521
534
  if (noop) runtime.noops += 1;
522
535
  else runtime.applied += 1;
523
536
  runtime.warnings.push(...piece.warnings);
524
- if (!input.member.last) return batchPlaceholder(input.member, piece, base.snapshotId);
537
+ if (!input.member.last) {
538
+ const placeholder = batchPlaceholder(input.member, piece, base.snapshotId);
539
+ placeholderResults.set(input.member.id, placeholder);
540
+ return placeholder;
541
+ }
525
542
  return finishBatch(input.member, input.signal);
526
543
  }
527
544
 
@@ -633,9 +650,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
633
650
  assertNotEmpty(base.content, composed);
634
651
  assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
635
652
  const finalBytes = base.bom + restoreEndings(composed, base.ending);
636
- if (Buffer.byteLength(finalBytes, "utf-8") > MAX_BYTES) {
637
- throw new Error(`[E_FILE_TOO_LARGE] File is too large: ${paths.displayPath} (exceeds the ${MAX_BYTES / (1024 * 1024)}MB size limit). For very large files, use write.`);
638
- }
653
+ assertByteLimit(finalBytes, paths.displayPath);
639
654
  } catch (error) {
640
655
  discardBatchState(runtime);
641
656
  if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
@@ -739,7 +754,7 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
739
754
  await getDiffContextLines(),
740
755
  );
741
756
  changed.details.diff = `${header}\n${changed.details.diff}`;
742
- changed.details.diffLineNumbers?.unshift(undefined);
757
+ changed.details.diffLineNumbers?.unshift(null);
743
758
  try {
744
759
  serveRows(runtime.target, resultHashes, splitLines(composed), servedHashesFromDiff(changed.details.diff));
745
760
  } catch (error) {
@@ -786,7 +801,10 @@ export async function finalizeTurn(toolCallIds: string[]): Promise<void> {
786
801
  for (const key of keys) {
787
802
  const runtime = batches.get(key);
788
803
  if (!runtime) continue;
789
- for (const id of runtime.memberIds) plan.delete(id);
804
+ for (const id of runtime.memberIds) {
805
+ plan.delete(id);
806
+ placeholderResults.delete(id);
807
+ }
790
808
  batches.delete(key);
791
809
  }
792
810
  }
package/src/commit.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import type { PipelineResult } from "./replace";
3
- import { abortIf, errCode, splitLines } from "./utils";
3
+ import { abortIf, assertByteLimit, errCode, splitLines } from "./utils";
4
4
  import { DEDUP_ANCHOR } from "./constants";
5
5
  import { HASH_SEP } from "./hashline";
6
6
  import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
@@ -70,6 +70,9 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
70
70
  );
71
71
  }
72
72
 
73
+ const finalFileBytes = pipe.bom + restoreEndings(pipe.result, pipe.originalEnding);
74
+ assertByteLimit(finalFileBytes, path);
75
+
73
76
  if (pipe.hadUtf8DecodeErrors) {
74
77
  warnings.push(
75
78
  "Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.",
@@ -112,7 +115,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
112
115
  abortIf(signal);
113
116
  await writeAtomic(
114
117
  absolutePath,
115
- pipe.bom + restoreEndings(pipe.result, pipe.originalEnding),
118
+ finalFileBytes,
116
119
  pipe.identity,
117
120
  );
118
121
  } catch (error) {
@@ -16,22 +16,6 @@ export const HASH_RUN = `[${LETTERS}]{${HASH_LEN},${HASH_LEN + 1}}`;
16
16
 
17
17
  export const HASH_RE = new RegExp(`^${HASH_CLASS}$`);
18
18
 
19
- let indexByAnchor: Map<string, number> | undefined;
20
-
21
- function reverseIndex(): Map<string, number> {
22
- if (!indexByAnchor) {
23
- indexByAnchor = new Map();
24
- for (let i = 0; i < ANCHOR_COUNT; i++) {
25
- indexByAnchor.set(TABLE.slice(i * HASH_LEN, i * HASH_LEN + HASH_LEN), i);
26
- }
27
- }
28
- return indexByAnchor;
29
- }
30
-
31
19
  export function anchorAt(idx: number): string {
32
20
  return TABLE.slice(idx * HASH_LEN, idx * HASH_LEN + HASH_LEN);
33
21
  }
34
-
35
- export function anchorIndex(hash: string): number {
36
- return reverseIndex().get(hash) ?? -1;
37
- }
@@ -1,5 +1,4 @@
1
1
  import { abortIf, clipLine, splitLines } from "../utils";
2
- import { _lineHashesPure } from "./hash";
3
2
  import {
4
3
  valEdit,
5
4
  stripBarePrefixes,
@@ -42,7 +42,7 @@ export function colorLines(lines: string[], theme: FgT): string[] {
42
42
  return theme.fg("dim", line);
43
43
  });
44
44
  }
45
- export function toNumberedDiff(diff: string, lineNumbers: (number|undefined)[]): string {
45
+ export function toNumberedDiff(diff: string, lineNumbers: (number | null | undefined)[]): string {
46
46
  return withLineNumbers(diff, lineNumbers);
47
47
  }
48
48
 
@@ -162,7 +162,7 @@ function extractSummary(text: string | undefined): string | undefined {
162
162
 
163
163
  const BATCH_HEADER_RE = /^batch \d+:\n/;
164
164
 
165
- function splitBatchHeader(diff: string, lineNumbers: (number | undefined)[] | undefined): { header: string | undefined; body: string; lineNumbers: (number | undefined)[] | undefined } {
165
+ function splitBatchHeader(diff: string, lineNumbers: (number | null | undefined)[] | undefined): { header: string | undefined; body: string; lineNumbers: (number | null | undefined)[] | undefined } {
166
166
  const match = diff.match(BATCH_HEADER_RE);
167
167
  if (!match) return { header: undefined, body: diff, lineNumbers };
168
168
  return { header: match[0]!.slice(0, -1), body: diff.slice(match[0].length), lineNumbers: lineNumbers?.slice(1) };
@@ -240,7 +240,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced", diffContext
240
240
  editMeta.firstChangedLine ?? baseDiff.firstChangedLine,
241
241
  snapshotId,
242
242
  metrics,
243
- diffLineNumbers: diffResult.lineNumbers,
243
+ diffLineNumbers: diffResult.lineNumbers.map((line) => line ?? null),
244
244
  ...(warnings?.length ? { warnings: [...warnings] } : {}),
245
245
  },
246
246
  };
@@ -248,7 +248,7 @@ export function regUndo(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
248
248
  ],
249
249
  details: {
250
250
  diff: undoDiff,
251
- diffLineNumbers: undoDiffResult.lineNumbers,
251
+ diffLineNumbers: undoDiffResult.lineNumbers.map((line) => line ?? null),
252
252
  patch: patchResult.patch,
253
253
  ...(patchResult.truncated ? { patchTruncated: true as const } : {}),
254
254
  metrics: buildMetrics({
package/src/replace.ts CHANGED
@@ -45,9 +45,9 @@ export type ReplaceDetails = {
45
45
  snapshotId?: string;
46
46
  classification?: "noop";
47
47
  metrics?: RMetrics;
48
- diffLineNumbers?: (number|undefined)[];
48
+ diffLineNumbers?: (number | null)[];
49
49
  warnings?: string[];
50
- batch?: { id: number; size: number; last: boolean; total: number };
50
+ batch?: { id: number; size: number; last: boolean; total: number; aborted?: boolean; abortMessage?: string };
51
51
  };
52
52
 
53
53
  export interface PipelineResult {
package/src/utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { NUL_CONTENT_MSG } from "./constants";
1
+ import { NUL_CONTENT_MSG, MAX_BYTES } from "./constants";
2
2
  import { HASH_CLASS } from "./hashline/alphabet";
3
3
 
4
4
  export function isRec(value: unknown): value is Record<string, unknown> {
@@ -173,14 +173,14 @@ export function numberedRead(text: string, offset: number): string {
173
173
  }).join("\n");
174
174
  }
175
175
 
176
- export function withLineNumbers(text: string, numbers: (number|undefined)[]): string {
176
+ export function withLineNumbers(text: string, numbers: (number | null | undefined)[]): string {
177
177
  const lines = text.split("\n");
178
178
  const nums = numbers ?? [];
179
- const max = nums.reduce<number>((m, n) => n !== undefined && n > m ? n : m, 0);
179
+ const max = nums.reduce<number>((m, n) => n !== undefined && n !== null && n > m ? n : m, 0);
180
180
  const width = gutterWidth(max, lines.length);
181
181
  return lines.map((line, i) => {
182
182
  const n = nums[i];
183
- const prefix = n !== undefined ? formatGutter(n, width) : blankGutter(width);
183
+ const prefix = n !== undefined && n !== null ? formatGutter(n, width) : blankGutter(width);
184
184
  return prefix + line;
185
185
  }).join("\n");
186
186
  }
@@ -192,6 +192,11 @@ export function assertLineLimit(content: string, displayPath: string, limit: num
192
192
  const count = splitLines(content).length;
193
193
  if (count > limit) throw new Error(formatLineLimit(displayPath, limit, count));
194
194
  }
195
+ export function assertByteLimit(content: string, displayPath: string, limit = MAX_BYTES): void {
196
+ if (Buffer.byteLength(content, "utf-8") > limit) {
197
+ throw new Error(`[E_FILE_TOO_LARGE] File is too large: ${displayPath} (exceeds the ${limit / (1024 * 1024)}MB size limit). For very large files, use write.`);
198
+ }
199
+ }
195
200
  export function lineLimitMoreThanMessage(displayPath: string, limit: number): string {
196
201
  return formatLineLimit(displayPath, limit, undefined);
197
202
  }