@ottocode/sdk 0.1.279 → 0.1.280
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/package.json +1 -1
- package/src/core/src/tools/builtin/fs/copy-attachment.ts +196 -0
- package/src/core/src/tools/builtin/fs/copy-attachment.txt +9 -0
- package/src/core/src/tools/builtin/fs/index.ts +4 -0
- package/src/core/src/tools/builtin/fs/read-image.ts +381 -0
- package/src/core/src/tools/builtin/fs/read-image.txt +5 -0
package/package.json
CHANGED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { copyFile, mkdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { dirname, extname, join } from 'node:path';
|
|
4
|
+
import { tool, type Tool } from 'ai';
|
|
5
|
+
import { z } from 'zod/v3';
|
|
6
|
+
import { createToolError, type ToolResponse } from '../../error.ts';
|
|
7
|
+
import { expandTilde, isAbsoluteLike, resolveSafePath } from './util.ts';
|
|
8
|
+
import { rememberFileWrite } from './read-tracker.ts';
|
|
9
|
+
import DESCRIPTION from './copy-attachment.txt' with { type: 'text' };
|
|
10
|
+
|
|
11
|
+
type AttachmentMetadata = {
|
|
12
|
+
id: string;
|
|
13
|
+
filename: string;
|
|
14
|
+
mimeType: string;
|
|
15
|
+
size: number;
|
|
16
|
+
sha256: string;
|
|
17
|
+
kind: 'image' | 'pdf' | 'text' | 'binary';
|
|
18
|
+
originalPath: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const ATTACHMENTS_DIR = '.otto/attachments';
|
|
23
|
+
const MIME_EXTENSIONS: Record<string, string[]> = {
|
|
24
|
+
'image/png': ['.png'],
|
|
25
|
+
'image/jpeg': ['.jpg', '.jpeg'],
|
|
26
|
+
'image/jpg': ['.jpg', '.jpeg'],
|
|
27
|
+
'image/gif': ['.gif'],
|
|
28
|
+
'image/webp': ['.webp'],
|
|
29
|
+
'image/svg+xml': ['.svg'],
|
|
30
|
+
'image/bmp': ['.bmp'],
|
|
31
|
+
'image/avif': ['.avif'],
|
|
32
|
+
'application/pdf': ['.pdf'],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function getExpectedExtensions(mimeType: string): string[] {
|
|
36
|
+
return MIME_EXTENSIONS[mimeType.toLowerCase().split(';', 1)[0].trim()] ?? [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function replaceExtension(path: string, extension: string): string {
|
|
40
|
+
return extname(path)
|
|
41
|
+
? path.replace(/\.[^/.]*$/, extension)
|
|
42
|
+
: `${path}${extension}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readAttachmentMetadata(
|
|
46
|
+
projectRoot: string,
|
|
47
|
+
attachmentId: string,
|
|
48
|
+
): Promise<AttachmentMetadata> {
|
|
49
|
+
const metadataPath = join(
|
|
50
|
+
projectRoot,
|
|
51
|
+
ATTACHMENTS_DIR,
|
|
52
|
+
attachmentId,
|
|
53
|
+
'metadata.json',
|
|
54
|
+
);
|
|
55
|
+
const raw = await readFile(metadataPath, 'utf-8');
|
|
56
|
+
return JSON.parse(raw) as AttachmentMetadata;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildCopyAttachmentTool(projectRoot: string): {
|
|
60
|
+
name: string;
|
|
61
|
+
tool: Tool;
|
|
62
|
+
} {
|
|
63
|
+
const copyAttachment = tool({
|
|
64
|
+
description: DESCRIPTION,
|
|
65
|
+
inputSchema: z.object({
|
|
66
|
+
attachmentId: z
|
|
67
|
+
.string()
|
|
68
|
+
.describe('Attachment id from the current chat, e.g. att_...'),
|
|
69
|
+
targetPath: z
|
|
70
|
+
.string()
|
|
71
|
+
.describe('Project-relative destination path for the original file.'),
|
|
72
|
+
overwrite: z
|
|
73
|
+
.boolean()
|
|
74
|
+
.optional()
|
|
75
|
+
.default(false)
|
|
76
|
+
.describe('Overwrite the target path if it already exists.'),
|
|
77
|
+
createDirs: z
|
|
78
|
+
.boolean()
|
|
79
|
+
.optional()
|
|
80
|
+
.default(true)
|
|
81
|
+
.describe('Create parent directories for the target path.'),
|
|
82
|
+
}),
|
|
83
|
+
async execute({
|
|
84
|
+
attachmentId,
|
|
85
|
+
targetPath,
|
|
86
|
+
overwrite,
|
|
87
|
+
createDirs,
|
|
88
|
+
}: {
|
|
89
|
+
attachmentId: string;
|
|
90
|
+
targetPath: string;
|
|
91
|
+
overwrite?: boolean;
|
|
92
|
+
createDirs?: boolean;
|
|
93
|
+
}): Promise<
|
|
94
|
+
ToolResponse<{
|
|
95
|
+
attachmentId: string;
|
|
96
|
+
path: string;
|
|
97
|
+
filename: string;
|
|
98
|
+
mimeType: string;
|
|
99
|
+
bytes: number;
|
|
100
|
+
sha256: string;
|
|
101
|
+
created: boolean;
|
|
102
|
+
requestedPath?: string;
|
|
103
|
+
extensionAdjusted?: boolean;
|
|
104
|
+
}>
|
|
105
|
+
> {
|
|
106
|
+
if (!attachmentId || attachmentId.trim().length === 0) {
|
|
107
|
+
return createToolError(
|
|
108
|
+
'Missing required parameter: attachmentId',
|
|
109
|
+
'validation',
|
|
110
|
+
{ parameter: 'attachmentId' },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!targetPath || targetPath.trim().length === 0) {
|
|
114
|
+
return createToolError(
|
|
115
|
+
'Missing required parameter: targetPath',
|
|
116
|
+
'validation',
|
|
117
|
+
{ parameter: 'targetPath' },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const requestedPath = expandTilde(targetPath);
|
|
122
|
+
if (isAbsoluteLike(requestedPath)) {
|
|
123
|
+
return createToolError(
|
|
124
|
+
`Refusing to copy outside project root: ${requestedPath}. Use a relative path within the project.`,
|
|
125
|
+
'permission',
|
|
126
|
+
{
|
|
127
|
+
parameter: 'targetPath',
|
|
128
|
+
value: requestedPath,
|
|
129
|
+
suggestion: 'Use a relative path within the project',
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const metadata = await readAttachmentMetadata(
|
|
136
|
+
projectRoot,
|
|
137
|
+
attachmentId,
|
|
138
|
+
);
|
|
139
|
+
const source = join(projectRoot, metadata.originalPath);
|
|
140
|
+
const expectedExtensions = getExpectedExtensions(metadata.mimeType);
|
|
141
|
+
const targetExtension = extname(requestedPath).toLowerCase();
|
|
142
|
+
const preferredExtension = expectedExtensions[0];
|
|
143
|
+
const req =
|
|
144
|
+
preferredExtension && !expectedExtensions.includes(targetExtension)
|
|
145
|
+
? replaceExtension(requestedPath, preferredExtension)
|
|
146
|
+
: requestedPath;
|
|
147
|
+
const extensionAdjusted = req !== requestedPath;
|
|
148
|
+
const target = resolveSafePath(projectRoot, req);
|
|
149
|
+
try {
|
|
150
|
+
await stat(target);
|
|
151
|
+
if (!overwrite) {
|
|
152
|
+
return createToolError(
|
|
153
|
+
`Target file already exists: ${req}`,
|
|
154
|
+
'validation',
|
|
155
|
+
{
|
|
156
|
+
parameter: 'targetPath',
|
|
157
|
+
value: req,
|
|
158
|
+
suggestion:
|
|
159
|
+
'Choose a different target path or set overwrite to true',
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (createDirs) {
|
|
168
|
+
await mkdir(dirname(target), { recursive: true });
|
|
169
|
+
}
|
|
170
|
+
await copyFile(source, target);
|
|
171
|
+
await rememberFileWrite(projectRoot, target);
|
|
172
|
+
const bytes = await readFile(target);
|
|
173
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
174
|
+
return {
|
|
175
|
+
ok: true,
|
|
176
|
+
attachmentId,
|
|
177
|
+
path: req,
|
|
178
|
+
...(extensionAdjusted ? { requestedPath } : {}),
|
|
179
|
+
extensionAdjusted,
|
|
180
|
+
filename: metadata.filename,
|
|
181
|
+
mimeType: metadata.mimeType,
|
|
182
|
+
bytes: bytes.byteLength,
|
|
183
|
+
sha256,
|
|
184
|
+
created: true,
|
|
185
|
+
};
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return createToolError(
|
|
188
|
+
`Failed to copy attachment: ${error instanceof Error ? error.message : String(error)}`,
|
|
189
|
+
'execution',
|
|
190
|
+
{ parameter: 'attachmentId', value: attachmentId },
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
return { name: 'copy_attachment_to_project', tool: copyAttachment };
|
|
196
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Copy an original uploaded chat attachment into the project.
|
|
2
|
+
|
|
3
|
+
Use this when the user drops/uploads a file and asks you to add it to the repo. The copied bytes are the untouched original upload, not the compressed image sent to the model.
|
|
4
|
+
|
|
5
|
+
Rules:
|
|
6
|
+
- `targetPath` must be project-relative and cannot escape the project root.
|
|
7
|
+
- Existing files are not overwritten unless `overwrite: true` is provided.
|
|
8
|
+
- For known file types, the tool uses the original MIME type to ensure the final extension is correct. For example, if `targetPath` is `.jpg` but the upload is `image/png`, the file is written as `.png` and the returned `path` is the actual path.
|
|
9
|
+
- Prefer sensible asset paths such as `public/`, `src/assets/`, or an existing app asset directory.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { Tool } from 'ai';
|
|
2
2
|
import { buildEditTool } from './edit.ts';
|
|
3
3
|
import { buildReadTool } from './read.ts';
|
|
4
|
+
import { buildReadImageTool } from './read-image.ts';
|
|
4
5
|
import { buildMultiEditTool } from './multiedit.ts';
|
|
5
6
|
import { buildWriteTool } from './write.ts';
|
|
6
7
|
import { buildCopyIntoTool } from './copy-into.ts';
|
|
8
|
+
import { buildCopyAttachmentTool } from './copy-attachment.ts';
|
|
7
9
|
import { buildLsTool } from './ls.ts';
|
|
8
10
|
import { buildTreeTool } from './tree.ts';
|
|
9
11
|
import { buildPwdTool } from './pwd.ts';
|
|
@@ -14,10 +16,12 @@ export function buildFsTools(
|
|
|
14
16
|
): Array<{ name: string; tool: Tool }> {
|
|
15
17
|
const out: Array<{ name: string; tool: Tool }> = [];
|
|
16
18
|
out.push(buildReadTool(projectRoot));
|
|
19
|
+
out.push(buildReadImageTool(projectRoot));
|
|
17
20
|
out.push(buildEditTool(projectRoot));
|
|
18
21
|
out.push(buildMultiEditTool(projectRoot));
|
|
19
22
|
out.push(buildWriteTool(projectRoot));
|
|
20
23
|
out.push(buildCopyIntoTool(projectRoot));
|
|
24
|
+
out.push(buildCopyAttachmentTool(projectRoot));
|
|
21
25
|
out.push(buildLsTool(projectRoot));
|
|
22
26
|
out.push(buildTreeTool(projectRoot));
|
|
23
27
|
out.push(buildPwdTool());
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { tool, type Tool } from 'ai';
|
|
5
|
+
import { z } from 'zod/v3';
|
|
6
|
+
import { createToolError, type ToolResponse } from '../../error.ts';
|
|
7
|
+
import { expandTilde, isAbsoluteLike, resolveSafePath } from './util.ts';
|
|
8
|
+
import DESCRIPTION from './read-image.txt' with { type: 'text' };
|
|
9
|
+
|
|
10
|
+
type BunImageMetadata = {
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
13
|
+
format?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type BunImagePipeline = {
|
|
17
|
+
metadata(): Promise<BunImageMetadata>;
|
|
18
|
+
resize(
|
|
19
|
+
width: number,
|
|
20
|
+
height?: number,
|
|
21
|
+
options?: {
|
|
22
|
+
fit?: 'inside';
|
|
23
|
+
withoutEnlargement?: boolean;
|
|
24
|
+
},
|
|
25
|
+
): BunImagePipeline;
|
|
26
|
+
jpeg(options?: { quality?: number }): BunImagePipeline;
|
|
27
|
+
bytes(): Promise<Uint8Array>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type BunImageConstructor = new (
|
|
31
|
+
input: string | ArrayBuffer | Uint8Array | Blob,
|
|
32
|
+
) => BunImagePipeline;
|
|
33
|
+
|
|
34
|
+
type JsonValue =
|
|
35
|
+
| null
|
|
36
|
+
| boolean
|
|
37
|
+
| number
|
|
38
|
+
| string
|
|
39
|
+
| JsonValue[]
|
|
40
|
+
| { [key: string]: JsonValue };
|
|
41
|
+
|
|
42
|
+
type ReadImageResult = {
|
|
43
|
+
ok: true;
|
|
44
|
+
path: string;
|
|
45
|
+
mediaType: string;
|
|
46
|
+
data: string;
|
|
47
|
+
size: number;
|
|
48
|
+
transmittedSize: number;
|
|
49
|
+
sha256: string;
|
|
50
|
+
compressed: boolean;
|
|
51
|
+
width?: number;
|
|
52
|
+
height?: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type PreparedImage = {
|
|
56
|
+
data: Uint8Array;
|
|
57
|
+
mediaType: string;
|
|
58
|
+
width?: number;
|
|
59
|
+
height?: number;
|
|
60
|
+
compressed: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type ReadImageInput = {
|
|
64
|
+
path: string;
|
|
65
|
+
maxEdge?: number;
|
|
66
|
+
quality?: number;
|
|
67
|
+
maxBytes?: number;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const DEFAULT_MAX_EDGE = 1568;
|
|
71
|
+
const DEFAULT_QUALITY = 82;
|
|
72
|
+
const DEFAULT_MAX_BYTES = 4 * 1024 * 1024;
|
|
73
|
+
const JPEG_MEDIA_TYPE = 'image/jpeg';
|
|
74
|
+
const SUPPORTED_IMAGE_TYPES = new Set([
|
|
75
|
+
'image/bmp',
|
|
76
|
+
'image/gif',
|
|
77
|
+
'image/jpeg',
|
|
78
|
+
'image/png',
|
|
79
|
+
'image/webp',
|
|
80
|
+
]);
|
|
81
|
+
const COMPRESSIBLE_IMAGE_TYPES = new Set([
|
|
82
|
+
'image/bmp',
|
|
83
|
+
'image/jpeg',
|
|
84
|
+
'image/png',
|
|
85
|
+
'image/webp',
|
|
86
|
+
]);
|
|
87
|
+
const MEDIA_TYPE_BY_EXTENSION: Record<string, string> = {
|
|
88
|
+
'.bmp': 'image/bmp',
|
|
89
|
+
'.gif': 'image/gif',
|
|
90
|
+
'.jpeg': 'image/jpeg',
|
|
91
|
+
'.jpg': 'image/jpeg',
|
|
92
|
+
'.png': 'image/png',
|
|
93
|
+
'.webp': 'image/webp',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function getBunImageConstructor(): BunImageConstructor | undefined {
|
|
97
|
+
return (Bun as typeof Bun & { Image?: BunImageConstructor }).Image;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toJsonValue(value: unknown): JsonValue {
|
|
101
|
+
if (value === undefined) return null;
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(JSON.stringify(value)) as JsonValue;
|
|
104
|
+
} catch {
|
|
105
|
+
return String(value);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeMediaType(mediaType: string | undefined): string | undefined {
|
|
110
|
+
const normalized = mediaType?.toLowerCase().split(';', 1)[0].trim();
|
|
111
|
+
return normalized === 'image/jpg' ? JPEG_MEDIA_TYPE : normalized;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function detectMediaType(
|
|
115
|
+
filePath: string,
|
|
116
|
+
data: Uint8Array,
|
|
117
|
+
): string | undefined {
|
|
118
|
+
if (
|
|
119
|
+
data.length >= 8 &&
|
|
120
|
+
data[0] === 0x89 &&
|
|
121
|
+
data[1] === 0x50 &&
|
|
122
|
+
data[2] === 0x4e &&
|
|
123
|
+
data[3] === 0x47
|
|
124
|
+
) {
|
|
125
|
+
return 'image/png';
|
|
126
|
+
}
|
|
127
|
+
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8) {
|
|
128
|
+
return JPEG_MEDIA_TYPE;
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
data.length >= 12 &&
|
|
132
|
+
data[0] === 0x52 &&
|
|
133
|
+
data[1] === 0x49 &&
|
|
134
|
+
data[2] === 0x46 &&
|
|
135
|
+
data[3] === 0x46 &&
|
|
136
|
+
data[8] === 0x57 &&
|
|
137
|
+
data[9] === 0x45 &&
|
|
138
|
+
data[10] === 0x42 &&
|
|
139
|
+
data[11] === 0x50
|
|
140
|
+
) {
|
|
141
|
+
return 'image/webp';
|
|
142
|
+
}
|
|
143
|
+
if (
|
|
144
|
+
data.length >= 6 &&
|
|
145
|
+
data[0] === 0x47 &&
|
|
146
|
+
data[1] === 0x49 &&
|
|
147
|
+
data[2] === 0x46
|
|
148
|
+
) {
|
|
149
|
+
return 'image/gif';
|
|
150
|
+
}
|
|
151
|
+
if (data.length >= 2 && data[0] === 0x42 && data[1] === 0x4d) {
|
|
152
|
+
return 'image/bmp';
|
|
153
|
+
}
|
|
154
|
+
return normalizeMediaType(
|
|
155
|
+
MEDIA_TYPE_BY_EXTENSION[extname(filePath).toLowerCase()],
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function prepareImage(
|
|
160
|
+
input: Uint8Array,
|
|
161
|
+
mediaType: string,
|
|
162
|
+
options: Required<Pick<ReadImageInput, 'maxEdge' | 'quality'>>,
|
|
163
|
+
): Promise<PreparedImage> {
|
|
164
|
+
const ImageConstructor = getBunImageConstructor();
|
|
165
|
+
if (!ImageConstructor || !COMPRESSIBLE_IMAGE_TYPES.has(mediaType)) {
|
|
166
|
+
return { data: input, mediaType, compressed: false };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const image = new ImageConstructor(input);
|
|
171
|
+
const metadata = await image.metadata();
|
|
172
|
+
const width = metadata.width ?? 0;
|
|
173
|
+
const height = metadata.height ?? 0;
|
|
174
|
+
if (width <= 0 || height <= 0) {
|
|
175
|
+
return { data: input, mediaType, compressed: false };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const pipeline =
|
|
179
|
+
width > options.maxEdge || height > options.maxEdge
|
|
180
|
+
? image.resize(options.maxEdge, options.maxEdge, {
|
|
181
|
+
fit: 'inside',
|
|
182
|
+
withoutEnlargement: true,
|
|
183
|
+
})
|
|
184
|
+
: image;
|
|
185
|
+
const output = await pipeline.jpeg({ quality: options.quality }).bytes();
|
|
186
|
+
|
|
187
|
+
if (output.byteLength >= input.byteLength) {
|
|
188
|
+
return { data: input, mediaType, width, height, compressed: false };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
data: output,
|
|
193
|
+
mediaType: JPEG_MEDIA_TYPE,
|
|
194
|
+
width,
|
|
195
|
+
height,
|
|
196
|
+
compressed: true,
|
|
197
|
+
};
|
|
198
|
+
} catch {
|
|
199
|
+
return { data: input, mediaType, compressed: false };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Builds the read_image tool, which loads local image files and returns image
|
|
205
|
+
* content that vision-capable language models can inspect.
|
|
206
|
+
*/
|
|
207
|
+
export function buildReadImageTool(projectRoot: string): {
|
|
208
|
+
name: string;
|
|
209
|
+
tool: Tool;
|
|
210
|
+
} {
|
|
211
|
+
const readImage = tool({
|
|
212
|
+
description: DESCRIPTION,
|
|
213
|
+
inputSchema: z.object({
|
|
214
|
+
path: z
|
|
215
|
+
.string()
|
|
216
|
+
.describe(
|
|
217
|
+
"Image path. Relative to project root by default; absolute ('/...') and home ('~/...') paths are allowed.",
|
|
218
|
+
),
|
|
219
|
+
maxEdge: z
|
|
220
|
+
.number()
|
|
221
|
+
.int()
|
|
222
|
+
.min(1)
|
|
223
|
+
.max(4096)
|
|
224
|
+
.optional()
|
|
225
|
+
.describe(
|
|
226
|
+
'Maximum width or height for model submission. Defaults to 1568.',
|
|
227
|
+
),
|
|
228
|
+
quality: z
|
|
229
|
+
.number()
|
|
230
|
+
.int()
|
|
231
|
+
.min(1)
|
|
232
|
+
.max(100)
|
|
233
|
+
.optional()
|
|
234
|
+
.describe('JPEG quality when compression is used. Defaults to 82.'),
|
|
235
|
+
maxBytes: z
|
|
236
|
+
.number()
|
|
237
|
+
.int()
|
|
238
|
+
.min(1024)
|
|
239
|
+
.max(20 * 1024 * 1024)
|
|
240
|
+
.optional()
|
|
241
|
+
.describe(
|
|
242
|
+
'Maximum transmitted image bytes after compression. Defaults to 4 MiB.',
|
|
243
|
+
),
|
|
244
|
+
}),
|
|
245
|
+
async execute({
|
|
246
|
+
path,
|
|
247
|
+
maxEdge = DEFAULT_MAX_EDGE,
|
|
248
|
+
quality = DEFAULT_QUALITY,
|
|
249
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
250
|
+
}: ReadImageInput): Promise<ToolResponse<ReadImageResult>> {
|
|
251
|
+
if (!path || path.trim().length === 0) {
|
|
252
|
+
return createToolError(
|
|
253
|
+
'Missing required parameter: path',
|
|
254
|
+
'validation',
|
|
255
|
+
{
|
|
256
|
+
parameter: 'path',
|
|
257
|
+
value: path,
|
|
258
|
+
suggestion: 'Provide an image file path to read',
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const req = expandTilde(path);
|
|
264
|
+
let abs: string;
|
|
265
|
+
try {
|
|
266
|
+
abs = isAbsoluteLike(req) ? req : resolveSafePath(projectRoot, req);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return createToolError(
|
|
269
|
+
`Invalid image path: ${error instanceof Error ? error.message : String(error)}`,
|
|
270
|
+
'validation',
|
|
271
|
+
{ parameter: 'path', value: req },
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const input = await readFile(abs);
|
|
277
|
+
if (input.byteLength === 0) {
|
|
278
|
+
return createToolError('Image file is empty', 'validation', {
|
|
279
|
+
parameter: 'path',
|
|
280
|
+
value: req,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const mediaType = detectMediaType(abs, input);
|
|
285
|
+
if (!mediaType || !SUPPORTED_IMAGE_TYPES.has(mediaType)) {
|
|
286
|
+
return createToolError(
|
|
287
|
+
`Unsupported image type: ${mediaType ?? 'unknown'}`,
|
|
288
|
+
'validation',
|
|
289
|
+
{
|
|
290
|
+
parameter: 'path',
|
|
291
|
+
value: req,
|
|
292
|
+
suggestion:
|
|
293
|
+
'Use PNG, JPEG, WebP, GIF, or BMP images with read_image',
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const prepared = await prepareImage(input, mediaType, {
|
|
299
|
+
maxEdge,
|
|
300
|
+
quality,
|
|
301
|
+
});
|
|
302
|
+
if (prepared.data.byteLength > maxBytes) {
|
|
303
|
+
return createToolError(
|
|
304
|
+
`Image is too large after compression: ${prepared.data.byteLength} bytes`,
|
|
305
|
+
'validation',
|
|
306
|
+
{
|
|
307
|
+
parameter: 'maxBytes',
|
|
308
|
+
value: maxBytes,
|
|
309
|
+
suggestion:
|
|
310
|
+
'Increase maxBytes or lower maxEdge/quality for this image',
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const result: ReadImageResult = {
|
|
316
|
+
ok: true,
|
|
317
|
+
path: req,
|
|
318
|
+
mediaType: prepared.mediaType,
|
|
319
|
+
data: Buffer.from(prepared.data).toString('base64'),
|
|
320
|
+
size: input.byteLength,
|
|
321
|
+
transmittedSize: prepared.data.byteLength,
|
|
322
|
+
sha256: createHash('sha256').update(input).digest('hex'),
|
|
323
|
+
compressed: prepared.compressed,
|
|
324
|
+
};
|
|
325
|
+
if (prepared.width) result.width = prepared.width;
|
|
326
|
+
if (prepared.height) result.height = prepared.height;
|
|
327
|
+
return result;
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const isEnoent =
|
|
330
|
+
error &&
|
|
331
|
+
typeof error === 'object' &&
|
|
332
|
+
'code' in error &&
|
|
333
|
+
error.code === 'ENOENT';
|
|
334
|
+
return createToolError(
|
|
335
|
+
isEnoent
|
|
336
|
+
? `Image not found: ${req}`
|
|
337
|
+
: `Failed to read image: ${error instanceof Error ? error.message : String(error)}`,
|
|
338
|
+
isEnoent ? 'not_found' : 'execution',
|
|
339
|
+
{
|
|
340
|
+
parameter: 'path',
|
|
341
|
+
value: req,
|
|
342
|
+
suggestion: isEnoent
|
|
343
|
+
? 'Use ls or tree to find available images'
|
|
344
|
+
: undefined,
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
toModelOutput({ output }) {
|
|
350
|
+
const maybeResult = output as Partial<ReadImageResult>;
|
|
351
|
+
if (
|
|
352
|
+
maybeResult.ok !== true ||
|
|
353
|
+
typeof maybeResult.data !== 'string' ||
|
|
354
|
+
typeof maybeResult.mediaType !== 'string'
|
|
355
|
+
) {
|
|
356
|
+
return { type: 'json', value: toJsonValue(output) };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const dimensions =
|
|
360
|
+
typeof maybeResult.width === 'number' &&
|
|
361
|
+
typeof maybeResult.height === 'number'
|
|
362
|
+
? `, ${maybeResult.width}x${maybeResult.height}`
|
|
363
|
+
: '';
|
|
364
|
+
return {
|
|
365
|
+
type: 'content',
|
|
366
|
+
value: [
|
|
367
|
+
{
|
|
368
|
+
type: 'text',
|
|
369
|
+
text: `Image read from ${maybeResult.path} (${maybeResult.mediaType}${dimensions}, ${maybeResult.transmittedSize} bytes). Inspect the following image content.`,
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
type: 'image-data',
|
|
373
|
+
data: maybeResult.data,
|
|
374
|
+
mediaType: maybeResult.mediaType,
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
};
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
return { name: 'read_image', tool: readImage };
|
|
381
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
- Read a local image file and send it to the language model as image content
|
|
2
|
+
- Accepts project-relative paths by default; absolute and home paths are allowed
|
|
3
|
+
- Uses Bun.Image to downscale/compress supported images before model submission when possible
|
|
4
|
+
- Returns metadata plus the visual image payload; use when you need to inspect screenshots, icons, diagrams, or other images
|
|
5
|
+
- For text files, use the read tool instead
|