dsh-hashline-edit 0.2.1 → 0.2.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.
Files changed (109) hide show
  1. package/README.md +60 -56
  2. package/cordis.patch.yml +3 -3
  3. package/lib/index.js +7224 -6454
  4. package/lib/types/dsh/contracts.d.ts +3 -12
  5. package/lib/types/dsh/fs.d.ts +0 -3
  6. package/lib/types/dsh/guidance.d.ts +5 -5
  7. package/lib/types/dsh/host-compat.d.ts +78 -7
  8. package/lib/types/dsh/index.d.ts +9 -21
  9. package/lib/types/dsh/metrics/events.d.ts +1 -1
  10. package/lib/types/dsh/metrics/observer.d.ts +1 -1
  11. package/lib/types/dsh/sandbox.d.ts +2 -2
  12. package/lib/types/dsh/shell-hook.d.ts +1 -1
  13. package/lib/types/dsh/tools/edit.d.ts +10 -0
  14. package/lib/types/dsh/tools/read.d.ts +3 -3
  15. package/lib/types/src/domain-errors.d.ts +2 -16
  16. package/lib/types/src/edit-tool.d.ts +41 -0
  17. package/lib/types/src/edit-undo.d.ts +2 -11
  18. package/lib/types/src/file-content/index.d.ts +1 -1
  19. package/lib/types/src/file-content/preview.d.ts +1 -1
  20. package/lib/types/src/hash-store.d.ts +2 -2
  21. package/lib/types/src/hashline/alphabet.d.ts +16 -1
  22. package/lib/types/src/hashline/hash-identity.d.ts +10 -4
  23. package/lib/types/src/mutation-engine/engine.d.ts +31 -0
  24. package/lib/types/src/mutation-engine/pipeline.d.ts +1 -8
  25. package/lib/types/src/mutation-engine/types.d.ts +50 -40
  26. package/lib/types/src/payload-contract.d.ts +135 -0
  27. package/lib/types/src/served-session/serve-render.d.ts +0 -6
  28. package/lib/types/src/undo-store.d.ts +1 -17
  29. package/package.json +21 -18
  30. package/prompts/edit-guidelines.md +10 -0
  31. package/prompts/edit-snippet.md +1 -0
  32. package/prompts/edit.md +1 -0
  33. package/prompts/read-guidelines.md +4 -4
  34. package/prompts/read.md +1 -1
  35. package/prompts/undo-last-edit-guidelines.md +2 -2
  36. package/prompts/undo-last-edit-snippet.md +1 -1
  37. package/prompts/undo-last-edit.md +1 -1
  38. package/src/bash-classifier.ts +1626 -0
  39. package/src/bash-conversion/decide.ts +812 -0
  40. package/src/bash-display-transform.ts +50 -0
  41. package/src/bash-multifile-config.ts +239 -0
  42. package/src/bash-view-config.ts +158 -0
  43. package/src/cut-repair.ts +11 -77
  44. package/src/domain-errors.ts +3 -35
  45. package/src/edit-normalize.ts +8 -0
  46. package/src/edit-render.ts +161 -0
  47. package/src/edit-tool.ts +118 -0
  48. package/src/edit-undo.ts +25 -379
  49. package/src/edit.ts +136 -0
  50. package/src/file-content/index.ts +1 -1
  51. package/src/file-content/preview.ts +1 -1
  52. package/src/file-content/served-header.ts +1 -1
  53. package/src/hash-store.ts +1 -18
  54. package/src/hashline/alphabet.ts +42 -1
  55. package/src/hashline/hash-identity.ts +12 -3
  56. package/src/integrations/pi-lens/command.ts +712 -0
  57. package/src/integrations/pi-lens/config.ts +148 -0
  58. package/src/integrations/pi-lens/io-bridge-adapter.ts +234 -0
  59. package/src/integrations/pi-lens/io-bridge.ts +112 -0
  60. package/src/integrations/pi-lens/settings-overlay.ts +272 -0
  61. package/src/lifecycle-hooks/index.ts +1992 -0
  62. package/src/lifecycle-hooks/types.ts +64 -0
  63. package/src/mutated-files.ts +75 -0
  64. package/src/mutation-engine/engine.ts +239 -0
  65. package/src/mutation-engine/index.ts +17 -0
  66. package/src/mutation-engine/pipeline.ts +27 -198
  67. package/src/mutation-engine/types.ts +57 -39
  68. package/src/mutation-queue.ts +1 -1
  69. package/src/payload-contract.ts +685 -0
  70. package/src/preview-controller.ts +84 -0
  71. package/src/prompts.ts +43 -10
  72. package/src/read.ts +865 -0
  73. package/src/search-hook.ts +41 -0
  74. package/src/served-session/serve-render.ts +2 -2
  75. package/src/store-config.ts +73 -0
  76. package/src/store-janitor-warning.ts +169 -0
  77. package/src/store-janitor.ts +539 -0
  78. package/src/store-kernel/settings.ts +233 -0
  79. package/src/store.ts +154 -0
  80. package/src/tui-presenter.ts +134 -0
  81. package/src/undo-store.ts +5 -74
  82. package/src/write-hook.ts +130 -0
  83. package/lib/types/dsh/hide.d.ts +0 -79
  84. package/lib/types/dsh/tools/apply-patch.d.ts +0 -38
  85. package/lib/types/src/apply-patch/commit.d.ts +0 -49
  86. package/lib/types/src/apply-patch/grammar.d.ts +0 -29
  87. package/lib/types/src/apply-patch/lifecycle.d.ts +0 -32
  88. package/lib/types/src/apply-patch/plan.d.ts +0 -50
  89. package/lib/types/src/apply-patch/render.d.ts +0 -51
  90. package/lib/types/src/apply-patch/resolve.d.ts +0 -129
  91. package/lib/types/src/apply-patch/tool.d.ts +0 -54
  92. package/lib/types/src/apply-patch/types.d.ts +0 -362
  93. package/lib/types/src/fs-lifecycle.d.ts +0 -44
  94. package/lib/types/src/host-utils.d.ts +0 -63
  95. package/lib/types/src/mutation-engine/edit-request.d.ts +0 -34
  96. package/prompts/apply-patch-guidelines.md +0 -10
  97. package/prompts/apply-patch.md +0 -1
  98. package/src/apply-patch/commit.ts +0 -190
  99. package/src/apply-patch/grammar.ts +0 -620
  100. package/src/apply-patch/index.ts +0 -17
  101. package/src/apply-patch/lifecycle.ts +0 -195
  102. package/src/apply-patch/plan.ts +0 -800
  103. package/src/apply-patch/render.ts +0 -137
  104. package/src/apply-patch/resolve.ts +0 -470
  105. package/src/apply-patch/tool.ts +0 -257
  106. package/src/apply-patch/types.ts +0 -387
  107. package/src/fs-lifecycle.ts +0 -201
  108. package/src/host-utils.ts +0 -224
  109. package/src/mutation-engine/edit-request.ts +0 -39
package/src/read.ts ADDED
@@ -0,0 +1,865 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createReadTool, createReadToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import type { TruncationResult } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
5
+ import { Type, type Static } from "typebox";
6
+ import { MAX_READ_WINDOWS, SERVED_MAX_LINES } from "./constants.js";
7
+ import { HASH_LEN } from "./hashline/alphabet.js";
8
+ import type { ServedRow } from "./served-session/serve-render.js";
9
+ import { loadHashStore, type HashStore } from "./hash-store.js";
10
+ import { sessionFromContext, sessionKeyFor } from "./served-session/index.js";
11
+ import { contentChecksum } from "./hashline/hasher.js";
12
+ import { abortIf } from "./utils.js";
13
+ import { loadP, loadGuide } from "./prompts.js";
14
+ import {
15
+ decodeNormText,
16
+ loadFileKindAndText,
17
+ prepareFile,
18
+ type LFile,
19
+ type NormText,
20
+ type PrepareResult,
21
+ } from "./file-content/index.js";
22
+ import {
23
+ DEFAULT_ANCHOR_RADIUS,
24
+ DEFAULT_WINDOW_LIMIT,
25
+ admitReadRequest,
26
+ normalizeReadPayload,
27
+ planWindows,
28
+ type AdmittedRequest,
29
+ type AnchorResolver,
30
+ type RawReadParams,
31
+ type RenderMode,
32
+ type WindowPlan,
33
+ } from "./file-content/admission.js";
34
+ import { formatPageHeader } from "./file-content/served-header.js";
35
+ import { toCwd } from "./paths.js";
36
+ import { DomainError, withPayloadSubject } from "./domain-errors.js";
37
+ import { notifyServedSpans, servedRowsToSpans } from "./served-spans.js";
38
+ import { fileSnap } from "./file-reader.js";
39
+ import {
40
+ adoptStatSnapshotFor,
41
+ snapshotHashFor,
42
+ snapshotIOFor,
43
+ upsertSnapshotsFor,
44
+ } from "./snapshot-store";
45
+ import { lineHashes } from "./hashline/hash-identity.js";
46
+ import { reportDiagnostic, reportWarning } from "./diagnostics.js";
47
+ import { visibleLineTotal, walkLines } from "./file-content/line-walker.js";
48
+ import {
49
+ applyExpansions,
50
+ expansionNotes,
51
+ foldWindows,
52
+ widenWindow,
53
+ TRIGGER_MAX,
54
+ type ExpandedRead,
55
+ type ExpansionDecision,
56
+ type FoldedWindow,
57
+ type OffsetWindow,
58
+ type SpanProvider,
59
+ type WidenedBlock,
60
+ } from "./read-expansion/span-expansion.js";
61
+ import {
62
+ recordReadExpansion,
63
+ recordReadExpansionRuntimeAbsent,
64
+ recordReadExpansionToggleOff,
65
+ type ReadExpansionLogEntry,
66
+ } from "./read-expansion/audit.js";
67
+ import {
68
+ resolveReadExpansionMode,
69
+ resolveReadExpansionModeWithSource,
70
+ } from "./read-expansion-config.js";
71
+ // WHY: Facade re-export for callers still importing preview directly
72
+ export { fmtReadPreview } from "./file-content/preview.js";
73
+
74
+ const R_DESC = loadP("../prompts/read.md");
75
+ const R_SNIPPET = loadP("../prompts/read-snippet.md");
76
+
77
+ function readGuide(): string[] {
78
+ return loadGuide("../prompts/read-guidelines.md");
79
+ }
80
+
81
+ const renderModeSchema = Type.Union([Type.Literal("served"), Type.Literal("verbatim")], {
82
+ description: `Render mode: "served" (default) returns each line as a ${HASH_LEN}-char anchor plus content; "verbatim" returns plain text with no anchor prefix.`,
83
+ });
84
+
85
+ const readOffsetWindowSchema = Type.Object(
86
+ {
87
+ offset: Type.Optional(
88
+ Type.Integer({ minimum: 1, description: "Start line number (1-indexed). Defaults to 1." }),
89
+ ),
90
+ limit: Type.Optional(
91
+ Type.Integer({
92
+ minimum: 1,
93
+ description: `Number of lines to read. Defaults to ${DEFAULT_WINDOW_LIMIT} when \`offset\` is given.`,
94
+ }),
95
+ ),
96
+ },
97
+ // WHY: the union's two members must stay disjoint: both offset properties are optional, so without
98
+ // WHY: this an `{ around_anchor }` window would also satisfy the offset member and slip past the
99
+ // WHY: anchor's own length check at the seam.
100
+ { additionalProperties: false },
101
+ );
102
+
103
+ const readAnchorWindowSchema = Type.Object(
104
+ {
105
+ around_anchor: Type.String({
106
+ minLength: HASH_LEN,
107
+ maxLength: HASH_LEN,
108
+ description: `A ${HASH_LEN}-character anchor this session served: the window centres on its line.`,
109
+ }),
110
+ radius: Type.Optional(
111
+ Type.Integer({
112
+ minimum: 1,
113
+ default: DEFAULT_ANCHOR_RADIUS,
114
+ description: `Lines before and after the anchor (default ${DEFAULT_ANCHOR_RADIUS}).`,
115
+ }),
116
+ ),
117
+ },
118
+ { additionalProperties: false },
119
+ );
120
+
121
+ const readWindowSchema = Type.Union([readAnchorWindowSchema, readOffsetWindowSchema]);
122
+
123
+ // WHY: one file per call (ADR-0041 D1): `file` names it, `windows` scopes it, `mode` renders
124
+ // WHY: it. Parallel reads are parallel tool calls — there is no outer array and no per-file mode.
125
+ // WHY: A payload without `file` fails validation before admission runs.
126
+ export const readToolSchema = Type.Object({
127
+ file: Type.String({ description: "Path to the file to read (relative or absolute)." }),
128
+ windows: Type.Optional(
129
+ Type.Array(readWindowSchema, {
130
+ maxItems: MAX_READ_WINDOWS,
131
+ description:
132
+ "One or more line ranges in this file, each either { offset, limit } or { around_anchor, radius }; in the default `served` mode every window's rows are served, so anchors from all of them work in a single `edit`",
133
+ }),
134
+ ),
135
+ mode: Type.Optional(renderModeSchema),
136
+ });
137
+
138
+ // WHY: the builtin renderers key on `file_path ?? path` plus `offset`/`limit`; our payload
139
+ // WHY: field is `file` (ADR-0041 D1/D4), but recorded history (`files`-shaped per ADR-0040, or a
140
+ // WHY: legacy `file`/`path` caller per ADR-0040 D3) must still render, so the seam accepts every
141
+ // WHY: key. The D5 fallback order is exactly `file ?? path ?? files?.[0]?.file`.
142
+ type ReadRenderWindowTarget = {
143
+ offset?: number;
144
+ limit?: number;
145
+ around_anchor?: string;
146
+ radius?: number;
147
+ };
148
+ type ReadRenderArgs = {
149
+ file?: string;
150
+ path?: string;
151
+ windows?: ReadonlyArray<ReadRenderWindowTarget>;
152
+ mode?: string;
153
+ /** Retired outer shape (ADR-0040): history tolerance only, never produced anymore. */
154
+ files?: ReadonlyArray<{ file?: string }>;
155
+ };
156
+ /** Project the flat single-file payload onto the builtin renderer's keys (ADR-0041 D4/D5).
157
+ * Path is the call's own `file`, falling back through recorded-history shapes for rendering
158
+ * only. A range is derived only from exactly one simple `{ offset, limit }` window; anything
159
+ * else (multi-window, `around_anchor`) leaves the range absent rather than fabricating one.
160
+ * A multi-window call additionally reports its window count, which the caller appends to the
161
+ * call line. The result is a new object — `context.args` is never mutated. */
162
+ function projectReadRenderArgs(args: ReadRenderArgs | undefined): {
163
+ path: string | undefined;
164
+ offset?: number;
165
+ limit?: number;
166
+ windowsCount?: number;
167
+ } {
168
+ const windowCount = args?.windows?.length ?? 0;
169
+ const only = windowCount === 1 ? args!.windows![0] : undefined;
170
+ const ranged =
171
+ typeof only?.offset === "number" && typeof only?.limit === "number" ? only : undefined;
172
+ return {
173
+ path: args?.file ?? args?.path ?? args?.files?.[0]?.file,
174
+ ...(ranged !== undefined ? { offset: ranged.offset, limit: ranged.limit } : {}),
175
+ ...(windowCount >= 2 ? { windowsCount: windowCount } : {}),
176
+ };
177
+ }
178
+
179
+ interface PreparedTarget {
180
+ /** The path the caller named: the page header is the only chrome, and it names the file with this. */
181
+ file: string;
182
+ mode: RenderMode;
183
+ plan: WindowPlan;
184
+ prepared: PrepareResult;
185
+ /** The page's truncation, or `undefined` when the file rendered no page at all. */
186
+ truncation?: TruncationResult;
187
+ nextOffset?: number;
188
+ snapshotId?: string | undefined;
189
+ /** The merged runs this read widened to, absent for an exact read. */
190
+ widenedBlocks?: WidenedBlock[];
191
+ }
192
+
193
+ /** The `E_UNSUPPORTED_FILE` a non-text file owes, shaped exactly like the single-file read's. */
194
+ function unsupportedFile(target: AdmittedRequest, prepared: PrepareResult): DomainError {
195
+ if (prepared.kind === "directory") {
196
+ return new DomainError("E_UNSUPPORTED_FILE", { path: target.file, kind: "directory" });
197
+ }
198
+ if (prepared.kind === "binary") {
199
+ return new DomainError("E_UNSUPPORTED_FILE", {
200
+ path: target.file,
201
+ kind: "binary",
202
+ ...(prepared.description !== undefined ? { description: prepared.description } : {}),
203
+ });
204
+ }
205
+ return new DomainError("E_UNSUPPORTED_FILE", { path: target.file, kind: "image" });
206
+ }
207
+
208
+ /** A file the read could not prepare at all (missing, unreadable, over budget, not text). */
209
+ interface FailedTarget {
210
+ file: string;
211
+ /** The refusal text: a `DomainError`'s own message, or `file: message` for a foreign throw. */
212
+ message: string;
213
+ }
214
+
215
+ /** One entry of the result, in request order: a rendered file, or the failure that replaced it. */
216
+ type FileResult = PreparedTarget | FailedTarget;
217
+
218
+ function isPrepared(result: FileResult): result is PreparedTarget {
219
+ return "prepared" in result;
220
+ }
221
+
222
+ /** An image block the builtin read owns: this repo routes it, never decodes it. */
223
+ interface DelegatedImageBlock {
224
+ type: "image";
225
+ data: string;
226
+ mimeType: string;
227
+ }
228
+
229
+ /** One entry of the call's `content` array: rendered text, or a routed image block. */
230
+ type RoutedContent = { type: "text"; text: string } | DelegatedImageBlock;
231
+
232
+ /** One target's outcome in request order: a rendered file (or its refusal), or routed image blocks. */
233
+ type OrderedOutcome =
234
+ | { kind: "file"; entry: FileResult }
235
+ | { kind: "image"; blocks: RoutedContent[] };
236
+ /**
237
+ * WHY: the page header names the range this read actually rendered (#149, read-chrome): the shown
238
+ * WHY: range, the file's total, and — when the byte budget cut the page — where it stopped. A page
239
+ * WHY: covering the whole file states the count alone, because a range would only restate it.
240
+ */
241
+ function fileHeader(entry: PreparedTarget): string {
242
+ const shown = entry.prepared.shownRange;
243
+ return formatPageHeader({
244
+ file: entry.file,
245
+ totalLines: entry.prepared.lineTotals.visible,
246
+ ...(shown !== undefined ? { shownRange: shown } : {}),
247
+ ...(entry.mode === "verbatim" ? { verbatim: true } : {}),
248
+ ...(entry.truncation?.truncatedBy === "bytes" && shown !== undefined
249
+ ? { stoppedAtBytes: entry.truncation.maxBytes }
250
+ : {}),
251
+ });
252
+ }
253
+
254
+ /** One prepared file's section: its header, widening notes, inline warnings, then its rows. */
255
+ function preparedSection(entry: PreparedTarget): string {
256
+ const header = fileHeader(entry);
257
+ // WHY: notes are content, not chrome: they tell the model which structural blocks the rows are.
258
+ // WHY: No expansion means no note and no byte change.
259
+ const notes = expansionNotes(entry.widenedBlocks ?? [], entry.prepared.shownRange);
260
+ const lead = [header, ...notes].join("\n");
261
+ if (entry.plan.omittedAll) return `${lead}\n${entry.plan.warnings.join("\n")}`;
262
+ if (entry.plan.warnings.length === 0) return `${lead}\n${entry.prepared.preview}`;
263
+ return `${lead}\n${entry.plan.warnings.join("\n")}\n\n${entry.prepared.preview}`;
264
+ }
265
+
266
+ /** Whether an original requested window sits inside a folded span. */
267
+ function windowInside(outer: OffsetWindow, inner: OffsetWindow): boolean {
268
+ return (
269
+ inner.offset >= outer.offset && inner.offset + inner.limit - 1 <= outer.offset + outer.limit - 1
270
+ );
271
+ }
272
+
273
+ /** One audit record for an applied or refused expansion attempt. */
274
+ function auditEntryFor(
275
+ decision: ExpansionDecision,
276
+ group: FoldedWindow,
277
+ file: string,
278
+ mode: RenderMode,
279
+ ): ReadExpansionLogEntry {
280
+ const base = { file, mode, requested: group.requested, outcome: decision.outcome } as const;
281
+ if (decision.outcome === "applied") {
282
+ const { window, record } = decision.expansion;
283
+ return {
284
+ ...base,
285
+ served: { startLine: window.offset, endLine: window.offset + window.limit - 1 },
286
+ label: `${record.kind} ${record.name}`,
287
+ kind: record.kind,
288
+ name: record.name,
289
+ source: record.source,
290
+ ...(record.clamped === true ? { clamped: true } : {}),
291
+ ...(record.source === "md" ? { section: record.span } : {}),
292
+ };
293
+ }
294
+ return {
295
+ ...base,
296
+ reason: decision.reason,
297
+ ...(decision.span === undefined ? {} : { section: decision.span }),
298
+ ...(decision.kind === undefined ? {} : { kind: decision.kind }),
299
+ ...(decision.name === undefined ? {} : { name: decision.name }),
300
+ ...(decision.source === undefined ? {} : { source: decision.source }),
301
+ ...(decision.language === undefined ? {} : { language: decision.language }),
302
+ ...(decision.blockSize === undefined ? {} : { blockSize: decision.blockSize }),
303
+ ...(decision.kind === undefined || decision.name === undefined
304
+ ? {}
305
+ : { label: `${decision.kind} ${decision.name}` }),
306
+ };
307
+ }
308
+
309
+ /** One file's section: its rows when it prepared, its refusal when it did not. */
310
+ function sectionText(entry: FileResult): string {
311
+ return isPrepared(entry) ? preparedSection(entry) : entry.message;
312
+ }
313
+
314
+ /**
315
+ * The call's `content` array when an image is present: consecutive text sections stay joined as
316
+ * one text block, and the builtin's own note-plus-image blocks land in request order between them.
317
+ */
318
+ function routedContent(ordered: OrderedOutcome[]): RoutedContent[] {
319
+ const blocks: RoutedContent[] = [];
320
+ let pending: string[] = [];
321
+ const flush = (): void => {
322
+ if (pending.length > 0) {
323
+ blocks.push({ type: "text", text: pending.join("\n\n") });
324
+ pending = [];
325
+ }
326
+ };
327
+ for (const outcome of ordered) {
328
+ if (outcome.kind === "image") {
329
+ flush();
330
+ blocks.push(...outcome.blocks);
331
+ continue;
332
+ }
333
+ pending.push(sectionText(outcome.entry));
334
+ }
335
+ flush();
336
+ return blocks;
337
+ }
338
+ /** One file's rows for the served-state channels: nothing when the file rendered no page. */
339
+ function servedRowsOf(entry: PreparedTarget): ServedRow[] {
340
+ return entry.plan.omittedAll ? [] : entry.prepared.served;
341
+ }
342
+
343
+ /**
344
+ * The whole file's anchors for a content-hash materialization (ADR-0043).
345
+ *
346
+ * A stat-adopted page resolved only the anchors it served, so an entry that must fall back to content
347
+ * hashing re-derives the rest through the usual cache path. That is the rare race — its snapshot lost
348
+ * to a concurrent vacuum or re-stamp between the lookup and the adoption — never the common read.
349
+ */
350
+ async function fullHashesFor(
351
+ entry: PreparedTarget,
352
+ store: HashStore | undefined,
353
+ ): Promise<string[]> {
354
+ const prepared = entry.prepared;
355
+ if (prepared.fileHashes.length === prepared.lineTotals.split) return prepared.fileHashes;
356
+ const hashStore = store ?? (await loadHashStore());
357
+ // WHY: not persisted here — the caller's single batch transaction is the only writer allowed to
358
+ // WHY: materialize this read (spec §3.1.2); a pre-write would split the snapshot from its leases.
359
+ return lineHashes(
360
+ prepared.normalized,
361
+ prepared.absolutePath,
362
+ undefined,
363
+ snapshotIOFor(hashStore),
364
+ false,
365
+ );
366
+ }
367
+
368
+ // WHY: the provider is optional so the shipped entry point (`regRead(pi)`) performs no expansion
369
+ // WHY: until a parser ticket injects one; a null answer is likewise today's exact read.
370
+ export function regRead(pi: ExtensionAPI, options?: { spanProvider?: SpanProvider }): void {
371
+ const spanProvider = options?.spanProvider;
372
+ // WHY: session_start warms the grammars off the read path; a read still waits only its budget.
373
+ pi.on?.("session_start", (_event, ctx) => {
374
+ if (ctx.cwd === undefined || resolveReadExpansionMode(ctx.cwd) === "off") return;
375
+ void spanProvider?.prefetch?.(ctx.cwd);
376
+ });
377
+ // WHY: pi falls back to the builtin `read` renderer by tool name, but that renderer keys on
378
+ // WHY: `file_path ?? path` plus `offset`/`limit`. Our payload field is `file` (ADR-0041 D1), so
379
+ // WHY: project it for rendering only — the call line shows the filename, and `args` is never
380
+ // WHY: mutated. The `file ?? path ?? files?.[0]?.file` fallback is permanent tolerance for
381
+ // WHY: recorded transcripts (ADR-0040 D3/D5), not dead code.
382
+ // WHY: Pi reuses lastComponent; its builtin needs the Text behind our wrapper.
383
+ const readCallBases = new WeakMap<Component, Component>();
384
+ const builtinReadDef = createReadToolDefinition("");
385
+ const builtinRenderCall = builtinReadDef.renderCall as any;
386
+ const builtinRenderResult = builtinReadDef.renderResult as any;
387
+ pi.registerTool({
388
+ name: "read",
389
+ label: "Read",
390
+ description: R_DESC,
391
+ promptSnippet: R_SNIPPET,
392
+ promptGuidelines: readGuide(),
393
+ // SAFETY: the builtin renderers are keyed on `file_path ?? path` plus `offset`/`limit`;
394
+ // SAFETY: projectReadRenderArgs derives the path from the flat `file` (D4) and the range
395
+ // SAFETY: from a single simple window, without mutating the caller's `args`. Multi-window reads
396
+ // SAFETY: get their count appended below — the builtin has no chrome for them.
397
+ renderCall: (args: ReadRenderArgs, theme, context) => {
398
+ const projected = projectReadRenderArgs(args);
399
+ const lastComponent = context.lastComponent;
400
+ const call: Component = builtinRenderCall({ ...args, ...projected }, theme, {
401
+ ...context,
402
+ lastComponent: lastComponent
403
+ ? (readCallBases.get(lastComponent) ?? lastComponent)
404
+ : lastComponent,
405
+ });
406
+ // WHY: 1:1 honesty (ADR-0041 D4) — the builtin renders `read <path>[:start-end]` but has no
407
+ // WHY: multi-window chrome, so the call line carries the count itself. Single anchor-window and
408
+ // WHY: whole-file reads render the bare path (no range is ever fabricated).
409
+ if (projected.windowsCount === undefined) return call;
410
+ const suffix = ` (${projected.windowsCount} windows)`;
411
+ const suffixWidth = visibleWidth(suffix);
412
+ const wrapped: Component = {
413
+ invalidate: () => call.invalidate(),
414
+ render: (width: number): string[] => {
415
+ if (width <= 0) return [];
416
+ if (width <= suffixWidth) return [truncateToWidth(suffix.trimStart(), width)];
417
+ const budget = width - suffixWidth;
418
+ return call
419
+ .render(budget)
420
+ .map((line, index) => truncateToWidth(line, budget) + (index === 0 ? suffix : ""));
421
+ },
422
+ };
423
+ readCallBases.set(wrapped, call);
424
+ return wrapped;
425
+ },
426
+ renderResult: (result, options, theme, context) => {
427
+ // SAFETY: renderers receive raw call args, not the validated payload, so `context.args` may
428
+ // SAFETY: carry a retired shape that `Static<TParams>` does not model; the projection keys on
429
+ // SAFETY: `file`/`path`/`windows`/`files`, so history still renders (ADR-0041 D5).
430
+ const args = context.args as ReadRenderArgs;
431
+ return builtinRenderResult(result, options, theme, {
432
+ ...context,
433
+ args: { ...args, ...projectReadRenderArgs(args) },
434
+ });
435
+ },
436
+ parameters: readToolSchema,
437
+ // WHY: recorded flat payloads (`path`/`offset`/`limit`) reach execute through replay, and the
438
+ // WHY: model-facing schema deliberately stays the canonical `{ file, windows, mode }` shape.
439
+ // SAFETY: the normalizer returns the canonical shape when it can; an invalid legacy payload
440
+ // SAFETY: falls through to schema validation, which is exactly where it must be refused.
441
+ prepareArguments: (args: unknown) =>
442
+ normalizeReadPayload(args as RawReadParams) as unknown as Static<typeof readToolSchema>,
443
+
444
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
445
+ try {
446
+ const request = admitReadRequest(params as RawReadParams);
447
+ const cwd = ctx.cwd as string;
448
+ const sessionCtx = ctx as { sessionManager?: { getSessionId(): string } };
449
+ abortIf(signal);
450
+ // WHY: spec §4.3 resolves `around_anchor` against the target file's active lineage for the
451
+ // WHY: session and carries NO mode qualifier, so the store opens for an anchor window whether or
452
+ // WHY: not the target serves rows. A call naming neither a served target nor an anchor window
453
+ // WHY: still opens nothing, so the read that serves nothing pays nothing.
454
+ const needsStore =
455
+ request.mode === "served" ||
456
+ request.windows?.some((window) => window.kind === "anchor") === true;
457
+ const store = needsStore ? await loadHashStore() : undefined;
458
+ const ordered: OrderedOutcome[] = [];
459
+ // WHY: delegation, not reimplementation — the builtin owns image bytes, so this repo only
460
+ // WHY: routes `prepared.kind === "image"` to it. Built lazily so a text-only call pays nothing
461
+ // WHY: and its path stays untouched.
462
+ let builtinImageRead: ReturnType<typeof createReadTool> | undefined;
463
+ const readImageViaBuiltin = async (absolutePath: string) => {
464
+ builtinImageRead ??= createReadTool(cwd);
465
+ return builtinImageRead.execute(_toolCallId, { path: absolutePath }, signal);
466
+ };
467
+ // WHY: spec §4.2 — the read draws on the call's 200,000-line served budget, so a file that
468
+ // WHY: cannot afford what is left is refused inline instead of every prepared anchor,
469
+ // WHY: normalized text and hash staying alive until the return.
470
+ let remainingServedLines = SERVED_MAX_LINES;
471
+
472
+ // WHY: one file per call (ADR-0041 D1) — no fan-out loop, no sibling fallbacks: the single
473
+ // WHY: target below is the call, so its outcome is the call's outcome.
474
+ {
475
+ const target = request;
476
+ abortIf(signal);
477
+ const mode = request.mode;
478
+ // WHY: the served cap bounds the anchor space a read materializes; a verbatim file serves no
479
+ // WHY: anchors, so it carries no cap here (the decode gate is the served path's).
480
+ const cap = mode === "served" ? { maxLines: remainingServedLines } : {};
481
+ // WHY: an anchor window must resolve BEFORE the file is prepared (its offset is not known
482
+ // WHY: yet), so the file is read once here and handed to the seam below — never twice.
483
+ const wantsAnchor = target.windows?.some((window) => window.kind === "anchor") === true;
484
+ let preloaded: LFile | undefined = wantsAnchor
485
+ ? await loadFileKindAndText(toCwd(target.file, cwd), {
486
+ ...cap,
487
+ displayPath: target.file,
488
+ })
489
+ : undefined;
490
+ // WHY: decode once and share it — an anchor window resolves against these bytes and an
491
+ // WHY: expansion measures its span against the same visible line count.
492
+ let resolvedText: NormText | undefined;
493
+ if (preloaded !== undefined && preloaded.kind === "text") {
494
+ resolvedText = await decodeNormText(target.file, cwd, {
495
+ ...(signal !== undefined ? { signal } : {}),
496
+ ...cap,
497
+ preloadedFile: preloaded,
498
+ });
499
+ }
500
+ let resolver: AnchorResolver | undefined;
501
+ // WHY: resolution is lineage-scoped, not mode-scoped (spec §4.3): the store now opens for an
502
+ // WHY: anchor window of a verbatim target too, so this guard is the safety net for a window
503
+ // WHY: whose file could not be loaded as text — that window falls through to the same §4.3
504
+ // WHY: "not found" warning instead of dereferencing a store that is not there.
505
+ if (store !== undefined && resolvedText !== undefined) {
506
+ resolver = {
507
+ sessionKey: sessionFromContext(sessionCtx, resolvedText.absolutePath).sessionKey,
508
+ absolutePath: resolvedText.absolutePath,
509
+ normalized: resolvedText.normalized,
510
+ store,
511
+ };
512
+ }
513
+ const plan = planWindows(target, resolver);
514
+ // WHY: expansion is mode-independent (D11/D19): served adds leases, verbatim adds none;
515
+ // WHY: both serve the same widened range and note. Fold first, then trigger (D12), then
516
+ // WHY: expand each folded span and union the answers so one row is never served twice.
517
+ // WHY: D23 placement — this runs after the content load and before `prepareFile` hashes
518
+ // WHY: or dispatches on render mode. The 80/80/40 caps bound expansion; SERVED_MAX_LINES
519
+ // WHY: sizes the served admission, never the widening decision.
520
+ let expandedRead: ExpandedRead | undefined;
521
+ const readExpansion =
522
+ spanProvider !== undefined && plan.windows !== undefined && plan.windows.length > 0
523
+ ? resolveReadExpansionModeWithSource(cwd, (reason) =>
524
+ reportWarning("read-expansion", reason),
525
+ )
526
+ : undefined;
527
+ if (
528
+ spanProvider !== undefined &&
529
+ readExpansion?.value === "on" &&
530
+ plan.windows !== undefined &&
531
+ plan.windows.length > 0
532
+ ) {
533
+ const folded = foldWindows(plan.windows);
534
+ const needsText = folded.some((group) => group.window.limit <= TRIGGER_MAX);
535
+ let totalLines = 0;
536
+ let textReady = true;
537
+ if (needsText) {
538
+ preloaded ??= await loadFileKindAndText(toCwd(target.file, cwd), {
539
+ ...cap,
540
+ displayPath: target.file,
541
+ });
542
+ textReady = preloaded.kind === "text";
543
+ if (textReady) {
544
+ resolvedText ??= await decodeNormText(target.file, cwd, {
545
+ ...(signal !== undefined ? { signal } : {}),
546
+ ...cap,
547
+ preloadedFile: preloaded,
548
+ });
549
+ totalLines = visibleLineTotal(
550
+ resolvedText.normalized,
551
+ walkLines(resolvedText.normalized).total,
552
+ );
553
+ }
554
+ }
555
+ const auditSessionKey = sessionKeyFor(sessionCtx);
556
+ const decisions: ExpansionDecision[] = [];
557
+ for (const group of folded) {
558
+ const decision: ExpansionDecision =
559
+ group.window.limit > TRIGGER_MAX
560
+ ? { outcome: "refused", reason: "above_trigger" }
561
+ : !textReady
562
+ ? { outcome: "refused", reason: "not_text" }
563
+ : await widenWindow({
564
+ file: toCwd(target.file, cwd),
565
+ totalLines,
566
+ window: group.window,
567
+ requested: group.requested,
568
+ provider: spanProvider,
569
+ });
570
+ decisions.push(decision);
571
+ const runtimeDeclined =
572
+ decision.outcome === "refused" && decision.reason === "runtime_absent";
573
+ if (runtimeDeclined) {
574
+ recordReadExpansionRuntimeAbsent(
575
+ auditEntryFor(decision, group, target.file, mode),
576
+ auditSessionKey,
577
+ readExpansion.source,
578
+ );
579
+ } else {
580
+ recordReadExpansion(
581
+ auditEntryFor(decision, group, target.file, mode),
582
+ auditSessionKey,
583
+ readExpansion.source,
584
+ );
585
+ }
586
+ }
587
+ const servedWindows = plan.windows.map((window) => {
588
+ const index = folded.findIndex((group) => windowInside(group.window, window));
589
+ const decision = index < 0 ? undefined : decisions[index];
590
+ return decision?.outcome === "applied" ? decision.expansion.window : window;
591
+ });
592
+ const applied = decisions.flatMap((decision) =>
593
+ decision.outcome === "applied" ? [decision.expansion] : [],
594
+ );
595
+ expandedRead = applyExpansions(servedWindows, applied);
596
+ } else if (
597
+ spanProvider !== undefined &&
598
+ readExpansion?.value === "off" &&
599
+ plan.windows !== undefined &&
600
+ plan.windows.length > 0
601
+ ) {
602
+ // WHY: off still answers "why did this read not widen?" without touching provider work.
603
+ const auditSessionKey = sessionKeyFor(sessionCtx);
604
+ for (const group of foldWindows(plan.windows)) {
605
+ recordReadExpansionToggleOff(
606
+ auditEntryFor(
607
+ { outcome: "refused", reason: "toggle_off" },
608
+ group,
609
+ target.file,
610
+ mode,
611
+ ),
612
+ auditSessionKey,
613
+ readExpansion.source,
614
+ );
615
+ }
616
+ }
617
+ const effectivePlan =
618
+ expandedRead === undefined ? plan : { ...plan, windows: expandedRead.windows };
619
+ // WHY: single-window reads render as one §4.4 page (a header, no sub-banner, no
620
+ // WHY: continuation token): the flattening unified the old page chrome onto the header-only
621
+ // WHY: shape, a visible change for `files`-shaped singles that rendered the bounded-ask
622
+ // WHY: sub-banner at base. Multi-window reads keep their per-section headers.
623
+ const singlePage =
624
+ expandedRead !== undefined
625
+ ? expandedRead.windows.length === 1
626
+ ? expandedRead.windows[0]
627
+ : undefined
628
+ : target.windows?.length === 1 && target.windows[0]?.kind === "offset"
629
+ ? { offset: target.windows[0].offset, limit: target.windows[0].limit }
630
+ : undefined;
631
+ // WHY: Deep seam: one call handles kind detection, decode, normalize, hash, preview.
632
+ // WHY: `noPersist` defers the authoritative materialization until the served windows are
633
+ // WHY: known, so the snapshots + lineage + retirement + lease grants below commit as the ONE
634
+ // WHY: transaction spec §3.1.2 mandates instead of materializing first and leasing later.
635
+ const prepared = await prepareFile(target.file, cwd, {
636
+ ...(signal !== undefined ? { signal } : {}),
637
+ ...(singlePage !== undefined
638
+ ? { offset: singlePage.offset, limit: singlePage.limit }
639
+ : effectivePlan.windows !== undefined
640
+ ? { windows: effectivePlan.windows }
641
+ : {}),
642
+ // WHY: the served cap stays the served budget (independent of the anchor space): the paged
643
+ // WHY: walk still retains one anchor per hashed line (see `src/constants.ts`), so the budget
644
+ // WHY: survives paging — while verbatim skips the cap and the store (no anchors to bound).
645
+ maxLines: remainingServedLines,
646
+ ...(mode === "served" && store !== undefined ? { store } : {}),
647
+ noPersist: true,
648
+ render: mode,
649
+ ...(preloaded === undefined ? {} : { preloadedFile: preloaded }),
650
+ });
651
+ if (prepared.kind === "image") {
652
+ // WHY: the interception point — the image never becomes a `results` entry, so no image
653
+ // WHY: content enters the served / hash / snapshot / lease paths below. The lone image
654
+ // WHY: delegates directly to the builtin and returns its image block.
655
+ const imageResult = await readImageViaBuiltin(prepared.absolutePath);
656
+ return imageResult;
657
+ }
658
+ if (prepared.kind !== "text") throw unsupportedFile(target, prepared);
659
+ ordered.push({
660
+ kind: "file",
661
+ entry: {
662
+ file: target.file,
663
+ mode,
664
+ plan: effectivePlan,
665
+ ...(expandedRead === undefined
666
+ ? {}
667
+ : {
668
+ widenedBlocks: expandedRead.blocks,
669
+ }),
670
+ prepared,
671
+ ...(effectivePlan.omittedAll
672
+ ? {}
673
+ : {
674
+ ...(prepared.truncation !== undefined
675
+ ? { truncation: prepared.truncation }
676
+ : {}),
677
+ ...(prepared.nextOffset !== undefined
678
+ ? { nextOffset: prepared.nextOffset }
679
+ : {}),
680
+ }),
681
+ },
682
+ });
683
+ // WHY: a served file draws the WHOLE file's line count off the call's budget, not its page —
684
+ // WHY: the paged walk retained one anchor per hashed line, which is what the budget bounds.
685
+ // WHY: A verbatim file takes no cap and so draws nothing.
686
+ if (mode === "served") remainingServedLines -= prepared.lineTotals.split;
687
+ }
688
+ const results: FileResult[] = ordered.flatMap((outcome) =>
689
+ outcome.kind === "file" ? [outcome.entry] : [],
690
+ );
691
+ const preparedFiles = results.filter(isPrepared);
692
+ for (const entry of preparedFiles) {
693
+ if (entry.mode !== "served" || entry.plan.omittedAll) continue;
694
+ const adopted = entry.prepared.statAdoption;
695
+ if (adopted !== undefined) {
696
+ // WHY: (ADR-0043) the epoch names the same bytes `fileSnap` would describe — the checksum
697
+ // WHY: comes from the stat-matched snapshot, so adoption does not re-hash the content just
698
+ // WHY: to state which version it read.
699
+ entry.snapshotId = adopted.snapshotId;
700
+ continue;
701
+ }
702
+ try {
703
+ entry.snapshotId = (
704
+ await fileSnap(
705
+ entry.prepared.absolutePath,
706
+ contentChecksum(entry.prepared.normalized),
707
+ entry.prepared.stats,
708
+ )
709
+ ).snapshotId;
710
+ } catch {
711
+ entry.snapshotId = undefined;
712
+ }
713
+ }
714
+
715
+ const single = results.length === 1 ? preparedFiles[0] : undefined;
716
+ const firstTruncation = preparedFiles.find(
717
+ (entry) => entry.truncation !== undefined,
718
+ )?.truncation;
719
+ const anyTruncated = firstTruncation !== undefined;
720
+ // WHY: a text-only call keeps the one joined text section byte-identical; with an image
721
+ // WHY: present the blocks aggregate in request order around the builtin's own blocks.
722
+ const content: RoutedContent[] = ordered.every((outcome) => outcome.kind === "file")
723
+ ? [{ type: "text", text: results.map(sectionText).join("\n\n") }]
724
+ : routedContent(ordered);
725
+ const metrics = {
726
+ truncated: anyTruncated,
727
+ ...(single?.nextOffset !== undefined ? { next_offset: single.nextOffset } : {}),
728
+ };
729
+
730
+ const servedEntries = preparedFiles.filter(
731
+ (entry) => entry.mode === "served" && !entry.plan.omittedAll,
732
+ );
733
+ // WHY: a verbatim-only call shares admission/normalization but must not touch served state —
734
+ // WHY: no lease, snapshot, epoch, drift clear, or span notification. Return before any of it.
735
+ if (servedEntries.length === 0) {
736
+ return {
737
+ content,
738
+ details:
739
+ single?.mode === "verbatim"
740
+ ? {
741
+ ...(single.truncation !== undefined ? { truncation: single.truncation } : {}),
742
+ ...(single.nextOffset !== undefined ? { nextOffset: single.nextOffset } : {}),
743
+ metrics,
744
+ }
745
+ : {
746
+ ...(firstTruncation !== undefined ? { truncation: firstTruncation } : {}),
747
+ metrics,
748
+ },
749
+ };
750
+ }
751
+
752
+ const sessionKey = sessionFromContext(
753
+ sessionCtx,
754
+ servedEntries[0]!.prepared.absolutePath,
755
+ ).sessionKey;
756
+ // WHY: the materialization (spec §3.1.2 steps 4-6): the file's snapshot + lineage +
757
+ // WHY: retirement + served leases commit in ONE `BEGIN IMMEDIATE`, so a read can never lease
758
+ // WHY: anchors without its snapshot.
759
+ // WHY: Best-effort — a store failure never fails the read; the next call re-materializes.
760
+ try {
761
+ // WHY: (ADR-0043) a stat-matched page already names a committed snapshot: adopting it on the
762
+ // WHY: same identity commits the leases and mirror a materialization owns without hashing the
763
+ // WHY: content or splitting its lines again. A `false` (the row lost a race with a vacuum or a
764
+ // WHY: re-stamp) folds the entry back into the content-hash batch below, which is the only
765
+ // WHY: writer allowed to decide what this content's identity is.
766
+ const unmatched: PreparedTarget[] = [];
767
+ for (const entry of servedEntries) {
768
+ const adopted = entry.prepared.statAdoption;
769
+ const statKey = entry.prepared.statKey;
770
+ if (adopted === undefined || statKey === undefined) {
771
+ unmatched.push(entry);
772
+ continue;
773
+ }
774
+ const committed = await adoptStatSnapshotFor(
775
+ {
776
+ path: entry.prepared.absolutePath,
777
+ statKey,
778
+ snapshotHash: adopted.snapshotHash,
779
+ },
780
+ { retireLeases: true, leases: { sessionKey, rows: servedRowsOf(entry) } },
781
+ );
782
+ if (!committed) unmatched.push(entry);
783
+ }
784
+ if (unmatched.length > 0) {
785
+ const enrollments = await Promise.all(
786
+ unmatched.map(async (entry) => ({
787
+ descriptor: {
788
+ path: entry.prepared.absolutePath,
789
+ snapshotHash: snapshotHashFor(entry.prepared.normalized),
790
+ lineCount: entry.prepared.lineTotals.split,
791
+ hashes: await fullHashesFor(entry, store),
792
+ content: entry.prepared.normalized,
793
+ },
794
+ options: {
795
+ retireLeases: true,
796
+ leases: { sessionKey, rows: servedRowsOf(entry) },
797
+ // WHY: stamp the version this read actually observed, so the next read of an
798
+ // WHY: unchanged file adopts on stat identity instead of content hashing again.
799
+ ...(entry.prepared.statKey !== undefined
800
+ ? { statKey: entry.prepared.statKey }
801
+ : {}),
802
+ },
803
+ })),
804
+ );
805
+ await upsertSnapshotsFor(enrollments);
806
+ }
807
+ } catch (error) {
808
+ // SAFETY: best-effort post-read materialization — the preview rows are already computed
809
+ // SAFETY: and the served mirror below still records them; a missed snapshot/lease degrades
810
+ // SAFETY: to the fail-closed path the next edit would take anyway.
811
+ reportDiagnostic("read", "Failed to commit read-path snapshot materialization:", error);
812
+ }
813
+ for (const entry of servedEntries) {
814
+ const session = sessionFromContext(sessionCtx, entry.prepared.absolutePath);
815
+ const isFullRead = entry.plan.windows === undefined && entry.truncation === undefined;
816
+ // WHY: one file per call, so a session-write failure for the lone file stays the call's
817
+ // WHY: failure — the single-file contract pins it, exactly as the base multi-file code did
818
+ // WHY: for lone reads.
819
+ // WHY: mirror-only — the leases already committed in the transaction above, so no
820
+ // WHY: `contentHash` is passed and no third transaction remains on the read path. Canon
821
+ // WHY: evidence needs no write at all: it is derived from those leases (#151).
822
+ await session.recordEpoch({
823
+ rows: servedRowsOf(entry),
824
+ lineCount: entry.prepared.lineTotals.visible,
825
+ // WHY: (ADR-0043) a stat-adopted page holds only the anchors it served. A full read
826
+ // WHY: resolved every line (its one range is the whole file), so the array is whole exactly
827
+ // WHY: then; a partial read never consumes these hashes, so it passes none.
828
+ ...(isFullRead ? { fullReadHashes: entry.prepared.fileHashes } : {}),
829
+ ...(isFullRead && entry.snapshotId !== undefined
830
+ ? { snapshotId: entry.snapshotId }
831
+ : {}),
832
+ isFullRead,
833
+ });
834
+ if (isFullRead) await session.clearDrift();
835
+ // WHY: fire-and-forget by design — the seam snapshots its observers and isolates each one, so
836
+ // WHY: this read's return value and timing are unchanged whether or not an observer is attached.
837
+ notifyServedSpans({
838
+ filePath: entry.prepared.absolutePath,
839
+ spans: servedRowsToSpans(entry.prepared.served),
840
+ source: "read",
841
+ // WHY: the verbatim normalized bytes let the mirror hash caller evidence in memory instead
842
+ // WHY: of re-reading the file; the anchored preview is never the source of a line hash.
843
+ content: entry.prepared.normalized,
844
+ });
845
+ }
846
+ return {
847
+ content,
848
+ details: single
849
+ ? {
850
+ ...(single.truncation !== undefined ? { truncation: single.truncation } : {}),
851
+ ...(single.snapshotId !== undefined ? { snapshotId: single.snapshotId } : {}),
852
+ ...(single.nextOffset !== undefined ? { nextOffset: single.nextOffset } : {}),
853
+ metrics,
854
+ }
855
+ : {
856
+ ...(firstTruncation !== undefined ? { truncation: firstTruncation } : {}),
857
+ metrics,
858
+ },
859
+ };
860
+ } catch (error) {
861
+ throw withPayloadSubject(error, "read");
862
+ }
863
+ },
864
+ });
865
+ }