@oh-my-pi/pi-coding-agent 17.3.7 → 17.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/dist/{CHANGELOG-1hmwwt45.md → CHANGELOG-vr9cckb4.md} +60 -0
- package/dist/cli.js +2993 -3001
- package/dist/docs-index.generated.txt +1 -1
- package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
- package/dist/types/advisor/advise-tool.d.ts +4 -2
- package/dist/types/cli/auth-broker-cli.d.ts +15 -0
- package/dist/types/cli/update-cli.d.ts +8 -0
- package/dist/types/cli-commands.d.ts +10 -2
- package/dist/types/config/settings-schema.d.ts +28 -0
- package/dist/types/config/settings.d.ts +9 -0
- package/dist/types/extensibility/extensions/runner.d.ts +2 -1
- package/dist/types/launch/presence.d.ts +4 -1
- package/dist/types/mcp/oauth-credentials.d.ts +23 -0
- package/dist/types/mcp/oauth-flow.d.ts +11 -0
- package/dist/types/mnemopi/backend.d.ts +12 -0
- package/dist/types/modes/components/tool-execution.d.ts +12 -0
- package/dist/types/modes/controllers/input-controller.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +25 -3
- package/dist/types/modes/types.d.ts +10 -0
- package/dist/types/session/agent-session.d.ts +3 -0
- package/dist/types/session/prewalk.d.ts +4 -0
- package/dist/types/session/session-entries.d.ts +0 -1
- package/dist/types/session/session-manager.d.ts +12 -0
- package/dist/types/session/session-stats.d.ts +13 -1
- package/dist/types/session/skill-title-input.d.ts +13 -0
- package/dist/types/subprocess/worker-client.d.ts +7 -4
- package/dist/types/task/label.d.ts +2 -0
- package/dist/types/task/render.d.ts +2 -0
- package/dist/types/tiny/completion-prompt.d.ts +2 -0
- package/dist/types/tiny/title-client.d.ts +6 -4
- package/dist/types/tiny/title-protocol.d.ts +1 -0
- package/dist/types/tiny/worker.d.ts +27 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/read-format.d.ts +6 -0
- package/dist/types/tools/read-summary.d.ts +7 -1
- package/dist/types/utils/block-context.d.ts +14 -0
- package/dist/types/utils/fetch-timeout.d.ts +15 -0
- package/dist/types/utils/git.d.ts +25 -1
- package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
- package/package.json +13 -13
- package/src/advisor/advise-tool.ts +5 -3
- package/src/cli/auth-broker-cli.ts +36 -1
- package/src/cli/profile-bootstrap.ts +2 -6
- package/src/cli/update-cli.ts +63 -11
- package/src/cli-commands.ts +61 -7
- package/src/commands/completions.ts +2 -1
- package/src/commit/agentic/index.ts +15 -2
- package/src/commit/git/diff.ts +6 -2
- package/src/config/model-resolver.ts +52 -6
- package/src/config/models-config.ts +2 -2
- package/src/config/settings-schema.ts +33 -0
- package/src/config/settings.ts +159 -30
- package/src/discovery/helpers.ts +45 -2
- package/src/discovery/omp-plugins.ts +2 -1
- package/src/discovery/opencode.ts +56 -3
- package/src/eval/js/process-entry.ts +4 -4
- package/src/export/html/tool-views.generated.js +19 -19
- package/src/extensibility/extensions/runner.ts +3 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
- package/src/launch/client.ts +9 -4
- package/src/launch/presence.ts +19 -4
- package/src/lsp/defaults.json +1 -1
- package/src/mcp/manager.ts +41 -20
- package/src/mcp/oauth-credentials.ts +38 -0
- package/src/mcp/oauth-flow.ts +21 -0
- package/src/mcp/tool-bridge.ts +32 -16
- package/src/mnemopi/backend.ts +35 -3
- package/src/modes/components/model-hub.ts +37 -4
- package/src/modes/components/settings-selector.ts +17 -11
- package/src/modes/components/tool-execution.ts +97 -29
- package/src/modes/components/tree-selector.ts +7 -2
- package/src/modes/controllers/event-controller.ts +12 -2
- package/src/modes/controllers/input-controller.ts +64 -27
- package/src/modes/controllers/mcp-command-controller.ts +13 -4
- package/src/modes/interactive-mode.ts +79 -11
- package/src/modes/types.ts +11 -0
- package/src/prompts/system/memory-extraction-system.md +5 -22
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/session/agent-session.ts +52 -6
- package/src/session/messages.ts +6 -0
- package/src/session/prewalk.ts +25 -7
- package/src/session/session-entries.ts +0 -1
- package/src/session/session-maintenance.ts +10 -1
- package/src/session/session-manager.ts +15 -0
- package/src/session/session-stats.ts +24 -3
- package/src/session/settings-stream-fn.ts +7 -0
- package/src/session/skill-title-input.ts +32 -0
- package/src/session/turn-recovery.ts +23 -18
- package/src/subprocess/worker-client.ts +8 -5
- package/src/task/executor.ts +11 -0
- package/src/task/index.ts +2 -0
- package/src/task/label.ts +14 -1
- package/src/task/persisted-revive.ts +13 -0
- package/src/task/render.ts +1 -1
- package/src/task/structured-subagent.ts +5 -2
- package/src/tiny/completion-prompt.ts +16 -0
- package/src/tiny/title-client.ts +15 -6
- package/src/tiny/title-protocol.ts +8 -1
- package/src/tiny/worker.ts +21 -19
- package/src/tools/bash.ts +7 -1
- package/src/tools/read-format.ts +16 -2
- package/src/tools/read-summary.ts +9 -4
- package/src/tools/read.ts +306 -72
- package/src/utils/block-context.ts +15 -1
- package/src/utils/fetch-timeout.ts +33 -0
- package/src/utils/git.ts +54 -11
- package/src/web/search/providers/browser-page.ts +21 -3
- package/src/web/search/providers/tinyfish.ts +26 -0
package/src/tools/read.ts
CHANGED
|
@@ -10,7 +10,15 @@ import type {
|
|
|
10
10
|
ToolTier,
|
|
11
11
|
} from "@oh-my-pi/pi-agent-core";
|
|
12
12
|
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
BINARY_SNIFF_BYTES,
|
|
15
|
+
type ImageMetadata,
|
|
16
|
+
isProbablyBinary,
|
|
17
|
+
isProbablyBinaryHeader,
|
|
18
|
+
logger,
|
|
19
|
+
prompt,
|
|
20
|
+
readImageMetadata,
|
|
21
|
+
} from "@oh-my-pi/pi-utils";
|
|
14
22
|
import {
|
|
15
23
|
canonicalSnapshotKey,
|
|
16
24
|
getFileSnapshotStore,
|
|
@@ -85,6 +93,7 @@ import {
|
|
|
85
93
|
formatTextWithMode,
|
|
86
94
|
type HashlineHeaderContext,
|
|
87
95
|
hashlineHeaderContext,
|
|
96
|
+
hashlineHeaderContextForText,
|
|
88
97
|
lineNumbersFromSpans,
|
|
89
98
|
markMarkdownContentType,
|
|
90
99
|
prependHashlineHeader,
|
|
@@ -117,15 +126,198 @@ export { readToolRenderer } from "./read-renderer";
|
|
|
117
126
|
const MAX_PROFILE_SUMMARY_BYTES = 32 * 1024 * 1024;
|
|
118
127
|
const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES;
|
|
119
128
|
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
/** LF byte, scanned natively to find line boundaries in a buffered file. */
|
|
130
|
+
const LF_BYTE = 0x0a;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whole-file bytes plus every view the local text read path consumes,
|
|
134
|
+
* materialized exactly once.
|
|
135
|
+
*
|
|
136
|
+
* The binary sniff, the structural summary, the emitted line window, bracket
|
|
137
|
+
* context and the snapshot hash all want the same bytes. Each used to open the
|
|
138
|
+
* file for itself, so a single ranged read cost up to four opens, three UTF-8
|
|
139
|
+
* decodes and two CRLF normalization passes over identical content.
|
|
140
|
+
*
|
|
141
|
+
* Only files at or below {@link SNAPSHOT_MAX_BYTES} are buffered: past that cap
|
|
142
|
+
* bracket context and the snapshot are skipped anyway, so streaming a window
|
|
143
|
+
* stays strictly cheaper than materializing the file.
|
|
144
|
+
*/
|
|
145
|
+
interface BufferedFileText {
|
|
146
|
+
/** File bytes, verbatim. */
|
|
147
|
+
readonly bytes: Buffer;
|
|
148
|
+
/** Verbatim UTF-8 decode: a leading BOM and CRLF line endings both survive. */
|
|
149
|
+
readonly rawText: string;
|
|
150
|
+
/** {@link rawText} split on LF, CR retained, so segments stay byte-faithful. */
|
|
151
|
+
readonly rawSegments: readonly string[];
|
|
152
|
+
/** BOM-stripped, CRLF-preserving text: what `Bun.file(path).text()` returns. */
|
|
153
|
+
readonly strippedText: string;
|
|
154
|
+
/** {@link strippedText} normalized to LF — the exact text the snapshot store hashes. */
|
|
155
|
+
readonly normalizedText: string;
|
|
156
|
+
/** Addressable lines of {@link normalizedText}; bracket context indexes these. */
|
|
157
|
+
readonly addressableLines: readonly string[];
|
|
158
|
+
/** Whether the final byte is LF. */
|
|
159
|
+
readonly endsWithNewline: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read the whole file, or `undefined` when the bytes cannot be read — which
|
|
164
|
+
* drops the caller back to the streaming reader and reproduces today's error
|
|
165
|
+
* surface.
|
|
166
|
+
*
|
|
167
|
+
* Kept separate from {@link deriveBufferedFileText} so the binary sniff can run
|
|
168
|
+
* on the bytes first: a file that decodes to mojibake is refused, and building
|
|
169
|
+
* three string views of it before finding that out would be pure waste.
|
|
170
|
+
*/
|
|
171
|
+
async function readWholeFile(absolutePath: string): Promise<Buffer | undefined> {
|
|
122
172
|
try {
|
|
123
|
-
return
|
|
173
|
+
return await fs.readFile(absolutePath);
|
|
124
174
|
} catch {
|
|
125
175
|
return undefined;
|
|
126
176
|
}
|
|
127
177
|
}
|
|
128
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Derive every view of `bytes` the read path needs, decoding exactly once.
|
|
181
|
+
*
|
|
182
|
+
* `Bun.file(path).text()` strips a leading BOM while `Buffer.toString` keeps it,
|
|
183
|
+
* and the snapshot store plus the patcher's live-file read both go through the
|
|
184
|
+
* stripping decoder. {@link BufferedFileText.strippedText} therefore reproduces
|
|
185
|
+
* that decode for hashing while {@link BufferedFileText.rawText} stays verbatim
|
|
186
|
+
* for the emitted lines and their byte accounting.
|
|
187
|
+
*/
|
|
188
|
+
function deriveBufferedFileText(bytes: Buffer): BufferedFileText {
|
|
189
|
+
const rawText = bytes.toString("utf-8");
|
|
190
|
+
const strippedText = rawText.charCodeAt(0) === 0xfeff ? rawText.slice(1) : rawText;
|
|
191
|
+
// `normalizeToLF` allocates a copy; skip it outright for the common LF file.
|
|
192
|
+
const normalizedText = strippedText.includes("\r") ? normalizeToLF(strippedText) : strippedText;
|
|
193
|
+
const rawSegments = rawText.split("\n");
|
|
194
|
+
let addressableLines: readonly string[];
|
|
195
|
+
if (normalizedText === rawText) {
|
|
196
|
+
// Nothing was rewritten, so display and bracket context share one array;
|
|
197
|
+
// the terminal newline sentinel is dropped exactly as
|
|
198
|
+
// `splitAddressableFileLines` does.
|
|
199
|
+
const last = rawSegments.length - 1;
|
|
200
|
+
addressableLines = last > 0 && rawSegments[last] === "" ? rawSegments.slice(0, last) : rawSegments;
|
|
201
|
+
} else {
|
|
202
|
+
addressableLines = splitAddressableFileLines(normalizedText);
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
bytes,
|
|
206
|
+
rawText,
|
|
207
|
+
rawSegments,
|
|
208
|
+
strippedText,
|
|
209
|
+
normalizedText,
|
|
210
|
+
addressableLines,
|
|
211
|
+
endsWithNewline: bytes.length > 0 && bytes[bytes.length - 1] === LF_BYTE,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The line window a range read renders, with the budget accounting behind it. */
|
|
216
|
+
interface ReadLineWindow {
|
|
217
|
+
lines: string[];
|
|
218
|
+
totalFileLines: number;
|
|
219
|
+
collectedBytes: number;
|
|
220
|
+
stoppedByByteLimit: boolean;
|
|
221
|
+
firstLinePreview?: { text: string; bytes: number };
|
|
222
|
+
firstLineByteLength?: number;
|
|
223
|
+
/** Whether the fully scanned source ended in a newline. */
|
|
224
|
+
hasTrailingNewline: boolean;
|
|
225
|
+
/** False when `stopScanAfterCollect` cut the scan short — `totalFileLines` is then a lower bound. */
|
|
226
|
+
reachedEof: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Slice the window {@link streamLinesFromFile} would have collected out of an
|
|
231
|
+
* already-buffered file, under the identical line and byte budgets.
|
|
232
|
+
*
|
|
233
|
+
* Line byte lengths are walked out of the buffer rather than measured on the
|
|
234
|
+
* decoded strings: a file that is not valid UTF-8 decodes to U+FFFD, whose
|
|
235
|
+
* encoded length differs from the bytes on disk, and those lengths decide both
|
|
236
|
+
* the reported byte counts and where truncation lands.
|
|
237
|
+
*/
|
|
238
|
+
function collectLineWindowFromBuffer(
|
|
239
|
+
file: BufferedFileText,
|
|
240
|
+
startLine: number,
|
|
241
|
+
maxLinesToCollect: number,
|
|
242
|
+
maxBytes: number,
|
|
243
|
+
selectedLineLimit: number,
|
|
244
|
+
includeTerminalNewline: boolean,
|
|
245
|
+
): ReadLineWindow {
|
|
246
|
+
const { bytes, rawSegments, endsWithNewline } = file;
|
|
247
|
+
// A trailing LF closes the last line rather than opening an empty one, except
|
|
248
|
+
// in raw mode where that terminal sentinel is addressable.
|
|
249
|
+
const totalFileLines =
|
|
250
|
+
endsWithNewline && !includeTerminalNewline && rawSegments.length > 1
|
|
251
|
+
? rawSegments.length - 1
|
|
252
|
+
: rawSegments.length;
|
|
253
|
+
const window: ReadLineWindow = {
|
|
254
|
+
lines: [],
|
|
255
|
+
totalFileLines,
|
|
256
|
+
collectedBytes: 0,
|
|
257
|
+
stoppedByByteLimit: false,
|
|
258
|
+
hasTrailingNewline: endsWithNewline,
|
|
259
|
+
reachedEof: true,
|
|
260
|
+
};
|
|
261
|
+
if (startLine >= totalFileLines) return window;
|
|
262
|
+
|
|
263
|
+
let lineStart = 0;
|
|
264
|
+
for (let index = 0; index < startLine; index++) {
|
|
265
|
+
const newlineAt = bytes.indexOf(LF_BYTE, lineStart);
|
|
266
|
+
if (newlineAt === -1) {
|
|
267
|
+
lineStart = bytes.length;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
lineStart = newlineAt + 1;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let doneCollecting = false;
|
|
274
|
+
let selectedLinesSeen = 0;
|
|
275
|
+
for (let index = startLine; index < totalFileLines; index++) {
|
|
276
|
+
const newlineAt = bytes.indexOf(LF_BYTE, lineStart);
|
|
277
|
+
const lineEnd = newlineAt === -1 ? bytes.length : newlineAt;
|
|
278
|
+
const lineByteLength = lineEnd - lineStart;
|
|
279
|
+
|
|
280
|
+
if (selectedLinesSeen < selectedLineLimit) selectedLinesSeen++;
|
|
281
|
+
// Preview covers the first selected line only, capped at the byte budget:
|
|
282
|
+
// the oversized-first-line branch renders it when no full line fits.
|
|
283
|
+
if (window.lines.length === 0 && window.firstLinePreview === undefined && lineByteLength > 0) {
|
|
284
|
+
const previewEnd = Math.min(lineEnd, lineStart + maxBytes);
|
|
285
|
+
const { text, bytes: previewBytes } = truncateHeadBytes(bytes.subarray(lineStart, previewEnd), maxBytes);
|
|
286
|
+
window.firstLinePreview = { text, bytes: previewBytes };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!doneCollecting) {
|
|
290
|
+
const separatorBytes = window.lines.length > 0 ? 1 : 0;
|
|
291
|
+
if (window.lines.length >= maxLinesToCollect) {
|
|
292
|
+
doneCollecting = true;
|
|
293
|
+
} else if (window.lines.length === 0 && lineByteLength > maxBytes) {
|
|
294
|
+
window.stoppedByByteLimit = true;
|
|
295
|
+
doneCollecting = true;
|
|
296
|
+
window.firstLineByteLength ??= lineByteLength;
|
|
297
|
+
} else if (window.lines.length > 0 && window.collectedBytes + separatorBytes + lineByteLength > maxBytes) {
|
|
298
|
+
window.stoppedByByteLimit = true;
|
|
299
|
+
doneCollecting = true;
|
|
300
|
+
} else {
|
|
301
|
+
window.lines.push(rawSegments[index] ?? "");
|
|
302
|
+
window.collectedBytes += separatorBytes + lineByteLength;
|
|
303
|
+
window.firstLineByteLength ??= lineByteLength;
|
|
304
|
+
if (window.collectedBytes > maxBytes) {
|
|
305
|
+
window.stoppedByByteLimit = true;
|
|
306
|
+
doneCollecting = true;
|
|
307
|
+
} else if (window.lines.length >= maxLinesToCollect) {
|
|
308
|
+
doneCollecting = true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} else if (window.firstLineByteLength === undefined) {
|
|
312
|
+
window.firstLineByteLength = lineByteLength;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (doneCollecting && selectedLinesSeen >= selectedLineLimit) break;
|
|
316
|
+
lineStart = lineEnd + 1;
|
|
317
|
+
}
|
|
318
|
+
return window;
|
|
319
|
+
}
|
|
320
|
+
|
|
129
321
|
interface StreamFileLinesOptions {
|
|
130
322
|
includeTerminalNewline?: boolean;
|
|
131
323
|
stopScanAfterCollect?: boolean;
|
|
@@ -139,19 +331,7 @@ async function streamLinesFromFile(
|
|
|
139
331
|
selectedLineLimit: number | null,
|
|
140
332
|
signal?: AbortSignal,
|
|
141
333
|
options: StreamFileLinesOptions = {},
|
|
142
|
-
): Promise<{
|
|
143
|
-
lines: string[];
|
|
144
|
-
totalFileLines: number;
|
|
145
|
-
collectedBytes: number;
|
|
146
|
-
stoppedByByteLimit: boolean;
|
|
147
|
-
firstLinePreview?: { text: string; bytes: number };
|
|
148
|
-
firstLineByteLength?: number;
|
|
149
|
-
selectedBytesTotal: number;
|
|
150
|
-
/** Whether the fully scanned source ended in a newline. */
|
|
151
|
-
hasTrailingNewline: boolean;
|
|
152
|
-
/** False when `stopScanAfterCollect` cut the scan short — `totalFileLines` is then a lower bound. */
|
|
153
|
-
reachedEof: boolean;
|
|
154
|
-
}> {
|
|
334
|
+
): Promise<ReadLineWindow> {
|
|
155
335
|
const { includeTerminalNewline = false, stopScanAfterCollect = false } = options;
|
|
156
336
|
const bufferChunk = Buffer.allocUnsafe(READ_CHUNK_SIZE);
|
|
157
337
|
const collectedLines: string[] = [];
|
|
@@ -168,7 +348,6 @@ async function streamLinesFromFile(
|
|
|
168
348
|
let firstLinePreviewBytes = 0;
|
|
169
349
|
const firstLinePreviewChunks: Buffer[] = [];
|
|
170
350
|
let firstLineByteLength: number | undefined;
|
|
171
|
-
let selectedBytesTotal = 0;
|
|
172
351
|
let selectedLinesSeen = 0;
|
|
173
352
|
let captureLine = false;
|
|
174
353
|
let discardLineChunks = false;
|
|
@@ -219,7 +398,6 @@ async function streamLinesFromFile(
|
|
|
219
398
|
|
|
220
399
|
const finalizeLine = () => {
|
|
221
400
|
if (lineIndex >= startLine && (selectedLineLimit === null || selectedLinesSeen < selectedLineLimit)) {
|
|
222
|
-
selectedBytesTotal += currentLineLength + (selectedLinesSeen > 0 ? 1 : 0);
|
|
223
401
|
selectedLinesSeen++;
|
|
224
402
|
}
|
|
225
403
|
|
|
@@ -337,7 +515,6 @@ async function streamLinesFromFile(
|
|
|
337
515
|
stoppedByByteLimit,
|
|
338
516
|
firstLinePreview,
|
|
339
517
|
firstLineByteLength,
|
|
340
|
-
selectedBytesTotal,
|
|
341
518
|
reachedEof,
|
|
342
519
|
hasTrailingNewline: reachedEof && endedWithNewline,
|
|
343
520
|
};
|
|
@@ -659,15 +836,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
659
836
|
}
|
|
660
837
|
|
|
661
838
|
/**
|
|
662
|
-
*
|
|
663
|
-
* priority when present (editor buffer is source of truth); otherwise
|
|
664
|
-
*
|
|
665
|
-
*
|
|
839
|
+
* Render multiple non-contiguous ranges of a local file. ACP bridge takes
|
|
840
|
+
* priority when present (editor buffer is source of truth); otherwise ranges
|
|
841
|
+
* are sliced out of `buffered` when the caller already materialized the file,
|
|
842
|
+
* and streamed independently with their own line/byte budget when it did not.
|
|
843
|
+
* Out-of-bounds ranges surface as inline notices rather than aborting the read.
|
|
666
844
|
*/
|
|
667
845
|
async #readLocalFileMultiRange(
|
|
668
846
|
absolutePath: string,
|
|
669
847
|
ranges: readonly LineRange[],
|
|
670
848
|
fileSize: number,
|
|
849
|
+
buffered: BufferedFileText | undefined,
|
|
671
850
|
parsed: ParsedSelector,
|
|
672
851
|
displayMode: { hashLines: boolean; lineNumbers: boolean },
|
|
673
852
|
suffixResolution: { from: string; to: string } | undefined,
|
|
@@ -715,7 +894,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
715
894
|
const notices: string[] = [];
|
|
716
895
|
const visibleSpans: Array<{ startLine: number; endLine: number }> = [];
|
|
717
896
|
const displayLineByNumber = new Map<number, string>();
|
|
718
|
-
const fullLines = rawSelector ? undefined :
|
|
897
|
+
const fullLines = rawSelector ? undefined : buffered?.addressableLines;
|
|
719
898
|
let columnTruncated = 0;
|
|
720
899
|
let displayContent: { text: string; startLine: number; lineNumbers?: Array<number | null> } | undefined;
|
|
721
900
|
|
|
@@ -724,27 +903,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
724
903
|
const requestedLength = range.endLine !== undefined ? range.endLine - range.startLine + 1 : this.#defaultLimit;
|
|
725
904
|
const maxLines = Math.min(requestedLength, DEFAULT_MAX_LINES);
|
|
726
905
|
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
//
|
|
906
|
+
// The file is already in memory for everything within the snapshot byte
|
|
907
|
+
// cap, so slice ranges out of it instead of re-streaming per range. Raw
|
|
908
|
+
// mode cannot use the addressable lines (it keeps CR bytes and the
|
|
909
|
+
// terminal newline sentinel) but still slices the same buffer.
|
|
730
910
|
let collectedLines: string[];
|
|
731
911
|
let totalFileLines: number;
|
|
912
|
+
const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLines * 512);
|
|
732
913
|
if (fullLines) {
|
|
733
914
|
totalFileLines = fullLines.length;
|
|
734
915
|
collectedLines = fullLines.slice(rangeStart, rangeStart + maxLines);
|
|
735
916
|
} else {
|
|
736
|
-
const
|
|
737
|
-
|
|
738
|
-
absolutePath,
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
{ includeTerminalNewline: rawSelector, stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES },
|
|
745
|
-
);
|
|
746
|
-
totalFileLines = streamResult.totalFileLines;
|
|
747
|
-
collectedLines = streamResult.lines;
|
|
917
|
+
const window = buffered
|
|
918
|
+
? collectLineWindowFromBuffer(buffered, rangeStart, maxLines, maxBytesForRead, maxLines, rawSelector)
|
|
919
|
+
: await streamLinesFromFile(absolutePath, rangeStart, maxLines, maxBytesForRead, maxLines, signal, {
|
|
920
|
+
includeTerminalNewline: rawSelector,
|
|
921
|
+
stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES,
|
|
922
|
+
});
|
|
923
|
+
totalFileLines = window.totalFileLines;
|
|
924
|
+
collectedLines = window.lines;
|
|
748
925
|
}
|
|
749
926
|
|
|
750
927
|
if (rangeStart >= totalFileLines) {
|
|
@@ -786,7 +963,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
786
963
|
const entries = buildLineEntriesWithBlockContext(
|
|
787
964
|
fullLines,
|
|
788
965
|
visibleSpans,
|
|
789
|
-
{ path: absolutePath },
|
|
966
|
+
{ path: absolutePath, text: buffered?.normalizedText },
|
|
790
967
|
{
|
|
791
968
|
lineText: (lineNumber, sourceText) => {
|
|
792
969
|
const visibleText = displayLineByNumber.get(lineNumber);
|
|
@@ -811,14 +988,26 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
811
988
|
outputText = blocks.join("\n\n…\n\n");
|
|
812
989
|
}
|
|
813
990
|
if (shouldAddHashLines && outputText) {
|
|
814
|
-
const tag =
|
|
991
|
+
const tag = buffered
|
|
992
|
+
? getFileSnapshotStore(this.session).record(canonicalSnapshotKey(absolutePath), buffered.normalizedText)
|
|
993
|
+
: await recordFileSnapshot(this.session, absolutePath);
|
|
815
994
|
if (tag) {
|
|
816
995
|
recordSeenLinesFromBody(this.session, absolutePath, tag, outputText);
|
|
817
996
|
outputText = `${formatReadHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
|
|
818
997
|
}
|
|
819
998
|
} else if (rawSelector && visibleSpans.length > 0) {
|
|
820
999
|
const rawSeenLines = lineNumbersFromSpans(visibleSpans);
|
|
821
|
-
if (rawSeenLines.length > 0)
|
|
1000
|
+
if (rawSeenLines.length > 0) {
|
|
1001
|
+
if (buffered) {
|
|
1002
|
+
getFileSnapshotStore(this.session).record(
|
|
1003
|
+
canonicalSnapshotKey(absolutePath),
|
|
1004
|
+
buffered.normalizedText,
|
|
1005
|
+
rawSeenLines,
|
|
1006
|
+
);
|
|
1007
|
+
} else {
|
|
1008
|
+
await recordFileSnapshot(this.session, absolutePath, rawSeenLines);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
822
1011
|
}
|
|
823
1012
|
if (notices.length > 0) {
|
|
824
1013
|
outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
|
|
@@ -1161,6 +1350,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1161
1350
|
content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
|
|
1162
1351
|
}
|
|
1163
1352
|
} else {
|
|
1353
|
+
// One read for every consumer below. The sniff, the structural summary,
|
|
1354
|
+
// the rendered window, bracket context and the snapshot hash all want
|
|
1355
|
+
// the same bytes; past the snapshot cap nothing wants the whole file,
|
|
1356
|
+
// so the streaming reader keeps that case cheap.
|
|
1357
|
+
const wholeFileBytes = fileSize <= SNAPSHOT_MAX_BYTES ? await readWholeFile(absolutePath) : undefined;
|
|
1358
|
+
|
|
1164
1359
|
// Binary sniff before any UTF-8 text materialization. A binary file
|
|
1165
1360
|
// (font, object, archive, packed blob) decodes to NUL/control bytes and
|
|
1166
1361
|
// U+FFFD mojibake that corrupts the terminal and burns context. Images,
|
|
@@ -1168,7 +1363,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1168
1363
|
// everything reaching here is meant to be plain text. `:raw` stays the
|
|
1169
1364
|
// explicit escape hatch for reading bytes verbatim. This single guard
|
|
1170
1365
|
// covers both the multi-range and single-range disk paths below.
|
|
1171
|
-
|
|
1366
|
+
const looksBinary =
|
|
1367
|
+
!isRawSelector(parsed) &&
|
|
1368
|
+
(wholeFileBytes
|
|
1369
|
+
? isProbablyBinaryHeader(wholeFileBytes.subarray(0, BINARY_SNIFF_BYTES))
|
|
1370
|
+
: await isProbablyBinary(absolutePath));
|
|
1371
|
+
if (looksBinary) {
|
|
1172
1372
|
return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
|
|
1173
1373
|
.text(
|
|
1174
1374
|
prependSuffixResolutionNotice(
|
|
@@ -1179,13 +1379,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1179
1379
|
.sourcePath(absolutePath)
|
|
1180
1380
|
.done();
|
|
1181
1381
|
}
|
|
1382
|
+
// Decode only what survived the sniff.
|
|
1383
|
+
const buffered = wholeFileBytes ? deriveBufferedFileText(wholeFileBytes) : undefined;
|
|
1182
1384
|
|
|
1183
1385
|
if (
|
|
1184
1386
|
parsed.kind === "none" &&
|
|
1185
1387
|
this.session.settings.get("read.summarize.enabled") &&
|
|
1186
1388
|
(this.session.settings.get("read.summarize.prose") || !isProseSummaryPath(absolutePath))
|
|
1187
1389
|
) {
|
|
1188
|
-
const summary = await trySummarize(this.session, absolutePath, fileSize, signal);
|
|
1390
|
+
const summary = await trySummarize(this.session, absolutePath, fileSize, signal, buffered?.strippedText);
|
|
1189
1391
|
if (summary?.parsed && summary.elided) {
|
|
1190
1392
|
const renderedSummary = renderSummary(this.session, summary);
|
|
1191
1393
|
const footer = formatSummaryElisionFooter(
|
|
@@ -1194,7 +1396,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1194
1396
|
renderedSummary.elidedLines,
|
|
1195
1397
|
);
|
|
1196
1398
|
const summaryHashContext = displayMode.hashLines
|
|
1197
|
-
?
|
|
1399
|
+
? buffered
|
|
1400
|
+
? hashlineHeaderContextForText(
|
|
1401
|
+
this.session,
|
|
1402
|
+
absolutePath,
|
|
1403
|
+
this.session.cwd,
|
|
1404
|
+
buffered.normalizedText,
|
|
1405
|
+
)
|
|
1406
|
+
: await readHashlineHeaderContext(this.session, absolutePath, this.session.cwd)
|
|
1198
1407
|
: undefined;
|
|
1199
1408
|
const bodyText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
|
|
1200
1409
|
const modelText = prependHashlineHeader(bodyText, summaryHashContext);
|
|
@@ -1221,6 +1430,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1221
1430
|
absolutePath,
|
|
1222
1431
|
parsed.ranges,
|
|
1223
1432
|
fileSize,
|
|
1433
|
+
buffered,
|
|
1224
1434
|
parsed,
|
|
1225
1435
|
displayMode,
|
|
1226
1436
|
suffixResolution,
|
|
@@ -1285,15 +1495,24 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1285
1495
|
// Assume ~512 bytes/line average; never go below the shared default.
|
|
1286
1496
|
const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
|
|
1287
1497
|
|
|
1288
|
-
const
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1498
|
+
const lineWindow = buffered
|
|
1499
|
+
? collectLineWindowFromBuffer(
|
|
1500
|
+
buffered,
|
|
1501
|
+
startLine,
|
|
1502
|
+
maxLinesToCollect,
|
|
1503
|
+
maxBytesForRead,
|
|
1504
|
+
selectedLineLimit,
|
|
1505
|
+
rawSelector,
|
|
1506
|
+
)
|
|
1507
|
+
: await streamLinesFromFile(
|
|
1508
|
+
absolutePath,
|
|
1509
|
+
startLine,
|
|
1510
|
+
maxLinesToCollect,
|
|
1511
|
+
maxBytesForRead,
|
|
1512
|
+
selectedLineLimit,
|
|
1513
|
+
undefined, // plain-file read: deterministic and fast, never abort mid-read
|
|
1514
|
+
{ includeTerminalNewline: rawSelector, stopScanAfterCollect: fileSize > SNAPSHOT_MAX_BYTES },
|
|
1515
|
+
);
|
|
1297
1516
|
|
|
1298
1517
|
const {
|
|
1299
1518
|
lines: collectedLines,
|
|
@@ -1304,7 +1523,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1304
1523
|
firstLineByteLength,
|
|
1305
1524
|
reachedEof,
|
|
1306
1525
|
hasTrailingNewline,
|
|
1307
|
-
} =
|
|
1526
|
+
} = lineWindow;
|
|
1308
1527
|
|
|
1309
1528
|
// Check if offset is out of bounds - return graceful message instead of throwing
|
|
1310
1529
|
if (requestedStart >= totalFileLines) {
|
|
@@ -1347,9 +1566,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1347
1566
|
for (let i = 0; i < displayLines.length; i++) {
|
|
1348
1567
|
displayLineByNumber.set(startLineDisplay + i, displayLines[i] ?? "");
|
|
1349
1568
|
}
|
|
1350
|
-
const bracketContextFullLines = rawSelector
|
|
1351
|
-
? undefined
|
|
1352
|
-
: await readBracketContextFullLines(absolutePath, fileSize);
|
|
1569
|
+
const bracketContextFullLines = rawSelector ? undefined : buffered?.addressableLines;
|
|
1353
1570
|
const displayedEndLine = startLineDisplay + Math.max(0, displayLines.length - 1);
|
|
1354
1571
|
|
|
1355
1572
|
const selectedContent = displayLines.join("\n");
|
|
@@ -1376,17 +1593,22 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1376
1593
|
const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
1377
1594
|
let hashContext: HashlineHeaderContext | undefined;
|
|
1378
1595
|
if (shouldAddHashLines && collectedLines.length > 0 && !firstLineExceedsLimit) {
|
|
1379
|
-
// The tag is a content hash of the WHOLE file
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1596
|
+
// The tag is a content hash of the WHOLE file, so any anchor the
|
|
1597
|
+
// model returns validates while the live file is unchanged. The
|
|
1598
|
+
// buffered text is that whole file; above the snapshot cap only a
|
|
1599
|
+
// non-truncated whole-file window can supply it.
|
|
1383
1600
|
const isWholeFile = offset === undefined && limit === undefined && !wasTruncated;
|
|
1384
|
-
const tag =
|
|
1601
|
+
const tag = buffered
|
|
1385
1602
|
? getFileSnapshotStore(this.session).record(
|
|
1386
1603
|
canonicalSnapshotKey(absolutePath),
|
|
1387
|
-
|
|
1604
|
+
buffered.normalizedText,
|
|
1388
1605
|
)
|
|
1389
|
-
:
|
|
1606
|
+
: isWholeFile
|
|
1607
|
+
? getFileSnapshotStore(this.session).record(
|
|
1608
|
+
canonicalSnapshotKey(absolutePath),
|
|
1609
|
+
normalizeToLF(`${collectedLines.join("\n")}${hasTrailingNewline ? "\n" : ""}`),
|
|
1610
|
+
)
|
|
1611
|
+
: await recordFileSnapshot(this.session, absolutePath);
|
|
1390
1612
|
if (tag) {
|
|
1391
1613
|
hashContext = hashlineHeaderContext(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag);
|
|
1392
1614
|
}
|
|
@@ -1413,7 +1635,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1413
1635
|
const entries = buildLineEntriesWithBlockContext(
|
|
1414
1636
|
bracketContextFullLines,
|
|
1415
1637
|
[{ startLine: startLineDisplay, endLine: displayedEndLine }],
|
|
1416
|
-
{ path: absolutePath },
|
|
1638
|
+
{ path: absolutePath, text: buffered?.normalizedText },
|
|
1417
1639
|
{
|
|
1418
1640
|
lineText: (lineNumber, sourceText) => {
|
|
1419
1641
|
const visibleText = displayLineByNumber.get(lineNumber);
|
|
@@ -1500,11 +1722,18 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1500
1722
|
recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText);
|
|
1501
1723
|
}
|
|
1502
1724
|
if (rawSelector && !firstLineExceedsLimit && collectedLines.length > 0) {
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1725
|
+
// A raw read emits no header, but recording the range it displayed
|
|
1726
|
+
// lets a same-content hashline tag inherit its provenance.
|
|
1727
|
+
const seenLines = contiguousLineNumbers(startLineDisplay, collectedLines.length);
|
|
1728
|
+
if (buffered) {
|
|
1729
|
+
getFileSnapshotStore(this.session).record(
|
|
1730
|
+
canonicalSnapshotKey(absolutePath),
|
|
1731
|
+
buffered.normalizedText,
|
|
1732
|
+
seenLines,
|
|
1733
|
+
);
|
|
1734
|
+
} else {
|
|
1735
|
+
await recordFileSnapshot(this.session, absolutePath, seenLines);
|
|
1736
|
+
}
|
|
1508
1737
|
}
|
|
1509
1738
|
|
|
1510
1739
|
if (capturedDisplayContent) {
|
|
@@ -1685,10 +1914,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1685
1914
|
const rawSelector = isRawSelector(parsedSel);
|
|
1686
1915
|
const displayMode = resolveFileDisplayMode(this.session, { raw: rawSelector, immutable: true });
|
|
1687
1916
|
if (isMultiRange(parsedSel) && parsedSel.kind === "lines") {
|
|
1917
|
+
// Bracket context and per-range slicing both want the whole artifact, so
|
|
1918
|
+
// materialize it once exactly as the plain-file path does.
|
|
1919
|
+
const artifactBytes = artifact.size <= SNAPSHOT_MAX_BYTES ? await readWholeFile(artifact.path) : undefined;
|
|
1920
|
+
const buffered = artifactBytes ? deriveBufferedFileText(artifactBytes) : undefined;
|
|
1688
1921
|
const read = await this.#readLocalFileMultiRange(
|
|
1689
1922
|
artifact.path,
|
|
1690
1923
|
parsedSel.ranges,
|
|
1691
1924
|
artifact.size,
|
|
1925
|
+
buffered,
|
|
1692
1926
|
parsedSel,
|
|
1693
1927
|
displayMode,
|
|
1694
1928
|
undefined,
|
|
@@ -22,6 +22,20 @@ export interface LineSpan {
|
|
|
22
22
|
export interface BlockContextSource {
|
|
23
23
|
path?: string;
|
|
24
24
|
lang?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The whole source `fullLines` was split from, when the caller still holds it.
|
|
27
|
+
* Supplying it skips re-joining every line into a fresh whole-file string on
|
|
28
|
+
* the way to the parser. It MUST be the same content as `fullLines`; a
|
|
29
|
+
* differing trailing newline is the only tolerated variation, since it moves
|
|
30
|
+
* no node's line number.
|
|
31
|
+
*
|
|
32
|
+
* Every current supplier derives both from one buffer in the same breath, so
|
|
33
|
+
* the two cannot drift. Do NOT set it on a source object that is reused
|
|
34
|
+
* across two different line arrays — a before/after diff pair, say — because
|
|
35
|
+
* the boundary lines tree-sitter reports would then be indexed into the wrong
|
|
36
|
+
* array and surface off-by-N context rows.
|
|
37
|
+
*/
|
|
38
|
+
text?: string;
|
|
25
39
|
}
|
|
26
40
|
|
|
27
41
|
export type LineEntry = { kind: "line"; lineNumber: number; text: string; context: boolean } | { kind: "ellipsis" };
|
|
@@ -105,7 +119,7 @@ function nativeBlockContext(
|
|
|
105
119
|
let boundaries: number[] | null;
|
|
106
120
|
try {
|
|
107
121
|
boundaries = enclosingBlockBoundaries({
|
|
108
|
-
code: fullLines.join("\n"),
|
|
122
|
+
code: source.text ?? fullLines.join("\n"),
|
|
109
123
|
path: source.path,
|
|
110
124
|
lang: source.lang,
|
|
111
125
|
ranges,
|
|
@@ -8,3 +8,36 @@ export function withTimeoutSignal(timeoutMs: number, signal?: AbortSignal): Abor
|
|
|
8
8
|
export function isTimeoutError(error: unknown): boolean {
|
|
9
9
|
return error instanceof Error && error.name === "TimeoutError";
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Proxy environment variables Bun's `fetch` consults, in the precedence order it
|
|
14
|
+
* reads them. Used to name the offending entry in {@link unsupportedProxyMessage}.
|
|
15
|
+
*/
|
|
16
|
+
const PROXY_ENV_VARS = ["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy", "HTTP_PROXY", "http_proxy"] as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Detect Bun's `UnsupportedProxyProtocol` fetch rejection, raised when a proxy
|
|
20
|
+
* env var (`HTTPS_PROXY`, `ALL_PROXY`, …) points at a scheme it cannot drive —
|
|
21
|
+
* most commonly a SOCKS proxy (`socks5://`, `socks5h://`). The raw error tells
|
|
22
|
+
* the caller to "pass `verbose: true` in the second argument to fetch()", which
|
|
23
|
+
* is meaningless from a CLI, so callers translate it into
|
|
24
|
+
* {@link unsupportedProxyMessage}.
|
|
25
|
+
*/
|
|
26
|
+
export function isUnsupportedProxyError(error: unknown): boolean {
|
|
27
|
+
return error instanceof Error && error.message.includes("UnsupportedProxyProtocol");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build an actionable CLI message for an {@link isUnsupportedProxyError} failure,
|
|
32
|
+
* naming any set proxy env var whose scheme is not `http(s)://` so the user can
|
|
33
|
+
* see exactly which variable to change.
|
|
34
|
+
*/
|
|
35
|
+
export function unsupportedProxyMessage(env: Record<string, string | undefined> = process.env): string {
|
|
36
|
+
const offending: string[] = [];
|
|
37
|
+
for (const name of PROXY_ENV_VARS) {
|
|
38
|
+
const value = env[name];
|
|
39
|
+
if (value && !/^https?:\/\//i.test(value)) offending.push(`${name}=${value}`);
|
|
40
|
+
}
|
|
41
|
+
const detail = offending.length > 0 ? ` (offending: ${offending.join(", ")})` : "";
|
|
42
|
+
return `Proxy configuration uses a scheme Bun's fetch cannot use${detail}. Only http:// and https:// proxies are supported — SOCKS proxies (socks5://, socks5h://) are not. Point HTTP_PROXY/HTTPS_PROXY at an http:// proxy URL or unset the proxy variables, then retry.`;
|
|
43
|
+
}
|