@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/examples/extensions/plan-mode.ts +0 -1
- package/package.json +10 -10
- package/scripts/build-binary.ts +5 -0
- package/src/autoresearch/helpers.ts +17 -0
- package/src/autoresearch/tools/log-experiment.ts +9 -17
- package/src/autoresearch/tools/run-experiment.ts +2 -17
- package/src/capability/skill.ts +7 -0
- package/src/cli/list-models.ts +1 -1
- package/src/cli/shell-cli.ts +3 -13
- package/src/cli/update-cli.ts +1 -1
- package/src/cli.ts +10 -29
- package/src/commands/commit.ts +10 -0
- package/src/commit/agentic/tools/propose-changelog.ts +8 -1
- package/src/commit/analysis/conventional.ts +8 -66
- package/src/commit/map-reduce/reduce-phase.ts +6 -65
- package/src/commit/pipeline.ts +2 -2
- package/src/commit/shared-llm.ts +89 -0
- package/src/config/config-file.ts +210 -0
- package/src/config/model-equivalence.ts +8 -11
- package/src/config/model-registry.ts +44 -3
- package/src/config/model-resolver.ts +1 -4
- package/src/config/settings-schema.ts +82 -1
- package/src/config/settings.ts +1 -1
- package/src/config.ts +3 -219
- package/src/discovery/claude-plugins.ts +19 -7
- package/src/edit/renderer.ts +7 -1
- package/src/eval/js/executor.ts +3 -0
- package/src/eval/js/shared/rewrite-imports.ts +2 -2
- package/src/eval/py/executor.ts +5 -0
- package/src/eval/py/runner.py +42 -11
- package/src/eval/py/runtime.ts +1 -0
- package/src/exa/factory.ts +2 -2
- package/src/exa/mcp-client.ts +74 -1
- package/src/exec/bash-executor.ts +5 -1
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +0 -11
- package/src/extensibility/extensions/get-commands-handler.ts +77 -0
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +89 -223
- package/src/extensibility/hooks/types.ts +89 -314
- package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
- package/src/extensibility/shared-events.ts +343 -0
- package/src/extensibility/skills.ts +9 -0
- package/src/goals/index.ts +3 -0
- package/src/goals/runtime.ts +500 -0
- package/src/goals/state.ts +37 -0
- package/src/goals/tools/goal-tool.ts +237 -0
- package/src/hashline/anchors.ts +2 -2
- package/src/hashline/input.ts +2 -1
- package/src/hashline/parser.ts +27 -3
- package/src/hindsight/mental-models.ts +1 -1
- package/src/internal-urls/agent-protocol.ts +1 -20
- package/src/internal-urls/artifact-protocol.ts +1 -19
- package/src/internal-urls/docs-index.generated.ts +11 -12
- package/src/internal-urls/registry-helpers.ts +25 -0
- package/src/internal-urls/router.ts +8 -0
- package/src/internal-urls/types.ts +21 -0
- package/src/lsp/config.ts +15 -6
- package/src/lsp/defaults.json +6 -2
- package/src/main.ts +11 -2
- package/src/mcp/oauth-flow.ts +20 -0
- package/src/modes/acp/acp-agent.ts +327 -95
- package/src/modes/components/assistant-message.ts +14 -8
- package/src/modes/components/bash-execution.ts +24 -63
- package/src/modes/components/custom-message.ts +14 -40
- package/src/modes/components/eval-execution.ts +27 -57
- package/src/modes/components/execution-shared.ts +102 -0
- package/src/modes/components/hook-message.ts +17 -49
- package/src/modes/components/mcp-add-wizard.ts +26 -5
- package/src/modes/components/message-frame.ts +88 -0
- package/src/modes/components/model-selector.ts +1 -1
- package/src/modes/components/session-observer-overlay.ts +6 -2
- package/src/modes/components/session-selector.ts +1 -1
- package/src/modes/components/status-line/segments.ts +93 -8
- package/src/modes/components/status-line/types.ts +4 -0
- package/src/modes/components/status-line.ts +28 -10
- package/src/modes/components/tool-execution.ts +7 -8
- package/src/modes/controllers/command-controller-shared.ts +108 -0
- package/src/modes/controllers/command-controller.ts +13 -4
- package/src/modes/controllers/event-controller.ts +36 -7
- package/src/modes/controllers/extension-ui-controller.ts +3 -2
- package/src/modes/controllers/input-controller.ts +13 -0
- package/src/modes/controllers/mcp-command-controller.ts +56 -61
- package/src/modes/controllers/ssh-command-controller.ts +18 -57
- package/src/modes/interactive-mode.ts +624 -52
- package/src/modes/print-mode.ts +16 -86
- package/src/modes/rpc/host-uris.ts +235 -0
- package/src/modes/rpc/rpc-mode.ts +41 -88
- package/src/modes/rpc/rpc-types.ts +57 -0
- package/src/modes/runtime-init.ts +116 -0
- package/src/modes/theme/defaults/dark-poimandres.json +3 -0
- package/src/modes/theme/defaults/light-poimandres.json +3 -0
- package/src/modes/theme/theme.ts +24 -6
- package/src/modes/types.ts +14 -3
- package/src/modes/utils/context-usage.ts +13 -13
- package/src/modes/utils/ui-helpers.ts +10 -3
- package/src/plan-mode/approved-plan.ts +35 -1
- package/src/prompts/goals/goal-budget-limit.md +16 -0
- package/src/prompts/goals/goal-continuation.md +28 -0
- package/src/prompts/goals/goal-mode-active.md +23 -0
- package/src/prompts/system/plan-mode-active.md +5 -5
- package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
- package/src/prompts/tools/bash.md +6 -0
- package/src/prompts/tools/github.md +4 -4
- package/src/prompts/tools/goal.md +13 -0
- package/src/prompts/tools/hashline.md +101 -117
- package/src/prompts/tools/read.md +55 -36
- package/src/prompts/tools/resolve.md +6 -5
- package/src/sdk.ts +12 -5
- package/src/session/agent-session.ts +428 -106
- package/src/session/blob-store.ts +36 -3
- package/src/session/messages.ts +67 -2
- package/src/session/session-manager.ts +131 -12
- package/src/session/session-storage.ts +33 -15
- package/src/session/streaming-output.ts +309 -13
- package/src/slash-commands/builtin-registry.ts +18 -0
- package/src/ssh/ssh-executor.ts +5 -0
- package/src/system-prompt.ts +4 -2
- package/src/task/discovery.ts +5 -2
- package/src/task/executor.ts +19 -8
- package/src/task/index.ts +3 -0
- package/src/task/render.ts +21 -15
- package/src/task/types.ts +4 -0
- package/src/tools/ast-edit.ts +21 -120
- package/src/tools/ast-grep.ts +21 -119
- package/src/tools/bash-command-fixup.ts +47 -0
- package/src/tools/bash-interactive.ts +9 -1
- package/src/tools/bash.ts +66 -19
- package/src/tools/browser/attach.ts +3 -3
- package/src/tools/browser/launch.ts +81 -18
- package/src/tools/browser/registry.ts +1 -5
- package/src/tools/browser/render.ts +2 -2
- package/src/tools/browser/tab-supervisor.ts +51 -14
- package/src/tools/conflict-detect.ts +15 -4
- package/src/tools/eval.ts +12 -2
- package/src/tools/find.ts +20 -38
- package/src/tools/gh.ts +44 -10
- package/src/tools/index.ts +22 -11
- package/src/tools/inspect-image.ts +3 -10
- package/src/tools/job.ts +16 -7
- package/src/tools/output-meta.ts +202 -37
- package/src/tools/path-utils.ts +125 -2
- package/src/tools/read.ts +548 -237
- package/src/tools/render-utils.ts +92 -0
- package/src/tools/renderers.ts +2 -0
- package/src/tools/resolve.ts +72 -44
- package/src/tools/search.ts +120 -186
- package/src/tools/ssh.ts +3 -2
- package/src/tools/write.ts +64 -9
- package/src/utils/file-mentions.ts +1 -1
- package/src/utils/image-loading.ts +7 -3
- package/src/utils/image-resize.ts +32 -43
- package/src/vim/parser.ts +0 -17
- package/src/vim/render.ts +1 -1
- package/src/vim/types.ts +1 -1
- package/src/web/search/providers/anthropic.ts +5 -0
- package/src/web/search/providers/exa.ts +3 -0
- package/src/web/search/providers/gemini.ts +40 -95
- package/src/web/search/providers/jina.ts +5 -2
- package/src/web/search/providers/zai.ts +5 -2
- package/src/prompts/tools/exit-plan-mode.md +0 -6
- package/src/tools/exit-plan-mode.ts +0 -97
- package/src/utils/fuzzy.ts +0 -108
- package/src/utils/image-convert.ts +0 -27
package/src/tools/read.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type TruncationResult,
|
|
26
26
|
truncateHead,
|
|
27
27
|
truncateHeadBytes,
|
|
28
|
+
truncateLine,
|
|
28
29
|
} from "../session/streaming-output";
|
|
29
30
|
import { renderCodeCell, renderMarkdownCell, renderStatusLine } from "../tui";
|
|
30
31
|
import { CachedOutputBlock } from "../tui/output-block";
|
|
@@ -54,7 +55,13 @@ import {
|
|
|
54
55
|
renderReadUrlResult,
|
|
55
56
|
} from "./fetch";
|
|
56
57
|
import { applyListLimit } from "./list-limit";
|
|
57
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
formatFullOutputReference,
|
|
60
|
+
formatStyledTruncationWarning,
|
|
61
|
+
type OutputMeta,
|
|
62
|
+
resolveOutputMaxColumns,
|
|
63
|
+
stripOutputNotice,
|
|
64
|
+
} from "./output-meta";
|
|
58
65
|
import { expandPath, formatPathRelativeToCwd, resolveReadPath, splitPathAndSel } from "./path-utils";
|
|
59
66
|
import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
|
|
60
67
|
import {
|
|
@@ -81,6 +88,12 @@ const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx"
|
|
|
81
88
|
|
|
82
89
|
const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
|
|
83
90
|
const MAX_SUMMARY_LINES = 20_000;
|
|
91
|
+
/**
|
|
92
|
+
* Per-line column cap for file reads. Lines wider than the value of
|
|
93
|
+
* `tools.outputMaxColumns` are ellipsis-truncated at display time; the file
|
|
94
|
+
* on disk is unchanged. Shared with the streaming sink path so one setting
|
|
95
|
+
* covers `bash`/`ssh`/`python`/`js eval` and `read` uniformly.
|
|
96
|
+
*/
|
|
84
97
|
const PROSE_SUMMARY_EXTENSIONS = new Set([".md", ".txt"]);
|
|
85
98
|
// Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
|
|
86
99
|
const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
|
|
@@ -160,21 +173,41 @@ function countTextLines(text: string): number {
|
|
|
160
173
|
if (text.length === 0) return 0;
|
|
161
174
|
return text.split("\n").length;
|
|
162
175
|
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Footer appended to summarized reads telling the model how to recover the
|
|
179
|
+
* elided body. Without this hint, agents either ignore the `...`/`{ .. }`
|
|
180
|
+
* markers or burn a turn guessing the right selector (see issue #1046).
|
|
181
|
+
*/
|
|
182
|
+
function formatSummaryElisionFooter(readPath: string, elidedSpans: number, elidedLines: number): string {
|
|
183
|
+
if (elidedSpans <= 0) return "";
|
|
184
|
+
const spanWord = elidedSpans === 1 ? "region" : "regions";
|
|
185
|
+
const lineWord = elidedLines === 1 ? "line" : "lines";
|
|
186
|
+
const linePart = elidedLines > 0 ? `${elidedLines} ${lineWord} across ` : "";
|
|
187
|
+
return `[${linePart}${elidedSpans} elided ${spanWord}; read ${readPath}:raw or a line range like ${readPath}:1-9999 for verbatim content]`;
|
|
188
|
+
}
|
|
163
189
|
const READ_CHUNK_SIZE = 8 * 1024;
|
|
164
190
|
|
|
165
191
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
192
|
+
* Context lines added around an explicit range read. Anchor-stale failures
|
|
193
|
+
* cluster on edits whose anchors land just outside the most recent read
|
|
194
|
+
* window, but the data (`scripts/session-stats/analyze_selector_reads.py`)
|
|
195
|
+
* shows most follow-up reads are disjoint hops, not adjacent extensions —
|
|
196
|
+
* so symmetric padding rarely pays for itself.
|
|
197
|
+
*
|
|
198
|
+
* Leading=1 catches accidental single-line reads where the anchor is the
|
|
199
|
+
* line immediately above the requested start. Trailing=3 buffers the
|
|
200
|
+
* common case where the agent asks for a narrow range and then needs the
|
|
201
|
+
* next few lines to disambiguate an anchor.
|
|
170
202
|
*/
|
|
171
|
-
const
|
|
203
|
+
const RANGE_LEADING_CONTEXT_LINES = 1;
|
|
204
|
+
const RANGE_TRAILING_CONTEXT_LINES = 3;
|
|
172
205
|
|
|
173
206
|
/**
|
|
174
|
-
* Expand a [start, end) range with
|
|
207
|
+
* Expand a [start, end) range with leading/trailing context lines on the
|
|
175
208
|
* sides where the user actually constrained the range. A start of 0 (no
|
|
176
|
-
* explicit offset) does not get leading context — that's already an
|
|
177
|
-
* read from the top.
|
|
209
|
+
* explicit offset) does not get leading context — that's already an
|
|
210
|
+
* open-ended read from the top.
|
|
178
211
|
*/
|
|
179
212
|
function expandRangeWithContext(
|
|
180
213
|
requestedStart: number,
|
|
@@ -184,8 +217,8 @@ function expandRangeWithContext(
|
|
|
184
217
|
expandEnd: boolean,
|
|
185
218
|
): { startLine: number; endLine: number } {
|
|
186
219
|
return {
|
|
187
|
-
startLine: expandStart ? Math.max(0, requestedStart -
|
|
188
|
-
endLine: expandEnd ? Math.min(totalLines, requestedEnd +
|
|
220
|
+
startLine: expandStart ? Math.max(0, requestedStart - RANGE_LEADING_CONTEXT_LINES) : requestedStart,
|
|
221
|
+
endLine: expandEnd ? Math.min(totalLines, requestedEnd + RANGE_TRAILING_CONTEXT_LINES) : requestedEnd,
|
|
189
222
|
};
|
|
190
223
|
}
|
|
191
224
|
|
|
@@ -465,7 +498,7 @@ export interface ReadToolDetails {
|
|
|
465
498
|
* Mirrors the same lines the model receives but without hashline/line-number prefixes,
|
|
466
499
|
* so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
|
|
467
500
|
displayContent?: { text: string; startLine: number };
|
|
468
|
-
summary?: { lines: number; elidedSpans: number };
|
|
501
|
+
summary?: { lines: number; elidedSpans: number; elidedLines: number };
|
|
469
502
|
/** Number of unresolved git conflicts surfaced by this read (TUI uses for inline `⚠ N` badge). */
|
|
470
503
|
conflictCount?: number;
|
|
471
504
|
}
|
|
@@ -473,11 +506,13 @@ export interface ReadToolDetails {
|
|
|
473
506
|
type ReadParams = ReadToolInput;
|
|
474
507
|
|
|
475
508
|
/** Parsed representation of a path-embedded selector. */
|
|
509
|
+
type LineRange = { startLine: number; endLine: number | undefined };
|
|
510
|
+
|
|
476
511
|
type ParsedSelector =
|
|
477
512
|
| { kind: "none" }
|
|
478
513
|
| { kind: "raw" }
|
|
479
514
|
| { kind: "conflicts" }
|
|
480
|
-
| { kind: "lines";
|
|
515
|
+
| { kind: "lines"; ranges: [LineRange, ...LineRange[]]; raw?: boolean };
|
|
481
516
|
|
|
482
517
|
const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
|
|
483
518
|
|
|
@@ -486,7 +521,12 @@ function isRawSelector(parsed: ParsedSelector): boolean {
|
|
|
486
521
|
return parsed.kind === "raw" || (parsed.kind === "lines" && parsed.raw === true);
|
|
487
522
|
}
|
|
488
523
|
|
|
489
|
-
|
|
524
|
+
/** Returns true when the selector requested multiple line ranges. */
|
|
525
|
+
function isMultiRange(parsed: ParsedSelector): boolean {
|
|
526
|
+
return parsed.kind === "lines" && parsed.ranges.length > 1;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function parseLineRangeChunk(sel: string): LineRange | null {
|
|
490
530
|
const lineMatch = LINE_RANGE_RE.exec(sel);
|
|
491
531
|
if (!lineMatch) return null;
|
|
492
532
|
const rawStart = Number.parseInt(lineMatch[1]!, 10);
|
|
@@ -510,11 +550,45 @@ function parseLineRangeChunk(sel: string): { startLine: number; endLine: number
|
|
|
510
550
|
return { startLine: rawStart, endLine: rawEnd };
|
|
511
551
|
}
|
|
512
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Parse a comma-separated list of line ranges (e.g. `5-16,960-973`). Returns
|
|
555
|
+
* the ranges in ascending order with overlapping/adjacent ranges merged so
|
|
556
|
+
* downstream consumers can stream the file in a single forward pass per range.
|
|
557
|
+
*/
|
|
558
|
+
function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null {
|
|
559
|
+
const chunks = sel.split(",");
|
|
560
|
+
const parsed: LineRange[] = [];
|
|
561
|
+
for (const chunk of chunks) {
|
|
562
|
+
const range = parseLineRangeChunk(chunk);
|
|
563
|
+
if (!range) return null;
|
|
564
|
+
parsed.push(range);
|
|
565
|
+
}
|
|
566
|
+
if (parsed.length === 0) return null;
|
|
567
|
+
parsed.sort((a, b) => a.startLine - b.startLine);
|
|
568
|
+
|
|
569
|
+
const merged: LineRange[] = [parsed[0]];
|
|
570
|
+
for (let i = 1; i < parsed.length; i++) {
|
|
571
|
+
const current = parsed[i];
|
|
572
|
+
const last = merged[merged.length - 1];
|
|
573
|
+
// Open-ended (endLine undefined) means "to EOF" — any later range is absorbed.
|
|
574
|
+
if (last.endLine === undefined) continue;
|
|
575
|
+
// Merge when current starts within (or immediately after) the last range.
|
|
576
|
+
if (current.startLine <= last.endLine + 1) {
|
|
577
|
+
if (current.endLine === undefined || current.endLine > last.endLine) {
|
|
578
|
+
merged[merged.length - 1] = { startLine: last.startLine, endLine: current.endLine };
|
|
579
|
+
}
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
merged.push(current);
|
|
583
|
+
}
|
|
584
|
+
return merged as [LineRange, ...LineRange[]];
|
|
585
|
+
}
|
|
586
|
+
|
|
513
587
|
function parseSel(sel: string | undefined): ParsedSelector {
|
|
514
588
|
if (!sel || sel.length === 0) return { kind: "none" };
|
|
515
589
|
|
|
516
590
|
// Compound selector: `1-50:raw` or `raw:1-50`. Split into chunks and accept
|
|
517
|
-
// any combination of one line range and the literal `raw`.
|
|
591
|
+
// any combination of one line range (possibly multi) and the literal `raw`.
|
|
518
592
|
if (sel.includes(":")) {
|
|
519
593
|
const chunks = sel.split(":");
|
|
520
594
|
if (chunks.length === 2) {
|
|
@@ -524,9 +598,9 @@ function parseSel(sel: string | undefined): ParsedSelector {
|
|
|
524
598
|
const rangeChunk = aIsRaw ? b : bIsRaw ? a : null;
|
|
525
599
|
const rawChunk = aIsRaw ? a : bIsRaw ? b : null;
|
|
526
600
|
if (rangeChunk !== null && rawChunk !== null) {
|
|
527
|
-
const
|
|
528
|
-
if (
|
|
529
|
-
return { kind: "lines",
|
|
601
|
+
const ranges = parseLineRanges(rangeChunk);
|
|
602
|
+
if (ranges) {
|
|
603
|
+
return { kind: "lines", ranges, raw: true };
|
|
530
604
|
}
|
|
531
605
|
}
|
|
532
606
|
}
|
|
@@ -536,19 +610,24 @@ function parseSel(sel: string | undefined): ParsedSelector {
|
|
|
536
610
|
|
|
537
611
|
if (sel.toLowerCase() === "raw") return { kind: "raw" };
|
|
538
612
|
if (sel.toLowerCase() === "conflicts") return { kind: "conflicts" };
|
|
539
|
-
const
|
|
540
|
-
if (
|
|
541
|
-
return { kind: "lines",
|
|
613
|
+
const ranges = parseLineRanges(sel);
|
|
614
|
+
if (ranges) {
|
|
615
|
+
return { kind: "lines", ranges };
|
|
542
616
|
}
|
|
543
617
|
// Unrecognized selectors fall through; sqlite/archive/url readers consume their own colon syntax.
|
|
544
618
|
return { kind: "none" };
|
|
545
619
|
}
|
|
546
620
|
|
|
547
|
-
/**
|
|
621
|
+
/**
|
|
622
|
+
* Convert a single-range selector to the offset/limit pair used by internal pagination.
|
|
623
|
+
* Returns the FIRST range only — multi-range callers MUST branch on `isMultiRange` before
|
|
624
|
+
* calling this helper.
|
|
625
|
+
*/
|
|
548
626
|
function selToOffsetLimit(parsed: ParsedSelector): { offset?: number; limit?: number } {
|
|
549
627
|
if (parsed.kind === "lines") {
|
|
550
|
-
const
|
|
551
|
-
|
|
628
|
+
const first = parsed.ranges[0];
|
|
629
|
+
const limit = first.endLine !== undefined ? first.endLine - first.startLine + 1 : undefined;
|
|
630
|
+
return { offset: first.startLine, limit };
|
|
552
631
|
}
|
|
553
632
|
return {};
|
|
554
633
|
}
|
|
@@ -818,6 +897,160 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
818
897
|
return resultBuilder.done();
|
|
819
898
|
}
|
|
820
899
|
|
|
900
|
+
/**
|
|
901
|
+
* Render a multi-range read against in-memory text. Each range emits a
|
|
902
|
+
* formatted block with its own anchors / line numbers, blocks are joined
|
|
903
|
+
* with an elision separator, and ranges past EOF surface as `[…]` notices
|
|
904
|
+
* so the model can correct the next call. No leading/trailing context is
|
|
905
|
+
* added — multi-range callers always specify exact bounds.
|
|
906
|
+
*/
|
|
907
|
+
#buildInMemoryMultiRangeResult(
|
|
908
|
+
text: string,
|
|
909
|
+
ranges: readonly LineRange[],
|
|
910
|
+
options: {
|
|
911
|
+
details?: ReadToolDetails;
|
|
912
|
+
sourcePath?: string;
|
|
913
|
+
sourceUrl?: string;
|
|
914
|
+
sourceInternal?: string;
|
|
915
|
+
entityLabel: string;
|
|
916
|
+
raw?: boolean;
|
|
917
|
+
immutable?: boolean;
|
|
918
|
+
},
|
|
919
|
+
): AgentToolResult<ReadToolDetails> {
|
|
920
|
+
const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw, immutable: options.immutable });
|
|
921
|
+
const details = options.details ?? {};
|
|
922
|
+
const allLines = text.split("\n");
|
|
923
|
+
const totalLines = allLines.length;
|
|
924
|
+
const shouldAddHashLines = displayMode.hashLines;
|
|
925
|
+
const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
926
|
+
|
|
927
|
+
const resultBuilder = toolResult(details);
|
|
928
|
+
if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath);
|
|
929
|
+
if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl);
|
|
930
|
+
if (options.sourceInternal) resultBuilder.sourceInternal(options.sourceInternal);
|
|
931
|
+
|
|
932
|
+
const parts: string[] = [];
|
|
933
|
+
const outOfBounds: LineRange[] = [];
|
|
934
|
+
for (const range of ranges) {
|
|
935
|
+
if (range.startLine > totalLines) {
|
|
936
|
+
outOfBounds.push(range);
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines);
|
|
940
|
+
const sliced = allLines.slice(range.startLine - 1, effectiveEnd).join("\n");
|
|
941
|
+
parts.push(formatTextWithMode(sliced, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const outputText = parts.length > 0 ? parts.join("\n\n…\n\n") : "";
|
|
945
|
+
const notices: string[] = [];
|
|
946
|
+
for (const range of outOfBounds) {
|
|
947
|
+
const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`;
|
|
948
|
+
notices.push(`[Range ${bound} is beyond end of ${options.entityLabel} (${totalLines} lines total); skipped]`);
|
|
949
|
+
}
|
|
950
|
+
const finalText =
|
|
951
|
+
notices.length > 0 ? (outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n")) : outputText;
|
|
952
|
+
resultBuilder.text(finalText);
|
|
953
|
+
return resultBuilder.done();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Stream multiple non-contiguous ranges from a local file. ACP bridge takes
|
|
958
|
+
* priority when present (editor buffer is source of truth); otherwise each
|
|
959
|
+
* range is streamed independently with its own line/byte budget. Out-of-bounds
|
|
960
|
+
* ranges surface as inline notices rather than aborting the read.
|
|
961
|
+
*/
|
|
962
|
+
async #readLocalFileMultiRange(
|
|
963
|
+
absolutePath: string,
|
|
964
|
+
ranges: readonly LineRange[],
|
|
965
|
+
parsed: ParsedSelector,
|
|
966
|
+
displayMode: { hashLines: boolean; lineNumbers: boolean },
|
|
967
|
+
suffixResolution: { from: string; to: string } | undefined,
|
|
968
|
+
signal: AbortSignal | undefined,
|
|
969
|
+
): Promise<{
|
|
970
|
+
outputText: string;
|
|
971
|
+
columnTruncated: number;
|
|
972
|
+
bridgeResult?: AgentToolResult<ReadToolDetails>;
|
|
973
|
+
}> {
|
|
974
|
+
const rawSelector = isRawSelector(parsed);
|
|
975
|
+
|
|
976
|
+
// ACP bridge first — the editor's in-memory buffer is source of truth.
|
|
977
|
+
const bridgePromise = this.#routeReadThroughBridge(absolutePath);
|
|
978
|
+
if (bridgePromise !== undefined) {
|
|
979
|
+
try {
|
|
980
|
+
const bridgeText = await bridgePromise;
|
|
981
|
+
const bridgeResult = this.#buildInMemoryMultiRangeResult(bridgeText, ranges, {
|
|
982
|
+
details: { resolvedPath: absolutePath, suffixResolution },
|
|
983
|
+
sourcePath: absolutePath,
|
|
984
|
+
entityLabel: "file",
|
|
985
|
+
raw: rawSelector,
|
|
986
|
+
});
|
|
987
|
+
if (suffixResolution) {
|
|
988
|
+
const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
|
|
989
|
+
const firstText = bridgeResult.content.find((c): c is TextContent => c.type === "text");
|
|
990
|
+
if (firstText) firstText.text = `${notice}\n${firstText.text}`;
|
|
991
|
+
}
|
|
992
|
+
return { outputText: "", columnTruncated: 0, bridgeResult };
|
|
993
|
+
} catch (error) {
|
|
994
|
+
logger.warn("ACP fs readTextFile failed; falling back to disk", { path: absolutePath, error });
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const shouldAddHashLines = !rawSelector && displayMode.hashLines;
|
|
999
|
+
const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
1000
|
+
const maxColumns = resolveOutputMaxColumns(this.session.settings);
|
|
1001
|
+
|
|
1002
|
+
const blocks: string[] = [];
|
|
1003
|
+
const notices: string[] = [];
|
|
1004
|
+
let columnTruncated = 0;
|
|
1005
|
+
|
|
1006
|
+
for (const range of ranges) {
|
|
1007
|
+
const rangeStart = range.startLine - 1; // 0-indexed
|
|
1008
|
+
const requestedLength = range.endLine !== undefined ? range.endLine - range.startLine + 1 : this.#defaultLimit;
|
|
1009
|
+
const maxLines = Math.min(requestedLength, DEFAULT_MAX_LINES);
|
|
1010
|
+
const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLines * 512);
|
|
1011
|
+
|
|
1012
|
+
const streamResult = await streamLinesFromFile(
|
|
1013
|
+
absolutePath,
|
|
1014
|
+
rangeStart,
|
|
1015
|
+
maxLines,
|
|
1016
|
+
maxBytesForRead,
|
|
1017
|
+
maxLines,
|
|
1018
|
+
signal,
|
|
1019
|
+
);
|
|
1020
|
+
const totalFileLines = streamResult.totalFileLines;
|
|
1021
|
+
|
|
1022
|
+
if (rangeStart >= totalFileLines) {
|
|
1023
|
+
const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`;
|
|
1024
|
+
notices.push(`[Range ${bound} is beyond end of file (${totalFileLines} lines total); skipped]`);
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const collectedLines = streamResult.lines;
|
|
1029
|
+
if (!rawSelector && maxColumns > 0) {
|
|
1030
|
+
for (let i = 0; i < collectedLines.length; i++) {
|
|
1031
|
+
const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
|
|
1032
|
+
if (wasTruncated) {
|
|
1033
|
+
collectedLines[i] = text;
|
|
1034
|
+
columnTruncated = maxColumns;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
if (collectedLines.length > 0) {
|
|
1040
|
+
getFileReadCache(this.session).recordContiguous(absolutePath, range.startLine, collectedLines);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const blockText = collectedLines.join("\n");
|
|
1044
|
+
blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
let outputText = blocks.join("\n\n…\n\n");
|
|
1048
|
+
if (notices.length > 0) {
|
|
1049
|
+
outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
|
|
1050
|
+
}
|
|
1051
|
+
return { outputText, columnTruncated };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
821
1054
|
async #readArchiveDirectory(
|
|
822
1055
|
archive: ArchiveReader,
|
|
823
1056
|
archivePath: string,
|
|
@@ -861,11 +1094,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
861
1094
|
|
|
862
1095
|
async #readArchive(
|
|
863
1096
|
readPath: string,
|
|
864
|
-
|
|
865
|
-
limit: number | undefined,
|
|
1097
|
+
parsedSel: ParsedSelector,
|
|
866
1098
|
resolvedArchivePath: ResolvedArchiveReadPath,
|
|
867
1099
|
signal?: AbortSignal,
|
|
868
|
-
options?: { raw?: boolean },
|
|
869
1100
|
): Promise<AgentToolResult<ReadToolDetails>> {
|
|
870
1101
|
throwIfAborted(signal);
|
|
871
1102
|
const archive = await openArchive(resolvedArchivePath.absolutePath);
|
|
@@ -882,6 +1113,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
882
1113
|
}
|
|
883
1114
|
|
|
884
1115
|
if (node.isDirectory) {
|
|
1116
|
+
if (isMultiRange(parsedSel)) {
|
|
1117
|
+
throw new ToolError("Multi-range line selectors are not supported for archive directory listings.");
|
|
1118
|
+
}
|
|
1119
|
+
const { limit } = selToOffsetLimit(parsedSel);
|
|
885
1120
|
return this.#readArchiveDirectory(
|
|
886
1121
|
archive,
|
|
887
1122
|
resolvedArchivePath.absolutePath,
|
|
@@ -906,12 +1141,26 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
906
1141
|
.done();
|
|
907
1142
|
}
|
|
908
1143
|
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1144
|
+
const raw = isRawSelector(parsedSel);
|
|
1145
|
+
const result =
|
|
1146
|
+
isMultiRange(parsedSel) && parsedSel.kind === "lines"
|
|
1147
|
+
? this.#buildInMemoryMultiRangeResult(text, parsedSel.ranges, {
|
|
1148
|
+
details,
|
|
1149
|
+
sourcePath: resolvedArchivePath.absolutePath,
|
|
1150
|
+
entityLabel: "archive entry",
|
|
1151
|
+
raw,
|
|
1152
|
+
})
|
|
1153
|
+
: this.#buildInMemoryTextResult(
|
|
1154
|
+
text,
|
|
1155
|
+
selToOffsetLimit(parsedSel).offset,
|
|
1156
|
+
selToOffsetLimit(parsedSel).limit,
|
|
1157
|
+
{
|
|
1158
|
+
details,
|
|
1159
|
+
sourcePath: resolvedArchivePath.absolutePath,
|
|
1160
|
+
entityLabel: "archive entry",
|
|
1161
|
+
raw,
|
|
1162
|
+
},
|
|
1163
|
+
);
|
|
915
1164
|
const firstText = result.content.find((content): content is TextContent => content.type === "text");
|
|
916
1165
|
if (firstText) {
|
|
917
1166
|
firstText.text = prependSuffixResolutionNotice(firstText.text, resolvedArchivePath.suffixResolution);
|
|
@@ -1082,6 +1331,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1082
1331
|
text: string;
|
|
1083
1332
|
displayText: string;
|
|
1084
1333
|
elidedSpans: number;
|
|
1334
|
+
elidedLines: number;
|
|
1085
1335
|
} {
|
|
1086
1336
|
const displayMode = resolveFileDisplayMode(this.session);
|
|
1087
1337
|
const shouldAddHashLines = displayMode.hashLines;
|
|
@@ -1142,11 +1392,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1142
1392
|
const modelParts: string[] = [];
|
|
1143
1393
|
const displayParts: string[] = [];
|
|
1144
1394
|
let elidedSpans = 0;
|
|
1395
|
+
let elidedLines = 0;
|
|
1145
1396
|
for (const unit of units) {
|
|
1146
1397
|
if (unit.kind === "elided") {
|
|
1147
1398
|
modelParts.push("...");
|
|
1148
1399
|
displayParts.push("...");
|
|
1149
1400
|
elidedSpans++;
|
|
1401
|
+
elidedLines += unit.endLine - unit.startLine + 1;
|
|
1150
1402
|
continue;
|
|
1151
1403
|
}
|
|
1152
1404
|
if (unit.kind === "merged") {
|
|
@@ -1161,13 +1413,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1161
1413
|
modelParts.push(formatted.model);
|
|
1162
1414
|
displayParts.push(formatted.display);
|
|
1163
1415
|
elidedSpans++;
|
|
1416
|
+
// Merged brace pair encloses (start+1)..(end-1) as elided.
|
|
1417
|
+
elidedLines += Math.max(0, unit.endLine - unit.startLine - 1);
|
|
1164
1418
|
continue;
|
|
1165
1419
|
}
|
|
1166
1420
|
modelParts.push(formatSingleLine(unit.line, unit.text, shouldAddHashLines, shouldAddLineNumbers));
|
|
1167
1421
|
displayParts.push(unit.text);
|
|
1168
1422
|
}
|
|
1169
1423
|
|
|
1170
|
-
return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedSpans };
|
|
1424
|
+
return { text: modelParts.join("\n"), displayText: displayParts.join("\n"), elidedSpans, elidedLines };
|
|
1171
1425
|
}
|
|
1172
1426
|
|
|
1173
1427
|
async execute(
|
|
@@ -1223,22 +1477,18 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1223
1477
|
const internalRouter = InternalUrlRouter.instance();
|
|
1224
1478
|
if (internalRouter.canHandle(internalTarget.path)) {
|
|
1225
1479
|
const parsed = parseSel(internalTarget.sel);
|
|
1226
|
-
|
|
1227
|
-
return this.#handleInternalUrl(internalTarget.path, offset, limit, { raw: isRawSelector(parsed) }, signal);
|
|
1480
|
+
return this.#handleInternalUrl(internalTarget.path, parsed, signal);
|
|
1228
1481
|
}
|
|
1229
1482
|
|
|
1230
1483
|
const archivePath = await this.#resolveArchiveReadPath(readPath, signal);
|
|
1231
1484
|
if (archivePath) {
|
|
1232
1485
|
const archiveSubPath = splitPathAndSel(archivePath.archiveSubPath);
|
|
1233
1486
|
const archiveParsed = parseSel(archiveSubPath.sel);
|
|
1234
|
-
const { offset, limit } = selToOffsetLimit(archiveParsed);
|
|
1235
1487
|
return this.#readArchive(
|
|
1236
1488
|
readPath,
|
|
1237
|
-
|
|
1238
|
-
limit,
|
|
1489
|
+
archiveParsed,
|
|
1239
1490
|
{ ...archivePath, archiveSubPath: archiveSubPath.path },
|
|
1240
1491
|
signal,
|
|
1241
|
-
{ raw: isRawSelector(archiveParsed) },
|
|
1242
1492
|
);
|
|
1243
1493
|
}
|
|
1244
1494
|
|
|
@@ -1287,6 +1537,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1287
1537
|
}
|
|
1288
1538
|
|
|
1289
1539
|
if (isDirectory) {
|
|
1540
|
+
if (isMultiRange(parsed)) {
|
|
1541
|
+
throw new ToolError("Multi-range line selectors are not supported for directory listings.");
|
|
1542
|
+
}
|
|
1290
1543
|
const dirResult = await this.#readDirectory(absolutePath, selToOffsetLimit(parsed).limit, signal);
|
|
1291
1544
|
if (suffixResolution) {
|
|
1292
1545
|
dirResult.details ??= {};
|
|
@@ -1302,13 +1555,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1302
1555
|
const imageMetadata = await readImageMetadata(absolutePath);
|
|
1303
1556
|
const mimeType = imageMetadata?.mimeType;
|
|
1304
1557
|
const ext = path.extname(absolutePath).toLowerCase();
|
|
1305
|
-
const _hasEditTool = this.session.hasEditTool ?? true;
|
|
1306
|
-
const _language = getLanguageFromPath(absolutePath);
|
|
1307
1558
|
const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext);
|
|
1308
1559
|
// Read the file based on type
|
|
1309
1560
|
let content: Array<TextContent | ImageContent> | undefined;
|
|
1310
1561
|
let details: ReadToolDetails = {};
|
|
1311
1562
|
let sourcePath: string | undefined;
|
|
1563
|
+
let columnTruncated = 0;
|
|
1312
1564
|
let truncationInfo:
|
|
1313
1565
|
| { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
|
|
1314
1566
|
| undefined;
|
|
@@ -1372,17 +1624,20 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1372
1624
|
}
|
|
1373
1625
|
}
|
|
1374
1626
|
} else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) {
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
offset,
|
|
1379
|
-
limit,
|
|
1380
|
-
{
|
|
1627
|
+
const notebookText = await readEditableNotebookText(absolutePath, localReadPath);
|
|
1628
|
+
if (isMultiRange(parsed) && parsed.kind === "lines") {
|
|
1629
|
+
return this.#buildInMemoryMultiRangeResult(notebookText, parsed.ranges, {
|
|
1381
1630
|
details: { resolvedPath: absolutePath },
|
|
1382
1631
|
sourcePath: absolutePath,
|
|
1383
1632
|
entityLabel: "notebook",
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
const { offset, limit } = selToOffsetLimit(parsed);
|
|
1636
|
+
return this.#buildInMemoryTextResult(notebookText, offset, limit, {
|
|
1637
|
+
details: { resolvedPath: absolutePath },
|
|
1638
|
+
sourcePath: absolutePath,
|
|
1639
|
+
entityLabel: "notebook",
|
|
1640
|
+
});
|
|
1386
1641
|
} else if (shouldConvertWithMarkit) {
|
|
1387
1642
|
// Convert document via markit.
|
|
1388
1643
|
const result = await convertFileWithMarkit(absolutePath, signal);
|
|
@@ -1410,211 +1665,251 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1410
1665
|
const summary = await this.#trySummarize(absolutePath, fileSize, signal);
|
|
1411
1666
|
if (summary?.parsed && summary.elided) {
|
|
1412
1667
|
const renderedSummary = this.#renderSummary(summary);
|
|
1668
|
+
const footer = formatSummaryElisionFooter(
|
|
1669
|
+
localReadPath,
|
|
1670
|
+
renderedSummary.elidedSpans,
|
|
1671
|
+
renderedSummary.elidedLines,
|
|
1672
|
+
);
|
|
1673
|
+
const modelText = footer ? `${renderedSummary.text}\n\n${footer}` : renderedSummary.text;
|
|
1413
1674
|
details = {
|
|
1414
1675
|
displayContent: { text: renderedSummary.displayText, startLine: 1 },
|
|
1415
1676
|
summary: {
|
|
1416
1677
|
lines: countTextLines(renderedSummary.text),
|
|
1417
1678
|
elidedSpans: renderedSummary.elidedSpans,
|
|
1679
|
+
elidedLines: renderedSummary.elidedLines,
|
|
1418
1680
|
},
|
|
1419
1681
|
};
|
|
1420
1682
|
|
|
1421
1683
|
sourcePath = absolutePath;
|
|
1422
|
-
content = [{ type: "text", text:
|
|
1684
|
+
content = [{ type: "text", text: modelText }];
|
|
1423
1685
|
}
|
|
1424
1686
|
}
|
|
1425
1687
|
|
|
1426
1688
|
if (!content) {
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1689
|
+
if (isMultiRange(parsed) && parsed.kind === "lines") {
|
|
1690
|
+
const multiResult = await this.#readLocalFileMultiRange(
|
|
1691
|
+
absolutePath,
|
|
1692
|
+
parsed.ranges,
|
|
1693
|
+
parsed,
|
|
1694
|
+
displayMode,
|
|
1695
|
+
suffixResolution,
|
|
1696
|
+
signal,
|
|
1697
|
+
);
|
|
1698
|
+
if (multiResult.bridgeResult) return multiResult.bridgeResult;
|
|
1699
|
+
content = [{ type: "text", text: multiResult.outputText }];
|
|
1700
|
+
sourcePath = absolutePath;
|
|
1701
|
+
details = {};
|
|
1702
|
+
if (multiResult.columnTruncated > 0) {
|
|
1703
|
+
columnTruncated = multiResult.columnTruncated;
|
|
1704
|
+
}
|
|
1705
|
+
} else {
|
|
1706
|
+
// Raw text or line-range mode
|
|
1707
|
+
const { offset, limit } = selToOffsetLimit(parsed);
|
|
1708
|
+
// Try ACP bridge first — editor's in-memory buffer is source of truth.
|
|
1709
|
+
// Request full text so local range rendering keeps normal context and line numbers.
|
|
1710
|
+
const bridgePromise = this.#routeReadThroughBridge(absolutePath);
|
|
1711
|
+
if (bridgePromise !== undefined) {
|
|
1712
|
+
try {
|
|
1713
|
+
const bridgeText = await bridgePromise;
|
|
1714
|
+
const bridgeResult = this.#buildInMemoryTextResult(bridgeText, offset, limit, {
|
|
1715
|
+
details: { resolvedPath: absolutePath, suffixResolution },
|
|
1716
|
+
sourcePath: absolutePath,
|
|
1717
|
+
entityLabel: "file",
|
|
1718
|
+
raw: isRawSelector(parsed),
|
|
1719
|
+
});
|
|
1720
|
+
if (suffixResolution) {
|
|
1721
|
+
const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
|
|
1722
|
+
const firstText = bridgeResult.content.find((c): c is TextContent => c.type === "text");
|
|
1723
|
+
if (firstText) firstText.text = `${notice}\n${firstText.text}`;
|
|
1724
|
+
}
|
|
1725
|
+
return bridgeResult;
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
logger.warn("ACP fs readTextFile failed; falling back to disk", { path: absolutePath, error });
|
|
1445
1728
|
}
|
|
1446
|
-
return bridgeResult;
|
|
1447
|
-
} catch (error) {
|
|
1448
|
-
logger.warn("ACP fs readTextFile failed; falling back to disk", { path: absolutePath, error });
|
|
1449
1729
|
}
|
|
1450
|
-
}
|
|
1451
1730
|
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1731
|
+
// User-requested 0-indexed range start. Lines BEFORE this become
|
|
1732
|
+
// leading context (added below if offset is explicit).
|
|
1733
|
+
const requestedStart = offset ? Math.max(0, offset - 1) : 0;
|
|
1734
|
+
const expandStart = offset !== undefined && offset > 1;
|
|
1735
|
+
const expandEnd = limit !== undefined;
|
|
1736
|
+
const leadingContext = expandStart ? Math.min(requestedStart, RANGE_LEADING_CONTEXT_LINES) : 0;
|
|
1737
|
+
const trailingContext = expandEnd ? RANGE_TRAILING_CONTEXT_LINES : 0;
|
|
1738
|
+
const startLine = requestedStart - leadingContext;
|
|
1739
|
+
const startLineDisplay = startLine + 1;
|
|
1740
|
+
|
|
1741
|
+
const DEFAULT_LIMIT = this.#defaultLimit;
|
|
1742
|
+
const effectiveLimit = limit ?? DEFAULT_LIMIT;
|
|
1743
|
+
const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
|
|
1744
|
+
const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
|
|
1745
|
+
// Scale byte budget with line limit so the configured line count actually fits.
|
|
1746
|
+
// Assume ~512 bytes/line average; never go below the shared default.
|
|
1747
|
+
const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
|
|
1748
|
+
|
|
1749
|
+
const streamResult = await streamLinesFromFile(
|
|
1750
|
+
absolutePath,
|
|
1751
|
+
startLine,
|
|
1752
|
+
maxLinesToCollect,
|
|
1753
|
+
maxBytesForRead,
|
|
1754
|
+
selectedLineLimit,
|
|
1755
|
+
signal,
|
|
1756
|
+
);
|
|
1478
1757
|
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1758
|
+
const {
|
|
1759
|
+
lines: collectedLines,
|
|
1760
|
+
totalFileLines,
|
|
1761
|
+
collectedBytes,
|
|
1762
|
+
stoppedByByteLimit,
|
|
1763
|
+
firstLinePreview,
|
|
1764
|
+
firstLineByteLength,
|
|
1765
|
+
} = streamResult;
|
|
1766
|
+
|
|
1767
|
+
// Check if offset is out of bounds - return graceful message instead of throwing
|
|
1768
|
+
if (requestedStart >= totalFileLines) {
|
|
1769
|
+
const suggestion =
|
|
1770
|
+
totalFileLines === 0
|
|
1771
|
+
? "The file is empty."
|
|
1772
|
+
: `Use :1 to read from the start, or :${totalFileLines} to read the last line.`;
|
|
1773
|
+
return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
|
|
1774
|
+
.text(
|
|
1775
|
+
`Line ${requestedStart + 1} is beyond end of file (${totalFileLines} lines total). ${suggestion}`,
|
|
1776
|
+
)
|
|
1777
|
+
.done();
|
|
1778
|
+
}
|
|
1500
1779
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
outputBytes: collectedBytes,
|
|
1517
|
-
lastLinePartial: false,
|
|
1518
|
-
firstLineExceedsLimit,
|
|
1519
|
-
};
|
|
1780
|
+
// Per-line column cap. Skipped in raw mode so `:raw` always returns
|
|
1781
|
+
// verbatim bytes for paste-back-into-tool workflows. Total byte/line
|
|
1782
|
+
// counts in `truncation` keep reflecting the source, not the trimmed
|
|
1783
|
+
// view — column truncation surfaces separately via `.limits()`.
|
|
1784
|
+
const rawSelector = isRawSelector(parsed);
|
|
1785
|
+
const maxColumns = resolveOutputMaxColumns(this.session.settings);
|
|
1786
|
+
if (!rawSelector && maxColumns > 0) {
|
|
1787
|
+
for (let i = 0; i < collectedLines.length; i++) {
|
|
1788
|
+
const { text, wasTruncated } = truncateLine(collectedLines[i], maxColumns);
|
|
1789
|
+
if (wasTruncated) {
|
|
1790
|
+
collectedLines[i] = text;
|
|
1791
|
+
columnTruncated = maxColumns;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1520
1795
|
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1796
|
+
const selectedContent = collectedLines.join("\n");
|
|
1797
|
+
const userLimitedLines = collectedLines.length;
|
|
1798
|
+
|
|
1799
|
+
const totalSelectedLines = totalFileLines - startLine;
|
|
1800
|
+
const totalSelectedBytes = collectedBytes;
|
|
1801
|
+
const wasTruncated = collectedLines.length < totalSelectedLines || stoppedByByteLimit;
|
|
1802
|
+
const firstLineExceedsLimit = firstLineByteLength !== undefined && firstLineByteLength > maxBytesForRead;
|
|
1803
|
+
|
|
1804
|
+
const truncation: TruncationResult = {
|
|
1805
|
+
content: selectedContent,
|
|
1806
|
+
truncated: wasTruncated,
|
|
1807
|
+
truncatedBy: stoppedByByteLimit ? "bytes" : wasTruncated ? "lines" : undefined,
|
|
1808
|
+
totalLines: totalSelectedLines,
|
|
1809
|
+
totalBytes: totalSelectedBytes,
|
|
1810
|
+
outputLines: collectedLines.length,
|
|
1811
|
+
outputBytes: collectedBytes,
|
|
1812
|
+
lastLinePartial: false,
|
|
1813
|
+
firstLineExceedsLimit,
|
|
1814
|
+
};
|
|
1524
1815
|
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
let capturedDisplayContent: { text: string; startLine: number } | undefined;
|
|
1529
|
-
const formatText = (text: string, startNum: number): string => {
|
|
1530
|
-
capturedDisplayContent = { text, startLine: startNum };
|
|
1531
|
-
return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
|
|
1532
|
-
};
|
|
1816
|
+
if (collectedLines.length > 0 && !firstLineExceedsLimit) {
|
|
1817
|
+
getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines);
|
|
1818
|
+
}
|
|
1533
1819
|
|
|
1534
|
-
|
|
1820
|
+
const shouldAddHashLines = !rawSelector && displayMode.hashLines;
|
|
1821
|
+
const shouldAddLineNumbers = rawSelector ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
1822
|
+
let capturedDisplayContent: { text: string; startLine: number } | undefined;
|
|
1823
|
+
const formatText = (text: string, startNum: number): string => {
|
|
1824
|
+
capturedDisplayContent = { text, startLine: startNum };
|
|
1825
|
+
return formatTextWithMode(text, startNum, shouldAddHashLines, shouldAddLineNumbers);
|
|
1826
|
+
};
|
|
1827
|
+
|
|
1828
|
+
let outputText: string;
|
|
1535
1829
|
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1830
|
+
if (truncation.firstLineExceedsLimit) {
|
|
1831
|
+
const firstLineBytes = firstLineByteLength ?? 0;
|
|
1832
|
+
const snippet = firstLinePreview ?? { text: "", bytes: 0 };
|
|
1539
1833
|
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1834
|
+
if (shouldAddHashLines) {
|
|
1835
|
+
outputText = `[Line ${startLineDisplay} is ${formatBytes(
|
|
1836
|
+
firstLineBytes,
|
|
1837
|
+
)}, exceeds ${formatBytes(maxBytesForRead)} limit. Hashline output requires full lines; cannot compute hashes for a truncated preview.]`;
|
|
1838
|
+
} else {
|
|
1839
|
+
outputText = formatText(snippet.text, startLineDisplay);
|
|
1840
|
+
}
|
|
1841
|
+
if (snippet.text.length === 0) {
|
|
1842
|
+
outputText = `[Line ${startLineDisplay} is ${formatBytes(
|
|
1843
|
+
firstLineBytes,
|
|
1844
|
+
)}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
|
|
1845
|
+
}
|
|
1846
|
+
details = { truncation };
|
|
1847
|
+
sourcePath = absolutePath;
|
|
1848
|
+
truncationInfo = {
|
|
1849
|
+
result: truncation,
|
|
1850
|
+
options: { direction: "head", startLine: startLineDisplay, totalFileLines },
|
|
1851
|
+
};
|
|
1852
|
+
} else if (truncation.truncated) {
|
|
1853
|
+
outputText = formatText(truncation.content, startLineDisplay);
|
|
1854
|
+
details = { truncation };
|
|
1855
|
+
sourcePath = absolutePath;
|
|
1856
|
+
truncationInfo = {
|
|
1857
|
+
result: truncation,
|
|
1858
|
+
options: { direction: "head", startLine: startLineDisplay, totalFileLines },
|
|
1859
|
+
};
|
|
1860
|
+
} else if (startLine + userLimitedLines < totalFileLines) {
|
|
1861
|
+
const remaining = totalFileLines - (startLine + userLimitedLines);
|
|
1862
|
+
const nextOffset = startLine + userLimitedLines + 1;
|
|
1863
|
+
|
|
1864
|
+
outputText = formatText(truncation.content, startLineDisplay);
|
|
1865
|
+
outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`;
|
|
1866
|
+
details = {};
|
|
1867
|
+
sourcePath = absolutePath;
|
|
1544
1868
|
} else {
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
firstLineBytes,
|
|
1550
|
-
)}, exceeds ${formatBytes(maxBytesForRead)} limit. Unable to display a valid UTF-8 snippet.]`;
|
|
1869
|
+
// No truncation, no user limit exceeded
|
|
1870
|
+
outputText = formatText(truncation.content, startLineDisplay);
|
|
1871
|
+
details = {};
|
|
1872
|
+
sourcePath = absolutePath;
|
|
1551
1873
|
}
|
|
1552
|
-
details = { truncation };
|
|
1553
|
-
sourcePath = absolutePath;
|
|
1554
|
-
truncationInfo = {
|
|
1555
|
-
result: truncation,
|
|
1556
|
-
options: { direction: "head", startLine: startLineDisplay, totalFileLines },
|
|
1557
|
-
};
|
|
1558
|
-
} else if (truncation.truncated) {
|
|
1559
|
-
outputText = formatText(truncation.content, startLineDisplay);
|
|
1560
|
-
details = { truncation };
|
|
1561
|
-
sourcePath = absolutePath;
|
|
1562
|
-
truncationInfo = {
|
|
1563
|
-
result: truncation,
|
|
1564
|
-
options: { direction: "head", startLine: startLineDisplay, totalFileLines },
|
|
1565
|
-
};
|
|
1566
|
-
} else if (startLine + userLimitedLines < totalFileLines) {
|
|
1567
|
-
const remaining = totalFileLines - (startLine + userLimitedLines);
|
|
1568
|
-
const nextOffset = startLine + userLimitedLines + 1;
|
|
1569
|
-
|
|
1570
|
-
outputText = formatText(truncation.content, startLineDisplay);
|
|
1571
|
-
outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`;
|
|
1572
|
-
details = {};
|
|
1573
|
-
sourcePath = absolutePath;
|
|
1574
|
-
} else {
|
|
1575
|
-
// No truncation, no user limit exceeded
|
|
1576
|
-
outputText = formatText(truncation.content, startLineDisplay);
|
|
1577
|
-
details = {};
|
|
1578
|
-
sourcePath = absolutePath;
|
|
1579
|
-
}
|
|
1580
1874
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1875
|
+
if (capturedDisplayContent) {
|
|
1876
|
+
details.displayContent = capturedDisplayContent;
|
|
1877
|
+
}
|
|
1584
1878
|
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1879
|
+
if (!firstLineExceedsLimit && collectedLines.length > 0) {
|
|
1880
|
+
const blocks = scanConflictLines(collectedLines, startLineDisplay);
|
|
1881
|
+
if (blocks.length > 0) {
|
|
1882
|
+
const history = getConflictHistory(this.session);
|
|
1883
|
+
const displayPathForWarning = formatPathRelativeToCwd(absolutePath, this.session.cwd);
|
|
1884
|
+
const entries = blocks.map(block =>
|
|
1885
|
+
history.register({
|
|
1886
|
+
absolutePath,
|
|
1887
|
+
displayPath: displayPathForWarning,
|
|
1888
|
+
...block,
|
|
1889
|
+
}),
|
|
1890
|
+
);
|
|
1891
|
+
// Cheap full-file scan only when the window already showed
|
|
1892
|
+
// at least one conflict — otherwise pay nothing on clean files.
|
|
1893
|
+
let totalInFile = entries.length;
|
|
1894
|
+
let scanTruncated = false;
|
|
1895
|
+
try {
|
|
1896
|
+
const fileScan = await scanFileForConflicts(absolutePath);
|
|
1897
|
+
totalInFile = Math.max(entries.length, fileScan.blocks.length);
|
|
1898
|
+
scanTruncated = fileScan.scanTruncated;
|
|
1899
|
+
} catch {
|
|
1900
|
+
// Best-effort enrichment; fall back to window-only count.
|
|
1901
|
+
}
|
|
1902
|
+
outputText += formatConflictWarning(entries, {
|
|
1903
|
+
totalInFile,
|
|
1593
1904
|
displayPath: displayPathForWarning,
|
|
1594
|
-
|
|
1595
|
-
})
|
|
1596
|
-
|
|
1597
|
-
// Cheap full-file scan only when the window already showed
|
|
1598
|
-
// at least one conflict — otherwise pay nothing on clean files.
|
|
1599
|
-
let totalInFile = entries.length;
|
|
1600
|
-
let scanTruncated = false;
|
|
1601
|
-
try {
|
|
1602
|
-
const fileScan = await scanFileForConflicts(absolutePath);
|
|
1603
|
-
totalInFile = Math.max(entries.length, fileScan.blocks.length);
|
|
1604
|
-
scanTruncated = fileScan.scanTruncated;
|
|
1605
|
-
} catch {
|
|
1606
|
-
// Best-effort enrichment; fall back to window-only count.
|
|
1905
|
+
scanTruncated,
|
|
1906
|
+
});
|
|
1907
|
+
details.conflictCount = entries.length;
|
|
1607
1908
|
}
|
|
1608
|
-
outputText += formatConflictWarning(entries, {
|
|
1609
|
-
totalInFile,
|
|
1610
|
-
displayPath: displayPathForWarning,
|
|
1611
|
-
scanTruncated,
|
|
1612
|
-
});
|
|
1613
|
-
details.conflictCount = entries.length;
|
|
1614
1909
|
}
|
|
1615
|
-
}
|
|
1616
1910
|
|
|
1617
|
-
|
|
1911
|
+
content = [{ type: "text", text: outputText }];
|
|
1912
|
+
}
|
|
1618
1913
|
}
|
|
1619
1914
|
}
|
|
1620
1915
|
|
|
@@ -1636,6 +1931,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1636
1931
|
if (truncationInfo) {
|
|
1637
1932
|
resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
|
|
1638
1933
|
}
|
|
1934
|
+
if (columnTruncated > 0) {
|
|
1935
|
+
resultBuilder.limits({ columnMax: columnTruncated });
|
|
1936
|
+
}
|
|
1639
1937
|
return resultBuilder.done();
|
|
1640
1938
|
}
|
|
1641
1939
|
|
|
@@ -1710,33 +2008,31 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1710
2008
|
*/
|
|
1711
2009
|
async #handleInternalUrl(
|
|
1712
2010
|
url: string,
|
|
1713
|
-
|
|
1714
|
-
limit?: number,
|
|
1715
|
-
options?: { raw?: boolean },
|
|
2011
|
+
parsedSel: ParsedSelector,
|
|
1716
2012
|
signal?: AbortSignal,
|
|
1717
2013
|
): Promise<AgentToolResult<ReadToolDetails>> {
|
|
1718
2014
|
const internalRouter = InternalUrlRouter.instance();
|
|
1719
2015
|
|
|
1720
2016
|
// Check if URL has query extraction (agent:// only).
|
|
1721
2017
|
// Use parseInternalUrl which handles colons in host (namespaced skills).
|
|
1722
|
-
let
|
|
2018
|
+
let urlMeta: InternalUrl;
|
|
1723
2019
|
try {
|
|
1724
|
-
|
|
2020
|
+
urlMeta = parseInternalUrl(url);
|
|
1725
2021
|
} catch (e) {
|
|
1726
2022
|
throw new ToolError(e instanceof Error ? e.message : String(e));
|
|
1727
2023
|
}
|
|
1728
|
-
const scheme =
|
|
2024
|
+
const scheme = urlMeta.protocol.replace(/:$/, "").toLowerCase();
|
|
1729
2025
|
let hasExtraction = false;
|
|
1730
2026
|
if (scheme === "agent") {
|
|
1731
|
-
const hasPathExtraction =
|
|
1732
|
-
const queryParam =
|
|
2027
|
+
const hasPathExtraction = urlMeta.pathname && urlMeta.pathname !== "/" && urlMeta.pathname !== "";
|
|
2028
|
+
const queryParam = urlMeta.searchParams.get("q");
|
|
1733
2029
|
const hasQueryExtraction = queryParam !== null && queryParam !== "";
|
|
1734
2030
|
hasExtraction = hasPathExtraction || hasQueryExtraction;
|
|
1735
2031
|
}
|
|
1736
2032
|
|
|
1737
|
-
// Reject
|
|
1738
|
-
if (hasExtraction &&
|
|
1739
|
-
throw new ToolError("Cannot combine query extraction with
|
|
2033
|
+
// Reject line selectors when query extraction is used
|
|
2034
|
+
if (hasExtraction && parsedSel.kind !== "none" && parsedSel.kind !== "raw") {
|
|
2035
|
+
throw new ToolError("Cannot combine query extraction with line selectors");
|
|
1740
2036
|
}
|
|
1741
2037
|
|
|
1742
2038
|
// Resolve the internal URL
|
|
@@ -1752,6 +2048,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1752
2048
|
return toolResult(details).text(resource.content).sourceInternal(url).done();
|
|
1753
2049
|
}
|
|
1754
2050
|
|
|
2051
|
+
const raw = isRawSelector(parsedSel);
|
|
2052
|
+
if (isMultiRange(parsedSel) && parsedSel.kind === "lines") {
|
|
2053
|
+
return this.#buildInMemoryMultiRangeResult(resource.content, parsedSel.ranges, {
|
|
2054
|
+
details,
|
|
2055
|
+
sourcePath: resource.sourcePath,
|
|
2056
|
+
sourceInternal: url,
|
|
2057
|
+
entityLabel: "resource",
|
|
2058
|
+
immutable: resource.immutable,
|
|
2059
|
+
raw,
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
const { offset, limit } = selToOffsetLimit(parsedSel);
|
|
1755
2064
|
return this.#buildInMemoryTextResult(resource.content, offset, limit, {
|
|
1756
2065
|
details,
|
|
1757
2066
|
sourcePath: resource.sourcePath,
|
|
@@ -1759,7 +2068,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1759
2068
|
entityLabel: "resource",
|
|
1760
2069
|
ignoreResultLimits: scheme === "skill",
|
|
1761
2070
|
immutable: resource.immutable,
|
|
1762
|
-
raw
|
|
2071
|
+
raw,
|
|
1763
2072
|
});
|
|
1764
2073
|
}
|
|
1765
2074
|
|
|
@@ -1886,7 +2195,9 @@ export const readToolRenderer = {
|
|
|
1886
2195
|
const rawText = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
1887
2196
|
// Prefer structured `displayContent` from details when available so the TUI
|
|
1888
2197
|
// shows clean file content (no model-only hashline anchors) without parsing the formatted text.
|
|
1889
|
-
|
|
2198
|
+
// Fall back to the raw text, but strip the LLM-facing notice so it doesn't
|
|
2199
|
+
// echo next to the styled warning line below.
|
|
2200
|
+
const contentText = details?.displayContent?.text ?? stripOutputNotice(rawText, details?.meta);
|
|
1890
2201
|
const imageContent = result.content?.find(c => c.type === "image");
|
|
1891
2202
|
const rawPath = args?.file_path || args?.path || "";
|
|
1892
2203
|
const filePath = shortenPath(rawPath);
|