pi-hashline-edit-pro 2.8.0 → 2.8.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.
@@ -1,12 +1,14 @@
1
- import { readFile } from "fs/promises";
1
+ import { constants } from "fs";
2
+ import { open } from "fs/promises";
2
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
4
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
5
  import { Type } from "typebox";
5
6
  import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
6
- import { recordServedDiff } from "./served";
7
- import { resolveInCwd, writeAtomic } from "./fs-write";
8
- import { toLF, stripBOM, genDiff, genPatch, restoreEndings, type LineEnding } from "./replace-diff";
9
- import { cntDiff, errCode, makePrepareArguments } from "./utils";
7
+ import { recordServed, buildServedMap, servedHashesFromDiff } from "./served";
8
+ import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
9
+ import { toLF, stripBOM, restoreEndings, type LineEnding } from "./normalize";
10
+ import { genDiff, genPatch } from "./replace-diff";
11
+ import { cntDiff, errCode, makePrepareArguments, splitLines } from "./utils";
10
12
  import { loadP, loadGuide } from "./prompts";
11
13
  import { buildMetrics } from "./replace-response";
12
14
  import { renderEditResult } from "./replace-render";
@@ -121,8 +123,17 @@ export function regUndo(pi: ExtensionAPI): void {
121
123
 
122
124
  return withFileMutationQueue(mutationTargetPath, async () => {
123
125
  let currentRaw: string | undefined;
126
+ let currentIdentity: FileIdentity | undefined;
124
127
  try {
125
- currentRaw = await readFile(mutationTargetPath, "utf-8");
128
+ const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
129
+ const handle = await open(mutationTargetPath, constants.O_RDONLY | noFollow);
130
+ try {
131
+ const { dev, ino } = await handle.stat();
132
+ currentIdentity = { dev, ino };
133
+ currentRaw = await handle.readFile("utf-8");
134
+ } finally {
135
+ await handle.close();
136
+ }
126
137
  } catch (error) {
127
138
  if (errCode(error) !== "ENOENT") throw error;
128
139
  }
@@ -146,6 +157,7 @@ export function regUndo(pi: ExtensionAPI): void {
146
157
  await writeAtomic(
147
158
  mutationTargetPath,
148
159
  undo.bom + restoreEndings(undo.content, undo.originalEnding),
160
+ currentIdentity,
149
161
  );
150
162
 
151
163
  const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
@@ -160,7 +172,10 @@ export function regUndo(pi: ExtensionAPI): void {
160
172
  try {
161
173
  const store = await loadHashStore();
162
174
  persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes);
163
- recordServedDiff(store, mutationTargetPath, undoDiff, new Set(undo.hashes));
175
+ const diffHashes = servedHashesFromDiff(undoDiff);
176
+ const undoLines = splitLines(undo.content);
177
+ const servedMap = buildServedMap(undo.hashes, undoLines, diffHashes);
178
+ recordServed(store, mutationTargetPath, servedMap, new Set(undo.hashes));
164
179
  } catch (error) {
165
180
  console.error("Failed to restore hash store snapshot after undo:", error);
166
181
  }
package/src/replace.ts CHANGED
@@ -2,7 +2,6 @@ import type {
2
2
  ExtensionAPI,
3
3
  ToolDefinition,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
5
  import { constants } from "fs";
7
6
  import {
8
7
  genDiff,
@@ -10,9 +9,9 @@ import {
10
9
  } from "./replace-diff";
11
10
  import { readNormFile, type NormFile } from "./file-reader";
12
11
  import { editToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
13
- import { isRec, abortIf, makePrepareArguments } from "./utils";
12
+ import { isRec } from "./utils";
14
13
  import { loadP, loadGuide } from "./prompts";
15
- import { resolveInCwd } from "./fs-write";
14
+ import { type FileIdentity } from "./fs-write";
16
15
  import { applyEdit,
17
16
  lineHashes,
18
17
  resEdit,
@@ -26,14 +25,13 @@ import { applyEdit,
26
25
  import { commitEdit } from "./commit";
27
26
  import type { RMetrics } from "./replace-response";
28
27
  import {
29
- makeRenderCall,
30
- renderEditResult,
31
28
  type RPreview,
32
29
  type RRState,
33
30
  } from "./replace-render";
34
31
  import { loadHashStore, findSnapshotPaths, findServedPaths, type HashStore } from "./hash-store";
35
32
  import { getServed, recordServedSafe } from "./served";
36
33
  import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
34
+ import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
37
35
 
38
36
  export { editToolSchema, type ReqParams, assertReq };
39
37
 
@@ -65,6 +63,7 @@ export interface PipelineResult {
65
63
  totalRemovedLines: number;
66
64
  hadBoundaryDedup: boolean;
67
65
  boundaryRemovedLines: number;
66
+ identity: FileIdentity;
68
67
  }
69
68
 
70
69
  async function resolveMissingPath(
@@ -120,8 +119,8 @@ function hashSpan(hashes: string[], from: string, to: string): [number, number]
120
119
  }
121
120
  async function noteAnchorError(absolutePath: string, error: unknown, scopeHashes: string[], noPersist?: boolean): Promise<void> {
122
121
  if (noPersist === true) return;
123
- if (error instanceof RangeStaleError) await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(scopeHashes));
124
- else if (error instanceof AnchorMismatchError) await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(scopeHashes));
122
+ if (error instanceof RangeStaleError) await recordServedSafe(absolutePath, error.rangeServedMap, "range-stale feedback", new Set(scopeHashes));
123
+ else if (error instanceof AnchorMismatchError) await recordServedSafe(absolutePath, error.feedbackMap, "anchor-mismatch feedback", new Set(scopeHashes));
125
124
  }
126
125
 
127
126
  function collectRemovedHashes(
@@ -170,7 +169,7 @@ export async function execPipeline(
170
169
  );
171
170
 
172
171
  const hashStore = options?.store ?? await loadHashStore();
173
- const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
172
+ const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath, identity } = await readNormFile(
174
173
  path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist, preloadedNorm: options?.preloadedNorm },
175
174
  );
176
175
 
@@ -227,6 +226,7 @@ export async function execPipeline(
227
226
  totalRemovedLines,
228
227
  hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
229
228
  boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
229
+ identity,
230
230
  };
231
231
  }
232
232
 
@@ -277,22 +277,9 @@ export function buildToolDef(): ToolDef {
277
277
  parameters,
278
278
  promptSnippet: E_SNIPPET,
279
279
  promptGuidelines: E_GUIDE,
280
- prepareArguments: makePrepareArguments(),
281
- executionMode: "sequential",
282
- renderShell: "default",
283
- renderCall: makeRenderCall(compPreview),
284
- renderResult(result, { isPartial, expanded }, theme, context) {
285
- return renderEditResult(
286
- result as {
287
- content?: Array<{ type: string; text?: string }>;
288
- details?: ReplaceDetails;
289
- },
290
- { isPartial, expanded },
291
- theme,
292
- context,
293
- );
294
- },
295
-
280
+ ...editToolBase,
281
+ renderCall: editRenderCallWrapper(compPreview),
282
+ renderResult: editRenderResultWrapper,
296
283
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
297
284
  const canonical = normReq(params);
298
285
  const resolution = isRec(canonical) ? await resolveMissingPath(canonical) : undefined;
@@ -300,14 +287,11 @@ export function buildToolDef(): ToolDef {
300
287
  canonical.path = resolution.path;
301
288
  }
302
289
  assertReq(canonical);
303
-
304
290
  const normalizedParams = canonical;
305
291
  const path = normalizedParams.path;
306
- const { absolute: absolutePath, resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
307
- const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
308
- const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
309
- return withFileMutationQueue(mutationTargetPath, async () => {
310
- abortIf(signal);
292
+ return queuedEdit(path, ctx.cwd, signal, async (absolutePath, mutationTargetPath) => {
293
+ const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
294
+ const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
311
295
  const pipe = await execPipeline(
312
296
  normalizedParams,
313
297
  ctx.cwd,
package/src/served.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { loadHashStore, parseStoredHashes, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
1
+ import { loadHashStore, parseStoredServed, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
2
2
  import { HASH_CLASS } from "./hashline/alphabet";
3
+ import { contentChecksum } from "./hashline/hasher";
3
4
 
4
5
  const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
5
6
 
@@ -12,60 +13,88 @@ export function servedHashesFromDiff(diff: string): string[] {
12
13
  return hashes;
13
14
  }
14
15
 
15
- export function getServed(store: HashStore, path: string): Set<string> | undefined {
16
+ export function servedMapFromDiff(diff: string): Map<string, string> {
17
+ const map = new Map<string, string>();
18
+ for (const line of diff.split("\n")) {
19
+ const match = SERVED_DIFF_ROW_RE.exec(line);
20
+ if (!match) continue;
21
+ const hash = match[1]!;
22
+ const content = line.slice(match[0].length);
23
+ map.set(hash, contentChecksum(content));
24
+ }
25
+ return map;
26
+ }
27
+
28
+ export function buildServedMap(fileHashes: string[], fileLines: string[], wantedHashes: string[]): Map<string, string> {
29
+ const index = new Map<string, number>();
30
+ for (let i = 0; i < fileHashes.length; i++) index.set(fileHashes[i]!, i);
31
+ const map = new Map<string, string>();
32
+ for (const h of wantedHashes) {
33
+ const idx = index.get(h);
34
+ if (idx !== undefined) map.set(h, contentChecksum(fileLines[idx]!));
35
+ }
36
+ return map;
37
+ }
38
+
39
+ export function getServed(store: HashStore, path: string): Map<string, string> | undefined {
16
40
  const row = store.stmts.servedGet(path);
17
- const parsed = parseStoredHashes(row, () => store.stmts.servedDelete(path));
41
+ const parsed = parseStoredServed(row, () => store.stmts.servedDelete(path));
18
42
  if (!parsed) return undefined;
19
- return new Set(parsed);
43
+ return parsed;
20
44
  }
21
45
 
22
46
  function computeUpdate(
23
47
  store: HashStore,
24
48
  path: string,
25
- hashes: string[],
49
+ entries: Map<string, string>,
26
50
  scope?: ReadonlySet<string>,
27
- ): Set<string> | undefined {
51
+ ): Map<string, string> | undefined {
28
52
  const existing = getServed(store, path);
29
- if (!existing && hashes.length === 0) return undefined;
30
- const set = existing ?? new Set<string>();
53
+ if (!existing && entries.size === 0 && !scope) return undefined;
54
+ const map = existing ? new Map(existing) : new Map<string, string>();
31
55
  let changed = false;
32
56
  if (scope) {
33
- for (const hash of set) {
57
+ for (const hash of [...map.keys()]) {
34
58
  if (!scope.has(hash)) {
35
- set.delete(hash);
59
+ map.delete(hash);
36
60
  changed = true;
37
61
  }
38
62
  }
39
63
  }
40
- for (const hash of hashes) {
41
- if (!set.has(hash)) {
42
- set.add(hash);
64
+ for (const [hash, content] of entries) {
65
+ const prev = map.get(hash);
66
+ if (prev !== content) {
67
+ map.set(hash, content);
43
68
  changed = true;
44
69
  }
45
70
  }
46
- if (!changed) return undefined;
47
- return set;
71
+ if (!changed && existing) return undefined;
72
+ if (map.size === 0 && (!existing || existing.size === 0)) return undefined;
73
+ if (!changed) return existing;
74
+ return map;
48
75
  }
49
76
 
50
77
  export function recordServed(
51
78
  store: HashStore,
52
79
  path: string,
53
- hashes: string[],
80
+ entries: Map<string, string>,
54
81
  scope?: ReadonlySet<string>,
55
82
  ): void {
56
83
  try {
57
84
  withStore(() => {
58
- const set = computeUpdate(store, path, hashes, scope);
59
- if (!set) return;
60
- store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
85
+ const map = computeUpdate(store, path, entries, scope);
86
+ if (!map) return;
87
+ const obj = Object.fromEntries(map);
88
+ store.stmts.servedUpsert(path, JSON.stringify(obj), Date.now());
61
89
  });
62
90
  return;
63
91
  } catch (error) {
64
92
  if (!(error instanceof Error && error.message === STORE_NOT_OPEN_MESSAGE)) throw error;
65
93
  }
66
- const set = computeUpdate(store, path, hashes, scope);
67
- if (!set) return;
68
- store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
94
+ const map = computeUpdate(store, path, entries, scope);
95
+ if (!map) return;
96
+ const obj = Object.fromEntries(map);
97
+ store.stmts.servedUpsert(path, JSON.stringify(obj), Date.now());
69
98
  }
70
99
 
71
100
  export function recordServedDiff(
@@ -74,7 +103,7 @@ export function recordServedDiff(
74
103
  diff: string,
75
104
  scope?: ReadonlySet<string>,
76
105
  ): void {
77
- recordServed(store, path, servedHashesFromDiff(diff), scope);
106
+ recordServed(store, path, servedMapFromDiff(diff), scope);
78
107
  }
79
108
 
80
109
  export function clearServed(store: HashStore, path: string): void {
@@ -83,14 +112,14 @@ export function clearServed(store: HashStore, path: string): void {
83
112
 
84
113
  export async function recordServedSafe(
85
114
  path: string,
86
- hashes: string[],
115
+ entries: Map<string, string>,
87
116
  context: string,
88
117
  scope?: ReadonlySet<string>,
89
118
  ): Promise<void> {
90
- if (hashes.length === 0 && !scope) return;
119
+ if (entries.size === 0 && !scope) return;
91
120
  try {
92
121
  const store = await loadHashStore();
93
- recordServed(store, path, hashes, scope);
122
+ recordServed(store, path, entries, scope);
94
123
  } catch (error) {
95
124
  console.error(`Failed to record served state (${context}):`, error);
96
125
  }
@@ -103,5 +132,5 @@ export async function recordServedDiffSafe(
103
132
  scope?: ReadonlySet<string>,
104
133
  ): Promise<void> {
105
134
  if (!diff) return;
106
- await recordServedSafe(path, servedHashesFromDiff(diff), context, scope);
135
+ await recordServedSafe(path, servedMapFromDiff(diff), context, scope);
107
136
  }
package/src/validation.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { constants } from "fs";
2
2
  import { access as fsAccess } from "fs/promises";
3
3
  import type { LFile } from "./file-kind";
4
+ import type { FileIdentity } from "./fs-write";
4
5
  import { errCode } from "./utils";
5
6
 
6
7
  export async function valAccess(
@@ -26,7 +27,7 @@ export async function valAccess(
26
27
  }
27
28
  }
28
29
 
29
- export function valKind(file: LFile, path: string): asserts file is { kind: "text"; text: string; hadUtf8DecodeErrors?: true } {
30
+ export function valKind(file: LFile, path: string): asserts file is { kind: "text"; text: string; identity?: FileIdentity; hadUtf8DecodeErrors?: true } {
30
31
  if (file.kind === "directory") {
31
32
  throw new Error(`[E_NOT_TEXT] Path is a directory: ${path}. Use ls to inspect directories.`);
32
33
  }
@@ -42,4 +43,3 @@ export function valKind(file: LFile, path: string): asserts file is { kind: "tex
42
43
  );
43
44
  }
44
45
  }
45
-
package/src/write-hook.ts CHANGED
@@ -8,19 +8,19 @@ import { abortIf, splitLines, isRec, normalizeFilePath } from "./utils";
8
8
 
9
9
  const HASH_ECHO_RE = new RegExp(`^(${HASH_CLASS})${HASH_SEP}`);
10
10
 
11
- function searchEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
11
+ function searchEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
12
12
  for (let i = 0; i < lines.length; i++) {
13
13
  const match = HASH_ECHO_RE.exec(lines[i]!);
14
- if (match && served.has(match[1]!)) return { line: i + 1, hash: match[1]! };
14
+ if (match && served.has(match[1]! as never)) return { line: i + 1, hash: match[1]! };
15
15
  }
16
16
  return undefined;
17
17
  }
18
18
 
19
- export function findServedHashEcho(content: string, served: ReadonlySet<string>): { line: number; hash: string } | undefined {
19
+ export function findServedHashEcho(content: string, served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
20
20
  return searchEcho(splitLines(content), served);
21
21
  }
22
22
 
23
- export function findEditHashEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
23
+ export function findEditHashEcho(lines: string[], served: ReadonlyMap<string, string> | ReadonlySet<string>): { line: number; hash: string } | undefined {
24
24
  return searchEcho(lines, served);
25
25
  }
26
26