@oh-my-pi/pi-coding-agent 15.5.4 → 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 +27 -1
- package/dist/types/edit/hashline/diff.d.ts +6 -1
- package/dist/types/edit/hashline/execute.d.ts +1 -2
- package/dist/types/edit/hashline/params.d.ts +4 -5
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +23 -0
- package/dist/types/tools/fetch.d.ts +3 -0
- package/dist/types/tools/find.d.ts +7 -0
- package/package.json +8 -8
- package/scripts/build-binary.ts +11 -0
- package/src/edit/diff.ts +5 -3
- package/src/edit/hashline/diff.ts +11 -4
- package/src/edit/hashline/execute.ts +3 -10
- package/src/edit/hashline/params.ts +10 -3
- package/src/edit/index.ts +9 -12
- package/src/edit/renderer.ts +14 -7
- package/src/edit/streaming.ts +15 -128
- package/src/extensibility/legacy-pi-ai-shim.ts +24 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +47 -3
- 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/session/agent-session.ts +18 -2
- package/src/tools/fetch.ts +93 -50
- package/src/tools/find.ts +38 -6
- package/src/tools/read.ts +57 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.5.6] - 2026-05-27
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- Support for multi-range line selectors on URLs (e.g., `:5-10,20-30`) to fetch and display multiple non-contiguous sections
|
|
9
|
+
- Support for combining `:raw` mode with line range selectors on URLs (e.g., `:raw:1-120` or `:1-120:raw`)
|
|
10
|
+
- Support for line range selectors on directory listings (e.g., `:30-40` to view lines 30–40 of a directory tree)
|
|
11
|
+
- Clear error message when requesting a line offset beyond the end of a directory listing
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- URL selector parsing now supports multiple trailing selector tokens (e.g., `:raw:N-M`), applying them left-to-right
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Fixed `:raw` selector being ignored for JSON and feed URLs, causing them to be pretty-printed or converted to markdown instead of returning raw content
|
|
20
|
+
- Fixed directory listing line selectors silently dropping the offset parameter and only applying the limit
|
|
21
|
+
|
|
22
|
+
## [15.5.5] - 2026-05-27
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Removed the model-facing `path` property from hashline edit tool parameters; hashline edit targets now come from `¶PATH` headers in `input`.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Fixed legacy pi-* extension loading regression where `import { Type } from "@(scope)/pi-ai"` (e.g. `@earendil-works/pi-ai` used by `@plannotator/pi-extension`) failed with `Export named 'Type' not found` after pi-ai 15.1.0 removed the root `Type` runtime export; the legacy-pi compat layer now redirects bare `@oh-my-pi/pi-ai` root imports through a sibling shim that re-exports the canonical pi-ai surface plus the Zod-backed `Type` runtime from the same TypeBox shim served to `@sinclair/typebox` imports ([#1437](https://github.com/can1357/oh-my-pi/issues/1437))
|
|
31
|
+
|
|
5
32
|
## [15.5.4] - 2026-05-27
|
|
6
33
|
|
|
7
34
|
### Breaking Changes
|
|
8
|
-
|
|
9
35
|
- Removed the package root `hashline` export so imports from the top-level entrypoint can no longer access `hashline` helpers directly
|
|
10
36
|
|
|
11
37
|
### Added
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
import { type PatchSection } from "@oh-my-pi/hashline";
|
|
13
13
|
export interface HashlineDiffOptions {
|
|
14
14
|
autoDropPureInsertDuplicates?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Use the streaming-tolerant applier ({@link PatchSection.applyPartialTo})
|
|
17
|
+
* so trailing in-flight ops do not throw or emit phantom edits. Streaming
|
|
18
|
+
* preview path only.
|
|
19
|
+
*/
|
|
20
|
+
streaming?: boolean;
|
|
15
21
|
}
|
|
16
22
|
export declare function computeHashlineSectionDiff(section: PatchSection, cwd: string, options?: HashlineDiffOptions): Promise<{
|
|
17
23
|
diff: string;
|
|
@@ -21,7 +27,6 @@ export declare function computeHashlineSectionDiff(section: PatchSection, cwd: s
|
|
|
21
27
|
}>;
|
|
22
28
|
export declare function computeHashlineDiff(input: {
|
|
23
29
|
input: string;
|
|
24
|
-
path?: string;
|
|
25
30
|
}, cwd: string, options?: HashlineDiffOptions): Promise<{
|
|
26
31
|
diff: string;
|
|
27
32
|
firstChangedLine: number | undefined;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Coding-agent runner that drives the hashline {@link Patcher} on behalf of
|
|
3
|
-
* the `edit` tool. Converts a `{input
|
|
3
|
+
* the `edit` tool. Converts a `{input}` tool-call payload into a
|
|
4
4
|
* fully-applied patch, wraps the result in the agent's
|
|
5
5
|
* {@link AgentToolResult} shape, and attaches LSP diagnostics + `outputMeta`
|
|
6
6
|
* for the renderer.
|
|
@@ -19,7 +19,6 @@ import { type HashlineParams, hashlineEditParamsSchema } from "./params";
|
|
|
19
19
|
export interface ExecuteHashlineSingleOptions {
|
|
20
20
|
session: ToolSession;
|
|
21
21
|
input: string;
|
|
22
|
-
path?: string;
|
|
23
22
|
signal?: AbortSignal;
|
|
24
23
|
batchRequest?: LspBatchRequest;
|
|
25
24
|
writethrough: WritethroughCallback;
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Zod schema for the `edit` tool's hashline mode payload. The schema is
|
|
3
3
|
* deliberately permissive (`.passthrough()`) so providers can attach extra
|
|
4
|
-
* keys without rejection; only `input` is required
|
|
5
|
-
*
|
|
4
|
+
* keys without rejection; only `input` is required. `_input` is accepted as a
|
|
5
|
+
* provider-emitted alias for `input`.
|
|
6
6
|
*/
|
|
7
7
|
import * as z from "zod/v4";
|
|
8
|
-
export declare const hashlineEditParamsSchema: z.ZodObject<{
|
|
8
|
+
export declare const hashlineEditParamsSchema: z.ZodPreprocess<z.ZodObject<{
|
|
9
9
|
input: z.ZodString;
|
|
10
|
-
|
|
11
|
-
}, z.core.$loose>;
|
|
10
|
+
}, z.core.$loose>>;
|
|
12
11
|
export type HashlineParams = z.infer<typeof hashlineEditParamsSchema>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compatibility shim for legacy extensions importing the package root of
|
|
3
|
+
* `@oh-my-pi/pi-ai` (or one of its aliased scopes like `@earendil-works/pi-ai`
|
|
4
|
+
* or `@mariozechner/pi-ai`).
|
|
5
|
+
*
|
|
6
|
+
* pi-ai 15.1.0 removed the historical TypeBox root exports (`Type`, plus the
|
|
7
|
+
* runtime-relevant half of the `Static`/`TSchema` pair) from the package
|
|
8
|
+
* entrypoint. Legacy extensions still author parameter schemas as
|
|
9
|
+
* `Type.Object({ ... })`, so this file is served by `legacy-pi-compat.ts` in
|
|
10
|
+
* place of the real pi-ai entrypoint whenever a legacy extension imports the
|
|
11
|
+
* bare package root. Subpath imports (`@oh-my-pi/pi-ai/utils/oauth`, etc.)
|
|
12
|
+
* continue to resolve directly against the bundled pi-ai package.
|
|
13
|
+
*
|
|
14
|
+
* The `Type` runtime is borrowed from the Zod-backed TypeBox shim that
|
|
15
|
+
* already serves bare `@sinclair/typebox` imports for the same extension
|
|
16
|
+
* class, keeping the legacy-compat surface internally consistent.
|
|
17
|
+
*
|
|
18
|
+
* Type-level `Static` and `TSchema` continue to come from pi-ai's own
|
|
19
|
+
* `types.ts` via the `export *` below — pi-ai still exports both as types,
|
|
20
|
+
* only the runtime `Type` builder was removed.
|
|
21
|
+
*/
|
|
22
|
+
export * from "@oh-my-pi/pi-ai";
|
|
23
|
+
export { Type } from "./typebox";
|
|
@@ -4,12 +4,15 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
|
4
4
|
import { type Theme } from "../modes/theme/theme";
|
|
5
5
|
import type { ToolSession } from "../sdk";
|
|
6
6
|
import { type OutputMeta } from "./output-meta";
|
|
7
|
+
import { type LineRange } from "./path-utils";
|
|
7
8
|
export declare function isReadableUrlPath(value: string): boolean;
|
|
8
9
|
export interface ParsedReadUrlTarget {
|
|
9
10
|
path: string;
|
|
10
11
|
raw: boolean;
|
|
11
12
|
offset?: number;
|
|
12
13
|
limit?: number;
|
|
14
|
+
/** Populated only when the selector carries 2+ ranges. Single-range stays on offset/limit. */
|
|
15
|
+
ranges?: readonly LineRange[];
|
|
13
16
|
}
|
|
14
17
|
export declare function parseReadUrlTarget(readPath: string): ParsedReadUrlTarget | null;
|
|
15
18
|
interface FetchImagePayload {
|
|
@@ -23,6 +23,13 @@ export type FindToolInput = z.infer<typeof findSchema>;
|
|
|
23
23
|
* must pass through.
|
|
24
24
|
*/
|
|
25
25
|
export declare function validateFindPathInputs(paths: readonly string[]): void;
|
|
26
|
+
/**
|
|
27
|
+
* Group find matches by their directory so the model doesn't pay repeated
|
|
28
|
+
* tokens for shared path prefixes. Preserves the input order: groups appear in
|
|
29
|
+
* the order their first member was emitted (mtime-desc for native glob), and
|
|
30
|
+
* within a group entries keep their relative order.
|
|
31
|
+
*/
|
|
32
|
+
export declare function formatFindGroupedOutput(paths: readonly string[]): string;
|
|
26
33
|
export interface FindToolDetails {
|
|
27
34
|
truncation?: TruncationResult;
|
|
28
35
|
resultLimitReached?: number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "15.5.
|
|
4
|
+
"version": "15.5.6",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -47,13 +47,13 @@
|
|
|
47
47
|
"@agentclientprotocol/sdk": "0.21.0",
|
|
48
48
|
"@babel/parser": "^7.29.3",
|
|
49
49
|
"@mozilla/readability": "^0.6.0",
|
|
50
|
-
"@oh-my-pi/hashline": "15.5.
|
|
51
|
-
"@oh-my-pi/omp-stats": "15.5.
|
|
52
|
-
"@oh-my-pi/pi-agent-core": "15.5.
|
|
53
|
-
"@oh-my-pi/pi-ai": "15.5.
|
|
54
|
-
"@oh-my-pi/pi-natives": "15.5.
|
|
55
|
-
"@oh-my-pi/pi-tui": "15.5.
|
|
56
|
-
"@oh-my-pi/pi-utils": "15.5.
|
|
50
|
+
"@oh-my-pi/hashline": "15.5.6",
|
|
51
|
+
"@oh-my-pi/omp-stats": "15.5.6",
|
|
52
|
+
"@oh-my-pi/pi-agent-core": "15.5.6",
|
|
53
|
+
"@oh-my-pi/pi-ai": "15.5.6",
|
|
54
|
+
"@oh-my-pi/pi-natives": "15.5.6",
|
|
55
|
+
"@oh-my-pi/pi-tui": "15.5.6",
|
|
56
|
+
"@oh-my-pi/pi-utils": "15.5.6",
|
|
57
57
|
"@puppeteer/browsers": "^2.13.0",
|
|
58
58
|
"@types/turndown": "5.0.6",
|
|
59
59
|
"@xterm/headless": "^6.0.0",
|
package/scripts/build-binary.ts
CHANGED
|
@@ -56,6 +56,17 @@ async function main(): Promise<void> {
|
|
|
56
56
|
"../stats/src/sync-worker.ts",
|
|
57
57
|
"./src/tools/browser/tab-worker-entry.ts",
|
|
58
58
|
"./src/eval/js/worker-entry.ts",
|
|
59
|
+
// Legacy pi-* extension compat shims served by `legacy-pi-compat.ts`.
|
|
60
|
+
// Both are reached only via the computed `TYPEBOX_SHIM_PATH` /
|
|
61
|
+
// `LEGACY_PI_AI_SHIM_PATH` constants (which `--compile`'s static
|
|
62
|
+
// analyzer cannot trace), so each shim must be listed here to land
|
|
63
|
+
// in bunfs alongside the workers above. The bunfs entry path is
|
|
64
|
+
// `--root`-relative with a `.js` extension, e.g.
|
|
65
|
+
// `/$bunfs/root/packages/coding-agent/src/extensibility/typebox.js`,
|
|
66
|
+
// which is what the `isCompiledBinary()` branch in
|
|
67
|
+
// `legacy-pi-compat.ts` resolves to at runtime.
|
|
68
|
+
"./src/extensibility/typebox.ts",
|
|
69
|
+
"./src/extensibility/legacy-pi-ai-shim.ts",
|
|
59
70
|
"--outfile",
|
|
60
71
|
"dist/omp",
|
|
61
72
|
],
|
package/src/edit/diff.ts
CHANGED
|
@@ -58,7 +58,7 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten
|
|
|
58
58
|
* Generate a unified diff string with line numbers and context.
|
|
59
59
|
* Returns both the diff string and the first changed line number (in the new file).
|
|
60
60
|
*/
|
|
61
|
-
export function generateDiffString(oldContent: string, newContent: string, contextLines =
|
|
61
|
+
export function generateDiffString(oldContent: string, newContent: string, contextLines = 2): DiffResult {
|
|
62
62
|
const parts = Diff.diffLines(oldContent, newContent);
|
|
63
63
|
const output: string[] = [];
|
|
64
64
|
|
|
@@ -119,8 +119,9 @@ export function generateDiffString(oldContent: string, newContent: string, conte
|
|
|
119
119
|
linesToShow = raw.slice(0, contextLimit);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Leading-skip placeholder is omitted: the first emitted line's
|
|
123
|
+
// number already conveys that earlier lines were trimmed.
|
|
122
124
|
if (leadingSkip > 0) {
|
|
123
|
-
output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
|
|
124
125
|
oldLineNum += leadingSkip;
|
|
125
126
|
newLineNum += leadingSkip;
|
|
126
127
|
}
|
|
@@ -143,8 +144,9 @@ export function generateDiffString(oldContent: string, newContent: string, conte
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
// Trailing-skip placeholder is omitted for the same reason: the
|
|
148
|
+
// final emitted line's number tells the reader the file continues.
|
|
146
149
|
if (trailingSkip > 0) {
|
|
147
|
-
output.push(formatNumberedDiffLine(" ", oldLineNum, "..."));
|
|
148
150
|
oldLineNum += trailingSkip;
|
|
149
151
|
newLineNum += trailingSkip;
|
|
150
152
|
}
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
* and no auto-generated-file refusal — those belong on the write path.
|
|
11
11
|
*/
|
|
12
12
|
import {
|
|
13
|
-
applyEdits,
|
|
14
13
|
computeFileHash,
|
|
15
14
|
Patch as HashlinePatch,
|
|
16
15
|
normalizeToLF,
|
|
@@ -24,6 +23,12 @@ import { readEditFileText } from "../read-file";
|
|
|
24
23
|
|
|
25
24
|
export interface HashlineDiffOptions {
|
|
26
25
|
autoDropPureInsertDuplicates?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Use the streaming-tolerant applier ({@link PatchSection.applyPartialTo})
|
|
28
|
+
* so trailing in-flight ops do not throw or emit phantom edits. Streaming
|
|
29
|
+
* preview path only.
|
|
30
|
+
*/
|
|
31
|
+
streaming?: boolean;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
async function readSectionText(absolutePath: string, sectionPath: string): Promise<string> {
|
|
@@ -62,7 +67,9 @@ export async function computeHashlineSectionDiff(
|
|
|
62
67
|
const normalized = normalizeToLF(content);
|
|
63
68
|
const hashError = validateSectionHash(section, normalized);
|
|
64
69
|
if (hashError) return { error: hashError };
|
|
65
|
-
const result =
|
|
70
|
+
const result = options.streaming
|
|
71
|
+
? section.applyPartialTo(normalized, options)
|
|
72
|
+
: section.applyTo(normalized, options);
|
|
66
73
|
if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
|
|
67
74
|
return generateDiffString(normalized, result.text);
|
|
68
75
|
} catch (err) {
|
|
@@ -71,13 +78,13 @@ export async function computeHashlineSectionDiff(
|
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
export async function computeHashlineDiff(
|
|
74
|
-
input: { input: string
|
|
81
|
+
input: { input: string },
|
|
75
82
|
cwd: string,
|
|
76
83
|
options: HashlineDiffOptions = {},
|
|
77
84
|
): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
|
|
78
85
|
let patch: Patch;
|
|
79
86
|
try {
|
|
80
|
-
patch = HashlinePatch.parse(input.input, { cwd
|
|
87
|
+
patch = HashlinePatch.parse(input.input, { cwd });
|
|
81
88
|
} catch (err) {
|
|
82
89
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
83
90
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Coding-agent runner that drives the hashline {@link Patcher} on behalf of
|
|
3
|
-
* the `edit` tool. Converts a `{input
|
|
3
|
+
* the `edit` tool. Converts a `{input}` tool-call payload into a
|
|
4
4
|
* fully-applied patch, wraps the result in the agent's
|
|
5
5
|
* {@link AgentToolResult} shape, and attaches LSP diagnostics + `outputMeta`
|
|
6
6
|
* for the renderer.
|
|
@@ -31,7 +31,6 @@ import { type HashlineParams, hashlineEditParamsSchema } from "./params";
|
|
|
31
31
|
export interface ExecuteHashlineSingleOptions {
|
|
32
32
|
session: ToolSession;
|
|
33
33
|
input: string;
|
|
34
|
-
path?: string;
|
|
35
34
|
signal?: AbortSignal;
|
|
36
35
|
batchRequest?: LspBatchRequest;
|
|
37
36
|
writethrough: WritethroughCallback;
|
|
@@ -91,16 +90,10 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
|
|
|
91
90
|
|
|
92
91
|
const warningsBlock = result.warnings.length > 0 ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
|
|
93
92
|
const previewBlock = preview.preview ? `\n${preview.preview}` : "";
|
|
94
|
-
const headline = preview.preview
|
|
95
|
-
? `${result.path}:`
|
|
96
|
-
: result.op === "create"
|
|
97
|
-
? `Created ${result.path}`
|
|
98
|
-
: `Updated ${result.path}`;
|
|
99
|
-
|
|
100
93
|
const firstChangedLine = result.firstChangedLine ?? diff.firstChangedLine;
|
|
101
94
|
return {
|
|
102
95
|
toolResult: {
|
|
103
|
-
content: [{ type: "text", text: `${
|
|
96
|
+
content: [{ type: "text", text: `${result.header}${previewBlock}${warningsBlock}` }],
|
|
104
97
|
details: {
|
|
105
98
|
diff: diff.diff,
|
|
106
99
|
firstChangedLine,
|
|
@@ -122,7 +115,7 @@ function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsR
|
|
|
122
115
|
export async function executeHashlineSingle(
|
|
123
116
|
options: ExecuteHashlineSingleOptions,
|
|
124
117
|
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
|
125
|
-
const patch = Patch.parse(options.input, { cwd: options.session.cwd
|
|
118
|
+
const patch = Patch.parse(options.input, { cwd: options.session.cwd });
|
|
126
119
|
if (patch.sections.length === 0) {
|
|
127
120
|
throw new Error("No hashline sections found in input.");
|
|
128
121
|
}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Zod schema for the `edit` tool's hashline mode payload. The schema is
|
|
3
3
|
* deliberately permissive (`.passthrough()`) so providers can attach extra
|
|
4
|
-
* keys without rejection; only `input` is required
|
|
5
|
-
*
|
|
4
|
+
* keys without rejection; only `input` is required. `_input` is accepted as a
|
|
5
|
+
* provider-emitted alias for `input`.
|
|
6
6
|
*/
|
|
7
7
|
import * as z from "zod/v4";
|
|
8
8
|
|
|
9
|
-
export const hashlineEditParamsSchema = z.
|
|
9
|
+
export const hashlineEditParamsSchema = z.preprocess(raw => {
|
|
10
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
|
11
|
+
|
|
12
|
+
const record = raw as Record<string, unknown>;
|
|
13
|
+
if (typeof record.input === "string" || typeof record._input !== "string") return raw;
|
|
14
|
+
|
|
15
|
+
return { ...record, input: record._input };
|
|
16
|
+
}, z.object({ input: z.string() }).passthrough());
|
|
10
17
|
|
|
11
18
|
export type HashlineParams = z.infer<typeof hashlineEditParamsSchema>;
|
package/src/edit/index.ts
CHANGED
|
@@ -262,19 +262,17 @@ async function executeSinglePathEntries(
|
|
|
262
262
|
|
|
263
263
|
function extractApprovalPath(args: unknown): string {
|
|
264
264
|
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
|
265
|
-
const targetPath = record.path;
|
|
266
|
-
if (typeof targetPath === "string" && targetPath.length > 0) {
|
|
267
|
-
return targetPath;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
265
|
const input = typeof record.input === "string" ? record.input : undefined;
|
|
271
|
-
if (
|
|
266
|
+
if (input) {
|
|
267
|
+
const hashlineMatch = /^(?:¶|§|@)([^\s#]+)/m.exec(input);
|
|
268
|
+
if (hashlineMatch?.[1]) return hashlineMatch[1];
|
|
272
269
|
|
|
273
|
-
|
|
274
|
-
|
|
270
|
+
const applyPatchMatch = /^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/m.exec(input);
|
|
271
|
+
if (applyPatchMatch?.[1]) return applyPatchMatch[1].trim();
|
|
272
|
+
}
|
|
275
273
|
|
|
276
|
-
const
|
|
277
|
-
return
|
|
274
|
+
const targetPath = record.path;
|
|
275
|
+
return typeof targetPath === "string" && targetPath.length > 0 ? targetPath : "(unknown)";
|
|
278
276
|
}
|
|
279
277
|
|
|
280
278
|
export class EditTool implements AgentTool<TInput> {
|
|
@@ -430,11 +428,10 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
430
428
|
batchRequest: LspBatchRequest | undefined,
|
|
431
429
|
_onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
|
|
432
430
|
) => {
|
|
433
|
-
const { input
|
|
431
|
+
const { input } = params as HashlineParams;
|
|
434
432
|
return executeHashlineSingle({
|
|
435
433
|
session: tool.session,
|
|
436
434
|
input,
|
|
437
|
-
path,
|
|
438
435
|
signal,
|
|
439
436
|
batchRequest,
|
|
440
437
|
writethrough: tool.#writethrough,
|
package/src/edit/renderer.ts
CHANGED
|
@@ -235,14 +235,21 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string)
|
|
|
235
235
|
|
|
236
236
|
function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
|
|
237
237
|
if (!diff) return "";
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
238
|
+
// Hunk-aware truncation keeps the change rows themselves visible and
|
|
239
|
+
// trims surrounding context proportionally so a multi-hunk diff doesn't
|
|
240
|
+
// turn into just the tail of the last hunk while streaming.
|
|
241
|
+
const {
|
|
242
|
+
text: truncatedDiff,
|
|
243
|
+
hiddenHunks,
|
|
244
|
+
hiddenLines,
|
|
245
|
+
} = truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, EDIT_STREAMING_PREVIEW_LINES);
|
|
242
246
|
let text = "\n\n";
|
|
243
|
-
text += renderDiffColored(
|
|
244
|
-
if (
|
|
245
|
-
|
|
247
|
+
text += renderDiffColored(truncatedDiff, { filePath: rawPath });
|
|
248
|
+
if (hiddenHunks > 0 || hiddenLines > 0) {
|
|
249
|
+
const remainder: string[] = [];
|
|
250
|
+
if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
|
|
251
|
+
if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
|
|
252
|
+
text += uiTheme.fg("dim", `\n… (${label} +${remainder.join(", ")})`);
|
|
246
253
|
} else {
|
|
247
254
|
text += uiTheme.fg("dim", `\n(${label})`);
|
|
248
255
|
}
|
package/src/edit/streaming.ts
CHANGED
|
@@ -20,11 +20,8 @@ import {
|
|
|
20
20
|
END_PATCH_MARKER,
|
|
21
21
|
type PatchSection as HashlineInputSection,
|
|
22
22
|
Patch as HashlinePatch,
|
|
23
|
-
Tokenizer as HashlineTokenizer,
|
|
24
23
|
} from "@oh-my-pi/hashline";
|
|
25
|
-
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
26
24
|
import type { Theme } from "../modes/theme/theme";
|
|
27
|
-
import { replaceTabs, truncateToWidth } from "../tools/render-utils";
|
|
28
25
|
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
|
|
29
26
|
import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
|
|
30
27
|
import { computeHashlineDiff, computeHashlineSectionDiff } from "./hashline/diff";
|
|
@@ -73,48 +70,6 @@ export interface EditStreamingStrategy<Args = unknown> {
|
|
|
73
70
|
renderStreamingFallback(args: Args, uiTheme: Theme): string;
|
|
74
71
|
}
|
|
75
72
|
|
|
76
|
-
const STREAMING_FALLBACK_LINES = 12;
|
|
77
|
-
const STREAMING_FALLBACK_WIDTH = 80;
|
|
78
|
-
|
|
79
|
-
// Streaming-preview classification reuses one tokenizer instance for the
|
|
80
|
-
// stateless predicates and `tokenize`/`tokenizeAll` helpers; instances are
|
|
81
|
-
// cheap, but keeping a single module-level reference matches the rest of
|
|
82
|
-
// the hashline package.
|
|
83
|
-
const HASHLINE_TOKENIZER = new HashlineTokenizer();
|
|
84
|
-
|
|
85
|
-
function trimHashlineStreamingSyntax(lines: string[]): string[] {
|
|
86
|
-
let index = lines.findIndex(line => line.trim().length > 0);
|
|
87
|
-
if (index === -1) return [];
|
|
88
|
-
|
|
89
|
-
if (HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "envelope-begin") {
|
|
90
|
-
index++;
|
|
91
|
-
while (index < lines.length && lines[index].trim().length === 0) index++;
|
|
92
|
-
}
|
|
93
|
-
if (index < lines.length && HASHLINE_TOKENIZER.tokenize(lines[index]).kind === "header") {
|
|
94
|
-
index++;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return lines.slice(index).filter(line => !HASHLINE_TOKENIZER.isEnvelopeMarker(line));
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function renderHashlineInputFallback(input: string, uiTheme: Theme): string {
|
|
101
|
-
const lines = trimHashlineStreamingSyntax(sanitizeText(input).split("\n"));
|
|
102
|
-
if (!lines.some(line => line.trim().length > 0)) return "";
|
|
103
|
-
|
|
104
|
-
const displayLines = lines.slice(-STREAMING_FALLBACK_LINES);
|
|
105
|
-
const hidden = lines.length - displayLines.length;
|
|
106
|
-
let text = "\n\n";
|
|
107
|
-
text += displayLines
|
|
108
|
-
.map(line => uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), STREAMING_FALLBACK_WIDTH)))
|
|
109
|
-
.join("\n");
|
|
110
|
-
if (hidden > 0) {
|
|
111
|
-
text += uiTheme.fg("dim", `\n… (streaming +${hidden} lines)`);
|
|
112
|
-
} else {
|
|
113
|
-
text += uiTheme.fg("dim", "\n(streaming)");
|
|
114
|
-
}
|
|
115
|
-
return text;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
73
|
// -----------------------------------------------------------------------------
|
|
119
74
|
// Partial-JSON handling
|
|
120
75
|
// -----------------------------------------------------------------------------
|
|
@@ -273,7 +228,6 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
|
|
|
273
228
|
|
|
274
229
|
interface HashlineArgs {
|
|
275
230
|
input?: string;
|
|
276
|
-
path?: string;
|
|
277
231
|
__partialJson?: string;
|
|
278
232
|
}
|
|
279
233
|
|
|
@@ -353,75 +307,6 @@ function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[
|
|
|
353
307
|
return previews.length > 0 ? previews : null;
|
|
354
308
|
}
|
|
355
309
|
|
|
356
|
-
/**
|
|
357
|
-
* Hashline equivalent: emit each payload line as a `+added` line in the
|
|
358
|
-
* order the model typed it. We deliberately omit op headers and removal
|
|
359
|
-
* targets from the streaming preview because their content lives in the file
|
|
360
|
-
* and would require a costly re-apply per tick; the complete unified diff is
|
|
361
|
-
* shown once streaming finishes.
|
|
362
|
-
*/
|
|
363
|
-
function buildHashlineNaturalOrderPreviews(
|
|
364
|
-
input: string,
|
|
365
|
-
defaultPath: string | undefined,
|
|
366
|
-
): PerFileDiffPreview[] | null {
|
|
367
|
-
const groups = new Map<string, string[]>();
|
|
368
|
-
let currentPath = defaultPath ?? "";
|
|
369
|
-
const ensure = (sectionPath: string): string[] => {
|
|
370
|
-
let bucket = groups.get(sectionPath);
|
|
371
|
-
if (!bucket) {
|
|
372
|
-
bucket = [];
|
|
373
|
-
groups.set(sectionPath, bucket);
|
|
374
|
-
}
|
|
375
|
-
return bucket;
|
|
376
|
-
};
|
|
377
|
-
|
|
378
|
-
// Per-call instance: the streaming preview re-runs each tick with the
|
|
379
|
-
// cumulative input, and we need the line counter to start at 1. A
|
|
380
|
-
// dedicated tokenizer keeps the shared HASHLINE_TOKENIZER above free
|
|
381
|
-
// for stateless predicate use elsewhere in this module.
|
|
382
|
-
const streamer = new HashlineTokenizer();
|
|
383
|
-
for (const token of streamer.tokenizeAll(input)) {
|
|
384
|
-
switch (token.kind) {
|
|
385
|
-
case "envelope-begin":
|
|
386
|
-
case "envelope-end":
|
|
387
|
-
case "abort":
|
|
388
|
-
case "op-delete":
|
|
389
|
-
continue;
|
|
390
|
-
case "blank":
|
|
391
|
-
case "raw":
|
|
392
|
-
continue;
|
|
393
|
-
case "header":
|
|
394
|
-
currentPath = token.path;
|
|
395
|
-
if (currentPath) ensure(currentPath);
|
|
396
|
-
continue;
|
|
397
|
-
case "op-insert":
|
|
398
|
-
case "op-replace":
|
|
399
|
-
// Inline body on the op line itself (`N↓payload`, `A-B:payload`) is
|
|
400
|
-
// payload content that just happens to share a line with the op
|
|
401
|
-
// header — render it the same as a standalone payload token so
|
|
402
|
-
// the very first character the model types after the sigil shows
|
|
403
|
-
// up in the streaming preview. Without this, the preview is
|
|
404
|
-
// empty until a newline arrives, and the renderer falls back to
|
|
405
|
-
// raw input ("A-B: bla bla bla") instead of "+ bla bla bla".
|
|
406
|
-
if (!currentPath || token.inlineBody === undefined) continue;
|
|
407
|
-
ensure(currentPath).push(`+${token.inlineBody}`);
|
|
408
|
-
continue;
|
|
409
|
-
case "payload":
|
|
410
|
-
if (!currentPath) continue;
|
|
411
|
-
ensure(currentPath).push(`+${token.text}`);
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (groups.size === 0) return null;
|
|
417
|
-
const previews: PerFileDiffPreview[] = [];
|
|
418
|
-
for (const [sectionPath, body] of groups) {
|
|
419
|
-
if (body.length === 0) continue;
|
|
420
|
-
previews.push({ path: sectionPath, diff: body.join("\n") });
|
|
421
|
-
}
|
|
422
|
-
return previews.length > 0 ? previews : null;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
310
|
const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
426
311
|
extractCompleteEdits(args) {
|
|
427
312
|
return args;
|
|
@@ -430,25 +315,21 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
430
315
|
if (typeof args.input !== "string" || args.input.length === 0) return null;
|
|
431
316
|
const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
|
|
432
317
|
if (input.length === 0) return null;
|
|
433
|
-
if (ctx.isStreaming) {
|
|
434
|
-
// Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
|
|
435
|
-
// reordering by showing payload lines in input order.
|
|
436
|
-
return buildHashlineNaturalOrderPreviews(input, args.path);
|
|
437
|
-
}
|
|
438
318
|
ctx.signal.throwIfAborted();
|
|
439
319
|
|
|
440
320
|
let sections: readonly HashlineInputSection[];
|
|
441
321
|
try {
|
|
442
|
-
sections = HashlinePatch.parse(input, { cwd: ctx.cwd
|
|
322
|
+
sections = HashlinePatch.parse(input, { cwd: ctx.cwd }).sections;
|
|
443
323
|
} catch {
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
|
|
324
|
+
// While streaming, the trailing op may still be mid-typed and fail
|
|
325
|
+
// to parse; suppress until the next chunk arrives. Once args are
|
|
326
|
+
// complete, surface the error so the model sees what went wrong.
|
|
327
|
+
if (ctx.isStreaming) return null;
|
|
328
|
+
const result = await computeHashlineDiff({ input }, ctx.cwd, {
|
|
447
329
|
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
|
|
448
330
|
});
|
|
449
331
|
ctx.signal.throwIfAborted();
|
|
450
|
-
|
|
451
|
-
return [toPerFilePreview(args.path ?? "", result)];
|
|
332
|
+
return [toPerFilePreview("", result)];
|
|
452
333
|
}
|
|
453
334
|
if (sections.length === 0) return null;
|
|
454
335
|
|
|
@@ -467,6 +348,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
467
348
|
const section = sectionsToProcess[i];
|
|
468
349
|
const result = await computeHashlineSectionDiff(section, ctx.cwd, {
|
|
469
350
|
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
|
|
351
|
+
streaming: ctx.isStreaming,
|
|
470
352
|
});
|
|
471
353
|
ctx.signal.throwIfAborted();
|
|
472
354
|
// In a multi-section preview, ignore parse/apply errors from the
|
|
@@ -479,8 +361,13 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
|
479
361
|
}
|
|
480
362
|
return previews.length > 0 ? previews : null;
|
|
481
363
|
},
|
|
482
|
-
renderStreamingFallback(
|
|
483
|
-
|
|
364
|
+
renderStreamingFallback() {
|
|
365
|
+
// Never leak raw hashline syntax (`64:`, `|payload`, `¶path#hash`)
|
|
366
|
+
// to the user — the streaming preview already projects every
|
|
367
|
+
// parseable op onto the real file via applyPartialTo, and an
|
|
368
|
+
// unparseable trailing chunk renders as "no preview yet" rather
|
|
369
|
+
// than a sigil dump.
|
|
370
|
+
return "";
|
|
484
371
|
},
|
|
485
372
|
};
|
|
486
373
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compatibility shim for legacy extensions importing the package root of
|
|
3
|
+
* `@oh-my-pi/pi-ai` (or one of its aliased scopes like `@earendil-works/pi-ai`
|
|
4
|
+
* or `@mariozechner/pi-ai`).
|
|
5
|
+
*
|
|
6
|
+
* pi-ai 15.1.0 removed the historical TypeBox root exports (`Type`, plus the
|
|
7
|
+
* runtime-relevant half of the `Static`/`TSchema` pair) from the package
|
|
8
|
+
* entrypoint. Legacy extensions still author parameter schemas as
|
|
9
|
+
* `Type.Object({ ... })`, so this file is served by `legacy-pi-compat.ts` in
|
|
10
|
+
* place of the real pi-ai entrypoint whenever a legacy extension imports the
|
|
11
|
+
* bare package root. Subpath imports (`@oh-my-pi/pi-ai/utils/oauth`, etc.)
|
|
12
|
+
* continue to resolve directly against the bundled pi-ai package.
|
|
13
|
+
*
|
|
14
|
+
* The `Type` runtime is borrowed from the Zod-backed TypeBox shim that
|
|
15
|
+
* already serves bare `@sinclair/typebox` imports for the same extension
|
|
16
|
+
* class, keeping the legacy-compat surface internally consistent.
|
|
17
|
+
*
|
|
18
|
+
* Type-level `Static` and `TSchema` continue to come from pi-ai's own
|
|
19
|
+
* `types.ts` via the `export *` below — pi-ai still exports both as types,
|
|
20
|
+
* only the runtime `Type` builder was removed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export * from "@oh-my-pi/pi-ai";
|
|
24
|
+
export { Type } from "./typebox";
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import * as url from "node:url";
|
|
5
|
+
import { isCompiledBinary } from "@oh-my-pi/pi-utils";
|
|
5
6
|
|
|
6
7
|
// Canonical scope for in-process pi packages. Plugins published against any of
|
|
7
8
|
// the aliased scopes below (mariozechner's original publish, earendil-works'
|
|
@@ -56,7 +57,34 @@ const resolvedSpecifierFallbacks = new Map<string, string>();
|
|
|
56
57
|
// relying on them must vendor `@sinclair/typebox` directly.
|
|
57
58
|
const TYPEBOX_SPECIFIER = "@sinclair/typebox";
|
|
58
59
|
const TYPEBOX_SPECIFIER_FILTER = /^@sinclair\/typebox$/;
|
|
59
|
-
|
|
60
|
+
|
|
61
|
+
// In-process compat shim paths. In dev `import.meta.dir` is the source folder of
|
|
62
|
+
// this file, so the dev branches resolve to the real `.ts` source. In compiled
|
|
63
|
+
// binaries `import.meta.dir` collapses to `/$bunfs/root`, so the runtime cannot
|
|
64
|
+
// recover the source layout that way; instead, each shim file is registered as
|
|
65
|
+
// a `--compile` entrypoint in `scripts/build-binary.ts`, which Bun emits into
|
|
66
|
+
// bunfs at a deterministic `--root`-relative path with a `.js` extension. The
|
|
67
|
+
// literals below must stay in sync with that listing — if either path drifts,
|
|
68
|
+
// every legacy plugin loading the shim fails with a missing-module error in
|
|
69
|
+
// release builds (without affecting `bun test`/dev).
|
|
70
|
+
const TYPEBOX_SHIM_PATH = isCompiledBinary()
|
|
71
|
+
? "/$bunfs/root/packages/coding-agent/src/extensibility/typebox.js"
|
|
72
|
+
: path.resolve(import.meta.dir, "../typebox.ts");
|
|
73
|
+
|
|
74
|
+
// Legacy extensions historically imported `Type` (and `Static`/`TSchema`) from
|
|
75
|
+
// the package root of `@(scope)/pi-ai`. pi-ai 15.1.0 removed the runtime `Type`
|
|
76
|
+
// export (see `packages/ai/CHANGELOG.md`), so the bare canonical specifier no
|
|
77
|
+
// longer satisfies those imports. The override below redirects only the bare
|
|
78
|
+
// pi-ai package root onto a sibling shim that re-exports the canonical surface
|
|
79
|
+
// plus the borrowed `Type` runtime from the Zod-backed TypeBox shim. Subpath
|
|
80
|
+
// imports such as `@oh-my-pi/pi-ai/utils/oauth` continue to resolve directly
|
|
81
|
+
// against the bundled pi-ai package.
|
|
82
|
+
const LEGACY_PI_AI_SHIM_PATH = isCompiledBinary()
|
|
83
|
+
? "/$bunfs/root/packages/coding-agent/src/extensibility/legacy-pi-ai-shim.js"
|
|
84
|
+
: path.resolve(import.meta.dir, "../legacy-pi-ai-shim.ts");
|
|
85
|
+
const LEGACY_PI_PACKAGE_ROOT_OVERRIDES: Record<string, string> = {
|
|
86
|
+
[`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
|
|
87
|
+
};
|
|
60
88
|
|
|
61
89
|
let isLegacyPiSpecifierShimInstalled = false;
|
|
62
90
|
|
|
@@ -85,6 +113,22 @@ function getResolvedSpecifier(specifier: string): string {
|
|
|
85
113
|
return resolved;
|
|
86
114
|
}
|
|
87
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Resolve a canonical `@oh-my-pi/*` specifier to a filesystem path, preferring
|
|
118
|
+
* a bundled compat shim when one is registered for the package root.
|
|
119
|
+
*
|
|
120
|
+
* Falls back to `getResolvedSpecifier` (which may throw under compiled binary
|
|
121
|
+
* mode); callers handle that the same way they would for non-overridden
|
|
122
|
+
* specifiers.
|
|
123
|
+
*/
|
|
124
|
+
function resolveCanonicalPiSpecifier(remappedSpecifier: string): string {
|
|
125
|
+
const override = LEGACY_PI_PACKAGE_ROOT_OVERRIDES[remappedSpecifier];
|
|
126
|
+
if (override) {
|
|
127
|
+
return override;
|
|
128
|
+
}
|
|
129
|
+
return getResolvedSpecifier(remappedSpecifier);
|
|
130
|
+
}
|
|
131
|
+
|
|
88
132
|
function toImportSpecifier(resolvedPath: string): string {
|
|
89
133
|
return url.pathToFileURL(resolvedPath).href;
|
|
90
134
|
}
|
|
@@ -99,7 +143,7 @@ function rewriteLegacyPiImports(source: string): string {
|
|
|
99
143
|
}
|
|
100
144
|
|
|
101
145
|
try {
|
|
102
|
-
return `${prefix}${toImportSpecifier(
|
|
146
|
+
return `${prefix}${toImportSpecifier(resolveCanonicalPiSpecifier(remappedSpecifier))}${suffix}`;
|
|
103
147
|
} catch {
|
|
104
148
|
// Resolution failed — typically in compiled binary mode where
|
|
105
149
|
// Bun.resolveSync cannot walk up from /$bunfs/root to find the
|
|
@@ -250,7 +294,7 @@ function resolveLegacyPiSpecifier(args: { path: string; importer: string }): { p
|
|
|
250
294
|
// Primary: resolve the canonical @oh-my-pi/* specifier from the host binary
|
|
251
295
|
// location. Works in dev mode and in source-link installs.
|
|
252
296
|
try {
|
|
253
|
-
return { path:
|
|
297
|
+
return { path: resolveCanonicalPiSpecifier(remappedSpecifier) };
|
|
254
298
|
} catch {
|
|
255
299
|
// Fallback for compiled binary mode: the bundled packages live inside
|
|
256
300
|
// /$bunfs/root and aren't reachable by filesystem resolution. Try the
|
package/src/main.ts
CHANGED
|
@@ -9,6 +9,7 @@ import * as fs from "node:fs/promises";
|
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { createInterface } from "node:readline/promises";
|
|
12
|
+
import { keepaliveWhile } from "@oh-my-pi/pi-agent-core";
|
|
12
13
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
13
14
|
import {
|
|
14
15
|
$env,
|
|
@@ -315,7 +316,7 @@ async function runInteractiveMode(
|
|
|
315
316
|
}
|
|
316
317
|
|
|
317
318
|
while (true) {
|
|
318
|
-
const input = await mode.getUserInput();
|
|
319
|
+
const input = await keepaliveWhile(mode.getUserInput());
|
|
319
320
|
await submitInteractiveInput(mode, session, input);
|
|
320
321
|
}
|
|
321
322
|
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Spawns the agent in RPC mode and provides a typed API for all operations.
|
|
5
5
|
*/
|
|
6
|
+
|
|
7
|
+
import { isPromise } from "node:util/types";
|
|
6
8
|
import type { AgentEvent, AgentMessage, AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
7
9
|
import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
|
|
8
10
|
import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
|
|
@@ -790,7 +792,7 @@ export class RpcClient {
|
|
|
790
792
|
const stdin = this.#process.stdin as import("bun").FileSink;
|
|
791
793
|
stdin.write(`${JSON.stringify(frame)}\n`);
|
|
792
794
|
const flushResult = stdin.flush();
|
|
793
|
-
if (flushResult
|
|
795
|
+
if (isPromise(flushResult)) {
|
|
794
796
|
flushResult.catch((err: Error) => {
|
|
795
797
|
onError?.(err);
|
|
796
798
|
});
|
|
@@ -5,17 +5,18 @@ Finds files and directories using fast pattern matching that works with any code
|
|
|
5
5
|
- Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`), NEVER as a single comma-joined string (`paths: ["a,b"]` is rejected)
|
|
6
6
|
- `gitignore` defaults to `true` and hides files matched by `.gitignore`. Set `gitignore: false` to find `.env*`, `*.log`, freshly-created build outputs, or anything else your repo ignores
|
|
7
7
|
- `hidden` defaults to `true`; combine with `gitignore: false` to surface dotfiles that are also gitignored
|
|
8
|
+
- `limit` is clamped to 1-200 (default 200). Narrow the pattern instead of raising the limit
|
|
8
9
|
- `timeout` is in seconds (default 5, clamped to 0.5–60). On timeout, find returns whatever partial matches it has collected with `truncated: true` and a notice — increase `timeout` or narrow the pattern instead of retrying blindly
|
|
9
10
|
- You SHOULD perform multiple searches in parallel when potentially useful
|
|
10
11
|
</instruction>
|
|
11
12
|
|
|
12
13
|
<output>
|
|
13
|
-
Matching file and directory paths sorted by modification time (most recent first).
|
|
14
|
+
Matching file and directory paths sorted by modification time (most recent first), grouped by directory to reduce token usage. Each group starts with `# <dir>/` followed by basenames (one per line); directory entries get a trailing `/`. Root-level entries have no header. Truncated at 200 entries or 50KB.
|
|
14
15
|
</output>
|
|
15
16
|
|
|
16
17
|
<examples>
|
|
17
18
|
# Find files
|
|
18
|
-
`{"paths": ["src/**/*.ts"]
|
|
19
|
+
`{"paths": ["src/**/*.ts"]}`
|
|
19
20
|
# Multiple targets — separate array elements
|
|
20
21
|
`{"paths": ["src/**/*.ts", "test/**/*.ts"]}`
|
|
21
22
|
# Find gitignored files like .env
|
|
@@ -17,6 +17,7 @@ import * as crypto from "node:crypto";
|
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
18
|
import * as path from "node:path";
|
|
19
19
|
import { scheduler } from "node:timers/promises";
|
|
20
|
+
import { isPromise } from "node:util/types";
|
|
20
21
|
import {
|
|
21
22
|
type AfterToolCallContext,
|
|
22
23
|
type AfterToolCallResult,
|
|
@@ -1307,10 +1308,25 @@ export class AgentSession {
|
|
|
1307
1308
|
|
|
1308
1309
|
/** Emit an event to all listeners */
|
|
1309
1310
|
#emit(event: AgentSessionEvent): void {
|
|
1310
|
-
// Copy array before iteration to avoid mutation during iteration
|
|
1311
|
+
// Copy array before iteration to avoid mutation during iteration.
|
|
1311
1312
|
const listeners = [...this.#eventListeners];
|
|
1312
1313
|
for (const l of listeners) {
|
|
1313
|
-
|
|
1314
|
+
try {
|
|
1315
|
+
const result = l(event) as unknown;
|
|
1316
|
+
// Listener may be an async function whose returned Promise we don't await;
|
|
1317
|
+
// attach a catch so a rejection does not become an unhandled rejection.
|
|
1318
|
+
if (isPromise(result)) {
|
|
1319
|
+
result.catch(err => {
|
|
1320
|
+
logger.warn("AgentSession listener rejected", {
|
|
1321
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
} catch (err) {
|
|
1326
|
+
logger.warn("AgentSession listener threw", {
|
|
1327
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1314
1330
|
}
|
|
1315
1331
|
}
|
|
1316
1332
|
|
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/read.ts
CHANGED
|
@@ -1488,6 +1488,21 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1488
1488
|
if (!this.session.settings.get("fetch.enabled")) {
|
|
1489
1489
|
throw new ToolError("URL reads are disabled by settings.");
|
|
1490
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
|
+
}
|
|
1491
1506
|
if (parsedUrlTarget.offset !== undefined || parsedUrlTarget.limit !== undefined) {
|
|
1492
1507
|
const cached = await loadReadUrlCacheEntry(
|
|
1493
1508
|
this.session,
|
|
@@ -1502,6 +1517,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1502
1517
|
details: { ...cached.details },
|
|
1503
1518
|
sourceUrl: cached.details.finalUrl,
|
|
1504
1519
|
entityLabel: "URL output",
|
|
1520
|
+
raw: parsedUrlTarget.raw,
|
|
1505
1521
|
immutable: true,
|
|
1506
1522
|
});
|
|
1507
1523
|
}
|
|
@@ -1578,7 +1594,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1578
1594
|
if (isMultiRange(parsed)) {
|
|
1579
1595
|
throw new ToolError("Multi-range line selectors are not supported for directory listings.");
|
|
1580
1596
|
}
|
|
1581
|
-
const
|
|
1597
|
+
const { offset, limit } = selToOffsetLimit(parsed);
|
|
1598
|
+
const dirResult = await this.#readDirectory(absolutePath, offset, limit, signal);
|
|
1582
1599
|
if (suffixResolution) {
|
|
1583
1600
|
dirResult.details ??= {};
|
|
1584
1601
|
dirResult.details.suffixResolution = suffixResolution;
|
|
@@ -2136,6 +2153,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2136
2153
|
/** Read directory contents as a formatted listing */
|
|
2137
2154
|
async #readDirectory(
|
|
2138
2155
|
absolutePath: string,
|
|
2156
|
+
offset: number | undefined,
|
|
2139
2157
|
limit: number | undefined,
|
|
2140
2158
|
signal?: AbortSignal,
|
|
2141
2159
|
): Promise<AgentToolResult<ReadToolDetails>> {
|
|
@@ -2149,7 +2167,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2149
2167
|
maxDepth: READ_DIRECTORY_MAX_DEPTH,
|
|
2150
2168
|
perDirLimit: READ_DIRECTORY_CHILD_LIMIT,
|
|
2151
2169
|
rootLimit: null,
|
|
2152
|
-
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,
|
|
2153
2173
|
});
|
|
2154
2174
|
} catch (error) {
|
|
2155
2175
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -2158,12 +2178,46 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2158
2178
|
throwIfAborted(signal);
|
|
2159
2179
|
|
|
2160
2180
|
const output = tree.totalLines <= 1 ? "(empty directory)" : tree.rendered;
|
|
2161
|
-
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
2162
2181
|
const details: ReadToolDetails = {
|
|
2163
2182
|
isDirectory: true,
|
|
2164
2183
|
resolvedPath: tree.rootPath,
|
|
2165
2184
|
};
|
|
2166
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 });
|
|
2167
2221
|
const resultBuilder = toolResult(details).text(truncation.content).sourcePath(tree.rootPath);
|
|
2168
2222
|
if (tree.truncated) {
|
|
2169
2223
|
resultBuilder.limits({ resultLimit: 1 });
|