dsh-hashline-edit 0.1.0 → 0.2.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.
- package/README.md +72 -70
- package/cordis.patch.yml +1 -1
- package/lib/index.js +4494 -4813
- package/lib/types/dsh/contracts.d.ts +69 -6
- package/lib/types/dsh/fs.d.ts +38 -3
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/hide.d.ts +78 -0
- package/lib/types/dsh/host-compat.d.ts +7 -78
- package/lib/types/dsh/index.d.ts +17 -7
- package/lib/types/dsh/metrics/events.d.ts +8 -1
- package/lib/types/dsh/metrics/instrument.d.ts +20 -14
- package/lib/types/dsh/metrics/observer.d.ts +28 -0
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +17 -8
- package/lib/types/dsh/shell-view.d.ts +65 -3
- package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
- package/lib/types/dsh/tools/context.d.ts +19 -1
- package/lib/types/dsh/tools/output.d.ts +32 -4
- package/lib/types/dsh/tools/read.d.ts +12 -15
- package/lib/types/dsh/tools/undo.d.ts +5 -0
- package/lib/types/dsh/write-hook.d.ts +8 -22
- package/lib/types/src/apply-patch/commit.d.ts +49 -0
- package/lib/types/src/apply-patch/grammar.d.ts +29 -0
- package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
- package/lib/types/src/apply-patch/plan.d.ts +50 -0
- package/lib/types/src/apply-patch/render.d.ts +51 -0
- package/lib/types/src/apply-patch/resolve.d.ts +129 -0
- package/lib/types/src/apply-patch/tool.d.ts +54 -0
- package/lib/types/src/apply-patch/types.d.ts +362 -0
- package/lib/types/src/bash-view-banner.d.ts +20 -0
- package/lib/types/src/domain-errors.d.ts +16 -2
- package/lib/types/src/edit-undo.d.ts +11 -2
- package/lib/types/src/file-content/detection.d.ts +17 -0
- package/lib/types/src/file-content/index.d.ts +2 -2
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/fs-lifecycle.d.ts +44 -0
- package/lib/types/src/hashline/resolve.d.ts +6 -0
- package/lib/types/src/host-utils.d.ts +63 -0
- package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
- package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
- package/lib/types/src/mutation-engine/types.d.ts +50 -58
- package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
- package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
- package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
- package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
- package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
- package/lib/types/src/served-session/serve-render.d.ts +9 -3
- package/lib/types/src/undo-store.d.ts +17 -1
- package/package.json +10 -22
- package/prompts/apply-patch-guidelines.md +10 -0
- package/prompts/apply-patch.md +1 -0
- package/prompts/read-guidelines.md +5 -5
- package/prompts/read.md +1 -1
- package/prompts/undo-last-edit-guidelines.md +2 -2
- package/prompts/undo-last-edit-snippet.md +1 -1
- package/prompts/undo-last-edit.md +1 -1
- package/src/apply-patch/commit.ts +190 -0
- package/src/apply-patch/grammar.ts +620 -0
- package/src/apply-patch/index.ts +17 -0
- package/src/apply-patch/lifecycle.ts +195 -0
- package/src/apply-patch/plan.ts +800 -0
- package/src/apply-patch/render.ts +137 -0
- package/src/apply-patch/resolve.ts +470 -0
- package/src/apply-patch/tool.ts +257 -0
- package/src/apply-patch/types.ts +387 -0
- package/src/cut-repair.ts +77 -11
- package/src/domain-errors.ts +58 -10
- package/src/edit-undo.ts +379 -25
- package/src/file-content/detection.ts +34 -0
- package/src/file-content/index.ts +2 -2
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/fs-lifecycle.ts +201 -0
- package/src/hash-store.ts +18 -1
- package/src/hashline/lease-resolve.ts +14 -8
- package/src/hashline/resolve.ts +30 -27
- package/src/host-utils.ts +224 -0
- package/src/mutation-engine/batch-span-gate.ts +17 -4
- package/src/mutation-engine/edit-request.ts +39 -0
- package/src/mutation-engine/pipeline.ts +535 -63
- package/src/mutation-engine/types.ts +49 -65
- package/src/mutation-queue.ts +1 -1
- package/src/prompts.ts +0 -28
- package/src/read-expansion/ast-provider.ts +4 -0
- package/src/read-expansion/grammar-manifest.ts +21 -2
- package/src/read-expansion/java-decoration.ts +34 -0
- package/src/read-expansion/span-expansion.ts +6 -2
- package/src/read-expansion/span-kind.ts +3 -1
- package/src/read-expansion/vue-decoration.ts +43 -0
- package/src/served-session/serve-render.ts +5 -5
- package/src/undo-store.ts +74 -5
- package/lib/types/dsh/encoding-bridge.d.ts +0 -25
- package/lib/types/dsh/encoding.d.ts +0 -144
- package/lib/types/dsh/tools/edit.d.ts +0 -10
- package/lib/types/src/edit-tool.d.ts +0 -41
- package/lib/types/src/mutation-engine/engine.d.ts +0 -31
- package/lib/types/src/payload-contract.d.ts +0 -124
- package/prompts/edit-guidelines.md +0 -10
- package/prompts/edit-snippet.md +0 -1
- package/prompts/edit.md +0 -1
- package/src/bash-classifier.ts +0 -1626
- package/src/bash-conversion/decide.ts +0 -812
- package/src/bash-display-transform.ts +0 -50
- package/src/bash-multifile-config.ts +0 -239
- package/src/bash-view-config.ts +0 -158
- package/src/edit-normalize.ts +0 -8
- package/src/edit-render.ts +0 -161
- package/src/edit-tool.ts +0 -116
- package/src/edit.ts +0 -136
- package/src/integrations/pi-lens/command.ts +0 -712
- package/src/integrations/pi-lens/config.ts +0 -148
- package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
- package/src/integrations/pi-lens/io-bridge.ts +0 -112
- package/src/integrations/pi-lens/settings-overlay.ts +0 -272
- package/src/lifecycle-hooks/index.ts +0 -1992
- package/src/lifecycle-hooks/types.ts +0 -64
- package/src/mutated-files.ts +0 -75
- package/src/mutation-engine/engine.ts +0 -173
- package/src/mutation-engine/index.ts +0 -17
- package/src/payload-contract.ts +0 -637
- package/src/preview-controller.ts +0 -84
- package/src/read.ts +0 -865
- package/src/search-hook.ts +0 -41
- package/src/store-config.ts +0 -73
- package/src/store-janitor-warning.ts +0 -169
- package/src/store-janitor.ts +0 -539
- package/src/store-kernel/settings.ts +0 -233
- package/src/store.ts +0 -154
- package/src/tui-presenter.ts +0 -134
- package/src/write-hook.ts +0 -130
package/src/read.ts
DELETED
|
@@ -1,865 +0,0 @@
|
|
|
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
|
-
}
|