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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
  3. package/dist/cli.js +10795 -10791
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/config/custom-models.d.ts +31 -0
  6. package/dist/types/config/model-config-values.d.ts +19 -0
  7. package/dist/types/config/model-patch.d.ts +93 -0
  8. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  9. package/dist/types/config/model-registry.d.ts +5 -52
  10. package/dist/types/config/settings.d.ts +5 -3
  11. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  12. package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
  13. package/dist/types/discovery/contained-path.d.ts +2 -3
  14. package/dist/types/eval/executor-base.d.ts +8 -2
  15. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  16. package/dist/types/export/share.d.ts +1 -1
  17. package/dist/types/lsp/diagnostics.d.ts +96 -0
  18. package/dist/types/lsp/index.d.ts +7 -128
  19. package/dist/types/lsp/servers.d.ts +72 -0
  20. package/dist/types/lsp/tool.d.ts +42 -0
  21. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  22. package/dist/types/lsp/writethrough.d.ts +33 -0
  23. package/dist/types/mcp/transports/header-policy.d.ts +2 -1
  24. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  25. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  26. package/dist/types/modes/theme/color.d.ts +19 -0
  27. package/dist/types/modes/theme/loader.d.ts +19 -0
  28. package/dist/types/modes/theme/schema.d.ts +175 -0
  29. package/dist/types/modes/theme/symbols.d.ts +28 -0
  30. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  31. package/dist/types/modes/theme/theme.d.ts +9 -280
  32. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  33. package/dist/types/registry/agent-registry.d.ts +1 -3
  34. package/dist/types/secrets/index.d.ts +3 -1
  35. package/dist/types/secrets/message-transform.d.ts +40 -0
  36. package/dist/types/secrets/obfuscator.d.ts +0 -108
  37. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  38. package/dist/types/secrets/placeholder.d.ts +94 -0
  39. package/dist/types/secrets/replacement.d.ts +82 -0
  40. package/dist/types/session/agent-session-types.d.ts +6 -0
  41. package/dist/types/session/agent-session.d.ts +1 -1
  42. package/dist/types/session/session-handoff.d.ts +3 -3
  43. package/dist/types/session/session-manager.d.ts +32 -0
  44. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  45. package/dist/types/session/turn-recovery.d.ts +2 -2
  46. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  47. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  48. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  49. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  50. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  51. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  52. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  53. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  54. package/dist/types/tools/gh-common.d.ts +69 -0
  55. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  56. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  57. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  58. package/dist/types/tools/gh-search.d.ts +61 -0
  59. package/dist/types/tools/gh-types.d.ts +340 -0
  60. package/dist/types/tools/gh-view.d.ts +88 -0
  61. package/dist/types/tools/gh.d.ts +4 -201
  62. package/dist/types/tools/read-archive.d.ts +16 -0
  63. package/dist/types/tools/read-format.d.ts +104 -0
  64. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  65. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  66. package/dist/types/tools/read-renderer.d.ts +24 -0
  67. package/dist/types/tools/read-selector.d.ts +27 -0
  68. package/dist/types/tools/read-sqlite.d.ts +16 -0
  69. package/dist/types/tools/read-summary.d.ts +19 -0
  70. package/dist/types/tools/read.d.ts +1 -23
  71. package/dist/types/tools/shell-tokenize.d.ts +2 -1
  72. package/dist/types/utils/changelog.d.ts +0 -5
  73. package/package.json +13 -13
  74. package/src/advisor/runtime.ts +12 -7
  75. package/src/config/custom-models.ts +188 -0
  76. package/src/config/model-config-values.ts +128 -0
  77. package/src/config/model-patch.ts +252 -0
  78. package/src/config/model-provider-discovery.ts +132 -0
  79. package/src/config/model-registry.ts +64 -704
  80. package/src/config/settings.ts +7 -3
  81. package/src/debug/raw-sse-buffer.ts +20 -0
  82. package/src/discovery/agent-plugin-format.ts +7 -7
  83. package/src/discovery/contained-path.ts +2 -5
  84. package/src/edit/hashline/diff.ts +5 -1
  85. package/src/eval/executor-base.ts +39 -6
  86. package/src/eval/jl/executor.ts +82 -371
  87. package/src/eval/kernel-session-registry.ts +398 -0
  88. package/src/eval/py/executor.ts +53 -261
  89. package/src/eval/rb/executor.ts +36 -279
  90. package/src/export/share.ts +2 -1
  91. package/src/lsp/diagnostics.ts +516 -0
  92. package/src/lsp/index.ts +20 -2819
  93. package/src/lsp/servers.ts +296 -0
  94. package/src/lsp/tool.ts +1352 -0
  95. package/src/lsp/workspace-diagnostics.ts +170 -0
  96. package/src/lsp/writethrough.ts +561 -0
  97. package/src/mcp/transports/header-policy.ts +1 -1
  98. package/src/modes/components/agent-hub-projection.ts +2 -2
  99. package/src/modes/components/agent-hub-renderer.ts +3 -7
  100. package/src/modes/controllers/command-controller.ts +4 -1
  101. package/src/modes/theme/color.ts +133 -0
  102. package/src/modes/theme/loader.ts +178 -0
  103. package/src/modes/theme/schema.ts +263 -0
  104. package/src/modes/theme/symbols.ts +1000 -0
  105. package/src/modes/theme/theme-class.ts +611 -0
  106. package/src/modes/theme/theme.ts +27 -2453
  107. package/src/modes/theme/tui-adapters.ts +279 -0
  108. package/src/registry/agent-registry.ts +2 -2
  109. package/src/secrets/index.ts +6 -12
  110. package/src/secrets/message-transform.ts +287 -0
  111. package/src/secrets/obfuscator.ts +39 -1303
  112. package/src/secrets/placeholder-scan.ts +506 -0
  113. package/src/secrets/placeholder.ts +309 -0
  114. package/src/secrets/replacement.ts +216 -0
  115. package/src/session/agent-session-types.ts +6 -0
  116. package/src/session/agent-session.ts +113 -7
  117. package/src/session/indexed-session-storage.ts +7 -2
  118. package/src/session/session-advisors.ts +32 -34
  119. package/src/session/session-handoff.ts +39 -20
  120. package/src/session/session-manager.ts +67 -3
  121. package/src/session/session-provider-boundary.ts +3 -6
  122. package/src/session/turn-recovery.ts +21 -12
  123. package/src/slash-commands/builtin-collaboration.ts +504 -0
  124. package/src/slash-commands/builtin-completions.ts +291 -0
  125. package/src/slash-commands/builtin-control.ts +78 -0
  126. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  127. package/src/slash-commands/builtin-marketplace.ts +567 -0
  128. package/src/slash-commands/builtin-modes.ts +518 -0
  129. package/src/slash-commands/builtin-registry.ts +21 -3000
  130. package/src/slash-commands/builtin-session.ts +592 -0
  131. package/src/task/executor.ts +17 -14
  132. package/src/tools/gh-common.ts +288 -0
  133. package/src/tools/gh-pr-checkout.ts +679 -0
  134. package/src/tools/gh-pr-diff.ts +473 -0
  135. package/src/tools/gh-run-watch.ts +1015 -0
  136. package/src/tools/gh-search.ts +500 -0
  137. package/src/tools/gh-types.ts +379 -0
  138. package/src/tools/gh-view.ts +612 -0
  139. package/src/tools/gh.ts +109 -3821
  140. package/src/tools/read-archive.ts +209 -0
  141. package/src/tools/read-format.ts +593 -0
  142. package/src/tools/read-path-resolution.ts +36 -0
  143. package/src/tools/read-pdf-images.ts +250 -0
  144. package/src/tools/read-renderer.ts +289 -0
  145. package/src/tools/read-selector.ts +84 -0
  146. package/src/tools/read-sqlite.ts +215 -0
  147. package/src/tools/read-summary.ts +199 -0
  148. package/src/tools/read.ts +77 -1820
  149. package/src/tools/shell-tokenize.ts +1 -1
  150. package/src/utils/changelog.ts +1 -1
  151. package/src/utils/title-generator.ts +3 -1
  152. package/src/web/search/providers/zai.ts +12 -3
@@ -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
+ }