image-hook.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import { createHash } from 'node:crypto';
  2. import {
  3. existsSync,
  4. mkdirSync,
  5. readdirSync,
  6. rmdirSync,
  7. statSync,
  8. unlinkSync,
  9. writeFileSync,
  10. } from 'node:fs';
  11. import { basename, extname, join } from 'node:path';
  12. import { log } from '../utils/logger';
  13. import { isUserMessageWithParts, type MessageWithParts } from './types';
  14. // Debounce: only run cleanup every 10 minutes per directory
  15. const lastCleanupByDir = new Map<string, number>();
  16. const CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes
  17. interface ImagePart {
  18. type: string;
  19. url?: string;
  20. mime?: string;
  21. filename?: string;
  22. name?: string;
  23. [key: string]: unknown;
  24. }
  25. function isImagePart(p: ImagePart): boolean {
  26. if (p.type === 'image') return true;
  27. if (p.type === 'file') {
  28. const mime = p.mime as string | undefined;
  29. if (mime?.startsWith('image/')) return true;
  30. const filename = p.filename as string | undefined;
  31. const name = p.name as string | undefined;
  32. const fileName = filename ?? name;
  33. if (
  34. fileName &&
  35. /\.(png|jpg|jpeg|gif|bmp|webp|svg|ico|tiff?|heic)$/i.test(fileName)
  36. )
  37. return true;
  38. }
  39. return false;
  40. }
  41. function decodeDataUrl(url: string): { mime: string; data: Buffer } | null {
  42. const match = url.match(/^data:([^;]+);base64,(.+)$/);
  43. if (!match) return null;
  44. return { mime: match[1], data: Buffer.from(match[2], 'base64') };
  45. }
  46. function extFromMime(mime: string): string {
  47. const map: Record<string, string> = {
  48. 'image/png': '.png',
  49. 'image/jpeg': '.jpg',
  50. 'image/gif': '.gif',
  51. 'image/webp': '.webp',
  52. 'image/svg+xml': '.svg',
  53. 'image/bmp': '.bmp',
  54. };
  55. return map[mime] ?? '.png';
  56. }
  57. function sanitizeFilename(name: string): string {
  58. return name.replace(/[^a-zA-Z0-9._-]/g, '_');
  59. }
  60. function cleanupAllSessions(saveDir: string): void {
  61. const now = Date.now();
  62. const lastCleanup = lastCleanupByDir.get(saveDir) ?? 0;
  63. if (now - lastCleanup < CLEANUP_INTERVAL) return;
  64. lastCleanupByDir.set(saveDir, now);
  65. const maxAge = 60 * 60 * 1000;
  66. const dirsToScan: string[] = [];
  67. // Collect saveDir itself (for non-session images) + all session subdirs
  68. try {
  69. for (const entry of readdirSync(saveDir, { withFileTypes: true })) {
  70. const fp = join(saveDir, entry.name);
  71. if (entry.isDirectory()) {
  72. dirsToScan.push(fp);
  73. } else {
  74. try {
  75. if (now - statSync(fp).mtimeMs > maxAge) unlinkSync(fp);
  76. } catch (err) {
  77. log('[image-hook] file cleanup failed', String(err));
  78. }
  79. }
  80. }
  81. } catch (err) {
  82. log('[image-hook] directory scan failed', String(err));
  83. }
  84. for (const dir of dirsToScan) {
  85. try {
  86. let isEmpty = true;
  87. let allRemoved = true;
  88. for (const f of readdirSync(dir)) {
  89. isEmpty = false;
  90. const fp = join(dir, f);
  91. try {
  92. if (now - statSync(fp).mtimeMs > maxAge) {
  93. unlinkSync(fp);
  94. } else {
  95. allRemoved = false;
  96. }
  97. } catch (err) {
  98. log('[image-hook] file cleanup failed', String(err));
  99. allRemoved = false;
  100. }
  101. }
  102. // Remove session subdirectory only if it had files and all were expired
  103. if (!isEmpty && allRemoved) {
  104. try {
  105. rmdirSync(dir);
  106. } catch (err) {
  107. log('[image-hook] directory removal failed', String(err));
  108. }
  109. }
  110. } catch (err) {
  111. log('[image-hook] session cleanup failed', String(err));
  112. }
  113. }
  114. }
  115. function writeUniqueFile(
  116. dir: string,
  117. name: string,
  118. data: Buffer,
  119. log: (msg: string) => void,
  120. ): string | null {
  121. const ext = extname(name);
  122. const base = basename(name, ext) || name;
  123. let candidate = join(dir, name);
  124. if (existsSync(candidate)) {
  125. return candidate;
  126. }
  127. let counter = 0;
  128. const MAX_ATTEMPTS = 1000;
  129. for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
  130. try {
  131. writeFileSync(candidate, data, { flag: 'wx' });
  132. return candidate;
  133. } catch (e) {
  134. if (
  135. e instanceof Error &&
  136. (e as NodeJS.ErrnoException).code === 'EEXIST'
  137. ) {
  138. counter += 1;
  139. candidate = join(dir, `${base}-${counter}${ext}`);
  140. continue;
  141. }
  142. log(`[image-hook] failed to save image: ${e}`);
  143. return null;
  144. }
  145. }
  146. log(
  147. `[image-hook] failed to save image: max attempts (${MAX_ATTEMPTS}) reached`,
  148. );
  149. return null;
  150. }
  151. export function processImageAttachments(args: {
  152. messages: MessageWithParts[];
  153. workDir: string;
  154. imageRouting: 'auto' | 'direct';
  155. disabledAgents: Set<string>;
  156. log: (msg: string) => void;
  157. }): void {
  158. const { messages, workDir, imageRouting, disabledAgents, log } = args;
  159. // direct mode: never intercept attachments; the orchestrator handles them
  160. // inline. @observer remains available for manual delegation.
  161. if (imageRouting === 'direct') return;
  162. // auto mode: observer must be enabled (enforced at config load). Retain
  163. // this guard as defense-in-depth in case validation is bypassed.
  164. const observerEnabled = !disabledAgents.has('observer');
  165. if (!observerEnabled) return;
  166. const messagesWithImages: Array<{
  167. msg: MessageWithParts;
  168. imageParts: ImagePart[];
  169. }> = [];
  170. for (const msg of messages) {
  171. if (!isUserMessageWithParts(msg)) continue;
  172. const imageParts = msg.parts.filter(isImagePart);
  173. if (imageParts.length > 0) {
  174. messagesWithImages.push({ msg, imageParts });
  175. }
  176. }
  177. // Save images inside the project's .opencode/images/ directory.
  178. // This is within the workspace so the read tool won't require extra permissions.
  179. const saveDir = join(workDir, '.opencode', 'images');
  180. if (messagesWithImages.length === 0) {
  181. if (existsSync(saveDir)) cleanupAllSessions(saveDir);
  182. return;
  183. }
  184. const gitignorePath = join(workDir, '.opencode', '.gitignore');
  185. try {
  186. mkdirSync(saveDir, { recursive: true });
  187. if (!existsSync(gitignorePath)) writeFileSync(gitignorePath, '*\n');
  188. } catch (e) {
  189. log(`[image-hook] failed to create image directory: ${e}`);
  190. }
  191. cleanupAllSessions(saveDir);
  192. for (const { msg, imageParts } of messagesWithImages) {
  193. const sessionSubdir = msg.info.sessionID
  194. ? sanitizeFilename(msg.info.sessionID)
  195. : undefined;
  196. const targetDir = sessionSubdir ? join(saveDir, sessionSubdir) : saveDir;
  197. try {
  198. mkdirSync(targetDir, { recursive: true });
  199. } catch (e) {
  200. log(`[image-hook] failed to create target image directory: ${e}`);
  201. }
  202. // Save each image to .opencode/images/ and collect paths
  203. const savedPaths: string[] = [];
  204. const savedImageParts = new Set<ImagePart>();
  205. for (const p of imageParts) {
  206. const url = p.url as string | undefined;
  207. const filename =
  208. (p.filename as string | undefined) ?? (p.name as string | undefined);
  209. if (url) {
  210. const decoded = decodeDataUrl(url);
  211. if (decoded) {
  212. const hash = createHash('sha1')
  213. .update(decoded.data)
  214. .digest('hex')
  215. .slice(0, 8);
  216. const sanitizedFilename = filename
  217. ? sanitizeFilename(filename)
  218. : undefined;
  219. const baseName = sanitizedFilename
  220. ? sanitizedFilename.replace(/\.[^.]+$/, '') || 'image'
  221. : 'image';
  222. const ext = sanitizedFilename
  223. ? extname(sanitizedFilename) || extFromMime(decoded.mime)
  224. : extFromMime(decoded.mime);
  225. const name = `${baseName}-${hash}${ext}`;
  226. const filePath = writeUniqueFile(targetDir, name, decoded.data, log);
  227. if (filePath) {
  228. savedPaths.push(filePath);
  229. savedImageParts.add(p);
  230. }
  231. }
  232. }
  233. }
  234. // If no image could be saved, do not strip the parts: the orchestrator
  235. // would receive a nudge with no usable path and the bytes would be lost.
  236. if (savedPaths.length === 0) {
  237. log('[image-hook] no images saved; leaving original parts in message');
  238. continue;
  239. }
  240. const pathsText = ` Saved to: ${savedPaths.join(', ')}`;
  241. log(`[image-hook] saved image/file parts to disk${pathsText}`);
  242. log(
  243. `[image-routing] auto mode: intercepted ${savedImageParts.size} image(s), delegating to @observer`,
  244. );
  245. msg.parts = msg.parts
  246. .filter((p) => !savedImageParts.has(p as ImagePart))
  247. .concat([
  248. {
  249. type: 'text',
  250. 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.]`,
  251. },
  252. ]);
  253. }
  254. }