@oh-my-pi/pi-mom 1.337.0

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.
@@ -0,0 +1,165 @@
1
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
3
+ import { Type } from "@sinclair/typebox";
4
+ import { extname } from "path";
5
+ import type { Executor } from "../sandbox.js";
6
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
7
+
8
+ /**
9
+ * Map of file extensions to MIME types for common image formats
10
+ */
11
+ const IMAGE_MIME_TYPES: Record<string, string> = {
12
+ ".jpg": "image/jpeg",
13
+ ".jpeg": "image/jpeg",
14
+ ".png": "image/png",
15
+ ".gif": "image/gif",
16
+ ".webp": "image/webp",
17
+ };
18
+
19
+ /**
20
+ * Check if a file is an image based on its extension
21
+ */
22
+ function isImageFile(filePath: string): string | null {
23
+ const ext = extname(filePath).toLowerCase();
24
+ return IMAGE_MIME_TYPES[ext] || null;
25
+ }
26
+
27
+ const readSchema = Type.Object({
28
+ label: Type.String({ description: "Brief description of what you're reading and why (shown to user)" }),
29
+ path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
30
+ offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
31
+ limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
32
+ });
33
+
34
+ interface ReadToolDetails {
35
+ truncation?: TruncationResult;
36
+ }
37
+
38
+ export function createReadTool(executor: Executor): AgentTool<typeof readSchema> {
39
+ return {
40
+ name: "read",
41
+ label: "read",
42
+ description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${
43
+ DEFAULT_MAX_BYTES / 1024
44
+ }KB (whichever is hit first). Use offset/limit for large files.`,
45
+ parameters: readSchema,
46
+ execute: async (
47
+ _toolCallId: string,
48
+ { path, offset, limit }: { label: string; path: string; offset?: number; limit?: number },
49
+ signal?: AbortSignal,
50
+ ): Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }> => {
51
+ const mimeType = isImageFile(path);
52
+
53
+ if (mimeType) {
54
+ // Read as image (binary) - use base64
55
+ const result = await executor.exec(`base64 < ${shellEscape(path)}`, { signal });
56
+ if (result.code !== 0) {
57
+ throw new Error(result.stderr || `Failed to read file: ${path}`);
58
+ }
59
+ const base64 = result.stdout.replace(/\s/g, ""); // Remove whitespace from base64
60
+
61
+ return {
62
+ content: [
63
+ { type: "text", text: `Read image file [${mimeType}]` },
64
+ { type: "image", data: base64, mimeType },
65
+ ],
66
+ details: undefined,
67
+ };
68
+ }
69
+
70
+ // Get total line count first
71
+ const countResult = await executor.exec(`wc -l < ${shellEscape(path)}`, { signal });
72
+ if (countResult.code !== 0) {
73
+ throw new Error(countResult.stderr || `Failed to read file: ${path}`);
74
+ }
75
+ const totalFileLines = Number.parseInt(countResult.stdout.trim(), 10) + 1; // wc -l counts newlines, not lines
76
+
77
+ // Apply offset if specified (1-indexed)
78
+ const startLine = offset ? Math.max(1, offset) : 1;
79
+ const startLineDisplay = startLine;
80
+
81
+ // Check if offset is out of bounds
82
+ if (startLine > totalFileLines) {
83
+ throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`);
84
+ }
85
+
86
+ // Read content with offset
87
+ let cmd: string;
88
+ if (startLine === 1) {
89
+ cmd = `cat ${shellEscape(path)}`;
90
+ } else {
91
+ cmd = `tail -n +${startLine} ${shellEscape(path)}`;
92
+ }
93
+
94
+ const result = await executor.exec(cmd, { signal });
95
+ if (result.code !== 0) {
96
+ throw new Error(result.stderr || `Failed to read file: ${path}`);
97
+ }
98
+
99
+ let selectedContent = result.stdout;
100
+ let userLimitedLines: number | undefined;
101
+
102
+ // Apply user limit if specified
103
+ if (limit !== undefined) {
104
+ const lines = selectedContent.split("\n");
105
+ const endLine = Math.min(limit, lines.length);
106
+ selectedContent = lines.slice(0, endLine).join("\n");
107
+ userLimitedLines = endLine;
108
+ }
109
+
110
+ // Apply truncation (respects both line and byte limits)
111
+ const truncation = truncateHead(selectedContent);
112
+
113
+ let outputText: string;
114
+ let details: ReadToolDetails | undefined;
115
+
116
+ if (truncation.firstLineExceedsLimit) {
117
+ // First line at offset exceeds 50KB - tell model to use bash
118
+ const firstLineSize = formatSize(Buffer.byteLength(selectedContent.split("\n")[0], "utf-8"));
119
+ outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(
120
+ DEFAULT_MAX_BYTES,
121
+ )} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
122
+ details = { truncation };
123
+ } else if (truncation.truncated) {
124
+ // Truncation occurred - build actionable notice
125
+ const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
126
+ const nextOffset = endLineDisplay + 1;
127
+
128
+ outputText = truncation.content;
129
+
130
+ if (truncation.truncatedBy === "lines") {
131
+ outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
132
+ } else {
133
+ outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(
134
+ DEFAULT_MAX_BYTES,
135
+ )} limit). Use offset=${nextOffset} to continue]`;
136
+ }
137
+ details = { truncation };
138
+ } else if (userLimitedLines !== undefined) {
139
+ // User specified limit, check if there's more content
140
+ const linesFromStart = startLine - 1 + userLimitedLines;
141
+ if (linesFromStart < totalFileLines) {
142
+ const remaining = totalFileLines - linesFromStart;
143
+ const nextOffset = startLine + userLimitedLines;
144
+
145
+ outputText = truncation.content;
146
+ outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
147
+ } else {
148
+ outputText = truncation.content;
149
+ }
150
+ } else {
151
+ // No truncation, no user limit exceeded
152
+ outputText = truncation.content;
153
+ }
154
+
155
+ return {
156
+ content: [{ type: "text", text: outputText }],
157
+ details,
158
+ };
159
+ },
160
+ };
161
+ }
162
+
163
+ function shellEscape(s: string): string {
164
+ return `'${s.replace(/'/g, "'\\''")}'`;
165
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Shared truncation utilities for tool outputs.
3
+ *
4
+ * Truncation is based on two independent limits - whichever is hit first wins:
5
+ * - Line limit (default: 2000 lines)
6
+ * - Byte limit (default: 50KB)
7
+ *
8
+ * Never returns partial lines (except bash tail truncation edge case).
9
+ */
10
+
11
+ export const DEFAULT_MAX_LINES = 2000;
12
+ export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
13
+
14
+ export interface TruncationResult {
15
+ /** The truncated content */
16
+ content: string;
17
+ /** Whether truncation occurred */
18
+ truncated: boolean;
19
+ /** Which limit was hit: "lines", "bytes", or null if not truncated */
20
+ truncatedBy: "lines" | "bytes" | null;
21
+ /** Total number of lines in the original content */
22
+ totalLines: number;
23
+ /** Total number of bytes in the original content */
24
+ totalBytes: number;
25
+ /** Number of complete lines in the truncated output */
26
+ outputLines: number;
27
+ /** Number of bytes in the truncated output */
28
+ outputBytes: number;
29
+ /** Whether the last line was partially truncated (only for tail truncation edge case) */
30
+ lastLinePartial: boolean;
31
+ /** Whether the first line exceeded the byte limit (for head truncation) */
32
+ firstLineExceedsLimit: boolean;
33
+ }
34
+
35
+ export interface TruncationOptions {
36
+ /** Maximum number of lines (default: 2000) */
37
+ maxLines?: number;
38
+ /** Maximum number of bytes (default: 50KB) */
39
+ maxBytes?: number;
40
+ }
41
+
42
+ /**
43
+ * Format bytes as human-readable size.
44
+ */
45
+ export function formatSize(bytes: number): string {
46
+ if (bytes < 1024) {
47
+ return `${bytes}B`;
48
+ } else if (bytes < 1024 * 1024) {
49
+ return `${(bytes / 1024).toFixed(1)}KB`;
50
+ } else {
51
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Truncate content from the head (keep first N lines/bytes).
57
+ * Suitable for file reads where you want to see the beginning.
58
+ *
59
+ * Never returns partial lines. If first line exceeds byte limit,
60
+ * returns empty content with firstLineExceedsLimit=true.
61
+ */
62
+ export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
63
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
64
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
65
+
66
+ const totalBytes = Buffer.byteLength(content, "utf-8");
67
+ const lines = content.split("\n");
68
+ const totalLines = lines.length;
69
+
70
+ // Check if no truncation needed
71
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
72
+ return {
73
+ content,
74
+ truncated: false,
75
+ truncatedBy: null,
76
+ totalLines,
77
+ totalBytes,
78
+ outputLines: totalLines,
79
+ outputBytes: totalBytes,
80
+ lastLinePartial: false,
81
+ firstLineExceedsLimit: false,
82
+ };
83
+ }
84
+
85
+ // Check if first line alone exceeds byte limit
86
+ const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
87
+ if (firstLineBytes > maxBytes) {
88
+ return {
89
+ content: "",
90
+ truncated: true,
91
+ truncatedBy: "bytes",
92
+ totalLines,
93
+ totalBytes,
94
+ outputLines: 0,
95
+ outputBytes: 0,
96
+ lastLinePartial: false,
97
+ firstLineExceedsLimit: true,
98
+ };
99
+ }
100
+
101
+ // Collect complete lines that fit
102
+ const outputLinesArr: string[] = [];
103
+ let outputBytesCount = 0;
104
+ let truncatedBy: "lines" | "bytes" = "lines";
105
+
106
+ for (let i = 0; i < lines.length && i < maxLines; i++) {
107
+ const line = lines[i];
108
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
109
+
110
+ if (outputBytesCount + lineBytes > maxBytes) {
111
+ truncatedBy = "bytes";
112
+ break;
113
+ }
114
+
115
+ outputLinesArr.push(line);
116
+ outputBytesCount += lineBytes;
117
+ }
118
+
119
+ // If we exited due to line limit
120
+ if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
121
+ truncatedBy = "lines";
122
+ }
123
+
124
+ const outputContent = outputLinesArr.join("\n");
125
+ const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
126
+
127
+ return {
128
+ content: outputContent,
129
+ truncated: true,
130
+ truncatedBy,
131
+ totalLines,
132
+ totalBytes,
133
+ outputLines: outputLinesArr.length,
134
+ outputBytes: finalOutputBytes,
135
+ lastLinePartial: false,
136
+ firstLineExceedsLimit: false,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Truncate content from the tail (keep last N lines/bytes).
142
+ * Suitable for bash output where you want to see the end (errors, final results).
143
+ *
144
+ * May return partial first line if the last line of original content exceeds byte limit.
145
+ */
146
+ export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
147
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
148
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
149
+
150
+ const totalBytes = Buffer.byteLength(content, "utf-8");
151
+ const lines = content.split("\n");
152
+ const totalLines = lines.length;
153
+
154
+ // Check if no truncation needed
155
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
156
+ return {
157
+ content,
158
+ truncated: false,
159
+ truncatedBy: null,
160
+ totalLines,
161
+ totalBytes,
162
+ outputLines: totalLines,
163
+ outputBytes: totalBytes,
164
+ lastLinePartial: false,
165
+ firstLineExceedsLimit: false,
166
+ };
167
+ }
168
+
169
+ // Work backwards from the end
170
+ const outputLinesArr: string[] = [];
171
+ let outputBytesCount = 0;
172
+ let truncatedBy: "lines" | "bytes" = "lines";
173
+ let lastLinePartial = false;
174
+
175
+ for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
176
+ const line = lines[i];
177
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
178
+
179
+ if (outputBytesCount + lineBytes > maxBytes) {
180
+ truncatedBy = "bytes";
181
+ // Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes,
182
+ // take the end of the line (partial)
183
+ if (outputLinesArr.length === 0) {
184
+ const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes);
185
+ outputLinesArr.unshift(truncatedLine);
186
+ outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8");
187
+ lastLinePartial = true;
188
+ }
189
+ break;
190
+ }
191
+
192
+ outputLinesArr.unshift(line);
193
+ outputBytesCount += lineBytes;
194
+ }
195
+
196
+ // If we exited due to line limit
197
+ if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
198
+ truncatedBy = "lines";
199
+ }
200
+
201
+ const outputContent = outputLinesArr.join("\n");
202
+ const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
203
+
204
+ return {
205
+ content: outputContent,
206
+ truncated: true,
207
+ truncatedBy,
208
+ totalLines,
209
+ totalBytes,
210
+ outputLines: outputLinesArr.length,
211
+ outputBytes: finalOutputBytes,
212
+ lastLinePartial,
213
+ firstLineExceedsLimit: false,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Truncate a string to fit within a byte limit (from the end).
219
+ * Handles multi-byte UTF-8 characters correctly.
220
+ */
221
+ function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
222
+ const buf = Buffer.from(str, "utf-8");
223
+ if (buf.length <= maxBytes) {
224
+ return str;
225
+ }
226
+
227
+ // Start from the end, skip maxBytes back
228
+ let start = buf.length - maxBytes;
229
+
230
+ // Find a valid UTF-8 boundary (start of a character)
231
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80) {
232
+ start++;
233
+ }
234
+
235
+ return buf.slice(start).toString("utf-8");
236
+ }
@@ -0,0 +1,45 @@
1
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import { Type } from "@sinclair/typebox";
3
+ import type { Executor } from "../sandbox.js";
4
+
5
+ const writeSchema = Type.Object({
6
+ label: Type.String({ description: "Brief description of what you're writing (shown to user)" }),
7
+ path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
8
+ content: Type.String({ description: "Content to write to the file" }),
9
+ });
10
+
11
+ export function createWriteTool(executor: Executor): AgentTool<typeof writeSchema> {
12
+ return {
13
+ name: "write",
14
+ label: "write",
15
+ description:
16
+ "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
17
+ parameters: writeSchema,
18
+ execute: async (
19
+ _toolCallId: string,
20
+ { path, content }: { label: string; path: string; content: string },
21
+ signal?: AbortSignal,
22
+ ) => {
23
+ // Create parent directories and write file using heredoc
24
+ const dir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : ".";
25
+
26
+ // Use printf to handle content with special characters, pipe to file
27
+ // This avoids issues with heredoc and special characters
28
+ const cmd = `mkdir -p ${shellEscape(dir)} && printf '%s' ${shellEscape(content)} > ${shellEscape(path)}`;
29
+
30
+ const result = await executor.exec(cmd, { signal });
31
+ if (result.code !== 0) {
32
+ throw new Error(result.stderr || `Failed to write file: ${path}`);
33
+ }
34
+
35
+ return {
36
+ content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }],
37
+ details: undefined,
38
+ };
39
+ },
40
+ };
41
+ }
42
+
43
+ function shellEscape(s: string): string {
44
+ return `'${s.replace(/'/g, "'\\''")}'`;
45
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*.ts"],
8
+ "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
9
+ }