@gajae-code/coding-agent 0.11.11 → 0.12.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.
Files changed (182) hide show
  1. package/CHANGELOG.md +49 -3
  2. package/dist/types/cli/read-cli.d.ts +1 -0
  3. package/dist/types/commands/deep-interview.d.ts +9 -0
  4. package/dist/types/commands/harness.d.ts +22 -0
  5. package/dist/types/commands/read.d.ts +6 -0
  6. package/dist/types/config/model-registry.d.ts +4 -0
  7. package/dist/types/config/provider-auth-health.d.ts +14 -0
  8. package/dist/types/config/provider-ranking.d.ts +59 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/config/settings.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +22 -2
  12. package/dist/types/extensibility/shared-events.d.ts +6 -0
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  14. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  15. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  16. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  17. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  18. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  19. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +52 -1
  20. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  21. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  22. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  23. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  24. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +7 -0
  26. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  27. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  28. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  29. package/dist/types/main.d.ts +1 -1
  30. package/dist/types/memories/index.d.ts +2 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +8 -0
  32. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  33. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  34. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  35. package/dist/types/modes/components/session-observer-overlay.d.ts +4 -0
  36. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  37. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  38. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  39. package/dist/types/modes/interactive-mode.d.ts +2 -0
  40. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  41. package/dist/types/modes/types.d.ts +2 -0
  42. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  43. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  44. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  45. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  46. package/dist/types/sdk/bus/index.d.ts +15 -1
  47. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +37 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  49. package/dist/types/sdk/bus/reconciliation-store.d.ts +65 -0
  50. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  51. package/dist/types/sdk/host/host.d.ts +2 -0
  52. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  53. package/dist/types/session/agent-session.d.ts +44 -10
  54. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  55. package/dist/types/session/session-manager.d.ts +25 -13
  56. package/dist/types/session/streaming-output.d.ts +60 -5
  57. package/dist/types/skill-state/active-state.d.ts +0 -4
  58. package/dist/types/skill-state/workflow-hud.d.ts +3 -0
  59. package/dist/types/tools/browser/launch.d.ts +7 -0
  60. package/dist/types/tools/fetch.d.ts +17 -1
  61. package/dist/types/tools/output-meta.d.ts +21 -1
  62. package/dist/types/tools/read-internals.d.ts +12 -0
  63. package/dist/types/tools/read.d.ts +45 -1
  64. package/dist/types/tools/resource-gc.d.ts +4 -0
  65. package/dist/types/tools/tool-result.d.ts +7 -1
  66. package/package.json +9 -13
  67. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  68. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  69. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  71. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  72. package/src/capability/index.ts +8 -0
  73. package/src/cli/read-cli.ts +2 -1
  74. package/src/cli.ts +1 -0
  75. package/src/commands/deep-interview.ts +29 -9
  76. package/src/commands/harness.ts +35 -12
  77. package/src/commands/read.ts +11 -2
  78. package/src/commands/team.ts +1 -1
  79. package/src/config/model-registry.ts +21 -2
  80. package/src/config/provider-auth-health.ts +42 -0
  81. package/src/config/provider-ranking.ts +119 -0
  82. package/src/config/settings-schema.ts +23 -1
  83. package/src/config/settings.ts +246 -108
  84. package/src/defaults/gjc/skills/deep-interview/SKILL.md +229 -82
  85. package/src/defaults/gjc/skills/ralplan/SKILL.md +58 -25
  86. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  87. package/src/defaults/gjc/skills/ultragoal/SKILL.md +25 -15
  88. package/src/eval/py/executor.ts +7 -2
  89. package/src/extensibility/extensions/types.ts +28 -4
  90. package/src/extensibility/shared-events.ts +5 -0
  91. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  92. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  93. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  94. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  95. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  96. package/src/gjc-runtime/gc-render.ts +1 -0
  97. package/src/gjc-runtime/gc-runtime.ts +11 -1
  98. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  99. package/src/gjc-runtime/ralplan-runtime.ts +742 -85
  100. package/src/gjc-runtime/state-runtime.ts +2 -1
  101. package/src/gjc-runtime/state-writer.ts +70 -222
  102. package/src/gjc-runtime/team-launch.ts +34 -5
  103. package/src/gjc-runtime/team-runtime.ts +970 -33
  104. package/src/gjc-runtime/team-store.ts +32 -0
  105. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  106. package/src/gjc-runtime/team-workers.ts +1 -0
  107. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -0
  108. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  109. package/src/gjc-runtime/ultragoal-runtime.ts +13 -7
  110. package/src/gjc-runtime/workflow-manifest.generated.json +97 -350
  111. package/src/gjc-runtime/workflow-manifest.ts +34 -179
  112. package/src/hooks/native-skill-hook.ts +17 -4
  113. package/src/hooks/skill-state.ts +1 -23
  114. package/src/internal-urls/docs-index.generated.ts +8 -9
  115. package/src/internal-urls/local-root-gc.ts +127 -0
  116. package/src/main.ts +9 -5
  117. package/src/memories/index.ts +10 -0
  118. package/src/modes/DESIGN.md +47 -0
  119. package/src/modes/acp/acp-agent.ts +43 -7
  120. package/src/modes/acp/acp-event-mapper.ts +14 -7
  121. package/src/modes/acp/acp-mode.ts +39 -3
  122. package/src/modes/components/assistant-message.ts +123 -22
  123. package/src/modes/components/irc-sidebar.ts +176 -62
  124. package/src/modes/components/model-selector.ts +61 -4
  125. package/src/modes/components/oauth-selector.ts +42 -12
  126. package/src/modes/components/read-tool-group.ts +82 -14
  127. package/src/modes/components/session-observer-overlay.ts +187 -32
  128. package/src/modes/components/settings-selector.ts +60 -18
  129. package/src/modes/components/tool-execution.ts +138 -27
  130. package/src/modes/controllers/event-controller.ts +109 -27
  131. package/src/modes/controllers/extension-ui-controller.ts +19 -2
  132. package/src/modes/controllers/input-controller.ts +18 -3
  133. package/src/modes/controllers/selector-controller.ts +83 -37
  134. package/src/modes/interactive-mode.ts +62 -3
  135. package/src/modes/irc-observation-ledger.ts +71 -15
  136. package/src/modes/runtime-init.ts +19 -2
  137. package/src/modes/types.ts +2 -0
  138. package/src/modes/utils/ui-helpers.ts +94 -1
  139. package/src/prompts/agents/architect.md +9 -1
  140. package/src/prompts/agents/critic.md +9 -0
  141. package/src/prompts/system/system-prompt.md +0 -1
  142. package/src/prompts/tools/read.md +4 -2
  143. package/src/runtime-mcp/smithery-auth.ts +17 -2
  144. package/src/runtime-mcp/smithery-connect.ts +11 -1
  145. package/src/sdk/broker/lifecycle.ts +25 -6
  146. package/src/sdk/bus/control-drain-lease.ts +104 -0
  147. package/src/sdk/bus/index.ts +341 -38
  148. package/src/sdk/bus/kind-aware-reconciliation.ts +242 -0
  149. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  150. package/src/sdk/bus/reconciliation-store.ts +224 -0
  151. package/src/sdk/host/control/dispatch.ts +5 -1
  152. package/src/sdk/host/control/operations.ts +1 -1
  153. package/src/sdk/host/host.ts +23 -1
  154. package/src/sdk/host/query/handlers.ts +55 -0
  155. package/src/sdk/mcp/server.ts +46 -18
  156. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  157. package/src/sdk/protocol/operation-registry.ts +7 -2
  158. package/src/sdk/session.ts +13 -1
  159. package/src/session/agent-session.ts +466 -50
  160. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  161. package/src/session/session-manager.ts +764 -17
  162. package/src/session/streaming-output.ts +262 -43
  163. package/src/setup/provider-onboarding.ts +19 -4
  164. package/src/skill-state/active-state.ts +0 -10
  165. package/src/skill-state/workflow-hud.ts +25 -2
  166. package/src/skill-state/workflow-mutation-guard.ts +65 -7
  167. package/src/tools/ask.ts +89 -69
  168. package/src/tools/browser/launch.ts +41 -6
  169. package/src/tools/fetch.ts +114 -23
  170. package/src/tools/output-meta.ts +176 -9
  171. package/src/tools/path-utils.ts +5 -1
  172. package/src/tools/read-internals.ts +44 -0
  173. package/src/tools/read.ts +1542 -310
  174. package/src/tools/resource-gc.ts +182 -18
  175. package/src/tools/tool-result.ts +14 -1
  176. package/src/web/search/providers/codex.ts +1 -1
  177. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  178. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  179. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  180. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  181. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  182. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
package/src/tools/read.ts CHANGED
@@ -19,14 +19,16 @@ import type { InternalUrl } from "../internal-urls/types";
19
19
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
20
20
  import readDescription from "../prompts/tools/read.md" with { type: "text" };
21
21
  import type { ToolSession } from "../sdk";
22
+ import type { ReadSegment, ReadWindow, TruncationDirection, TruncationResult } from "../session/streaming-output";
22
23
  import {
23
24
  DEFAULT_MAX_BYTES,
24
25
  DEFAULT_MAX_LINES,
25
- noTruncResult,
26
- type TruncationResult,
26
+ formatMiddleElisionMarker,
27
27
  truncateHead,
28
28
  truncateHeadBytes,
29
29
  truncateLine,
30
+ truncateMiddleWindows,
31
+ truncateTailBytes,
30
32
  } from "../session/streaming-output";
31
33
  import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui";
32
34
  import { CachedOutputBlock } from "../tui/output-block";
@@ -51,6 +53,7 @@ import {
51
53
  isReadableUrlPath,
52
54
  loadReadUrlCacheEntry,
53
55
  parseReadUrlTarget,
56
+ prepareReadUrlSelectorInput,
54
57
  type ReadUrlToolDetails,
55
58
  renderReadUrlCall,
56
59
  renderReadUrlResult,
@@ -71,6 +74,7 @@ import {
71
74
  splitInternalUrlSel,
72
75
  splitPathAndSel,
73
76
  } from "./path-utils";
77
+ import { type ReadRoute, resolveEffectiveDirection } from "./read-internals";
74
78
  import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
75
79
  import {
76
80
  enforceSqliteQueryOnly,
@@ -99,6 +103,12 @@ const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
99
103
  const MAX_SUMMARY_LINES = 20_000;
100
104
  const RAW_COLLECTOR_MAX_BYTES = MAX_SUMMARY_BYTES;
101
105
 
106
+ const BODY_TRUNCATION_FOOTER_KEY = "__bodyTruncationFooter";
107
+
108
+ function rememberBodyTruncationFooter(details: ReadToolDetails, footer: string): void {
109
+ Object.defineProperty(details, BODY_TRUNCATION_FOOTER_KEY, { value: footer, configurable: true, enumerable: false });
110
+ }
111
+
102
112
  /**
103
113
  * Per-line column cap for file reads. Lines wider than the value of
104
114
  * `tools.outputMaxColumns` are ellipsis-truncated at display time; the file
@@ -185,6 +195,259 @@ function countTextLines(text: string): number {
185
195
  return text.split("\n").length;
186
196
  }
187
197
 
198
+ function windowSegments(window: ReadWindow): ReadSegment[] {
199
+ return [window.head, window.tail].filter((segment): segment is ReadSegment => segment !== undefined);
200
+ }
201
+
202
+ function windowHasPartialSegment(window: ReadWindow): boolean {
203
+ return windowSegments(window).some(segment => segment.kind === "partial-line");
204
+ }
205
+
206
+ function partialTailOf(window: ReadWindow): Extract<ReadSegment, { kind: "partial-line" }> | undefined {
207
+ const tail = window.tail;
208
+ return tail?.kind === "partial-line" ? tail : undefined;
209
+ }
210
+
211
+ function linesOfSegment(segment: ReadSegment): string[] {
212
+ return segment.content.split("\n");
213
+ }
214
+
215
+ function rebaseWindow(window: ReadWindow, offset: number): ReadWindow {
216
+ const rebaseSegment = (segment: ReadSegment | undefined): ReadSegment | undefined => {
217
+ if (!segment) return undefined;
218
+ return {
219
+ ...segment,
220
+ origin: {
221
+ startLine: segment.origin.startLine + offset,
222
+ endLine: segment.origin.endLine + offset,
223
+ },
224
+ };
225
+ };
226
+ const rebased: ReadWindow = {
227
+ ...window,
228
+ head: rebaseSegment(window.head),
229
+ tail: rebaseSegment(window.tail),
230
+ };
231
+ for (const key of ["maxBytes", "truncatedBy", "outputLinesOverride", "outputBytesOverride"] as const) {
232
+ const descriptor = Object.getOwnPropertyDescriptor(window, key);
233
+ if (descriptor) Object.defineProperty(rebased, key, descriptor);
234
+ }
235
+ return rebased;
236
+ }
237
+
238
+ function renderReadWindow(
239
+ window: ReadWindow,
240
+ shouldAddHashLines: boolean,
241
+ shouldAddLineNumbers: boolean,
242
+ maxColumns = 0,
243
+ ): string {
244
+ const renderSegment = (segment: ReadSegment): string => {
245
+ const hash = shouldAddHashLines && segment.kind === "lines";
246
+ const numbers = !hash && shouldAddLineNumbers;
247
+ const renderContent =
248
+ !hash && maxColumns > 0
249
+ ? segment.content
250
+ .split("\n")
251
+ .map(line => truncateLine(line, maxColumns).text)
252
+ .join("\n")
253
+ : segment.content;
254
+ return formatTextWithMode(renderContent, segment.origin.startLine, hash, numbers);
255
+ };
256
+ const marker = formatMiddleElisionMarker(window.elidedLines, window.elidedBytes);
257
+ if (window.kind === "full") return window.head ? renderSegment(window.head) : "";
258
+ if (window.kind === "head-only") return window.head ? renderSegment(window.head) : "";
259
+ if (window.kind === "tail-only") return window.tail ? renderSegment(window.tail) : "";
260
+ if (!window.head || !window.tail) return "";
261
+ if (window.elidedLines <= 0) return `${renderSegment(window.head)}\n${renderSegment(window.tail)}`;
262
+ return `${renderSegment(window.head)}\n${marker}\n${renderSegment(window.tail)}`;
263
+ }
264
+
265
+ function windowOutputLines(window: ReadWindow): number {
266
+ if (window.kind === "full") return window.head?.lines ?? 0;
267
+ if (window.kind === "head-only") return window.head?.lines ?? 0;
268
+ if (window.kind === "tail-only") return window.tail?.lines ?? 0;
269
+ return (window.head?.lines ?? 0) + (window.tail?.lines ?? 0) + (window.elidedLines > 0 ? 1 : 0);
270
+ }
271
+
272
+ function windowOutputBytes(window: ReadWindow): number {
273
+ if (window.kind === "full") return window.head?.bytes ?? 0;
274
+ if (window.kind === "head-only") return window.head?.bytes ?? 0;
275
+ if (window.kind === "tail-only") return window.tail?.bytes ?? 0;
276
+ if (window.elidedLines <= 0) return (window.head?.bytes ?? 0) + (window.tail?.bytes ?? 0) + 1;
277
+ const marker = formatMiddleElisionMarker(window.elidedLines, window.elidedBytes);
278
+ return (window.head?.bytes ?? 0) + (window.tail?.bytes ?? 0) + Buffer.byteLength(marker, "utf-8") + 2;
279
+ }
280
+
281
+ function windowTruncatedBy(window: ReadWindow): "lines" | "bytes" | "middle" | undefined {
282
+ return (window as ReadWindow & { truncatedBy?: "lines" | "bytes" | "middle" }).truncatedBy;
283
+ }
284
+
285
+ function makeWindowResult(window: ReadWindow, content: string): TruncationResult {
286
+ const truncatedBy = windowTruncatedBy(window);
287
+ const lastLineExceedsLimit = window.tail?.kind === "partial-line";
288
+ return {
289
+ content,
290
+ truncated: window.kind !== "full",
291
+ truncatedBy: truncatedBy ?? (window.kind === "middle" ? "middle" : "lines"),
292
+ totalLines: window.totalLines,
293
+ totalBytes: window.totalBytes,
294
+ outputLines: windowOutputLines(window),
295
+ outputBytes: windowOutputBytes(window),
296
+ // `elidedLines`/`elidedBytes` are documented as middle-only on the legacy
297
+ // `TruncationResult`; head/tail windows leave them absent so one-sided
298
+ // results stay byte-identical to the pre-change shape.
299
+ ...(window.kind === "middle" ? { elidedLines: window.elidedLines, elidedBytes: window.elidedBytes } : {}),
300
+ lastLinePartial: windowHasPartialSegment(window),
301
+ firstLineExceedsLimit: false,
302
+ ...(lastLineExceedsLimit ? { lastLineExceedsLimit: true } : {}),
303
+ };
304
+ }
305
+
306
+ function fullReadWindow(content: string, totalLines: number, totalBytes: number): ReadWindow {
307
+ return {
308
+ kind: "full",
309
+ head: {
310
+ kind: "lines",
311
+ content,
312
+ lines: totalLines,
313
+ bytes: totalBytes,
314
+ origin: { startLine: 1, endLine: totalLines },
315
+ lastLinePartial: false,
316
+ },
317
+ overlap: "disjoint",
318
+ elidedLines: 0,
319
+ elidedBytes: 0,
320
+ totalLines,
321
+ totalBytes,
322
+ };
323
+ }
324
+
325
+ function selectReceiptWindow(
326
+ allLines: readonly string[],
327
+ direction: "head" | "tail",
328
+ budgetLines: number,
329
+ budgetBytes: number,
330
+ ): ReadWindow {
331
+ const totalLines = allLines.length;
332
+ const fullContent = allLines.join("\n");
333
+ const totalBytes = Buffer.byteLength(fullContent, "utf-8");
334
+ const take = (from: number, step: 1 | -1): { picked: string[]; bytes: number; truncatedBy?: "lines" | "bytes" } => {
335
+ const picked: string[] = [];
336
+ let bytes = 0;
337
+ let truncatedBy: "lines" | "bytes" | undefined;
338
+ for (let i = from; i >= 0 && i < allLines.length; i += step) {
339
+ if (picked.length >= budgetLines) {
340
+ truncatedBy = "lines";
341
+ break;
342
+ }
343
+ const lineBytes = Buffer.byteLength(allLines[i] ?? "", "utf-8") + (picked.length > 0 ? 1 : 0);
344
+ if (bytes + lineBytes > budgetBytes) {
345
+ // Report the constraint that actually bound the window. When the
346
+ // line budget was already full, the byte overflow is incidental and
347
+ // the materialized oracle calls it "lines"; matching keeps A26
348
+ // parity honest.
349
+ truncatedBy = picked.length >= budgetLines ? "lines" : "bytes";
350
+ break;
351
+ }
352
+ picked.push(allLines[i] ?? "");
353
+ bytes += lineBytes;
354
+ }
355
+ return { picked, bytes, truncatedBy };
356
+ };
357
+ const taken = direction === "head" ? take(0, 1) : take(allLines.length - 1, -1);
358
+ if (direction === "tail") taken.picked.reverse();
359
+ const startLine = direction === "head" ? 1 : totalLines - taken.picked.length + 1;
360
+ const selected = taken.picked.join("\n");
361
+ if (taken.picked.length === totalLines) return fullReadWindow(fullContent, totalLines, totalBytes);
362
+ const segment: ReadSegment | undefined =
363
+ taken.picked.length > 0
364
+ ? {
365
+ kind: "lines",
366
+ content: selected,
367
+ lines: taken.picked.length,
368
+ bytes: taken.bytes,
369
+ origin: { startLine, endLine: startLine + taken.picked.length - 1 },
370
+ lastLinePartial: false,
371
+ }
372
+ : undefined;
373
+ const window: ReadWindow = {
374
+ kind: direction === "head" ? "head-only" : "tail-only",
375
+ ...(direction === "head" ? (segment ? { head: segment } : {}) : segment ? { tail: segment } : {}),
376
+ overlap: "disjoint",
377
+ elidedLines: Math.max(0, totalLines - taken.picked.length),
378
+ elidedBytes: Math.max(0, totalBytes - taken.bytes),
379
+ totalLines,
380
+ totalBytes,
381
+ };
382
+ if (taken.truncatedBy) Object.defineProperty(window, "truncatedBy", { value: taken.truncatedBy, enumerable: false });
383
+ return window;
384
+ }
385
+
386
+ function selectReadWindow(
387
+ text: string,
388
+ allLines: readonly string[],
389
+ direction: TruncationDirection,
390
+ budgetLines: number,
391
+ budgetBytes: number,
392
+ ): ReadWindow {
393
+ if (direction === "both") {
394
+ return truncateMiddleWindows(text, { maxLines: budgetLines, maxBytes: budgetBytes });
395
+ }
396
+ return selectReceiptWindow(allLines, direction === "head" ? "head" : "tail", budgetLines, budgetBytes);
397
+ }
398
+
399
+ function formatDirectionalFooter(readPath: string, window: ReadWindow): string {
400
+ if (window.kind === "full") return "";
401
+ const total = window.totalLines;
402
+ const shownSegments = windowSegments(window);
403
+ const shownBytes =
404
+ shownSegments.reduce((sum, segment) => sum + segment.bytes, 0) + Math.max(0, shownSegments.length - 1);
405
+ const head = window.head;
406
+ const tail = window.tail;
407
+ const partialTail = partialTailOf(window);
408
+ if (partialTail) {
409
+ return `[Showing last ${formatBytes(partialTail.bytes)} of line ${partialTail.origin.startLine} (line is ${formatBytes(partialTail.sourceLineBytes)}); re-read ${readPath}:1-${total} or ${readPath}:raw for the full file]`;
410
+ }
411
+ if (window.kind === "tail-only" && tail) {
412
+ const start = tail.origin.startLine;
413
+ return `[Showing last ${tail.lines} of ${total} lines (lines ${start}-${tail.origin.endLine}, ~${Math.ceil(shownBytes / 1024)} KiB); omitted lines 1-${start - 1}; re-read ${readPath}:1-${total} or ${readPath}:raw for the full file]`;
414
+ }
415
+ if (window.kind === "middle" && head && tail) {
416
+ const omittedStart = head.origin.endLine + 1;
417
+ const omittedEnd = tail.origin.startLine - 1;
418
+ return `[Showing lines 1-${head.origin.endLine} and ${tail.origin.startLine}-${tail.origin.endLine} of ${total} (~${Math.ceil(shownBytes / 1024)} KiB); omitted lines ${omittedStart}-${omittedEnd}; re-read ${readPath}:1-${total} or ${readPath}:raw for the full file]`;
419
+ }
420
+ const shown = head?.lines ?? 0;
421
+ return `[Showing first ${shown} of ${total} lines (~${Math.ceil(shownBytes / 1024)} KiB); re-read ${readPath}:1-${total} or ${readPath}:raw for the full file]`;
422
+ }
423
+
424
+ function formatOversizedLineWarning(
425
+ readPath: string,
426
+ lineNumber: number,
427
+ lineBytes: number,
428
+ maxBytes: number,
429
+ fullFileLines: number,
430
+ ): string {
431
+ return `[Line ${lineNumber} is ${formatBytes(lineBytes)}, exceeds ${formatBytes(maxBytes)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview. Re-read ${readPath}:1-${fullFileLines} or ${readPath}:raw for the full file]`;
432
+ }
433
+
434
+ function formatListingTruncationNotice(
435
+ shownLines: number,
436
+ shownBytes: number,
437
+ absolutePath: string,
438
+ direction: TruncationDirection,
439
+ ): string {
440
+ const retained = direction === "last" ? "last" : direction === "both" ? "first and last" : "first";
441
+ const directionPart = direction === "head" ? "" : `; retained ${retained} lines`;
442
+ return `[Listing truncated at ${shownLines} lines / ${Math.ceil(shownBytes / 1024)} KiB${directionPart}; read a deeper subpath (e.g. ${absolutePath}/<subdir>) to narrow the view]`;
443
+ }
444
+
445
+ function formatRawReadCapNotice(maxBytes: number, fileSize: number, direction: TruncationDirection): string {
446
+ const retained = direction === "last" ? "last" : direction === "both" ? "first and last" : "first";
447
+ const directionPart = direction === "head" ? "" : `; retained ${retained} bytes`;
448
+ return `[Raw read capped at ${formatBytes(maxBytes)} of ${formatBytes(fileSize)}${directionPart}; re-read a line range to narrow the view]`;
449
+ }
450
+
188
451
  /**
189
452
  * Footer appended to summarized reads telling the model how to recover the
190
453
  * elided body. Without this hint, agents either ignore the `...`/`{ .. }`
@@ -202,8 +465,15 @@ function formatReceiptFooter(readPath: string, shownLines: number, totalLines: n
202
465
  return `[Showing first ${shownLines} of ${totalLines} lines (~${Math.ceil(shownBytes / 1024)} KiB); re-read ${readPath}:1-${totalLines} or ${readPath}:raw for the full file]`;
203
466
  }
204
467
 
205
- function formatSummaryCapFooter(readPath: string, maxBytes: number, totalLines: number): string {
206
- return `[Summary truncated at ${Math.ceil(maxBytes / 1024)} KiB; re-read ${readPath}:raw or ${readPath}:1-${totalLines} for the full source]`;
468
+ function formatSummaryCapFooter(
469
+ readPath: string,
470
+ maxBytes: number,
471
+ totalLines: number,
472
+ direction: TruncationDirection = "head",
473
+ ): string {
474
+ const retained = direction === "last" ? "last" : direction === "both" ? "first and last" : "first";
475
+ const directionPart = direction === "head" ? "" : `; retained ${retained} summary units`;
476
+ return `[Summary truncated at ${Math.ceil(maxBytes / 1024)} KiB${directionPart}; re-read ${readPath}:1-${totalLines} or ${readPath}:raw for the full source]`;
207
477
  }
208
478
  const READ_CHUNK_SIZE = 8 * 1024;
209
479
 
@@ -241,13 +511,15 @@ function expandRangeWithContext(
241
511
  };
242
512
  }
243
513
 
244
- async function streamLinesFromFile(
514
+ export async function streamLinesFromFile(
245
515
  filePath: string,
246
516
  startLine: number,
247
517
  maxLinesToCollect: number,
248
518
  maxBytes: number,
249
519
  selectedLineLimit: number | null,
250
520
  signal?: AbortSignal,
521
+ collect: "head" | "tail" | "both" = "head",
522
+ collectEndExclusive: number | null = null,
251
523
  ): Promise<{
252
524
  lines: string[];
253
525
  totalFileLines: number;
@@ -256,6 +528,22 @@ async function streamLinesFromFile(
256
528
  firstLinePreview?: { text: string; bytes: number };
257
529
  firstLineByteLength?: number;
258
530
  selectedBytesTotal: number;
531
+ windowBytesTotal: number;
532
+ windowLinesTotal: number;
533
+ windowStartIndex: number;
534
+ tailPartial?: { text: string; bytes: number; sourceIndex: number; sourceLineBytes: number };
535
+ maxBytes: number;
536
+ headLines?: string[];
537
+ headBytes?: number;
538
+ headStartIndex?: number;
539
+ degenerateTailBudget: boolean;
540
+ degenerateHeadBudget: boolean;
541
+ headStoppedByBytes: boolean;
542
+ headStoppedByLines: boolean;
543
+ ringBudgetLines: number;
544
+ ringEvictedForBytes: boolean;
545
+ ringEvictedForLines: boolean;
546
+ tailDroppedForBytes: boolean;
259
547
  }> {
260
548
  const bufferChunk = Buffer.allocUnsafe(READ_CHUNK_SIZE);
261
549
  const collectedLines: string[] = [];
@@ -273,30 +561,72 @@ async function streamLinesFromFile(
273
561
  let firstLineByteLength: number | undefined;
274
562
  let selectedBytesTotal = 0;
275
563
  let selectedLinesSeen = 0;
564
+ let windowBytesTotal = 0;
565
+ let windowLinesTotal = 0;
276
566
  let captureLine = false;
277
567
  let discardLineChunks = false;
278
568
  let lineCaptureLimit = 0;
279
569
 
570
+ // A middle read uses the same split as truncateMiddleWindows. The candidate
571
+ // state below is deliberately full-fit first: a file that fits the unsplit
572
+ // budget must not lose bytes merely because the eventual mode is "both".
573
+ const headBudgetBytes = collect === "both" ? Math.floor(maxBytes / 2) : 0;
574
+ const ringBudgetBytes = collect === "both" ? Math.max(0, maxBytes - headBudgetBytes) : maxBytes;
575
+ const headBudgetLines = collect === "both" ? Math.max(1, Math.floor(maxLinesToCollect / 2)) : 0;
576
+ const ringBudgetLines = collect === "both" ? Math.max(0, maxLinesToCollect - headBudgetLines) : maxLinesToCollect;
577
+ // Keep the full-fit candidate ahead of this classification. Once a split is
578
+ // required, these predicates dispatch the effective one-sided window kind.
579
+ const degenerateHeadBudget = collect === "both" && (headBudgetBytes <= 0 || headBudgetLines <= 0);
580
+ const degenerateTailBudget = collect === "both" && (ringBudgetBytes <= 0 || ringBudgetLines <= 0);
581
+ type BothState = "full-fit" | "split";
582
+ let oneSidedDispatch: "head" | "tail" | undefined;
583
+ let state: BothState = collect === "both" ? "full-fit" : "split";
584
+ let fitLines: string[] = [];
585
+ let fitByteLens: number[] = [];
586
+ let fitBytes = 0;
587
+ let fitStartIndex = -1;
588
+
589
+ const ring: string[] = [];
590
+ const ringLineBytes: number[] = [];
591
+ let ringHead = 0;
592
+ let ringBytes = 0;
593
+ let ringStartIndex = 0;
594
+ let ringOriginPending = true;
595
+ let ringEvictedForBytes = false;
596
+ let ringEvictedForLines = false;
597
+ let tailDroppedForBytes = false;
598
+
599
+ const headLines: string[] = [];
600
+ let headBytes = 0;
601
+ let headSealed = false;
602
+ let headStoppedByBytes = false;
603
+ let headStoppedByLines = false;
604
+
605
+ let currentLineKept = 0;
606
+ let currentLineDropped = false;
607
+ let oversizedPending: { index: number; byteLength: number; preview: { text: string; bytes: number } } | undefined;
608
+
609
+ const windowEndExclusive = collectEndExclusive === null ? Number.POSITIVE_INFINITY : collectEndExclusive;
610
+ const inWindow = () => lineIndex >= startLine && lineIndex < windowEndExclusive;
611
+
280
612
  const setupLineState = () => {
281
- captureLine = !doneCollecting && lineIndex >= startLine;
613
+ captureLine = collect === "head" && !doneCollecting && lineIndex >= startLine;
282
614
  discardLineChunks = !captureLine;
283
615
  if (captureLine) {
284
616
  const separatorBytes = collectedLines.length > 0 ? 1 : 0;
285
617
  lineCaptureLimit = maxBytes - collectedBytes - separatorBytes;
286
- if (lineCaptureLimit <= 0) {
287
- discardLineChunks = true;
288
- }
618
+ if (lineCaptureLimit <= 0) discardLineChunks = true;
289
619
  } else {
290
620
  lineCaptureLimit = 0;
291
621
  }
292
622
  };
293
623
 
294
624
  const decodeLine = (): string => {
295
- if (currentLineLength === 0) return "";
296
- if (currentLineChunks.length === 1 && currentLineChunks[0]?.length === currentLineLength) {
625
+ if (currentLineKept === 0) return "";
626
+ if (currentLineChunks.length === 1 && currentLineChunks[0]?.length === currentLineKept) {
297
627
  return currentLineChunks[0].toString("utf-8");
298
628
  }
299
- return Buffer.concat(currentLineChunks, currentLineLength).toString("utf-8");
629
+ return Buffer.concat(currentLineChunks, currentLineKept).toString("utf-8");
300
630
  };
301
631
 
302
632
  const maybeCapturePreview = (segment: Uint8Array) => {
@@ -309,15 +639,123 @@ async function streamLinesFromFile(
309
639
  firstLinePreviewBytes += slice.length;
310
640
  };
311
641
 
642
+ const trimCurrentLineBuffer = (cap: number) => {
643
+ while (currentLineKept > cap && currentLineChunks.length > 0) {
644
+ const front = currentLineChunks[0];
645
+ const excess = currentLineKept - cap;
646
+ if (front.length <= excess) {
647
+ currentLineChunks.shift();
648
+ currentLineKept -= front.length;
649
+ } else {
650
+ currentLineChunks[0] = Buffer.from(front.subarray(excess));
651
+ currentLineKept -= excess;
652
+ }
653
+ currentLineDropped = true;
654
+ }
655
+ };
656
+
312
657
  const appendSegment = (segment: Uint8Array) => {
313
658
  currentLineLength += segment.length;
314
- maybeCapturePreview(segment);
315
- if (!captureLine || discardLineChunks || segment.length === 0) return;
316
- if (currentLineLength <= lineCaptureLimit) {
317
- currentLineChunks.push(Buffer.from(segment));
318
- } else {
319
- discardLineChunks = true;
659
+ if (collect === "head") {
660
+ maybeCapturePreview(segment);
661
+ if (!captureLine || discardLineChunks || segment.length === 0) return;
662
+ if (currentLineLength <= lineCaptureLimit) {
663
+ currentLineChunks.push(Buffer.from(segment));
664
+ currentLineKept += segment.length;
665
+ } else {
666
+ discardLineChunks = true;
667
+ }
668
+ return;
320
669
  }
670
+ if (!inWindow() || segment.length === 0) return;
671
+ currentLineChunks.push(Buffer.from(segment));
672
+ currentLineKept += segment.length;
673
+ trimCurrentLineBuffer(collect === "both" && state === "full-fit" ? maxBytes : ringBudgetBytes);
674
+ };
675
+
676
+ const capturePartialPreview = (): { text: string; bytes: number } => {
677
+ const raw = Buffer.concat(currentLineChunks, currentLineKept);
678
+ let start = 0;
679
+ while (start < raw.length && (raw[start]! & 0xc0) === 0x80) start++;
680
+ const aligned = raw.subarray(start);
681
+ return { text: aligned.toString("utf-8"), bytes: aligned.length };
682
+ };
683
+
684
+ const pushSplit = (
685
+ index: number,
686
+ text: string,
687
+ bytes: number,
688
+ dropped: boolean,
689
+ previewOverride?: { text: string; bytes: number },
690
+ ) => {
691
+ if (collect === "both" && oneSidedDispatch !== "tail" && !headSealed) {
692
+ if (dropped || bytes > headBudgetBytes) {
693
+ headSealed = true;
694
+ headStoppedByBytes = true;
695
+ } else {
696
+ const separatorBytes = headLines.length > 0 ? 1 : 0;
697
+ if (headLines.length < headBudgetLines && headBytes + separatorBytes + bytes <= headBudgetBytes) {
698
+ headLines.push(text);
699
+ headBytes += separatorBytes + bytes;
700
+ } else {
701
+ headSealed = true;
702
+ if (headLines.length >= headBudgetLines) headStoppedByLines = true;
703
+ else headStoppedByBytes = true;
704
+ }
705
+ }
706
+ }
707
+ if (collect === "both" && oneSidedDispatch === "head") return;
708
+
709
+ if (dropped || bytes > ringBudgetBytes) {
710
+ const preview = previewOverride ?? truncateTailBytes(text, ringBudgetBytes);
711
+ oversizedPending = { index, byteLength: bytes, preview };
712
+ ring.length = 0;
713
+ ringHead = 0;
714
+ ringLineBytes.length = 0;
715
+ ringBytes = 0;
716
+ ringOriginPending = true;
717
+ tailDroppedForBytes = true;
718
+ return;
719
+ }
720
+
721
+ if (ringOriginPending) {
722
+ ringStartIndex = index;
723
+ ringOriginPending = false;
724
+ }
725
+ ring.push(text);
726
+ ringLineBytes.push(bytes);
727
+ ringBytes += (ring.length - ringHead > 1 ? 1 : 0) + bytes;
728
+ oversizedPending = undefined;
729
+
730
+ while (ring.length - ringHead > ringBudgetLines || (ringBytes > ringBudgetBytes && ring.length - ringHead > 1)) {
731
+ if (ring.length - ringHead > ringBudgetLines) ringEvictedForLines = true;
732
+ if (ringBytes > ringBudgetBytes) ringEvictedForBytes = true;
733
+ const evictedBytes = ringLineBytes[ringHead] ?? 0;
734
+ const evictedSep = ring.length - ringHead > 1 ? 1 : 0;
735
+ ringBytes -= evictedBytes + evictedSep;
736
+ ring[ringHead] = undefined as unknown as string;
737
+ ringHead++;
738
+ ringStartIndex++;
739
+ }
740
+ if (ring.length - ringHead === 0) ringOriginPending = true;
741
+ if (ringHead > 4096) {
742
+ ring.splice(0, ringHead);
743
+ ringLineBytes.splice(0, ringHead);
744
+ ringHead = 0;
745
+ }
746
+ };
747
+
748
+ const transitionToSplit = () => {
749
+ state = "split";
750
+ oneSidedDispatch = degenerateHeadBudget ? "tail" : degenerateTailBudget ? "head" : undefined;
751
+ for (let i = 0; i < fitLines.length; i++) {
752
+ pushSplit(fitStartIndex + i, fitLines[i] ?? "", fitByteLens[i] ?? 0, false);
753
+ }
754
+ fitLines = [];
755
+ fitByteLens = [];
756
+ fitBytes = 0;
757
+ fitStartIndex = -1;
758
+ trimCurrentLineBuffer(ringBudgetBytes);
321
759
  };
322
760
 
323
761
  const finalizeLine = () => {
@@ -325,41 +763,65 @@ async function streamLinesFromFile(
325
763
  selectedBytesTotal += currentLineLength + (selectedLinesSeen > 0 ? 1 : 0);
326
764
  selectedLinesSeen++;
327
765
  }
766
+ if (inWindow()) {
767
+ windowBytesTotal += currentLineLength + (windowLinesTotal > 0 ? 1 : 0);
768
+ windowLinesTotal++;
769
+ }
328
770
 
329
- if (!doneCollecting && lineIndex >= startLine) {
330
- const separatorBytes = collectedLines.length > 0 ? 1 : 0;
331
- if (collectedLines.length >= maxLinesToCollect) {
332
- doneCollecting = true;
333
- } else if (collectedLines.length === 0 && currentLineLength > maxBytes) {
334
- stoppedByByteLimit = true;
335
- doneCollecting = true;
336
- if (firstLineByteLength === undefined) {
337
- firstLineByteLength = currentLineLength;
338
- }
339
- } else if (collectedLines.length > 0 && collectedBytes + separatorBytes + currentLineLength > maxBytes) {
340
- stoppedByByteLimit = true;
341
- doneCollecting = true;
342
- } else {
343
- const lineText = decodeLine();
344
- collectedLines.push(lineText);
345
- collectedBytes += separatorBytes + currentLineLength;
346
- if (firstLineByteLength === undefined) {
347
- firstLineByteLength = currentLineLength;
348
- }
349
- if (collectedBytes > maxBytes) {
771
+ if (collect === "head") {
772
+ if (!doneCollecting && lineIndex >= startLine) {
773
+ const separatorBytes = collectedLines.length > 0 ? 1 : 0;
774
+ if (collectedLines.length >= maxLinesToCollect) {
775
+ doneCollecting = true;
776
+ } else if (collectedLines.length === 0 && currentLineLength > maxBytes) {
350
777
  stoppedByByteLimit = true;
351
778
  doneCollecting = true;
352
- } else if (collectedLines.length >= maxLinesToCollect) {
779
+ if (firstLineByteLength === undefined) firstLineByteLength = currentLineLength;
780
+ } else if (collectedLines.length > 0 && collectedBytes + separatorBytes + currentLineLength > maxBytes) {
781
+ stoppedByByteLimit = true;
353
782
  doneCollecting = true;
783
+ } else {
784
+ const lineText = decodeLine();
785
+ collectedLines.push(lineText);
786
+ collectedBytes += separatorBytes + currentLineLength;
787
+ if (firstLineByteLength === undefined) firstLineByteLength = currentLineLength;
788
+ if (collectedBytes > maxBytes) {
789
+ stoppedByByteLimit = true;
790
+ doneCollecting = true;
791
+ } else if (collectedLines.length >= maxLinesToCollect) {
792
+ doneCollecting = true;
793
+ }
354
794
  }
795
+ } else if (lineIndex >= startLine && firstLineByteLength === undefined) {
796
+ firstLineByteLength = currentLineLength;
797
+ }
798
+ } else if (inWindow()) {
799
+ const lineText = decodeLine();
800
+ if (collect === "both" && state === "full-fit") {
801
+ const separatorBytes = fitLines.length > 0 ? 1 : 0;
802
+ const stillFits =
803
+ !currentLineDropped &&
804
+ fitLines.length + 1 <= maxLinesToCollect &&
805
+ fitBytes + separatorBytes + currentLineLength <= maxBytes;
806
+ if (stillFits) {
807
+ if (fitStartIndex < 0) fitStartIndex = lineIndex;
808
+ fitLines.push(lineText);
809
+ fitByteLens.push(currentLineLength);
810
+ fitBytes += separatorBytes + currentLineLength;
811
+ } else {
812
+ transitionToSplit();
813
+ pushSplit(lineIndex, lineText, currentLineLength, currentLineDropped, capturePartialPreview());
814
+ }
815
+ } else {
816
+ pushSplit(lineIndex, lineText, currentLineLength, currentLineDropped, capturePartialPreview());
355
817
  }
356
- } else if (lineIndex >= startLine && firstLineByteLength === undefined) {
357
- firstLineByteLength = currentLineLength;
358
818
  }
359
819
 
360
820
  lineIndex++;
361
821
  currentLineLength = 0;
822
+ currentLineKept = 0;
362
823
  currentLineChunks = [];
824
+ currentLineDropped = false;
363
825
  setupLineState();
364
826
  };
365
827
 
@@ -367,7 +829,6 @@ async function streamLinesFromFile(
367
829
 
368
830
  try {
369
831
  fileHandle = await fs.open(filePath, "r");
370
-
371
832
  while (true) {
372
833
  throwIfAborted(signal);
373
834
  const { bytesRead } = await fileHandle.read(bufferChunk, 0, bufferChunk.length, null);
@@ -381,27 +842,18 @@ async function streamLinesFromFile(
381
842
  for (let i = 0; i < chunk.length; i++) {
382
843
  if (chunk[i] === 0x0a) {
383
844
  const segment = chunk.subarray(start, i);
384
- if (segment.length > 0) {
385
- appendSegment(segment);
386
- }
845
+ if (segment.length > 0) appendSegment(segment);
387
846
  finalizeLine();
388
847
  start = i + 1;
389
848
  }
390
849
  }
391
-
392
- if (start < chunk.length) {
393
- appendSegment(chunk.subarray(start));
394
- }
850
+ if (start < chunk.length) appendSegment(chunk.subarray(start));
395
851
  }
396
852
  } finally {
397
- if (fileHandle) {
398
- await fileHandle.close();
399
- }
853
+ if (fileHandle) await fileHandle.close();
400
854
  }
401
855
 
402
- if (endedWithNewline || currentLineLength > 0 || !sawAnyByte) {
403
- finalizeLine();
404
- }
856
+ if (endedWithNewline || currentLineLength > 0 || !sawAnyByte) finalizeLine();
405
857
 
406
858
  let firstLinePreview: { text: string; bytes: number } | undefined;
407
859
  if (firstLinePreviewBytes > 0) {
@@ -409,17 +861,271 @@ async function streamLinesFromFile(
409
861
  firstLinePreview = { text, bytes };
410
862
  }
411
863
 
864
+ const tailPartial =
865
+ collect !== "head" && oversizedPending
866
+ ? {
867
+ text: oversizedPending.preview.text,
868
+ bytes: oversizedPending.preview.bytes,
869
+ sourceIndex: oversizedPending.index,
870
+ sourceLineBytes: oversizedPending.byteLength,
871
+ }
872
+ : undefined;
873
+ // I8: selected-window line totals are defined on the valid domain only;
874
+ // out-of-bounds starts clamp the mathematical count at zero.
875
+ // `inWindow()` above enforces the same predicate while collecting.
876
+ // I8 is therefore `windowLinesTotal === Math.max(0, windowEnd - startLine)`.
877
+ const ringSize = ring.length - ringHead;
878
+ const ringLines = ring.slice(ringHead);
879
+ const windowEnd = Math.min(collectEndExclusive ?? lineIndex, lineIndex);
880
+ const windowStartIndex =
881
+ collect === "head"
882
+ ? startLine
883
+ : ringSize > 0
884
+ ? ringStartIndex
885
+ : tailPartial
886
+ ? tailPartial.sourceIndex
887
+ : windowEnd;
888
+
889
+ if (collect !== "head") collectedBytes = ringBytes;
890
+
891
+ const resolvedHeadLines = collect === "both" && state === "full-fit" ? fitLines : headLines;
892
+ const resolvedHeadBytes = collect === "both" && state === "full-fit" ? fitBytes : headBytes;
412
893
  return {
413
- lines: collectedLines,
894
+ lines: collect === "head" ? collectedLines : ringLines,
414
895
  totalFileLines: lineIndex,
415
896
  collectedBytes,
416
- stoppedByByteLimit,
897
+ stoppedByByteLimit: collect === "head" ? stoppedByByteLimit : ringEvictedForBytes,
417
898
  firstLinePreview,
418
899
  firstLineByteLength,
419
900
  selectedBytesTotal,
901
+ windowBytesTotal,
902
+ windowLinesTotal,
903
+ windowStartIndex,
904
+ tailPartial,
905
+ maxBytes,
906
+ headLines: collect === "both" ? resolvedHeadLines : undefined,
907
+ headBytes: collect === "both" ? resolvedHeadBytes : undefined,
908
+ headStartIndex: collect === "both" ? startLine : undefined,
909
+ degenerateTailBudget,
910
+ degenerateHeadBudget,
911
+ headStoppedByBytes,
912
+ headStoppedByLines,
913
+ ringBudgetLines,
914
+ ringEvictedForBytes,
915
+ ringEvictedForLines,
916
+ tailDroppedForBytes,
420
917
  };
421
918
  }
422
919
 
920
+ export function streamResultWindow(
921
+ result: Awaited<ReturnType<typeof streamLinesFromFile>>,
922
+ collect: "tail" | "both",
923
+ startLine: number,
924
+ collectEndExclusive: number | null,
925
+ ): ReadWindow {
926
+ const windowEnd = Math.min(collectEndExclusive ?? result.totalFileLines, result.totalFileLines);
927
+ const totalLines = result.windowLinesTotal;
928
+ const totalBytes = result.windowBytesTotal;
929
+ const ringLines = result.lines;
930
+ const partial = result.tailPartial;
931
+ const attachReason = (window: ReadWindow, reason: "lines" | "bytes" | "middle" | undefined): ReadWindow => {
932
+ Object.defineProperty(window, "maxBytes", { value: result.maxBytes, enumerable: false });
933
+ if (reason !== undefined) Object.defineProperty(window, "truncatedBy", { value: reason, enumerable: false });
934
+ return window;
935
+ };
936
+ const linesSegment = (lines: readonly string[], bytes: number, sourceStart: number): ReadSegment | undefined => {
937
+ if (lines.length === 0) return undefined;
938
+ return {
939
+ kind: "lines",
940
+ content: lines.join("\n"),
941
+ lines: lines.length,
942
+ bytes,
943
+ origin: { startLine: sourceStart + 1, endLine: sourceStart + lines.length },
944
+ lastLinePartial: false,
945
+ };
946
+ };
947
+ const partialSegment = (): ReadSegment | undefined => {
948
+ if (!partial) return undefined;
949
+ return {
950
+ kind: "partial-line",
951
+ content: partial.text,
952
+ lines: 1,
953
+ bytes: partial.bytes,
954
+ origin: { startLine: partial.sourceIndex + 1, endLine: partial.sourceIndex + 1 },
955
+ sourceLineBytes: partial.sourceLineBytes,
956
+ lastLinePartial: true,
957
+ };
958
+ };
959
+ const sourceBytes = (lines: readonly string[]): number => Buffer.byteLength(lines.join("\n"), "utf-8");
960
+ const tail = partialSegment() ?? linesSegment(ringLines, result.collectedBytes, result.windowStartIndex);
961
+ const tailStartIndex = partial?.sourceIndex ?? (ringLines.length > 0 ? result.windowStartIndex : windowEnd);
962
+ const tailCount = partial ? 1 : ringLines.length;
963
+ const lineBudgetWon = !partial && result.ringEvictedForLines && tailCount >= result.ringBudgetLines;
964
+ const tailReason: "lines" | "bytes" | undefined =
965
+ partial || result.tailDroppedForBytes || (result.ringEvictedForBytes && !lineBudgetWon)
966
+ ? "bytes"
967
+ : lineBudgetWon || tailCount < totalLines
968
+ ? "lines"
969
+ : undefined;
970
+
971
+ if (collect === "tail") {
972
+ if (
973
+ tailCount === totalLines &&
974
+ !partial &&
975
+ !result.ringEvictedForBytes &&
976
+ !result.ringEvictedForLines &&
977
+ !result.tailDroppedForBytes
978
+ ) {
979
+ return {
980
+ kind: "full",
981
+ head: linesSegment(ringLines, totalBytes, startLine),
982
+ overlap: "disjoint",
983
+ elidedLines: 0,
984
+ elidedBytes: 0,
985
+ totalLines,
986
+ totalBytes,
987
+ };
988
+ }
989
+ return attachReason(
990
+ {
991
+ kind: "tail-only",
992
+ ...(tail ? { tail } : {}),
993
+ overlap: "disjoint",
994
+ elidedLines: Math.max(0, totalLines - tailCount),
995
+ elidedBytes: partial
996
+ ? Math.max(0, totalBytes - partial.bytes)
997
+ : Math.max(0, totalBytes - result.collectedBytes),
998
+ totalLines,
999
+ totalBytes,
1000
+ },
1001
+ tailReason,
1002
+ );
1003
+ }
1004
+
1005
+ const headLines = result.headLines ?? [];
1006
+ const headCount = headLines.length;
1007
+ const headBytes = result.headBytes ?? sourceBytes(headLines);
1008
+ const head = linesSegment(headLines, headBytes, result.headStartIndex ?? startLine);
1009
+ const headEndExclusive = startLine + headCount;
1010
+ const windowLineCount = totalLines;
1011
+ const headReason: "lines" | "bytes" | undefined = result.degenerateTailBudget
1012
+ ? result.headStoppedByBytes
1013
+ ? "bytes"
1014
+ : result.headStoppedByLines || headCount < windowLineCount
1015
+ ? "lines"
1016
+ : undefined
1017
+ : result.ringEvictedForBytes || result.tailDroppedForBytes
1018
+ ? "bytes"
1019
+ : result.ringEvictedForLines || headCount < windowLineCount
1020
+ ? "lines"
1021
+ : undefined;
1022
+ const makeHeadOnly = (): ReadWindow =>
1023
+ attachReason(
1024
+ {
1025
+ kind: "head-only",
1026
+ ...(head ? { head } : {}),
1027
+ overlap: "disjoint",
1028
+ elidedLines: Math.max(0, windowLineCount - headCount),
1029
+ elidedBytes: Math.max(0, totalBytes - headBytes),
1030
+ totalLines: windowLineCount,
1031
+ totalBytes,
1032
+ },
1033
+ headReason,
1034
+ );
1035
+ const makeTailOnly = (): ReadWindow =>
1036
+ attachReason(
1037
+ {
1038
+ kind: "tail-only",
1039
+ ...(tail ? { tail } : {}),
1040
+ overlap: "disjoint",
1041
+ elidedLines: Math.max(0, windowLineCount - tailCount),
1042
+ elidedBytes: partial
1043
+ ? Math.max(0, totalBytes - partial.bytes)
1044
+ : Math.max(0, totalBytes - result.collectedBytes),
1045
+ totalLines: windowLineCount,
1046
+ totalBytes,
1047
+ },
1048
+ tailReason,
1049
+ );
1050
+ if (
1051
+ collect === "both" &&
1052
+ headCount === windowLineCount &&
1053
+ !partial &&
1054
+ !result.ringEvictedForBytes &&
1055
+ !result.ringEvictedForLines &&
1056
+ !result.tailDroppedForBytes
1057
+ ) {
1058
+ return {
1059
+ kind: "full",
1060
+ head: linesSegment(headLines, totalBytes, startLine),
1061
+ overlap: "disjoint",
1062
+ elidedLines: 0,
1063
+ elidedBytes: 0,
1064
+ totalLines: windowLineCount,
1065
+ totalBytes,
1066
+ };
1067
+ }
1068
+
1069
+ if (collect === "both" && result.degenerateHeadBudget) return makeTailOnly();
1070
+ if (collect === "both" && result.degenerateTailBudget) return makeHeadOnly();
1071
+ if (headCount === 0) return makeTailOnly();
1072
+ if (ringLines.length === 0 && !partial) return makeHeadOnly();
1073
+ if (!tail) return makeHeadOnly();
1074
+
1075
+ const headTailGap = tailStartIndex - headEndExclusive;
1076
+ const overlap: ReadWindow["overlap"] = headTailGap > 0 ? "disjoint" : headTailGap === 0 ? "adjacent" : "overlapping";
1077
+ if (headTailGap <= 0) {
1078
+ const headKeep = Math.max(0, tailStartIndex - startLine);
1079
+ if (headKeep === 0) return makeTailOnly();
1080
+ if (!partial && headKeep + ringLines.length >= windowLineCount) {
1081
+ const fullLines = headLines.slice(0, headKeep).concat(ringLines);
1082
+ return {
1083
+ kind: "full",
1084
+ head: linesSegment(fullLines, totalBytes, startLine),
1085
+ overlap,
1086
+ elidedLines: 0,
1087
+ elidedBytes: 0,
1088
+ totalLines: windowLineCount,
1089
+ totalBytes,
1090
+ };
1091
+ }
1092
+ const keptHeadLines = headLines.slice(0, headKeep);
1093
+ const keptHead = linesSegment(keptHeadLines, sourceBytes(keptHeadLines), startLine);
1094
+ if (!keptHead) return makeTailOnly();
1095
+ return attachReason(
1096
+ {
1097
+ kind: "middle",
1098
+ head: keptHead,
1099
+ tail,
1100
+ overlap,
1101
+ elidedLines: partial ? Math.max(0, tailStartIndex - (startLine + headKeep)) : 0,
1102
+ elidedBytes: partial
1103
+ ? Math.max(0, totalBytes - keptHead.bytes - partial.sourceLineBytes)
1104
+ : Math.max(0, totalBytes - keptHead.bytes - tail.bytes),
1105
+ totalLines: windowLineCount,
1106
+ totalBytes,
1107
+ },
1108
+ "middle",
1109
+ );
1110
+ }
1111
+
1112
+ const elidedLines = partial ? Math.max(0, tailStartIndex - headEndExclusive) : headTailGap;
1113
+ const partialSourceBytes = partial ? partial.sourceLineBytes : tail.bytes;
1114
+ return attachReason(
1115
+ {
1116
+ kind: "middle",
1117
+ ...(head ? { head } : {}),
1118
+ tail,
1119
+ overlap,
1120
+ elidedLines,
1121
+ elidedBytes: Math.max(0, totalBytes - headBytes - partialSourceBytes),
1122
+ totalLines: windowLineCount,
1123
+ totalBytes,
1124
+ },
1125
+ "middle",
1126
+ );
1127
+ }
1128
+
423
1129
  // Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization
424
1130
  const MAX_IMAGE_SIZE = MAX_IMAGE_INPUT_BYTES;
425
1131
  const GLOB_TIMEOUT_MS = 5000;
@@ -495,6 +1201,12 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
495
1201
  const readSchema = z
496
1202
  .object({
497
1203
  path: z.string().describe('path or url; append :<sel> for line ranges or raw mode (e.g. "src/foo.ts:50-100")'),
1204
+ truncation: z
1205
+ .enum(["head", "last", "both"])
1206
+ .optional()
1207
+ .describe(
1208
+ "which end of an over-budget result to keep: head | last | both. Route defaults are route-aware: read.truncation is consulted for bare local and archive-member routes (factory default: last), while URL, converted, directory, range, internal, and other routes default to head. SQLite row, schema, and query reads ignore this parameter; raw reads honor explicit directions.",
1209
+ ),
498
1210
  })
499
1211
  .strict();
500
1212
 
@@ -730,6 +1442,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
730
1442
  this.description = prompt.render(readDescription, {
731
1443
  DEFAULT_LIMIT: String(this.#defaultLimit),
732
1444
  DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
1445
+ TRUNCATION_DEFAULT: String(this.session.settings.get("read.truncation") ?? "last"),
1446
+ RECEIPT_LINES: String(this.session.settings.get("read.receiptBudgetLines")),
1447
+ RECEIPT_KIB: String(this.session.settings.get("read.receiptBudgetBytes")),
733
1448
  IS_HL_MODE: displayMode.hashLines,
734
1449
  IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
735
1450
  });
@@ -833,6 +1548,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
833
1548
  sourcePath?: string;
834
1549
  spillEligible?: boolean;
835
1550
  receiptPath?: string;
1551
+ bodyFooterPath?: string;
836
1552
  sourceUrl?: string;
837
1553
  sourceInternal?: string;
838
1554
  entityLabel: string;
@@ -841,19 +1557,21 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
841
1557
  raw?: boolean;
842
1558
  immutable?: boolean;
843
1559
  wrapUntrusted?: boolean;
1560
+ truncationDirection?: TruncationDirection;
1561
+ /** Truncation metadata stays relative to the selected body, not a protected prefix. */
1562
+ truncationMetadataBodyRelative?: boolean;
1563
+ /** Preserve this leading URL preamble while truncating only the selected body. */
1564
+ protectedPrefixChars?: number;
1565
+ cacheLinesFor?: string;
844
1566
  },
845
1567
  ): AgentToolResult<ReadToolDetails> {
846
1568
  const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw, immutable: options.immutable });
847
1569
  const details = options.details ?? {};
848
1570
  const allLines = text.split("\n");
849
1571
  const totalLines = allLines.length;
850
- // User-requested 0-indexed range start. Lines BEFORE this are leading
851
- // context (added below if offset is explicit).
852
1572
  const requestedStart = offset ? Math.max(0, offset - 1) : 0;
853
1573
  const ignoreResultLimits = options.ignoreResultLimits ?? false;
854
1574
  const requestedEnd = limit !== undefined ? Math.min(requestedStart + limit, allLines.length) : allLines.length;
855
- // Expand only on sides the user actually constrained: leading context
856
- // when offset>1, trailing context when a finite limit was set.
857
1575
  const expanded = expandRangeWithContext(
858
1576
  requestedStart,
859
1577
  requestedEnd,
@@ -862,19 +1580,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
862
1580
  limit !== undefined,
863
1581
  );
864
1582
  const startLine = expanded.startLine;
865
- const endLineExpanded = expanded.endLine;
1583
+ const endLine = expanded.endLine;
866
1584
  const startLineDisplay = startLine + 1;
867
1585
 
868
1586
  const resultBuilder = toolResult(details);
869
- if (options.sourcePath) {
870
- resultBuilder.sourcePath(options.sourcePath);
871
- }
872
- if (options.sourceUrl) {
873
- resultBuilder.sourceUrl(options.sourceUrl);
874
- }
875
- if (options.sourceInternal) {
876
- resultBuilder.sourceInternal(options.sourceInternal);
877
- }
1587
+ if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath);
1588
+ if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl);
1589
+ if (options.sourceInternal) resultBuilder.sourceInternal(options.sourceInternal);
878
1590
 
879
1591
  if (requestedStart >= allLines.length) {
880
1592
  const suggestion =
@@ -888,92 +1600,217 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
888
1600
  .done();
889
1601
  }
890
1602
 
891
- const endLine = endLineExpanded;
892
- let selectedContent = allLines.slice(startLine, endLine).join("\n");
893
-
894
- if (options.receiptPath) {
895
- const budgetLines = this.session.settings.get("read.receiptBudgetLines");
896
- const budgetBytes = this.session.settings.get("read.receiptBudgetBytes") * 1024;
897
- const receiptLines: string[] = [];
898
- let bytes = 0;
899
- for (const line of allLines) {
900
- const lineBytes = Buffer.byteLength(line, "utf-8") + (receiptLines.length > 0 ? 1 : 0);
901
- if (receiptLines.length >= budgetLines || bytes + lineBytes > budgetBytes) break;
902
- receiptLines.push(line);
903
- bytes += lineBytes;
904
- }
905
- selectedContent = receiptLines.join("\n");
906
- }
907
- const userLimitedLines = limit !== undefined ? endLine - startLine : undefined;
908
- const truncation = ignoreResultLimits
909
- ? noTruncResult(selectedContent)
910
- : truncateHead(
911
- selectedContent,
912
- options.resultByteCeiling === undefined
913
- ? undefined
914
- : { maxBytes: options.resultByteCeiling, maxLines: Number.MAX_SAFE_INTEGER },
1603
+ const direction = options.truncationDirection ?? "head";
1604
+ const receipt = options.receiptPath !== undefined;
1605
+ const exposeReceiptHeadMeta = receipt && options.cacheLinesFor !== undefined;
1606
+ const bodyFooterPath = options.receiptPath ?? options.bodyFooterPath;
1607
+ const preservePrefix =
1608
+ direction !== "head" && options.protectedPrefixChars !== undefined && options.protectedPrefixChars > 0;
1609
+ const protectedPrefix = preservePrefix ? text.slice(0, options.protectedPrefixChars) : "";
1610
+ const protectedPrefixLines = preservePrefix ? protectedPrefix.split("\n").length - 1 : 0;
1611
+ const rangeStart = preservePrefix ? Math.max(startLine, protectedPrefixLines) : startLine;
1612
+
1613
+ const rangeLines = allLines.slice(rangeStart, endLine);
1614
+ const rangeText = rangeLines.join("\n");
1615
+ const budgetLines = receipt
1616
+ ? this.session.settings.get("read.receiptBudgetLines")
1617
+ : options.resultByteCeiling === undefined
1618
+ ? DEFAULT_MAX_LINES
1619
+ : Number.MAX_SAFE_INTEGER;
1620
+ const budgetBytes = receipt
1621
+ ? this.session.settings.get("read.receiptBudgetBytes") * 1024
1622
+ : (options.resultByteCeiling ?? DEFAULT_MAX_BYTES);
1623
+ const relativeWindow = ignoreResultLimits
1624
+ ? fullReadWindow(rangeText, rangeLines.length, Buffer.byteLength(rangeText, "utf-8"))
1625
+ : selectReadWindow(
1626
+ receipt ? text : rangeText,
1627
+ receipt ? allLines : rangeLines,
1628
+ direction,
1629
+ budgetLines,
1630
+ budgetBytes,
915
1631
  );
916
-
1632
+ // A directional head window cannot render anything when its first source line
1633
+ // exceeds the byte budget. Fall back to the historical head truncator so ACP
1634
+ // still emits the same UTF-8 preview and metadata as the pre-direction code.
1635
+ const oversizedHeadWindow =
1636
+ !receipt &&
1637
+ direction === "head" &&
1638
+ options.resultByteCeiling === undefined &&
1639
+ relativeWindow.kind === "head-only" &&
1640
+ relativeWindow.head === undefined;
1641
+ const legacyHeadByteCeiling = options.resultByteCeiling ?? (oversizedHeadWindow ? budgetBytes : undefined);
1642
+ const window = rebaseWindow(relativeWindow, receipt ? 0 : rangeStart);
1643
+ // Keep explicit ranges in the selected-window coordinate system for truncation metadata.
1644
+ // Rendered output still uses `window`, whose anchors retain absolute file line numbers.
1645
+ const metadataWindow =
1646
+ options.truncationMetadataBodyRelative || (offset !== undefined && direction !== "head")
1647
+ ? relativeWindow
1648
+ : window;
1649
+ const metadataRangeBase =
1650
+ options.truncationMetadataBodyRelative || (offset !== undefined && direction !== "head")
1651
+ ? "window"
1652
+ : undefined;
917
1653
  const shouldAddHashLines = displayMode.hashLines;
918
- const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
919
- const formatText = (content: string, startNum: number): string => {
920
- details.displayContent = { text: content, startLine: startNum };
921
- return formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers);
1654
+ const shouldAddLineNumbers = !shouldAddHashLines && displayMode.lineNumbers;
1655
+ const maxColumns = options.raw ? 0 : resolveOutputMaxColumns(this.session.settings);
1656
+ const bodyRawWindow = renderReadWindow(window, false, false, maxColumns);
1657
+ const bodyRenderedWindow = renderReadWindow(window, shouldAddHashLines, shouldAddLineNumbers, maxColumns);
1658
+ const rawWindow = preservePrefix ? `${protectedPrefix}${bodyRawWindow}` : bodyRawWindow;
1659
+ const renderedWindow = preservePrefix ? `${protectedPrefix}${bodyRenderedWindow}` : bodyRenderedWindow;
1660
+ const partialTail = partialTailOf(window);
1661
+ const partialTailWarning = partialTail !== undefined && shouldAddHashLines && bodyFooterPath !== undefined;
1662
+ if (partialTailWarning) {
1663
+ Object.defineProperty(metadataWindow, "outputLinesOverride", { value: 0, enumerable: false });
1664
+ Object.defineProperty(metadataWindow, "outputBytesOverride", { value: 0, enumerable: false });
1665
+ }
1666
+ const selectedContent = windowSegments(window)
1667
+ .map(segment => segment.content)
1668
+ .join("\n");
1669
+ details.displayContent = {
1670
+ text: rawWindow,
1671
+ startLine: preservePrefix
1672
+ ? 1
1673
+ : (window.head?.origin.startLine ?? window.tail?.origin.startLine ?? startLineDisplay),
922
1674
  };
923
1675
 
924
- let outputText: string;
925
- let truncationInfo:
926
- | { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
927
- | undefined;
928
-
929
- if (truncation.firstLineExceedsLimit) {
930
- const firstLine = allLines[startLine] ?? "";
931
- const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
932
- const snippet = truncateHeadBytes(firstLine, DEFAULT_MAX_BYTES);
933
-
934
- if (shouldAddHashLines) {
935
- outputText = `[Line ${startLineDisplay} is ${formatBytes(
936
- firstLineBytes,
937
- )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
1676
+ let usedLegacyHeadTruncation = false;
1677
+ let outputText = partialTailWarning
1678
+ ? formatOversizedLineWarning(
1679
+ bodyFooterPath!,
1680
+ partialTail!.origin.startLine,
1681
+ partialTail!.sourceLineBytes,
1682
+ budgetBytes,
1683
+ totalLines,
1684
+ )
1685
+ : renderedWindow;
1686
+ let truncationResult: TruncationResult | undefined;
1687
+ if (window.kind !== "full" && (!receipt || direction !== "head" || exposeReceiptHeadMeta)) {
1688
+ const windowResult = makeWindowResult(metadataWindow, selectedContent);
1689
+ const baseTruncation =
1690
+ !receipt && direction === "head"
1691
+ ? {
1692
+ ...windowResult,
1693
+ truncated: true,
1694
+ }
1695
+ : windowResult;
1696
+ truncationResult = partialTailWarning
1697
+ ? { ...baseTruncation, outputLines: 0, outputBytes: 0, lastLineExceedsLimit: true }
1698
+ : baseTruncation;
1699
+ details.truncation = truncationResult;
1700
+ }
1701
+
1702
+ // Preserve the historical head byte-cap oversized-line behavior. The
1703
+ // directional window model is used for tail/both; head remains the old
1704
+ // oracle so existing hashline and snippet contracts stay byte-identical.
1705
+ if (!receipt && direction === "head" && legacyHeadByteCeiling !== undefined) {
1706
+ const legacy = truncateHead(rangeText, {
1707
+ maxBytes: legacyHeadByteCeiling,
1708
+ maxLines: Number.MAX_SAFE_INTEGER,
1709
+ });
1710
+ truncationResult = legacy;
1711
+ usedLegacyHeadTruncation = legacy.truncated === true;
1712
+ details.truncation = legacy.truncated ? legacy : undefined;
1713
+ if (legacy.firstLineExceedsLimit) {
1714
+ const firstLine = allLines[startLine] ?? "";
1715
+ const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
1716
+ const snippet = truncateHeadBytes(firstLine, legacyHeadByteCeiling);
1717
+ if (shouldAddHashLines) {
1718
+ outputText = `[Line ${startLineDisplay} is ${formatBytes(firstLineBytes)}, exceeds ${formatBytes(legacyHeadByteCeiling)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
1719
+ } else {
1720
+ outputText = formatTextWithMode(snippet.text, startLineDisplay, false, shouldAddLineNumbers);
1721
+ }
1722
+ if (snippet.text.length === 0) {
1723
+ outputText = `[Line ${startLineDisplay} is ${formatBytes(firstLineBytes)}, exceeds ${formatBytes(legacyHeadByteCeiling)} limit. Unable to display a valid UTF-8 snippet.]`;
1724
+ }
1725
+ details.displayContent = { text: snippet.text, startLine: startLineDisplay };
1726
+ } else if (legacy.truncated) {
1727
+ outputText = formatTextWithMode(legacy.content, startLineDisplay, shouldAddHashLines, shouldAddLineNumbers);
938
1728
  } else {
939
- outputText = formatText(snippet.text, startLineDisplay);
1729
+ outputText = formatTextWithMode(rangeText, startLineDisplay, shouldAddHashLines, shouldAddLineNumbers);
940
1730
  }
1731
+ }
941
1732
 
942
- if (snippet.text.length === 0) {
943
- outputText = `[Line ${startLineDisplay} is ${formatBytes(
944
- firstLineBytes,
945
- )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`;
1733
+ const userLimitedLines = limit !== undefined ? endLine - startLine : undefined;
1734
+ if (bodyFooterPath !== undefined && window.kind !== "full" && !partialTailWarning) {
1735
+ // A head window only owns a body footer on the historical receipt paths.
1736
+ // Archive members and internal URLs reach here through `bodyFooterPath`
1737
+ // alone; adding a head footer there would emit a second notice beside the
1738
+ // wrapper's, changing output that must stay byte-identical when the
1739
+ // direction is rolled back to head.
1740
+ const footer =
1741
+ direction === "head"
1742
+ ? receipt
1743
+ ? formatReceiptFooter(
1744
+ bodyFooterPath,
1745
+ metadataWindow.head?.lines ?? 0,
1746
+ metadataWindow.totalLines,
1747
+ windowSegments(metadataWindow).reduce((sum, segment) => sum + segment.bytes, 0),
1748
+ )
1749
+ : undefined
1750
+ : formatDirectionalFooter(bodyFooterPath, metadataWindow);
1751
+ if (footer) {
1752
+ outputText += `\n\n${footer}`;
1753
+ if (direction !== "head") rememberBodyTruncationFooter(details, footer);
946
1754
  }
947
-
948
- details.truncation = truncation;
949
- truncationInfo = {
950
- result: truncation,
951
- options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines },
952
- };
953
- } else if (truncation.truncated) {
954
- outputText = formatText(truncation.content, startLineDisplay);
955
- details.truncation = truncation;
956
- truncationInfo = {
957
- result: truncation,
958
- options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines },
959
- };
960
- } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
1755
+ } else if (
1756
+ !receipt &&
1757
+ window.kind === "full" &&
1758
+ userLimitedLines !== undefined &&
1759
+ startLine + userLimitedLines < allLines.length
1760
+ ) {
961
1761
  const remaining = allLines.length - (startLine + userLimitedLines);
962
1762
  const nextOffset = startLine + userLimitedLines + 1;
963
-
964
- outputText = formatText(selectedContent, startLineDisplay);
965
1763
  outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`;
966
- } else {
967
- outputText = formatText(truncation.content, startLineDisplay);
968
- }
969
- if (options.receiptPath && (truncation.truncated || countTextLines(selectedContent) < totalLines)) {
970
- outputText += `\n\n${formatReceiptFooter(options.receiptPath, countTextLines(selectedContent), totalLines, Buffer.byteLength(selectedContent, "utf-8"))}`;
971
1764
  }
972
1765
 
973
- details.spillEligible = options.spillEligible === true && selectedContent.length > 0;
1766
+ details.spillEligible =
1767
+ options.spillEligible === true && selectedContent.length > 0 && !windowHasPartialSegment(window);
1768
+ if (options.cacheLinesFor && !windowHasPartialSegment(window)) {
1769
+ for (const segment of windowSegments(window)) {
1770
+ if (segment.kind === "lines" && segment.lines > 0) {
1771
+ getFileReadCache(this.session).recordContiguous(
1772
+ options.cacheLinesFor,
1773
+ segment.origin.startLine,
1774
+ linesOfSegment(segment),
1775
+ );
1776
+ }
1777
+ }
1778
+ }
974
1779
  resultBuilder.text(options.wrapUntrusted ? wrapUntrustedContent(outputText) : outputText);
975
- if (truncationInfo) {
976
- resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
1780
+ if (window.kind !== "full" && (!receipt || direction !== "head" || exposeReceiptHeadMeta)) {
1781
+ const receiptHead = receipt && direction === "head";
1782
+ if (receiptHead && truncationResult?.truncated) {
1783
+ const diskLike = {
1784
+ ...truncationResult,
1785
+ totalBytes: Buffer.byteLength(selectedContent, "utf-8"),
1786
+ };
1787
+ resultBuilder.truncation(diskLike, {
1788
+ direction: "head",
1789
+ startLine: startLineDisplay,
1790
+ totalFileLines: totalLines,
1791
+ });
1792
+ } else if (usedLegacyHeadTruncation && truncationResult?.truncated) {
1793
+ resultBuilder.truncation(truncationResult, {
1794
+ direction: "head",
1795
+ startLine: startLineDisplay,
1796
+ totalFileLines: totalLines,
1797
+ });
1798
+ } else {
1799
+ // Only a directional window moves notice ownership into the body: its
1800
+ // footer names the kept range and the re-read path. A head window keeps
1801
+ // the wrapper notice, which carries the `:N to continue` hint instead.
1802
+ resultBuilder.truncationWindows(metadataWindow, {
1803
+ noticeOwner: bodyFooterPath !== undefined && direction !== "head" ? "body" : undefined,
1804
+ maxBytes: direction === "head" ? undefined : budgetBytes,
1805
+ rangeBase: metadataRangeBase,
1806
+ });
1807
+ }
1808
+ } else if (truncationResult?.truncated) {
1809
+ resultBuilder.truncation(truncationResult, {
1810
+ direction: "head",
1811
+ startLine: startLineDisplay,
1812
+ totalFileLines: totalLines,
1813
+ });
977
1814
  }
978
1815
  return resultBuilder.done();
979
1816
  }
@@ -996,6 +1833,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
996
1833
  entityLabel: string;
997
1834
  raw?: boolean;
998
1835
  immutable?: boolean;
1836
+ truncationDirection?: TruncationDirection;
1837
+ cacheLinesFor?: string;
999
1838
  },
1000
1839
  ): AgentToolResult<ReadToolDetails> {
1001
1840
  const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw, immutable: options.immutable });
@@ -1004,13 +1843,54 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1004
1843
  const totalLines = allLines.length;
1005
1844
  const shouldAddHashLines = displayMode.hashLines;
1006
1845
  const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
1846
+ const maxColumns = resolveOutputMaxColumns(this.session.settings);
1847
+ const direction = options.truncationDirection ?? "head";
1848
+ let columnTruncated = 0;
1007
1849
 
1008
1850
  const resultBuilder = toolResult(details);
1009
1851
  if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath);
1010
1852
  if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl);
1011
1853
  if (options.sourceInternal) resultBuilder.sourceInternal(options.sourceInternal);
1012
1854
 
1855
+ const renderBlockWindow = (
1856
+ window: ReadWindow,
1857
+ ): { text: string; partialTail?: Extract<ReadSegment, { kind: "partial-line" }> } => {
1858
+ const renderSegment = (segment: ReadSegment): string => {
1859
+ const hash = shouldAddHashLines && segment.kind === "lines";
1860
+ // A partial line is a byte suffix, not a complete source line. Never
1861
+ // give it an anchor that could make the preview look fully readable.
1862
+ const numbers = !hash && shouldAddLineNumbers && segment.kind === "lines";
1863
+ const renderContent =
1864
+ !options.raw && !hash && maxColumns > 0
1865
+ ? segment.content
1866
+ .split("\n")
1867
+ .map(line => {
1868
+ const result = truncateLine(line, maxColumns);
1869
+ if (result.wasTruncated) columnTruncated = maxColumns;
1870
+ return result.text;
1871
+ })
1872
+ .join("\n")
1873
+ : segment.content;
1874
+ return formatTextWithMode(renderContent, segment.origin.startLine, hash, numbers);
1875
+ };
1876
+ let text = "";
1877
+ if (window.kind === "full") text = window.head ? renderSegment(window.head) : "";
1878
+ else if (window.kind === "head-only") text = window.head ? renderSegment(window.head) : "";
1879
+ else if (window.kind === "tail-only") text = window.tail ? renderSegment(window.tail) : "";
1880
+ else {
1881
+ const marker = formatMiddleElisionMarker(window.elidedLines, window.elidedBytes);
1882
+ if (window.head && window.tail) {
1883
+ text =
1884
+ window.elidedLines <= 0
1885
+ ? `${renderSegment(window.head)}\n${renderSegment(window.tail)}`
1886
+ : `${renderSegment(window.head)}\n${marker}\n${renderSegment(window.tail)}`;
1887
+ }
1888
+ }
1889
+ return { text, partialTail: partialTailOf(window) };
1890
+ };
1891
+
1013
1892
  const parts: string[] = [];
1893
+ let hasPartialSegment = false;
1014
1894
  const outOfBounds: LineRange[] = [];
1015
1895
  for (const range of ranges) {
1016
1896
  if (range.startLine > totalLines) {
@@ -1018,8 +1898,41 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1018
1898
  continue;
1019
1899
  }
1020
1900
  const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines);
1021
- const sliced = allLines.slice(range.startLine - 1, effectiveEnd).join("\n");
1022
- parts.push(formatTextWithMode(sliced, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1901
+ const blockLines = allLines.slice(range.startLine - 1, effectiveEnd);
1902
+ const blockText = blockLines.join("\n");
1903
+ const requestedLength = range.endLine !== undefined ? range.endLine - range.startLine + 1 : this.#defaultLimit;
1904
+ const blockMaxLines = Math.min(requestedLength, DEFAULT_MAX_LINES);
1905
+ const blockMaxBytes = Math.max(DEFAULT_MAX_BYTES, blockMaxLines * 512);
1906
+ const relative = selectReadWindow(blockText, blockLines, direction, blockMaxLines, blockMaxBytes);
1907
+ const window = rebaseWindow(relative, range.startLine - 1);
1908
+ hasPartialSegment ||= windowHasPartialSegment(window);
1909
+ const rendered = renderBlockWindow(window);
1910
+ let blockOutput = rendered.text;
1911
+ if (rendered.partialTail) {
1912
+ const disclosurePath =
1913
+ options.sourceInternal ?? options.sourceUrl ?? options.sourcePath ?? options.entityLabel;
1914
+ blockOutput = shouldAddHashLines
1915
+ ? formatOversizedLineWarning(
1916
+ disclosurePath,
1917
+ rendered.partialTail.origin.startLine,
1918
+ rendered.partialTail.sourceLineBytes,
1919
+ blockMaxBytes,
1920
+ totalLines,
1921
+ )
1922
+ : `${blockOutput}\n\n${formatDirectionalFooter(disclosurePath, window)}`;
1923
+ }
1924
+ parts.push(blockOutput);
1925
+ if (options.cacheLinesFor && !windowHasPartialSegment(window)) {
1926
+ for (const segment of windowSegments(window)) {
1927
+ if (segment.kind === "lines" && segment.lines > 0) {
1928
+ getFileReadCache(this.session).recordContiguous(
1929
+ options.cacheLinesFor,
1930
+ segment.origin.startLine,
1931
+ linesOfSegment(segment),
1932
+ );
1933
+ }
1934
+ }
1935
+ }
1023
1936
  }
1024
1937
 
1025
1938
  const outputText = parts.length > 0 ? parts.join("\n\n…\n\n") : "";
@@ -1030,9 +1943,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1030
1943
  }
1031
1944
  const finalText =
1032
1945
  notices.length > 0 ? (outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n")) : outputText;
1033
- details.spillEligible = parts.length > 0;
1034
-
1946
+ details.spillEligible = parts.length > 0 && !hasPartialSegment;
1035
1947
  resultBuilder.text(finalText);
1948
+ if (columnTruncated > 0) resultBuilder.limits({ columnMax: columnTruncated });
1036
1949
  return resultBuilder.done();
1037
1950
  }
1038
1951
 
@@ -1049,6 +1962,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1049
1962
  displayMode: { hashLines: boolean; lineNumbers: boolean },
1050
1963
  suffixResolution: { from: string; to: string } | undefined,
1051
1964
  signal: AbortSignal | undefined,
1965
+ direction: TruncationDirection,
1052
1966
  ): Promise<{
1053
1967
  outputText: string;
1054
1968
  contentLineCount: number;
@@ -1067,6 +1981,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1067
1981
  sourcePath: absolutePath,
1068
1982
  entityLabel: "file",
1069
1983
  raw: rawSelector,
1984
+ truncationDirection: direction,
1985
+ cacheLinesFor: absolutePath,
1070
1986
  });
1071
1987
  if (suffixResolution) {
1072
1988
  const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
@@ -1079,6 +1995,27 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1079
1995
  }
1080
1996
  }
1081
1997
 
1998
+ // The legacy stream collector remains the head/default implementation.
1999
+ // Directional multi-range reads use the same materialized window oracle as
2000
+ // ACP so each block has identical bytes, anchors, and cache entries.
2001
+ if (direction !== "head") {
2002
+ const diskText = await Bun.file(absolutePath).text();
2003
+ const result = this.#buildInMemoryMultiRangeResult(diskText, ranges, {
2004
+ details: { resolvedPath: absolutePath, suffixResolution },
2005
+ sourcePath: absolutePath,
2006
+ entityLabel: "file",
2007
+ raw: rawSelector,
2008
+ truncationDirection: direction,
2009
+ cacheLinesFor: absolutePath,
2010
+ });
2011
+ const firstText = result.content.find((content): content is TextContent => content.type === "text");
2012
+ if (suffixResolution && firstText) {
2013
+ const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
2014
+ firstText.text = `${notice}\n${firstText.text}`;
2015
+ }
2016
+ return { outputText: "", contentLineCount: 0, columnTruncated: 0, bridgeResult: result };
2017
+ }
2018
+
1082
2019
  const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1083
2020
  const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1084
2021
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
@@ -1111,6 +2048,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1111
2048
  }
1112
2049
 
1113
2050
  const collectedLines = streamResult.lines;
2051
+ const cacheLines = collectedLines.slice();
1114
2052
  if (!rawSelector && !shouldAddHashLines && maxColumns > 0) {
1115
2053
  for (let i = 0; i < collectedLines.length; i++) {
1116
2054
  const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
@@ -1122,8 +2060,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1122
2060
  }
1123
2061
 
1124
2062
  contentLineCount += collectedLines.length;
1125
- if (collectedLines.length > 0) {
1126
- getFileReadCache(this.session).recordContiguous(absolutePath, range.startLine, collectedLines);
2063
+ if (cacheLines.length > 0) {
2064
+ getFileReadCache(this.session).recordContiguous(absolutePath, range.startLine, cacheLines);
1127
2065
  }
1128
2066
 
1129
2067
  const blockText = collectedLines.join("\n");
@@ -1143,6 +2081,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1143
2081
  subPath: string,
1144
2082
  limit: number | undefined,
1145
2083
  details: ReadToolDetails,
2084
+ direction: TruncationDirection,
1146
2085
  signal?: AbortSignal,
1147
2086
  ): Promise<AgentToolResult<ReadToolDetails>> {
1148
2087
  const DEFAULT_LIMIT = 500;
@@ -1166,14 +2105,21 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1166
2105
  }
1167
2106
 
1168
2107
  const output = results.length > 0 ? results.join("\n") : "(empty archive directory)";
1169
- const text = prependSuffixResolutionNotice(output, details.suffixResolution);
1170
- const truncation = truncateHead(text, { maxLines: Number.MAX_SAFE_INTEGER });
1171
- const directoryDetails: ReadToolDetails = { ...details, isDirectory: true };
1172
- const resultBuilder = toolResult<ReadToolDetails>(directoryDetails).text(truncation.content);
2108
+ const window = selectReadWindow(output, results, direction, Number.MAX_SAFE_INTEGER, DEFAULT_MAX_BYTES);
2109
+ const rendered = prependSuffixResolutionNotice(renderReadWindow(window, false, false), details.suffixResolution);
2110
+ const directoryDetails: ReadToolDetails = {
2111
+ ...details,
2112
+ isDirectory: true,
2113
+ ...(window.kind !== "full" ? { truncation: makeWindowResult(window, output) } : {}),
2114
+ };
2115
+ const resultBuilder = toolResult<ReadToolDetails>(directoryDetails).text(rendered);
1173
2116
  resultBuilder.sourcePath(archivePath).limits({ resultLimit: limitMeta.resultLimit?.reached });
1174
- if (truncation.truncated) {
1175
- directoryDetails.truncation = truncation;
1176
- resultBuilder.truncation(truncation, { direction: "head" });
2117
+ if (window.kind !== "full") {
2118
+ if (direction === "head") {
2119
+ resultBuilder.truncation(makeWindowResult(window, output), { direction: "head" });
2120
+ } else {
2121
+ resultBuilder.truncationWindows(window, { maxBytes: DEFAULT_MAX_BYTES });
2122
+ }
1177
2123
  }
1178
2124
  return resultBuilder.done();
1179
2125
  }
@@ -1183,6 +2129,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1183
2129
  parsedSel: ParsedSelector,
1184
2130
  resolvedArchivePath: ResolvedArchiveReadPath,
1185
2131
  signal?: AbortSignal,
2132
+ truncationParam?: TruncationDirection,
1186
2133
  ): Promise<AgentToolResult<ReadToolDetails>> {
1187
2134
  throwIfAborted(signal);
1188
2135
  const archive = await openArchive(resolvedArchivePath.absolutePath);
@@ -1197,6 +2144,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1197
2144
  if (!node) {
1198
2145
  throw new ToolError(`Path '${readPath}' not found inside archive`);
1199
2146
  }
2147
+ const route: ReadRoute = node.isDirectory
2148
+ ? "dir-archive"
2149
+ : isMultiRange(parsedSel)
2150
+ ? "local-multi-range"
2151
+ : parsedSel.kind === "none"
2152
+ ? "archive-member-bare"
2153
+ : "local-range";
2154
+ const direction = resolveEffectiveDirection(truncationParam, route, this.session.settings);
1200
2155
 
1201
2156
  if (node.isDirectory) {
1202
2157
  if (isMultiRange(parsedSel)) {
@@ -1209,12 +2164,16 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1209
2164
  resolvedArchivePath.archiveSubPath,
1210
2165
  limit,
1211
2166
  details,
2167
+ direction,
1212
2168
  signal,
1213
2169
  );
1214
2170
  }
1215
2171
 
1216
2172
  const entry = await archive.readFile(resolvedArchivePath.archiveSubPath);
1217
2173
  const text = decodeUtf8Text(entry.bytes);
2174
+ if (text === null && truncationParam !== undefined && truncationParam !== "head") {
2175
+ throw new ToolError("Explicit truncation for binary archive entries is not yet supported.");
2176
+ }
1218
2177
  if (text === null) {
1219
2178
  return toolResult<ReadToolDetails>(details)
1220
2179
  .text(
@@ -1235,6 +2194,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1235
2194
  sourcePath: resolvedArchivePath.absolutePath,
1236
2195
  entityLabel: "archive entry",
1237
2196
  raw,
2197
+ truncationDirection: direction,
1238
2198
  })
1239
2199
  : this.#buildInMemoryTextResult(
1240
2200
  text,
@@ -1245,6 +2205,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1245
2205
  sourcePath: resolvedArchivePath.absolutePath,
1246
2206
  entityLabel: "archive entry",
1247
2207
  raw,
2208
+ truncationDirection: direction,
2209
+ bodyFooterPath: parsedSel.kind === "none" ? readPath : undefined,
1248
2210
  },
1249
2211
  );
1250
2212
  const firstText = result.content.find((content): content is TextContent => content.type === "text");
@@ -1257,6 +2219,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1257
2219
  async #readSqlite(
1258
2220
  resolvedSqlitePath: ResolvedSqliteReadPath,
1259
2221
  signal?: AbortSignal,
2222
+ truncationParam?: TruncationDirection,
1260
2223
  ): Promise<AgentToolResult<ReadToolDetails>> {
1261
2224
  throwIfAborted(signal);
1262
2225
 
@@ -1269,6 +2232,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1269
2232
  resolvedPath: resolvedSqlitePath.absolutePath,
1270
2233
  suffixResolution: resolvedSqlitePath.suffixResolution,
1271
2234
  };
2235
+ const sqliteRoute: ReadRoute = selector.kind === "list" ? "sqlite-list" : "sqlite-rows";
2236
+ const direction = resolveEffectiveDirection(truncationParam, sqliteRoute, this.session.settings);
2237
+ // SQLite row/schema/query/raw selectors are row-selection routes: queryRows and
2238
+ // executeReadQuery apply their SQL row limits, while renderRow/renderTable only
2239
+ // cap individual cell width. No rendered-output truncator runs on these paths.
2240
+ // Keep explicit direction as a deliberate no-op there; sqlite-list below
2241
+ // truncates only its rendered table listing.
1272
2242
 
1273
2243
  let db: Database | null = null;
1274
2244
  try {
@@ -1280,18 +2250,29 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1280
2250
  switch (selector.kind) {
1281
2251
  case "list": {
1282
2252
  const listLimit = applyListLimit(listTables(db), { limit: 500 });
1283
- const output = prependSuffixResolutionNotice(
1284
- renderTableList(listLimit.items),
2253
+ const body = renderTableList(listLimit.items);
2254
+ const window = selectReadWindow(
2255
+ body,
2256
+ body.split("\n"),
2257
+ direction,
2258
+ Number.MAX_SAFE_INTEGER,
2259
+ DEFAULT_MAX_BYTES,
2260
+ );
2261
+ const rendered = prependSuffixResolutionNotice(
2262
+ renderReadWindow(window, false, false),
1285
2263
  resolvedSqlitePath.suffixResolution,
1286
2264
  );
1287
- const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
1288
- details.truncation = truncation.truncated ? truncation : undefined;
2265
+ details.truncation = window.kind !== "full" ? makeWindowResult(window, body) : undefined;
1289
2266
  const resultBuilder = toolResult<ReadToolDetails>(details)
1290
- .text(truncation.content)
2267
+ .text(rendered)
1291
2268
  .sourcePath(resolvedSqlitePath.absolutePath)
1292
2269
  .limits({ resultLimit: listLimit.meta.resultLimit?.reached });
1293
- if (truncation.truncated) {
1294
- resultBuilder.truncation(truncation, { direction: "head" });
2270
+ if (window.kind !== "full") {
2271
+ if (direction === "head") {
2272
+ resultBuilder.truncation(makeWindowResult(window, body), { direction: "head" });
2273
+ } else {
2274
+ resultBuilder.truncationWindows(window, { maxBytes: DEFAULT_MAX_BYTES });
2275
+ }
1295
2276
  }
1296
2277
  return resultBuilder.done();
1297
2278
  }
@@ -1553,6 +2534,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1553
2534
  "Reading `conflict://*` is not supported — wildcards are write-only. Use the `<path>:conflicts` read selector for the full list of conflicts in a file, or read `conflict://<N>` to inspect a single block.",
1554
2535
  );
1555
2536
  }
2537
+ if (params.truncation !== undefined && params.truncation !== "head") {
2538
+ throw new ToolError("Explicit truncation for conflict:// regions is not yet supported.");
2539
+ }
1556
2540
  return this.#readConflictRegion(conflictUri.id, conflictUri.scope);
1557
2541
  }
1558
2542
  const displayMode = resolveFileDisplayMode(this.session);
@@ -1572,15 +2556,36 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1572
2556
  preferCached: true,
1573
2557
  },
1574
2558
  );
1575
- return this.#buildInMemoryTextResult(cached.output, parsedUrlTarget.offset, parsedUrlTarget.limit, {
2559
+ const requestedDirection = resolveEffectiveDirection(
2560
+ params.truncation,
2561
+ "url-cache-page",
2562
+ this.session.settings,
2563
+ );
2564
+ const direction =
2565
+ requestedDirection !== "head" && cached.wrappedPreambleChars === undefined ? "head" : requestedDirection;
2566
+ const selectorInput = prepareReadUrlSelectorInput(
2567
+ cached.output,
2568
+ cached.preambleChars,
2569
+ cached.wrappedPreambleChars,
2570
+ );
2571
+
2572
+ return this.#buildInMemoryTextResult(selectorInput.text, parsedUrlTarget.offset, parsedUrlTarget.limit, {
1576
2573
  details: { ...cached.details },
1577
2574
  sourceUrl: cached.details.finalUrl,
1578
2575
  entityLabel: "URL output",
1579
2576
  immutable: true,
1580
2577
  wrapUntrusted: true,
2578
+ truncationDirection: direction,
2579
+ protectedPrefixChars: selectorInput.preambleChars,
2580
+ truncationMetadataBodyRelative: true,
1581
2581
  });
1582
2582
  }
1583
- return executeReadUrl(this.session, { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw }, signal);
2583
+ return executeReadUrl(
2584
+ this.session,
2585
+ { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw },
2586
+ signal,
2587
+ resolveEffectiveDirection(params.truncation, "url-reader", this.session.settings),
2588
+ );
1584
2589
  }
1585
2590
 
1586
2591
  // Handle internal URLs (agent://, artifact://, memory://, rule://, local://, gjc://, issue://, pr://).
@@ -1595,7 +2600,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1595
2600
  if (internalTarget.sel !== undefined && parsed.kind === "none") {
1596
2601
  throw new ToolError(`Invalid internal URL selector "${internalTarget.sel}".`);
1597
2602
  }
1598
- return this.#handleInternalUrl(internalTarget.path, parsed, signal);
2603
+ return this.#handleInternalUrl(internalTarget.path, parsed, signal, params.truncation);
1599
2604
  }
1600
2605
 
1601
2606
  const archivePath = await this.#resolveArchiveReadPath(readPath, signal);
@@ -1607,12 +2612,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1607
2612
  archiveParsed,
1608
2613
  { ...archivePath, archiveSubPath: archiveSubPath.path },
1609
2614
  signal,
2615
+ params.truncation,
1610
2616
  );
1611
2617
  }
1612
2618
 
1613
2619
  const sqlitePath = await this.#resolveSqliteReadPath(readPath, signal);
1614
2620
  if (sqlitePath) {
1615
- return this.#readSqlite(sqlitePath, signal);
2621
+ return this.#readSqlite(sqlitePath, signal, params.truncation);
1616
2622
  }
1617
2623
 
1618
2624
  const localTarget = splitPathAndSel(readPath);
@@ -1658,7 +2664,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1658
2664
  if (isMultiRange(parsed)) {
1659
2665
  throw new ToolError("Multi-range line selectors are not supported for directory listings.");
1660
2666
  }
1661
- const dirResult = await this.#readDirectory(absolutePath, selToOffsetLimit(parsed).limit, signal);
2667
+ const dirDirection = resolveEffectiveDirection(params.truncation, "dir-local", this.session.settings);
2668
+ const dirResult = await this.#readDirectory(
2669
+ absolutePath,
2670
+ selToOffsetLimit(parsed).limit,
2671
+ dirDirection,
2672
+ signal,
2673
+ );
2674
+
1662
2675
  if (suffixResolution) {
1663
2676
  dirResult.details ??= {};
1664
2677
  dirResult.details.suffixResolution = suffixResolution;
@@ -1667,7 +2680,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1667
2680
  }
1668
2681
 
1669
2682
  if (parsed.kind === "conflicts") {
1670
- return this.#readFileConflicts(absolutePath, suffixResolution, signal);
2683
+ return this.#readFileConflicts(absolutePath, suffixResolution, signal, params.truncation);
1671
2684
  }
1672
2685
 
1673
2686
  const imageMetadata = await readImageMetadata(absolutePath);
@@ -1680,10 +2693,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1680
2693
  let sourcePath: string | undefined;
1681
2694
  let columnTruncated = 0;
1682
2695
  let truncationInfo:
1683
- | { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
2696
+ | {
2697
+ result: TruncationResult;
2698
+ options: {
2699
+ direction: "head";
2700
+ startLine?: number;
2701
+ totalFileLines?: number;
2702
+ noticeOwner?: "body";
2703
+ maxBytes?: number;
2704
+ };
2705
+ }
1684
2706
  | undefined;
2707
+ let directionalWindow: ReadWindow | undefined;
2708
+ let directionalNoticeOwner: "body" | undefined;
2709
+ let directionalRangeBase: "file" | "window" | undefined;
1685
2710
 
1686
2711
  if (mimeType) {
2712
+ if (params.truncation !== undefined && params.truncation !== "head") {
2713
+ throw new ToolError("Explicit truncation for image reads is not yet supported.");
2714
+ }
1687
2715
  if (fileSize > MAX_IMAGE_SIZE) {
1688
2716
  const sizeStr = formatBytes(fileSize);
1689
2717
  const maxStr = formatBytes(MAX_IMAGE_SIZE);
@@ -1715,11 +2743,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1715
2743
  }
1716
2744
  } else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) {
1717
2745
  const notebookText = await readEditableNotebookText(absolutePath, localReadPath);
2746
+ const direction = resolveEffectiveDirection(params.truncation, "converted", this.session.settings);
1718
2747
  if (isMultiRange(parsed) && parsed.kind === "lines") {
1719
2748
  return this.#buildInMemoryMultiRangeResult(notebookText, parsed.ranges, {
1720
2749
  details: { resolvedPath: absolutePath },
1721
2750
  sourcePath: absolutePath,
1722
2751
  entityLabel: "notebook",
2752
+ truncationDirection: direction,
1723
2753
  });
1724
2754
  }
1725
2755
  const { offset, limit } = selToOffsetLimit(parsed);
@@ -1736,10 +2766,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1736
2766
  )
1737
2767
  : undefined,
1738
2768
  receiptPath: parsed.kind === "none" ? localReadPath : undefined,
2769
+ truncationDirection: direction,
1739
2770
  });
1740
2771
  } else if (shouldConvertWithMarkit) {
1741
2772
  const result = await convertFileWithMarkit(absolutePath, signal);
1742
2773
  if (result.ok) {
2774
+ const direction = resolveEffectiveDirection(params.truncation, "converted", this.session.settings);
1743
2775
  const { offset, limit } = selToOffsetLimit(parsed);
1744
2776
  return this.#buildInMemoryTextResult(result.content, offset, limit, {
1745
2777
  details: { resolvedPath: absolutePath },
@@ -1755,6 +2787,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1755
2787
  )
1756
2788
  : undefined,
1757
2789
  receiptPath: parsed.kind === "none" ? localReadPath : undefined,
2790
+ truncationDirection: direction,
1758
2791
  });
1759
2792
  }
1760
2793
  content = [{ type: "text", text: `[Cannot read ${ext} file: ${result.error || "conversion failed"}]` }];
@@ -1766,6 +2799,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1766
2799
  (this.session.settings.get("read.summarize.prose") || !PROSE_SUMMARY_EXTENSIONS.has(ext))
1767
2800
  ) {
1768
2801
  const summary = await this.#trySummarize(absolutePath, fileSize, signal);
2802
+ if (params.truncation !== undefined && params.truncation !== "head" && summary?.parsed) {
2803
+ throw new ToolError(
2804
+ "Explicit truncation for structural summaries is not yet supported; summary direction will be applied after summary units become directional.",
2805
+ );
2806
+ }
1769
2807
  if (summary?.parsed && summary.elided) {
1770
2808
  const renderedSummary = this.#renderSummary(summary);
1771
2809
  const footer = formatSummaryElisionFooter(
@@ -1804,6 +2842,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1804
2842
 
1805
2843
  if (!content) {
1806
2844
  if (isMultiRange(parsed) && parsed.kind === "lines") {
2845
+ const multiDirection = resolveEffectiveDirection(
2846
+ params.truncation,
2847
+ "local-multi-range",
2848
+ this.session.settings,
2849
+ );
1807
2850
  const multiResult = await this.#readLocalFileMultiRange(
1808
2851
  absolutePath,
1809
2852
  parsed.ranges,
@@ -1811,6 +2854,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1811
2854
  displayMode,
1812
2855
  suffixResolution,
1813
2856
  signal,
2857
+ multiDirection,
1814
2858
  );
1815
2859
  if (multiResult.bridgeResult) return multiResult.bridgeResult;
1816
2860
  content = [{ type: "text", text: multiResult.outputText }];
@@ -1822,6 +2866,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1822
2866
  } else {
1823
2867
  // Raw text or line-range mode
1824
2868
  const { offset, limit } = selToOffsetLimit(parsed);
2869
+ const bareEligible = parsed.kind === "none";
2870
+ const localDirection = resolveEffectiveDirection(
2871
+ params.truncation,
2872
+ bareEligible ? "local-bare-stream" : isRawSelector(parsed) ? "local-raw" : "local-range",
2873
+ this.session.settings,
2874
+ );
1825
2875
  // Try ACP bridge first — editor's in-memory buffer is source of truth.
1826
2876
  // Request full text so local range rendering keeps normal context and line numbers.
1827
2877
  const bridgePromise = this.#routeReadThroughBridge(absolutePath);
@@ -1833,6 +2883,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1833
2883
  sourcePath: absolutePath,
1834
2884
  entityLabel: "file",
1835
2885
  raw: isRawSelector(parsed),
2886
+ cacheLinesFor: absolutePath,
2887
+ truncationDirection: localDirection,
1836
2888
  });
1837
2889
  if (suffixResolution) {
1838
2890
  const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
@@ -1874,6 +2926,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1874
2926
  : isBareReceipt
1875
2927
  ? receiptBudgetBytes
1876
2928
  : Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
2929
+ const collectMode = localDirection === "head" ? "head" : localDirection === "last" ? "tail" : "both";
2930
+ const collectEndExclusive =
2931
+ rawSelector || isBareReceipt ? null : startLine + effectiveLimit + leadingContext + trailingContext;
1877
2932
 
1878
2933
  const streamResult = await streamLinesFromFile(
1879
2934
  absolutePath,
@@ -1882,6 +2937,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1882
2937
  maxBytesForRead,
1883
2938
  selectedLineLimit,
1884
2939
  signal,
2940
+ collectMode,
2941
+ collectEndExclusive,
1885
2942
  );
1886
2943
 
1887
2944
  const {
@@ -1906,148 +2963,295 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1906
2963
  .done();
1907
2964
  }
1908
2965
 
1909
- // Per-line column cap. Skipped in raw mode so `:raw` always returns
1910
- // verbatim bytes for paste-back-into-tool workflows. Total byte/line
1911
- // counts in `truncation` keep reflecting the source, not the trimmed
1912
- // view — column truncation surfaces separately via `.limits()`.
1913
-
1914
- const shouldAddHashLines = !rawSelector && displayMode.hashLines;
1915
- const maxColumns = resolveOutputMaxColumns(this.session.settings);
1916
- if (!rawSelector && !shouldAddHashLines && maxColumns > 0) {
1917
- for (let i = 0; i < collectedLines.length; i++) {
1918
- const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
1919
- if (wasTruncated) {
1920
- collectedLines[i] = text;
1921
- columnTruncated = maxColumns;
2966
+ if (collectMode !== "head") {
2967
+ const window = streamResultWindow(streamResult, collectMode, startLine, collectEndExclusive);
2968
+ const metadataWindow = rebaseWindow(window, -startLine);
2969
+ directionalWindow = metadataWindow;
2970
+ directionalNoticeOwner = isBareReceipt ? "body" : undefined;
2971
+ directionalRangeBase = offset !== undefined && localDirection !== "head" ? "window" : undefined;
2972
+ const directionalPartial = windowHasPartialSegment(window);
2973
+ const shouldAddDirectionalHashLines = !rawSelector && displayMode.hashLines;
2974
+ const shouldAddDirectionalLineNumbers = rawSelector
2975
+ ? false
2976
+ : !shouldAddDirectionalHashLines && displayMode.lineNumbers;
2977
+ const directionalMaxColumns = rawSelector ? 0 : resolveOutputMaxColumns(this.session.settings);
2978
+ if (!rawSelector && !shouldAddDirectionalHashLines && directionalMaxColumns > 0) {
2979
+ for (const segment of windowSegments(window)) {
2980
+ if (
2981
+ segment.content
2982
+ .split("\n")
2983
+ .some(line => truncateLine(line, directionalMaxColumns).wasTruncated)
2984
+ ) {
2985
+ columnTruncated = directionalMaxColumns;
2986
+ break;
2987
+ }
1922
2988
  }
1923
2989
  }
1924
- }
1925
-
1926
- const selectedContent = collectedLines.join("\n");
1927
- const userLimitedLines = collectedLines.length;
1928
-
1929
- const totalSelectedLines = totalFileLines - startLine;
1930
- const totalSelectedBytes = collectedBytes;
1931
- const wasTruncated = collectedLines.length < totalSelectedLines || stoppedByByteLimit;
1932
- const firstLineExceedsLimit = firstLineByteLength !== undefined && firstLineByteLength > maxBytesForRead;
1933
-
1934
- const truncation: TruncationResult = {
1935
- content: selectedContent,
1936
- truncated: wasTruncated,
1937
- truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : undefined,
1938
- totalLines: totalSelectedLines,
1939
- totalBytes: totalSelectedBytes,
1940
- outputLines: collectedLines.length,
1941
- outputBytes: collectedBytes,
1942
- lastLinePartial: false,
1943
- firstLineExceedsLimit,
1944
- };
1945
-
1946
- if (collectedLines.length > 0 && !firstLineExceedsLimit) {
1947
- getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines);
1948
- }
1949
-
1950
- const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
1951
- let capturedDisplayContent: { text: string; startLine: number } | undefined;
1952
- const formatText = (text: string, startNum: number): string => {
1953
- capturedDisplayContent = { text, startLine: startNum };
1954
- return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
1955
- };
1956
-
1957
- let outputText: string;
1958
-
1959
- if (truncation.firstLineExceedsLimit) {
1960
- const firstLineBytes = firstLineByteLength ?? 0;
1961
- const snippet = firstLinePreview ?? { text: "", bytes: 0 };
1962
-
1963
- if (shouldAddHashLines) {
1964
- outputText = `[Line ${startLineDisplay} is ${formatBytes(
1965
- firstLineBytes,
1966
- )}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
1967
- } else {
1968
- outputText = formatText(snippet.text, startLineDisplay);
2990
+ const selectedContent = windowSegments(window)
2991
+ .map(segment => segment.content)
2992
+ .join("\n");
2993
+ const partialTail = partialTailOf(window);
2994
+ const partialTailWarning = partialTail !== undefined && shouldAddDirectionalHashLines;
2995
+ if (partialTailWarning) {
2996
+ Object.defineProperty(window, "outputLinesOverride", { value: 0, enumerable: false });
2997
+ Object.defineProperty(window, "outputBytesOverride", { value: 0, enumerable: false });
2998
+ Object.defineProperty(metadataWindow, "outputLinesOverride", { value: 0, enumerable: false });
2999
+ Object.defineProperty(metadataWindow, "outputBytesOverride", { value: 0, enumerable: false });
3000
+ }
3001
+ const baseWindowResult =
3002
+ window.kind === "full" ? undefined : makeWindowResult(metadataWindow, selectedContent);
3003
+ const windowResult =
3004
+ baseWindowResult && partialTailWarning
3005
+ ? { ...baseWindowResult, outputLines: 0, outputBytes: 0, lastLineExceedsLimit: true }
3006
+ : baseWindowResult;
3007
+ const rawWindow = renderReadWindow(window, false, false, directionalMaxColumns);
3008
+ let outputText = partialTailWarning
3009
+ ? formatOversizedLineWarning(
3010
+ localReadPath,
3011
+ partialTail.origin.startLine,
3012
+ partialTail.sourceLineBytes,
3013
+ maxBytesForRead,
3014
+ totalFileLines,
3015
+ )
3016
+ : renderReadWindow(
3017
+ window,
3018
+ shouldAddDirectionalHashLines,
3019
+ shouldAddDirectionalLineNumbers,
3020
+ directionalMaxColumns,
3021
+ );
3022
+ const windowEnd = Math.min(collectEndExclusive ?? totalFileLines, totalFileLines);
3023
+ let bodyFooter: string | undefined;
3024
+ if (isBareReceipt && window.kind !== "full" && !partialTailWarning) {
3025
+ const footer = formatDirectionalFooter(localReadPath, metadataWindow);
3026
+ if (footer) {
3027
+ outputText += `\n\n${footer}`;
3028
+ bodyFooter = footer;
3029
+ }
1969
3030
  }
1970
- if (snippet.text.length === 0) {
1971
- outputText = `[Line ${startLineDisplay} is ${formatBytes(
1972
- firstLineBytes,
1973
- )}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
3031
+ if (rawSelector && window.kind !== "full") {
3032
+ outputText += `\n\n${formatRawReadCapNotice(maxBytesForRead, fileSize, localDirection)}`;
3033
+ }
3034
+ if (
3035
+ !isBareReceipt &&
3036
+ window.kind === "full" &&
3037
+ limit !== undefined &&
3038
+ startLine < windowEnd &&
3039
+ windowEnd < totalFileLines
3040
+ ) {
3041
+ const remaining = totalFileLines - windowEnd;
3042
+ outputText += `\n\n[${remaining} more lines in file. Use :${windowEnd + 1} to continue]`;
3043
+ }
3044
+ details = {
3045
+ resolvedPath: absolutePath,
3046
+ suffixResolution,
3047
+ displayContent: {
3048
+ text: rawWindow,
3049
+ startLine: window.head?.origin.startLine ?? window.tail?.origin.startLine ?? startLineDisplay,
3050
+ },
3051
+ ...(windowResult ? { truncation: windowResult } : {}),
3052
+ spillEligible: !isBareReceipt && selectedContent.length > 0 && !directionalPartial,
3053
+ };
3054
+ if (bodyFooter) rememberBodyTruncationFooter(details, bodyFooter);
3055
+ if (!directionalPartial) {
3056
+ for (const segment of windowSegments(window)) {
3057
+ if (segment.kind === "lines" && segment.lines > 0) {
3058
+ getFileReadCache(this.session).recordContiguous(
3059
+ absolutePath,
3060
+ segment.origin.startLine,
3061
+ linesOfSegment(segment),
3062
+ );
3063
+ }
3064
+ }
3065
+ }
3066
+ if (!partialTailWarning) {
3067
+ const conflictBlocks = windowSegments(window).flatMap(segment =>
3068
+ segment.kind === "lines"
3069
+ ? scanConflictLines(linesOfSegment(segment), segment.origin.startLine)
3070
+ : [],
3071
+ );
3072
+ if (conflictBlocks.length > 0) {
3073
+ const history = getConflictHistory(this.session);
3074
+ const displayPathForWarning = formatPathRelativeToCwd(absolutePath, this.session.cwd);
3075
+ const entries = conflictBlocks.map(block =>
3076
+ history.register({
3077
+ absolutePath,
3078
+ displayPath: displayPathForWarning,
3079
+ ...block,
3080
+ }),
3081
+ );
3082
+ // Cheap full-file scan only when the window already showed
3083
+ // at least one conflict — otherwise pay nothing on clean files.
3084
+ let totalInFile = entries.length;
3085
+ let scanTruncated = false;
3086
+ try {
3087
+ const fileScan = await scanFileForConflicts(absolutePath);
3088
+ totalInFile = Math.max(entries.length, fileScan.blocks.length);
3089
+ scanTruncated = fileScan.scanTruncated;
3090
+ } catch {
3091
+ // Best-effort enrichment; fall back to window-only count.
3092
+ }
3093
+ outputText += formatConflictWarning(entries, {
3094
+ totalInFile,
3095
+ displayPath: displayPathForWarning,
3096
+ scanTruncated,
3097
+ });
3098
+ details.conflictCount = entries.length;
3099
+ }
1974
3100
  }
1975
- details = { truncation, spillEligible: !isBareReceipt && collectedLines.length > 0 };
1976
-
1977
3101
  sourcePath = absolutePath;
1978
- truncationInfo = {
1979
- result: truncation,
1980
- options: { direction: "head", startLine: startLineDisplay, totalFileLines },
3102
+ content = [{ type: "text", text: outputText }];
3103
+ } else {
3104
+ // Per-line column cap. Skipped in raw mode so `:raw` always returns
3105
+ // verbatim bytes for paste-back-into-tool workflows. Total byte/line
3106
+ // counts in `truncation` keep reflecting the source, not the trimmed
3107
+ // view — column truncation surfaces separately via `.limits()`.
3108
+
3109
+ const shouldAddHashLines = !rawSelector && displayMode.hashLines;
3110
+ const maxColumns = resolveOutputMaxColumns(this.session.settings);
3111
+ if (!rawSelector && !shouldAddHashLines && maxColumns > 0) {
3112
+ for (let i = 0; i < collectedLines.length; i++) {
3113
+ const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
3114
+ if (wasTruncated) {
3115
+ collectedLines[i] = text;
3116
+ columnTruncated = maxColumns;
3117
+ }
3118
+ }
3119
+ }
3120
+
3121
+ const selectedContent = collectedLines.join("\n");
3122
+ const userLimitedLines = collectedLines.length;
3123
+
3124
+ const totalSelectedLines = totalFileLines - startLine;
3125
+ const totalSelectedBytes = collectedBytes;
3126
+ const wasTruncated = collectedLines.length < totalSelectedLines || stoppedByByteLimit;
3127
+ const firstLineExceedsLimit =
3128
+ firstLineByteLength !== undefined && firstLineByteLength > maxBytesForRead;
3129
+
3130
+ const truncation: TruncationResult = {
3131
+ content: selectedContent,
3132
+ truncated: wasTruncated,
3133
+ truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : undefined,
3134
+ totalLines: totalSelectedLines,
3135
+ totalBytes: totalSelectedBytes,
3136
+ outputLines: collectedLines.length,
3137
+ outputBytes: collectedBytes,
3138
+ lastLinePartial: false,
3139
+ firstLineExceedsLimit,
1981
3140
  };
1982
- } else if (truncation.truncated) {
1983
- outputText = formatText(truncation.content, startLineDisplay);
1984
- details = { truncation, spillEligible: !isBareReceipt && collectedLines.length > 0 };
1985
3141
 
1986
- sourcePath = absolutePath;
1987
- truncationInfo = {
1988
- result: truncation,
1989
- options: { direction: "head", startLine: startLineDisplay, totalFileLines },
3142
+ if (collectedLines.length > 0 && !firstLineExceedsLimit) {
3143
+ getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines);
3144
+ }
3145
+
3146
+ const shouldAddLineNumbers = rawSelector
3147
+ ? false
3148
+ : shouldAddHashLines
3149
+ ? false
3150
+ : displayMode.lineNumbers;
3151
+ let capturedDisplayContent: { text: string; startLine: number } | undefined;
3152
+ const formatText = (text: string, startNum: number): string => {
3153
+ capturedDisplayContent = { text, startLine: startNum };
3154
+ return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
1990
3155
  };
1991
- } else if (startLine + userLimitedLines < totalFileLines) {
1992
- const remaining = totalFileLines - (startLine + userLimitedLines);
1993
- const nextOffset = startLine + userLimitedLines + 1;
1994
3156
 
1995
- outputText = formatText(truncation.content, startLineDisplay);
1996
- outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`;
1997
- details = { spillEligible: !isBareReceipt && collectedLines.length > 0 };
3157
+ let outputText: string;
1998
3158
 
1999
- sourcePath = absolutePath;
2000
- } else {
2001
- // No truncation, no user limit exceeded
2002
- outputText = formatText(truncation.content, startLineDisplay);
2003
- details = { spillEligible: !isBareReceipt && collectedLines.length > 0 };
3159
+ if (truncation.firstLineExceedsLimit) {
3160
+ const firstLineBytes = firstLineByteLength ?? 0;
3161
+ const snippet = firstLinePreview ?? { text: "", bytes: 0 };
2004
3162
 
2005
- sourcePath = absolutePath;
2006
- }
2007
- if (isBareReceipt && (truncation.truncated || collectedLines.length < totalFileLines)) {
2008
- outputText += `\n\n${formatReceiptFooter(localReadPath, collectedLines.length, totalFileLines, collectedBytes)}`;
2009
- }
2010
- if (rawSelector && truncation.truncated) {
2011
- outputText += `\n\n[Raw read capped at ${formatBytes(maxBytesForRead)} of ${formatBytes(fileSize)}; re-read a line range to narrow the view]`;
2012
- }
3163
+ if (shouldAddHashLines) {
3164
+ outputText = `[Line ${startLineDisplay} is ${formatBytes(
3165
+ firstLineBytes,
3166
+ )}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
3167
+ } else {
3168
+ outputText = formatText(snippet.text, startLineDisplay);
3169
+ }
3170
+ if (snippet.text.length === 0) {
3171
+ outputText = `[Line ${startLineDisplay} is ${formatBytes(
3172
+ firstLineBytes,
3173
+ )}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
3174
+ }
3175
+ details = { truncation, spillEligible: !isBareReceipt && collectedLines.length > 0 };
3176
+
3177
+ sourcePath = absolutePath;
3178
+ truncationInfo = {
3179
+ result: truncation,
3180
+ // Head receipts keep the wrapper notice: it carries the `:N to
3181
+ // continue` hint, which the body footer does not duplicate.
3182
+ // Only directional windows transfer notice ownership to the body.
3183
+ options: { direction: "head", startLine: startLineDisplay, totalFileLines },
3184
+ };
3185
+ } else if (truncation.truncated) {
3186
+ outputText = formatText(truncation.content, startLineDisplay);
3187
+ details = { truncation, spillEligible: !isBareReceipt && collectedLines.length > 0 };
3188
+
3189
+ sourcePath = absolutePath;
3190
+ truncationInfo = {
3191
+ result: truncation,
3192
+ options: { direction: "head", startLine: startLineDisplay, totalFileLines },
3193
+ };
3194
+ } else if (startLine + userLimitedLines < totalFileLines) {
3195
+ const remaining = totalFileLines - (startLine + userLimitedLines);
3196
+ const nextOffset = startLine + userLimitedLines + 1;
3197
+
3198
+ outputText = formatText(truncation.content, startLineDisplay);
3199
+ outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`;
3200
+ details = { spillEligible: !isBareReceipt && collectedLines.length > 0 };
3201
+
3202
+ sourcePath = absolutePath;
3203
+ } else {
3204
+ // No truncation, no user limit exceeded
3205
+ outputText = formatText(truncation.content, startLineDisplay);
3206
+ details = { spillEligible: !isBareReceipt && collectedLines.length > 0 };
2013
3207
 
2014
- if (capturedDisplayContent) {
2015
- details.displayContent = capturedDisplayContent;
2016
- }
3208
+ sourcePath = absolutePath;
3209
+ }
3210
+ if (isBareReceipt && (truncation.truncated || collectedLines.length < totalFileLines)) {
3211
+ outputText += `\n\n${formatReceiptFooter(localReadPath, collectedLines.length, totalFileLines, collectedBytes)}`;
3212
+ }
3213
+ if (rawSelector && truncation.truncated) {
3214
+ outputText += `\n\n${formatRawReadCapNotice(maxBytesForRead, fileSize, localDirection)}`;
3215
+ }
2017
3216
 
2018
- if (!firstLineExceedsLimit && collectedLines.length > 0) {
2019
- const blocks = scanConflictLines(collectedLines, startLineDisplay);
2020
- if (blocks.length > 0) {
2021
- const history = getConflictHistory(this.session);
2022
- const displayPathForWarning = formatPathRelativeToCwd(absolutePath, this.session.cwd);
2023
- const entries = blocks.map(block =>
2024
- history.register({
2025
- absolutePath,
3217
+ if (capturedDisplayContent) {
3218
+ details.displayContent = capturedDisplayContent;
3219
+ }
3220
+
3221
+ if (!firstLineExceedsLimit && collectedLines.length > 0) {
3222
+ const blocks = scanConflictLines(collectedLines, startLineDisplay);
3223
+ if (blocks.length > 0) {
3224
+ const history = getConflictHistory(this.session);
3225
+ const displayPathForWarning = formatPathRelativeToCwd(absolutePath, this.session.cwd);
3226
+ const entries = blocks.map(block =>
3227
+ history.register({
3228
+ absolutePath,
3229
+ displayPath: displayPathForWarning,
3230
+ ...block,
3231
+ }),
3232
+ );
3233
+ // Cheap full-file scan only when the window already showed
3234
+ // at least one conflict — otherwise pay nothing on clean files.
3235
+ let totalInFile = entries.length;
3236
+ let scanTruncated = false;
3237
+ try {
3238
+ const fileScan = await scanFileForConflicts(absolutePath);
3239
+ totalInFile = Math.max(entries.length, fileScan.blocks.length);
3240
+ scanTruncated = fileScan.scanTruncated;
3241
+ } catch {
3242
+ // Best-effort enrichment; fall back to window-only count.
3243
+ }
3244
+ outputText += formatConflictWarning(entries, {
3245
+ totalInFile,
2026
3246
  displayPath: displayPathForWarning,
2027
- ...block,
2028
- }),
2029
- );
2030
- // Cheap full-file scan only when the window already showed
2031
- // at least one conflict — otherwise pay nothing on clean files.
2032
- let totalInFile = entries.length;
2033
- let scanTruncated = false;
2034
- try {
2035
- const fileScan = await scanFileForConflicts(absolutePath);
2036
- totalInFile = Math.max(entries.length, fileScan.blocks.length);
2037
- scanTruncated = fileScan.scanTruncated;
2038
- } catch {
2039
- // Best-effort enrichment; fall back to window-only count.
3247
+ scanTruncated,
3248
+ });
3249
+ details.conflictCount = entries.length;
2040
3250
  }
2041
- outputText += formatConflictWarning(entries, {
2042
- totalInFile,
2043
- displayPath: displayPathForWarning,
2044
- scanTruncated,
2045
- });
2046
- details.conflictCount = entries.length;
2047
3251
  }
2048
- }
2049
3252
 
2050
- content = [{ type: "text", text: outputText }];
3253
+ content = [{ type: "text", text: outputText }];
3254
+ }
2051
3255
  }
2052
3256
  }
2053
3257
  }
@@ -2070,6 +3274,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2070
3274
  if (truncationInfo) {
2071
3275
  resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
2072
3276
  }
3277
+ if (directionalWindow) {
3278
+ resultBuilder.truncationWindows(directionalWindow, {
3279
+ noticeOwner: directionalNoticeOwner,
3280
+ rangeBase: directionalRangeBase,
3281
+ });
3282
+ }
2073
3283
  if (columnTruncated > 0) {
2074
3284
  resultBuilder.limits({ columnMax: columnTruncated });
2075
3285
  }
@@ -2115,8 +3325,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2115
3325
  absolutePath: string,
2116
3326
  suffixResolution: { from: string; to: string } | undefined,
2117
3327
  signal: AbortSignal | undefined,
3328
+ truncationParam?: TruncationDirection,
2118
3329
  ): Promise<AgentToolResult<ReadToolDetails>> {
2119
3330
  throwIfAborted(signal);
3331
+ if (truncationParam !== undefined && truncationParam !== "head") {
3332
+ throw new ToolError(
3333
+ "Explicit truncation for :conflicts indexes is not yet supported; the conflict preamble and bulk-resolve notice will be preserved when a structural index window is added.",
3334
+ );
3335
+ }
3336
+
2120
3337
  const scan = await scanFileForConflicts(absolutePath);
2121
3338
  const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
2122
3339
  const history = getConflictHistory(this.session);
@@ -2149,6 +3366,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2149
3366
  url: string,
2150
3367
  parsedSel: ParsedSelector,
2151
3368
  signal?: AbortSignal,
3369
+ truncationParam?: TruncationDirection,
2152
3370
  ): Promise<AgentToolResult<ReadToolDetails>> {
2153
3371
  const internalRouter = InternalUrlRouter.instance();
2154
3372
 
@@ -2185,8 +3403,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2185
3403
 
2186
3404
  // If extraction was used, return directly (no pagination)
2187
3405
  if (hasExtraction) {
3406
+ if (truncationParam !== undefined && truncationParam !== "head") {
3407
+ throw new ToolError("Explicit truncation for internal URL query extraction is not yet supported.");
3408
+ }
2188
3409
  return toolResult(details).text(resource.content).sourceInternal(url).done();
2189
3410
  }
3411
+ const direction = resolveEffectiveDirection(truncationParam, "converted", this.session.settings);
2190
3412
 
2191
3413
  const raw = isRawSelector(parsedSel);
2192
3414
  if (isMultiRange(parsedSel) && parsedSel.kind === "lines") {
@@ -2197,6 +3419,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2197
3419
  entityLabel: "resource",
2198
3420
  immutable: resource.immutable,
2199
3421
  raw,
3422
+ truncationDirection: direction,
2200
3423
  });
2201
3424
  }
2202
3425
 
@@ -2209,6 +3432,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2209
3432
  ignoreResultLimits: scheme === "skill",
2210
3433
  immutable: resource.immutable,
2211
3434
  raw,
3435
+ truncationDirection: direction,
3436
+ bodyFooterPath: parsedSel.kind === "none" && !raw ? url : undefined,
2212
3437
  });
2213
3438
  }
2214
3439
 
@@ -2216,6 +3441,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2216
3441
  async #readDirectory(
2217
3442
  absolutePath: string,
2218
3443
  limit: number | undefined,
3444
+ direction: TruncationDirection,
2219
3445
  signal?: AbortSignal,
2220
3446
  ): Promise<AgentToolResult<ReadToolDetails>> {
2221
3447
  const READ_DIRECTORY_MAX_DEPTH = 2;
@@ -2239,20 +3465,26 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2239
3465
  const receiptLines = this.session.settings.get("read.receiptBudgetLines");
2240
3466
  const receiptBytes = this.session.settings.get("read.receiptBudgetBytes") * 1024;
2241
3467
  const sourceLines = tree.totalLines <= 1 ? ["(empty directory)"] : tree.rendered.split("\n");
2242
- const bodyLines: string[] = [];
2243
- let bodyBytes = 0;
2244
- for (const line of sourceLines) {
2245
- const bytes = Buffer.byteLength(line, "utf-8") + (bodyLines.length > 0 ? 1 : 0);
2246
- if (bodyLines.length >= receiptLines || bodyBytes + bytes > receiptBytes) break;
2247
- bodyLines.push(line);
2248
- bodyBytes += bytes;
2249
- }
2250
- let output = bodyLines.join("\n");
2251
- if (bodyLines.length < sourceLines.length || tree.truncated) {
2252
- output += `\n\n[Listing truncated at ${bodyLines.length} lines / ${Math.ceil(bodyBytes / 1024)} KiB; read a deeper subpath (e.g. ${absolutePath}/<subdir>) to narrow the view]`;
2253
- }
2254
- const details: ReadToolDetails = { isDirectory: true, spillEligible: false, resolvedPath: tree.rootPath };
2255
- return toolResult(details).text(output).sourcePath(tree.rootPath).done();
3468
+ const source = sourceLines.join("\n");
3469
+ const window = selectReadWindow(source, sourceLines, direction, receiptLines, receiptBytes);
3470
+ const shownLines = windowOutputLines(window);
3471
+ const shownSegments = windowSegments(window);
3472
+ const shownBytes =
3473
+ shownSegments.reduce((sum, segment) => sum + segment.bytes, 0) + Math.max(0, shownSegments.length - 1);
3474
+ let output = renderReadWindow(window, false, false);
3475
+ if (window.kind !== "full" || tree.truncated) {
3476
+ output += `\n\n${formatListingTruncationNotice(shownLines, shownBytes, absolutePath, direction)}`;
3477
+ }
3478
+ const details: ReadToolDetails = {
3479
+ isDirectory: true,
3480
+ spillEligible: false,
3481
+ resolvedPath: tree.rootPath,
3482
+ ...(window.kind !== "full" && direction !== "head" ? { truncation: makeWindowResult(window, source) } : {}),
3483
+ };
3484
+ const resultBuilder = toolResult(details).text(output).sourcePath(tree.rootPath);
3485
+ if (window.kind !== "full" && direction !== "head")
3486
+ resultBuilder.truncationWindows(window, { noticeOwner: "body", maxBytes: receiptBytes });
3487
+ return resultBuilder.done();
2256
3488
  }
2257
3489
  }
2258
3490