@oh-my-pi/pi-coding-agent 17.2.11 → 17.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
- package/dist/cli.js +10795 -10791
- package/dist/types/advisor/runtime.d.ts +1 -1
- package/dist/types/config/custom-models.d.ts +31 -0
- package/dist/types/config/model-config-values.d.ts +19 -0
- package/dist/types/config/model-patch.d.ts +93 -0
- package/dist/types/config/model-provider-discovery.d.ts +51 -0
- package/dist/types/config/model-registry.d.ts +5 -52
- package/dist/types/config/settings.d.ts +5 -3
- package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
- package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
- package/dist/types/discovery/contained-path.d.ts +2 -3
- package/dist/types/eval/executor-base.d.ts +8 -2
- package/dist/types/eval/kernel-session-registry.d.ts +60 -0
- package/dist/types/export/share.d.ts +1 -1
- package/dist/types/lsp/diagnostics.d.ts +96 -0
- package/dist/types/lsp/index.d.ts +7 -128
- package/dist/types/lsp/servers.d.ts +72 -0
- package/dist/types/lsp/tool.d.ts +42 -0
- package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
- package/dist/types/lsp/writethrough.d.ts +33 -0
- package/dist/types/mcp/transports/header-policy.d.ts +2 -1
- package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
- package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
- package/dist/types/modes/theme/color.d.ts +19 -0
- package/dist/types/modes/theme/loader.d.ts +19 -0
- package/dist/types/modes/theme/schema.d.ts +175 -0
- package/dist/types/modes/theme/symbols.d.ts +28 -0
- package/dist/types/modes/theme/theme-class.d.ts +244 -0
- package/dist/types/modes/theme/theme.d.ts +9 -280
- package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
- package/dist/types/registry/agent-registry.d.ts +1 -3
- package/dist/types/secrets/index.d.ts +3 -1
- package/dist/types/secrets/message-transform.d.ts +40 -0
- package/dist/types/secrets/obfuscator.d.ts +0 -108
- package/dist/types/secrets/placeholder-scan.d.ts +95 -0
- package/dist/types/secrets/placeholder.d.ts +94 -0
- package/dist/types/secrets/replacement.d.ts +82 -0
- package/dist/types/session/agent-session-types.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/session-handoff.d.ts +3 -3
- package/dist/types/session/session-manager.d.ts +32 -0
- package/dist/types/session/session-provider-boundary.d.ts +1 -1
- package/dist/types/session/turn-recovery.d.ts +2 -2
- package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
- package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
- package/dist/types/slash-commands/builtin-control.d.ts +2 -0
- package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
- package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
- package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
- package/dist/types/slash-commands/builtin-session.d.ts +2 -0
- package/dist/types/tools/gh-common.d.ts +69 -0
- package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
- package/dist/types/tools/gh-pr-diff.d.ts +102 -0
- package/dist/types/tools/gh-run-watch.d.ts +78 -0
- package/dist/types/tools/gh-search.d.ts +61 -0
- package/dist/types/tools/gh-types.d.ts +340 -0
- package/dist/types/tools/gh-view.d.ts +88 -0
- package/dist/types/tools/gh.d.ts +4 -201
- package/dist/types/tools/read-archive.d.ts +16 -0
- package/dist/types/tools/read-format.d.ts +104 -0
- package/dist/types/tools/read-path-resolution.d.ts +19 -0
- package/dist/types/tools/read-pdf-images.d.ts +12 -0
- package/dist/types/tools/read-renderer.d.ts +24 -0
- package/dist/types/tools/read-selector.d.ts +27 -0
- package/dist/types/tools/read-sqlite.d.ts +16 -0
- package/dist/types/tools/read-summary.d.ts +19 -0
- package/dist/types/tools/read.d.ts +1 -23
- package/dist/types/tools/shell-tokenize.d.ts +2 -1
- package/dist/types/utils/changelog.d.ts +0 -5
- package/package.json +13 -13
- package/src/advisor/runtime.ts +12 -7
- package/src/config/custom-models.ts +188 -0
- package/src/config/model-config-values.ts +128 -0
- package/src/config/model-patch.ts +252 -0
- package/src/config/model-provider-discovery.ts +132 -0
- package/src/config/model-registry.ts +64 -704
- package/src/config/settings.ts +7 -3
- package/src/debug/raw-sse-buffer.ts +20 -0
- package/src/discovery/agent-plugin-format.ts +7 -7
- package/src/discovery/contained-path.ts +2 -5
- package/src/edit/hashline/diff.ts +5 -1
- package/src/eval/executor-base.ts +39 -6
- package/src/eval/jl/executor.ts +82 -371
- package/src/eval/kernel-session-registry.ts +398 -0
- package/src/eval/py/executor.ts +53 -261
- package/src/eval/rb/executor.ts +36 -279
- package/src/export/share.ts +2 -1
- package/src/lsp/diagnostics.ts +516 -0
- package/src/lsp/index.ts +20 -2819
- package/src/lsp/servers.ts +296 -0
- package/src/lsp/tool.ts +1352 -0
- package/src/lsp/workspace-diagnostics.ts +170 -0
- package/src/lsp/writethrough.ts +561 -0
- package/src/mcp/transports/header-policy.ts +1 -1
- package/src/modes/components/agent-hub-projection.ts +2 -2
- package/src/modes/components/agent-hub-renderer.ts +3 -7
- package/src/modes/controllers/command-controller.ts +4 -1
- package/src/modes/theme/color.ts +133 -0
- package/src/modes/theme/loader.ts +178 -0
- package/src/modes/theme/schema.ts +263 -0
- package/src/modes/theme/symbols.ts +1000 -0
- package/src/modes/theme/theme-class.ts +611 -0
- package/src/modes/theme/theme.ts +27 -2453
- package/src/modes/theme/tui-adapters.ts +279 -0
- package/src/registry/agent-registry.ts +2 -2
- package/src/secrets/index.ts +6 -12
- package/src/secrets/message-transform.ts +287 -0
- package/src/secrets/obfuscator.ts +39 -1303
- package/src/secrets/placeholder-scan.ts +506 -0
- package/src/secrets/placeholder.ts +309 -0
- package/src/secrets/replacement.ts +216 -0
- package/src/session/agent-session-types.ts +6 -0
- package/src/session/agent-session.ts +113 -7
- package/src/session/indexed-session-storage.ts +7 -2
- package/src/session/session-advisors.ts +32 -34
- package/src/session/session-handoff.ts +39 -20
- package/src/session/session-manager.ts +67 -3
- package/src/session/session-provider-boundary.ts +3 -6
- package/src/session/turn-recovery.ts +21 -12
- package/src/slash-commands/builtin-collaboration.ts +504 -0
- package/src/slash-commands/builtin-completions.ts +291 -0
- package/src/slash-commands/builtin-control.ts +78 -0
- package/src/slash-commands/builtin-lifecycle.ts +506 -0
- package/src/slash-commands/builtin-marketplace.ts +567 -0
- package/src/slash-commands/builtin-modes.ts +518 -0
- package/src/slash-commands/builtin-registry.ts +21 -3000
- package/src/slash-commands/builtin-session.ts +592 -0
- package/src/task/executor.ts +17 -14
- package/src/tools/gh-common.ts +288 -0
- package/src/tools/gh-pr-checkout.ts +679 -0
- package/src/tools/gh-pr-diff.ts +473 -0
- package/src/tools/gh-run-watch.ts +1015 -0
- package/src/tools/gh-search.ts +500 -0
- package/src/tools/gh-types.ts +379 -0
- package/src/tools/gh-view.ts +612 -0
- package/src/tools/gh.ts +109 -3821
- package/src/tools/read-archive.ts +209 -0
- package/src/tools/read-format.ts +593 -0
- package/src/tools/read-path-resolution.ts +36 -0
- package/src/tools/read-pdf-images.ts +250 -0
- package/src/tools/read-renderer.ts +289 -0
- package/src/tools/read-selector.ts +84 -0
- package/src/tools/read-sqlite.ts +215 -0
- package/src/tools/read-summary.ts +199 -0
- package/src/tools/read.ts +77 -1820
- package/src/tools/shell-tokenize.ts +1 -1
- package/src/utils/changelog.ts +1 -1
- package/src/utils/title-generator.ts +3 -1
- package/src/web/search/providers/zai.ts +12 -3
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline";
|
|
3
|
+
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
4
|
+
import { canonicalSnapshotKey, getFileSnapshotStore, recordSeenLines } from "../edit/file-snapshot-store";
|
|
5
|
+
import { normalizeToLF } from "../edit/normalize";
|
|
6
|
+
import { isMarkdownPath } from "../modes/theme/theme";
|
|
7
|
+
import type { ToolSession } from "../sdk";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_MAX_BYTES,
|
|
10
|
+
noTruncResult,
|
|
11
|
+
type TruncationResult,
|
|
12
|
+
truncateHead,
|
|
13
|
+
truncateHeadBytes,
|
|
14
|
+
} from "../session/streaming-output";
|
|
15
|
+
import { buildLineEntriesWithBlockContext, type LineEntry, lineEntriesToPlainText } from "../utils/block-context";
|
|
16
|
+
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
17
|
+
import { formatPathRelativeToCwd, type LineRange } from "./path-utils";
|
|
18
|
+
import type { ReadToolDetails } from "./read";
|
|
19
|
+
import { formatBytes, shortenPath } from "./render-utils";
|
|
20
|
+
import { ToolError } from "./tool-errors";
|
|
21
|
+
import { toolResult } from "./tool-result";
|
|
22
|
+
|
|
23
|
+
function prependLineNumbers(text: string, startNum: number): string {
|
|
24
|
+
const textLines = text.split("\n");
|
|
25
|
+
return textLines.map((line, i) => `${startNum + i}|${line}`).join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HashlineHeaderContext {
|
|
29
|
+
header: string;
|
|
30
|
+
tag: string;
|
|
31
|
+
fullText?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatReadHashlineHeader(displayPath: string, tag: string): string {
|
|
35
|
+
// In-workspace reads collapse to the bare filename for brevity: the edit
|
|
36
|
+
// tool's snapshot-tag recovery rebinds a bare `[name#tag]` onto the in-tree
|
|
37
|
+
// file it uniquely names. Out-of-workspace reads can't lean on that —
|
|
38
|
+
// recovery refuses to redirect a write outside the cwd/sandbox
|
|
39
|
+
// (HashlineFilesystem.allowTagPathRecovery) — so an absolute displayPath
|
|
40
|
+
// must stay directly resolvable, otherwise the basename resolves against
|
|
41
|
+
// cwd, misses, and the edit fails with "File not found" (e.g. ~/.claude/*).
|
|
42
|
+
// `shortenPath` keeps `~/.claude/...` (round-trips through resolveToCwd's ~
|
|
43
|
+
// expansion) instead of leaking the full home path into the read output.
|
|
44
|
+
const anchor = path.isAbsolute(displayPath) ? shortenPath(displayPath) : path.basename(displayPath);
|
|
45
|
+
return formatHashlineHeader(anchor, tag);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function recordFullHashlineContext(
|
|
49
|
+
session: ToolSession,
|
|
50
|
+
absolutePath: string | undefined,
|
|
51
|
+
displayPath: string,
|
|
52
|
+
fullText: string,
|
|
53
|
+
): HashlineHeaderContext | undefined {
|
|
54
|
+
if (!absolutePath || !path.isAbsolute(absolutePath)) return undefined;
|
|
55
|
+
const normalized = normalizeToLF(fullText);
|
|
56
|
+
const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
|
|
57
|
+
return {
|
|
58
|
+
header: formatReadHashlineHeader(displayPath, tag),
|
|
59
|
+
tag,
|
|
60
|
+
fullText: normalized,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function readHashlineHeaderContext(
|
|
65
|
+
session: ToolSession,
|
|
66
|
+
absolutePath: string,
|
|
67
|
+
cwd: string,
|
|
68
|
+
): Promise<HashlineHeaderContext> {
|
|
69
|
+
const fullText = await Bun.file(absolutePath).text();
|
|
70
|
+
const context = recordFullHashlineContext(
|
|
71
|
+
session,
|
|
72
|
+
absolutePath,
|
|
73
|
+
formatPathRelativeToCwd(absolutePath, cwd),
|
|
74
|
+
fullText,
|
|
75
|
+
);
|
|
76
|
+
if (!context) throw new ToolError(`Cannot record hashline snapshot for non-absolute path: ${absolutePath}`);
|
|
77
|
+
return context;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function hashlineHeaderContext(displayPath: string, tag: string): HashlineHeaderContext {
|
|
81
|
+
return { header: formatReadHashlineHeader(displayPath, tag), tag };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function prependHashlineHeader(text: string, context: HashlineHeaderContext | undefined): string {
|
|
85
|
+
return context ? `${context.header}\n${text}` : text;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function formatTextWithMode(
|
|
89
|
+
text: string,
|
|
90
|
+
startNum: number,
|
|
91
|
+
shouldAddHashLines: boolean,
|
|
92
|
+
shouldAddLineNumbers: boolean,
|
|
93
|
+
): string {
|
|
94
|
+
if (shouldAddHashLines) return formatNumberedLines(text, startNum);
|
|
95
|
+
if (shouldAddLineNumbers) return prependLineNumbers(text, startNum);
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const BRACKET_CONTEXT_ELLIPSIS = "…";
|
|
100
|
+
|
|
101
|
+
function formatLineEntryWithMode(entry: LineEntry, shouldAddHashLines: boolean, shouldAddLineNumbers: boolean): string {
|
|
102
|
+
if (entry.kind === "ellipsis") return BRACKET_CONTEXT_ELLIPSIS;
|
|
103
|
+
return formatSingleLine(entry.lineNumber, entry.text, shouldAddHashLines, shouldAddLineNumbers);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatLineEntriesWithMode(
|
|
107
|
+
entries: readonly LineEntry[],
|
|
108
|
+
shouldAddHashLines: boolean,
|
|
109
|
+
shouldAddLineNumbers: boolean,
|
|
110
|
+
): string {
|
|
111
|
+
return entries.map(entry => formatLineEntryWithMode(entry, shouldAddHashLines, shouldAddLineNumbers)).join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const BRACE_PAIRS: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
|
|
115
|
+
const BRACE_TAIL_TRAILING_RE = /^[;,)\]}]*$/;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Decide whether the kept lines surrounding an elided range collapse to a
|
|
119
|
+
* single brace-pair line in the rendered summary. Returns true when the head
|
|
120
|
+
* line ends with `{` / `(` / `[` and the tail line is the matching closer
|
|
121
|
+
* (optionally followed by terminating punctuation like `;`, `,`, or further
|
|
122
|
+
* closers — e.g. `};`, `})`, `]);`).
|
|
123
|
+
*/
|
|
124
|
+
export function canMergeBracePair(headLine: string, tailLine: string): boolean {
|
|
125
|
+
const head = headLine.trimEnd();
|
|
126
|
+
const tail = tailLine.trim();
|
|
127
|
+
const opener = head.slice(-1);
|
|
128
|
+
const closer = BRACE_PAIRS[opener];
|
|
129
|
+
if (!closer) return false;
|
|
130
|
+
if (!tail.startsWith(closer)) return false;
|
|
131
|
+
return BRACE_TAIL_TRAILING_RE.test(tail.slice(closer.length));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function formatSingleLine(
|
|
135
|
+
line: number,
|
|
136
|
+
text: string,
|
|
137
|
+
shouldAddHashLines: boolean,
|
|
138
|
+
shouldAddLineNumbers: boolean,
|
|
139
|
+
): string {
|
|
140
|
+
if (shouldAddHashLines) return formatNumberedLine(line, text);
|
|
141
|
+
if (shouldAddLineNumbers) return `${line}|${text}`;
|
|
142
|
+
return text;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function formatMergedBraceLine(
|
|
146
|
+
startLine: number,
|
|
147
|
+
endLine: number,
|
|
148
|
+
headText: string,
|
|
149
|
+
tailText: string,
|
|
150
|
+
shouldAddHashLines: boolean,
|
|
151
|
+
shouldAddLineNumbers: boolean,
|
|
152
|
+
): { model: string; display: string } {
|
|
153
|
+
const merged = `${headText.trimEnd()} … ${tailText.trim()}`;
|
|
154
|
+
if (shouldAddHashLines) {
|
|
155
|
+
return { model: `${startLine}-${endLine}:${merged}`, display: merged };
|
|
156
|
+
}
|
|
157
|
+
if (shouldAddLineNumbers) {
|
|
158
|
+
return { model: `${startLine}-${endLine}|${merged}`, display: merged };
|
|
159
|
+
}
|
|
160
|
+
return { model: merged, display: merged };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function countTextLines(text: string): number {
|
|
164
|
+
if (text.length === 0) return 0;
|
|
165
|
+
// Count newlines directly instead of allocating an array via split("\n").
|
|
166
|
+
// Called on every read of file content; the result is identical (N newlines
|
|
167
|
+
// ⇒ N+1 lines for non-empty text).
|
|
168
|
+
let lines = 1;
|
|
169
|
+
for (let i = 0; i < text.length; i++) {
|
|
170
|
+
if (text.charCodeAt(i) === 10) lines++;
|
|
171
|
+
}
|
|
172
|
+
return lines;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function contiguousLineNumbers(startLine: number, count: number): number[] {
|
|
176
|
+
const lines: number[] = [];
|
|
177
|
+
for (let offset = 0; offset < count; offset++) lines.push(startLine + offset);
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function lineNumbersFromSpans(spans: readonly { startLine: number; endLine: number }[]): number[] {
|
|
182
|
+
const lines: number[] = [];
|
|
183
|
+
for (const span of spans) {
|
|
184
|
+
for (let line = span.startLine; line <= span.endLine; line++) lines.push(line);
|
|
185
|
+
}
|
|
186
|
+
return lines;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function recordInMemorySeenLines(
|
|
190
|
+
session: ToolSession,
|
|
191
|
+
absolutePath: string | undefined,
|
|
192
|
+
fullText: string,
|
|
193
|
+
seenLines: readonly number[] | undefined,
|
|
194
|
+
): void {
|
|
195
|
+
if (!absolutePath || !path.isAbsolute(absolutePath) || !seenLines || seenLines.length === 0) return;
|
|
196
|
+
getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalizeToLF(fullText), seenLines);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function lineNumbersFromEntries(entries: readonly LineEntry[]): number[] {
|
|
200
|
+
const lines: number[] = [];
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
if (entry.kind === "line") lines.push(entry.lineNumber);
|
|
203
|
+
}
|
|
204
|
+
return lines;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Inclusive line range describing one elided span in a structural summary. */
|
|
208
|
+
export interface ElidedRange {
|
|
209
|
+
start: number;
|
|
210
|
+
end: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Sample ranges shown in the footer to demonstrate the multi-range syntax. */
|
|
214
|
+
const FOOTER_RANGE_SAMPLES = 2;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Footer appended to summarized reads telling the model how to recover the
|
|
218
|
+
* elided body. Without this hint, agents either ignore the `…`/`{ … }`
|
|
219
|
+
* markers or burn a turn guessing the right selector (see issue #1046). The
|
|
220
|
+
* footer demonstrates the multi-range selector syntax with concrete sample
|
|
221
|
+
* ranges drawn from the actual elision so the model re-reads only what it
|
|
222
|
+
* needs instead of falling back to `:raw` or whole-file reads.
|
|
223
|
+
*/
|
|
224
|
+
export function formatSummaryElisionFooter(
|
|
225
|
+
readPath: string,
|
|
226
|
+
elidedRanges: ReadonlyArray<ElidedRange>,
|
|
227
|
+
elidedLines: number,
|
|
228
|
+
): string {
|
|
229
|
+
if (elidedRanges.length === 0) return "";
|
|
230
|
+
const sampleCount = Math.min(elidedRanges.length, FOOTER_RANGE_SAMPLES);
|
|
231
|
+
const selector = elidedRanges
|
|
232
|
+
.slice(0, sampleCount)
|
|
233
|
+
.map(r => `${r.start}-${r.end}`)
|
|
234
|
+
.join(",");
|
|
235
|
+
const example = `${readPath}:${selector}`;
|
|
236
|
+
const tail = elidedRanges.length > sampleCount ? `, e.g. ${example}` : ` with ${example}`;
|
|
237
|
+
return `[…${elidedLines}ln elided; re-read needed ranges${tail}]`;
|
|
238
|
+
}
|
|
239
|
+
export const READ_CHUNK_SIZE = 8 * 1024;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Context lines added around an explicit range read. Anchor-stale failures
|
|
243
|
+
* cluster on edits whose anchors land just outside the most recent read
|
|
244
|
+
* window, but the data (`scripts/session-stats/analyze_selector_reads.py`)
|
|
245
|
+
* shows most follow-up reads are disjoint hops, not adjacent extensions —
|
|
246
|
+
* so symmetric padding rarely pays for itself.
|
|
247
|
+
*
|
|
248
|
+
* Leading=1 catches accidental single-line reads where the anchor is the
|
|
249
|
+
* line immediately above the requested start. Trailing=3 buffers the
|
|
250
|
+
* common case where the agent asks for a narrow range and then needs the
|
|
251
|
+
* next few lines to disambiguate an anchor.
|
|
252
|
+
*/
|
|
253
|
+
export const RANGE_LEADING_CONTEXT_LINES = 1;
|
|
254
|
+
export const RANGE_TRAILING_CONTEXT_LINES = 3;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Expand a [start, end) range with leading/trailing context lines on the
|
|
258
|
+
* sides where the user actually constrained the range. A start of 0 (no
|
|
259
|
+
* explicit offset) does not get leading context — that's already an
|
|
260
|
+
* open-ended read from the top.
|
|
261
|
+
*/
|
|
262
|
+
function expandRangeWithContext(
|
|
263
|
+
requestedStart: number,
|
|
264
|
+
requestedEnd: number,
|
|
265
|
+
totalLines: number,
|
|
266
|
+
expandStart: boolean,
|
|
267
|
+
expandEnd: boolean,
|
|
268
|
+
): { startLine: number; endLine: number } {
|
|
269
|
+
return {
|
|
270
|
+
startLine: expandStart ? Math.max(0, requestedStart - RANGE_LEADING_CONTEXT_LINES) : requestedStart,
|
|
271
|
+
endLine: expandEnd ? Math.min(totalLines, requestedEnd + RANGE_TRAILING_CONTEXT_LINES) : requestedEnd,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function buildInMemoryTextResult(
|
|
276
|
+
session: ToolSession,
|
|
277
|
+
text: string,
|
|
278
|
+
offset: number | undefined,
|
|
279
|
+
limit: number | undefined,
|
|
280
|
+
options: {
|
|
281
|
+
details?: ReadToolDetails;
|
|
282
|
+
sourcePath?: string;
|
|
283
|
+
sourceUrl?: string;
|
|
284
|
+
sourceInternal?: string;
|
|
285
|
+
entityLabel: string;
|
|
286
|
+
ignoreResultLimits?: boolean;
|
|
287
|
+
raw?: boolean;
|
|
288
|
+
immutable?: boolean;
|
|
289
|
+
},
|
|
290
|
+
): AgentToolResult<ReadToolDetails> {
|
|
291
|
+
const displayMode = resolveFileDisplayMode(session, { raw: options.raw, immutable: options.immutable });
|
|
292
|
+
const details = options.details ?? {};
|
|
293
|
+
const allLines = text.split("\n");
|
|
294
|
+
const totalLines = allLines.length;
|
|
295
|
+
details.totalLines = totalLines;
|
|
296
|
+
// User-requested 0-indexed range start. Lines BEFORE this are leading
|
|
297
|
+
// context (added below if offset is explicit).
|
|
298
|
+
const requestedStart = offset ? Math.max(0, offset - 1) : 0;
|
|
299
|
+
const ignoreResultLimits = options.ignoreResultLimits ?? false;
|
|
300
|
+
const requestedEnd = limit !== undefined ? Math.min(requestedStart + limit, allLines.length) : allLines.length;
|
|
301
|
+
// Expand only on sides the user actually constrained: leading context
|
|
302
|
+
// when offset>1, trailing context when a finite limit was set. Raw mode
|
|
303
|
+
// never expands — without line numbers the padding is indistinguishable
|
|
304
|
+
// from requested content, so `raw:31-31` must return line 31 and nothing
|
|
305
|
+
// else (verbatim-extraction contract).
|
|
306
|
+
const rawDisplay = options.raw === true;
|
|
307
|
+
const expanded = expandRangeWithContext(
|
|
308
|
+
requestedStart,
|
|
309
|
+
requestedEnd,
|
|
310
|
+
allLines.length,
|
|
311
|
+
!rawDisplay && offset !== undefined && offset > 1,
|
|
312
|
+
!rawDisplay && limit !== undefined,
|
|
313
|
+
);
|
|
314
|
+
const startLine = expanded.startLine;
|
|
315
|
+
const endLineExpanded = expanded.endLine;
|
|
316
|
+
const startLineDisplay = startLine + 1;
|
|
317
|
+
|
|
318
|
+
const resultBuilder = toolResult(details);
|
|
319
|
+
if (options.sourcePath) {
|
|
320
|
+
resultBuilder.sourcePath(options.sourcePath);
|
|
321
|
+
}
|
|
322
|
+
if (options.sourceUrl) {
|
|
323
|
+
resultBuilder.sourceUrl(options.sourceUrl);
|
|
324
|
+
}
|
|
325
|
+
if (options.sourceInternal) {
|
|
326
|
+
resultBuilder.sourceInternal(options.sourceInternal);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (requestedStart >= allLines.length) {
|
|
330
|
+
const suggestion =
|
|
331
|
+
allLines.length === 0
|
|
332
|
+
? `The ${options.entityLabel} is empty.`
|
|
333
|
+
: `Use :1 to read from the start, or :${allLines.length} to read the last line.`;
|
|
334
|
+
return resultBuilder
|
|
335
|
+
.text(
|
|
336
|
+
`Line ${requestedStart + 1} is beyond end of ${options.entityLabel} (${allLines.length} lines total). ${suggestion}`,
|
|
337
|
+
)
|
|
338
|
+
.done();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const endLine = endLineExpanded;
|
|
342
|
+
const selectedContent = allLines.slice(startLine, endLine).join("\n");
|
|
343
|
+
const userLimitedLines = limit !== undefined ? endLine - startLine : undefined;
|
|
344
|
+
const truncation = ignoreResultLimits ? noTruncResult(selectedContent) : truncateHead(selectedContent);
|
|
345
|
+
|
|
346
|
+
const shouldAddHashLines = displayMode.hashLines;
|
|
347
|
+
const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
348
|
+
const hashContext =
|
|
349
|
+
shouldAddHashLines && options.sourcePath
|
|
350
|
+
? recordFullHashlineContext(
|
|
351
|
+
session,
|
|
352
|
+
options.sourcePath,
|
|
353
|
+
formatPathRelativeToCwd(options.sourcePath, session.cwd),
|
|
354
|
+
text,
|
|
355
|
+
)
|
|
356
|
+
: undefined;
|
|
357
|
+
let emittedHashlineHeader = false;
|
|
358
|
+
let seenLines: number[] | undefined;
|
|
359
|
+
let rawSeenLines: number[] | undefined;
|
|
360
|
+
const formatText = (content: string, startNum: number): string => {
|
|
361
|
+
const lineCount = countTextLines(content);
|
|
362
|
+
details.displayContent = {
|
|
363
|
+
text: content,
|
|
364
|
+
startLine: startNum,
|
|
365
|
+
lineNumbers: Array.from({ length: lineCount }, (_, i) => startNum + i),
|
|
366
|
+
};
|
|
367
|
+
if (shouldAddHashLines) seenLines = contiguousLineNumbers(startNum, lineCount);
|
|
368
|
+
const formatted = formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers);
|
|
369
|
+
if (!hashContext || emittedHashlineHeader) return formatted;
|
|
370
|
+
emittedHashlineHeader = true;
|
|
371
|
+
return prependHashlineHeader(formatted, hashContext);
|
|
372
|
+
};
|
|
373
|
+
const formatLineEntries = (entries: readonly LineEntry[], startNum: number): string => {
|
|
374
|
+
const firstLine = entries.find(entry => entry.kind === "line");
|
|
375
|
+
details.displayContent = {
|
|
376
|
+
text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
|
|
377
|
+
startLine: firstLine?.kind === "line" ? firstLine.lineNumber : startNum,
|
|
378
|
+
lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
|
|
379
|
+
};
|
|
380
|
+
if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries);
|
|
381
|
+
const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
|
|
382
|
+
if (!hashContext || emittedHashlineHeader) return formatted;
|
|
383
|
+
emittedHashlineHeader = true;
|
|
384
|
+
return prependHashlineHeader(formatted, hashContext);
|
|
385
|
+
};
|
|
386
|
+
const buildLineEntries = (endLineDisplay: number): LineEntry[] =>
|
|
387
|
+
buildLineEntriesWithBlockContext(allLines, [{ startLine: startLineDisplay, endLine: endLineDisplay }], {
|
|
388
|
+
path: options.sourcePath,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
let outputText: string;
|
|
392
|
+
let truncationInfo:
|
|
393
|
+
| { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
|
|
394
|
+
| undefined;
|
|
395
|
+
|
|
396
|
+
if (truncation.firstLineExceedsLimit) {
|
|
397
|
+
const firstLine = allLines[startLine] ?? "";
|
|
398
|
+
const firstLineBytes = Buffer.byteLength(firstLine, "utf-8");
|
|
399
|
+
const snippet = truncateHeadBytes(firstLine, DEFAULT_MAX_BYTES);
|
|
400
|
+
|
|
401
|
+
if (shouldAddHashLines) {
|
|
402
|
+
outputText = `[Line ${startLineDisplay} is ${formatBytes(
|
|
403
|
+
firstLineBytes,
|
|
404
|
+
)}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Hashline output requires full lines; cannot emit an editable numbered preview for a truncated line.]`;
|
|
405
|
+
} else {
|
|
406
|
+
outputText = formatText(snippet.text, startLineDisplay);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (snippet.text.length === 0) {
|
|
410
|
+
outputText = `[Line ${startLineDisplay} is ${formatBytes(
|
|
411
|
+
firstLineBytes,
|
|
412
|
+
)}, exceeds ${formatBytes(DEFAULT_MAX_BYTES)} limit. Unable to display a valid UTF-8 snippet.]`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
details.truncation = truncation;
|
|
416
|
+
truncationInfo = {
|
|
417
|
+
result: truncation,
|
|
418
|
+
options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines },
|
|
419
|
+
};
|
|
420
|
+
} else if (truncation.truncated) {
|
|
421
|
+
const outputLines = truncation.outputLines ?? countTextLines(truncation.content);
|
|
422
|
+
const endLineDisplay = startLineDisplay + Math.max(0, outputLines - 1);
|
|
423
|
+
if (options.raw === true) {
|
|
424
|
+
rawSeenLines = contiguousLineNumbers(startLineDisplay, outputLines);
|
|
425
|
+
outputText = formatText(truncation.content, startLineDisplay);
|
|
426
|
+
} else {
|
|
427
|
+
outputText = formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay);
|
|
428
|
+
}
|
|
429
|
+
details.truncation = truncation;
|
|
430
|
+
truncationInfo = {
|
|
431
|
+
result: truncation,
|
|
432
|
+
options: { direction: "head", startLine: startLineDisplay, totalFileLines: totalLines },
|
|
433
|
+
};
|
|
434
|
+
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
|
|
435
|
+
const remaining = allLines.length - (startLine + userLimitedLines);
|
|
436
|
+
const nextOffset = startLine + userLimitedLines + 1;
|
|
437
|
+
|
|
438
|
+
if (options.raw === true) {
|
|
439
|
+
rawSeenLines = contiguousLineNumbers(startLineDisplay, userLimitedLines);
|
|
440
|
+
outputText = formatText(selectedContent, startLineDisplay);
|
|
441
|
+
} else {
|
|
442
|
+
outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
|
|
443
|
+
}
|
|
444
|
+
outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`;
|
|
445
|
+
} else {
|
|
446
|
+
if (options.raw === true) {
|
|
447
|
+
rawSeenLines = contiguousLineNumbers(startLineDisplay, endLine - startLine);
|
|
448
|
+
outputText = formatText(truncation.content, startLineDisplay);
|
|
449
|
+
} else {
|
|
450
|
+
outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (hashContext?.tag && options.sourcePath && seenLines) {
|
|
455
|
+
recordSeenLines(session, options.sourcePath, hashContext.tag, seenLines);
|
|
456
|
+
}
|
|
457
|
+
if (options.raw === true && options.sourcePath && options.immutable !== true && rawSeenLines) {
|
|
458
|
+
recordInMemorySeenLines(session, options.sourcePath, text, rawSeenLines);
|
|
459
|
+
}
|
|
460
|
+
resultBuilder.text(outputText);
|
|
461
|
+
if (truncationInfo) {
|
|
462
|
+
resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
|
|
463
|
+
}
|
|
464
|
+
return resultBuilder.done();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Render a multi-range read against in-memory text. Each range emits a
|
|
469
|
+
* formatted block with its own anchors / line numbers, blocks are joined
|
|
470
|
+
* with an elision separator, and ranges past EOF surface as `[…]` notices
|
|
471
|
+
* so the model can correct the next call. No leading/trailing context is
|
|
472
|
+
* added — multi-range callers always specify exact bounds.
|
|
473
|
+
*/
|
|
474
|
+
export function buildInMemoryMultiRangeResult(
|
|
475
|
+
session: ToolSession,
|
|
476
|
+
text: string,
|
|
477
|
+
ranges: readonly LineRange[],
|
|
478
|
+
options: {
|
|
479
|
+
details?: ReadToolDetails;
|
|
480
|
+
sourcePath?: string;
|
|
481
|
+
sourceUrl?: string;
|
|
482
|
+
sourceInternal?: string;
|
|
483
|
+
entityLabel: string;
|
|
484
|
+
raw?: boolean;
|
|
485
|
+
immutable?: boolean;
|
|
486
|
+
},
|
|
487
|
+
): AgentToolResult<ReadToolDetails> {
|
|
488
|
+
const displayMode = resolveFileDisplayMode(session, { raw: options.raw, immutable: options.immutable });
|
|
489
|
+
const details = options.details ?? {};
|
|
490
|
+
const allLines = text.split("\n");
|
|
491
|
+
const totalLines = allLines.length;
|
|
492
|
+
details.totalLines = totalLines;
|
|
493
|
+
const shouldAddHashLines = displayMode.hashLines;
|
|
494
|
+
const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
|
|
495
|
+
const hashContext =
|
|
496
|
+
shouldAddHashLines && options.sourcePath
|
|
497
|
+
? recordFullHashlineContext(
|
|
498
|
+
session,
|
|
499
|
+
options.sourcePath,
|
|
500
|
+
formatPathRelativeToCwd(options.sourcePath, session.cwd),
|
|
501
|
+
text,
|
|
502
|
+
)
|
|
503
|
+
: undefined;
|
|
504
|
+
let emittedHashlineHeader = false;
|
|
505
|
+
|
|
506
|
+
let seenLines: number[] | undefined;
|
|
507
|
+
const resultBuilder = toolResult(details);
|
|
508
|
+
if (options.sourcePath) resultBuilder.sourcePath(options.sourcePath);
|
|
509
|
+
if (options.sourceUrl) resultBuilder.sourceUrl(options.sourceUrl);
|
|
510
|
+
if (options.sourceInternal) resultBuilder.sourceInternal(options.sourceInternal);
|
|
511
|
+
|
|
512
|
+
const outOfBounds: LineRange[] = [];
|
|
513
|
+
const visibleSpans: Array<{ startLine: number; endLine: number }> = [];
|
|
514
|
+
const rawParts: string[] = [];
|
|
515
|
+
for (const range of ranges) {
|
|
516
|
+
if (range.startLine > totalLines) {
|
|
517
|
+
outOfBounds.push(range);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const effectiveEnd = Math.min(range.endLine ?? totalLines, totalLines);
|
|
521
|
+
visibleSpans.push({ startLine: range.startLine, endLine: effectiveEnd });
|
|
522
|
+
if (options.raw === true) {
|
|
523
|
+
rawParts.push(allLines.slice(range.startLine - 1, effectiveEnd).join("\n"));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
let outputText = "";
|
|
528
|
+
if (options.raw === true) {
|
|
529
|
+
outputText = rawParts.length > 0 ? rawParts.join("\n\n…\n\n") : "";
|
|
530
|
+
} else if (visibleSpans.length > 0) {
|
|
531
|
+
const entries = buildLineEntriesWithBlockContext(allLines, visibleSpans, { path: options.sourcePath });
|
|
532
|
+
if (shouldAddHashLines) seenLines = lineNumbersFromEntries(entries);
|
|
533
|
+
const firstLine = entries.find(entry => entry.kind === "line");
|
|
534
|
+
if (firstLine?.kind === "line") {
|
|
535
|
+
details.displayContent = {
|
|
536
|
+
text: lineEntriesToPlainText(entries, BRACKET_CONTEXT_ELLIPSIS),
|
|
537
|
+
startLine: firstLine.lineNumber,
|
|
538
|
+
lineNumbers: entries.map(entry => (entry.kind === "line" ? entry.lineNumber : null)),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
const formatted = formatLineEntriesWithMode(entries, shouldAddHashLines, shouldAddLineNumbers);
|
|
542
|
+
outputText = hashContext && !emittedHashlineHeader ? prependHashlineHeader(formatted, hashContext) : formatted;
|
|
543
|
+
if (hashContext) emittedHashlineHeader = true;
|
|
544
|
+
}
|
|
545
|
+
const notices: string[] = [];
|
|
546
|
+
for (const range of outOfBounds) {
|
|
547
|
+
const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`;
|
|
548
|
+
notices.push(`[Range ${bound} is beyond end of ${options.entityLabel} (${totalLines} lines total); skipped]`);
|
|
549
|
+
}
|
|
550
|
+
const finalText =
|
|
551
|
+
notices.length > 0 ? (outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n")) : outputText;
|
|
552
|
+
if (hashContext?.tag && options.sourcePath && seenLines) {
|
|
553
|
+
recordSeenLines(session, options.sourcePath, hashContext.tag, seenLines);
|
|
554
|
+
}
|
|
555
|
+
if (options.raw === true && options.sourcePath && options.immutable !== true && visibleSpans.length > 0) {
|
|
556
|
+
recordInMemorySeenLines(session, options.sourcePath, text, lineNumbersFromSpans(visibleSpans));
|
|
557
|
+
}
|
|
558
|
+
resultBuilder.text(finalText);
|
|
559
|
+
return resultBuilder.done();
|
|
560
|
+
}
|
|
561
|
+
export function decodeUtf8Text(bytes: Uint8Array): string | null {
|
|
562
|
+
if (bytes.indexOf(0) !== -1) return null;
|
|
563
|
+
|
|
564
|
+
try {
|
|
565
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
566
|
+
} catch {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function prependSuffixResolutionNotice(text: string, suffixResolution?: { from: string; to: string }): string {
|
|
572
|
+
if (!suffixResolution) return text;
|
|
573
|
+
|
|
574
|
+
const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
|
|
575
|
+
return text ? `${notice}\n${text}` : notice;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Tag Markdown reads for the TUI's formatted preview, gated on the opt-in
|
|
579
|
+
* `read.renderMarkdown` setting. Off by default; when disabled, no local
|
|
580
|
+
* read is tagged `text/markdown`, so the renderer output is identical to
|
|
581
|
+
* the pre-setting behavior. Internal-URL reads keep their protocol-supplied
|
|
582
|
+
* `contentType` and render as Markdown regardless of the setting.
|
|
583
|
+
*/
|
|
584
|
+
export function markMarkdownContentType(
|
|
585
|
+
session: ToolSession,
|
|
586
|
+
details: ReadToolDetails,
|
|
587
|
+
filePath: string,
|
|
588
|
+
): ReadToolDetails {
|
|
589
|
+
if (!details.contentType && session.settings.get("read.renderMarkdown") && isMarkdownPath(filePath)) {
|
|
590
|
+
details.contentType = "text/markdown";
|
|
591
|
+
}
|
|
592
|
+
return details;
|
|
593
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { getRemoteDir } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import type { ToolSession } from "../sdk";
|
|
4
|
+
import { findUniqueWorkspaceSuffix } from "./path-utils";
|
|
5
|
+
|
|
6
|
+
// Remote mount path prefix (sshfs mounts) - skip fuzzy matching to avoid hangs
|
|
7
|
+
const REMOTE_MOUNT_PREFIX = getRemoteDir() + path.sep;
|
|
8
|
+
export function isRemoteMountPath(absolutePath: string): boolean {
|
|
9
|
+
return absolutePath.startsWith(REMOTE_MOUNT_PREFIX);
|
|
10
|
+
}
|
|
11
|
+
export function isNotFoundError(error: unknown): boolean {
|
|
12
|
+
if (!error || typeof error !== "object") return false;
|
|
13
|
+
const code = (error as { code?: string }).code;
|
|
14
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
15
|
+
}
|
|
16
|
+
/** Per-execute memo of suffix-glob lookups; `null` records a confirmed miss. */
|
|
17
|
+
export type SuffixMatchCache = Map<string, { absolutePath: string; displayPath: string } | null>;
|
|
18
|
+
/**
|
|
19
|
+
* Memoized {@link findUniqueWorkspaceSuffix} for a single read call. A missing
|
|
20
|
+
* path with archive/sqlite extensions probes the workspace once per stage
|
|
21
|
+
* (archive candidates, sqlite candidates, plain path) — each glob carries a
|
|
22
|
+
* 5s timeout, so repeated lookups of the same string stack into a long
|
|
23
|
+
* stall before erroring. The cache collapses repeats within one execute().
|
|
24
|
+
*/
|
|
25
|
+
export async function findSuffixMatchCached(
|
|
26
|
+
session: ToolSession,
|
|
27
|
+
cache: SuffixMatchCache,
|
|
28
|
+
rawPath: string,
|
|
29
|
+
signal?: AbortSignal,
|
|
30
|
+
): Promise<{ absolutePath: string; displayPath: string } | null> {
|
|
31
|
+
const hit = cache.get(rawPath);
|
|
32
|
+
if (hit !== undefined) return hit;
|
|
33
|
+
const result = await findUniqueWorkspaceSuffix(rawPath, session.cwd, signal);
|
|
34
|
+
cache.set(rawPath, result);
|
|
35
|
+
return result;
|
|
36
|
+
}
|