@oh-my-pi/pi-coding-agent 15.5.3 → 15.5.6

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 (88) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/types/config/settings-schema.d.ts +27 -0
  3. package/dist/types/config.d.ts +31 -5
  4. package/dist/types/edit/file-snapshot-store.d.ts +18 -0
  5. package/dist/types/edit/hashline/diff.d.ts +35 -0
  6. package/dist/types/edit/hashline/execute.d.ts +28 -0
  7. package/dist/types/edit/hashline/filesystem.d.ts +57 -0
  8. package/dist/types/edit/hashline/index.d.ts +4 -0
  9. package/dist/types/edit/hashline/params.d.ts +11 -0
  10. package/dist/types/edit/index.d.ts +4 -3
  11. package/dist/types/edit/normalize.d.ts +4 -16
  12. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +23 -0
  13. package/dist/types/index.d.ts +0 -1
  14. package/dist/types/tools/fetch.d.ts +3 -0
  15. package/dist/types/tools/find.d.ts +7 -0
  16. package/dist/types/tools/index.d.ts +6 -5
  17. package/dist/types/tools/path-utils.d.ts +18 -0
  18. package/dist/types/utils/changelog.d.ts +8 -3
  19. package/package.json +8 -15
  20. package/scripts/build-binary.ts +11 -0
  21. package/src/config/settings-schema.ts +32 -0
  22. package/src/config.ts +42 -15
  23. package/src/edit/diff.ts +5 -3
  24. package/src/edit/file-snapshot-store.ts +22 -0
  25. package/src/edit/hashline/diff.ts +95 -0
  26. package/src/edit/hashline/execute.ts +181 -0
  27. package/src/edit/hashline/filesystem.ts +129 -0
  28. package/src/edit/hashline/index.ts +4 -0
  29. package/src/edit/hashline/params.ts +18 -0
  30. package/src/edit/index.ts +16 -27
  31. package/src/edit/normalize.ts +11 -41
  32. package/src/edit/renderer.ts +15 -8
  33. package/src/edit/streaming.ts +20 -134
  34. package/src/extensibility/legacy-pi-ai-shim.ts +24 -0
  35. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -3
  36. package/src/index.ts +0 -1
  37. package/src/internal-urls/docs-index.generated.ts +1 -1
  38. package/src/main.ts +2 -1
  39. package/src/modes/rpc/rpc-client.ts +3 -1
  40. package/src/prompts/tools/find.md +3 -2
  41. package/src/sdk.ts +8 -1
  42. package/src/session/agent-session.ts +18 -2
  43. package/src/tools/ast-edit.ts +1 -1
  44. package/src/tools/ast-grep.ts +3 -3
  45. package/src/tools/fetch.ts +93 -50
  46. package/src/tools/find.ts +38 -6
  47. package/src/tools/index.ts +6 -5
  48. package/src/tools/path-utils.ts +81 -0
  49. package/src/tools/read.ts +71 -75
  50. package/src/tools/search.ts +136 -17
  51. package/src/tools/write.ts +3 -3
  52. package/src/utils/changelog.ts +11 -3
  53. package/src/utils/file-mentions.ts +1 -1
  54. package/dist/types/edit/file-read-cache.d.ts +0 -36
  55. package/dist/types/hashline/anchors.d.ts +0 -26
  56. package/dist/types/hashline/apply.d.ts +0 -14
  57. package/dist/types/hashline/constants.d.ts +0 -48
  58. package/dist/types/hashline/diff-preview.d.ts +0 -2
  59. package/dist/types/hashline/diff.d.ts +0 -16
  60. package/dist/types/hashline/execute.d.ts +0 -4
  61. package/dist/types/hashline/executor.d.ts +0 -56
  62. package/dist/types/hashline/hash.d.ts +0 -76
  63. package/dist/types/hashline/index.d.ts +0 -14
  64. package/dist/types/hashline/input.d.ts +0 -4
  65. package/dist/types/hashline/prefixes.d.ts +0 -7
  66. package/dist/types/hashline/recovery.d.ts +0 -21
  67. package/dist/types/hashline/stream.d.ts +0 -2
  68. package/dist/types/hashline/tokenizer.d.ts +0 -94
  69. package/dist/types/hashline/types.d.ts +0 -75
  70. package/src/edit/file-read-cache.ts +0 -138
  71. package/src/hashline/anchors.ts +0 -104
  72. package/src/hashline/apply.ts +0 -790
  73. package/src/hashline/bigrams.json +0 -649
  74. package/src/hashline/constants.ts +0 -60
  75. package/src/hashline/diff-preview.ts +0 -42
  76. package/src/hashline/diff.ts +0 -82
  77. package/src/hashline/execute.ts +0 -334
  78. package/src/hashline/executor.ts +0 -347
  79. package/src/hashline/grammar.lark +0 -22
  80. package/src/hashline/hash.ts +0 -131
  81. package/src/hashline/index.ts +0 -14
  82. package/src/hashline/input.ts +0 -137
  83. package/src/hashline/prefixes.ts +0 -111
  84. package/src/hashline/recovery.ts +0 -139
  85. package/src/hashline/stream.ts +0 -123
  86. package/src/hashline/tokenizer.ts +0 -473
  87. package/src/hashline/types.ts +0 -66
  88. package/src/prompts/tools/hashline.md +0 -83
@@ -6,6 +6,7 @@ import { htmlToMarkdown } from "@oh-my-pi/pi-natives";
6
6
  import { type Component, Text } from "@oh-my-pi/pi-tui";
7
7
  import { $which, ptree, truncate } from "@oh-my-pi/pi-utils";
8
8
  import { parseHTML } from "linkedom";
9
+ import { LRUCache } from "lru-cache/raw";
9
10
  import type { Settings } from "../config/settings";
10
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
12
  import { type Theme, theme } from "../modes/theme/theme";
@@ -23,6 +24,7 @@ import { finalizeOutput, loadPage, looksLikeHtml, MAX_OUTPUT_CHARS } from "../we
23
24
  import { convertWithMarkit, fetchBinary } from "../web/scrapers/utils";
24
25
  import { applyListLimit } from "./list-limit";
25
26
  import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
27
+ import { type LineRange, parseLineRanges } from "./path-utils";
26
28
  import { formatExpandHint, getDomain, replaceTabs } from "./render-utils";
27
29
  import { ToolAbortError, ToolError } from "./tool-errors";
28
30
  import { toolResult } from "./tool-result";
@@ -138,16 +140,31 @@ export function isReadableUrlPath(value: string): boolean {
138
140
  return /^https?:\/\//i.test(value) || /^www\./i.test(value);
139
141
  }
140
142
 
141
- // URL line selectors mirror the file form: `:50`, `:50-100`, `:50+150`, `:raw`.
142
- // If a URL would otherwise look like `host:port`, add a trailing slash before the selector
143
- // (e.g. `https://example.com/:80` to read line 80 of the document at `https://example.com/`).
144
- const URL_LINE_RANGE_RE = /^(\d+)(?:([-+])(\d+))?$/;
143
+ // URL line selectors mirror the file form: `:50`, `:50-100`, `:50+150`, `:5-10,20-30`, `:raw`,
144
+ // or `:raw:N-M` / `:N-M:raw` to combine raw mode with a range. If a URL would otherwise look
145
+ // like `host:port`, add a trailing slash before the selector (e.g. `https://example.com/:80`
146
+ // to read line 80 of the document at `https://example.com/`).
145
147
 
146
148
  export interface ParsedReadUrlTarget {
147
149
  path: string;
148
150
  raw: boolean;
149
151
  offset?: number;
150
152
  limit?: number;
153
+ /** Populated only when the selector carries 2+ ranges. Single-range stays on offset/limit. */
154
+ ranges?: readonly LineRange[];
155
+ }
156
+
157
+ /** Recognize a single selector token (`raw` or one/many line ranges). */
158
+ function isUrlSelectorToken(token: string): boolean {
159
+ if (token === "raw") return true;
160
+ try {
161
+ return parseLineRanges(token) !== null;
162
+ } catch {
163
+ // `parseLineRanges` throws `ToolError` for malformed ranges (e.g. `5+0`). Only treat the
164
+ // token as a selector when it parses cleanly so URL ports like `:80` keep flowing
165
+ // through to the URL path.
166
+ return false;
167
+ }
151
168
  }
152
169
 
153
170
  export function parseReadUrlTarget(readPath: string): ParsedReadUrlTarget | null {
@@ -157,62 +174,71 @@ export function parseReadUrlTarget(readPath: string): ParsedReadUrlTarget | null
157
174
  return null;
158
175
  }
159
176
 
160
- const selector = embedded?.sel;
161
- const raw = selector === "raw";
162
- const lineMatch = selector && selector !== "raw" ? URL_LINE_RANGE_RE.exec(selector) : null;
163
- if (lineMatch) {
164
- const startLine = Number.parseInt(lineMatch[1]!, 10);
165
- if (startLine < 1) {
166
- throw new ToolError("URL line selector 0 is invalid; lines are 1-indexed. Use :1.");
177
+ let raw = false;
178
+ let ranges: readonly LineRange[] | undefined;
179
+ for (const sel of embedded?.sels ?? []) {
180
+ if (sel === "raw") {
181
+ raw = true;
182
+ continue;
167
183
  }
168
- const sep = lineMatch[2];
169
- const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
170
- let endLine: number | undefined;
171
- if (sep === "+") {
172
- if (rhs === undefined || rhs < 1) {
173
- throw new ToolError(`Invalid range ${startLine}+${rhs ?? 0}: count must be >= 1.`);
174
- }
175
- endLine = startLine + rhs - 1;
176
- } else if (sep === "-") {
177
- if (rhs === undefined || rhs < startLine) {
178
- throw new ToolError(`Invalid range ${startLine}-${rhs ?? 0}: end must be >= start.`);
179
- }
180
- endLine = rhs;
184
+ if (ranges !== undefined) {
185
+ // Two range groups on the same URL (`…:5-10:20-30`) combine with commas instead.
186
+ throw new ToolError(
187
+ `URL selector has multiple range groups; combine them with commas (e.g. \`:5-10,20-30\`).`,
188
+ );
181
189
  }
190
+ const parsed = parseLineRanges(sel);
191
+ if (parsed === null) {
192
+ // Shouldn't happen — isUrlSelectorToken vetted it. Belt-and-suspenders.
193
+ throw new ToolError(`Invalid URL line selector: ${sel}`);
194
+ }
195
+ ranges = parsed;
196
+ }
197
+
198
+ if (!ranges || ranges.length === 0) return { path: urlPath, raw };
199
+ if (ranges.length === 1) {
200
+ const r = ranges[0];
182
201
  return {
183
202
  path: urlPath,
184
- raw: false,
185
- offset: startLine,
186
- limit: endLine !== undefined ? endLine - startLine + 1 : undefined,
203
+ raw,
204
+ offset: r.startLine,
205
+ limit: r.endLine !== undefined ? r.endLine - r.startLine + 1 : undefined,
187
206
  };
188
207
  }
189
-
190
- return { path: urlPath, raw };
208
+ return { path: urlPath, raw, ranges };
191
209
  }
192
210
 
193
- function tryExtractEmbeddedUrlSelector(readPath: string): { path: string; sel?: string } | null {
194
- const lastColonIndex = readPath.lastIndexOf(":");
195
- if (lastColonIndex <= 0) {
196
- return null;
197
- }
198
-
199
- const candidateSelector = readPath.slice(lastColonIndex + 1);
200
- const basePath = readPath.slice(0, lastColonIndex);
201
- if (!isReadableUrlPath(basePath)) {
202
- return null;
203
- }
211
+ /**
212
+ * Peel one or more selector tokens off the right of a URL string. Walks back through
213
+ * trailing `:tok` segments while each token (a) looks like a selector and (b) leaves
214
+ * behind a string that still parses as a URL. Returns selectors left-to-right so callers
215
+ * can apply them in source order.
216
+ */
217
+ function tryExtractEmbeddedUrlSelector(readPath: string): { path: string; sels: string[] } | null {
218
+ let basePath = readPath;
219
+ const sels: string[] = [];
220
+ while (true) {
221
+ const lastColonIndex = basePath.lastIndexOf(":");
222
+ if (lastColonIndex <= 0) break;
223
+
224
+ const candidate = basePath.slice(lastColonIndex + 1);
225
+ const remainder = basePath.slice(0, lastColonIndex);
226
+ if (!isReadableUrlPath(remainder)) break;
227
+ if (!isUrlSelectorToken(candidate)) break;
204
228
 
205
- const isEmbeddedSelector = candidateSelector === "raw" || URL_LINE_RANGE_RE.test(candidateSelector);
206
- if (!isEmbeddedSelector) {
207
- return null;
208
- }
229
+ try {
230
+ new URL(
231
+ remainder.startsWith("http://") || remainder.startsWith("https://") ? remainder : `https://${remainder}`,
232
+ );
233
+ } catch {
234
+ break;
235
+ }
209
236
 
210
- try {
211
- new URL(basePath.startsWith("http://") || basePath.startsWith("https://") ? basePath : `https://${basePath}`);
212
- return { path: basePath, sel: candidateSelector };
213
- } catch {
214
- return null;
237
+ sels.unshift(candidate);
238
+ basePath = remainder;
215
239
  }
240
+ if (sels.length === 0) return null;
241
+ return { path: basePath, sels };
216
242
  }
217
243
 
218
244
  /**
@@ -931,6 +957,22 @@ async function renderUrl(
931
957
  const isText = mime.includes("text/plain") || mime.includes("text/markdown");
932
958
  const isFeed = mime.includes("rss") || mime.includes("atom") || mime.includes("feed");
933
959
 
960
+ // Raw mode skips every text-shaping branch below (JSON pretty-print, feed-to-markdown,
961
+ // HTML extraction) and returns the response body verbatim. The image/markit branches
962
+ // above already ran because raw isn't useful for binary payloads.
963
+ if (raw) {
964
+ const output = finalizeOutput(rawContent);
965
+ return {
966
+ url,
967
+ finalUrl,
968
+ contentType: mime,
969
+ method: "raw",
970
+ content: output.content,
971
+ fetchedAt,
972
+ truncated: output.truncated,
973
+ notes,
974
+ };
975
+ }
934
976
  if (isJson) {
935
977
  const output = finalizeOutput(formatJson(rawContent));
936
978
  return {
@@ -1174,7 +1216,8 @@ interface ReadUrlCacheEntry {
1174
1216
  output: string;
1175
1217
  }
1176
1218
 
1177
- const readUrlCache = new Map<string, ReadUrlCacheEntry>();
1219
+ const READ_URL_CACHE_MAX_ENTRIES = 100;
1220
+ const readUrlCache = new LRUCache<string, ReadUrlCacheEntry>({ max: READ_URL_CACHE_MAX_ENTRIES });
1178
1221
 
1179
1222
  function getReadUrlCacheKey(session: ToolSession, requestedUrl: string, raw: boolean): string {
1180
1223
  const scope = session.getSessionFile() ?? session.cwd;
package/src/tools/find.ts CHANGED
@@ -39,14 +39,15 @@ const findSchema = z
39
39
  paths: z.array(z.string().describe("glob including search path")).min(1).describe("globs including search paths"),
40
40
  hidden: z.boolean().default(true).describe("include hidden files").optional(),
41
41
  gitignore: z.boolean().default(true).describe("respect gitignore").optional(),
42
- limit: z.number().default(1000).describe("max results").optional(),
42
+ limit: z.number().default(200).describe("max results (clamped to 1-200)").optional(),
43
43
  timeout: z.number().min(0.5).max(60).default(5).describe("timeout in seconds (0.5–60)").optional(),
44
44
  })
45
45
  .strict();
46
46
 
47
47
  export type FindToolInput = z.infer<typeof findSchema>;
48
48
 
49
- const DEFAULT_LIMIT = 1000;
49
+ const DEFAULT_LIMIT = 200;
50
+ const MAX_LIMIT = 200;
50
51
  const DEFAULT_GLOB_TIMEOUT_MS = 5000;
51
52
  const MIN_GLOB_TIMEOUT_MS = 500;
52
53
  const MAX_GLOB_TIMEOUT_MS = 60_000;
@@ -78,6 +79,37 @@ export function validateFindPathInputs(paths: readonly string[]): void {
78
79
  }
79
80
  }
80
81
 
82
+ /**
83
+ * Group find matches by their directory so the model doesn't pay repeated
84
+ * tokens for shared path prefixes. Preserves the input order: groups appear in
85
+ * the order their first member was emitted (mtime-desc for native glob), and
86
+ * within a group entries keep their relative order.
87
+ */
88
+ export function formatFindGroupedOutput(paths: readonly string[]): string {
89
+ if (paths.length === 0) return "";
90
+ const groups = new Map<string, string[]>();
91
+ for (const entry of paths) {
92
+ const hasTrailingSlash = entry.endsWith("/");
93
+ const trimmed = hasTrailingSlash ? entry.slice(0, -1) : entry;
94
+ const slash = trimmed.lastIndexOf("/");
95
+ const dir = slash === -1 ? "" : trimmed.slice(0, slash);
96
+ const base = slash === -1 ? trimmed : trimmed.slice(slash + 1);
97
+ const label = hasTrailingSlash ? `${base}/` : base;
98
+ const list = groups.get(dir);
99
+ if (list) list.push(label);
100
+ else groups.set(dir, [label]);
101
+ }
102
+ const sections: string[] = [];
103
+ for (const [dir, entries] of groups) {
104
+ if (dir === "") {
105
+ sections.push(entries.join("\n"));
106
+ } else {
107
+ sections.push(`# ${dir}/\n${entries.join("\n")}`);
108
+ }
109
+ }
110
+ return sections.join("\n\n");
111
+ }
112
+
81
113
  export interface FindToolDetails {
82
114
  truncation?: TruncationResult;
83
115
  resultLimitReached?: number;
@@ -195,11 +227,11 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
195
227
  if (searchPath === "/") {
196
228
  throw new ToolError("Searching from root directory '/' is not allowed");
197
229
  }
198
- const rawLimit = limit ?? DEFAULT_LIMIT;
199
- const effectiveLimit = Number.isFinite(rawLimit) ? Math.floor(rawLimit) : Number.NaN;
200
- if (!Number.isFinite(effectiveLimit) || effectiveLimit <= 0) {
230
+ const requestedLimit = limit ?? DEFAULT_LIMIT;
231
+ if (!Number.isFinite(requestedLimit) || requestedLimit <= 0) {
201
232
  throw new ToolError("Limit must be a positive number");
202
233
  }
234
+ const effectiveLimit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(requestedLimit)));
203
235
  const includeHidden = hidden ?? true;
204
236
  const useGitignore = gitignore ?? true;
205
237
  const requestedTimeoutMs = timeout != null ? Math.round(timeout * 1000) : DEFAULT_GLOB_TIMEOUT_MS;
@@ -241,7 +273,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
241
273
  const listLimit = applyListLimit(files, { limit: effectiveLimit });
242
274
  const limited = listLimit.items;
243
275
  const limitMeta = listLimit.meta;
244
- const baseOutput = limited.join("\n");
276
+ const baseOutput = formatFindGroupedOutput(limited);
245
277
  const trailingNotes: string[] = [];
246
278
  if (notice) trailingNotes.push(notice);
247
279
  if (missingPathsNote) trailingNotes.push(missingPathsNote);
@@ -1,3 +1,4 @@
1
+ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
1
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
2
3
  import type { ToolChoice } from "@oh-my-pi/pi-ai";
3
4
  import { $env, $flag, logger } from "@oh-my-pi/pi-utils";
@@ -230,11 +231,11 @@ export interface ToolSession {
230
231
  /** Set or clear active checkpoint state. */
231
232
  setCheckpointState?: (state: CheckpointState | null) => void;
232
233
 
233
- /** Per-session cache of file contents as last shown to the model by
234
- * `read`/`search`. Used by hashline anchor-stale recovery to reconstruct
235
- * the version the model authored anchors against when the file changed
236
- * out-of-band. Lazily initialized by `getFileReadCache`. */
237
- fileReadCache?: import("../edit/file-read-cache").FileReadCache;
234
+ /** Per-session snapshot store of file contents as last shown to the model
235
+ * by `read`/`search`. Used by hashline anchor-stale recovery to
236
+ * reconstruct the version the model authored anchors against when the
237
+ * file changed out-of-band. Lazily initialized by `getFileSnapshotStore`. */
238
+ fileSnapshotStore?: InMemorySnapshotStore;
238
239
 
239
240
  /** Per-session log of unresolved git merge conflict regions surfaced by
240
241
  * `read`. Each entry gets a stable id N referenced by `write conflict://N`
@@ -133,6 +133,87 @@ export function expandPath(filePath: string): string {
133
133
  const normalized = stripFileUrl(normalizeUnicodeSpaces(normalizeAtPrefix(filePath)));
134
134
  return expandTilde(normalized);
135
135
  }
136
+ /**
137
+ * Inclusive line range describing one selector segment (e.g. `50-100`,
138
+ * `301-`, or `50+10`). `endLine` is `undefined` for open-ended ranges.
139
+ */
140
+ export interface LineRange {
141
+ startLine: number;
142
+ endLine: number | undefined;
143
+ }
144
+
145
+ const LINE_RANGE_CHUNK_RE = /^L?(\d+)(?:([-+])L?(\d+)?)?$/i;
146
+
147
+ /** Parse a single `N`, `N-M`, `N-`, or `N+K` chunk. Throws via {@link ToolError} on invalid bounds. */
148
+ export function parseLineRangeChunk(sel: string): LineRange | null {
149
+ const lineMatch = LINE_RANGE_CHUNK_RE.exec(sel);
150
+ if (!lineMatch) return null;
151
+ const rawStart = Number.parseInt(lineMatch[1]!, 10);
152
+ if (rawStart < 1) {
153
+ throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
154
+ }
155
+ const sep = lineMatch[2];
156
+ const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
157
+ let rawEnd: number | undefined;
158
+ if (sep === "+") {
159
+ if (rhs === undefined || rhs < 1) {
160
+ throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
161
+ }
162
+ rawEnd = rawStart + rhs - 1;
163
+ } else if (sep === "-") {
164
+ // `301-` is shorthand for "from 301 onward" — equivalent to bare `301`.
165
+ if (rhs !== undefined) {
166
+ if (rhs < rawStart) {
167
+ throw new ToolError(`Invalid range ${rawStart}-${rhs}: end must be >= start.`);
168
+ }
169
+ rawEnd = rhs;
170
+ }
171
+ }
172
+ return { startLine: rawStart, endLine: rawEnd };
173
+ }
174
+
175
+ /**
176
+ * Parse a comma-separated list of line ranges (e.g. `5-16,960-973`). Returns
177
+ * the ranges in ascending order with overlapping/adjacent ranges merged so
178
+ * downstream consumers can stream the file in a single forward pass per range.
179
+ */
180
+ export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null {
181
+ const chunks = sel.split(",");
182
+ const parsed: LineRange[] = [];
183
+ for (const chunk of chunks) {
184
+ const range = parseLineRangeChunk(chunk);
185
+ if (!range) return null;
186
+ parsed.push(range);
187
+ }
188
+ if (parsed.length === 0) return null;
189
+ parsed.sort((a, b) => a.startLine - b.startLine);
190
+
191
+ const merged: LineRange[] = [parsed[0]];
192
+ for (let i = 1; i < parsed.length; i++) {
193
+ const current = parsed[i];
194
+ const last = merged[merged.length - 1];
195
+ // Open-ended (endLine undefined) means "to EOF" — any later range is absorbed.
196
+ if (last.endLine === undefined) continue;
197
+ // Merge when current starts within (or immediately after) the last range.
198
+ if (current.startLine <= last.endLine + 1) {
199
+ if (current.endLine === undefined || current.endLine > last.endLine) {
200
+ merged[merged.length - 1] = { startLine: last.startLine, endLine: current.endLine };
201
+ }
202
+ continue;
203
+ }
204
+ merged.push(current);
205
+ }
206
+ return merged as [LineRange, ...LineRange[]];
207
+ }
208
+
209
+ /** Return `true` when `lineNumber` (1-indexed) falls in any of the supplied ranges. */
210
+ export function isLineInRanges(lineNumber: number, ranges: readonly LineRange[]): boolean {
211
+ for (const range of ranges) {
212
+ if (lineNumber < range.startLine) continue;
213
+ if (range.endLine === undefined || lineNumber <= range.endLine) return true;
214
+ }
215
+ return false;
216
+ }
136
217
 
137
218
  export function splitPathAndSel(rawPath: string): { path: string; sel?: string } {
138
219
  const colon = rawPath.lastIndexOf(":");
package/src/tools/read.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import { computeFileHash, formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline";
4
5
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
6
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
6
7
  import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
@@ -8,11 +9,10 @@ import type { Component } from "@oh-my-pi/pi-tui";
8
9
  import { Text } from "@oh-my-pi/pi-tui";
9
10
  import { getRemoteDir, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
10
11
  import * as z from "zod/v4";
11
- import { getFileReadCache } from "../edit/file-read-cache";
12
+ import { getFileSnapshotStore } from "../edit/file-snapshot-store";
12
13
  import { normalizeToLF } from "../edit/normalize";
13
14
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
14
15
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
15
- import { computeFileHash, formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "../hashline/hash";
16
16
  import { InternalUrlRouter } from "../internal-urls";
17
17
  import { parseInternalUrl } from "../internal-urls/parse";
18
18
  import type { InternalUrl } from "../internal-urls/types";
@@ -66,6 +66,8 @@ import {
66
66
  import {
67
67
  expandPath,
68
68
  formatPathRelativeToCwd,
69
+ type LineRange,
70
+ parseLineRanges,
69
71
  resolveReadPath,
70
72
  splitInternalUrlSel,
71
73
  splitPathAndSel,
@@ -145,7 +147,7 @@ function recordHashlineSnapshot(
145
147
  context: HashlineHeaderContext | undefined,
146
148
  ): void {
147
149
  if (!context || !absolutePath || !path.isAbsolute(absolutePath)) return;
148
- getFileReadCache(session).recordContiguous(absolutePath, 1, context.fullText.split("\n"), {
150
+ getFileSnapshotStore(session).recordContiguous(absolutePath, 1, context.fullText.split("\n"), {
149
151
  fullText: context.fullText,
150
152
  fileHash: context.fileHash,
151
153
  });
@@ -568,16 +570,12 @@ export interface ReadToolDetails {
568
570
  type ReadParams = ReadToolInput;
569
571
 
570
572
  /** Parsed representation of a path-embedded selector. */
571
- type LineRange = { startLine: number; endLine: number | undefined };
572
-
573
573
  type ParsedSelector =
574
574
  | { kind: "none" }
575
575
  | { kind: "raw" }
576
576
  | { kind: "conflicts" }
577
577
  | { kind: "lines"; ranges: [LineRange, ...LineRange[]]; raw?: boolean };
578
578
 
579
- const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+)?)?$/i;
580
-
581
579
  /** Returns true when the selector requested verbatim/raw output (alone or combined with a range). */
582
580
  function isRawSelector(parsed: ParsedSelector): boolean {
583
581
  return parsed.kind === "raw" || (parsed.kind === "lines" && parsed.raw === true);
@@ -588,67 +586,6 @@ function isMultiRange(parsed: ParsedSelector): boolean {
588
586
  return parsed.kind === "lines" && parsed.ranges.length > 1;
589
587
  }
590
588
 
591
- function parseLineRangeChunk(sel: string): LineRange | null {
592
- const lineMatch = LINE_RANGE_RE.exec(sel);
593
- if (!lineMatch) return null;
594
- const rawStart = Number.parseInt(lineMatch[1]!, 10);
595
- if (rawStart < 1) {
596
- throw new ToolError("Line selector 0 is invalid; lines are 1-indexed. Use :1.");
597
- }
598
- const sep = lineMatch[2];
599
- const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
600
- let rawEnd: number | undefined;
601
- if (sep === "+") {
602
- if (rhs === undefined || rhs < 1) {
603
- throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
604
- }
605
- rawEnd = rawStart + rhs - 1;
606
- } else if (sep === "-") {
607
- // `301-` is shorthand for "from 301 onward" — equivalent to bare `301`.
608
- if (rhs !== undefined) {
609
- if (rhs < rawStart) {
610
- throw new ToolError(`Invalid range ${rawStart}-${rhs}: end must be >= start.`);
611
- }
612
- rawEnd = rhs;
613
- }
614
- }
615
- return { startLine: rawStart, endLine: rawEnd };
616
- }
617
-
618
- /**
619
- * Parse a comma-separated list of line ranges (e.g. `5-16,960-973`). Returns
620
- * the ranges in ascending order with overlapping/adjacent ranges merged so
621
- * downstream consumers can stream the file in a single forward pass per range.
622
- */
623
- function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null {
624
- const chunks = sel.split(",");
625
- const parsed: LineRange[] = [];
626
- for (const chunk of chunks) {
627
- const range = parseLineRangeChunk(chunk);
628
- if (!range) return null;
629
- parsed.push(range);
630
- }
631
- if (parsed.length === 0) return null;
632
- parsed.sort((a, b) => a.startLine - b.startLine);
633
-
634
- const merged: LineRange[] = [parsed[0]];
635
- for (let i = 1; i < parsed.length; i++) {
636
- const current = parsed[i];
637
- const last = merged[merged.length - 1];
638
- // Open-ended (endLine undefined) means "to EOF" — any later range is absorbed.
639
- if (last.endLine === undefined) continue;
640
- // Merge when current starts within (or immediately after) the last range.
641
- if (current.startLine <= last.endLine + 1) {
642
- if (current.endLine === undefined || current.endLine > last.endLine) {
643
- merged[merged.length - 1] = { startLine: last.startLine, endLine: current.endLine };
644
- }
645
- continue;
646
- }
647
- merged.push(current);
648
- }
649
- return merged as [LineRange, ...LineRange[]];
650
- }
651
-
652
589
  function parseSel(sel: string | undefined): ParsedSelector {
653
590
  if (!sel || sel.length === 0) return { kind: "none" };
654
591
 
@@ -1122,7 +1059,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1122
1059
  }
1123
1060
 
1124
1061
  if (collectedLines.length > 0) {
1125
- getFileReadCache(this.session).recordContiguous(
1062
+ getFileSnapshotStore(this.session).recordContiguous(
1126
1063
  absolutePath,
1127
1064
  range.startLine,
1128
1065
  collectedLines,
@@ -1406,14 +1343,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1406
1343
  ? await bridgePromise.catch(() => Bun.file(absolutePath).text())
1407
1344
  : await Bun.file(absolutePath).text();
1408
1345
  throwIfAborted(signal);
1409
- if (countTextLines(code) > MAX_SUMMARY_LINES) return null;
1346
+ const lineCount = countTextLines(code);
1347
+ if (lineCount > MAX_SUMMARY_LINES) return null;
1348
+ if (lineCount < this.session.settings.get("read.summarize.minTotalLines")) return null;
1410
1349
 
1411
- return summarizeCode({
1350
+ const result = summarizeCode({
1412
1351
  code,
1413
1352
  path: absolutePath,
1414
1353
  minBodyLines: this.session.settings.get("read.summarize.minBodyLines"),
1415
1354
  minCommentLines: this.session.settings.get("read.summarize.minCommentLines"),
1355
+ unfoldUntilLines: this.session.settings.get("read.summarize.unfoldUntil"),
1356
+ unfoldLimitLines: this.session.settings.get("read.summarize.unfoldLimit"),
1416
1357
  });
1358
+ return result;
1417
1359
  } catch {
1418
1360
  return null;
1419
1361
  }
@@ -1546,6 +1488,21 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1546
1488
  if (!this.session.settings.get("fetch.enabled")) {
1547
1489
  throw new ToolError("URL reads are disabled by settings.");
1548
1490
  }
1491
+ if (parsedUrlTarget.ranges !== undefined) {
1492
+ const cached = await loadReadUrlCacheEntry(
1493
+ this.session,
1494
+ { path: parsedUrlTarget.path, raw: parsedUrlTarget.raw },
1495
+ signal,
1496
+ { ensureArtifact: true, preferCached: true },
1497
+ );
1498
+ return this.#buildInMemoryMultiRangeResult(cached.output, parsedUrlTarget.ranges, {
1499
+ details: { ...cached.details },
1500
+ sourceUrl: cached.details.finalUrl,
1501
+ entityLabel: "URL output",
1502
+ raw: parsedUrlTarget.raw,
1503
+ immutable: true,
1504
+ });
1505
+ }
1549
1506
  if (parsedUrlTarget.offset !== undefined || parsedUrlTarget.limit !== undefined) {
1550
1507
  const cached = await loadReadUrlCacheEntry(
1551
1508
  this.session,
@@ -1560,6 +1517,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1560
1517
  details: { ...cached.details },
1561
1518
  sourceUrl: cached.details.finalUrl,
1562
1519
  entityLabel: "URL output",
1520
+ raw: parsedUrlTarget.raw,
1563
1521
  immutable: true,
1564
1522
  });
1565
1523
  }
@@ -1636,7 +1594,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1636
1594
  if (isMultiRange(parsed)) {
1637
1595
  throw new ToolError("Multi-range line selectors are not supported for directory listings.");
1638
1596
  }
1639
- const dirResult = await this.#readDirectory(absolutePath, selToOffsetLimit(parsed).limit, signal);
1597
+ const { offset, limit } = selToOffsetLimit(parsed);
1598
+ const dirResult = await this.#readDirectory(absolutePath, offset, limit, signal);
1640
1599
  if (suffixResolution) {
1641
1600
  dirResult.details ??= {};
1642
1601
  dirResult.details.suffixResolution = suffixResolution;
@@ -1921,7 +1880,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1921
1880
  : undefined;
1922
1881
 
1923
1882
  if (collectedLines.length > 0 && !firstLineExceedsLimit) {
1924
- getFileReadCache(this.session).recordContiguous(
1883
+ getFileSnapshotStore(this.session).recordContiguous(
1925
1884
  absolutePath,
1926
1885
  startLineDisplay,
1927
1886
  collectedLines,
@@ -2194,6 +2153,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2194
2153
  /** Read directory contents as a formatted listing */
2195
2154
  async #readDirectory(
2196
2155
  absolutePath: string,
2156
+ offset: number | undefined,
2197
2157
  limit: number | undefined,
2198
2158
  signal?: AbortSignal,
2199
2159
  ): Promise<AgentToolResult<ReadToolDetails>> {
@@ -2207,7 +2167,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2207
2167
  maxDepth: READ_DIRECTORY_MAX_DEPTH,
2208
2168
  perDirLimit: READ_DIRECTORY_CHILD_LIMIT,
2209
2169
  rootLimit: null,
2210
- lineCap: limit ?? null,
2170
+ // `lineCap` truncates the rendered tree itself, so apply it only when the caller
2171
+ // did not request an offset — otherwise we'd cap the first N lines before slicing.
2172
+ lineCap: offset === undefined && limit !== undefined ? limit : null,
2211
2173
  });
2212
2174
  } catch (error) {
2213
2175
  const message = error instanceof Error ? error.message : String(error);
@@ -2216,12 +2178,46 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2216
2178
  throwIfAborted(signal);
2217
2179
 
2218
2180
  const output = tree.totalLines <= 1 ? "(empty directory)" : tree.rendered;
2219
- const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
2220
2181
  const details: ReadToolDetails = {
2221
2182
  isDirectory: true,
2222
2183
  resolvedPath: tree.rootPath,
2223
2184
  };
2224
2185
 
2186
+ // Slice the rendered listing when the caller passed an offset/limit. We do this
2187
+ // instead of passing the selector down to `buildDirectoryTree` because the tree
2188
+ // builder lays out entries hierarchically (per-dir caps, recent-then-elided
2189
+ // summaries); line-based slicing operates on the formatted text and matches what
2190
+ // users expect from `:N-M` on long listings.
2191
+ const wantsSlice = offset !== undefined || limit !== undefined;
2192
+ if (wantsSlice) {
2193
+ const allLines = output.split("\n");
2194
+ const start = offset ? Math.max(0, offset - 1) : 0;
2195
+ if (start >= allLines.length) {
2196
+ const suggestion =
2197
+ allLines.length === 0
2198
+ ? "The listing is empty."
2199
+ : `Use :1 to read from the start, or :${allLines.length} to read the last line.`;
2200
+ return toolResult(details)
2201
+ .text(`Line ${start + 1} is beyond end of listing (${allLines.length} lines total). ${suggestion}`)
2202
+ .sourcePath(tree.rootPath)
2203
+ .done();
2204
+ }
2205
+ const end = limit !== undefined ? Math.min(start + limit, allLines.length) : allLines.length;
2206
+ const sliced = allLines.slice(start, end).join("\n");
2207
+ const resultBuilder = toolResult(details).sourcePath(tree.rootPath);
2208
+ let text = sliced;
2209
+ if (end < allLines.length) {
2210
+ const remaining = allLines.length - end;
2211
+ text += `\n\n[${remaining} more lines in listing. Use :${end + 1} to continue]`;
2212
+ }
2213
+ resultBuilder.text(text);
2214
+ if (tree.truncated) {
2215
+ resultBuilder.limits({ resultLimit: 1 });
2216
+ }
2217
+ return resultBuilder.done();
2218
+ }
2219
+
2220
+ const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
2225
2221
  const resultBuilder = toolResult(details).text(truncation.content).sourcePath(tree.rootPath);
2226
2222
  if (tree.truncated) {
2227
2223
  resultBuilder.limits({ resultLimit: 1 });