import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from 'node:fs'; import { basename, extname, join } from 'node:path'; import { log } from '../utils/logger'; import { isUserMessageWithParts, type MessageWithParts } from './types'; // Debounce: only run cleanup every 10 minutes per directory const lastCleanupByDir = new Map(); const CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes interface ImagePart { type: string; url?: string; mime?: string; filename?: string; name?: string; [key: string]: unknown; } function isImagePart(p: ImagePart): boolean { if (p.type === 'image') return true; if (p.type === 'file') { const mime = p.mime as string | undefined; if (mime?.startsWith('image/')) return true; const filename = p.filename as string | undefined; const name = p.name as string | undefined; const fileName = filename ?? name; if ( fileName && /\.(png|jpg|jpeg|gif|bmp|webp|svg|ico|tiff?|heic)$/i.test(fileName) ) return true; } return false; } function decodeDataUrl(url: string): { mime: string; data: Buffer } | null { const match = url.match(/^data:([^;]+);base64,(.+)$/); if (!match) return null; return { mime: match[1], data: Buffer.from(match[2], 'base64') }; } function extFromMime(mime: string): string { const map: Record = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/svg+xml': '.svg', 'image/bmp': '.bmp', }; return map[mime] ?? '.png'; } function sanitizeFilename(name: string): string { return name.replace(/[^a-zA-Z0-9._-]/g, '_'); } function cleanupAllSessions(saveDir: string): void { const now = Date.now(); const lastCleanup = lastCleanupByDir.get(saveDir) ?? 0; if (now - lastCleanup < CLEANUP_INTERVAL) return; lastCleanupByDir.set(saveDir, now); const maxAge = 60 * 60 * 1000; const dirsToScan: string[] = []; // Collect saveDir itself (for non-session images) + all session subdirs try { for (const entry of readdirSync(saveDir, { withFileTypes: true })) { const fp = join(saveDir, entry.name); if (entry.isDirectory()) { dirsToScan.push(fp); } else { try { if (now - statSync(fp).mtimeMs > maxAge) unlinkSync(fp); } catch (err) { log('[image-hook] file cleanup failed', String(err)); } } } } catch (err) { log('[image-hook] directory scan failed', String(err)); } for (const dir of dirsToScan) { try { let isEmpty = true; let allRemoved = true; for (const f of readdirSync(dir)) { isEmpty = false; const fp = join(dir, f); try { if (now - statSync(fp).mtimeMs > maxAge) { unlinkSync(fp); } else { allRemoved = false; } } catch (err) { log('[image-hook] file cleanup failed', String(err)); allRemoved = false; } } // Remove session subdirectory only if it had files and all were expired if (!isEmpty && allRemoved) { try { rmdirSync(dir); } catch (err) { log('[image-hook] directory removal failed', String(err)); } } } catch (err) { log('[image-hook] session cleanup failed', String(err)); } } } function writeUniqueFile( dir: string, name: string, data: Buffer, log: (msg: string) => void, ): string | null { const ext = extname(name); const base = basename(name, ext) || name; let candidate = join(dir, name); if (existsSync(candidate)) { return candidate; } let counter = 0; const MAX_ATTEMPTS = 1000; for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { try { writeFileSync(candidate, data, { flag: 'wx' }); return candidate; } catch (e) { if ( e instanceof Error && (e as NodeJS.ErrnoException).code === 'EEXIST' ) { counter += 1; candidate = join(dir, `${base}-${counter}${ext}`); continue; } log(`[image-hook] failed to save image: ${e}`); return null; } } log( `[image-hook] failed to save image: max attempts (${MAX_ATTEMPTS}) reached`, ); return null; } export function processImageAttachments(args: { messages: MessageWithParts[]; workDir: string; imageRouting: 'auto' | 'direct'; disabledAgents: Set; log: (msg: string) => void; }): void { const { messages, workDir, imageRouting, disabledAgents, log } = args; // direct mode: never intercept attachments; the orchestrator handles them // inline. @observer remains available for manual delegation. if (imageRouting === 'direct') return; // auto mode: observer must be enabled (enforced at config load). Retain // this guard as defense-in-depth in case validation is bypassed. const observerEnabled = !disabledAgents.has('observer'); if (!observerEnabled) return; const messagesWithImages: Array<{ msg: MessageWithParts; imageParts: ImagePart[]; }> = []; for (const msg of messages) { if (!isUserMessageWithParts(msg)) continue; const imageParts = msg.parts.filter(isImagePart); if (imageParts.length > 0) { messagesWithImages.push({ msg, imageParts }); } } // Save images inside the project's .opencode/images/ directory. // This is within the workspace so the read tool won't require extra permissions. const saveDir = join(workDir, '.opencode', 'images'); if (messagesWithImages.length === 0) { if (existsSync(saveDir)) cleanupAllSessions(saveDir); return; } const gitignorePath = join(workDir, '.opencode', '.gitignore'); try { mkdirSync(saveDir, { recursive: true }); if (!existsSync(gitignorePath)) writeFileSync(gitignorePath, '*\n'); } catch (e) { log(`[image-hook] failed to create image directory: ${e}`); } cleanupAllSessions(saveDir); for (const { msg, imageParts } of messagesWithImages) { const sessionSubdir = msg.info.sessionID ? sanitizeFilename(msg.info.sessionID) : undefined; const targetDir = sessionSubdir ? join(saveDir, sessionSubdir) : saveDir; try { mkdirSync(targetDir, { recursive: true }); } catch (e) { log(`[image-hook] failed to create target image directory: ${e}`); } // Save each image to .opencode/images/ and collect paths const savedPaths: string[] = []; const savedImageParts = new Set(); for (const p of imageParts) { const url = p.url as string | undefined; const filename = (p.filename as string | undefined) ?? (p.name as string | undefined); if (url) { const decoded = decodeDataUrl(url); if (decoded) { const hash = createHash('sha1') .update(decoded.data) .digest('hex') .slice(0, 8); const sanitizedFilename = filename ? sanitizeFilename(filename) : undefined; const baseName = sanitizedFilename ? sanitizedFilename.replace(/\.[^.]+$/, '') || 'image' : 'image'; const ext = sanitizedFilename ? extname(sanitizedFilename) || extFromMime(decoded.mime) : extFromMime(decoded.mime); const name = `${baseName}-${hash}${ext}`; const filePath = writeUniqueFile(targetDir, name, decoded.data, log); if (filePath) { savedPaths.push(filePath); savedImageParts.add(p); } } } } // If no image could be saved, do not strip the parts: the orchestrator // would receive a nudge with no usable path and the bytes would be lost. if (savedPaths.length === 0) { log('[image-hook] no images saved; leaving original parts in message'); continue; } const pathsText = ` Saved to: ${savedPaths.join(', ')}`; log(`[image-hook] saved image/file parts to disk${pathsText}`); log( `[image-routing] auto mode: intercepted ${savedImageParts.size} image(s), delegating to @observer`, ); msg.parts = msg.parts .filter((p) => !savedImageParts.has(p as ImagePart)) .concat([ { type: 'text', text: `[Image attachment detected.${pathsText} Your model may not support image input. Delegate to @observer with the file path(s) above so it can read the file with its read tool.]`, }, ]); } }