@arnilo/prism-coding-agent 0.0.3

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/dist/shell.js ADDED
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Shell tool: execute a command in the host shell.
3
+ *
4
+ * Behavioral port of pi's core/tools/bash for @arnilo/prism-coding-agent, adapted to
5
+ * Prism's `ToolDefinition` contract. Drops pi's TUI (live `onUpdate` streaming,
6
+ * `renderCall`/`renderResult`) and process-shutdown child tracking; re-ports spawn/
7
+ * kill/waitForChildProcess directly over stdlib so the package stays self-contained.
8
+ *
9
+ * Deviations from pi (documented):
10
+ * - Tool named `"shell"` (pi: `"bash"`).
11
+ * - `timeout` param is in **seconds** (matches pi).
12
+ * - Shell resolution honors `process.env.SHELL` → `/bin/bash` → `sh` (pi forces `/bin/bash`);
13
+ * overridable via `options.shellPath`. Rationale: a host integrating the tool usually wants its
14
+ * login shell respected; `shellPath` still lets a host force bash for fully predictable POSIX.
15
+ * - Non-zero exit is **not** a tool error: returned as a normal `ToolResult` with `exitCode` in
16
+ * `metadata` and a status footer in `content` (pi throws). timeout/abort *are* error results
17
+ * (the command did not complete). Rationale: `ToolResult.error` should mean the tool call failed,
18
+ * not that the command returned non-zero.
19
+ * - Drops detached-child PID tracking (`killTrackedDetachedChildren`): the host owns process
20
+ * lifecycle; the tool kills the tree only on timeout/abort. Drops the stdin command transport
21
+ * (argv `-c` only). Default spawn env is `process.env` (no pi CLI binDir PATH injection).
22
+ */
23
+ import { spawn } from "node:child_process";
24
+ import { constants, existsSync } from "node:fs";
25
+ import { access as fsAccess } from "node:fs/promises";
26
+ import { OutputAccumulator } from "./output-accumulator.js";
27
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "./truncate.js";
28
+ const EXIT_STDIO_GRACE_MS = 100;
29
+ // --- spawn internals (re-ported from pi utils/shell.js + utils/child-process.js) ---
30
+ /** Resolve the shell binary + args. shellPath → SHELL env → /bin/bash → sh. */
31
+ export function getShellConfig(customShellPath) {
32
+ if (customShellPath) {
33
+ if (existsSync(customShellPath))
34
+ return { shell: customShellPath, args: ["-c"] };
35
+ throw new Error(`Custom shell path not found: ${customShellPath}`);
36
+ }
37
+ const shellEnv = process.env.SHELL;
38
+ if (shellEnv && existsSync(shellEnv))
39
+ return { shell: shellEnv, args: ["-c"] };
40
+ if (existsSync("/bin/bash"))
41
+ return { shell: "/bin/bash", args: ["-c"] };
42
+ return { shell: "sh", args: ["-c"] };
43
+ }
44
+ /** Kill a process and all its descendants (cross-platform). */
45
+ export function killProcessTree(pid) {
46
+ if (process.platform === "win32") {
47
+ try {
48
+ spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
49
+ stdio: "ignore",
50
+ detached: true,
51
+ windowsHide: true,
52
+ });
53
+ }
54
+ catch {
55
+ // ignore — best effort
56
+ }
57
+ return;
58
+ }
59
+ try {
60
+ // child is spawned detached, so it is its own process-group leader: -pid targets the group.
61
+ process.kill(-pid, "SIGKILL");
62
+ }
63
+ catch {
64
+ try {
65
+ process.kill(pid, "SIGKILL");
66
+ }
67
+ catch {
68
+ // already dead
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * Wait for a child to terminate without hanging on inherited stdio handles held by detached descendants.
74
+ *
75
+ * A short-lived child can `exit` while a detached descendant keeps its stdout/stderr pipe open. After
76
+ * `exit` we wait for the pipes to fall idle: the grace timer is re-armed on every chunk, so an actively
77
+ * writing descendant keeps us reading, while a quiet inherited handle releases us after the grace elapses.
78
+ */
79
+ export function waitForChildProcess(child) {
80
+ return new Promise((resolve, reject) => {
81
+ let settled = false;
82
+ let exited = false;
83
+ let exitCode = null;
84
+ let postExitTimer;
85
+ let stdoutEnded = child.stdout === null;
86
+ let stderrEnded = child.stderr === null;
87
+ const cleanup = () => {
88
+ if (postExitTimer) {
89
+ clearTimeout(postExitTimer);
90
+ postExitTimer = undefined;
91
+ }
92
+ child.removeListener("error", onError);
93
+ child.removeListener("exit", onExit);
94
+ child.removeListener("close", onClose);
95
+ child.stdout?.removeListener("end", onStdoutEnd);
96
+ child.stderr?.removeListener("end", onStderrEnd);
97
+ child.stdout?.removeListener("data", onData);
98
+ child.stderr?.removeListener("data", onData);
99
+ };
100
+ const finalize = (code) => {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ cleanup();
105
+ child.stdout?.destroy();
106
+ child.stderr?.destroy();
107
+ resolve(code);
108
+ };
109
+ const maybeFinalizeAfterExit = () => {
110
+ if (!exited || settled)
111
+ return;
112
+ if (stdoutEnded && stderrEnded)
113
+ finalize(exitCode);
114
+ };
115
+ const armIdleTimer = () => {
116
+ if (postExitTimer)
117
+ clearTimeout(postExitTimer);
118
+ postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS);
119
+ };
120
+ const onData = () => {
121
+ if (exited && !settled)
122
+ armIdleTimer();
123
+ };
124
+ const onStdoutEnd = () => {
125
+ stdoutEnded = true;
126
+ maybeFinalizeAfterExit();
127
+ };
128
+ const onStderrEnd = () => {
129
+ stderrEnded = true;
130
+ maybeFinalizeAfterExit();
131
+ };
132
+ const onError = (err) => {
133
+ if (settled)
134
+ return;
135
+ settled = true;
136
+ cleanup();
137
+ reject(err);
138
+ };
139
+ const onExit = (code) => {
140
+ exited = true;
141
+ exitCode = code;
142
+ maybeFinalizeAfterExit();
143
+ if (!settled)
144
+ armIdleTimer();
145
+ };
146
+ const onClose = (code) => {
147
+ finalize(code);
148
+ };
149
+ child.stdout?.once("end", onStdoutEnd);
150
+ child.stderr?.once("end", onStderrEnd);
151
+ child.stdout?.on("data", onData);
152
+ child.stderr?.on("data", onData);
153
+ child.once("error", onError);
154
+ child.once("exit", onExit);
155
+ child.once("close", onClose);
156
+ });
157
+ }
158
+ /** Default local-shell operations: spawn the command in a shell, stream combined stdout+stderr. */
159
+ export function createLocalBashOperations(options) {
160
+ return {
161
+ exec: async (command, cwd, { onData, signal, timeout, env }) => {
162
+ const shellConfig = getShellConfig(options?.shellPath);
163
+ try {
164
+ await fsAccess(cwd, constants.F_OK);
165
+ }
166
+ catch {
167
+ throw new Error(`Working directory does not exist: ${cwd}\nCannot execute shell commands.`);
168
+ }
169
+ if (signal?.aborted) {
170
+ throw new Error("aborted");
171
+ }
172
+ const child = spawn(shellConfig.shell, [...shellConfig.args, command], {
173
+ cwd,
174
+ detached: process.platform !== "win32",
175
+ env: env ?? { ...process.env },
176
+ stdio: ["ignore", "pipe", "pipe"],
177
+ windowsHide: true,
178
+ });
179
+ let timedOut = false;
180
+ let timeoutHandle;
181
+ const onAbort = () => {
182
+ if (child.pid)
183
+ killProcessTree(child.pid);
184
+ };
185
+ try {
186
+ if (timeout !== undefined && timeout > 0) {
187
+ timeoutHandle = setTimeout(() => {
188
+ timedOut = true;
189
+ if (child.pid)
190
+ killProcessTree(child.pid);
191
+ }, timeout * 1000);
192
+ }
193
+ child.stdout?.on("data", onData);
194
+ child.stderr?.on("data", onData);
195
+ if (signal) {
196
+ if (signal.aborted)
197
+ onAbort();
198
+ else
199
+ signal.addEventListener("abort", onAbort, { once: true });
200
+ }
201
+ const exitCode = await waitForChildProcess(child);
202
+ if (signal?.aborted)
203
+ throw new Error("aborted");
204
+ if (timedOut)
205
+ throw new Error(`timeout:${timeout}`);
206
+ return { exitCode };
207
+ }
208
+ finally {
209
+ if (timeoutHandle)
210
+ clearTimeout(timeoutHandle);
211
+ if (signal)
212
+ signal.removeEventListener("abort", onAbort);
213
+ }
214
+ },
215
+ };
216
+ }
217
+ // --- result formatting (adapted from pi, minus TUI) ---
218
+ function formatOutput(snapshot, lastLineBytes, emptyText = "(no output)") {
219
+ const truncation = snapshot.truncation;
220
+ let text = snapshot.content || emptyText;
221
+ if (truncation.truncated) {
222
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
223
+ const endLine = truncation.totalLines;
224
+ if (truncation.lastLinePartial) {
225
+ const lastLineSize = formatSize(lastLineBytes);
226
+ text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${snapshot.fullOutputPath}]`;
227
+ }
228
+ else if (truncation.truncatedBy === "lines") {
229
+ text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${snapshot.fullOutputPath}]`;
230
+ }
231
+ else {
232
+ text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${snapshot.fullOutputPath}]`;
233
+ }
234
+ }
235
+ return { text, truncation, fullOutputPath: snapshot.fullOutputPath };
236
+ }
237
+ function appendStatus(text, status) {
238
+ return text ? `${text}\n\n${status}` : status;
239
+ }
240
+ // --- tool factory ---
241
+ export function createShellTool(cwd, options) {
242
+ const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath });
243
+ const commandPrefix = options?.commandPrefix;
244
+ const spawnHook = options?.spawnHook;
245
+ const maxLines = options?.maxLines ?? DEFAULT_MAX_LINES;
246
+ const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
247
+ const tempFilePrefix = options?.tempFilePrefix ?? "prism-shell";
248
+ return {
249
+ name: "shell",
250
+ description: `Execute a shell command in the current working directory. Returns combined stdout and stderr. Output is truncated to the last ${maxLines} lines or ${maxBytes / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
251
+ parameters: {
252
+ type: "object",
253
+ properties: {
254
+ command: { type: "string", description: "Shell command to execute" },
255
+ timeout: { type: "number", description: "Timeout in seconds (optional, no default timeout)" },
256
+ },
257
+ required: ["command"],
258
+ additionalProperties: false,
259
+ },
260
+ async execute(args, context) {
261
+ const toolCallId = context.toolCallId;
262
+ const command = typeof args.command === "string" ? args.command : "";
263
+ const timeout = typeof args.timeout === "number" ? args.timeout : undefined;
264
+ if (command.length === 0) {
265
+ return {
266
+ toolCallId,
267
+ name: "shell",
268
+ content: [{ type: "text", text: "Error: command is required and must be a non-empty string." }],
269
+ error: { message: "command is required and must be a non-empty string." },
270
+ };
271
+ }
272
+ const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
273
+ const spawnContext = spawnHook
274
+ ? spawnHook({ command: resolvedCommand, cwd, env: { ...process.env } })
275
+ : { command: resolvedCommand, cwd, env: { ...process.env } };
276
+ const output = new OutputAccumulator({ maxLines, maxBytes, tempFilePrefix });
277
+ let acceptingOutput = true;
278
+ const handleData = (data) => {
279
+ if (!acceptingOutput)
280
+ return;
281
+ output.append(data);
282
+ };
283
+ const finishOutput = async () => {
284
+ acceptingOutput = false;
285
+ output.finish();
286
+ const snapshot = output.snapshot({ persistIfTruncated: true });
287
+ await output.closeTempFile();
288
+ return snapshot;
289
+ };
290
+ // Safety net: never leak an unhandled throw to the host runtime.
291
+ try {
292
+ let exitCode;
293
+ try {
294
+ const result = await ops.exec(spawnContext.command, spawnContext.cwd, {
295
+ onData: handleData,
296
+ signal: context.signal,
297
+ timeout,
298
+ env: spawnContext.env,
299
+ });
300
+ exitCode = result.exitCode;
301
+ }
302
+ catch (err) {
303
+ const snapshot = await finishOutput();
304
+ const { text } = formatOutput(snapshot, output.getLastLineBytes(), "");
305
+ const message = err instanceof Error ? err.message : String(err);
306
+ const meta = {
307
+ exitCode: null,
308
+ truncation: snapshot.truncation,
309
+ fullOutputPath: snapshot.fullOutputPath,
310
+ };
311
+ if (message === "aborted") {
312
+ return {
313
+ toolCallId,
314
+ name: "shell",
315
+ content: [{ type: "text", text: appendStatus(text, "[Command aborted]") }],
316
+ error: { message: "Command aborted" },
317
+ metadata: meta,
318
+ };
319
+ }
320
+ if (message.startsWith("timeout:")) {
321
+ const timeoutSecs = message.split(":")[1];
322
+ const status = `Command timed out after ${timeoutSecs} seconds`;
323
+ return {
324
+ toolCallId,
325
+ name: "shell",
326
+ content: [{ type: "text", text: appendStatus(text, `[${status}]`) }],
327
+ error: { message: status },
328
+ metadata: meta,
329
+ };
330
+ }
331
+ // Spawn error (missing cwd, shell ENOENT, …): message is already host-friendly.
332
+ return {
333
+ toolCallId,
334
+ name: "shell",
335
+ content: [{ type: "text", text: appendStatus(text, message) }],
336
+ error: { message },
337
+ metadata: meta,
338
+ };
339
+ }
340
+ const snapshot = await finishOutput();
341
+ const formatted = formatOutput(snapshot, output.getLastLineBytes());
342
+ let text = formatted.text;
343
+ // Non-zero exit is not a tool error: surface exit code in a footer + metadata.
344
+ if (exitCode !== 0 && exitCode !== null) {
345
+ text = appendStatus(text, `[Command exited with code ${exitCode}]`);
346
+ }
347
+ return {
348
+ toolCallId,
349
+ name: "shell",
350
+ content: [{ type: "text", text }],
351
+ metadata: {
352
+ exitCode,
353
+ truncation: snapshot.truncation,
354
+ fullOutputPath: snapshot.fullOutputPath,
355
+ },
356
+ };
357
+ }
358
+ catch (err) {
359
+ const message = err instanceof Error ? err.message : String(err);
360
+ return {
361
+ toolCallId,
362
+ name: "shell",
363
+ content: [{ type: "text", text: message }],
364
+ error: { message },
365
+ };
366
+ }
367
+ },
368
+ };
369
+ }
370
+ //# sourceMappingURL=shell.js.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared truncation utilities for tool outputs.
3
+ *
4
+ * Behavioral port of pi's core/tools/truncate for @arnilo/prism-coding-agent.
5
+ * stdlib only (Buffer). Truncation is based on two independent limits — whichever
6
+ * is hit first wins:
7
+ * - Line limit (default: 2000 lines)
8
+ * - Byte limit (default: 50KB)
9
+ *
10
+ * Never returns partial lines (except the documented tail single-line edge case).
11
+ */
12
+ export declare const DEFAULT_MAX_LINES = 2000;
13
+ export declare const DEFAULT_MAX_BYTES: number;
14
+ export interface TruncationOptions {
15
+ /** Maximum number of lines (default: 2000) */
16
+ maxLines?: number;
17
+ /** Maximum number of bytes (default: 50KB) */
18
+ maxBytes?: number;
19
+ }
20
+ export interface TruncationResult {
21
+ /** The truncated content */
22
+ content: string;
23
+ /** Whether truncation occurred */
24
+ truncated: boolean;
25
+ /** Which limit was hit: "lines", "bytes", or null if not truncated */
26
+ truncatedBy: "lines" | "bytes" | null;
27
+ /** Total number of lines in the original content */
28
+ totalLines: number;
29
+ /** Total number of bytes in the original content */
30
+ totalBytes: number;
31
+ /** Number of complete lines in the truncated output */
32
+ outputLines: number;
33
+ /** Number of bytes in the truncated output */
34
+ outputBytes: number;
35
+ /** Whether the last line was partially truncated (only for tail truncation edge case) */
36
+ lastLinePartial: boolean;
37
+ /** Whether the first line exceeded the byte limit (for head truncation) */
38
+ firstLineExceedsLimit: boolean;
39
+ /** The max lines limit that was applied */
40
+ maxLines: number;
41
+ /** The max bytes limit that was applied */
42
+ maxBytes: number;
43
+ }
44
+ /** Format bytes as human-readable size. */
45
+ export declare function formatSize(bytes: number): string;
46
+ /**
47
+ * Truncate content from the head (keep first N lines/bytes).
48
+ * Suitable for file reads where you want to see the beginning.
49
+ *
50
+ * Never returns partial lines. If the first line alone exceeds the byte limit,
51
+ * returns empty content with firstLineExceedsLimit=true.
52
+ */
53
+ export declare function truncateHead(content: string, options?: TruncationOptions): TruncationResult;
54
+ /**
55
+ * Truncate content from the tail (keep last N lines/bytes).
56
+ * Suitable for shell output where you want to see the end (errors, final results).
57
+ *
58
+ * May return a partial first line if the last line of the original content exceeds
59
+ * the byte limit.
60
+ */
61
+ export declare function truncateTail(content: string, options?: TruncationOptions): TruncationResult;
62
+ /**
63
+ * Truncate a single line to max characters, adding a `[truncated]` suffix.
64
+ * Used for long single-line outputs (e.g. grep match lines).
65
+ */
66
+ export declare function truncateLine(line: string, maxChars?: number): {
67
+ text: string;
68
+ wasTruncated: boolean;
69
+ };
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Shared truncation utilities for tool outputs.
3
+ *
4
+ * Behavioral port of pi's core/tools/truncate for @arnilo/prism-coding-agent.
5
+ * stdlib only (Buffer). Truncation is based on two independent limits — whichever
6
+ * is hit first wins:
7
+ * - Line limit (default: 2000 lines)
8
+ * - Byte limit (default: 50KB)
9
+ *
10
+ * Never returns partial lines (except the documented tail single-line edge case).
11
+ */
12
+ export const DEFAULT_MAX_LINES = 2000;
13
+ export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
14
+ /**
15
+ * Default char cap for {@link truncateLine}. pi names this GREP_MAX_LINE_LENGTH
16
+ * because grep is its only caller; grep is out of scope for this package, so the
17
+ * constant stays internal and unprefixed.
18
+ */
19
+ const DEFAULT_LINE_CHAR_LIMIT = 500;
20
+ function splitLinesForCounting(content) {
21
+ if (content.length === 0)
22
+ return [];
23
+ const lines = content.split("\n");
24
+ if (content.endsWith("\n"))
25
+ lines.pop();
26
+ return lines;
27
+ }
28
+ /** Format bytes as human-readable size. */
29
+ export function formatSize(bytes) {
30
+ if (bytes < 1024)
31
+ return `${bytes}B`;
32
+ if (bytes < 1024 * 1024)
33
+ return `${(bytes / 1024).toFixed(1)}KB`;
34
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
35
+ }
36
+ /**
37
+ * Truncate content from the head (keep first N lines/bytes).
38
+ * Suitable for file reads where you want to see the beginning.
39
+ *
40
+ * Never returns partial lines. If the first line alone exceeds the byte limit,
41
+ * returns empty content with firstLineExceedsLimit=true.
42
+ */
43
+ export function truncateHead(content, options = {}) {
44
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
45
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
46
+ const totalBytes = Buffer.byteLength(content, "utf-8");
47
+ const lines = splitLinesForCounting(content);
48
+ const totalLines = lines.length;
49
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
50
+ return {
51
+ content,
52
+ truncated: false,
53
+ truncatedBy: null,
54
+ totalLines,
55
+ totalBytes,
56
+ outputLines: totalLines,
57
+ outputBytes: totalBytes,
58
+ lastLinePartial: false,
59
+ firstLineExceedsLimit: false,
60
+ maxLines,
61
+ maxBytes,
62
+ };
63
+ }
64
+ // First line alone exceeds the byte limit → nothing fits.
65
+ const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
66
+ if (firstLineBytes > maxBytes) {
67
+ return {
68
+ content: "",
69
+ truncated: true,
70
+ truncatedBy: "bytes",
71
+ totalLines,
72
+ totalBytes,
73
+ outputLines: 0,
74
+ outputBytes: 0,
75
+ lastLinePartial: false,
76
+ firstLineExceedsLimit: true,
77
+ maxLines,
78
+ maxBytes,
79
+ };
80
+ }
81
+ const outputLinesArr = [];
82
+ let outputBytesCount = 0;
83
+ let truncatedBy = "lines";
84
+ for (let i = 0; i < lines.length && i < maxLines; i++) {
85
+ const line = lines[i];
86
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
87
+ if (outputBytesCount + lineBytes > maxBytes) {
88
+ truncatedBy = "bytes";
89
+ break;
90
+ }
91
+ outputLinesArr.push(line);
92
+ outputBytesCount += lineBytes;
93
+ }
94
+ if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
95
+ truncatedBy = "lines";
96
+ }
97
+ const outputContent = outputLinesArr.join("\n");
98
+ const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
99
+ return {
100
+ content: outputContent,
101
+ truncated: true,
102
+ truncatedBy,
103
+ totalLines,
104
+ totalBytes,
105
+ outputLines: outputLinesArr.length,
106
+ outputBytes: finalOutputBytes,
107
+ lastLinePartial: false,
108
+ firstLineExceedsLimit: false,
109
+ maxLines,
110
+ maxBytes,
111
+ };
112
+ }
113
+ /**
114
+ * Truncate content from the tail (keep last N lines/bytes).
115
+ * Suitable for shell output where you want to see the end (errors, final results).
116
+ *
117
+ * May return a partial first line if the last line of the original content exceeds
118
+ * the byte limit.
119
+ */
120
+ export function truncateTail(content, options = {}) {
121
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
122
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
123
+ const totalBytes = Buffer.byteLength(content, "utf-8");
124
+ const lines = splitLinesForCounting(content);
125
+ const totalLines = lines.length;
126
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
127
+ return {
128
+ content,
129
+ truncated: false,
130
+ truncatedBy: null,
131
+ totalLines,
132
+ totalBytes,
133
+ outputLines: totalLines,
134
+ outputBytes: totalBytes,
135
+ lastLinePartial: false,
136
+ firstLineExceedsLimit: false,
137
+ maxLines,
138
+ maxBytes,
139
+ };
140
+ }
141
+ const outputLinesArr = [];
142
+ let outputBytesCount = 0;
143
+ let truncatedBy = "lines";
144
+ let lastLinePartial = false;
145
+ for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
146
+ const line = lines[i];
147
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
148
+ if (outputBytesCount + lineBytes > maxBytes) {
149
+ truncatedBy = "bytes";
150
+ // Edge case: no lines added yet and this line alone exceeds maxBytes →
151
+ // take the end of the line (partial).
152
+ if (outputLinesArr.length === 0) {
153
+ const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
154
+ outputLinesArr.unshift(truncatedLine);
155
+ outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
156
+ lastLinePartial = true;
157
+ }
158
+ break;
159
+ }
160
+ outputLinesArr.unshift(line);
161
+ outputBytesCount += lineBytes;
162
+ }
163
+ if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
164
+ truncatedBy = "lines";
165
+ }
166
+ const outputContent = outputLinesArr.join("\n");
167
+ const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
168
+ return {
169
+ content: outputContent,
170
+ truncated: true,
171
+ truncatedBy,
172
+ totalLines,
173
+ totalBytes,
174
+ outputLines: outputLinesArr.length,
175
+ outputBytes: finalOutputBytes,
176
+ lastLinePartial,
177
+ firstLineExceedsLimit: false,
178
+ maxLines,
179
+ maxBytes,
180
+ };
181
+ }
182
+ /**
183
+ * Truncate a string to fit within a byte limit, keeping the end. Handles
184
+ * multi-byte UTF-8 characters by advancing to the next character boundary.
185
+ */
186
+ function truncateStringToBytesFromEnd(str, maxBytes) {
187
+ const buf = Buffer.from(str, "utf-8");
188
+ if (buf.length <= maxBytes)
189
+ return str;
190
+ let start = buf.length - maxBytes;
191
+ // Skip continuation bytes (0x80-masked) to land on a character start.
192
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80)
193
+ start++;
194
+ return buf.subarray(start).toString("utf-8");
195
+ }
196
+ /**
197
+ * Truncate a single line to max characters, adding a `[truncated]` suffix.
198
+ * Used for long single-line outputs (e.g. grep match lines).
199
+ */
200
+ export function truncateLine(line, maxChars = DEFAULT_LINE_CHAR_LIMIT) {
201
+ if (line.length <= maxChars)
202
+ return { text: line, wasTruncated: false };
203
+ return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
204
+ }
205
+ //# sourceMappingURL=truncate.js.map
@@ -0,0 +1,16 @@
1
+ import type { ToolDefinition } from "@arnilo/prism";
2
+ /**
3
+ * Pluggable operations for the write tool. Override to delegate file writing to remote systems
4
+ * (e.g. SSH) while keeping the tool's directory-creation + per-path serialization behavior.
5
+ */
6
+ export interface WriteOperations {
7
+ /** Write content to a file (creating or overwriting). */
8
+ writeFile: (absolutePath: string, content: string) => Promise<void>;
9
+ /** Create a directory recursively. */
10
+ mkdir: (dir: string) => Promise<void>;
11
+ }
12
+ export interface WriteToolOptions {
13
+ /** Custom operations backend (default: local filesystem). */
14
+ operations?: WriteOperations;
15
+ }
16
+ export declare function createWriteTool(cwd: string, options?: WriteToolOptions): ToolDefinition;