pi-hashline-edit-pro 2.7.2 → 2.8.0

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,8 +1,9 @@
1
1
  import { Markdown, Text } from "@earendil-works/pi-tui";
2
2
  import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
3
- import { normReq } from "./replace-normalize";
4
- import type { ReqParams, ReplaceDetails } from "./replace";
5
- import { isRec } from "./utils";
3
+ import type { ReplaceDetails } from "./replace";
4
+ import { withLineNumbers } from "./utils";
5
+ import { getPreviewInput } from "./payload-contract";
6
+ export { getPreviewInput };
6
7
 
7
8
  export type FgT = Pick<Theme, "fg">;
8
9
  export type CallT = Pick<Theme, "fg" | "bold">;
@@ -20,42 +21,12 @@ export type RRState = {
20
21
  previewTimer?: ReturnType<typeof setTimeout>;
21
22
  };
22
23
 
23
- export function getPreviewInput(
24
- args: unknown,
25
- ): ReqParams | null {
26
- let normalized: unknown;
27
- try {
28
- normalized = normReq(args);
29
- } catch {
30
- return null;
31
- }
32
- if (!isRec(normalized) || typeof normalized.path !== "string") {
33
- return null;
34
- }
35
-
36
- if (
37
- typeof normalized.remove_from !== "string" ||
38
- typeof normalized.remove_to !== "string" ||
39
- !Array.isArray(normalized.replacement_lines) ||
40
- normalized.replacement_lines.some((line) => typeof line !== "string")
41
- ) {
42
- return null;
43
- }
44
-
45
- const request: ReqParams = {
46
- path: normalized.path,
47
- remove_from: normalized.remove_from,
48
- remove_to: normalized.remove_to,
49
- replacement_lines: normalized.replacement_lines,
50
- };
51
- return request;
52
- }
53
-
54
24
  type DiffRowKind = "added" | "removed" | "context";
55
25
 
56
26
  function diffRowKind(line: string): DiffRowKind {
57
- if (line.startsWith("+") && !line.startsWith("+++")) return "added";
58
- if (line.startsWith("-") && !line.startsWith("---")) return "removed";
27
+ const stripped = line.replace(/^\s*\d+\s+│\s*/, "");
28
+ if (stripped.startsWith("+") && !stripped.startsWith("+++")) return "added";
29
+ if (stripped.startsWith("-") && !stripped.startsWith("---")) return "removed";
59
30
  return "context";
60
31
  }
61
32
 
@@ -67,6 +38,9 @@ export function colorLines(lines: string[], theme: FgT): string[] {
67
38
  return theme.fg("dim", line);
68
39
  });
69
40
  }
41
+ export function toNumberedDiff(diff: string, lineNumbers: (number|undefined)[]): string {
42
+ return withLineNumbers(diff, lineNumbers);
43
+ }
70
44
 
71
45
  export function fmtPreview(
72
46
  diff: string,
@@ -173,10 +147,11 @@ export function buildAppliedText(
173
147
  const summary = extractSummary(text);
174
148
  if (summary) sections.push(summary);
175
149
  if (details?.diff) {
150
+ const rawDiff = details.diffLineNumbers ? toNumberedDiff(details.diff, details.diffLineNumbers) : details.diff;
176
151
  const diffLines = details.diff.split("\n");
177
152
  const diffSection = expanded
178
- ? fmtResult(details.diff, theme)
179
- : fmtPreview(details.diff, false, theme);
153
+ ? fmtResult(rawDiff, theme)
154
+ : fmtPreview(rawDiff, false, theme);
180
155
  const hint =
181
156
  !expanded && diffLines.length > RESULT_PREVIEW_LINES
182
157
  ? ` (${expandHint()})`
@@ -322,10 +297,12 @@ export function makeRenderCall(
322
297
 
323
298
  export function renderEditResult(
324
299
  result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
325
- isPartial: boolean,
300
+ options: { isPartial: boolean; expanded?: boolean } | boolean,
326
301
  theme: FgT,
327
302
  context: any,
328
303
  ): Text | Markdown {
304
+ const isPartial = typeof options === "boolean" ? options : options.isPartial;
305
+ const optionsExpanded = typeof options === "boolean" ? undefined : options.expanded;
329
306
  if (isPartial) return reuseText(context, theme.fg("warning", "Editing..."));
330
307
  const renderedText = getResultText(result);
331
308
  const renderState = context.state as RRState | undefined;
@@ -343,7 +320,8 @@ export function renderEditResult(
343
320
  : new Text("", 0, 0);
344
321
  }
345
322
  if (isApplied(result.details)) {
346
- const appliedText = buildAppliedText(renderedText, result.details, theme, context.expanded === true);
323
+ const isExpanded = optionsExpanded === true || context.expanded === true;
324
+ const appliedText = buildAppliedText(renderedText, result.details, theme, isExpanded);
347
325
  return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
348
326
  }
349
327
  if (!renderedText) return new Text("", 0, 0);
@@ -164,6 +164,7 @@ export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
164
164
  editMeta.firstChangedLine ?? diffResult.firstChangedLine,
165
165
  snapshotId,
166
166
  metrics,
167
+ diffLineNumbers: diffResult.lineNumbers,
167
168
  },
168
169
  };
169
170
  }
@@ -2,15 +2,14 @@ import { readFile } from "fs/promises";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
- import { loadHashStore, upsertSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
5
+ import { loadHashStore, persistSnapshot, upsertUndo, getUndoEntry, deleteUndo, type UndoRecord } from "./hash-store";
6
6
  import { recordServedDiff } from "./served";
7
- import { contentChecksum } from "./hashline/hasher";
8
- import { resolveTarget, writeAtomic } from "./fs-write";
9
- import { toCwd } from "./paths";
7
+ import { resolveInCwd, writeAtomic } from "./fs-write";
10
8
  import { toLF, stripBOM, genDiff, genPatch, restoreEndings, type LineEnding } from "./replace-diff";
11
- import { cntDiff, splitLines, errCode, makePrepareArguments } from "./utils";
9
+ import { cntDiff, errCode, makePrepareArguments } from "./utils";
12
10
  import { loadP, loadGuide } from "./prompts";
13
11
  import { buildMetrics } from "./replace-response";
12
+ import { renderEditResult } from "./replace-render";
14
13
  import { changedRange, lineHashes } from "./hashline";
15
14
  export interface UndoEntry {
16
15
  content: string;
@@ -98,11 +97,13 @@ export function regUndo(pi: ExtensionAPI): void {
98
97
  description: "Path to the file to undo",
99
98
  }),
100
99
  }),
101
-
100
+ executionMode: "sequential",
101
+ renderResult(result, opts, theme, context) {
102
+ return renderEditResult(result as never, opts as { isPartial: boolean; expanded?: boolean }, theme as never, context as never);
103
+ },
102
104
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
103
105
  const path = params.path;
104
- const absolutePath = toCwd(path, ctx.cwd);
105
- const mutationTargetPath = await resolveTarget(absolutePath);
106
+ const { resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
106
107
 
107
108
  const undo = await getUndo(mutationTargetPath);
108
109
  if (!undo) {
@@ -153,11 +154,12 @@ export function regUndo(pi: ExtensionAPI): void {
153
154
  const linesAddedByReplace = cntDiff(diffResult.diff, "+");
154
155
  const linesRemovedByReplace = cntDiff(diffResult.diff, "-");
155
156
  const restoredRange = changedRange(currentNormalized, undo.content);
156
- const undoDiff = genDiff(currentNormalized, undo.content, 1, undo.hashes, currentHashes).diff;
157
+ const undoDiffResult = genDiff(currentNormalized, undo.content, 1, undo.hashes, currentHashes);
158
+ const undoDiff = undoDiffResult.diff;
157
159
 
158
160
  try {
159
161
  const store = await loadHashStore();
160
- upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), splitLines(undo.content).length, undo.hashes);
162
+ persistSnapshot(store, mutationTargetPath, undo.content, undo.hashes);
161
163
  recordServedDiff(store, mutationTargetPath, undoDiff, new Set(undo.hashes));
162
164
  } catch (error) {
163
165
  console.error("Failed to restore hash store snapshot after undo:", error);
@@ -190,6 +192,7 @@ export function regUndo(pi: ExtensionAPI): void {
190
192
  ],
191
193
  details: {
192
194
  diff: undoDiff,
195
+ diffLineNumbers: undoDiffResult.lineNumbers,
193
196
  patch: patchResult.patch,
194
197
  ...(patchResult.truncated ? { patchTruncated: true as const } : {}),
195
198
  metrics: buildMetrics({
package/src/replace.ts CHANGED
@@ -3,16 +3,16 @@ import type {
3
3
  ToolDefinition,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
- import { Type } from "typebox";
7
6
  import { constants } from "fs";
8
7
  import {
9
8
  genDiff,
10
9
  type LineEnding,
11
10
  } from "./replace-diff";
12
11
  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";
12
+ import { editToolSchema, type ReqParams, assertReq, normReq } from "./payload-contract";
13
+ import { isRec, abortIf, makePrepareArguments } from "./utils";
14
+ import { loadP, loadGuide } from "./prompts";
15
+ import { resolveInCwd } from "./fs-write";
16
16
  import { applyEdit,
17
17
  lineHashes,
18
18
  resEdit,
@@ -23,7 +23,7 @@ import { applyEdit,
23
23
  type HEdit,
24
24
  type NEdit,
25
25
  } from "./hashline";
26
- import { toCwd } from "./paths";
26
+ import { commitEdit } from "./commit";
27
27
  import type { RMetrics } from "./replace-response";
28
28
  import {
29
29
  makeRenderCall,
@@ -31,46 +31,11 @@ import {
31
31
  type RPreview,
32
32
  type RRState,
33
33
  } from "./replace-render";
34
- import { loadP, loadGuide } from "./prompts";
35
34
  import { loadHashStore, findSnapshotPaths, findServedPaths, type HashStore } from "./hash-store";
36
35
  import { getServed, recordServedSafe } from "./served";
37
36
  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
- );
50
-
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
37
 
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
- };
38
+ export { editToolSchema, type ReqParams, assertReq };
74
39
 
75
40
  export type ReplaceDetails = {
76
41
  diff: string;
@@ -80,6 +45,7 @@ export type ReplaceDetails = {
80
45
  snapshotId?: string;
81
46
  classification?: "noop";
82
47
  metrics?: RMetrics;
48
+ diffLineNumbers?: (number|undefined)[];
83
49
  };
84
50
 
85
51
  export interface PipelineResult {
@@ -101,33 +67,6 @@ export interface PipelineResult {
101
67
  boundaryRemovedLines: number;
102
68
  }
103
69
 
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
- }
129
- }
130
-
131
70
  async function resolveMissingPath(
132
71
  request: Record<string, unknown>,
133
72
  ): Promise<{ path: string; warning: string } | undefined> {
@@ -173,21 +112,26 @@ export interface ExecPipelineOptions {
173
112
  preloadedNorm?: NormFile;
174
113
  }
175
114
 
115
+ function hashSpan(hashes: string[], from: string, to: string): [number, number] | undefined {
116
+ const a = hashes.indexOf(from);
117
+ const b = hashes.indexOf(to);
118
+ if (a < 0 || b < 0) return undefined;
119
+ return [Math.min(a, b), Math.max(a, b)];
120
+ }
121
+ async function noteAnchorError(absolutePath: string, error: unknown, scopeHashes: string[], noPersist?: boolean): Promise<void> {
122
+ 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));
125
+ }
126
+
176
127
  function collectRemovedHashes(
177
128
  edit: HEdit,
178
129
  originalHashes: string[],
179
130
  ): Set<string> {
131
+ const span = hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
180
132
  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
- }
133
+ if (span) {
134
+ for (let i = span[0]; i <= span[1]; i++) removedHashes.add(originalHashes[i]!);
191
135
  }
192
136
  return removedHashes;
193
137
  }
@@ -199,12 +143,8 @@ function countLineChanges(
199
143
  removedAutoFixes: number,
200
144
  ): { totalAddedLines: number; totalRemovedLines: number } {
201
145
  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
- }
146
+ const span = hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
147
+ const totalRemovedLines = span ? span[1] - span[0] + 1 : 0;
208
148
  return {
209
149
  totalAddedLines: Math.max(0, edit.content_lines.length - removedAutoFixes),
210
150
  totalRemovedLines,
@@ -247,13 +187,7 @@ export async function execPipeline(
247
187
  options?.skipBoundaryDedup,
248
188
  );
249
189
  } 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
- }
190
+ await noteAnchorError(absolutePath, error, originalHashes, options?.noPersist);
257
191
  throw error;
258
192
  }
259
193
 
@@ -296,6 +230,17 @@ export async function execPipeline(
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",
@@ -341,15 +278,16 @@ export function buildToolDef(): ToolDef {
341
278
  promptSnippet: E_SNIPPET,
342
279
  promptGuidelines: E_GUIDE,
343
280
  prepareArguments: makePrepareArguments(),
281
+ executionMode: "sequential",
344
282
  renderShell: "default",
345
283
  renderCall: makeRenderCall(compPreview),
346
- renderResult(result, { isPartial }, theme, context) {
284
+ renderResult(result, { isPartial, expanded }, theme, context) {
347
285
  return renderEditResult(
348
286
  result as {
349
287
  content?: Array<{ type: string; text?: string }>;
350
288
  details?: ReplaceDetails;
351
289
  },
352
- isPartial,
290
+ { isPartial, expanded },
353
291
  theme,
354
292
  context,
355
293
  );
@@ -365,8 +303,7 @@ export function buildToolDef(): ToolDef {
365
303
 
366
304
  const normalizedParams = canonical;
367
305
  const path = normalizedParams.path;
368
- const absolutePath = toCwd(path, ctx.cwd);
369
- const mutationTargetPath = await resolveTarget(absolutePath);
306
+ const { absolute: absolutePath, resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
370
307
  const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
371
308
  const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
372
309
  return withFileMutationQueue(mutationTargetPath, async () => {
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
+ }
@@ -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
- }