pi-hashline-edit-pro 2.7.1 → 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
- import 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";
2
+ import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
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,
@@ -145,20 +119,47 @@ export function isApplied(
145
119
  );
146
120
  }
147
121
 
122
+ const RESULT_PREVIEW_LINES = 16;
123
+
124
+ function expandHint(): string {
125
+ try {
126
+ return keyHint("app.tools.expand", "to expand");
127
+ } catch {
128
+ return "ctrl+o to expand";
129
+ }
130
+ }
131
+
132
+ function extractSummary(text: string | undefined): string | undefined {
133
+ if (!text) return undefined;
134
+ if (text.includes("│")) return undefined;
135
+ const warningsIdx = text.indexOf("\n\nWarnings:");
136
+ const summary = warningsIdx >= 0 ? text.slice(0, warningsIdx) : text;
137
+ return summary.length > 0 ? summary : undefined;
138
+ }
139
+
148
140
  export function buildAppliedText(
149
141
  text: string | undefined,
150
142
  details: ReplaceDetails | undefined,
151
143
  theme: FgT,
144
+ expanded: boolean,
152
145
  ): string | undefined {
153
146
  const sections: string[] = [];
154
-
147
+ const summary = extractSummary(text);
148
+ if (summary) sections.push(summary);
155
149
  if (details?.diff) {
156
- sections.push(fmtResult(details.diff, theme));
150
+ const rawDiff = details.diffLineNumbers ? toNumberedDiff(details.diff, details.diffLineNumbers) : details.diff;
151
+ const diffLines = details.diff.split("\n");
152
+ const diffSection = expanded
153
+ ? fmtResult(rawDiff, theme)
154
+ : fmtPreview(rawDiff, false, theme);
155
+ const hint =
156
+ !expanded && diffLines.length > RESULT_PREVIEW_LINES
157
+ ? ` (${expandHint()})`
158
+ : "";
159
+ sections.push(`${diffSection}${hint}`);
157
160
  }
158
-
159
161
  const warnings = extractWarnings(text);
160
162
  if (warnings) sections.push(warnings);
161
-
162
163
  return sections.length > 0 ? sections.join("\n\n") : undefined;
163
164
  }
164
165
 
@@ -296,10 +297,12 @@ export function makeRenderCall(
296
297
 
297
298
  export function renderEditResult(
298
299
  result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
299
- isPartial: boolean,
300
+ options: { isPartial: boolean; expanded?: boolean } | boolean,
300
301
  theme: FgT,
301
302
  context: any,
302
303
  ): Text | Markdown {
304
+ const isPartial = typeof options === "boolean" ? options : options.isPartial;
305
+ const optionsExpanded = typeof options === "boolean" ? undefined : options.expanded;
303
306
  if (isPartial) return reuseText(context, theme.fg("warning", "Editing..."));
304
307
  const renderedText = getResultText(result);
305
308
  const renderState = context.state as RRState | undefined;
@@ -317,7 +320,8 @@ export function renderEditResult(
317
320
  : new Text("", 0, 0);
318
321
  }
319
322
  if (isApplied(result.details)) {
320
- const appliedText = buildAppliedText(renderedText, result.details, theme);
323
+ const isExpanded = optionsExpanded === true || context.expanded === true;
324
+ const appliedText = buildAppliedText(renderedText, result.details, theme, isExpanded);
321
325
  return appliedText ? reuseText(context, appliedText) : new Text("", 0, 0);
322
326
  }
323
327
  if (!renderedText) return new Text("", 0, 0);
@@ -153,15 +153,18 @@ export function buildChanged(input: SuccessInput, verb = "replaced"): TResult {
153
153
  removedLines,
154
154
  });
155
155
 
156
+ const patchResult = genPatch(path, originalNormalized, result);
156
157
  return {
157
158
  content: [{ type: "text", text }],
158
159
  details: {
159
160
  diff: diffResult.diff,
160
- patch: genPatch(path, originalNormalized, result),
161
+ patch: patchResult.patch,
162
+ ...(patchResult.truncated ? { patchTruncated: true as const } : {}),
161
163
  firstChangedLine:
162
164
  editMeta.firstChangedLine ?? diffResult.firstChangedLine,
163
165
  snapshotId,
164
166
  metrics,
167
+ diffLineNumbers: diffResult.lineNumbers,
165
168
  },
166
169
  };
167
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) {
@@ -149,15 +150,16 @@ export function regUndo(pi: ExtensionAPI): void {
149
150
 
150
151
  const currentNormalized = currentRaw === undefined ? "" : toLF(stripBOM(currentRaw).text);
151
152
  const currentHashes = await lineHashes(currentNormalized, mutationTargetPath);
152
- const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes);
153
+ const diffResult = genDiff(undo.content, undo.resultContent, 0, undefined, undo.hashes, { unlimited: true });
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);
@@ -180,6 +182,7 @@ export function regUndo(pi: ExtensionAPI): void {
180
182
  "Call read for fresh anchors.",
181
183
  );
182
184
 
185
+ const patchResult = genPatch(path, currentNormalized, undo.content);
183
186
  return {
184
187
  content: [
185
188
  {
@@ -189,7 +192,9 @@ export function regUndo(pi: ExtensionAPI): void {
189
192
  ],
190
193
  details: {
191
194
  diff: undoDiff,
192
- patch: genPatch(path, currentNormalized, undo.content),
195
+ diffLineNumbers: undoDiffResult.lineNumbers,
196
+ patch: patchResult.patch,
197
+ ...(patchResult.truncated ? { patchTruncated: true as const } : {}),
193
198
  metrics: buildMetrics({
194
199
  classification: "applied",
195
200
  editsAttempted: 1,
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,54 +31,21 @@ 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 HASH only (e.g. \"aB3\"): copy just the hash 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 HASH only (e.g. \"aB3\"): copy just the hash 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;
77
42
  patch?: string;
43
+ patchTruncated?: boolean;
78
44
  firstChangedLine?: number;
79
45
  snapshotId?: string;
80
46
  classification?: "noop";
81
47
  metrics?: RMetrics;
48
+ diffLineNumbers?: (number|undefined)[];
82
49
  };
83
50
 
84
51
  export interface PipelineResult {
@@ -100,33 +67,6 @@ export interface PipelineResult {
100
67
  boundaryRemovedLines: number;
101
68
  }
102
69
 
103
- const ROOT_KS = new Set(["path", "remove_from", "remove_to", "replacement_lines"]);
104
-
105
- export function assertReq(
106
- request: unknown,
107
- ): asserts request is ReqParams {
108
- if (!isRec(request)) {
109
- throw new Error("[E_BAD_SHAPE] Edit request must be an object.");
110
- }
111
-
112
- rejectUnknownFields(request, ROOT_KS, "Edit request");
113
-
114
- if (typeof request.path !== "string" || request.path.length === 0) {
115
- throw new Error('[E_BAD_SHAPE] Edit request requires a non-empty "path" string.');
116
- }
117
-
118
- if (
119
- typeof request.remove_from !== "string" ||
120
- typeof request.remove_to !== "string" ||
121
- !Array.isArray(request.replacement_lines) ||
122
- request.replacement_lines.some((line) => typeof line !== "string")
123
- ) {
124
- throw new Error(
125
- '[E_BAD_SHAPE] Edit request requires "remove_from", "remove_to", and "replacement_lines" (array of strings, one per line; use [] to delete).',
126
- );
127
- }
128
- }
129
-
130
70
  async function resolveMissingPath(
131
71
  request: Record<string, unknown>,
132
72
  ): Promise<{ path: string; warning: string } | undefined> {
@@ -172,21 +112,26 @@ export interface ExecPipelineOptions {
172
112
  preloadedNorm?: NormFile;
173
113
  }
174
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
+
175
127
  function collectRemovedHashes(
176
128
  edit: HEdit,
177
129
  originalHashes: string[],
178
130
  ): Set<string> {
131
+ const span = hashSpan(originalHashes, edit.hash_bounds[0].hash, edit.hash_bounds[1].hash);
179
132
  const removedHashes = new Set<string>();
180
- const startHash = edit.hash_bounds[0].hash;
181
- const endHash = edit.hash_bounds[1].hash;
182
- const startLine = originalHashes.indexOf(startHash);
183
- const endLine = originalHashes.indexOf(endHash);
184
- if (startLine >= 0 && endLine >= 0) {
185
- const firstLine = Math.min(startLine, endLine);
186
- const lastLine = Math.max(startLine, endLine);
187
- for (let i = firstLine; i <= lastLine; i++) {
188
- removedHashes.add(originalHashes[i]!);
189
- }
133
+ if (span) {
134
+ for (let i = span[0]; i <= span[1]; i++) removedHashes.add(originalHashes[i]!);
190
135
  }
191
136
  return removedHashes;
192
137
  }
@@ -198,12 +143,8 @@ function countLineChanges(
198
143
  removedAutoFixes: number,
199
144
  ): { totalAddedLines: number; totalRemovedLines: number } {
200
145
  if (isNoop) return { totalAddedLines: 0, totalRemovedLines: 0 };
201
- let totalRemovedLines = 0;
202
- const startLine = originalHashes.indexOf(edit.hash_bounds[0].hash);
203
- const endLine = originalHashes.indexOf(edit.hash_bounds[1].hash);
204
- if (startLine >= 0 && endLine >= 0) {
205
- totalRemovedLines = Math.abs(endLine - startLine) + 1;
206
- }
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;
207
148
  return {
208
149
  totalAddedLines: Math.max(0, edit.content_lines.length - removedAutoFixes),
209
150
  totalRemovedLines,
@@ -246,13 +187,7 @@ export async function execPipeline(
246
187
  options?.skipBoundaryDedup,
247
188
  );
248
189
  } catch (error) {
249
- if (options?.noPersist !== true) {
250
- if (error instanceof RangeStaleError) {
251
- await recordServedSafe(absolutePath, error.rangeHashes, "range-stale feedback", new Set(originalHashes));
252
- } else if (error instanceof AnchorMismatchError) {
253
- await recordServedSafe(absolutePath, error.feedbackHashes, "anchor-mismatch feedback", new Set(originalHashes));
254
- }
255
- }
190
+ await noteAnchorError(absolutePath, error, originalHashes, options?.noPersist);
256
191
  throw error;
257
192
  }
258
193
 
@@ -295,6 +230,17 @@ export async function execPipeline(
295
230
  };
296
231
  }
297
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
+ }
298
244
  export async function compPreview(
299
245
  request: unknown,
300
246
  cwd: string,
@@ -302,20 +248,14 @@ export async function compPreview(
302
248
  try {
303
249
  const normalized = normReq(request);
304
250
  assertReq(normalized);
305
- const { path, originalNormalized, result, resultHashes, originalHashes } = await execPipeline(
251
+ const pipe = await execPipeline(
306
252
  normalized,
307
253
  cwd,
308
254
  { accessMode: constants.R_OK, noPersist: true },
309
255
  );
310
- if (originalNormalized === result) {
311
- return {
312
- error: `No changes made to ${path}. The edit produced identical content.`,
313
- };
314
- }
315
-
316
- return { diff: genDiff(originalNormalized, result, 4, resultHashes, originalHashes).diff };
256
+ return previewFromPipe(pipe);
317
257
  } catch (error: unknown) {
318
- return { error: error instanceof Error ? error.message : String(error) };
258
+ return previewError(error);
319
259
  }
320
260
  }
321
261
 
@@ -325,12 +265,10 @@ type ToolDef = ToolDefinition<
325
265
  RRState
326
266
  > & { renderShell?: "default" | "self" };
327
267
 
328
-
329
268
  export function buildToolDef(): ToolDef {
330
269
  const E_DESC = loadP("../prompts/replace.md");
331
270
  const E_SNIPPET = loadP("../prompts/replace-snippet.md");
332
271
  const E_GUIDE = loadGuide("../prompts/replace-guidelines.md");
333
-
334
272
  const parameters = editToolSchema;
335
273
  return {
336
274
  name: "replace",
@@ -340,15 +278,16 @@ export function buildToolDef(): ToolDef {
340
278
  promptSnippet: E_SNIPPET,
341
279
  promptGuidelines: E_GUIDE,
342
280
  prepareArguments: makePrepareArguments(),
281
+ executionMode: "sequential",
343
282
  renderShell: "default",
344
283
  renderCall: makeRenderCall(compPreview),
345
- renderResult(result, { isPartial }, theme, context) {
284
+ renderResult(result, { isPartial, expanded }, theme, context) {
346
285
  return renderEditResult(
347
286
  result as {
348
287
  content?: Array<{ type: string; text?: string }>;
349
288
  details?: ReplaceDetails;
350
289
  },
351
- isPartial,
290
+ { isPartial, expanded },
352
291
  theme,
353
292
  context,
354
293
  );
@@ -364,8 +303,7 @@ export function buildToolDef(): ToolDef {
364
303
 
365
304
  const normalizedParams = canonical;
366
305
  const path = normalizedParams.path;
367
- const absolutePath = toCwd(path, ctx.cwd);
368
- const mutationTargetPath = await resolveTarget(absolutePath);
306
+ const { absolute: absolutePath, resolved: mutationTargetPath } = await resolveInCwd(path, ctx.cwd);
369
307
  const noopPayload = noopPayloadKey(mutationTargetPath, normalizedParams.remove_from, normalizedParams.remove_to, normalizedParams.replacement_lines);
370
308
  const boundaryBypass = consumeBoundaryBypass(mutationTargetPath, noopPayload);
371
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
@@ -93,7 +93,78 @@ export function firstNonEmpty(lines: string[]): string | undefined {
93
93
  return idx >= 0 ? lines[idx] : undefined;
94
94
  }
95
95
 
96
+ export function truncateToBytes(s: string, maxBytes: number): string {
97
+ if (Buffer.byteLength(s, "utf-8") <= maxBytes) return s;
98
+ let out = "";
99
+ let bytes = 0;
100
+ for (const ch of s) {
101
+ const chBytes = Buffer.byteLength(ch, "utf-8");
102
+ if (bytes + chBytes > maxBytes) break;
103
+ out += ch;
104
+ bytes += chBytes;
105
+ }
106
+ return out;
107
+ }
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
+ }
96
156
  export function clipLine(line: string, maxLen = 200): string {
97
157
  const flat = line.replace(/\n/g, "\\n");
98
158
  return flat.length > maxLen ? `${flat.slice(0, maxLen)}...` : flat;
99
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
+ }