@oh-my-pi/pi-coding-agent 6.8.3 → 6.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/package.json +6 -6
- package/src/core/agent-session.ts +6 -13
- package/src/core/bash-executor.ts +7 -64
- package/src/core/extensions/types.ts +0 -3
- package/src/core/index.ts +1 -1
- package/src/core/python-executor.ts +15 -25
- package/src/core/ssh/ssh-executor.ts +8 -13
- package/src/core/streaming-output.ts +64 -150
- package/src/core/tools/bash.ts +10 -44
- package/src/core/tools/index.ts +1 -1
- package/src/core/tools/python.ts +6 -15
- package/src/core/tools/ssh.ts +6 -14
- package/src/core/tools/truncate.ts +92 -0
- package/src/core/tools/web-fetch.ts +6 -4
- package/src/core/tools/web-scrapers/utils.ts +7 -30
- package/src/core/tools/web-search/providers/anthropic.ts +0 -1
- package/src/index.ts +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [6.8.4] - 2026-01-21
|
|
6
|
+
### Changed
|
|
7
|
+
|
|
8
|
+
- Updated output sink to properly handle large outputs
|
|
9
|
+
- Improved error message formatting in SSH executor
|
|
10
|
+
- Updated web fetch timeout bounds and conversion
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Fixed output truncation handling in streaming output
|
|
15
|
+
- Fixed timeout handling in web fetch tool
|
|
16
|
+
- Fixed async stream dumping in executors
|
|
17
|
+
|
|
5
18
|
## [6.8.3] - 2026-01-21
|
|
6
19
|
|
|
7
20
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
3
|
-
"version": "6.8.
|
|
3
|
+
"version": "6.8.4",
|
|
4
4
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"ompConfig": {
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
"prepublishOnly": "bun run generate-template && bun run clean && bun run build"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@oh-my-pi/pi-agent-core": "6.8.
|
|
44
|
-
"@oh-my-pi/pi-ai": "6.8.
|
|
45
|
-
"@oh-my-pi/pi-git-tool": "6.8.
|
|
46
|
-
"@oh-my-pi/pi-tui": "6.8.
|
|
47
|
-
"@oh-my-pi/pi-utils": "6.8.
|
|
43
|
+
"@oh-my-pi/pi-agent-core": "6.8.4",
|
|
44
|
+
"@oh-my-pi/pi-ai": "6.8.4",
|
|
45
|
+
"@oh-my-pi/pi-git-tool": "6.8.4",
|
|
46
|
+
"@oh-my-pi/pi-tui": "6.8.4",
|
|
47
|
+
"@oh-my-pi/pi-utils": "6.8.4",
|
|
48
48
|
"@openai/agents": "^0.3.7",
|
|
49
49
|
"@sinclair/typebox": "^0.34.46",
|
|
50
50
|
"ajv": "^8.17.1",
|
|
@@ -22,7 +22,7 @@ import type { Rule } from "../capability/rule";
|
|
|
22
22
|
import { getAgentDbPath } from "../config";
|
|
23
23
|
import { theme } from "../modes/interactive/theme/theme";
|
|
24
24
|
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
|
|
25
|
-
import { type BashResult, executeBash as executeBashCommand
|
|
25
|
+
import { type BashResult, executeBash as executeBashCommand } from "./bash-executor";
|
|
26
26
|
import {
|
|
27
27
|
type CompactionResult,
|
|
28
28
|
calculateContextTokens,
|
|
@@ -60,7 +60,6 @@ import type { Skill, SkillWarning } from "./skills";
|
|
|
60
60
|
import { expandSlashCommand, type FileSlashCommand } from "./slash-commands";
|
|
61
61
|
import { closeAllConnections } from "./ssh/connection-manager";
|
|
62
62
|
import { unmountAll } from "./ssh/sshfs-mount";
|
|
63
|
-
import type { BashOperations } from "./tools/bash";
|
|
64
63
|
import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "./tools/patch";
|
|
65
64
|
import { resolveToCwd } from "./tools/path-utils";
|
|
66
65
|
import type { TodoItem } from "./tools/todo-write";
|
|
@@ -2546,25 +2545,19 @@ export class AgentSession {
|
|
|
2546
2545
|
* @param command The bash command to execute
|
|
2547
2546
|
* @param onChunk Optional streaming callback for output
|
|
2548
2547
|
* @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
|
|
2549
|
-
* @param options.operations Custom BashOperations for remote execution
|
|
2550
2548
|
*/
|
|
2551
2549
|
async executeBash(
|
|
2552
2550
|
command: string,
|
|
2553
2551
|
onChunk?: (chunk: string) => void,
|
|
2554
|
-
options?: { excludeFromContext?: boolean
|
|
2552
|
+
options?: { excludeFromContext?: boolean },
|
|
2555
2553
|
): Promise<BashResult> {
|
|
2556
2554
|
this._bashAbortController = new AbortController();
|
|
2557
2555
|
|
|
2558
2556
|
try {
|
|
2559
|
-
const result =
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
})
|
|
2564
|
-
: await executeBashCommand(command, {
|
|
2565
|
-
onChunk,
|
|
2566
|
-
signal: this._bashAbortController.signal,
|
|
2567
|
-
});
|
|
2557
|
+
const result = await executeBashCommand(command, {
|
|
2558
|
+
onChunk,
|
|
2559
|
+
signal: this._bashAbortController.signal,
|
|
2560
|
+
});
|
|
2568
2561
|
|
|
2569
2562
|
this.recordBashResult(command, result, options);
|
|
2570
2563
|
return result;
|
|
@@ -8,7 +8,6 @@ import { cspawn, Exception, ptree } from "@oh-my-pi/pi-utils";
|
|
|
8
8
|
import { getShellConfig } from "../utils/shell";
|
|
9
9
|
import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot";
|
|
10
10
|
import { OutputSink } from "./streaming-output";
|
|
11
|
-
import type { BashOperations } from "./tools/bash";
|
|
12
11
|
|
|
13
12
|
export interface BashExecutorOptions {
|
|
14
13
|
cwd?: string;
|
|
@@ -34,7 +33,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
34
33
|
const prefixedCommand = prefix ? `${prefix} ${command}` : command;
|
|
35
34
|
const finalCommand = `${snapshotPrefix}${prefixedCommand}`;
|
|
36
35
|
|
|
37
|
-
const
|
|
36
|
+
const sink = new OutputSink({ onChunk: options?.onChunk });
|
|
38
37
|
|
|
39
38
|
const child = cspawn([shell, ...args, finalCommand], {
|
|
40
39
|
cwd: options?.cwd,
|
|
@@ -45,12 +44,9 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
45
44
|
|
|
46
45
|
// Pump streams - errors during abort/timeout are expected
|
|
47
46
|
// Use preventClose to avoid closing the shared sink when either stream finishes
|
|
48
|
-
await Promise.allSettled([
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
])
|
|
52
|
-
.then(() => stream.close())
|
|
53
|
-
.catch(() => {});
|
|
47
|
+
await Promise.allSettled([child.stdout.pipeTo(sink.createInput()), child.stderr.pipeTo(sink.createInput())]).catch(
|
|
48
|
+
() => {},
|
|
49
|
+
);
|
|
54
50
|
|
|
55
51
|
// Wait for process exit
|
|
56
52
|
try {
|
|
@@ -58,7 +54,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
58
54
|
return {
|
|
59
55
|
exitCode: child.exitCode ?? 0,
|
|
60
56
|
cancelled: false,
|
|
61
|
-
...
|
|
57
|
+
...(await sink.dump()),
|
|
62
58
|
};
|
|
63
59
|
} catch (err) {
|
|
64
60
|
// Exception covers NonZeroExitError, AbortError, TimeoutError
|
|
@@ -71,7 +67,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
71
67
|
return {
|
|
72
68
|
exitCode: undefined,
|
|
73
69
|
cancelled: true,
|
|
74
|
-
...
|
|
70
|
+
...(await sink.dump(annotation)),
|
|
75
71
|
};
|
|
76
72
|
}
|
|
77
73
|
|
|
@@ -79,60 +75,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
79
75
|
return {
|
|
80
76
|
exitCode: err.exitCode,
|
|
81
77
|
cancelled: false,
|
|
82
|
-
...
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
throw err;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export async function executeBashWithOperations(
|
|
91
|
-
command: string,
|
|
92
|
-
cwd: string,
|
|
93
|
-
operations: BashOperations,
|
|
94
|
-
options?: BashExecutorOptions,
|
|
95
|
-
): Promise<BashResult> {
|
|
96
|
-
const stream = new OutputSink({ onChunk: options?.onChunk });
|
|
97
|
-
const writable = stream.createWritable();
|
|
98
|
-
const writer = writable.getWriter();
|
|
99
|
-
|
|
100
|
-
const closeStreams = async () => {
|
|
101
|
-
try {
|
|
102
|
-
await writer.close();
|
|
103
|
-
} catch {}
|
|
104
|
-
try {
|
|
105
|
-
await writable.close();
|
|
106
|
-
} catch {}
|
|
107
|
-
try {
|
|
108
|
-
await stream.close();
|
|
109
|
-
} catch {}
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
const result = await operations.exec(command, cwd, {
|
|
114
|
-
onData: (data) => writer.write(data),
|
|
115
|
-
signal: options?.signal,
|
|
116
|
-
timeout: options?.timeout,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
await closeStreams();
|
|
120
|
-
|
|
121
|
-
const cancelled = options?.signal?.aborted ?? false;
|
|
122
|
-
|
|
123
|
-
return {
|
|
124
|
-
exitCode: cancelled ? undefined : (result.exitCode ?? undefined),
|
|
125
|
-
cancelled,
|
|
126
|
-
...stream.dump(),
|
|
127
|
-
};
|
|
128
|
-
} catch (err) {
|
|
129
|
-
await closeStreams();
|
|
130
|
-
|
|
131
|
-
if (options?.signal?.aborted) {
|
|
132
|
-
return {
|
|
133
|
-
exitCode: undefined,
|
|
134
|
-
cancelled: true,
|
|
135
|
-
...stream.dump(),
|
|
78
|
+
...(await sink.dump()),
|
|
136
79
|
};
|
|
137
80
|
}
|
|
138
81
|
|
|
@@ -29,7 +29,6 @@ import type {
|
|
|
29
29
|
SessionManager,
|
|
30
30
|
} from "../session-manager";
|
|
31
31
|
import type { BashToolDetails, FindToolDetails, GrepToolDetails, LsToolDetails, ReadToolDetails } from "../tools";
|
|
32
|
-
import type { BashOperations } from "../tools/bash";
|
|
33
32
|
import type { EditToolDetails } from "../tools/patch";
|
|
34
33
|
|
|
35
34
|
export type { ExecOptions, ExecResult } from "../exec";
|
|
@@ -551,8 +550,6 @@ export interface InputEventResult {
|
|
|
551
550
|
|
|
552
551
|
/** Result from user_bash event handler */
|
|
553
552
|
export interface UserBashEventResult {
|
|
554
|
-
/** Custom operations to use for execution */
|
|
555
|
-
operations?: BashOperations;
|
|
556
553
|
/** Full replacement: extension handled execution, use this result */
|
|
557
554
|
result?: BashResult;
|
|
558
555
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export {
|
|
|
11
11
|
type PromptOptions,
|
|
12
12
|
type SessionStats,
|
|
13
13
|
} from "./agent-session";
|
|
14
|
-
export { type BashExecutorOptions, type BashResult, executeBash
|
|
14
|
+
export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor";
|
|
15
15
|
export type { CompactionResult } from "./compaction/index";
|
|
16
16
|
export {
|
|
17
17
|
discoverAndLoadExtensions,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { logger
|
|
1
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import {
|
|
3
3
|
checkPythonKernelAvailability,
|
|
4
4
|
type KernelDisplayOutput,
|
|
@@ -210,30 +210,20 @@ async function executeWithKernel(
|
|
|
210
210
|
code: string,
|
|
211
211
|
options: PythonExecutorOptions | undefined,
|
|
212
212
|
): Promise<PythonResult> {
|
|
213
|
-
const sink = new OutputSink({
|
|
213
|
+
const sink = new OutputSink({ onChunk: options?.onChunk });
|
|
214
214
|
const displayOutputs: KernelDisplayOutput[] = [];
|
|
215
215
|
|
|
216
216
|
try {
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
onDisplay: (output) => {
|
|
228
|
-
displayOutputs.push(output);
|
|
229
|
-
},
|
|
230
|
-
});
|
|
231
|
-
} catch (err) {
|
|
232
|
-
await writer.abort(err);
|
|
233
|
-
throw err;
|
|
234
|
-
} finally {
|
|
235
|
-
await writer.close().catch(() => {});
|
|
236
|
-
}
|
|
217
|
+
const result = await kernel.execute(code, {
|
|
218
|
+
signal: options?.signal,
|
|
219
|
+
timeoutMs: options?.timeout,
|
|
220
|
+
onChunk: (text) => {
|
|
221
|
+
sink.push(text);
|
|
222
|
+
},
|
|
223
|
+
onDisplay: (output) => {
|
|
224
|
+
displayOutputs.push(output);
|
|
225
|
+
},
|
|
226
|
+
});
|
|
237
227
|
|
|
238
228
|
if (result.cancelled) {
|
|
239
229
|
const secs = options?.timeout ? Math.round(options.timeout / 1000) : undefined;
|
|
@@ -244,7 +234,7 @@ async function executeWithKernel(
|
|
|
244
234
|
cancelled: true,
|
|
245
235
|
displayOutputs,
|
|
246
236
|
stdinRequested: result.stdinRequested,
|
|
247
|
-
...sink.dump(annotation),
|
|
237
|
+
...(await sink.dump(annotation)),
|
|
248
238
|
};
|
|
249
239
|
}
|
|
250
240
|
|
|
@@ -254,7 +244,7 @@ async function executeWithKernel(
|
|
|
254
244
|
cancelled: false,
|
|
255
245
|
displayOutputs,
|
|
256
246
|
stdinRequested: true,
|
|
257
|
-
...sink.dump("Kernel requested stdin; interactive input is not supported."),
|
|
247
|
+
...(await sink.dump("Kernel requested stdin; interactive input is not supported.")),
|
|
258
248
|
};
|
|
259
249
|
}
|
|
260
250
|
|
|
@@ -264,7 +254,7 @@ async function executeWithKernel(
|
|
|
264
254
|
cancelled: false,
|
|
265
255
|
displayOutputs,
|
|
266
256
|
stdinRequested: false,
|
|
267
|
-
...sink.dump(),
|
|
257
|
+
...(await sink.dump()),
|
|
268
258
|
};
|
|
269
259
|
} catch (err) {
|
|
270
260
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -70,16 +70,11 @@ export async function executeSSH(
|
|
|
70
70
|
timeout: options?.timeout,
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
const sink = new OutputSink({
|
|
73
|
+
const sink = new OutputSink({ onChunk: options?.onChunk });
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
child.stderr.pipeTo(sink.createWritable()),
|
|
79
|
-
]);
|
|
80
|
-
} finally {
|
|
81
|
-
await sink.close();
|
|
82
|
-
}
|
|
75
|
+
await Promise.allSettled([child.stdout.pipeTo(sink.createInput()), child.stderr.pipeTo(sink.createInput())]).catch(
|
|
76
|
+
() => {},
|
|
77
|
+
);
|
|
83
78
|
|
|
84
79
|
try {
|
|
85
80
|
await child.exited;
|
|
@@ -87,7 +82,7 @@ export async function executeSSH(
|
|
|
87
82
|
return {
|
|
88
83
|
exitCode,
|
|
89
84
|
cancelled: false,
|
|
90
|
-
...sink.dump(),
|
|
85
|
+
...(await sink.dump()),
|
|
91
86
|
};
|
|
92
87
|
} catch (err) {
|
|
93
88
|
if (err instanceof ptree.Exception) {
|
|
@@ -95,20 +90,20 @@ export async function executeSSH(
|
|
|
95
90
|
return {
|
|
96
91
|
exitCode: undefined,
|
|
97
92
|
cancelled: true,
|
|
98
|
-
...sink.dump(`SSH: ${err.message}`),
|
|
93
|
+
...(await sink.dump(`SSH: ${err.message}`)),
|
|
99
94
|
};
|
|
100
95
|
}
|
|
101
96
|
if (err.aborted) {
|
|
102
97
|
return {
|
|
103
98
|
exitCode: undefined,
|
|
104
99
|
cancelled: true,
|
|
105
|
-
...sink.dump(`
|
|
100
|
+
...(await sink.dump(`Command aborted: ${err.message}`)),
|
|
106
101
|
};
|
|
107
102
|
}
|
|
108
103
|
return {
|
|
109
104
|
exitCode: err.exitCode,
|
|
110
105
|
cancelled: false,
|
|
111
|
-
...sink.dump(`Unexpected error: ${err.message}`),
|
|
106
|
+
...(await sink.dump(`Unexpected error: ${err.message}`)),
|
|
112
107
|
};
|
|
113
108
|
}
|
|
114
109
|
throw err;
|
|
@@ -2,7 +2,7 @@ import { tmpdir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
4
4
|
import { nanoid } from "nanoid";
|
|
5
|
-
import { DEFAULT_MAX_BYTES
|
|
5
|
+
import { DEFAULT_MAX_BYTES } from "./tools/truncate";
|
|
6
6
|
|
|
7
7
|
export interface OutputResult {
|
|
8
8
|
output: string;
|
|
@@ -14,7 +14,6 @@ export interface OutputSinkOptions {
|
|
|
14
14
|
allocateFilePath?: () => string;
|
|
15
15
|
spillThreshold?: number;
|
|
16
16
|
maxColumn?: number;
|
|
17
|
-
onLine?: (line: string) => void;
|
|
18
17
|
onChunk?: (chunk: string) => void;
|
|
19
18
|
}
|
|
20
19
|
|
|
@@ -29,182 +28,97 @@ function defaultFilePathAllocator(): string {
|
|
|
29
28
|
* When memory limit exceeded, spills ~half to file in one batch operation.
|
|
30
29
|
*/
|
|
31
30
|
export class OutputSink {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
31
|
+
#buffer = "";
|
|
32
|
+
#file?: {
|
|
33
|
+
path: string;
|
|
34
|
+
sink: Bun.FileSink;
|
|
35
|
+
};
|
|
36
|
+
#bytesWritten: number = 0;
|
|
37
|
+
#pending: Promise<void> = Promise.resolve();
|
|
38
|
+
|
|
39
|
+
readonly #allocateFilePath: () => string;
|
|
40
|
+
readonly #spillThreshold: number;
|
|
41
|
+
readonly #onChunk?: (chunk: string) => void;
|
|
43
42
|
|
|
44
43
|
constructor(options?: OutputSinkOptions) {
|
|
45
44
|
const {
|
|
46
45
|
allocateFilePath = defaultFilePathAllocator,
|
|
47
46
|
spillThreshold = DEFAULT_MAX_BYTES,
|
|
48
|
-
maxColumn = DEFAULT_MAX_COLUMN,
|
|
49
|
-
onLine,
|
|
50
47
|
onChunk,
|
|
51
48
|
} = options ?? {};
|
|
52
49
|
|
|
53
|
-
this
|
|
54
|
-
this
|
|
55
|
-
this
|
|
56
|
-
this.onLine = onLine;
|
|
57
|
-
this.onChunk = onChunk;
|
|
50
|
+
this.#allocateFilePath = allocateFilePath;
|
|
51
|
+
this.#spillThreshold = spillThreshold;
|
|
52
|
+
this.#onChunk = onChunk;
|
|
58
53
|
}
|
|
59
54
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
55
|
+
async #pushSanitized(data: string): Promise<void> {
|
|
56
|
+
this.#onChunk?.(data);
|
|
57
|
+
const dataBytes = Buffer.byteLength(data);
|
|
58
|
+
const overflow = dataBytes + this.#bytesWritten > this.#spillThreshold || this.#file != null;
|
|
65
59
|
|
|
66
|
-
this
|
|
67
|
-
if (term) {
|
|
68
|
-
this.buffer += term;
|
|
69
|
-
}
|
|
60
|
+
const sink = overflow ? await this.#fileSink() : null;
|
|
70
61
|
|
|
71
|
-
this
|
|
72
|
-
|
|
62
|
+
this.#buffer += data;
|
|
63
|
+
await sink?.write(data);
|
|
73
64
|
|
|
74
|
-
if (this
|
|
75
|
-
this.
|
|
65
|
+
if (this.#buffer.length > this.#spillThreshold) {
|
|
66
|
+
this.#buffer = this.#buffer.slice(-this.#spillThreshold);
|
|
76
67
|
}
|
|
77
68
|
}
|
|
78
69
|
|
|
79
|
-
|
|
80
|
-
this
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this.filePath = filePath;
|
|
88
|
-
this.fileSink = Bun.file(filePath).writer();
|
|
70
|
+
async #fileSink(): Promise<Bun.FileSink> {
|
|
71
|
+
if (!this.#file) {
|
|
72
|
+
const filePath = this.#allocateFilePath();
|
|
73
|
+
this.#file = {
|
|
74
|
+
path: filePath,
|
|
75
|
+
sink: Bun.file(filePath).writer(),
|
|
76
|
+
};
|
|
77
|
+
await this.#file.sink.write(this.#buffer);
|
|
89
78
|
}
|
|
90
|
-
return this.
|
|
79
|
+
return this.#file.sink;
|
|
91
80
|
}
|
|
92
81
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
let hi = this.lineEnds.length;
|
|
99
|
-
while (lo < hi) {
|
|
100
|
-
const mid = (lo + hi) >>> 1;
|
|
101
|
-
if (this.lineEnds[mid] < target) {
|
|
102
|
-
lo = mid + 1;
|
|
103
|
-
} else {
|
|
104
|
-
hi = mid;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Clamp: evict at least 1 line, keep at least 1 line
|
|
109
|
-
const splitIdx = Math.max(1, Math.min(lo, this.lineEnds.length - 1));
|
|
110
|
-
const splitPos = this.lineEnds[splitIdx - 1];
|
|
111
|
-
|
|
112
|
-
// Write evicted portion to file
|
|
113
|
-
this.getFileSink().write(this.buffer.slice(0, splitPos));
|
|
114
|
-
|
|
115
|
-
// Truncate buffer, shift line positions
|
|
116
|
-
this.buffer = this.buffer.slice(splitPos);
|
|
117
|
-
const remaining = this.lineEnds.length - splitIdx;
|
|
118
|
-
for (let i = 0; i < remaining; i++) {
|
|
119
|
-
this.lineEnds[i] = this.lineEnds[i + splitIdx] - splitPos;
|
|
120
|
-
}
|
|
121
|
-
this.lineEnds.length = remaining;
|
|
82
|
+
async push(chunk: string): Promise<void> {
|
|
83
|
+
chunk = sanitizeText(chunk);
|
|
84
|
+
const op = this.#pending.then(() => this.#pushSanitized(chunk));
|
|
85
|
+
this.#pending = op.catch(() => {});
|
|
86
|
+
await op;
|
|
122
87
|
}
|
|
123
88
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
let
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
buf = buf.trimEnd();
|
|
143
|
-
if (buf) {
|
|
144
|
-
this.pushChunk(`${buf}\n`);
|
|
145
|
-
}
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
return new WritableStream<Uint8Array>({
|
|
149
|
-
write: (chunk) => {
|
|
150
|
-
buf += sanitizeText(decoder.decode(chunk, { stream: true }));
|
|
151
|
-
flushLines();
|
|
89
|
+
createInput(): WritableStream<Uint8Array | string> {
|
|
90
|
+
let decoder: TextDecoder | undefined;
|
|
91
|
+
let finalize = async () => {};
|
|
92
|
+
|
|
93
|
+
return new WritableStream<Uint8Array | string>({
|
|
94
|
+
write: async (chunk) => {
|
|
95
|
+
if (typeof chunk === "string") {
|
|
96
|
+
await this.push(chunk);
|
|
97
|
+
} else {
|
|
98
|
+
if (!decoder) {
|
|
99
|
+
const dec = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
100
|
+
decoder = dec;
|
|
101
|
+
finalize = async () => {
|
|
102
|
+
await this.push(dec.decode());
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
await this.push(decoder.decode(chunk, { stream: true }));
|
|
106
|
+
}
|
|
152
107
|
},
|
|
153
108
|
close: finalize,
|
|
154
109
|
abort: finalize,
|
|
155
110
|
});
|
|
156
111
|
}
|
|
157
112
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const flushLines = () => {
|
|
162
|
-
let start = 0;
|
|
163
|
-
while (true) {
|
|
164
|
-
const nl = buf.indexOf("\n", start);
|
|
165
|
-
if (nl === -1) break;
|
|
166
|
-
this.pushChunk(buf.slice(start, nl + 1));
|
|
167
|
-
start = nl + 1;
|
|
168
|
-
}
|
|
169
|
-
buf = buf.slice(start);
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const finalize = () => {
|
|
173
|
-
flushLines();
|
|
174
|
-
buf = buf.trimEnd();
|
|
175
|
-
if (buf) {
|
|
176
|
-
this.pushChunk(`${buf}\n`);
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
return new WritableStream<string>({
|
|
181
|
-
write: (chunk) => {
|
|
182
|
-
buf += sanitizeText(chunk);
|
|
183
|
-
flushLines();
|
|
184
|
-
},
|
|
185
|
-
close: finalize,
|
|
186
|
-
abort: finalize,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async close(): Promise<void> {
|
|
191
|
-
await this.fileSink?.end();
|
|
192
|
-
}
|
|
113
|
+
async dump(notice?: string): Promise<OutputResult> {
|
|
114
|
+
await this.#pending;
|
|
115
|
+
const noticeLine = notice ? `[${notice}]\n` : "";
|
|
193
116
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (!this.filePath) {
|
|
200
|
-
return { output, truncated: false };
|
|
117
|
+
if (this.#file) {
|
|
118
|
+
await this.#file.sink.end();
|
|
119
|
+
return { output: `${noticeLine}...${this.#buffer}`, truncated: true, fullOutputPath: this.#file.path };
|
|
120
|
+
} else {
|
|
121
|
+
return { output: `${noticeLine}${this.#buffer}`, truncated: false };
|
|
201
122
|
}
|
|
202
|
-
this.fileSink!.write(this.buffer);
|
|
203
|
-
this.fileSink!.flush();
|
|
204
|
-
return {
|
|
205
|
-
output,
|
|
206
|
-
truncated: true,
|
|
207
|
-
fullOutputPath: this.filePath,
|
|
208
|
-
};
|
|
209
123
|
}
|
|
210
124
|
}
|
package/src/core/tools/bash.ts
CHANGED
|
@@ -6,14 +6,14 @@ import { Type } from "@sinclair/typebox";
|
|
|
6
6
|
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate";
|
|
7
7
|
import type { Theme } from "../../modes/interactive/theme/theme";
|
|
8
8
|
import bashDescription from "../../prompts/tools/bash.md" with { type: "text" };
|
|
9
|
-
import { type BashExecutorOptions, executeBash
|
|
9
|
+
import { type BashExecutorOptions, executeBash } from "../bash-executor";
|
|
10
10
|
import type { RenderResultOptions } from "../custom-tools/types";
|
|
11
11
|
import { renderPromptTemplate } from "../prompt-templates";
|
|
12
12
|
import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor";
|
|
13
13
|
import type { ToolSession } from "./index";
|
|
14
14
|
import { resolveToCwd } from "./path-utils";
|
|
15
15
|
import { ToolUIKit } from "./render-utils";
|
|
16
|
-
import {
|
|
16
|
+
import { formatTailTruncationNotice, type TruncationResult, truncateTail } from "./truncate";
|
|
17
17
|
|
|
18
18
|
export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
19
19
|
|
|
@@ -31,32 +31,12 @@ export interface BashToolDetails {
|
|
|
31
31
|
fullOutput?: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
* Pluggable operations for bash execution.
|
|
36
|
-
* Override to delegate command execution to remote systems.
|
|
37
|
-
*/
|
|
38
|
-
export interface BashOperations {
|
|
39
|
-
exec: (
|
|
40
|
-
command: string,
|
|
41
|
-
cwd: string,
|
|
42
|
-
options: {
|
|
43
|
-
onData: (data: Buffer) => void;
|
|
44
|
-
signal?: AbortSignal;
|
|
45
|
-
timeout?: number;
|
|
46
|
-
},
|
|
47
|
-
) => Promise<{ exitCode: number | null }>;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface BashToolOptions {
|
|
51
|
-
/** Custom operations for command execution. Default: local shell */
|
|
52
|
-
operations?: BashOperations;
|
|
53
|
-
}
|
|
34
|
+
export interface BashToolOptions {}
|
|
54
35
|
|
|
55
36
|
/**
|
|
56
37
|
* Bash tool implementation.
|
|
57
38
|
*
|
|
58
39
|
* Executes bash commands with optional timeout and working directory.
|
|
59
|
-
* Supports custom operations for remote execution.
|
|
60
40
|
*/
|
|
61
41
|
export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
|
|
62
42
|
public readonly name = "bash";
|
|
@@ -65,11 +45,9 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
|
|
|
65
45
|
public readonly parameters = bashSchema;
|
|
66
46
|
|
|
67
47
|
private readonly session: ToolSession;
|
|
68
|
-
private readonly options?: BashToolOptions;
|
|
69
48
|
|
|
70
|
-
constructor(session: ToolSession
|
|
49
|
+
constructor(session: ToolSession) {
|
|
71
50
|
this.session = session;
|
|
72
|
-
this.options = options;
|
|
73
51
|
this.description = renderPromptTemplate(bashDescription);
|
|
74
52
|
}
|
|
75
53
|
|
|
@@ -125,12 +103,8 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
|
|
|
125
103
|
},
|
|
126
104
|
};
|
|
127
105
|
|
|
128
|
-
// Use custom operations if provided, otherwise use default local executor
|
|
129
|
-
const result = this.options?.operations
|
|
130
|
-
? await executeBashWithOperations(command, commandCwd, this.options.operations, executorOptions)
|
|
131
|
-
: await executeBash(command, executorOptions);
|
|
132
|
-
|
|
133
106
|
// Handle errors
|
|
107
|
+
const result = await executeBash(command, executorOptions);
|
|
134
108
|
if (result.cancelled) {
|
|
135
109
|
throw new Error(result.output || "Command aborted");
|
|
136
110
|
}
|
|
@@ -147,18 +121,10 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails> {
|
|
|
147
121
|
fullOutputPath: result.fullOutputPath,
|
|
148
122
|
fullOutput: currentOutput,
|
|
149
123
|
};
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (truncation.lastLinePartial) {
|
|
155
|
-
const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
|
|
156
|
-
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`;
|
|
157
|
-
} else if (truncation.truncatedBy === "lines") {
|
|
158
|
-
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`;
|
|
159
|
-
} else {
|
|
160
|
-
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
|
|
161
|
-
}
|
|
124
|
+
outputText += formatTailTruncationNotice(truncation, {
|
|
125
|
+
fullOutputPath: result.fullOutputPath,
|
|
126
|
+
originalContent: result.output,
|
|
127
|
+
});
|
|
162
128
|
}
|
|
163
129
|
|
|
164
130
|
if (result.exitCode !== 0 && result.exitCode !== undefined) {
|
|
@@ -263,7 +229,7 @@ export const bashToolRenderer = {
|
|
|
263
229
|
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
|
|
264
230
|
} else {
|
|
265
231
|
warnings.push(
|
|
266
|
-
`Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes
|
|
232
|
+
`Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes)} limit)`,
|
|
267
233
|
);
|
|
268
234
|
}
|
|
269
235
|
}
|
package/src/core/tools/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { AskTool, type AskToolDetails } from "./ask";
|
|
2
|
-
export {
|
|
2
|
+
export { BashTool, type BashToolDetails, type BashToolOptions } from "./bash";
|
|
3
3
|
export { CalculatorTool, type CalculatorToolDetails } from "./calculator";
|
|
4
4
|
export { CompleteTool } from "./complete";
|
|
5
5
|
// Exa MCP tools (22 tools)
|
package/src/core/tools/python.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { PreludeHelper, PythonStatusEvent } from "../python-kernel";
|
|
|
14
14
|
import type { ToolSession } from "./index";
|
|
15
15
|
import { resolveToCwd } from "./path-utils";
|
|
16
16
|
import { getTreeBranch, getTreeContinuePrefix, shortenPath, ToolUIKit, truncate } from "./render-utils";
|
|
17
|
-
import { DEFAULT_MAX_BYTES,
|
|
17
|
+
import { DEFAULT_MAX_BYTES, formatTailTruncationNotice, type TruncationResult, truncateTail } from "./truncate";
|
|
18
18
|
|
|
19
19
|
export const PYTHON_DEFAULT_PREVIEW_LINES = 10;
|
|
20
20
|
|
|
@@ -234,7 +234,6 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
|
|
|
234
234
|
let details: PythonToolDetails | undefined;
|
|
235
235
|
|
|
236
236
|
if (truncation.truncated) {
|
|
237
|
-
const fullOutputSuffix = result.fullOutputPath ? ` Full output: ${result.fullOutputPath}` : "";
|
|
238
237
|
details = {
|
|
239
238
|
truncation,
|
|
240
239
|
fullOutputPath: result.fullOutputPath,
|
|
@@ -242,18 +241,10 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
|
|
|
242
241
|
images,
|
|
243
242
|
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
|
|
244
243
|
};
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (truncation.lastLinePartial) {
|
|
250
|
-
const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
|
|
251
|
-
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize})${fullOutputSuffix}]`;
|
|
252
|
-
} else if (truncation.truncatedBy === "lines") {
|
|
253
|
-
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputSuffix}]`;
|
|
254
|
-
} else {
|
|
255
|
-
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit)${fullOutputSuffix}]`;
|
|
256
|
-
}
|
|
244
|
+
outputText += formatTailTruncationNotice(truncation, {
|
|
245
|
+
fullOutputPath: result.fullOutputPath,
|
|
246
|
+
originalContent: result.output,
|
|
247
|
+
});
|
|
257
248
|
}
|
|
258
249
|
|
|
259
250
|
if (!details && (jsonOutputs.length > 0 || images.length > 0 || statusEvents.length > 0)) {
|
|
@@ -685,7 +676,7 @@ export const pythonToolRenderer = {
|
|
|
685
676
|
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
|
|
686
677
|
} else {
|
|
687
678
|
warnings.push(
|
|
688
|
-
`Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes
|
|
679
|
+
`Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes)} limit)`,
|
|
689
680
|
);
|
|
690
681
|
}
|
|
691
682
|
}
|
package/src/core/tools/ssh.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { ensureHostInfo, getHostInfoForHost } from "../ssh/connection-manager";
|
|
|
14
14
|
import { executeSSH } from "../ssh/ssh-executor";
|
|
15
15
|
import type { ToolSession } from "./index";
|
|
16
16
|
import { ToolUIKit } from "./render-utils";
|
|
17
|
-
import {
|
|
17
|
+
import { formatTailTruncationNotice, type TruncationResult, truncateTail } from "./truncate";
|
|
18
18
|
|
|
19
19
|
const sshSchema = Type.Object({
|
|
20
20
|
host: Type.String({ description: "Host name from ssh.json or .ssh.json" }),
|
|
@@ -193,18 +193,10 @@ export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
|
|
|
193
193
|
truncation,
|
|
194
194
|
fullOutputPath: result.fullOutputPath,
|
|
195
195
|
};
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (truncation.lastLinePartial) {
|
|
201
|
-
const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
|
|
202
|
-
outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${result.fullOutputPath}]`;
|
|
203
|
-
} else if (truncation.truncatedBy === "lines") {
|
|
204
|
-
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${result.fullOutputPath}]`;
|
|
205
|
-
} else {
|
|
206
|
-
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${result.fullOutputPath}]`;
|
|
207
|
-
}
|
|
196
|
+
outputText += formatTailTruncationNotice(truncation, {
|
|
197
|
+
fullOutputPath: result.fullOutputPath,
|
|
198
|
+
originalContent: result.output,
|
|
199
|
+
});
|
|
208
200
|
}
|
|
209
201
|
|
|
210
202
|
if (result.exitCode !== 0 && result.exitCode !== undefined) {
|
|
@@ -311,7 +303,7 @@ export const sshToolRenderer = {
|
|
|
311
303
|
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
|
|
312
304
|
} else {
|
|
313
305
|
warnings.push(
|
|
314
|
-
`Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes
|
|
306
|
+
`Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes)} limit)`,
|
|
315
307
|
);
|
|
316
308
|
}
|
|
317
309
|
}
|
|
@@ -289,3 +289,95 @@ export function truncateLine(
|
|
|
289
289
|
}
|
|
290
290
|
return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
|
|
291
291
|
}
|
|
292
|
+
|
|
293
|
+
// =============================================================================
|
|
294
|
+
// Truncation notice formatting
|
|
295
|
+
// =============================================================================
|
|
296
|
+
|
|
297
|
+
export interface TailTruncationNoticeOptions {
|
|
298
|
+
/** Path to full output file (e.g., from bash/python executor) */
|
|
299
|
+
fullOutputPath?: string;
|
|
300
|
+
/** Original content for computing last line size when lastLinePartial */
|
|
301
|
+
originalContent?: string;
|
|
302
|
+
/** Additional suffix to append inside the brackets */
|
|
303
|
+
suffix?: string;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Format a truncation notice for tail-truncated output (bash, python, ssh).
|
|
308
|
+
* Returns empty string if not truncated.
|
|
309
|
+
*
|
|
310
|
+
* Examples:
|
|
311
|
+
* - "[Showing last 50KB of line 1000 (line is 2.1MB). Full output: /tmp/out.txt]"
|
|
312
|
+
* - "[Showing lines 500-1000 of 1000. Full output: /tmp/out.txt]"
|
|
313
|
+
* - "[Showing lines 500-1000 of 1000 (50KB limit). Full output: /tmp/out.txt]"
|
|
314
|
+
*/
|
|
315
|
+
export function formatTailTruncationNotice(
|
|
316
|
+
truncation: TruncationResult,
|
|
317
|
+
options: TailTruncationNoticeOptions = {},
|
|
318
|
+
): string {
|
|
319
|
+
if (!truncation.truncated) {
|
|
320
|
+
return "";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const { fullOutputPath, originalContent, suffix = "" } = options;
|
|
324
|
+
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
|
325
|
+
const endLine = truncation.totalLines;
|
|
326
|
+
const fullOutputPart = fullOutputPath ? `. Full output: ${fullOutputPath}` : "";
|
|
327
|
+
|
|
328
|
+
let notice: string;
|
|
329
|
+
|
|
330
|
+
if (truncation.lastLinePartial) {
|
|
331
|
+
let lastLineSizePart = "";
|
|
332
|
+
if (originalContent) {
|
|
333
|
+
const lastLine = originalContent.split("\n").pop() || "";
|
|
334
|
+
lastLineSizePart = ` (line is ${formatSize(Buffer.byteLength(lastLine, "utf-8"))})`;
|
|
335
|
+
}
|
|
336
|
+
notice = `[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine}${lastLineSizePart}${fullOutputPart}${suffix}]`;
|
|
337
|
+
} else if (truncation.truncatedBy === "lines") {
|
|
338
|
+
notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputPart}${suffix}]`;
|
|
339
|
+
} else {
|
|
340
|
+
notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit)${fullOutputPart}${suffix}]`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return `\n\n${notice}`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface HeadTruncationNoticeOptions {
|
|
347
|
+
/** 1-indexed start line number (default: 1) */
|
|
348
|
+
startLine?: number;
|
|
349
|
+
/** Total lines in the original file (for "of N" display) */
|
|
350
|
+
totalFileLines?: number;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Format a truncation notice for head-truncated output (read tool).
|
|
355
|
+
* Returns empty string if not truncated.
|
|
356
|
+
*
|
|
357
|
+
* Examples:
|
|
358
|
+
* - "[Showing lines 1-2000 of 5000. Use offset=2001 to continue]"
|
|
359
|
+
* - "[Showing lines 100-2099 of 5000 (50KB limit). Use offset=2100 to continue]"
|
|
360
|
+
*/
|
|
361
|
+
export function formatHeadTruncationNotice(
|
|
362
|
+
truncation: TruncationResult,
|
|
363
|
+
options: HeadTruncationNoticeOptions = {},
|
|
364
|
+
): string {
|
|
365
|
+
if (!truncation.truncated) {
|
|
366
|
+
return "";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const startLineDisplay = options.startLine ?? 1;
|
|
370
|
+
const totalFileLines = options.totalFileLines ?? truncation.totalLines;
|
|
371
|
+
const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
|
|
372
|
+
const nextOffset = endLineDisplay + 1;
|
|
373
|
+
|
|
374
|
+
let notice: string;
|
|
375
|
+
|
|
376
|
+
if (truncation.truncatedBy === "lines") {
|
|
377
|
+
notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
|
|
378
|
+
} else {
|
|
379
|
+
notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue]`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return `\n\n${notice}`;
|
|
383
|
+
}
|
|
@@ -24,7 +24,9 @@ import { convertWithMarkitdown, fetchBinary } from "./web-scrapers/utils";
|
|
|
24
24
|
// Types and Constants
|
|
25
25
|
// =============================================================================
|
|
26
26
|
|
|
27
|
-
const
|
|
27
|
+
const MIN_TIMEOUT = 1_000;
|
|
28
|
+
const DEFAULT_TIMEOUT = 20_000;
|
|
29
|
+
const MAX_TIMEOUT = 45_000;
|
|
28
30
|
|
|
29
31
|
// Convertible document types (markitdown supported)
|
|
30
32
|
const CONVERTIBLE_MIMES = new Set([
|
|
@@ -109,7 +111,7 @@ async function exec(
|
|
|
109
111
|
): Promise<{ stdout: string; stderr: string; ok: boolean }> {
|
|
110
112
|
const proc = ptree.cspawn([cmd, ...args], {
|
|
111
113
|
stdin: options?.input ? "pipe" : null,
|
|
112
|
-
timeout: options?.timeout,
|
|
114
|
+
timeout: options?.timeout ? options.timeout * 1000 : undefined,
|
|
113
115
|
});
|
|
114
116
|
|
|
115
117
|
if (options?.input) {
|
|
@@ -244,7 +246,7 @@ async function tryMdSuffix(url: string, timeout: number, signal?: AbortSignal):
|
|
|
244
246
|
if (signal?.aborted) {
|
|
245
247
|
return null;
|
|
246
248
|
}
|
|
247
|
-
const result = await loadPage(candidate, { timeout: Math.min(timeout,
|
|
249
|
+
const result = await loadPage(candidate, { timeout: Math.min(timeout, MAX_TIMEOUT), signal });
|
|
248
250
|
if (result.ok && result.content.trim().length > 100 && !looksLikeHtml(result.content)) {
|
|
249
251
|
return result.content;
|
|
250
252
|
}
|
|
@@ -910,7 +912,7 @@ export class WebFetchTool implements AgentTool<typeof webFetchSchema, WebFetchTo
|
|
|
910
912
|
}
|
|
911
913
|
|
|
912
914
|
// Clamp timeout
|
|
913
|
-
const effectiveTimeout = Math.min(Math.max(timeout,
|
|
915
|
+
const effectiveTimeout = Math.min(Math.max(timeout, MIN_TIMEOUT), MAX_TIMEOUT);
|
|
914
916
|
|
|
915
917
|
const result = await renderUrl(url, effectiveTimeout, raw, signal);
|
|
916
918
|
|
|
@@ -1,36 +1,13 @@
|
|
|
1
1
|
import { rm } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { ptree } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import { nanoid } from "nanoid";
|
|
6
6
|
import { ensureTool } from "../../../utils/tools-manager";
|
|
7
7
|
import { createRequestSignal } from "./types";
|
|
8
8
|
|
|
9
9
|
const MAX_BYTES = 50 * 1024 * 1024; // 50MB for binary files
|
|
10
10
|
|
|
11
|
-
interface ExecResult {
|
|
12
|
-
stdout: string;
|
|
13
|
-
stderr: string;
|
|
14
|
-
ok: boolean;
|
|
15
|
-
exitCode: number;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async function exec(
|
|
19
|
-
cmd: string,
|
|
20
|
-
args: string[],
|
|
21
|
-
options?: { timeout?: number; input?: string | Buffer },
|
|
22
|
-
): Promise<ExecResult> {
|
|
23
|
-
void options;
|
|
24
|
-
const result = await $`${cmd} ${args}`.quiet().nothrow();
|
|
25
|
-
const decoder = new TextDecoder();
|
|
26
|
-
return {
|
|
27
|
-
stdout: result.stdout ? decoder.decode(result.stdout) : "",
|
|
28
|
-
stderr: result.stderr ? decoder.decode(result.stderr) : "",
|
|
29
|
-
ok: result.exitCode === 0,
|
|
30
|
-
exitCode: result.exitCode ?? -1,
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
11
|
export interface ConvertResult {
|
|
35
12
|
content: string;
|
|
36
13
|
ok: boolean;
|
|
@@ -72,16 +49,16 @@ export async function convertWithMarkitdown(
|
|
|
72
49
|
|
|
73
50
|
try {
|
|
74
51
|
await Bun.write(tmpFile, content);
|
|
75
|
-
const result = await
|
|
76
|
-
|
|
77
|
-
|
|
52
|
+
const result = await ptree.cspawn([markitdown, tmpFile], { timeout });
|
|
53
|
+
const [stdout, stderr, exitCode] = await Promise.all([result.stdout.text(), result.stderr.text(), result.exited]);
|
|
54
|
+
if (exitCode !== 0) {
|
|
78
55
|
return {
|
|
79
|
-
content:
|
|
56
|
+
content: stdout,
|
|
80
57
|
ok: false,
|
|
81
|
-
error: stderr.length > 0 ? stderr : `markitdown failed (exit ${
|
|
58
|
+
error: stderr.length > 0 ? stderr : `markitdown failed (exit ${exitCode})`,
|
|
82
59
|
};
|
|
83
60
|
}
|
|
84
|
-
return { content:
|
|
61
|
+
return { content: stdout, ok: true };
|
|
85
62
|
} finally {
|
|
86
63
|
try {
|
|
87
64
|
await rm(tmpFile, { force: true });
|
package/src/index.ts
CHANGED
|
@@ -194,7 +194,6 @@ export {
|
|
|
194
194
|
export { type FileSlashCommand, loadSlashCommands as discoverSlashCommands } from "./core/slash-commands";
|
|
195
195
|
// Tools (detail types and utilities)
|
|
196
196
|
export {
|
|
197
|
-
type BashOperations,
|
|
198
197
|
type BashToolDetails,
|
|
199
198
|
DEFAULT_MAX_BYTES,
|
|
200
199
|
DEFAULT_MAX_LINES,
|