pi-hashline-edit-pro 2.7.2 → 2.8.1

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.
Files changed (44) hide show
  1. package/README.md +7 -1
  2. package/index.ts +9 -6
  3. package/package.json +10 -10
  4. package/prompts/grep-guidelines.md +3 -7
  5. package/prompts/grep-snippet.md +1 -1
  6. package/prompts/grep.md +1 -1
  7. package/prompts/insert-guidelines.md +3 -7
  8. package/prompts/insert-snippet.md +1 -1
  9. package/prompts/insert.md +1 -1
  10. package/prompts/read-guidelines.md +1 -1
  11. package/prompts/read.md +1 -1
  12. package/prompts/replace-guidelines.md +5 -9
  13. package/prompts/replace-snippet.md +1 -1
  14. package/prompts/replace.md +1 -1
  15. package/prompts/undo-last-change-guidelines.md +2 -4
  16. package/prompts/undo-last-change-snippet.md +1 -1
  17. package/prompts/undo-last-change.md +1 -1
  18. package/src/commit.ts +2 -1
  19. package/src/edit-common.ts +45 -0
  20. package/src/file-kind.ts +14 -8
  21. package/src/file-reader.ts +34 -13
  22. package/src/fs-write.ts +60 -3
  23. package/src/grep.ts +119 -32
  24. package/src/hash-store/cache.ts +18 -0
  25. package/src/hash-store/retry.ts +48 -0
  26. package/src/hash-store/validation.ts +62 -0
  27. package/src/hash-store.ts +68 -133
  28. package/src/hashline/hash.ts +11 -9
  29. package/src/hashline/parse.ts +15 -1
  30. package/src/hashline/resolve.ts +3 -2
  31. package/src/insert.ts +11 -32
  32. package/src/normalize.ts +27 -0
  33. package/src/payload-contract.ts +102 -0
  34. package/src/read.ts +15 -1
  35. package/src/replace-diff.ts +28 -45
  36. package/src/replace-render.ts +18 -40
  37. package/src/replace-response.ts +1 -0
  38. package/src/replace-undo.ts +28 -13
  39. package/src/replace.ts +49 -128
  40. package/src/served.ts +26 -5
  41. package/src/utils.ts +58 -0
  42. package/src/validation.ts +2 -2
  43. package/src/write-hook.ts +59 -0
  44. package/src/replace-normalize.ts +0 -13
@@ -1,16 +1,17 @@
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
- import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
6
+ import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
6
7
  import { recordServedDiff } from "./served";
7
- import { contentChecksum } from "./hashline/hasher";
8
- import { resolveTarget, writeAtomic } from "./fs-write";
9
- import { toCwd } from "./paths";
10
- import { toLF, stripBOM, genDiff, genPatch, restoreEndings, type LineEnding } from "./replace-diff";
11
- import { cntDiff, splitLines, errCode, makePrepareArguments } from "./utils";
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 } from "./utils";
12
12
  import { loadP, loadGuide } from "./prompts";
13
13
  import { buildMetrics } from "./replace-response";
14
+ import { renderEditResult } from "./replace-render";
14
15
  import { changedRange, lineHashes } from "./hashline";
15
16
  export interface UndoEntry {
16
17
  content: string;
@@ -98,11 +99,13 @@ export function regUndo(pi: ExtensionAPI): void {
98
99
  description: "Path to the file to undo",
99
100
  }),
100
101
  }),
101
-
102
+ executionMode: "sequential",
103
+ renderResult(result, opts, theme, context) {
104
+ return renderEditResult(result as never, opts as { isPartial: boolean; expanded?: boolean }, theme as never, context as never);
105
+ },
102
106
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
103
107
  const path = params.path;
104
- const absolutePath = toCwd(path, ctx.cwd);
105
- const mutationTargetPath = await resolveTarget(absolutePath);
108
+ const { resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
106
109
 
107
110
  const undo = await getUndo(mutationTargetPath);
108
111
  if (!undo) {
@@ -120,8 +123,17 @@ export function regUndo(pi: ExtensionAPI): void {
120
123
 
121
124
  return withFileMutationQueue(mutationTargetPath, async () => {
122
125
  let currentRaw: string | undefined;
126
+ let currentIdentity: FileIdentity | undefined;
123
127
  try {
124
- 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
+ }
125
137
  } catch (error) {
126
138
  if (errCode(error) !== "ENOENT") throw error;
127
139
  }
@@ -145,6 +157,7 @@ export function regUndo(pi: ExtensionAPI): void {
145
157
  await writeAtomic(
146
158
  mutationTargetPath,
147
159
  undo.bom + restoreEndings(undo.content, undo.originalEnding),
160
+ currentIdentity,
148
161
  );
149
162
 
150
163
  const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
@@ -153,11 +166,12 @@ export function regUndo(pi: ExtensionAPI): void {
153
166
  const linesAddedByReplace = cntDiff(diffResult.diff, "+");
154
167
  const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
155
168
  const restoredRange = changedRange(currentNormalized, undo.content);
156
- const undoDiff = genDiff(currentNormalized, undo.content, 1, undo.hashes, currentHashes).diff;
169
+ const undoDiffResult = genDiff(currentNormalized, undo.content, 1, undo.hashes, currentHashes);
170
+ const undoDiff = undoDiffResult.diff;
157
171
 
158
172
  try {
159
173
  const store = await loadHashStore();
160
- upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
174
+ persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes);
161
175
  recordServedDiff(store, mutationTargetPath, undoDiff, new Set(undo.hashes));
162
176
  } catch (error) {
163
177
  console.error("Failed to restore hash store snapshot after undo:", error);
@@ -190,6 +204,7 @@ export function regUndo(pi: ExtensionAPI): void {
190
204
  ],
191
205
  details: {
192
206
  diff: undoDiff,
207
+ diffLineNumbers: undoDiffResult.lineNumbers,
193
208
  patch: patchResult.patch,
194
209
  ...(patchResult.truncated ? { patchTruncated: true as const } : {}),
195
210
  metrics: buildMetrics({
package/src/replace.ts CHANGED
@@ -2,17 +2,16 @@ 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
- import { Type } from "typebox";
7
5
  import { constants } from "fs";
8
6
  import {
9
7
  genDiff,
10
8
  type LineEnding,
11
9
  } from "./replace-diff";
12
10
  import { readNormFile, type NormFile } from "./file-reader";
13
- import { normReq } from "./replace-normalize";
14
- import { isRec, rejectUnknownFields, abortIf, makePrepareArguments } from "./utils";
15
- import { resolveTarget } from "./fs-write";
11
+ import { editToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
12
+ import { isRec } from "./utils";
13
+ import { loadP, loadGuide } from "./prompts";
14
+ import { type FileIdentity } from "./fs-write";
16
15
  import { applyEdit,
17
16
  lineHashes,
18
17
  resEdit,
@@ -23,54 +22,18 @@ import { applyEdit,
23
22
  type HEdit,
24
23
  type NEdit,
25
24
  } from "./hashline";
26
- import { toCwd } from "./paths";
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
- import { loadP, loadGuide } from "./prompts";
35
31
  import { loadHashStore, findSnapshotPaths, findServedPaths, type HashStore } from "./hash-store";
36
32
  import { getServed, recordServedSafe } from "./served";
37
33
  import { noopPayloadKey, markBoundaryNoop, consumeBoundaryBypass, clearBoundaryBypass } from "./boundary-bypass";
38
- import { commitEdit } from "./commit";
39
-
40
- const replacementLinesSchema = Type.Array(
41
- Type.String({
42
- description:
43
- "One replacement line. Each element is exactly one line; do not embed \\n inside an element: use separate elements.",
44
- }),
45
- {
46
- description:
47
- "Replacement lines as an array of strings, one element per line. Use [] to delete the range."
48
- }
49
- );
34
+ import { queuedEdit, editToolBase, editRenderCallWrapper, editRenderResultWrapper } from "./edit-common";
50
35
 
51
- const removeFromSchema = Type.String({
52
- description: "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the FIRST line to remove (inclusive)",
53
- });
54
-
55
- const removeToSchema = Type.String({
56
- description: "Bare 3-char anchor only (e.g. \"aB3\"): copy just the anchor from the leftmost column of a read row like `aB3│content`; never the line content. Marks the LAST line to remove (inclusive)",
57
- });
58
-
59
- export const editToolSchema = Type.Object(
60
- {
61
- path: Type.Optional(Type.String({ description: "Path to edit. Required: always provide it explicitly; it is only auto-resolved from the anchors as a fallback when omitted by mistake." })),
62
- remove_from: removeFromSchema,
63
- remove_to: removeToSchema,
64
- replacement_lines: replacementLinesSchema,
65
- },
66
- { additionalProperties: false },
67
- );
68
- export type ReqParams = {
69
- path: string;
70
- remove_from: string;
71
- remove_to: string;
72
- replacement_lines: string[];
73
- };
36
+ export { editToolSchema, type ReqParams, assertReq };
74
37
 
75
38
  export type ReplaceDetails = {
76
39
  diff: string;
@@ -80,6 +43,7 @@ export type ReplaceDetails = {
80
43
  snapshotId?: string;
81
44
  classification?: "noop";
82
45
  metrics?: RMetrics;
46
+ diffLineNumbers?: (number|undefined)[];
83
47
  };
84
48
 
85
49
  export interface PipelineResult {
@@ -99,33 +63,7 @@ export interface PipelineResult {
99
63
  totalRemovedLines: number;
100
64
  hadBoundaryDedup: boolean;
101
65
  boundaryRemovedLines: number;
102
- }
103
-
104
- const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
105
-
106
- export function assertReq(
107
- request: unknown,
108
- ): asserts request is ReqParams {
109
- if (!isRec(request)) {
110
- throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
111
- }
112
-
113
- rejectUnknownFields(request, ROOT_KS, "Edit request");
114
-
115
- if (typeof request.path !== "string" || request.path.length === 0) {
116
- throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
117
- }
118
-
119
- if (
120
- typeof request.remove_from !== "string" ||
121
- typeof request.remove_to !== "string" ||
122
- !Array.isArray(request.replacement_lines) ||
123
- request.replacement_lines.some((line) => typeof line !== "string")
124
- ) {
125
- throw new Error(
126
- '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" (array of strings, one per line; use [] to delete).',
127
- );
128
- }
66
+ identity: FileIdentity;
129
67
  }
130
68
 
131
69
  async function resolveMissingPath(
@@ -173,21 +111,26 @@ export interface ExecPipelineOptions {
173
111
  preloadedNorm?: NormFile;
174
112
  }
175
113
 
114
+ function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
115
+ const a = hashes.indexOf(from);
116
+ const b = hashes.indexOf(to);
117
+ if (a < 0 || b < 0) return undefined;
118
+ return [Math.min(a, b), Math.max(a, b)];
119
+ }
120
+ async function noteAnchorError(absolutePath: string, error: unknown, scopeHashes: string[], noPersist?: boolean): Promise<void> {
121
+ if (noPersist === true) return;
122
+ if (error instanceof RangeStaleError) await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(scopeHashes));
123
+ else if (error instanceof AnchorMismatchError) await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(scopeHashes));
124
+ }
125
+
176
126
  function collectRemovedHashes(
177
127
  edit: HEdit,
178
128
  originalHashes: string[],
179
129
  ): Set<string> {
130
+ const span = hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
180
131
  const removedHashes = new Set<string>();
181
- const startHash = edit.hash_bounds[0].hash;
182
- const endHash = edit.hash_bounds[1].hash;
183
- const startLine = originalHashes.indexOf(startHash);
184
- const endLine = originalHashes.indexOf(endHash);
185
- if (startLine >= 0 && endLine >= 0) {
186
- const firstLine = Math.min(startLine, endLine);
187
- const lastLine = Math.max(startLine, endLine);
188
- for (let i = firstLine; i <= lastLine; i++) {
189
- removedHashes.add(originalHashes[i]!);
190
- }
132
+ if (span) {
133
+ for (let i = span[0]; i <= span[1]; i++) removedHashes.add(originalHashes[i]!);
191
134
  }
192
135
  return removedHashes;
193
136
  }
@@ -199,12 +142,8 @@ function countLineChanges(
199
142
  removedAutoFixes: number,
200
143
  ): { totalAddedLines: number; totalRemovedLines: number } {
201
144
  if (isNoop) return { totalAddedLines: 0, totalRemovedLines: 0 };
202
- let totalRemovedLines = 0;
203
- const startLine = originalHashes.indexOf(edit.hash_bounds[0].hash);
204
- const endLine = originalHashes.indexOf(edit.hash_bounds[1].hash);
205
- if (startLine >= 0 && endLine >= 0) {
206
- totalRemovedLines = Math.abs(endLine - startLine) + 1;
207
- }
145
+ const span = hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
146
+ const totalRemovedLines = span ? span[1] - span[0] + 1 : 0;
208
147
  return {
209
148
  totalAddedLines: Math.max(0, edit.content_lines.length - removedAutoFixes),
210
149
  totalRemovedLines,
@@ -230,7 +169,7 @@ export async function execPipeline(
230
169
  );
231
170
 
232
171
  const hashStore = options?.store ?? await loadHashStore();
233
- const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath } = await readNormFile(
172
+ const { normalized: originalNormalized, bom, originalEnding, fileHashes: originalHashes, hadUtf8DecodeErrors, absolutePath, identity } = await readNormFile(
234
173
  path, cwd, { signal: options?.signal, accessMode: options?.accessMode, maxLines: MAX_HASH_LINES, store: hashStore, noPersist: options?.noPersist, preloadedNorm: options?.preloadedNorm },
235
174
  );
236
175
 
@@ -247,13 +186,7 @@ export async function execPipeline(
247
186
  options?.skipBoundaryDedup,
248
187
  );
249
188
  } catch (error) {
250
- if (options?.noPersist !== true) {
251
- if (error instanceof RangeStaleError) {
252
- await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(originalHashes));
253
- } else if (error instanceof AnchorMismatchError) {
254
- await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(originalHashes));
255
- }
256
- }
189
+ await noteAnchorError(absolutePath, error, originalHashes, options?.noPersist);
257
190
  throw error;
258
191
  }
259
192
 
@@ -293,9 +226,21 @@ export async function execPipeline(
293
226
  totalRemovedLines,
294
227
  hadBoundaryDedup: (anchorResult.autoFixes?.length ?? 0) > 0,
295
228
  boundaryRemovedLines: anchorResult.autoFixes?.length ?? 0,
229
+ identity,
296
230
  };
297
231
  }
298
232
 
233
+ export function previewFromPipe(pipe: PipelineResult): RPreview {
234
+ if (pipe.originalNormalized === pipe.result) {
235
+ return {
236
+ error: `No changes made to ${pipe.path}. The edit produced identical content.`,
237
+ };
238
+ }
239
+ return { diff: genDiff(pipe.originalNormalized, pipe.result, 4, pipe.resultHashes, pipe.originalHashes).diff };
240
+ }
241
+ export function previewError(error: unknown): RPreview {
242
+ return { error: error instanceof Error ? error.message : String(error) };
243
+ }
299
244
  export async function compPreview(
300
245
  request: unknown,
301
246
  cwd: string,
@@ -303,20 +248,14 @@ export async function compPreview(
303
248
  try {
304
249
  const normalized = normReq(request);
305
250
  assertReq(normalized);
306
- const { path, originalNormalized, result, resultHashes, originalHashes } = await execPipeline(
251
+ const pipe = await execPipeline(
307
252
  normalized,
308
253
  cwd,
309
254
  { accessMode: constants.R_OK, noPersist: true },
310
255
  );
311
- if (originalNormalized === result) {
312
- return {
313
- error: `No changes made to ${path}. The edit produced identical content.`,
314
- };
315
- }
316
-
317
- return { diff: genDiff(originalNormalized, result, 4, resultHashes, originalHashes).diff };
256
+ return previewFromPipe(pipe);
318
257
  } catch (error: unknown) {
319
- return { error: error instanceof Error ? error.message : String(error) };
258
+ return previewError(error);
320
259
  }
321
260
  }
322
261
 
@@ -326,12 +265,10 @@ type ToolDef = ToolDefinition<
326
265
  RRState
327
266
  > & { renderShell?: "default" | "self" };
328
267
 
329
-
330
268
  export function buildToolDef(): ToolDef {
331
269
  const E_DESC = loadP("../prompts/replace.md");
332
270
  const E_SNIPPET = loadP("../prompts/replace-snippet.md");
333
271
  const E_GUIDE = loadGuide("../prompts/replace-guidelines.md");
334
-
335
272
  const parameters = editToolSchema;
336
273
  return {
337
274
  name: "replace",
@@ -340,21 +277,9 @@ export function buildToolDef(): ToolDef {
340
277
  parameters,
341
278
  promptSnippet: E_SNIPPET,
342
279
  promptGuidelines: E_GUIDE,
343
- prepareArguments: makePrepareArguments(),
344
- renderShell: "default",
345
- renderCall: makeRenderCall(compPreview),
346
- renderResult(result, { isPartial }, theme, context) {
347
- return renderEditResult(
348
- result as {
349
- content?: Array<{ type: string; text?: string }>;
350
- details?: ReplaceDetails;
351
- },
352
- isPartial,
353
- theme,
354
- context,
355
- );
356
- },
357
-
280
+ ...editToolBase,
281
+ renderCall: editRenderCallWrapper(compPreview),
282
+ renderResult: editRenderResultWrapper,
358
283
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
359
284
  const canonical = normReq(params);
360
285
  const resolution = isRec(canonical) ? await resolveMissingPath(canonical) : undefined;
@@ -362,15 +287,11 @@ export function buildToolDef(): ToolDef {
362
287
  canonical.path = resolution.path;
363
288
  }
364
289
  assertReq(canonical);
365
-
366
290
  const normalizedParams = canonical;
367
291
  const path = normalizedParams.path;
368
- const absolutePath = toCwd(path, ctx.cwd);
369
- const mutationTargetPath = await resolveTarget(absolutePath);
370
- const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
371
- const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
372
- return withFileMutationQueue(mutationTargetPath, async () => {
373
- 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);
374
295
  const pipe = await execPipeline(
375
296
  normalizedParams,
376
297
  ctx.cwd,
package/src/served.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { loadHashStore, parseStoredHashes, type HashStore } from "./hash-store";
1
+ import { loadHashStore, parseStoredHashes, STORE_NOT_OPEN_MESSAGE, withStore, type HashStore } from "./hash-store";
2
2
  import { HASH_CLASS } from "./hashline/alphabet";
3
3
 
4
4
  const SERVED_DIFF_ROW_RE = new RegExp(`^[+ ](${HASH_CLASS})│`);
@@ -19,14 +19,14 @@ export function getServed(store: HashStore, path: string): Set<string> | undefin
19
19
  return new Set(parsed);
20
20
  }
21
21
 
22
- export function recordServed(
22
+ function computeUpdate(
23
23
  store: HashStore,
24
24
  path: string,
25
25
  hashes: string[],
26
26
  scope?: ReadonlySet<string>,
27
- ): void {
27
+ ): Set<string> | undefined {
28
28
  const existing = getServed(store, path);
29
- if (!existing && hashes.length === 0) return;
29
+ if (!existing && hashes.length === 0) return undefined;
30
30
  const set = existing ?? new Set<string>();
31
31
  let changed = false;
32
32
  if (scope) {
@@ -43,7 +43,28 @@ export function recordServed(
43
43
  changed = true;
44
44
  }
45
45
  }
46
- if (!changed) return;
46
+ if (!changed) return undefined;
47
+ return set;
48
+ }
49
+
50
+ export function recordServed(
51
+ store: HashStore,
52
+ path: string,
53
+ hashes: string[],
54
+ scope?: ReadonlySet<string>,
55
+ ): void {
56
+ try {
57
+ withStore(() => {
58
+ const set = computeUpdate(store, path, hashes, scope);
59
+ if (!set) return;
60
+ store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
61
+ });
62
+ return;
63
+ } catch (error) {
64
+ if (!(error instanceof Error && error.message === STORE_NOT_OPEN_MESSAGE)) throw error;
65
+ }
66
+ const set = computeUpdate(store, path, hashes, scope);
67
+ if (!set) return;
47
68
  store.stmts.servedUpsert(path, JSON.stringify([...set]), Date.now());
48
69
  }
49
70
 
package/src/utils.ts CHANGED
@@ -106,7 +106,65 @@ export function truncateToBytes(s: string, maxBytes: number): string {
106
106
  return out;
107
107
  }
108
108
 
109
+ export function getCached<K, V>(map: Map<K, V>, key: K, compute: (key: K) => V): V {
110
+ if (map.has(key)) return map.get(key)!;
111
+ const v = compute(key);
112
+ map.set(key, v);
113
+ return v;
114
+ }
115
+
116
+ export function isHashRow(line: string): boolean {
117
+ return /^[A-Za-z0-9]{3}│/.test(line);
118
+ }
119
+
120
+ function gutterWidth(max: number, fallback: number): number {
121
+ return String(max || fallback).length;
122
+ }
123
+
124
+ function formatGutter(n: number, width: number): string {
125
+ return String(n).padStart(width) + " │ ";
126
+ }
127
+
128
+ function blankGutter(width: number): string {
129
+ return " ".repeat(width) + " │ ";
130
+ }
131
+
132
+ export function numberedRead(text: string, offset: number): string {
133
+ const lines = text.split("\n");
134
+ const hashLines = lines.filter(isHashRow).length;
135
+ const max = hashLines > 0 ? offset + hashLines - 1 : offset;
136
+ const width = gutterWidth(max, offset);
137
+ let n = offset;
138
+ return lines.map((line) => {
139
+ if (!isHashRow(line)) return line;
140
+ const prefix = formatGutter(n++, width);
141
+ return prefix + line;
142
+ }).join("\n");
143
+ }
144
+
145
+ export function withLineNumbers(text: string, numbers: (number|undefined)[]): string {
146
+ const lines = text.split("\n");
147
+ const nums = numbers ?? [];
148
+ const max = nums.reduce<number>((m, n) => n !== undefined && n > m ? n : m, 0);
149
+ const width = gutterWidth(max, lines.length);
150
+ return lines.map((line, i) => {
151
+ const n = nums[i];
152
+ const prefix = n !== undefined ? formatGutter(n, width) : blankGutter(width);
153
+ return prefix + line;
154
+ }).join("\n");
155
+ }
109
156
  export function clipLine(line: string, maxLen = 200): string {
110
157
  const flat = line.replace(/\n/g, "\\n");
111
158
  return flat.length > maxLen ? `${flat.slice(0, maxLen)}...` : flat;
112
159
  }
160
+ export function assertLineLimit(content: string, displayPath: string, limit: number): void {
161
+ const count = splitLines(content).length;
162
+ if (count > limit) throw new Error(formatLineLimit(displayPath, limit, count));
163
+ }
164
+ export function lineLimitMoreThanMessage(displayPath: string, limit: number): string {
165
+ return formatLineLimit(displayPath, limit, undefined);
166
+ }
167
+ function formatLineLimit(displayPath: string, limit: number, count: number | undefined): string {
168
+ const detail = count === undefined ? `has more than ${limit}` : `has ${count}`;
169
+ return `[E_FILE_TOO_LARGE] ${displayPath} ${detail} lines, exceeding the ${limit}-line hashline limit. For very large files, use write.`;
170
+ }
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
-
@@ -0,0 +1,59 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { HASH_CLASS } from "./hashline/alphabet";
3
+ import { HASH_SEP } from "./hashline/hash";
4
+ import { loadHashStore } from "./hash-store";
5
+ import { getServed } from "./served";
6
+ import { resolveInCwd } from "./fs-write";
7
+ import { abortIf, splitLines, isRec, normalizeFilePath } from "./utils";
8
+
9
+ const HASH_ECHO_RE = new RegExp(`^(${HASH_CLASS})${HASH_SEP}`);
10
+
11
+ function searchEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
12
+ for (let i = 0; i < lines.length; i++) {
13
+ const match = HASH_ECHO_RE.exec(lines[i]!);
14
+ if (match && served.has(match[1]!)) return { line: i + 1, hash: match[1]! };
15
+ }
16
+ return undefined;
17
+ }
18
+
19
+ export function findServedHashEcho(content: string, served: ReadonlySet<string>): { line: number; hash: string } | undefined {
20
+ return searchEcho(splitLines(content), served);
21
+ }
22
+
23
+ export function findEditHashEcho(lines: string[], served: ReadonlySet<string>): { line: number; hash: string } | undefined {
24
+ return searchEcho(lines, served);
25
+ }
26
+
27
+ export async function servedHashEchoDenial(rawPath: string, content: string, cwd: string, signal?: AbortSignal): Promise<string | undefined> {
28
+ abortIf(signal);
29
+ const { resolved } = await resolveInCwd(rawPath, cwd);
30
+ abortIf(signal);
31
+ const store = await loadHashStore();
32
+ const served = getServed(store, resolved);
33
+ if (!served || served.size === 0) return undefined;
34
+ const echo = findServedHashEcho(content, served);
35
+ if (!echo) return undefined;
36
+ return `[E_WRITE_HASH_ECHO] Refused write to ${rawPath}: line ${echo.line} begins with the exact ${echo.hash}${HASH_SEP} anchor served for this file. Remove the copied anchors and retry. Nothing was written.`;
37
+ }
38
+
39
+ export function registerWriteHook(pi: ExtensionAPI): void {
40
+ pi.on("tool_call", async (event, ctx) => {
41
+ if (event.toolName !== "write") return;
42
+ const input = event.input as Record<string, unknown> | undefined;
43
+ if (!input || !isRec(input)) return;
44
+ const normalized = { ...input };
45
+ normalizeFilePath(normalized);
46
+ const rawPath = normalized.path as unknown;
47
+ const content = normalized.content as unknown;
48
+ if (typeof rawPath !== "string" || typeof content !== "string") return;
49
+ const signal = ctx.signal;
50
+ try {
51
+ const reason = await servedHashEchoDenial(rawPath, content, ctx.cwd, signal);
52
+ if (reason !== undefined) return { block: true, reason };
53
+ } catch (error) {
54
+ if (signal?.aborted) throw error;
55
+ console.error("write hook failed:", error);
56
+ }
57
+ return;
58
+ });
59
+ }
@@ -1,13 +0,0 @@
1
- import { isRec, normalizeFilePath } from "./utils";
2
-
3
- export function normReq(input: unknown): unknown {
4
- if (!isRec(input)) {
5
- return input;
6
- }
7
-
8
- const record: Record<string, unknown> = { ...input };
9
-
10
- normalizeFilePath(record);
11
-
12
- return record;
13
- }