@2en/clawly-plugins 1.30.0-beta.2 → 1.30.0-beta.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.
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Agent tool: clawly_send_file — send a file to the user.
3
+ *
4
+ * Accepts a URL (https/http) or a local file path (or arrays of either).
5
+ * URLs are downloaded and cached locally. Injects a FILE:<path>?<filename> assistant
6
+ * message via chat.inject so the mobile client can render or display the file.
7
+ *
8
+ * Restrictions:
9
+ * - URLs: executable files are rejected (by MIME/extension).
10
+ * - Local files:
11
+ * - Dot files rejected (e.g. ~/.openclaw, .env, .bashrc).
12
+ * - Only files under $HOME or /tmp (files outside home are rejected unless in /tmp).
13
+ * - State dir ($OPENCLAW_STATE_DIR) blocked, except clawly/, canvas/, memory/ subdirs.
14
+ * - Executable files rejected (binaries + script extensions).
15
+ */
16
+
17
+ import crypto from 'node:crypto'
18
+ import fsp from 'node:fs/promises'
19
+ import os from 'node:os'
20
+ import path from 'node:path'
21
+ import {fileTypeFromBuffer, fileTypeFromFile} from 'file-type'
22
+ import {injectAssistantMessage, resolveSessionKey} from '../gateway/inject'
23
+ import type {PluginApi} from '../types'
24
+
25
+ const TOOL_NAME = 'clawly_send_file'
26
+ const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB
27
+
28
+ const parameters: Record<string, unknown> = {
29
+ type: 'object',
30
+ required: ['source'],
31
+ properties: {
32
+ source: {
33
+ oneOf: [
34
+ {type: 'string', description: 'File URL (https://...) or local file path'},
35
+ {
36
+ type: 'array',
37
+ items: {type: 'string'},
38
+ description: 'Array of file URLs or local file paths',
39
+ },
40
+ ],
41
+ },
42
+ caption: {type: 'string', description: 'Optional caption text'},
43
+ },
44
+ }
45
+
46
+ // ── Executable blocklist ───────────────────────────────────────────────
47
+
48
+ /** MIME types for binary executables (ELF, Mach-O, PE, etc.) */
49
+ const EXECUTABLE_MIMES = new Set([
50
+ 'application/x-executable',
51
+ 'application/x-elf',
52
+ 'application/x-sharedlib',
53
+ 'application/x-pie-executable',
54
+ 'application/x-mach-binary',
55
+ 'application/x-mach-binary-fat',
56
+ 'application/x-msdownload',
57
+ ])
58
+
59
+ /** Extensions for executables and executable scripts */
60
+ const EXECUTABLE_EXTS = new Set([
61
+ '.exe',
62
+ '.com',
63
+ '.bat',
64
+ '.cmd',
65
+ '.sh',
66
+ '.bash',
67
+ '.csh',
68
+ '.zsh',
69
+ '.ps1',
70
+ '.psm1',
71
+ ])
72
+
73
+ function isExecutableByMime(mime?: string): boolean {
74
+ return !!mime && EXECUTABLE_MIMES.has(mime)
75
+ }
76
+
77
+ function isExecutableByExt(filePath: string): boolean {
78
+ const ext = path.extname(filePath).toLowerCase()
79
+ return EXECUTABLE_EXTS.has(ext)
80
+ }
81
+
82
+ function hasDotFileComponent(filePath: string): boolean {
83
+ const normalized = path.normalize(filePath)
84
+ const parts = normalized.split(path.sep)
85
+ return parts.some((p) => p.startsWith('.') && p.length > 1)
86
+ }
87
+
88
+ function isUnderAllowedRoot(filePath: string): boolean {
89
+ const resolved = path.resolve(filePath)
90
+ const home = os.homedir()
91
+ return resolved.startsWith(home + path.sep) || resolved === home || resolved.startsWith('/tmp/')
92
+ }
93
+
94
+ // ── Helpers ──────────────────────────────────────────────────────────
95
+
96
+ function downloadsDir(api: PluginApi): string {
97
+ return path.join(api.runtime.state.resolveStateDir(), 'clawly', 'downloads')
98
+ }
99
+
100
+ function extFromUrl(url: string): string {
101
+ try {
102
+ const pathname = new URL(url).pathname
103
+ const ext = path.extname(pathname).toLowerCase()
104
+ if (ext) return ext
105
+ } catch {}
106
+ return ''
107
+ }
108
+
109
+ function localPath(source: string, ext: string, api: PluginApi): string {
110
+ const hash = crypto.createHash('sha256').update(source).digest('hex')
111
+ return path.join(downloadsDir(api), `${hash}${ext || ''}`)
112
+ }
113
+
114
+ async function fileExists(p: string): Promise<boolean> {
115
+ try {
116
+ await fsp.access(p)
117
+ return true
118
+ } catch {
119
+ return false
120
+ }
121
+ }
122
+
123
+ function sourceFilename(source: string): string {
124
+ try {
125
+ return path.basename(new URL(source).pathname) || path.basename(source)
126
+ } catch {
127
+ return path.basename(source)
128
+ }
129
+ }
130
+
131
+ function formatMediaMessage(filePaths: string[], sources: string[], caption?: string): string {
132
+ const parts: string[] = []
133
+ if (caption) parts.push(caption)
134
+ for (let i = 0; i < filePaths.length; i++) {
135
+ const filename = sourceFilename(sources[i] ?? filePaths[i])
136
+ const pathBasename = path.basename(filePaths[i])
137
+ const qs = filename !== pathBasename ? `?filename=${encodeURIComponent(filename)}` : ''
138
+ parts.push(`FILE:${filePaths[i]}${qs}`)
139
+ }
140
+ return parts.join('\n')
141
+ }
142
+
143
+ // ── Download ─────────────────────────────────────────────────────────
144
+
145
+ const DOWNLOAD_TIMEOUT_MS = 30_000
146
+
147
+ async function downloadFile(url: string, api: PluginApi): Promise<string> {
148
+ const dir = downloadsDir(api)
149
+ await fsp.mkdir(dir, {recursive: true})
150
+
151
+ const controller = new AbortController()
152
+ const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS)
153
+ try {
154
+ const res = await fetch(url, {redirect: 'follow', signal: controller.signal})
155
+ if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`)
156
+
157
+ const contentLength = res.headers.get('content-length')
158
+ if (contentLength && Number(contentLength) > MAX_DOWNLOAD_BYTES) {
159
+ throw new Error(`file too large (${contentLength} bytes, max ${MAX_DOWNLOAD_BYTES})`)
160
+ }
161
+
162
+ const chunks: Uint8Array[] = []
163
+ let totalSize = 0
164
+ const reader = res.body?.getReader()
165
+ if (!reader) throw new Error('no response body')
166
+
167
+ while (true) {
168
+ const {done, value} = await reader.read()
169
+ if (done) break
170
+ totalSize += value.byteLength
171
+ if (totalSize > MAX_DOWNLOAD_BYTES) {
172
+ reader.cancel()
173
+ throw new Error(`file too large (exceeded ${MAX_DOWNLOAD_BYTES} bytes)`)
174
+ }
175
+ chunks.push(value)
176
+ }
177
+
178
+ const buffer = Buffer.concat(chunks)
179
+ const type = await fileTypeFromBuffer(buffer)
180
+
181
+ if (type && isExecutableByMime(type.mime)) {
182
+ throw new Error('downloaded file is an executable and cannot be sent')
183
+ }
184
+ const urlExt = extFromUrl(url)
185
+ if (urlExt && EXECUTABLE_EXTS.has(urlExt)) {
186
+ throw new Error('downloaded file has executable extension and cannot be sent')
187
+ }
188
+
189
+ // Use urlExt for cache key so resolveSource cache lookup matches
190
+ const dest = localPath(url, urlExt, api)
191
+ await fsp.writeFile(dest, buffer)
192
+ api.logger.info(`${TOOL_NAME}: downloaded ${url} -> ${dest} (${buffer.length} bytes)`)
193
+ return dest
194
+ } finally {
195
+ clearTimeout(timeout)
196
+ }
197
+ }
198
+
199
+ // ── Tool registration ────────────────────────────────────────────────
200
+
201
+ export function registerSendFileTool(api: PluginApi) {
202
+ api.registerTool({
203
+ name: TOOL_NAME,
204
+ description:
205
+ 'Send one or more files to the user. Accepts a single URL/path or an array of URLs/paths. Supports images, documents, audio, etc. Executable files and dot files are not allowed. Local files must be under $HOME or /tmp.',
206
+ parameters,
207
+ async execute(_toolCallId, params) {
208
+ let sources: string[]
209
+ if (typeof params.source === 'string') {
210
+ sources = [params.source.trim()].filter(Boolean)
211
+ } else if (Array.isArray(params.source)) {
212
+ sources = params.source
213
+ .filter((s): s is string => typeof s === 'string')
214
+ .map((s) => s.trim())
215
+ .filter(Boolean)
216
+ } else {
217
+ sources = []
218
+ }
219
+
220
+ if (sources.length === 0) {
221
+ return {content: [{type: 'text', text: JSON.stringify({error: 'source is required'})}]}
222
+ }
223
+
224
+ const caption = typeof params.caption === 'string' ? params.caption.trim() : undefined
225
+
226
+ const resolveSource = async (source: string): Promise<string> => {
227
+ if (source.startsWith('https://') || source.startsWith('http://')) {
228
+ const guessExt = extFromUrl(source)
229
+ if (guessExt && EXECUTABLE_EXTS.has(guessExt)) {
230
+ throw new Error(`${source}: URL has executable extension and cannot be sent`)
231
+ }
232
+ const cached = localPath(source, guessExt, api)
233
+ if (await fileExists(cached)) {
234
+ api.logger.info(`${TOOL_NAME}: serving cached ${cached}`)
235
+ return cached
236
+ }
237
+ return await downloadFile(source, api)
238
+ }
239
+
240
+ // Local file source
241
+ const resolved = path.resolve(source.replace(/^~/, os.homedir()))
242
+ if (!(await fileExists(resolved))) {
243
+ throw new Error(`${source}: file not found`)
244
+ }
245
+ if (!isUnderAllowedRoot(resolved)) {
246
+ throw new Error(
247
+ `${source}: file must be under $HOME or /tmp (files outside home are not allowed)`,
248
+ )
249
+ }
250
+ // Block state dir files except clawly/, canvas/, memory/ subdirs.
251
+ // This check runs BEFORE hasDotFileComponent because the state dir
252
+ // itself may contain a dot component (e.g. ~/.openclaw).
253
+ const stateDir = api.runtime.state.resolveStateDir()
254
+ const stateDirPrefix = stateDir + path.sep
255
+ const isUnderStateDir = resolved.startsWith(stateDirPrefix) || resolved === stateDir
256
+ if (isUnderStateDir) {
257
+ const allowedPrefixes = [
258
+ path.join(stateDir, 'clawly') + path.sep,
259
+ path.join(stateDir, 'canvas') + path.sep,
260
+ path.join(stateDir, 'memory') + path.sep,
261
+ ]
262
+ if (!allowedPrefixes.some((p) => resolved.startsWith(p))) {
263
+ throw new Error(`${source}: files under the state directory are not allowed`)
264
+ }
265
+ } else if (hasDotFileComponent(resolved)) {
266
+ throw new Error(`${source}: dot files (e.g. .openclaw, .env) are not allowed`)
267
+ }
268
+ if (isExecutableByExt(resolved)) {
269
+ throw new Error(`${source}: executable files are not allowed`)
270
+ }
271
+ const type = await fileTypeFromFile(resolved)
272
+ if (type && isExecutableByMime(type.mime)) {
273
+ throw new Error(`${source}: file is an executable and cannot be sent`)
274
+ }
275
+
276
+ const ext = path.extname(resolved) || (type?.ext ? `.${type.ext}` : '')
277
+ const dest = localPath(resolved, ext, api)
278
+ if (!(await fileExists(dest))) {
279
+ const dir = path.dirname(dest)
280
+ await fsp.mkdir(dir, {recursive: true})
281
+ await fsp.copyFile(resolved, dest)
282
+ }
283
+ return dest
284
+ }
285
+
286
+ try {
287
+ const resolved = await Promise.all(sources.map(resolveSource))
288
+ const mediaMessage = formatMediaMessage(resolved, sources, caption)
289
+ const sessionKey = await resolveSessionKey('clawly', api)
290
+ await injectAssistantMessage({sessionKey, message: mediaMessage}, api)
291
+ api.logger.info(`${TOOL_NAME}: injected ${resolved.length} file(s) into session`)
292
+
293
+ return {
294
+ content: [
295
+ {type: 'text', text: JSON.stringify({sent: true, count: resolved.length, sources})},
296
+ ],
297
+ }
298
+ } catch (err) {
299
+ const msg = err instanceof Error ? err.message : String(err)
300
+ api.logger.error(`${TOOL_NAME}: ${msg}`)
301
+ return {content: [{type: 'text', text: JSON.stringify({error: msg})}]}
302
+ }
303
+ },
304
+ })
305
+
306
+ api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
307
+ }
package/tools/index.ts CHANGED
@@ -4,7 +4,7 @@ import {registerIsUserOnlineTool} from './clawly-is-user-online'
4
4
  import {registerMsgBreakTool} from './clawly-msg-break'
5
5
  import {registerDeepSearchTool, registerGrokSearchTool, registerSearchTool} from './clawly-search'
6
6
  import {registerSendAppPushTool} from './clawly-send-app-push'
7
- import {registerSendImageTool} from './clawly-send-image'
7
+ import {registerSendFileTool} from './clawly-send-file'
8
8
  import {registerSendMessageTool} from './clawly-send-message'
9
9
 
10
10
  export function registerTools(api: PluginApi) {
@@ -15,6 +15,6 @@ export function registerTools(api: PluginApi) {
15
15
  registerDeepSearchTool(api)
16
16
  registerGrokSearchTool(api)
17
17
  registerSendAppPushTool(api)
18
- registerSendImageTool(api)
18
+ registerSendFileTool(api)
19
19
  registerSendMessageTool(api)
20
20
  }
package/types.ts CHANGED
@@ -22,7 +22,7 @@ export type PluginRuntimeCore = {
22
22
  version: string
23
23
  config: {
24
24
  loadConfig: (...args: unknown[]) => unknown
25
- writeConfigFile: (...args: unknown[]) => unknown
25
+ writeConfigFile: (config: OpenClawConfig) => Promise<unknown>
26
26
  }
27
27
  system: {
28
28
  enqueueSystemEvent: (...args: unknown[]) => void
@@ -1,84 +0,0 @@
1
- /**
2
- * Ensures all dangerous node commands are in gateway.nodes.allowCommands
3
- * so the AI agent can invoke them on connected Clawly nodes.
4
- *
5
- * Dangerous commands are defined by OpenClaw's node-command-policy and
6
- * must be explicitly allowlisted. Safe commands work without allowlisting.
7
- *
8
- * Runs on gateway_start. Writes directly to openclaw.json via
9
- * writeOpenclawConfig (no restart triggered — gateway.* changes are
10
- * classified as "none" by the config watcher).
11
- */
12
- import path from 'node:path'
13
-
14
- import type {PluginApi} from '../types'
15
- import {readOpenclawConfig, writeOpenclawConfig} from '../model-gateway-setup'
16
-
17
- const DANGEROUS_COMMANDS = [
18
- // Browser commands (Mac nodes)
19
- 'browser.proxy',
20
- 'browser.navigate',
21
- 'browser.click',
22
- 'browser.type',
23
- 'browser.screenshot',
24
- 'browser.read',
25
- 'browser.tabs',
26
- 'browser.back',
27
- 'browser.scroll',
28
- 'browser.evaluate',
29
- // Reminders + calendar (iOS nodes)
30
- 'reminders.add',
31
- 'calendar.add',
32
- // Device permissions (iOS nodes) — not in OpenClaw's iOS defaults
33
- 'device.permissions',
34
- 'device.requestPermission',
35
- ]
36
-
37
- export function registerNodeDangerousAllowlist(api: PluginApi) {
38
- api.on('gateway_start', async () => {
39
- let stateDir: string
40
- try {
41
- stateDir = api.runtime.state.resolveStateDir()
42
- } catch {
43
- api.logger.warn('node-dangerous-allowlist: cannot resolve state dir, skipping')
44
- return
45
- }
46
-
47
- const configPath = path.join(stateDir, 'openclaw.json')
48
- let config: Record<string, unknown>
49
- try {
50
- config = readOpenclawConfig(configPath)
51
- } catch {
52
- api.logger.warn('node-dangerous-allowlist: failed to read config, skipping')
53
- return
54
- }
55
-
56
- const gateway = (config.gateway as Record<string, unknown>) ?? {}
57
- const nodes = (gateway.nodes as Record<string, unknown>) ?? {}
58
- const existing: string[] = Array.isArray(nodes.allowCommands)
59
- ? (nodes.allowCommands as string[])
60
- : []
61
-
62
- const existingSet = new Set(existing)
63
- const toAdd = DANGEROUS_COMMANDS.filter((cmd) => !existingSet.has(cmd))
64
-
65
- if (toAdd.length === 0) {
66
- api.logger.info('node-dangerous-allowlist: all commands already allowlisted')
67
- return
68
- }
69
-
70
- nodes.allowCommands = [...existing, ...toAdd]
71
- gateway.nodes = nodes
72
- config.gateway = gateway
73
-
74
- try {
75
- writeOpenclawConfig(configPath, config)
76
- } catch {
77
- api.logger.warn('node-dangerous-allowlist: failed to write config')
78
- return
79
- }
80
- api.logger.info(
81
- `node-dangerous-allowlist: added ${toAdd.length} commands to allowlist: ${toAdd.join(', ')}`,
82
- )
83
- })
84
- }
@@ -1,228 +0,0 @@
1
- /**
2
- * Agent tool: clawly_send_image — send an image to the user.
3
- *
4
- * Accepts a URL (https/http) or a local file path (or arrays of either).
5
- * URLs are downloaded and cached locally. Magic-byte validation ensures
6
- * only real images are served. Injects a MEDIA:<path> assistant message
7
- * via chat.inject so the mobile client renders inline images.
8
- */
9
-
10
- import crypto from 'node:crypto'
11
- import fsp from 'node:fs/promises'
12
- import path from 'node:path'
13
-
14
- import {fileTypeFromBuffer, fileTypeFromFile} from 'file-type'
15
-
16
- import {injectAssistantMessage, resolveSessionKey} from '../gateway/inject'
17
- import type {PluginApi} from '../types'
18
-
19
- const TOOL_NAME = 'clawly_send_image'
20
- const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB
21
-
22
- const parameters: Record<string, unknown> = {
23
- type: 'object',
24
- required: ['source'],
25
- properties: {
26
- source: {
27
- oneOf: [
28
- {type: 'string', description: 'Image URL (https://...) or local file path'},
29
- {
30
- type: 'array',
31
- items: {type: 'string'},
32
- description: 'Array of image URLs or local file paths',
33
- },
34
- ],
35
- },
36
- caption: {type: 'string', description: 'Optional caption text'},
37
- },
38
- }
39
-
40
- // ── Image MIME allowlist (aligned with expo-image, excluding SVG) ────
41
-
42
- const IMAGE_MIMES = new Set([
43
- 'image/jpeg',
44
- 'image/png',
45
- 'image/apng',
46
- 'image/gif',
47
- 'image/webp',
48
- 'image/avif',
49
- 'image/heic',
50
- 'image/bmp',
51
- 'image/x-icon',
52
- ])
53
-
54
- // ── Helpers ──────────────────────────────────────────────────────────
55
-
56
- function downloadsDir(api: PluginApi): string {
57
- return path.join(api.runtime.state.resolveStateDir(), 'clawly', 'downloads')
58
- }
59
-
60
- function extFromUrl(url: string): string {
61
- try {
62
- const pathname = new URL(url).pathname
63
- const ext = path.extname(pathname).toLowerCase()
64
- if (
65
- ext &&
66
- ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.heic', '.ico'].includes(ext)
67
- )
68
- return ext
69
- } catch {}
70
- return '.jpg'
71
- }
72
-
73
- function localPath(source: string, ext: string, api: PluginApi): string {
74
- const hash = crypto.createHash('sha256').update(source).digest('hex')
75
- return path.join(downloadsDir(api), `${hash}${ext}`)
76
- }
77
-
78
- async function fileExists(p: string): Promise<boolean> {
79
- try {
80
- await fsp.access(p)
81
- return true
82
- } catch {
83
- return false
84
- }
85
- }
86
-
87
- function formatMediaMessage(filePaths: string[], caption?: string): string {
88
- const parts: string[] = []
89
- if (caption) parts.push(caption)
90
- for (const p of filePaths) parts.push(`MEDIA:${p}`)
91
- return parts.join('\n')
92
- }
93
-
94
- // ── Download ─────────────────────────────────────────────────────────
95
-
96
- /** Downloads an image, validates it, and saves with the detected extension. Returns the final path or throws. */
97
- const DOWNLOAD_TIMEOUT_MS = 30_000
98
-
99
- async function downloadImage(url: string, api: PluginApi): Promise<string> {
100
- const dir = downloadsDir(api)
101
- await fsp.mkdir(dir, {recursive: true})
102
-
103
- const controller = new AbortController()
104
- const timeout = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS)
105
- try {
106
- const res = await fetch(url, {redirect: 'follow', signal: controller.signal})
107
- if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`)
108
-
109
- const contentLength = res.headers.get('content-length')
110
- if (contentLength && Number(contentLength) > MAX_DOWNLOAD_BYTES) {
111
- throw new Error(`file too large (${contentLength} bytes, max ${MAX_DOWNLOAD_BYTES})`)
112
- }
113
-
114
- const chunks: Uint8Array[] = []
115
- let totalSize = 0
116
- const reader = res.body?.getReader()
117
- if (!reader) throw new Error('no response body')
118
-
119
- while (true) {
120
- const {done, value} = await reader.read()
121
- if (done) break
122
- totalSize += value.byteLength
123
- if (totalSize > MAX_DOWNLOAD_BYTES) {
124
- reader.cancel()
125
- throw new Error(`file too large (exceeded ${MAX_DOWNLOAD_BYTES} bytes)`)
126
- }
127
- chunks.push(value)
128
- }
129
-
130
- const buffer = Buffer.concat(chunks)
131
-
132
- const type = await fileTypeFromBuffer(buffer)
133
- if (!type || !IMAGE_MIMES.has(type.mime)) {
134
- throw new Error('downloaded file is not a recognized image format')
135
- }
136
-
137
- const dest = localPath(url, `.${type.ext}`, api)
138
- await fsp.writeFile(dest, buffer)
139
- api.logger.info(`${TOOL_NAME}: downloaded ${url} -> ${dest} (${buffer.length} bytes)`)
140
- return dest
141
- } finally {
142
- clearTimeout(timeout)
143
- }
144
- }
145
-
146
- // ── Tool registration ────────────────────────────────────────────────
147
-
148
- export function registerSendImageTool(api: PluginApi) {
149
- api.registerTool({
150
- name: TOOL_NAME,
151
- description:
152
- 'Send one or more images to the user. Accepts a single URL/path or an array of URLs/paths. Images are displayed inline in the chat.',
153
- parameters,
154
- async execute(_toolCallId, params) {
155
- // Normalize source to string[]
156
- let sources: string[]
157
- if (typeof params.source === 'string') {
158
- sources = [params.source.trim()]
159
- } else if (Array.isArray(params.source)) {
160
- sources = params.source
161
- .filter((s): s is string => typeof s === 'string')
162
- .map((s) => s.trim())
163
- .filter(Boolean)
164
- } else {
165
- sources = []
166
- }
167
-
168
- if (sources.length === 0) {
169
- return {content: [{type: 'text', text: JSON.stringify({error: 'source is required'})}]}
170
- }
171
-
172
- const caption = typeof params.caption === 'string' ? params.caption.trim() : undefined
173
-
174
- const resolveSource = async (source: string): Promise<string> => {
175
- if (source.startsWith('https://') || source.startsWith('http://')) {
176
- // Check cache with URL-derived extension (best guess)
177
- const guessExt = extFromUrl(source)
178
- const cached = localPath(source, guessExt, api)
179
- if (await fileExists(cached)) {
180
- api.logger.info(`${TOOL_NAME}: serving cached ${cached}`)
181
- return cached
182
- }
183
-
184
- return await downloadImage(source, api)
185
- }
186
-
187
- // Local file source
188
- if (!(await fileExists(source))) {
189
- throw new Error(`${source}: file not found`)
190
- }
191
-
192
- const type = await fileTypeFromFile(source)
193
- if (!type || !IMAGE_MIMES.has(type.mime)) {
194
- throw new Error(`${source}: not a recognized image format`)
195
- }
196
-
197
- // Copy to downloads dir so the outbound endpoint can serve it
198
- const dest = localPath(source, `.${type.ext}`, api)
199
- if (!(await fileExists(dest))) {
200
- const dir = path.dirname(dest)
201
- await fsp.mkdir(dir, {recursive: true})
202
- await fsp.copyFile(source, dest)
203
- }
204
- return dest
205
- }
206
-
207
- try {
208
- const resolved = await Promise.all(sources.map(resolveSource))
209
-
210
- // Inject MEDIA: lines as an assistant message so they appear in the chat
211
- const mediaMessage = formatMediaMessage(resolved, caption)
212
- const sessionKey = await resolveSessionKey('clawly', api)
213
- await injectAssistantMessage({sessionKey, message: mediaMessage}, api)
214
- api.logger.info(`${TOOL_NAME}: injected ${resolved.length} image(s) into session`)
215
-
216
- return {
217
- content: [{type: 'text', text: JSON.stringify({sent: true, count: resolved.length})}],
218
- }
219
- } catch (err) {
220
- const msg = err instanceof Error ? err.message : String(err)
221
- api.logger.error(`${TOOL_NAME}: ${msg}`)
222
- return {content: [{type: 'text', text: JSON.stringify({error: msg})}]}
223
- }
224
- },
225
- })
226
-
227
- api.logger.info(`tool: registered ${TOOL_NAME} agent tool`)
228
- }