image-hook.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  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. // Debounce: only run cleanup every 10 minutes per directory
  13. const lastCleanupByDir = new Map<string, number>();
  14. const CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes
  15. interface ImagePart {
  16. type: string;
  17. url?: string;
  18. mime?: string;
  19. filename?: string;
  20. name?: string;
  21. [key: string]: unknown;
  22. }
  23. interface MessageWithParts {
  24. info: { role: string; agent?: string; sessionID?: string };
  25. parts: Array<{
  26. type: string;
  27. text?: string;
  28. [key: string]: unknown;
  29. }>;
  30. }
  31. function isImagePart(p: ImagePart): boolean {
  32. if (p.type === 'image') return true;
  33. if (p.type === 'file') {
  34. const mime = p.mime as string | undefined;
  35. if (mime?.startsWith('image/')) return true;
  36. const filename = p.filename as string | undefined;
  37. const name = p.name as string | undefined;
  38. const fileName = filename ?? name;
  39. if (
  40. fileName &&
  41. /\.(png|jpg|jpeg|gif|bmp|webp|svg|ico|tiff?|heic)$/i.test(fileName)
  42. )
  43. return true;
  44. }
  45. return false;
  46. }
  47. function decodeDataUrl(url: string): { mime: string; data: Buffer } | null {
  48. const match = url.match(/^data:([^;]+);base64,(.+)$/);
  49. if (!match) return null;
  50. return { mime: match[1], data: Buffer.from(match[2], 'base64') };
  51. }
  52. function extFromMime(mime: string): string {
  53. const map: Record<string, string> = {
  54. 'image/png': '.png',
  55. 'image/jpeg': '.jpg',
  56. 'image/gif': '.gif',
  57. 'image/webp': '.webp',
  58. 'image/svg+xml': '.svg',
  59. 'image/bmp': '.bmp',
  60. };
  61. return map[mime] ?? '.png';
  62. }
  63. function sanitizeFilename(name: string): string {
  64. return name.replace(/[^a-zA-Z0-9._-]/g, '_');
  65. }
  66. function cleanupOldImages(dir: string, saveDir: string): void {
  67. const now = Date.now();
  68. if (!lastCleanupByDir.has(dir) && existsSync(dir)) {
  69. lastCleanupByDir.set(dir, now);
  70. }
  71. const lastCleanup = lastCleanupByDir.get(dir) ?? 0;
  72. if (now - lastCleanup < CLEANUP_INTERVAL) return;
  73. lastCleanupByDir.set(dir, now);
  74. try {
  75. const maxAge = 60 * 60 * 1000;
  76. for (const f of readdirSync(dir)) {
  77. const fp = join(dir, f);
  78. try {
  79. if (now - statSync(fp).mtimeMs > maxAge) unlinkSync(fp);
  80. } catch {}
  81. }
  82. // Remove empty session subdirectory and prune its debounce entry
  83. if (dir !== saveDir) {
  84. try {
  85. rmdirSync(dir);
  86. lastCleanupByDir.delete(dir);
  87. } catch {}
  88. }
  89. } catch {}
  90. }
  91. function writeUniqueFile(
  92. dir: string,
  93. name: string,
  94. data: Buffer,
  95. log: (msg: string) => void,
  96. ): string | null {
  97. const ext = extname(name);
  98. const base = basename(name, ext) || name;
  99. let candidate = join(dir, name);
  100. if (existsSync(candidate)) {
  101. return candidate;
  102. }
  103. let counter = 0;
  104. const MAX_ATTEMPTS = 1000;
  105. for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
  106. try {
  107. writeFileSync(candidate, data, { flag: 'wx' });
  108. return candidate;
  109. } catch (e) {
  110. if (
  111. e instanceof Error &&
  112. (e as NodeJS.ErrnoException).code === 'EEXIST'
  113. ) {
  114. counter += 1;
  115. candidate = join(dir, `${base}-${counter}${ext}`);
  116. continue;
  117. }
  118. log(`[image-hook] failed to save image: ${e}`);
  119. return null;
  120. }
  121. }
  122. log(
  123. `[image-hook] failed to save image: max attempts (${MAX_ATTEMPTS}) reached`,
  124. );
  125. return null;
  126. }
  127. export function processImageAttachments(args: {
  128. messages: MessageWithParts[];
  129. workDir: string;
  130. disabledAgents: Set<string>;
  131. log: (msg: string) => void;
  132. }): void {
  133. const { messages, workDir, disabledAgents, log } = args;
  134. const observerEnabled = !disabledAgents.has('observer');
  135. if (!observerEnabled) return;
  136. // Save images inside the project's .opencode/images/ directory.
  137. // This is within the workspace so the read tool won't require extra permissions.
  138. const saveDir = join(workDir, '.opencode', 'images');
  139. const gitignorePath = join(workDir, '.opencode', '.gitignore');
  140. try {
  141. mkdirSync(saveDir, { recursive: true });
  142. if (!existsSync(gitignorePath)) writeFileSync(gitignorePath, '*\n');
  143. } catch (e) {
  144. log(`[image-hook] failed to create image directory: ${e}`);
  145. }
  146. for (const msg of messages) {
  147. if (msg.info.role !== 'user') continue;
  148. const imageParts = msg.parts.filter(isImagePart);
  149. if (imageParts.length === 0) continue;
  150. const sessionSubdir = msg.info.sessionID
  151. ? sanitizeFilename(msg.info.sessionID)
  152. : undefined;
  153. const targetDir = sessionSubdir ? join(saveDir, sessionSubdir) : saveDir;
  154. try {
  155. mkdirSync(targetDir, { recursive: true });
  156. } catch (e) {
  157. log(`[image-hook] failed to create target image directory: ${e}`);
  158. }
  159. cleanupOldImages(targetDir, saveDir);
  160. // Save each image to .opencode/images/ and collect paths
  161. const savedPaths: string[] = [];
  162. for (const p of imageParts) {
  163. const url = p.url as string | undefined;
  164. const filename =
  165. (p.filename as string | undefined) ?? (p.name as string | undefined);
  166. if (url) {
  167. const decoded = decodeDataUrl(url);
  168. if (decoded) {
  169. const hash = createHash('sha1')
  170. .update(decoded.data)
  171. .digest('hex')
  172. .slice(0, 8);
  173. const sanitizedFilename = filename
  174. ? sanitizeFilename(filename)
  175. : undefined;
  176. const baseName = sanitizedFilename
  177. ? sanitizedFilename.replace(/\.[^.]+$/, '') || 'image'
  178. : 'image';
  179. const ext = sanitizedFilename
  180. ? extname(sanitizedFilename) || extFromMime(decoded.mime)
  181. : extFromMime(decoded.mime);
  182. const name = `${baseName}-${hash}${ext}`;
  183. const filePath = writeUniqueFile(targetDir, name, decoded.data, log);
  184. if (filePath) savedPaths.push(filePath);
  185. }
  186. }
  187. }
  188. const pathsText =
  189. savedPaths.length > 0 ? ` Saved to: ${savedPaths.join(', ')}` : '';
  190. log(`[image-hook] stripping image/file parts, saving to disk${pathsText}`);
  191. msg.parts = msg.parts
  192. .filter((p) => !isImagePart(p as ImagePart))
  193. .concat([
  194. {
  195. type: 'text',
  196. 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.]`,
  197. },
  198. ]);
  199. }
  200. }