@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.
- package/CHANGELOG.md +55 -0
- package/dist/types/config/settings-schema.d.ts +27 -0
- package/dist/types/config.d.ts +31 -5
- package/dist/types/edit/file-snapshot-store.d.ts +18 -0
- package/dist/types/edit/hashline/diff.d.ts +35 -0
- package/dist/types/edit/hashline/execute.d.ts +28 -0
- package/dist/types/edit/hashline/filesystem.d.ts +57 -0
- package/dist/types/edit/hashline/index.d.ts +4 -0
- package/dist/types/edit/hashline/params.d.ts +11 -0
- package/dist/types/edit/index.d.ts +4 -3
- package/dist/types/edit/normalize.d.ts +4 -16
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +23 -0
- package/dist/types/index.d.ts +0 -1
- package/dist/types/tools/fetch.d.ts +3 -0
- package/dist/types/tools/find.d.ts +7 -0
- package/dist/types/tools/index.d.ts +6 -5
- package/dist/types/tools/path-utils.d.ts +18 -0
- package/dist/types/utils/changelog.d.ts +8 -3
- package/package.json +8 -15
- package/scripts/build-binary.ts +11 -0
- package/src/config/settings-schema.ts +32 -0
- package/src/config.ts +42 -15
- package/src/edit/diff.ts +5 -3
- package/src/edit/file-snapshot-store.ts +22 -0
- package/src/edit/hashline/diff.ts +95 -0
- package/src/edit/hashline/execute.ts +181 -0
- package/src/edit/hashline/filesystem.ts +129 -0
- package/src/edit/hashline/index.ts +4 -0
- package/src/edit/hashline/params.ts +18 -0
- package/src/edit/index.ts +16 -27
- package/src/edit/normalize.ts +11 -41
- package/src/edit/renderer.ts +15 -8
- package/src/edit/streaming.ts +20 -134
- package/src/extensibility/legacy-pi-ai-shim.ts +24 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +47 -3
- package/src/index.ts +0 -1
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +2 -1
- package/src/modes/rpc/rpc-client.ts +3 -1
- package/src/prompts/tools/find.md +3 -2
- package/src/sdk.ts +8 -1
- package/src/session/agent-session.ts +18 -2
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +3 -3
- package/src/tools/fetch.ts +93 -50
- package/src/tools/find.ts +38 -6
- package/src/tools/index.ts +6 -5
- package/src/tools/path-utils.ts +81 -0
- package/src/tools/read.ts +71 -75
- package/src/tools/search.ts +136 -17
- package/src/tools/write.ts +3 -3
- package/src/utils/changelog.ts +11 -3
- package/src/utils/file-mentions.ts +1 -1
- package/dist/types/edit/file-read-cache.d.ts +0 -36
- package/dist/types/hashline/anchors.d.ts +0 -26
- package/dist/types/hashline/apply.d.ts +0 -14
- package/dist/types/hashline/constants.d.ts +0 -48
- package/dist/types/hashline/diff-preview.d.ts +0 -2
- package/dist/types/hashline/diff.d.ts +0 -16
- package/dist/types/hashline/execute.d.ts +0 -4
- package/dist/types/hashline/executor.d.ts +0 -56
- package/dist/types/hashline/hash.d.ts +0 -76
- package/dist/types/hashline/index.d.ts +0 -14
- package/dist/types/hashline/input.d.ts +0 -4
- package/dist/types/hashline/prefixes.d.ts +0 -7
- package/dist/types/hashline/recovery.d.ts +0 -21
- package/dist/types/hashline/stream.d.ts +0 -2
- package/dist/types/hashline/tokenizer.d.ts +0 -94
- package/dist/types/hashline/types.d.ts +0 -75
- package/src/edit/file-read-cache.ts +0 -138
- package/src/hashline/anchors.ts +0 -104
- package/src/hashline/apply.ts +0 -790
- package/src/hashline/bigrams.json +0 -649
- package/src/hashline/constants.ts +0 -60
- package/src/hashline/diff-preview.ts +0 -42
- package/src/hashline/diff.ts +0 -82
- package/src/hashline/execute.ts +0 -334
- package/src/hashline/executor.ts +0 -347
- package/src/hashline/grammar.lark +0 -22
- package/src/hashline/hash.ts +0 -131
- package/src/hashline/index.ts +0 -14
- package/src/hashline/input.ts +0 -137
- package/src/hashline/prefixes.ts +0 -111
- package/src/hashline/recovery.ts +0 -139
- package/src/hashline/stream.ts +0 -123
- package/src/hashline/tokenizer.ts +0 -473
- package/src/hashline/types.ts +0 -66
- package/src/prompts/tools/hashline.md +0 -83
package/src/tools/fetch.ts
CHANGED
|
@@ -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
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
199
|
-
|
|
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
|
|
276
|
+
const baseOutput = formatFindGroupedOutput(limited);
|
|
245
277
|
const trailingNotes: string[] = [];
|
|
246
278
|
if (notice) trailingNotes.push(notice);
|
|
247
279
|
if (missingPathsNote) trailingNotes.push(missingPathsNote);
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
|
234
|
-
* `read`/`search`. Used by hashline anchor-stale recovery to
|
|
235
|
-
* the version the model authored anchors against when the
|
|
236
|
-
* out-of-band. Lazily initialized by `
|
|
237
|
-
|
|
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`
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 });
|