@oh-my-pi/pi-coding-agent 17.2.11 → 17.2.12

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 (152) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
  3. package/dist/cli.js +10795 -10791
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/config/custom-models.d.ts +31 -0
  6. package/dist/types/config/model-config-values.d.ts +19 -0
  7. package/dist/types/config/model-patch.d.ts +93 -0
  8. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  9. package/dist/types/config/model-registry.d.ts +5 -52
  10. package/dist/types/config/settings.d.ts +5 -3
  11. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  12. package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
  13. package/dist/types/discovery/contained-path.d.ts +2 -3
  14. package/dist/types/eval/executor-base.d.ts +8 -2
  15. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  16. package/dist/types/export/share.d.ts +1 -1
  17. package/dist/types/lsp/diagnostics.d.ts +96 -0
  18. package/dist/types/lsp/index.d.ts +7 -128
  19. package/dist/types/lsp/servers.d.ts +72 -0
  20. package/dist/types/lsp/tool.d.ts +42 -0
  21. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  22. package/dist/types/lsp/writethrough.d.ts +33 -0
  23. package/dist/types/mcp/transports/header-policy.d.ts +2 -1
  24. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  25. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  26. package/dist/types/modes/theme/color.d.ts +19 -0
  27. package/dist/types/modes/theme/loader.d.ts +19 -0
  28. package/dist/types/modes/theme/schema.d.ts +175 -0
  29. package/dist/types/modes/theme/symbols.d.ts +28 -0
  30. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  31. package/dist/types/modes/theme/theme.d.ts +9 -280
  32. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  33. package/dist/types/registry/agent-registry.d.ts +1 -3
  34. package/dist/types/secrets/index.d.ts +3 -1
  35. package/dist/types/secrets/message-transform.d.ts +40 -0
  36. package/dist/types/secrets/obfuscator.d.ts +0 -108
  37. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  38. package/dist/types/secrets/placeholder.d.ts +94 -0
  39. package/dist/types/secrets/replacement.d.ts +82 -0
  40. package/dist/types/session/agent-session-types.d.ts +6 -0
  41. package/dist/types/session/agent-session.d.ts +1 -1
  42. package/dist/types/session/session-handoff.d.ts +3 -3
  43. package/dist/types/session/session-manager.d.ts +32 -0
  44. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  45. package/dist/types/session/turn-recovery.d.ts +2 -2
  46. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  47. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  48. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  49. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  50. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  51. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  52. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  53. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  54. package/dist/types/tools/gh-common.d.ts +69 -0
  55. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  56. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  57. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  58. package/dist/types/tools/gh-search.d.ts +61 -0
  59. package/dist/types/tools/gh-types.d.ts +340 -0
  60. package/dist/types/tools/gh-view.d.ts +88 -0
  61. package/dist/types/tools/gh.d.ts +4 -201
  62. package/dist/types/tools/read-archive.d.ts +16 -0
  63. package/dist/types/tools/read-format.d.ts +104 -0
  64. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  65. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  66. package/dist/types/tools/read-renderer.d.ts +24 -0
  67. package/dist/types/tools/read-selector.d.ts +27 -0
  68. package/dist/types/tools/read-sqlite.d.ts +16 -0
  69. package/dist/types/tools/read-summary.d.ts +19 -0
  70. package/dist/types/tools/read.d.ts +1 -23
  71. package/dist/types/tools/shell-tokenize.d.ts +2 -1
  72. package/dist/types/utils/changelog.d.ts +0 -5
  73. package/package.json +13 -13
  74. package/src/advisor/runtime.ts +12 -7
  75. package/src/config/custom-models.ts +188 -0
  76. package/src/config/model-config-values.ts +128 -0
  77. package/src/config/model-patch.ts +252 -0
  78. package/src/config/model-provider-discovery.ts +132 -0
  79. package/src/config/model-registry.ts +64 -704
  80. package/src/config/settings.ts +7 -3
  81. package/src/debug/raw-sse-buffer.ts +20 -0
  82. package/src/discovery/agent-plugin-format.ts +7 -7
  83. package/src/discovery/contained-path.ts +2 -5
  84. package/src/edit/hashline/diff.ts +5 -1
  85. package/src/eval/executor-base.ts +39 -6
  86. package/src/eval/jl/executor.ts +82 -371
  87. package/src/eval/kernel-session-registry.ts +398 -0
  88. package/src/eval/py/executor.ts +53 -261
  89. package/src/eval/rb/executor.ts +36 -279
  90. package/src/export/share.ts +2 -1
  91. package/src/lsp/diagnostics.ts +516 -0
  92. package/src/lsp/index.ts +20 -2819
  93. package/src/lsp/servers.ts +296 -0
  94. package/src/lsp/tool.ts +1352 -0
  95. package/src/lsp/workspace-diagnostics.ts +170 -0
  96. package/src/lsp/writethrough.ts +561 -0
  97. package/src/mcp/transports/header-policy.ts +1 -1
  98. package/src/modes/components/agent-hub-projection.ts +2 -2
  99. package/src/modes/components/agent-hub-renderer.ts +3 -7
  100. package/src/modes/controllers/command-controller.ts +4 -1
  101. package/src/modes/theme/color.ts +133 -0
  102. package/src/modes/theme/loader.ts +178 -0
  103. package/src/modes/theme/schema.ts +263 -0
  104. package/src/modes/theme/symbols.ts +1000 -0
  105. package/src/modes/theme/theme-class.ts +611 -0
  106. package/src/modes/theme/theme.ts +27 -2453
  107. package/src/modes/theme/tui-adapters.ts +279 -0
  108. package/src/registry/agent-registry.ts +2 -2
  109. package/src/secrets/index.ts +6 -12
  110. package/src/secrets/message-transform.ts +287 -0
  111. package/src/secrets/obfuscator.ts +39 -1303
  112. package/src/secrets/placeholder-scan.ts +506 -0
  113. package/src/secrets/placeholder.ts +309 -0
  114. package/src/secrets/replacement.ts +216 -0
  115. package/src/session/agent-session-types.ts +6 -0
  116. package/src/session/agent-session.ts +113 -7
  117. package/src/session/indexed-session-storage.ts +7 -2
  118. package/src/session/session-advisors.ts +32 -34
  119. package/src/session/session-handoff.ts +39 -20
  120. package/src/session/session-manager.ts +67 -3
  121. package/src/session/session-provider-boundary.ts +3 -6
  122. package/src/session/turn-recovery.ts +21 -12
  123. package/src/slash-commands/builtin-collaboration.ts +504 -0
  124. package/src/slash-commands/builtin-completions.ts +291 -0
  125. package/src/slash-commands/builtin-control.ts +78 -0
  126. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  127. package/src/slash-commands/builtin-marketplace.ts +567 -0
  128. package/src/slash-commands/builtin-modes.ts +518 -0
  129. package/src/slash-commands/builtin-registry.ts +21 -3000
  130. package/src/slash-commands/builtin-session.ts +592 -0
  131. package/src/task/executor.ts +17 -14
  132. package/src/tools/gh-common.ts +288 -0
  133. package/src/tools/gh-pr-checkout.ts +679 -0
  134. package/src/tools/gh-pr-diff.ts +473 -0
  135. package/src/tools/gh-run-watch.ts +1015 -0
  136. package/src/tools/gh-search.ts +500 -0
  137. package/src/tools/gh-types.ts +379 -0
  138. package/src/tools/gh-view.ts +612 -0
  139. package/src/tools/gh.ts +109 -3821
  140. package/src/tools/read-archive.ts +209 -0
  141. package/src/tools/read-format.ts +593 -0
  142. package/src/tools/read-path-resolution.ts +36 -0
  143. package/src/tools/read-pdf-images.ts +250 -0
  144. package/src/tools/read-renderer.ts +289 -0
  145. package/src/tools/read-selector.ts +84 -0
  146. package/src/tools/read-sqlite.ts +215 -0
  147. package/src/tools/read-summary.ts +199 -0
  148. package/src/tools/read.ts +77 -1820
  149. package/src/tools/shell-tokenize.ts +1 -1
  150. package/src/utils/changelog.ts +1 -1
  151. package/src/utils/title-generator.ts +3 -1
  152. package/src/web/search/providers/zai.ts +12 -3
package/src/tools/read.ts CHANGED
@@ -1,8 +1,5 @@
1
- import { Database } from "bun:sqlite";
2
1
  import * as fs from "node:fs/promises";
3
- import * as os from "node:os";
4
2
  import * as path from "node:path";
5
- import { formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline";
6
3
  import { type } from "@oh-my-pi/omptype";
7
4
  import type {
8
5
  AgentTool,
@@ -12,51 +9,31 @@ import type {
12
9
  ToolTier,
13
10
  } from "@oh-my-pi/pi-agent-core";
14
11
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
15
- import { type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
16
- import type { Component } from "@oh-my-pi/pi-tui";
17
- import { Text } from "@oh-my-pi/pi-tui";
18
- import {
19
- getRemoteDir,
20
- type ImageMetadata,
21
- isEexist,
22
- isEnotempty,
23
- isProbablyBinary,
24
- logger,
25
- prompt,
26
- readImageMetadata,
27
- untilAborted,
28
- } from "@oh-my-pi/pi-utils";
29
- import { LRUCache } from "@oh-my-pi/pi-utils/lru";
12
+ import { type ImageMetadata, isProbablyBinary, logger, prompt, readImageMetadata } from "@oh-my-pi/pi-utils";
30
13
  import {
31
14
  canonicalSnapshotKey,
32
15
  getFileSnapshotStore,
33
16
  recordFileSnapshot,
34
- recordSeenLines,
35
17
  recordSeenLinesFromBody,
36
18
  SNAPSHOT_MAX_BYTES,
37
19
  } from "../edit/file-snapshot-store";
38
20
  import { normalizeToLF } from "../edit/normalize";
39
21
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
40
- import type { RenderResultOptions } from "../extensibility/custom-tools/types";
41
22
  import { InternalUrlRouter, resolveLocalUrlToFile, resolveLocalUrlToPath } from "../internal-urls";
42
23
  import { type ResolvedArtifactFile, resolveArtifactFile } from "../internal-urls/artifact-protocol";
43
24
  import { parseInternalUrl } from "../internal-urls/parse";
44
25
  import type { InternalUrl } from "../internal-urls/types";
45
- import { getLanguageFromPath, isMarkdownPath, type Theme } from "../modes/theme/theme";
46
26
  import readDescription from "../prompts/tools/read.md" with { type: "text" };
47
27
  import type { ToolSession } from "../sdk";
48
28
  import {
49
29
  DEFAULT_MAX_BYTES,
50
30
  DEFAULT_MAX_LINES,
51
- noTruncResult,
52
31
  type TruncationResult,
53
32
  truncateHead,
54
33
  truncateHeadBytes,
55
34
  truncateLine,
56
35
  } from "../session/streaming-output";
57
- import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui";
58
- import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
59
- import { buildLineEntriesWithBlockContext, type LineEntry, lineEntriesToPlainText } from "../utils/block-context";
36
+ import { buildLineEntriesWithBlockContext, lineEntriesToPlainText } from "../utils/block-context";
60
37
  import { isCpuProfilePath, renderCpuProfile } from "../utils/cpuprofile";
61
38
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
62
39
  import {
@@ -68,7 +45,6 @@ import {
68
45
  import { isInspectImageToolActive } from "../utils/inspect-image-mode";
69
46
  import { CONVERTIBLE_EXTENSIONS, convertFileWithMarkit } from "../utils/markit";
70
47
  import { isSampleProfilePath, renderSampleProfile } from "../utils/sample-profile";
71
- import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "../utils/zip";
72
48
  import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree";
73
49
  import {
74
50
  type ConflictEntry,
@@ -81,29 +57,12 @@ import {
81
57
  scanConflictLines,
82
58
  scanFileForConflicts,
83
59
  } from "./conflict-detect";
84
- import {
85
- executeReadUrl,
86
- fetchReadUrl,
87
- parseReadUrlTarget,
88
- type ReadUrlToolDetails,
89
- renderReadUrlCall,
90
- renderReadUrlResult,
91
- } from "./fetch";
92
- import { applyListLimit } from "./list-limit";
93
- import {
94
- formatFullOutputReference,
95
- formatStyledTruncationWarning,
96
- type OutputMeta,
97
- resolveOutputMaxColumns,
98
- stripOutputNotice,
99
- } from "./output-meta";
60
+ import { executeReadUrl, fetchReadUrl, parseReadUrlTarget } from "./fetch";
61
+ import { type OutputMeta, resolveOutputMaxColumns } from "./output-meta";
100
62
  import {
101
63
  expandPath,
102
- findUniqueWorkspaceSuffix,
103
64
  formatPathRelativeToCwd,
104
- isReadableUrlPath,
105
65
  type LineRange,
106
- parseLineRanges,
107
66
  pathTargetsSsh,
108
67
  probeLiteralPathExists,
109
68
  resolveReadPath,
@@ -112,67 +71,50 @@ import {
112
71
  splitPathAndSel,
113
72
  splitPathAndSelPreferringLiteral,
114
73
  } from "./path-utils";
115
- import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
74
+ import { readArchive, resolveArchiveReadPath } from "./read-archive";
75
+ import {
76
+ BRACKET_CONTEXT_ELLIPSIS,
77
+ buildInMemoryMultiRangeResult,
78
+ buildInMemoryTextResult,
79
+ contiguousLineNumbers,
80
+ countTextLines,
81
+ formatLineEntriesWithMode,
82
+ formatReadHashlineHeader,
83
+ formatSummaryElisionFooter,
84
+ formatTextWithMode,
85
+ type HashlineHeaderContext,
86
+ hashlineHeaderContext,
87
+ lineNumbersFromSpans,
88
+ markMarkdownContentType,
89
+ prependHashlineHeader,
90
+ prependSuffixResolutionNotice,
91
+ RANGE_LEADING_CONTEXT_LINES,
92
+ RANGE_TRAILING_CONTEXT_LINES,
93
+ READ_CHUNK_SIZE,
94
+ readHashlineHeaderContext,
95
+ } from "./read-format";
96
+ import {
97
+ findSuffixMatchCached,
98
+ isNotFoundError,
99
+ isRemoteMountPath,
100
+ type SuffixMatchCache,
101
+ } from "./read-path-resolution";
102
+ import { readPdfImageMember, rewritePdfImagePlaceholders, splitPdfImageMemberReadPath } from "./read-pdf-images";
103
+ import { isMultiRange, isRawSelector, type ParsedSelector, parseSel, selToOffsetLimit } from "./read-selector";
104
+ import { readSqlite, resolveSqliteReadPath } from "./read-sqlite";
105
+ import { isProseSummaryPath, renderSummary, routeReadThroughBridge, trySummarize } from "./read-summary";
106
+ import { formatBytes, shortenPath } from "./render-utils";
116
107
  import { REPORT_ISSUE_DEVICE_NAME, reportIssueDeviceUsage } from "./report-tool-issue";
117
108
  import { isResolutionDeviceName, resolutionDeviceUsage } from "./resolve";
118
- import {
119
- executeReadQuery,
120
- getRowByKey,
121
- getRowByRowId,
122
- getTableSchema,
123
- isSqliteFile,
124
- listTables,
125
- MAX_RAW_QUERY_ROWS,
126
- parseSqlitePathCandidates,
127
- parseSqliteSelector,
128
- queryRows,
129
- renderRow,
130
- renderSchema,
131
- renderTable,
132
- renderTableList,
133
- resolveTableRowLookup,
134
- } from "./sqlite-reader";
135
109
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
136
110
  import { toolResult } from "./tool-result";
137
111
  import { xdevDocs, xdevListing } from "./xdev";
138
112
 
139
- // Per-session memo for tree-sitter summaries. `summarizeCode` is a pure function
140
- // of (code, path, fold settings) but costs ~12-18ms for a ~1500-line file, and a
141
- // repeat summary read of the same unchanged file re-parses from scratch. Key on
142
- // the content hash of the freshly-read bytes (+ path + fold settings): the file
143
- // is still read fresh on every call, so a hit only reuses the deterministic
144
- // parse — there is no staleness window and no stat guard is needed. Bounded LRU,
145
- // aged out with the session via WeakMap.
146
- // Unusable results (not parsed, or nothing elided) are memoized as `false`: the
147
- // full SummaryResult embeds the whole source in kept segments, and the caller
148
- // only ever renders `parsed && elided` summaries — caching the segments would
149
- // retain up to 48 near-2MiB sources just to remember "no summary".
150
- const SUMMARY_CACHE_MAX = 48;
151
- const summaryParseCaches = new WeakMap<object, LRUCache<string, SummaryResult | false>>();
152
- function getSummaryParseCache(session: object): LRUCache<string, SummaryResult | false> {
153
- let cache = summaryParseCaches.get(session);
154
- if (!cache) {
155
- cache = new LRUCache<string, SummaryResult | false>({ max: SUMMARY_CACHE_MAX });
156
- summaryParseCaches.set(session, cache);
157
- }
158
- return cache;
159
- }
113
+ export { readToolRenderer } from "./read-renderer";
160
114
 
161
- const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
162
115
  /** Largest profile (`*.sample.txt`, `*.cpuprofile`) converted to a bottleneck summary; bigger files read as plain text. */
163
116
  const MAX_PROFILE_SUMMARY_BYTES = 32 * 1024 * 1024;
164
- const MAX_SUMMARY_LINES = 20_000;
165
117
  const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES;
166
- /**
167
- * Prose files (Markdown flavors and plain text) skip code-block summarization
168
- * unless `read.summarize.prose` opts them in.
169
- */
170
- function isProseSummaryPath(filePath: string): boolean {
171
- return isMarkdownPath(filePath) || path.extname(filePath).toLowerCase() === ".txt";
172
- }
173
-
174
- // Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
175
- const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
176
118
 
177
119
  async function readBracketContextFullLines(absolutePath: string, fileSize: number): Promise<string[] | undefined> {
178
120
  if (fileSize > SNAPSHOT_MAX_BYTES) return undefined;
@@ -183,262 +125,6 @@ async function readBracketContextFullLines(absolutePath: string, fileSize: numbe
183
125
  }
184
126
  }
185
127
 
186
- function isRemoteMountPath(absolutePath: string): boolean {
187
- return absolutePath.startsWith(REMOTE_MOUNT_PREFIX);
188
- }
189
-
190
- function prependLineNumbers(text: string, startNum: number): string {
191
- const textLines = text.split("\n");
192
- return textLines.map((line, i) => `${startNum + i}|${line}`).join("\n");
193
- }
194
-
195
- interface HashlineHeaderContext {
196
- header: string;
197
- tag: string;
198
- fullText?: string;
199
- }
200
-
201
- function formatReadHashlineHeader(displayPath: string, tag: string): string {
202
- // In-workspace reads collapse to the bare filename for brevity: the edit
203
- // tool's snapshot-tag recovery rebinds a bare `[name#tag]` onto the in-tree
204
- // file it uniquely names. Out-of-workspace reads can't lean on that —
205
- // recovery refuses to redirect a write outside the cwd/sandbox
206
- // (HashlineFilesystem.allowTagPathRecovery) — so an absolute displayPath
207
- // must stay directly resolvable, otherwise the basename resolves against
208
- // cwd, misses, and the edit fails with "File not found" (e.g. ~/.claude/*).
209
- // `shortenPath` keeps `~/.claude/...` (round-trips through resolveToCwd's ~
210
- // expansion) instead of leaking the full home path into the read output.
211
- const anchor = path.isAbsolute(displayPath) ? shortenPath(displayPath) : path.basename(displayPath);
212
- return formatHashlineHeader(anchor, tag);
213
- }
214
-
215
- function recordFullHashlineContext(
216
- session: ToolSession,
217
- absolutePath: string | undefined,
218
- displayPath: string,
219
- fullText: string,
220
- ): HashlineHeaderContext | undefined {
221
- if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined;
222
- const normalized = normalizeToLF(fullText);
223
- const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
224
- return {
225
- header: formatReadHashlineHeader(displayPath, tag),
226
- tag,
227
- fullText: normalized,
228
- };
229
- }
230
-
231
- async function readHashlineHeaderContext(
232
- session: ToolSession,
233
- absolutePath: string,
234
- cwd: string,
235
- ): Promise<HashlineHeaderContext> {
236
- const fullText = await Bun.file(absolutePath).text();
237
- const context = recordFullHashlineContext(
238
- session,
239
- absolutePath,
240
- formatPathRelativeToCwd(absolutePath, cwd),
241
- fullText,
242
- );
243
- if (!context) throw new ToolError(`Cannot record hashline snapshot for non-absolute path: ${absolutePath}`);
244
- return context;
245
- }
246
-
247
- function hashlineHeaderContext(displayPath: string, tag: string): HashlineHeaderContext {
248
- return { header: formatReadHashlineHeader(displayPath, tag), tag };
249
- }
250
-
251
- function prependHashlineHeader(text: string, context: HashlineHeaderContext | undefined): string {
252
- return context ? `${context.header}\n${text}` : text;
253
- }
254
-
255
- function formatTextWithMode(
256
- text: string,
257
- startNum: number,
258
- shouldAddHashLines: boolean,
259
- shouldAddLineNumbers: boolean,
260
- ): string {
261
- if (shouldAddHashLines) return formatNumberedLines(text, startNum);
262
- if (shouldAddLineNumbers) return prependLineNumbers(text, startNum);
263
- return text;
264
- }
265
-
266
- const BRACKET_CONTEXT_ELLIPSIS = "…";
267
-
268
- function formatLineEntryWithMode(entry: LineEntry, shouldAddHashLines: boolean, shouldAddLineNumbers: boolean): string {
269
- if (entry.kind === "ellipsis") return BRACKET_CONTEXT_ELLIPSIS;
270
- return formatSingleLine(entry.lineNumber, entry.text, shouldAddHashLines, shouldAddLineNumbers);
271
- }
272
-
273
- function formatLineEntriesWithMode(
274
- entries: readonly LineEntry[],
275
- shouldAddHashLines: boolean,
276
- shouldAddLineNumbers: boolean,
277
- ): string {
278
- return entries.map(entry => formatLineEntryWithMode(entry, shouldAddHashLines, shouldAddLineNumbers)).join("\n");
279
- }
280
-
281
- const BRACE_PAIRS: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
282
- const BRACE_TAIL_TRAILING_RE = /^[;,)\]}]*$/;
283
-
284
- /**
285
- * Decide whether the kept lines surrounding an elided range collapse to a
286
- * single brace-pair line in the rendered summary. Returns true when the head
287
- * line ends with `{` / `(` / `[` and the tail line is the matching closer
288
- * (optionally followed by terminating punctuation like `;`, `,`, or further
289
- * closers — e.g. `};`, `})`, `]);`).
290
- */
291
- function canMergeBracePair(headLine: string, tailLine: string): boolean {
292
- const head = headLine.trimEnd();
293
- const tail = tailLine.trim();
294
- const opener = head.slice(-1);
295
- const closer = BRACE_PAIRS[opener];
296
- if (!closer) return false;
297
- if (!tail.startsWith(closer)) return false;
298
- return BRACE_TAIL_TRAILING_RE.test(tail.slice(closer.length));
299
- }
300
-
301
- function formatSingleLine(
302
- line: number,
303
- text: string,
304
- shouldAddHashLines: boolean,
305
- shouldAddLineNumbers: boolean,
306
- ): string {
307
- if (shouldAddHashLines) return formatNumberedLine(line, text);
308
- if (shouldAddLineNumbers) return `${line}|${text}`;
309
- return text;
310
- }
311
-
312
- function formatMergedBraceLine(
313
- startLine: number,
314
- endLine: number,
315
- headText: string,
316
- tailText: string,
317
- shouldAddHashLines: boolean,
318
- shouldAddLineNumbers: boolean,
319
- ): { model: string; display: string } {
320
- const merged = `${headText.trimEnd()} … ${tailText.trim()}`;
321
- if (shouldAddHashLines) {
322
- return { model: `${startLine}-${endLine}:${merged}`, display: merged };
323
- }
324
- if (shouldAddLineNumbers) {
325
- return { model: `${startLine}-${endLine}|${merged}`, display: merged };
326
- }
327
- return { model: merged, display: merged };
328
- }
329
-
330
- function countTextLines(text: string): number {
331
- if (text.length === 0) return 0;
332
- // Count newlines directly instead of allocating an array via split("\n").
333
- // Called on every read of file content; the result is identical (N newlines
334
- // ⇒ N+1 lines for non-empty text).
335
- let lines = 1;
336
- for (let i = 0; i < text.length; i++) {
337
- if (text.charCodeAt(i) === 10) lines++;
338
- }
339
- return lines;
340
- }
341
-
342
- function contiguousLineNumbers(startLine: number, count: number): number[] {
343
- const lines: number[] = [];
344
- for (let offset = 0; offset < count; offset++) lines.push(startLine + offset);
345
- return lines;
346
- }
347
-
348
- function lineNumbersFromSpans(spans: readonly { startLine: number; endLine: number }[]): number[] {
349
- const lines: number[] = [];
350
- for (const span of spans) {
351
- for (let line = span.startLine; line <= span.endLine; line++) lines.push(line);
352
- }
353
- return lines;
354
- }
355
-
356
- function recordInMemorySeenLines(
357
- session: ToolSession,
358
- absolutePath: string | undefined,
359
- fullText: string,
360
- seenLines: readonly number[] | undefined,
361
- ): void {
362
- if (!absolutePath || !path.isAbsolute(absolutePath) || !seenLines || seenLines.length === 0) return;
363
- getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalizeToLF(fullText), seenLines);
364
- }
365
-
366
- function lineNumbersFromEntries(entries: readonly LineEntry[]): number[] {
367
- const lines: number[] = [];
368
- for (const entry of entries) {
369
- if (entry.kind === "line") lines.push(entry.lineNumber);
370
- }
371
- return lines;
372
- }
373
-
374
- /** Inclusive line range describing one elided span in a structural summary. */
375
- interface ElidedRange {
376
- start: number;
377
- end: number;
378
- }
379
-
380
- /** Sample ranges shown in the footer to demonstrate the multi-range syntax. */
381
- const FOOTER_RANGE_SAMPLES = 2;
382
-
383
- /**
384
- * Footer appended to summarized reads telling the model how to recover the
385
- * elided body. Without this hint, agents either ignore the `…`/`{ … }`
386
- * markers or burn a turn guessing the right selector (see issue #1046). The
387
- * footer demonstrates the multi-range selector syntax with concrete sample
388
- * ranges drawn from the actual elision so the model re-reads only what it
389
- * needs instead of falling back to `:raw` or whole-file reads.
390
- */
391
- function formatSummaryElisionFooter(
392
- readPath: string,
393
- elidedRanges: ReadonlyArray<ElidedRange>,
394
- elidedLines: number,
395
- ): string {
396
- if (elidedRanges.length === 0) return "";
397
- const sampleCount = Math.min(elidedRanges.length, FOOTER_RANGE_SAMPLES);
398
- const selector = elidedRanges
399
- .slice(0, sampleCount)
400
- .map(r => `${r.start}-${r.end}`)
401
- .join(",");
402
- const example = `${readPath}:${selector}`;
403
- const tail = elidedRanges.length > sampleCount ? `, e.g. ${example}` : ` with ${example}`;
404
- return `[…${elidedLines}ln elided; re-read needed ranges${tail}]`;
405
- }
406
- const READ_CHUNK_SIZE = 8 * 1024;
407
-
408
- /**
409
- * Context lines added around an explicit range read. Anchor-stale failures
410
- * cluster on edits whose anchors land just outside the most recent read
411
- * window, but the data (`scripts/session-stats/analyze_selector_reads.py`)
412
- * shows most follow-up reads are disjoint hops, not adjacent extensions —
413
- * so symmetric padding rarely pays for itself.
414
- *
415
- * Leading=1 catches accidental single-line reads where the anchor is the
416
- * line immediately above the requested start. Trailing=3 buffers the
417
- * common case where the agent asks for a narrow range and then needs the
418
- * next few lines to disambiguate an anchor.
419
- */
420
- const RANGE_LEADING_CONTEXT_LINES = 1;
421
- const RANGE_TRAILING_CONTEXT_LINES = 3;
422
-
423
- /**
424
- * Expand a [start, end) range with leading/trailing context lines on the
425
- * sides where the user actually constrained the range. A start of 0 (no
426
- * explicit offset) does not get leading context — that's already an
427
- * open-ended read from the top.
428
- */
429
- function expandRangeWithContext(
430
- requestedStart: number,
431
- requestedEnd: number,
432
- totalLines: number,
433
- expandStart: boolean,
434
- expandEnd: boolean,
435
- ): { startLine: number; endLine: number } {
436
- return {
437
- startLine: expandStart ? Math.max(0, requestedStart - RANGE_LEADING_CONTEXT_LINES) : requestedStart,
438
- endLine: expandEnd ? Math.min(totalLines, requestedEnd + RANGE_TRAILING_CONTEXT_LINES) : requestedEnd,
439
- };
440
- }
441
-
442
128
  async function streamLinesFromFile(
443
129
  filePath: string,
444
130
  startLine: number,
@@ -650,71 +336,6 @@ async function streamLinesFromFile(
650
336
  // Maximum image file size (20MB) - larger images will be rejected to prevent OOM during serialization
651
337
  const MAX_IMAGE_SIZE = MAX_IMAGE_INPUT_BYTES;
652
338
 
653
- function isNotFoundError(error: unknown): boolean {
654
- if (!error || typeof error !== "object") return false;
655
- const code = (error as { code?: string }).code;
656
- return code === "ENOENT" || code === "ENOTDIR";
657
- }
658
-
659
- function decodeUtf8Text(bytes: Uint8Array): string | null {
660
- if (bytes.indexOf(0) !== -1) return null;
661
-
662
- try {
663
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
664
- } catch {
665
- return null;
666
- }
667
- }
668
-
669
- function prependSuffixResolutionNotice(text: string, suffixResolution?: { from: string; to: string }): string {
670
- if (!suffixResolution) return text;
671
-
672
- const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
673
- return text ? `${notice}\n${text}` : notice;
674
- }
675
- const PDF_IMAGE_PLACEHOLDER_RE = /<!--\s*image:\s*([^\s<>]+)(.*?)-->/g;
676
- const PDF_IMAGE_MEMBER_RE = /^(.*\.pdf):(.*)$/i;
677
- const PDF_IMAGE_MEMBER_EXTENSION_RE = /\.png$/i;
678
- const PDF_IMAGE_CACHE_BASENAME_MAX_LENGTH = 96;
679
-
680
- interface PdfImageSnapshot {
681
- directory: string;
682
- filePath: string;
683
- digest: string;
684
- }
685
-
686
- interface PdfImageExtraction {
687
- controller: AbortController;
688
- promise: Promise<string>;
689
- settled: boolean;
690
- waiters: number;
691
- }
692
-
693
- const pdfImageExtractions = new Map<string, PdfImageExtraction>();
694
-
695
- function pdfImageMemberPath(pdfPath: string, imageId: string): string {
696
- const member = PDF_IMAGE_MEMBER_EXTENSION_RE.test(imageId) ? imageId : `${imageId}.png`;
697
- return `${pdfPath}:${member}`;
698
- }
699
-
700
- function rewritePdfImagePlaceholders(markdown: string, pdfPath: string): string {
701
- return markdown.replace(PDF_IMAGE_PLACEHOLDER_RE, (_match: string, imageId: string, metadataText: string) => {
702
- const metadata = metadataText.trim();
703
- const suffix = metadata.length > 0 ? ` (${metadata})` : "";
704
- return `Image ${imageId}${suffix}: read \`${pdfImageMemberPath(pdfPath, imageId)}\``;
705
- });
706
- }
707
-
708
- function splitPdfImageMemberReadPath(readPath: string): { pdfPath: string; member: string } | null {
709
- const match = PDF_IMAGE_MEMBER_RE.exec(readPath);
710
- if (!match) return null;
711
- const pdfPath = match[1];
712
- const member = match[2];
713
- if (pdfPath === undefined || member === undefined) return null;
714
- if (member.length !== 0 && !PDF_IMAGE_MEMBER_EXTENSION_RE.test(member)) return null;
715
- return { pdfPath, member };
716
- }
717
-
718
339
  const readSchema = type({
719
340
  path: type("string").describe(
720
341
  "Local path, internal URI (e.g. memory://, skill://), or URL. Inline selectors are supported.",
@@ -759,104 +380,6 @@ export interface ReadToolDetails {
759
380
  }
760
381
  type ReadParams = ReadToolInput;
761
382
 
762
- /** Parsed representation of a path-embedded selector. */
763
- type ParsedSelector =
764
- | { kind: "none" }
765
- | { kind: "raw" }
766
- | { kind: "conflicts" }
767
- | { kind: "lines"; ranges: [LineRange, ...LineRange[]]; raw?: boolean };
768
-
769
- /** Returns true when the selector requested verbatim/raw output (alone or combined with a range). */
770
- function isRawSelector(parsed: ParsedSelector): boolean {
771
- return parsed.kind === "raw" || (parsed.kind === "lines" && parsed.raw === true);
772
- }
773
-
774
- /** Returns true when the selector requested multiple line ranges. */
775
- function isMultiRange(parsed: ParsedSelector): boolean {
776
- return parsed.kind === "lines" && parsed.ranges.length > 1;
777
- }
778
-
779
- function selectorChunkLooksReadLike(chunk: string): boolean {
780
- const lower = chunk.toLowerCase();
781
- return (
782
- lower === "raw" || lower === "conflicts" || /^-\d+(?:[-+]\d+)?$/.test(chunk) || parseLineRanges(chunk) !== null
783
- );
784
- }
785
-
786
- function invalidSelector(sel: string): ToolError {
787
- return new ToolError(
788
- `Invalid selector ':${sel}'. Use :N, :N-M, :N+K, :N- (open-ended), a comma-separated list of ranges, :raw, or a range combined with raw (e.g. :raw:50-100).`,
789
- );
790
- }
791
-
792
- function parseSel(sel: string | undefined): ParsedSelector {
793
- if (!sel || sel.length === 0) return { kind: "none" };
794
-
795
- // Compound selector: `1-50:raw` or `raw:1-50`. Split into chunks and accept
796
- // exactly one line range (possibly multi) plus the literal `raw`. Selector-like
797
- // compounds that are not in that accepted set are invalid rather than "none";
798
- // otherwise `read` can silently widen a malformed selector like
799
- // `artifact://5:conflicts:1-1` while `grep` rejects it.
800
- if (sel.includes(":")) {
801
- const chunks = sel.split(":");
802
- if (chunks.length === 2) {
803
- const [a, b] = chunks as [string, string];
804
- const aIsRaw = a.toLowerCase() === "raw";
805
- const bIsRaw = b.toLowerCase() === "raw";
806
- const rangeChunk = aIsRaw ? b : bIsRaw ? a : null;
807
- const rawChunk = aIsRaw ? a : bIsRaw ? b : null;
808
- if (rangeChunk !== null && rawChunk !== null) {
809
- const ranges = parseLineRanges(rangeChunk);
810
- if (ranges) {
811
- return { kind: "lines", ranges, raw: true };
812
- }
813
- }
814
- }
815
- if (chunks.every(selectorChunkLooksReadLike)) throw invalidSelector(sel);
816
- // Unrecognized compound — fall through (sqlite/archive/url consume their own colon syntax).
817
- return { kind: "none" };
818
- }
819
-
820
- if (sel.toLowerCase() === "raw") return { kind: "raw" };
821
- if (sel.toLowerCase() === "conflicts") return { kind: "conflicts" };
822
- const ranges = parseLineRanges(sel);
823
- if (ranges) {
824
- return { kind: "lines", ranges };
825
- }
826
- // Unrecognized selectors fall through; sqlite/archive/url readers consume their own colon syntax.
827
- return { kind: "none" };
828
- }
829
-
830
- /**
831
- * Convert a single-range selector to the offset/limit pair used by internal pagination.
832
- * Returns the FIRST range only — multi-range callers MUST branch on `isMultiRange` before
833
- * calling this helper.
834
- */
835
- function selToOffsetLimit(parsed: ParsedSelector): { offset?: number; limit?: number } {
836
- if (parsed.kind === "lines") {
837
- const first = parsed.ranges[0];
838
- const limit = first.endLine !== undefined ? first.endLine - first.startLine + 1 : undefined;
839
- return { offset: first.startLine, limit };
840
- }
841
- return {};
842
- }
843
-
844
- interface ResolvedArchiveReadPath {
845
- absolutePath: string;
846
- archiveSubPath: string;
847
- suffixResolution?: { from: string; to: string };
848
- }
849
-
850
- interface ResolvedSqliteReadPath {
851
- absolutePath: string;
852
- sqliteSubPath: string;
853
- queryString: string;
854
- suffixResolution?: { from: string; to: string };
855
- }
856
-
857
- /** Per-execute memo of suffix-glob lookups; `null` records a confirmed miss. */
858
- type SuffixMatchCache = Map<string, { absolutePath: string; displayPath: string } | null>;
859
-
860
383
  /**
861
384
  * Read tool implementation.
862
385
  *
@@ -1013,307 +536,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1013
536
  return toolResult<ReadToolDetails>({ notes, displayReadTargets }).content(content).done();
1014
537
  }
1015
538
 
1016
- /**
1017
- * Memoized {@link findUniqueWorkspaceSuffix} for a single read call. A missing
1018
- * path with archive/sqlite extensions probes the workspace once per stage
1019
- * (archive candidates, sqlite candidates, plain path) — each glob carries a
1020
- * 5s timeout, so repeated lookups of the same string stack into a long
1021
- * stall before erroring. The cache collapses repeats within one execute().
1022
- */
1023
- async #findSuffixMatchCached(
1024
- cache: SuffixMatchCache,
1025
- rawPath: string,
1026
- signal?: AbortSignal,
1027
- ): Promise<{ absolutePath: string; displayPath: string } | null> {
1028
- const hit = cache.get(rawPath);
1029
- if (hit !== undefined) return hit;
1030
- const result = await findUniqueWorkspaceSuffix(rawPath, this.session.cwd, signal);
1031
- cache.set(rawPath, result);
1032
- return result;
1033
- }
1034
-
1035
- async #resolveArchiveReadPath(
1036
- readPath: string,
1037
- suffixCache: SuffixMatchCache,
1038
- signal?: AbortSignal,
1039
- ): Promise<ResolvedArchiveReadPath | null> {
1040
- const candidates = parseArchivePathCandidates(readPath);
1041
- for (const candidate of candidates) {
1042
- let absolutePath = resolveReadPath(candidate.archivePath, this.session.cwd);
1043
- let suffixResolution: { from: string; to: string } | undefined;
1044
-
1045
- try {
1046
- const stat = await Bun.file(absolutePath).stat();
1047
- if (stat.isDirectory()) continue;
1048
- return {
1049
- absolutePath,
1050
- archiveSubPath: candidate.archivePath === readPath ? "" : candidate.subPath,
1051
- suffixResolution,
1052
- };
1053
- } catch (error) {
1054
- if (!isNotFoundError(error) || isRemoteMountPath(absolutePath)) continue;
1055
-
1056
- const suffixMatch = await this.#findSuffixMatchCached(suffixCache, candidate.archivePath, signal);
1057
- if (!suffixMatch) continue;
1058
-
1059
- try {
1060
- const retryStat = await Bun.file(suffixMatch.absolutePath).stat();
1061
- if (retryStat.isDirectory()) continue;
1062
-
1063
- absolutePath = suffixMatch.absolutePath;
1064
- suffixResolution = { from: candidate.archivePath, to: suffixMatch.displayPath };
1065
- return {
1066
- absolutePath,
1067
- archiveSubPath: candidate.archivePath === readPath ? "" : candidate.subPath,
1068
- suffixResolution,
1069
- };
1070
- } catch (retryError) {
1071
- if (!isNotFoundError(retryError)) {
1072
- throw retryError;
1073
- }
1074
- }
1075
- }
1076
- }
1077
-
1078
- return null;
1079
- }
1080
-
1081
- async #resolveSqliteReadPath(
1082
- readPath: string,
1083
- suffixCache: SuffixMatchCache,
1084
- signal?: AbortSignal,
1085
- ): Promise<ResolvedSqliteReadPath | null> {
1086
- const candidates = parseSqlitePathCandidates(readPath);
1087
- for (const candidate of candidates) {
1088
- let absolutePath = resolveReadPath(candidate.sqlitePath, this.session.cwd);
1089
- let suffixResolution: { from: string; to: string } | undefined;
1090
-
1091
- try {
1092
- const stat = await Bun.file(absolutePath).stat();
1093
- if (stat.isDirectory()) continue;
1094
- if (!(await isSqliteFile(absolutePath))) continue;
1095
-
1096
- return {
1097
- absolutePath,
1098
- sqliteSubPath: candidate.subPath,
1099
- queryString: candidate.queryString,
1100
- suffixResolution,
1101
- };
1102
- } catch (error) {
1103
- if (!isNotFoundError(error) || isRemoteMountPath(absolutePath)) continue;
1104
-
1105
- const suffixMatch = await this.#findSuffixMatchCached(suffixCache, candidate.sqlitePath, signal);
1106
- if (!suffixMatch) continue;
1107
-
1108
- try {
1109
- const retryStat = await Bun.file(suffixMatch.absolutePath).stat();
1110
- if (retryStat.isDirectory()) continue;
1111
- if (!(await isSqliteFile(suffixMatch.absolutePath))) continue;
1112
-
1113
- absolutePath = suffixMatch.absolutePath;
1114
- suffixResolution = { from: candidate.sqlitePath, to: suffixMatch.displayPath };
1115
- return {
1116
- absolutePath,
1117
- sqliteSubPath: candidate.subPath,
1118
- queryString: candidate.queryString,
1119
- suffixResolution,
1120
- };
1121
- } catch (retryError) {
1122
- if (!isNotFoundError(retryError)) {
1123
- throw retryError;
1124
- }
1125
- }
1126
- }
1127
- }
1128
-
1129
- return null;
1130
- }
1131
-
1132
- #pdfImageCacheDir(absolutePdfPath: string, contentDigest: string): string {
1133
- const artifactsDir = this.session.getArtifactsDir?.();
1134
- let root = artifactsDir ?? undefined;
1135
- if (root === undefined) {
1136
- const sessionFile = this.session.getSessionFile();
1137
- root = sessionFile?.endsWith(".jsonl")
1138
- ? sessionFile.slice(0, -6)
1139
- : path.join(os.tmpdir(), "omp-read-pdf-images");
1140
- }
1141
- const basename = path
1142
- .basename(absolutePdfPath)
1143
- .replace(/[^A-Za-z0-9._-]/g, "_")
1144
- .slice(0, PDF_IMAGE_CACHE_BASENAME_MAX_LENGTH);
1145
- const pathDigest = Bun.hash(absolutePdfPath).toString(36);
1146
- return path.join(root, "read-pdf-images", `${basename}-${pathDigest}-${contentDigest}`);
1147
- }
1148
-
1149
- async #snapshotPdfSource(absolutePdfPath: string, signal?: AbortSignal): Promise<PdfImageSnapshot> {
1150
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), "omp-read-pdf-"));
1151
- try {
1152
- const bytes = await untilAborted(signal, () => Bun.file(absolutePdfPath).bytes());
1153
- signal?.throwIfAborted();
1154
- const digest = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
1155
- const filePath = path.join(directory, "source.pdf");
1156
- await Bun.write(filePath, bytes);
1157
- signal?.throwIfAborted();
1158
- return { directory, filePath, digest };
1159
- } catch (error) {
1160
- await fs.rm(directory, { recursive: true, force: true });
1161
- throw error;
1162
- }
1163
- }
1164
-
1165
- async #listPdfImageMembers(imageDir: string): Promise<string[]> {
1166
- try {
1167
- const entries = await fs.readdir(imageDir, { withFileTypes: true });
1168
- const members: string[] = [];
1169
- for (const entry of entries) {
1170
- if (entry.isFile() && PDF_IMAGE_MEMBER_EXTENSION_RE.test(entry.name)) members.push(entry.name);
1171
- }
1172
- return members.sort();
1173
- } catch (error) {
1174
- if (isNotFoundError(error)) return [];
1175
- throw error;
1176
- }
1177
- }
1178
-
1179
- async #extractPdfImages(snapshot: PdfImageSnapshot, imageDir: string, signal: AbortSignal): Promise<string> {
1180
- const markerPath = path.join(imageDir, ".extracted");
1181
- try {
1182
- await fs.stat(markerPath);
1183
- return imageDir;
1184
- } catch (error) {
1185
- if (!isNotFoundError(error)) throw error;
1186
- }
1187
-
1188
- await fs.mkdir(path.dirname(imageDir), { recursive: true });
1189
- const stagingDir = await fs.mkdtemp(`${imageDir}.tmp-`);
1190
- let published = false;
1191
- try {
1192
- const result = await convertFileWithMarkit(snapshot.filePath, signal, { imageDir: stagingDir });
1193
- if (!result.ok) {
1194
- throw new ToolError(`Cannot extract images from PDF: ${result.error ?? "conversion failed"}`);
1195
- }
1196
- await Bun.write(path.join(stagingDir, ".extracted"), "ok");
1197
- try {
1198
- await fs.rename(stagingDir, imageDir);
1199
- published = true;
1200
- } catch (error) {
1201
- if (!isEexist(error) && !isEnotempty(error)) throw error;
1202
- try {
1203
- await fs.stat(markerPath);
1204
- } catch (markerError) {
1205
- if (isNotFoundError(markerError)) throw error;
1206
- throw markerError;
1207
- }
1208
- }
1209
- return imageDir;
1210
- } finally {
1211
- if (!published) await fs.rm(stagingDir, { recursive: true, force: true });
1212
- }
1213
- }
1214
-
1215
- #createPdfImageExtraction(snapshot: PdfImageSnapshot, imageDir: string): PdfImageExtraction {
1216
- const controller = new AbortController();
1217
- const promise = this.#extractPdfImages(snapshot, imageDir, controller.signal).finally(() =>
1218
- fs.rm(snapshot.directory, { recursive: true, force: true }),
1219
- );
1220
- const extraction: PdfImageExtraction = { controller, promise, settled: false, waiters: 0 };
1221
- const settle = () => {
1222
- extraction.settled = true;
1223
- if (pdfImageExtractions.get(imageDir) === extraction) pdfImageExtractions.delete(imageDir);
1224
- };
1225
- void promise.then(settle, settle);
1226
- return extraction;
1227
- }
1228
-
1229
- async #waitForPdfImageExtraction(extraction: PdfImageExtraction, signal: AbortSignal | undefined): Promise<string> {
1230
- extraction.waiters++;
1231
- try {
1232
- return await untilAborted(signal, extraction.promise);
1233
- } finally {
1234
- extraction.waiters--;
1235
- if (extraction.waiters === 0 && !extraction.settled) {
1236
- extraction.controller.abort();
1237
- try {
1238
- await extraction.promise;
1239
- } catch {}
1240
- }
1241
- }
1242
- }
1243
-
1244
- async #ensurePdfImageCache(absolutePdfPath: string, signal?: AbortSignal): Promise<string> {
1245
- const snapshot = await this.#snapshotPdfSource(absolutePdfPath, signal);
1246
- const imageDir = this.#pdfImageCacheDir(absolutePdfPath, snapshot.digest);
1247
- const existing = pdfImageExtractions.get(imageDir);
1248
- if (existing && !existing.settled && !existing.controller.signal.aborted) {
1249
- await fs.rm(snapshot.directory, { recursive: true, force: true });
1250
- return this.#waitForPdfImageExtraction(existing, signal);
1251
- }
1252
-
1253
- const extraction = this.#createPdfImageExtraction(snapshot, imageDir);
1254
- pdfImageExtractions.set(imageDir, extraction);
1255
- return this.#waitForPdfImageExtraction(extraction, signal);
1256
- }
1257
-
1258
- async #readPdfImageMember(
1259
- absolutePdfPath: string,
1260
- pdfDisplayPath: string,
1261
- member: string,
1262
- suffixResolution: { from: string; to: string } | undefined,
1263
- signal?: AbortSignal,
1264
- ): Promise<AgentToolResult<ReadToolDetails>> {
1265
- const imageDir = await this.#ensurePdfImageCache(absolutePdfPath, signal);
1266
- const members = await this.#listPdfImageMembers(imageDir);
1267
- if (member.length === 0) {
1268
- const text =
1269
- members.length === 0
1270
- ? "No extractable PDF image members found."
1271
- : `Extractable PDF image members:\n${members
1272
- .map(imageMember => `- read \`${pdfDisplayPath}:${imageMember}\``)
1273
- .join("\n")}`;
1274
- return toolResult<ReadToolDetails>({ resolvedPath: absolutePdfPath, suffixResolution })
1275
- .text(prependSuffixResolutionNotice(text, suffixResolution))
1276
- .sourcePath(absolutePdfPath)
1277
- .done();
1278
- }
1279
-
1280
- if (!members.includes(member)) {
1281
- const available = members.length === 0 ? "(none)" : members.join(", ");
1282
- throw new ToolError(`PDF image member '${member}' not found. Available members: ${available}`);
1283
- }
1284
-
1285
- const imagePath = path.join(imageDir, member);
1286
- const imageStat = await Bun.file(imagePath).stat();
1287
- if (imageStat.size > MAX_IMAGE_SIZE) {
1288
- const sizeStr = formatBytes(imageStat.size);
1289
- const maxStr = formatBytes(MAX_IMAGE_SIZE);
1290
- throw new ToolError(`Image file too large: ${sizeStr} exceeds ${maxStr} limit.`);
1291
- }
1292
- const metadata = await readImageMetadata(imagePath);
1293
- const mimeType = metadata?.mimeType;
1294
- if (!mimeType) throw new ToolError(`PDF image member '${member}' is not a supported image.`);
1295
- const imageInput = await loadImageInput({
1296
- path: `${pdfDisplayPath}:${member}`,
1297
- cwd: this.session.cwd,
1298
- autoResize: this.#autoResizeImages,
1299
- maxBytes: MAX_IMAGE_SIZE,
1300
- resolvedPath: imagePath,
1301
- detectedMimeType: mimeType,
1302
- excludeWebP: webpExclusionForModel(this.session.getActiveModel?.()),
1303
- });
1304
- if (!imageInput) {
1305
- throw new ToolError(`Read image file [${mimeType}] failed: unsupported image format.`);
1306
- }
1307
- const textNote = prependSuffixResolutionNotice(imageInput.textNote, suffixResolution);
1308
- return toolResult<ReadToolDetails>({ resolvedPath: absolutePdfPath, suffixResolution })
1309
- .content([
1310
- { type: "text", text: textNote },
1311
- { type: "image", data: imageInput.data, mimeType: imageInput.mimeType },
1312
- ])
1313
- .sourcePath(imageInput.resolvedPath)
1314
- .done();
1315
- }
1316
-
1317
539
  /**
1318
540
  * Build content blocks for an on-disk image file: an `inspect_image`
1319
541
  * metadata note when inspection is active, otherwise the decoded image
@@ -1387,291 +609,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1387
609
  }
1388
610
  }
1389
611
 
1390
- #buildInMemoryTextResult(
1391
- text: string,
1392
- offset: number | undefined,
1393
- limit: number | undefined,
1394
- options: {
1395
- details?: ReadToolDetails;
1396
- sourcePath?: string;
1397
- sourceUrl?: string;
1398
- sourceInternal?: string;
1399
- entityLabel: string;
1400
- ignoreResultLimits?: boolean;
1401
- raw?: boolean;
1402
- immutable?: boolean;
1403
- },
1404
- ): AgentToolResult<ReadToolDetails> {
1405
- const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw, immutable: options.immutable });
1406
- const details = options.details ?? {};
1407
- const allLines = text.split("\n");
1408
- const totalLines = allLines.length;
1409
- details.totalLines = totalLines;
1410
- // User-requested 0-indexed range start. Lines BEFORE this are leading
1411
- // context (added below if offset is explicit).
1412
- const requestedStart = offset ? Math.max(0, offset - 1) : 0;
1413
- const ignoreResultLimits = options.ignoreResultLimits ?? false;
1414
- const requestedEnd = limit !== undefined ? Math.min(requestedStart + limit, allLines.length) : allLines.length;
1415
- // Expand only on sides the user actually constrained: leading context
1416
- // when offset>1, trailing context when a finite limit was set. Raw mode
1417
- // never expands — without line numbers the padding is indistinguishable
1418
- // from requested content, so `raw:31-31` must return line 31 and nothing
1419
- // else (verbatim-extraction contract).
1420
- const rawDisplay = options.raw === true;
1421
- const expanded = expandRangeWithContext(
1422
- requestedStart,
1423
- requestedEnd,
1424
- allLines.length,
1425
- !rawDisplay && offset !== undefined && offset > 1,
1426
- !rawDisplay && limit !== undefined,
1427
- );
1428
- const startLine = expanded.startLine;
1429
- const endLineExpanded = expanded.endLine;
1430
- const startLineDisplay = startLine + 1;
1431
-
1432
- const resultBuilder = toolResult(details);
1433
- if (options.sourcePath) {
1434
- resultBuilder.sourcePath(options.sourcePath);
1435
- }
1436
- if (options.sourceUrl) {
1437
- resultBuilder.sourceUrl(options.sourceUrl);
1438
- }
1439
- if (options.sourceInternal) {
1440
- resultBuilder.sourceInternal(options.sourceInternal);
1441
- }
1442
-
1443
- if (requestedStart >= allLines.length) {
1444
- const suggestion =
1445
- allLines.length === 0
1446
- ? `The ${options.entityLabel} is empty.`
1447
- : `Use :1 to read from the start, or :${allLines.length} to read the last line.`;
1448
- return resultBuilder
1449
- .text(
1450
- `Line ${requestedStart + 1} is beyond end of ${options.entityLabel} (${allLines.length} lines total). ${suggestion}`,
1451
- )
1452
- .done();
1453
- }
1454
-
1455
- const endLine = endLineExpanded;
1456
- const selectedContent = allLines.slice(startLine, endLine).join("\n");
1457
- const userLimitedLines = limit !== undefined ? endLine - startLine : undefined;
1458
- const truncation = ignoreResultLimits ? noTruncResult(selectedContent) : truncateHead(selectedContent);
1459
-
1460
- const shouldAddHashLines = displayMode.hashLines;
1461
- const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
1462
- const hashContext =
1463
- shouldAddHashLines && options.sourcePath
1464
- ? recordFullHashlineContext(
1465
- this.session,
1466
- options.sourcePath,
1467
- formatPathRelativeToCwd(options.sourcePath, this.session.cwd),
1468
- text,
1469
- )
1470
- : undefined;
1471
- let emittedHashlineHeader = false;
1472
- let seenLines: number[] | undefined;
1473
- let rawSeenLines: number[] | undefined;
1474
- const formatText = (content: string, startNum: number): string => {
1475
- const lineCount = countTextLines(content);
1476
- details.displayContent = {
1477
- text: content,
1478
- startLine: startNum,
1479
- lineNumbers: Array.from({ length: lineCount }, (_, i) => startNum + i),
1480
- };
1481
- if (shouldAddHashLines) seenLines = contiguousLineNumbers(startNum, lineCount);
1482
- const formatted = formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers);
1483
- if (!hashContext || emittedHashlineHeader) return formatted;
1484
- emittedHashlineHeader = true;
1485
- return prependHashlineHeader(formatted, hashContext);
1486
- };
1487
- const formatLineEntries = (entries: readonly LineEntry[], startNum: number): string => {
1488
- const firstLine = entries.find(entry => entry.kind === "line");
1489
- details.displayContent = {
1490
- text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
1491
- startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startNum,
1492
- lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
1493
- };
1494
- if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries);
1495
- const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
1496
- if (!hashContext || emittedHashlineHeader) return formatted;
1497
- emittedHashlineHeader = true;
1498
- return prependHashlineHeader(formatted, hashContext);
1499
- };
1500
- const buildLineEntries = (endLineDisplay: number): LineEntry[] =>
1501
- buildLineEntriesWithBlockContext(allLines, [{ startLine: startLineDisplay, endLine: endLineDisplay }], {
1502
- path: options.sourcePath,
1503
- });
1504
-
1505
- let outputText: string;
1506
- let truncationInfo:
1507
- | { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
1508
- | undefined;
1509
-
1510
- if (truncation.firstLineExceedsLimit) {
1511
- const firstLine = allLines[startLine] ?? "";
1512
- const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
1513
- const snippet = truncateHeadBytes(firstLine, DEFAULT_MAX_BYTES);
1514
-
1515
- if (shouldAddHashLines) {
1516
- outputText = `[Line ${startLineDisplay} is ${formatBytes(
1517
- firstLineBytes,
1518
- )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Hashline output requires full lines; cannot emit an editable numbered preview for a truncated line.]`;
1519
- } else {
1520
- outputText = formatText(snippet.text, startLineDisplay);
1521
- }
1522
-
1523
- if (snippet.text.length === 0) {
1524
- outputText = `[Line ${startLineDisplay} is ${formatBytes(
1525
- firstLineBytes,
1526
- )}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`;
1527
- }
1528
-
1529
- details.truncation = truncation;
1530
- truncationInfo = {
1531
- result: truncation,
1532
- options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines },
1533
- };
1534
- } else if (truncation.truncated) {
1535
- const outputLines = truncation.outputLines ?? countTextLines(truncation.content);
1536
- const endLineDisplay = startLineDisplay + Math.max(0, outputLines - 1);
1537
- if (options.raw === true) {
1538
- rawSeenLines = contiguousLineNumbers(startLineDisplay, outputLines);
1539
- outputText = formatText(truncation.content, startLineDisplay);
1540
- } else {
1541
- outputText = formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay);
1542
- }
1543
- details.truncation = truncation;
1544
- truncationInfo = {
1545
- result: truncation,
1546
- options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines },
1547
- };
1548
- } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
1549
- const remaining = allLines.length - (startLine + userLimitedLines);
1550
- const nextOffset = startLine + userLimitedLines + 1;
1551
-
1552
- if (options.raw === true) {
1553
- rawSeenLines = contiguousLineNumbers(startLineDisplay, userLimitedLines);
1554
- outputText = formatText(selectedContent, startLineDisplay);
1555
- } else {
1556
- outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1557
- }
1558
- outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`;
1559
- } else {
1560
- if (options.raw === true) {
1561
- rawSeenLines = contiguousLineNumbers(startLineDisplay, endLine - startLine);
1562
- outputText = formatText(truncation.content, startLineDisplay);
1563
- } else {
1564
- outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1565
- }
1566
- }
1567
-
1568
- if (hashContext?.tag && options.sourcePath && seenLines) {
1569
- recordSeenLines(this.session, options.sourcePath, hashContext.tag, seenLines);
1570
- }
1571
- if (options.raw === true && options.sourcePath && options.immutable !== true && rawSeenLines) {
1572
- recordInMemorySeenLines(this.session, options.sourcePath, text, rawSeenLines);
1573
- }
1574
- resultBuilder.text(outputText);
1575
- if (truncationInfo) {
1576
- resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
1577
- }
1578
- return resultBuilder.done();
1579
- }
1580
-
1581
- /**
1582
- * Render a multi-range read against in-memory text. Each range emits a
1583
- * formatted block with its own anchors / line numbers, blocks are joined
1584
- * with an elision separator, and ranges past EOF surface as `[…]` notices
1585
- * so the model can correct the next call. No leading/trailing context is
1586
- * added — multi-range callers always specify exact bounds.
1587
- */
1588
- #buildInMemoryMultiRangeResult(
1589
- text: string,
1590
- ranges: readonly LineRange[],
1591
- options: {
1592
- details?: ReadToolDetails;
1593
- sourcePath?: string;
1594
- sourceUrl?: string;
1595
- sourceInternal?: string;
1596
- entityLabel: string;
1597
- raw?: boolean;
1598
- immutable?: boolean;
1599
- },
1600
- ): AgentToolResult<ReadToolDetails> {
1601
- const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw, immutable: options.immutable });
1602
- const details = options.details ?? {};
1603
- const allLines = text.split("\n");
1604
- const totalLines = allLines.length;
1605
- details.totalLines = totalLines;
1606
- const shouldAddHashLines = displayMode.hashLines;
1607
- const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
1608
- const hashContext =
1609
- shouldAddHashLines && options.sourcePath
1610
- ? recordFullHashlineContext(
1611
- this.session,
1612
- options.sourcePath,
1613
- formatPathRelativeToCwd(options.sourcePath, this.session.cwd),
1614
- text,
1615
- )
1616
- : undefined;
1617
- let emittedHashlineHeader = false;
1618
-
1619
- let seenLines: number[] | undefined;
1620
- const resultBuilder = toolResult(details);
1621
- if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath);
1622
- if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl);
1623
- if (options.sourceInternal) resultBuilder.sourceInternal(options.sourceInternal);
1624
-
1625
- const outOfBounds: LineRange[] = [];
1626
- const visibleSpans: Array<{ startLine: number; endLine: number }> = [];
1627
- const rawParts: string[] = [];
1628
- for (const range of ranges) {
1629
- if (range.startLine > totalLines) {
1630
- outOfBounds.push(range);
1631
- continue;
1632
- }
1633
- const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines);
1634
- visibleSpans.push({ startLine: range.startLine, endLine: effectiveEnd });
1635
- if (options.raw === true) {
1636
- rawParts.push(allLines.slice(range.startLine - 1, effectiveEnd).join("\n"));
1637
- }
1638
- }
1639
-
1640
- let outputText = "";
1641
- if (options.raw === true) {
1642
- outputText = rawParts.length > 0 ? rawParts.join("\n\n…\n\n") : "";
1643
- } else if (visibleSpans.length > 0) {
1644
- const entries = buildLineEntriesWithBlockContext(allLines, visibleSpans, { path: options.sourcePath });
1645
- if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries);
1646
- const firstLine = entries.find(entry => entry.kind === "line");
1647
- if (firstLine?.kind === "line") {
1648
- details.displayContent = {
1649
- text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
1650
- startLine: firstLine.lineNumber,
1651
- lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
1652
- };
1653
- }
1654
- const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
1655
- outputText = hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted;
1656
- if (hashContext) emittedHashlineHeader = true;
1657
- }
1658
- const notices: string[] = [];
1659
- for (const range of outOfBounds) {
1660
- const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`;
1661
- notices.push(`[Range ${bound} is beyond end of ${options.entityLabel} (${totalLines} lines total); skipped]`);
1662
- }
1663
- const finalText =
1664
- notices.length > 0 ? (outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n")) : outputText;
1665
- if (hashContext?.tag && options.sourcePath && seenLines) {
1666
- recordSeenLines(this.session, options.sourcePath, hashContext.tag, seenLines);
1667
- }
1668
- if (options.raw === true && options.sourcePath && options.immutable !== true && visibleSpans.length > 0) {
1669
- recordInMemorySeenLines(this.session, options.sourcePath, text, lineNumbersFromSpans(visibleSpans));
1670
- }
1671
- resultBuilder.text(finalText);
1672
- return resultBuilder.done();
1673
- }
1674
-
1675
612
  /**
1676
613
  * Stream multiple non-contiguous ranges from a local file. ACP bridge takes
1677
614
  * priority when present (editor buffer is source of truth); otherwise each
@@ -1696,12 +633,16 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1696
633
  const rawSelector = isRawSelector(parsed);
1697
634
 
1698
635
  // ACP bridge first — the editor's in-memory buffer is source of truth.
1699
- const bridgePromise = allowBridge ? this.#routeReadThroughBridge(absolutePath) : undefined;
636
+ const bridgePromise = allowBridge ? routeReadThroughBridge(this.session, absolutePath) : undefined;
1700
637
  if (bridgePromise !== undefined) {
1701
638
  try {
1702
639
  const bridgeText = await bridgePromise;
1703
- const bridgeResult = this.#buildInMemoryMultiRangeResult(bridgeText, ranges, {
1704
- details: this.#markMarkdownContentType({ resolvedPath: absolutePath, suffixResolution }, absolutePath),
640
+ const bridgeResult = buildInMemoryMultiRangeResult(this.session, bridgeText, ranges, {
641
+ details: markMarkdownContentType(
642
+ this.session,
643
+ { resolvedPath: absolutePath, suffixResolution },
644
+ absolutePath,
645
+ ),
1705
646
  sourcePath: absolutePath,
1706
647
  entityLabel: "file",
1707
648
  raw: rawSelector,
@@ -1836,423 +777,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1836
777
  return { outputText, columnTruncated, displayContent };
1837
778
  }
1838
779
 
1839
- async #readArchiveDirectory(
1840
- archive: ArchiveReader,
1841
- archivePath: string,
1842
- subPath: string,
1843
- offset: number | undefined,
1844
- limit: number | undefined,
1845
- details: ReadToolDetails,
1846
- signal?: AbortSignal,
1847
- ): Promise<AgentToolResult<ReadToolDetails>> {
1848
- const DEFAULT_LIMIT = 500;
1849
- const effectiveLimit = limit ?? DEFAULT_LIMIT;
1850
- const allEntries = archive.listDirectory(subPath);
1851
- // `offset` is 1-indexed (line-selector semantics): `a.zip:dir:50` starts
1852
- // the listing at the 50th entry instead of being silently ignored.
1853
- const entries = offset !== undefined && offset > 1 ? allEntries.slice(offset - 1) : allEntries;
1854
-
1855
- const listLimit = applyListLimit(entries, { limit: effectiveLimit });
1856
- const limitedEntries = listLimit.items;
1857
- const limitMeta = listLimit.meta;
1858
-
1859
- for (let index = 0; index < limitedEntries.length; index++) {
1860
- throwIfAborted(signal);
1861
- }
1862
- const results = formatArchiveEntryLines(limitedEntries);
1863
-
1864
- const output = results.length > 0 ? results.join("\n") : "(empty archive directory)";
1865
- const text = prependSuffixResolutionNotice(output, details.suffixResolution);
1866
- const truncation = truncateHead(text, { maxLines: Number.MAX_SAFE_INTEGER });
1867
- const directoryDetails: ReadToolDetails = { ...details, isDirectory: true };
1868
- const resultBuilder = toolResult<ReadToolDetails>(directoryDetails).text(truncation.content);
1869
- resultBuilder.sourcePath(archivePath).limits({ resultLimit: limitMeta.resultLimit?.reached });
1870
- if (truncation.truncated) {
1871
- directoryDetails.truncation = truncation;
1872
- resultBuilder.truncation(truncation, { direction: "head" });
1873
- }
1874
- return resultBuilder.done();
1875
- }
1876
-
1877
- async #readArchive(
1878
- readPath: string,
1879
- parsedSel: ParsedSelector,
1880
- resolvedArchivePath: ResolvedArchiveReadPath,
1881
- signal?: AbortSignal,
1882
- ): Promise<AgentToolResult<ReadToolDetails>> {
1883
- throwIfAborted(signal);
1884
- const archive = await openArchive(resolvedArchivePath.absolutePath);
1885
- throwIfAborted(signal);
1886
-
1887
- const details: ReadToolDetails = this.#markMarkdownContentType(
1888
- {
1889
- resolvedPath: resolvedArchivePath.absolutePath,
1890
- suffixResolution: resolvedArchivePath.suffixResolution,
1891
- },
1892
- resolvedArchivePath.archiveSubPath,
1893
- );
1894
-
1895
- let archiveSubPath = resolvedArchivePath.archiveSubPath;
1896
- let sel = parsedSel;
1897
- let node = archive.getNode(archiveSubPath);
1898
- if (!node && archiveSubPath) {
1899
- // `archive.zip:500` / `archive.zip:raw`: the whole subPath is a
1900
- // selector on the archive root, not a member name. Member names take
1901
- // precedence (getNode above); fall back to root + selector.
1902
- const wholeSel = parseSel(archiveSubPath);
1903
- if (wholeSel.kind !== "none") {
1904
- node = archive.getNode("");
1905
- archiveSubPath = "";
1906
- sel = wholeSel;
1907
- }
1908
- }
1909
- if (!node) {
1910
- throw new ToolError(`Path '${readPath}' not found inside archive`);
1911
- }
1912
-
1913
- if (node.isDirectory) {
1914
- if (isMultiRange(sel)) {
1915
- throw new ToolError("Multi-range line selectors are not supported for archive directory listings.");
1916
- }
1917
- const { offset, limit } = selToOffsetLimit(sel);
1918
- return this.#readArchiveDirectory(
1919
- archive,
1920
- resolvedArchivePath.absolutePath,
1921
- archiveSubPath,
1922
- offset,
1923
- limit,
1924
- details,
1925
- signal,
1926
- );
1927
- }
1928
-
1929
- const entry = await archive.readFile(archiveSubPath);
1930
- const text = decodeUtf8Text(entry.bytes);
1931
- if (text === null) {
1932
- return toolResult<ReadToolDetails>(details)
1933
- .text(
1934
- prependSuffixResolutionNotice(
1935
- `[Cannot read binary archive entry '${entry.path}' (${formatBytes(entry.size)})]`,
1936
- resolvedArchivePath.suffixResolution,
1937
- ),
1938
- )
1939
- .sourcePath(resolvedArchivePath.absolutePath)
1940
- .done();
1941
- }
1942
-
1943
- // Archive members are immutable: there is no edit path for bytes inside
1944
- // an archive, and a hashline tag keyed to the archive file would invite
1945
- // (and fail) edits while clobbering sibling members' snapshots.
1946
- const raw = isRawSelector(sel);
1947
- const result =
1948
- isMultiRange(sel) && sel.kind === "lines"
1949
- ? this.#buildInMemoryMultiRangeResult(text, sel.ranges, {
1950
- details,
1951
- sourcePath: resolvedArchivePath.absolutePath,
1952
- entityLabel: "archive entry",
1953
- raw,
1954
- immutable: true,
1955
- })
1956
- : this.#buildInMemoryTextResult(text, selToOffsetLimit(sel).offset, selToOffsetLimit(sel).limit, {
1957
- details,
1958
- sourcePath: resolvedArchivePath.absolutePath,
1959
- entityLabel: "archive entry",
1960
- raw,
1961
- immutable: true,
1962
- });
1963
- const firstText = result.content.find((content): content is TextContent => content.type === "text");
1964
- if (firstText) {
1965
- firstText.text = prependSuffixResolutionNotice(firstText.text, resolvedArchivePath.suffixResolution);
1966
- }
1967
- return result;
1968
- }
1969
-
1970
- async #readSqlite(
1971
- resolvedSqlitePath: ResolvedSqliteReadPath,
1972
- signal?: AbortSignal,
1973
- ): Promise<AgentToolResult<ReadToolDetails>> {
1974
- throwIfAborted(signal);
1975
-
1976
- const selectorInput = {
1977
- subPath: resolvedSqlitePath.sqliteSubPath,
1978
- queryString: resolvedSqlitePath.queryString,
1979
- };
1980
- const selector = parseSqliteSelector(selectorInput.subPath, selectorInput.queryString);
1981
- const details: ReadToolDetails = {
1982
- resolvedPath: resolvedSqlitePath.absolutePath,
1983
- suffixResolution: resolvedSqlitePath.suffixResolution,
1984
- };
1985
-
1986
- let db: Database | null = null;
1987
- try {
1988
- db = new Database(resolvedSqlitePath.absolutePath, { readonly: true, strict: true });
1989
- db.run("PRAGMA busy_timeout = 3000");
1990
- throwIfAborted(signal);
1991
-
1992
- switch (selector.kind) {
1993
- case "list": {
1994
- const listLimit = applyListLimit(listTables(db), { limit: 500 });
1995
- const output = prependSuffixResolutionNotice(
1996
- renderTableList(listLimit.items),
1997
- resolvedSqlitePath.suffixResolution,
1998
- );
1999
- const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
2000
- details.truncation = truncation.truncated ? truncation : undefined;
2001
- const resultBuilder = toolResult<ReadToolDetails>(details)
2002
- .text(truncation.content)
2003
- .sourcePath(resolvedSqlitePath.absolutePath)
2004
- .limits({ resultLimit: listLimit.meta.resultLimit?.reached });
2005
- if (truncation.truncated) {
2006
- resultBuilder.truncation(truncation, { direction: "head" });
2007
- }
2008
- return resultBuilder.done();
2009
- }
2010
- case "schema": {
2011
- const sampleRows = queryRows(db, selector.table, { limit: selector.sampleLimit, offset: 0 });
2012
- let output = renderSchema(getTableSchema(db, selector.table), {
2013
- columns: sampleRows.columns,
2014
- rows: sampleRows.rows,
2015
- });
2016
- if (sampleRows.rows.length < sampleRows.totalCount) {
2017
- const remaining = sampleRows.totalCount - sampleRows.rows.length;
2018
- output += `\n[${remaining} more rows; append :${selector.table}?limit=20&offset=${sampleRows.rows.length} to the database path to continue]`;
2019
- }
2020
- return toolResult<ReadToolDetails>(details)
2021
- .text(prependSuffixResolutionNotice(output, resolvedSqlitePath.suffixResolution))
2022
- .sourcePath(resolvedSqlitePath.absolutePath)
2023
- .done();
2024
- }
2025
- case "row": {
2026
- const lookup = resolveTableRowLookup(db, selector.table);
2027
- const row =
2028
- lookup.kind === "pk"
2029
- ? getRowByKey(db, selector.table, lookup, selector.key)
2030
- : getRowByRowId(db, selector.table, selector.key);
2031
- if (!row) {
2032
- return toolResult<ReadToolDetails>(details)
2033
- .text(
2034
- prependSuffixResolutionNotice(
2035
- `No row found in table '${selector.table}' for key '${selector.key}'.`,
2036
- resolvedSqlitePath.suffixResolution,
2037
- ),
2038
- )
2039
- .sourcePath(resolvedSqlitePath.absolutePath)
2040
- .done();
2041
- }
2042
- return toolResult<ReadToolDetails>(details)
2043
- .text(prependSuffixResolutionNotice(renderRow(row), resolvedSqlitePath.suffixResolution))
2044
- .sourcePath(resolvedSqlitePath.absolutePath)
2045
- .done();
2046
- }
2047
- case "query": {
2048
- const page = queryRows(db, selector.table, selector);
2049
- return toolResult<ReadToolDetails>(details)
2050
- .text(
2051
- prependSuffixResolutionNotice(
2052
- renderTable(page.columns, page.rows, {
2053
- totalCount: page.totalCount,
2054
- offset: selector.offset,
2055
- limit: selector.limit,
2056
- table: selector.table,
2057
- dbPath: resolvedSqlitePath.absolutePath,
2058
- }),
2059
- resolvedSqlitePath.suffixResolution,
2060
- ),
2061
- )
2062
- .sourcePath(resolvedSqlitePath.absolutePath)
2063
- .done();
2064
- }
2065
- case "raw": {
2066
- const result = executeReadQuery(db, selector.sql);
2067
- let output = renderTable(result.columns, result.rows, {
2068
- totalCount: result.rows.length,
2069
- offset: 0,
2070
- limit: result.rows.length || DEFAULT_MAX_LINES,
2071
- table: "query",
2072
- dbPath: resolvedSqlitePath.absolutePath,
2073
- });
2074
- if (result.truncated) {
2075
- output += `\n[Output capped at ${MAX_RAW_QUERY_ROWS} rows; add a LIMIT/OFFSET clause to the query to page through more]`;
2076
- }
2077
- return toolResult<ReadToolDetails>(details)
2078
- .text(prependSuffixResolutionNotice(output, resolvedSqlitePath.suffixResolution))
2079
- .sourcePath(resolvedSqlitePath.absolutePath)
2080
- .done();
2081
- }
2082
- }
2083
-
2084
- throw new ToolError("Unsupported SQLite selector");
2085
- } catch (error) {
2086
- if (error instanceof ToolError) {
2087
- throw error;
2088
- }
2089
- throw new ToolError(error instanceof Error ? error.message : String(error));
2090
- } finally {
2091
- db?.close();
2092
- }
2093
- }
2094
-
2095
- #routeReadThroughBridge(
2096
- absolutePath: string,
2097
- options?: { line?: number; limit?: number },
2098
- ): Promise<string> | undefined {
2099
- const bridge = this.session.getClientBridge?.();
2100
- if (!bridge?.capabilities.readTextFile || !bridge.readTextFile) return undefined;
2101
- return bridge.readTextFile({ path: absolutePath, ...options });
2102
- }
2103
-
2104
- /**
2105
- * Tag Markdown reads for the TUI's formatted preview, gated on the opt-in
2106
- * `read.renderMarkdown` setting. Off by default; when disabled, no local
2107
- * read is tagged `text/markdown`, so the renderer output is identical to
2108
- * the pre-setting behavior. Internal-URL reads keep their protocol-supplied
2109
- * `contentType` and render as Markdown regardless of the setting.
2110
- */
2111
- #markMarkdownContentType(details: ReadToolDetails, filePath: string): ReadToolDetails {
2112
- if (!details.contentType && this.session.settings.get("read.renderMarkdown") && isMarkdownPath(filePath)) {
2113
- details.contentType = "text/markdown";
2114
- }
2115
- return details;
2116
- }
2117
-
2118
- async #trySummarize(absolutePath: string, fileSize: number, signal?: AbortSignal): Promise<SummaryResult | null> {
2119
- if (fileSize > MAX_SUMMARY_BYTES) return null;
2120
-
2121
- try {
2122
- throwIfAborted(signal);
2123
- const bridgePromise = this.#routeReadThroughBridge(absolutePath);
2124
- const code =
2125
- bridgePromise !== undefined
2126
- ? await bridgePromise.catch(() => Bun.file(absolutePath).text())
2127
- : await Bun.file(absolutePath).text();
2128
- throwIfAborted(signal);
2129
- const lineCount = countTextLines(code);
2130
- if (lineCount > MAX_SUMMARY_LINES) return null;
2131
- if (lineCount < this.session.settings.get("read.summarize.minTotalLines")) return null;
2132
-
2133
- const minBodyLines = this.session.settings.get("read.summarize.minBodyLines");
2134
- const minCommentLines = this.session.settings.get("read.summarize.minCommentLines");
2135
- const unfoldUntilLines = this.session.settings.get("read.summarize.unfoldUntil");
2136
- const unfoldLimitLines = this.session.settings.get("read.summarize.unfoldLimit");
2137
- const cache = getSummaryParseCache(this.session);
2138
- const cacheKey = `${absolutePath}\0${Bun.hash(code)}\0${minBodyLines},${minCommentLines},${unfoldUntilLines},${unfoldLimitLines}`;
2139
- const memoized = cache.get(cacheKey);
2140
- if (memoized !== undefined) return memoized || null;
2141
- const result = summarizeCode({
2142
- code,
2143
- path: absolutePath,
2144
- minBodyLines,
2145
- minCommentLines,
2146
- unfoldUntilLines,
2147
- unfoldLimitLines,
2148
- });
2149
- const usable = result.parsed && result.elided ? result : false;
2150
- cache.set(cacheKey, usable);
2151
- return usable || null;
2152
- } catch {
2153
- return null;
2154
- }
2155
- }
2156
-
2157
- #renderSummary(summary: SummaryResult): {
2158
- text: string;
2159
- displayText: string;
2160
- elidedRanges: ElidedRange[];
2161
- elidedLines: number;
2162
- } {
2163
- const displayMode = resolveFileDisplayMode(this.session);
2164
- const shouldAddHashLines = displayMode.hashLines;
2165
- const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
2166
-
2167
- // Flatten segments into per-line units so we can merge a kept-head /
2168
- // elided / kept-tail sandwich into a single brace-pair line when the
2169
- // boundary lines look like `… {` and `}` (or matching variants).
2170
- type Unit =
2171
- | { kind: "line"; line: number; text: string }
2172
- | { kind: "elided"; startLine: number; endLine: number }
2173
- | {
2174
- kind: "merged";
2175
- startLine: number;
2176
- endLine: number;
2177
- headText: string;
2178
- tailText: string;
2179
- };
2180
-
2181
- const raw: Unit[] = [];
2182
- for (const segment of summary.segments) {
2183
- if (segment.kind === "elided") {
2184
- raw.push({ kind: "elided", startLine: segment.startLine, endLine: segment.endLine });
2185
- continue;
2186
- }
2187
- const text = segment.text ?? "";
2188
- if (text.length === 0) continue;
2189
- const lines = text.split("\n");
2190
- for (let i = 0; i < lines.length; i++) {
2191
- raw.push({ kind: "line", line: segment.startLine + i, text: lines[i] });
2192
- }
2193
- }
2194
-
2195
- const units: Unit[] = [];
2196
- let i = 0;
2197
- while (i < raw.length) {
2198
- const cur = raw[i];
2199
- if (cur.kind === "elided") {
2200
- const prev = units.length > 0 ? units[units.length - 1] : null;
2201
- const next = i + 1 < raw.length ? raw[i + 1] : null;
2202
- if (prev?.kind === "line" && next?.kind === "line" && canMergeBracePair(prev.text, next.text)) {
2203
- units.pop();
2204
- units.push({
2205
- kind: "merged",
2206
- startLine: prev.line,
2207
- endLine: next.line,
2208
- headText: prev.text,
2209
- tailText: next.text,
2210
- });
2211
- i += 2;
2212
- continue;
2213
- }
2214
- }
2215
- units.push(cur);
2216
- i++;
2217
- }
2218
-
2219
- const modelParts: string[] = [];
2220
- const displayParts: string[] = [];
2221
- const elidedRanges: ElidedRange[] = [];
2222
- let elidedLines = 0;
2223
- for (const unit of units) {
2224
- if (unit.kind === "elided") {
2225
- modelParts.push("…");
2226
- displayParts.push("…");
2227
- elidedRanges.push({ start: unit.startLine, end: unit.endLine });
2228
- elidedLines += unit.endLine - unit.startLine + 1;
2229
- continue;
2230
- }
2231
- if (unit.kind === "merged") {
2232
- const formatted = formatMergedBraceLine(
2233
- unit.startLine,
2234
- unit.endLine,
2235
- unit.headText,
2236
- unit.tailText,
2237
- shouldAddHashLines,
2238
- shouldAddLineNumbers,
2239
- );
2240
- modelParts.push(formatted.model);
2241
- displayParts.push(formatted.display);
2242
- // Suggest the full brace range so re-reading shows both braces
2243
- // plus the elided body in one shot.
2244
- elidedRanges.push({ start: unit.startLine, end: unit.endLine });
2245
- // Merged brace pair encloses (start+1)..(end-1) as elided.
2246
- elidedLines += Math.max(0, unit.endLine - unit.startLine - 1);
2247
- continue;
2248
- }
2249
- modelParts.push(formatSingleLine(unit.line, unit.text, shouldAddHashLines, shouldAddLineNumbers));
2250
- displayParts.push(unit.text);
2251
- }
2252
-
2253
- return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedRanges, elidedLines };
2254
- }
2255
-
2256
780
  async execute(
2257
781
  _toolCallId: string,
2258
782
  params: ReadParams,
@@ -2287,7 +811,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2287
811
  const entry = await fetchReadUrl(this.session, { path: parsedUrlTarget.path, raw: urlRaw }, signal, {
2288
812
  ensureArtifact: true,
2289
813
  });
2290
- return this.#buildInMemoryMultiRangeResult(entry.output, urlRanges, {
814
+ return buildInMemoryMultiRangeResult(this.session, entry.output, urlRanges, {
2291
815
  details: { ...entry.details },
2292
816
  sourceUrl: entry.details.finalUrl,
2293
817
  entityLabel: "URL output",
@@ -2301,7 +825,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2301
825
  const entry = await fetchReadUrl(this.session, { path: parsedUrlTarget.path, raw: urlRaw }, signal, {
2302
826
  ensureArtifact: true,
2303
827
  });
2304
- return this.#buildInMemoryTextResult(entry.output, urlOffset, urlLimit, {
828
+ return buildInMemoryTextResult(this.session, entry.output, urlOffset, urlLimit, {
2305
829
  details: { ...entry.details },
2306
830
  sourceUrl: entry.details.finalUrl,
2307
831
  entityLabel: "URL output",
@@ -2370,14 +894,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2370
894
  : literalSplit.sel === undefined && splitPathAndSel(readPath).sel !== undefined;
2371
895
 
2372
896
  if (!rawPathIsLiteral) {
2373
- const archivePath = await this.#resolveArchiveReadPath(readPath, suffixCache, signal);
897
+ const archivePath = await resolveArchiveReadPath(this.session, readPath, suffixCache, signal);
2374
898
  if (archivePath) {
2375
899
  const archiveSubPath =
2376
900
  promotedSelector === undefined
2377
901
  ? splitPathAndSel(archivePath.archiveSubPath)
2378
902
  : { path: archivePath.archiveSubPath, sel: promotedSelector };
2379
903
  const archiveParsed = parseSel(archiveSubPath.sel);
2380
- return this.#readArchive(
904
+ return readArchive(
905
+ this.session,
2381
906
  readPath,
2382
907
  archiveParsed,
2383
908
  { ...archivePath, archiveSubPath: archiveSubPath.path },
@@ -2385,9 +910,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2385
910
  );
2386
911
  }
2387
912
 
2388
- const sqlitePath = await this.#resolveSqliteReadPath(readPath, suffixCache, signal);
913
+ const sqlitePath = await resolveSqliteReadPath(this.session, readPath, suffixCache, signal);
2389
914
  if (sqlitePath) {
2390
- return this.#readSqlite(sqlitePath, signal);
915
+ return readSqlite(sqlitePath, signal);
2391
916
  }
2392
917
 
2393
918
  const pdfImageMemberPath = splitPdfImageMemberReadPath(readPath);
@@ -2400,12 +925,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2400
925
  throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' is a directory, not a PDF file`);
2401
926
  } catch (error) {
2402
927
  if (!isNotFoundError(error) || isRemoteMountPath(absolutePdfPath)) throw error;
2403
- const suffixMatch = await this.#findSuffixMatchCached(suffixCache, pdfImageMemberPath.pdfPath, signal);
928
+ const suffixMatch = await findSuffixMatchCached(
929
+ this.session,
930
+ suffixCache,
931
+ pdfImageMemberPath.pdfPath,
932
+ signal,
933
+ );
2404
934
  if (!suffixMatch) throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' not found`);
2405
935
  absolutePdfPath = suffixMatch.absolutePath;
2406
936
  suffixResolution = { from: pdfImageMemberPath.pdfPath, to: suffixMatch.displayPath };
2407
937
  }
2408
- return this.#readPdfImageMember(
938
+ return readPdfImageMember(
939
+ this.session,
940
+ this.#autoResizeImages,
2409
941
  absolutePdfPath,
2410
942
  pdfImageMemberPath.pdfPath,
2411
943
  pdfImageMemberPath.member,
@@ -2433,7 +965,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2433
965
  // Attempt unique suffix resolution before falling back to the approved-plan
2434
966
  // alias or fuzzy suggestions. Existing workspace files retain precedence.
2435
967
  if (!isRemoteMountPath(absolutePath)) {
2436
- const suffixMatch = await this.#findSuffixMatchCached(suffixCache, localReadPath, signal);
968
+ const suffixMatch = await findSuffixMatchCached(this.session, suffixCache, localReadPath, signal);
2437
969
  if (suffixMatch) {
2438
970
  try {
2439
971
  const retryStat = await Bun.file(suffixMatch.absolutePath).stat();
@@ -2510,14 +1042,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2510
1042
  else if (isCpuProfilePath(absolutePath)) rendered = renderCpuProfile(await Bun.file(absolutePath).text());
2511
1043
  if (rendered) {
2512
1044
  if (isMultiRange(parsed) && parsed.kind === "lines") {
2513
- return this.#buildInMemoryMultiRangeResult(rendered, parsed.ranges, {
1045
+ return buildInMemoryMultiRangeResult(this.session, rendered, parsed.ranges, {
2514
1046
  details: { resolvedPath: absolutePath },
2515
1047
  sourcePath: absolutePath,
2516
1048
  entityLabel: "profile summary",
2517
1049
  });
2518
1050
  }
2519
1051
  const { offset, limit } = selToOffsetLimit(parsed);
2520
- return this.#buildInMemoryTextResult(rendered, offset, limit, {
1052
+ return buildInMemoryTextResult(this.session, rendered, offset, limit, {
2521
1053
  details: { resolvedPath: absolutePath },
2522
1054
  sourcePath: absolutePath,
2523
1055
  entityLabel: "profile summary",
@@ -2544,14 +1076,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2544
1076
  } else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) {
2545
1077
  const notebookText = await readEditableNotebookText(absolutePath, resolvedDisplayPath);
2546
1078
  if (isMultiRange(parsed) && parsed.kind === "lines") {
2547
- return this.#buildInMemoryMultiRangeResult(notebookText, parsed.ranges, {
1079
+ return buildInMemoryMultiRangeResult(this.session, notebookText, parsed.ranges, {
2548
1080
  details: { resolvedPath: absolutePath },
2549
1081
  sourcePath: absolutePath,
2550
1082
  entityLabel: "notebook",
2551
1083
  });
2552
1084
  }
2553
1085
  const { offset, limit } = selToOffsetLimit(parsed);
2554
- return this.#buildInMemoryTextResult(notebookText, offset, limit, {
1086
+ return buildInMemoryTextResult(this.session, notebookText, offset, limit, {
2555
1087
  details: { resolvedPath: absolutePath },
2556
1088
  sourcePath: absolutePath,
2557
1089
  entityLabel: "notebook",
@@ -2568,7 +1100,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2568
1100
  // `file.pdf:50-100` silently returned the head of the document
2569
1101
  // because only `truncateHead` was being applied.
2570
1102
  if (isMultiRange(parsed) && parsed.kind === "lines") {
2571
- return this.#buildInMemoryMultiRangeResult(renderedContent, parsed.ranges, {
1103
+ return buildInMemoryMultiRangeResult(this.session, renderedContent, parsed.ranges, {
2572
1104
  details: {
2573
1105
  resolvedPath: absolutePath,
2574
1106
  contentType: this.session.settings.get("read.renderMarkdown") ? "text/markdown" : undefined,
@@ -2578,7 +1110,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2578
1110
  });
2579
1111
  }
2580
1112
  const { offset, limit } = selToOffsetLimit(parsed);
2581
- return this.#buildInMemoryTextResult(renderedContent, offset, limit, {
1113
+ return buildInMemoryTextResult(this.session, renderedContent, offset, limit, {
2582
1114
  details: {
2583
1115
  resolvedPath: absolutePath,
2584
1116
  contentType: this.session.settings.get("read.renderMarkdown") ? "text/markdown" : undefined,
@@ -2617,9 +1149,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2617
1149
  this.session.settings.get("read.summarize.enabled") &&
2618
1150
  (this.session.settings.get("read.summarize.prose") || !isProseSummaryPath(absolutePath))
2619
1151
  ) {
2620
- const summary = await this.#trySummarize(absolutePath, fileSize, signal);
1152
+ const summary = await trySummarize(this.session, absolutePath, fileSize, signal);
2621
1153
  if (summary?.parsed && summary.elided) {
2622
- const renderedSummary = this.#renderSummary(summary);
1154
+ const renderedSummary = renderSummary(this.session, summary);
2623
1155
  const footer = formatSummaryElisionFooter(
2624
1156
  resolvedDisplayPath,
2625
1157
  renderedSummary.elidedRanges,
@@ -2670,12 +1202,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2670
1202
  const { offset, limit } = selToOffsetLimit(parsed);
2671
1203
  // Try ACP bridge first — editor's in-memory buffer is source of truth.
2672
1204
  // Request full text so local range rendering keeps normal context and line numbers.
2673
- const bridgePromise = this.#routeReadThroughBridge(absolutePath);
1205
+ const bridgePromise = routeReadThroughBridge(this.session, absolutePath);
2674
1206
  if (bridgePromise !== undefined) {
2675
1207
  try {
2676
1208
  const bridgeText = await bridgePromise;
2677
- const bridgeResult = this.#buildInMemoryTextResult(bridgeText, offset, limit, {
2678
- details: this.#markMarkdownContentType(
1209
+ const bridgeResult = buildInMemoryTextResult(this.session, bridgeText, offset, limit, {
1210
+ details: markMarkdownContentType(
1211
+ this.session,
2679
1212
  { resolvedPath: absolutePath, suffixResolution },
2680
1213
  absolutePath,
2681
1214
  ),
@@ -2979,7 +1512,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2979
1512
  }
2980
1513
 
2981
1514
  details.fileSize = fileSize;
2982
- this.#markMarkdownContentType(details, absolutePath);
1515
+ markMarkdownContentType(this.session, details, absolutePath);
2983
1516
  if (suffixResolution) {
2984
1517
  details.suffixResolution = suffixResolution;
2985
1518
  // Inline resolution notice into first text block so the model sees the actual path
@@ -3340,7 +1873,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
3340
1873
 
3341
1874
  const raw = isRawSelector(parsedSel);
3342
1875
  if (isMultiRange(parsedSel) && parsedSel.kind === "lines") {
3343
- return this.#buildInMemoryMultiRangeResult(resource.content, parsedSel.ranges, {
1876
+ return buildInMemoryMultiRangeResult(this.session, resource.content, parsedSel.ranges, {
3344
1877
  details,
3345
1878
  sourcePath: resource.sourcePath,
3346
1879
  sourceInternal: url,
@@ -3351,7 +1884,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
3351
1884
  }
3352
1885
 
3353
1886
  const { offset, limit } = selToOffsetLimit(parsedSel);
3354
- return this.#buildInMemoryTextResult(resource.content, offset, limit, {
1887
+ return buildInMemoryTextResult(this.session, resource.content, offset, limit, {
3355
1888
  details,
3356
1889
  sourcePath: resource.sourcePath,
3357
1890
  sourceInternal: url,
@@ -3485,279 +2018,3 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
3485
2018
  return resultBuilder.done();
3486
2019
  }
3487
2020
  }
3488
-
3489
- // =============================================================================
3490
- // TUI Renderer
3491
- // =============================================================================
3492
-
3493
- interface ReadRenderArgs {
3494
- path?: unknown;
3495
- file_path?: unknown;
3496
- // Legacy fields from old schema — tolerated for in-flight tool calls during transition
3497
- offset?: number;
3498
- limit?: number;
3499
- raw?: boolean;
3500
- }
3501
-
3502
- const INTERNAL_URL_LIKE_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
3503
-
3504
- function splitReadRenderPath(rawPath: string): { path: string; sel?: string } {
3505
- if (INTERNAL_URL_LIKE_RE.test(rawPath)) {
3506
- const internal = splitInternalUrlSel(rawPath);
3507
- if (internal.sel) return internal;
3508
- }
3509
- return splitPathAndSel(rawPath);
3510
- }
3511
-
3512
- function firstReadSelectorLine(sel: string | undefined): number | undefined {
3513
- if (!sel) return undefined;
3514
- try {
3515
- const parsed = parseSel(sel);
3516
- if (parsed.kind !== "lines") return undefined;
3517
- return parsed.ranges[0].startLine;
3518
- } catch {
3519
- return undefined;
3520
- }
3521
- }
3522
-
3523
- /** Absolute fs path the read result actually resolved to, used as the OSC 8 link
3524
- * target when the structured `resolvedPath` isn't set (the common plain-file and
3525
- * image reads only record the path in `meta.source`). URL/internal sources are
3526
- * not fs paths, so only `type: "path"` qualifies. */
3527
- function readSourceFsPath(details: ReadToolDetails | undefined): string | undefined {
3528
- const source = details?.meta?.source;
3529
- return source?.type === "path" ? source.value : undefined;
3530
- }
3531
-
3532
- function formatReadPathLink(
3533
- rawPath: string,
3534
- options: {
3535
- resolvedPath?: string;
3536
- sourcePath?: string;
3537
- suffixResolution?: { from: string; to: string };
3538
- offset?: number;
3539
- fallbackLabel?: string;
3540
- },
3541
- ): string {
3542
- const split = splitReadRenderPath(rawPath);
3543
- const basePath = split.path || rawPath;
3544
- const selectorSuffix = split.sel ? `:${split.sel}` : "";
3545
- const plainDisplayPath = options.suffixResolution
3546
- ? shortenPath(options.suffixResolution.to)
3547
- : shortenPath(basePath || options.resolvedPath || options.fallbackLabel || rawPath);
3548
- const absoluteInputPath = path.isAbsolute(basePath) ? basePath : undefined;
3549
- const target =
3550
- options.resolvedPath ?? options.sourcePath ?? tryResolveInternalUrlSync(basePath) ?? absoluteInputPath;
3551
- const line = firstReadSelectorLine(split.sel) ?? options.offset;
3552
- const linkOptions = line !== undefined ? { line } : undefined;
3553
- const linkedPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
3554
- return `${linkedPath}${selectorSuffix}`;
3555
- }
3556
-
3557
- export const readToolRenderer = {
3558
- renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
3559
- const rawPath =
3560
- typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : "";
3561
- if (isReadableUrlPath(rawPath)) {
3562
- return renderReadUrlCall({ path: rawPath, raw: args.raw }, _options, uiTheme);
3563
- }
3564
-
3565
- const offset = args.offset;
3566
- const limit = args.limit;
3567
-
3568
- let pathDisplay = formatReadPathLink(rawPath, { offset, fallbackLabel: "…" }) || "…";
3569
- if (offset !== undefined || limit !== undefined) {
3570
- const startLine = offset ?? 1;
3571
- const endLine = limit !== undefined ? startLine + limit - 1 : "";
3572
- pathDisplay += `:${startLine}${endLine ? `-${endLine}` : ""}`;
3573
- }
3574
-
3575
- const text = renderStatusLine({ icon: "pending", title: "Read", description: pathDisplay }, uiTheme);
3576
- return new Text(text, 0, 0);
3577
- },
3578
-
3579
- renderResult(
3580
- result: { content: Array<{ type: string; text?: string }>; details?: ReadToolDetails; isError?: boolean },
3581
- options: RenderResultOptions,
3582
- uiTheme: Theme,
3583
- args?: ReadRenderArgs,
3584
- ): Component {
3585
- const urlDetails = result.details as ReadUrlToolDetails | undefined;
3586
- const baseRawPathForKind =
3587
- typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3588
- if (urlDetails?.kind === "url" || isReadableUrlPath(baseRawPathForKind)) {
3589
- return renderReadUrlResult(
3590
- result as {
3591
- content: Array<{ type: string; text?: string }>;
3592
- details?: ReadUrlToolDetails;
3593
- isError?: boolean;
3594
- },
3595
- options,
3596
- uiTheme,
3597
- );
3598
- }
3599
-
3600
- if (result.isError) {
3601
- const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
3602
- const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, "");
3603
- const rawPath =
3604
- typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3605
- const filePath =
3606
- formatReadPathLink(rawPath, { offset: args?.offset, sourcePath: readSourceFsPath(result.details) }) ||
3607
- shortenPath(rawPath);
3608
- let title = filePath ? `Read ${filePath}` : "Read";
3609
- if (args?.offset !== undefined || args?.limit !== undefined) {
3610
- const startLine = args.offset ?? 1;
3611
- const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
3612
- title += `:${startLine}${endLine ? `-${endLine}` : ""}`;
3613
- }
3614
- const header = renderStatusLine({ icon: "error", title }, uiTheme);
3615
- const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
3616
- const outputBlock = new CachedOutputBlock();
3617
- return markFramedBlockComponent({
3618
- render: (width: number) =>
3619
- outputBlock.render({ header, state: "error", sections: [{ lines: errorLines }], width }, uiTheme),
3620
- invalidate: () => outputBlock.invalidate(),
3621
- });
3622
- }
3623
- const details = result.details;
3624
- const rawText = result.content?.find(c => c.type === "text")?.text ?? "";
3625
- // Prefer structured `displayContent` from details when available so the TUI
3626
- // shows clean file content (no model-only hashline anchors) without parsing the formatted text.
3627
- // Fall back to the raw text, but strip the LLM-facing notice so it doesn't
3628
- // echo next to the styled warning line below.
3629
- const contentText = details?.displayContent?.text ?? stripOutputNotice(rawText, details?.meta);
3630
- const imageContent = result.content?.find(c => c.type === "image");
3631
- const rawPath =
3632
- typeof args?.file_path === "string" ? args.file_path : typeof args?.path === "string" ? args.path : "";
3633
- const renderPath = splitReadRenderPath(rawPath);
3634
- const lang = getLanguageFromPath(renderPath.path);
3635
-
3636
- const warningLines: string[] = [];
3637
- const truncation = details?.meta?.truncation;
3638
- const fallback = details?.truncation;
3639
- if (details?.resolvedPath) {
3640
- warningLines.push(uiTheme.fg("dim", wrapBrackets(`Resolved path: ${details.resolvedPath}`, uiTheme)));
3641
- }
3642
- if (truncation) {
3643
- if (fallback?.firstLineExceedsLimit) {
3644
- let warning = `First line exceeds ${formatBytes(fallback.outputBytes ?? fallback.totalBytes)} limit`;
3645
- if (truncation.artifactId) {
3646
- warning += `. ${formatFullOutputReference(truncation.artifactId)}`;
3647
- }
3648
- warningLines.push(uiTheme.fg("warning", wrapBrackets(warning, uiTheme)));
3649
- } else {
3650
- const warning = formatStyledTruncationWarning(details?.meta, uiTheme);
3651
- if (warning) warningLines.push(warning);
3652
- }
3653
- }
3654
-
3655
- if (imageContent) {
3656
- const suffix = details?.suffixResolution;
3657
- const displayPath = formatReadPathLink(rawPath, {
3658
- resolvedPath: details?.resolvedPath,
3659
- sourcePath: readSourceFsPath(details),
3660
- suffixResolution: suffix,
3661
- fallbackLabel: "image",
3662
- });
3663
- const correction = suffix ? ` ${uiTheme.fg("dim", `(corrected from ${shortenPath(suffix.from)})`)}` : "";
3664
- const header = renderStatusLine(
3665
- { icon: suffix ? "warning" : "success", title: "Read", description: `${displayPath}${correction}` },
3666
- uiTheme,
3667
- );
3668
- const detailLines = contentText ? contentText.split("\n").map(line => uiTheme.fg("toolOutput", line)) : [];
3669
- const lines = [...detailLines, ...warningLines];
3670
- const outputBlock = new CachedOutputBlock();
3671
- return markFramedBlockComponent({
3672
- render: (width: number) =>
3673
- outputBlock.render(
3674
- {
3675
- header,
3676
- state: "success",
3677
- sections: [
3678
- {
3679
- label: uiTheme.fg("toolTitle", "Details"),
3680
- lines: lines.length > 0 ? lines : [uiTheme.fg("dim", "(image)")],
3681
- },
3682
- ],
3683
- width,
3684
- },
3685
- uiTheme,
3686
- ),
3687
- invalidate: () => outputBlock.invalidate(),
3688
- });
3689
- }
3690
-
3691
- const suffix = details?.suffixResolution;
3692
- // resolvedPath is the absolute fs path when a read resolved/corrected the
3693
- // input (suffix match, internal URL, archive/sqlite/notebook); plain file
3694
- // reads only record the absolute path in meta.source, so fall back to that
3695
- // (and then to a sync internal-URL resolver) to keep the title clickable.
3696
- const displayPath = formatReadPathLink(rawPath, {
3697
- resolvedPath: details?.resolvedPath,
3698
- sourcePath: readSourceFsPath(details),
3699
- suffixResolution: suffix,
3700
- offset: args?.offset,
3701
- });
3702
- const correction = suffix ? ` ${uiTheme.fg("dim", `(corrected from ${shortenPath(suffix.from)})`)}` : "";
3703
- let title = displayPath ? `Read ${displayPath}${correction}` : "Read";
3704
- if (args?.offset !== undefined || args?.limit !== undefined) {
3705
- const startLine = args.offset ?? 1;
3706
- const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
3707
- title += `:${startLine}${endLine ? `-${endLine}` : ""}`;
3708
- }
3709
- if (details?.summary) {
3710
- title += ` (summary: ${details.summary.elidedSpans} elided span${details.summary.elidedSpans === 1 ? "" : "s"})`;
3711
- }
3712
- if (details?.conflictCount && details.conflictCount > 0) {
3713
- const n = details.conflictCount;
3714
- title += ` ${uiTheme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`;
3715
- }
3716
- const rawRequested = args?.raw === true || isRawSelector(parseSel(renderPath.sel));
3717
- const isMarkdown = details?.contentType === "text/markdown" && !rawRequested;
3718
- let cachedWidth: number | undefined;
3719
- let cachedExpanded: boolean | undefined;
3720
- let cachedLines: string[] | undefined;
3721
- return markFramedBlockComponent({
3722
- render: (width: number) => {
3723
- const expanded = options.expanded;
3724
- if (cachedLines && cachedWidth === width && cachedExpanded === expanded) return cachedLines;
3725
- cachedLines = isMarkdown
3726
- ? renderMarkdownCell(
3727
- {
3728
- content: contentText,
3729
- title,
3730
- status: "complete",
3731
- output: warningLines.length > 0 ? warningLines.join("\n") : undefined,
3732
- expanded,
3733
- width,
3734
- },
3735
- uiTheme,
3736
- )
3737
- : renderCodeCell(
3738
- {
3739
- code: contentText,
3740
- language: lang,
3741
- title,
3742
- status: "complete",
3743
- output: warningLines.length > 0 ? warningLines.join("\n") : undefined,
3744
- expanded,
3745
- codeStartLine: details?.displayContent?.startLine,
3746
- codeLineNumbers: details?.displayContent?.lineNumbers,
3747
- width,
3748
- },
3749
- uiTheme,
3750
- );
3751
- cachedWidth = width;
3752
- cachedExpanded = expanded;
3753
- return cachedLines;
3754
- },
3755
- invalidate: () => {
3756
- cachedWidth = undefined;
3757
- cachedExpanded = undefined;
3758
- cachedLines = undefined;
3759
- },
3760
- });
3761
- },
3762
- mergeCallAndResult: true,
3763
- };