From c2cb84607af1d14027384272c804916f1371fbb9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 9 May 2026 01:09:48 +0300 Subject: [PATCH] feat(attachments): add agent attachment foundation --- package.json | 1 + pnpm-lock.yaml | 90 ++++++++- .../agent-attachments/contracts/index.ts | 13 ++ .../core/domain/budgets.test.ts | 39 ++++ .../agent-attachments/core/domain/budgets.ts | 64 +++++++ .../core/domain/capabilities.ts | 89 +++++++++ .../agent-attachments/core/domain/errors.ts | 30 +++ .../agent-attachments/core/domain/index.ts | 6 + .../core/domain/storageIds.test.ts | 16 ++ .../core/domain/storageIds.ts | 11 ++ .../agent-attachments/core/domain/types.ts | 119 ++++++++++++ .../core/domain/validation.test.ts | 73 ++++++++ .../core/domain/validation.ts | 119 ++++++++++++ src/features/agent-attachments/index.ts | 1 + src/features/agent-attachments/main/index.ts | 1 + .../attachmentArtifactStore.test.ts | 40 ++++ .../infrastructure/attachmentArtifactStore.ts | 55 ++++++ .../agent-attachments/renderer/index.ts | 1 + .../renderer/optimizeImageForAgent.ts | 171 ++++++++++++++++++ src/main/services/team/TeamAttachmentStore.ts | 6 +- src/main/utils/atomicWrite.ts | 4 +- src/types/pica.d.ts | 11 ++ 22 files changed, 950 insertions(+), 10 deletions(-) create mode 100644 src/features/agent-attachments/contracts/index.ts create mode 100644 src/features/agent-attachments/core/domain/budgets.test.ts create mode 100644 src/features/agent-attachments/core/domain/budgets.ts create mode 100644 src/features/agent-attachments/core/domain/capabilities.ts create mode 100644 src/features/agent-attachments/core/domain/errors.ts create mode 100644 src/features/agent-attachments/core/domain/index.ts create mode 100644 src/features/agent-attachments/core/domain/storageIds.test.ts create mode 100644 src/features/agent-attachments/core/domain/storageIds.ts create mode 100644 src/features/agent-attachments/core/domain/types.ts create mode 100644 src/features/agent-attachments/core/domain/validation.test.ts create mode 100644 src/features/agent-attachments/core/domain/validation.ts create mode 100644 src/features/agent-attachments/index.ts create mode 100644 src/features/agent-attachments/main/index.ts create mode 100644 src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.test.ts create mode 100644 src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.ts create mode 100644 src/features/agent-attachments/renderer/index.ts create mode 100644 src/features/agent-attachments/renderer/optimizeImageForAgent.ts create mode 100644 src/types/pica.d.ts diff --git a/package.json b/package.json index 2b07673f..d0affd34 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,7 @@ "motion": "12.38.0", "node-diff3": "^3.2.0", "node-pty": "^1.1.0", + "pica": "9.0.1", "pidusage": "4.0.1", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb8e67f5..709fcc7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,9 @@ importers: node-pty: specifier: ^1.1.0 version: 1.1.0 + pica: + specifier: 9.0.1 + version: 9.0.1 pidusage: specifier: 4.0.1 version: 4.0.1 @@ -492,6 +495,9 @@ importers: '@shikijs/transformers': specifier: 3.22.0 version: 3.22.0 + autoprefixer: + specifier: ^10.5.0 + version: 10.5.0(postcss@8.5.8) eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -504,6 +510,9 @@ importers: sass: specifier: ^1.97.2 version: 1.98.0 + tailwindcss: + specifier: ^3.4.19 + version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) vite-plugin-vuetify: specifier: ^2.1.3 version: 2.1.3(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))(vuetify@3.12.3) @@ -5243,8 +5252,8 @@ packages: peerDependencies: postcss: ^8.1.0 - autoprefixer@10.4.27: - resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -5327,6 +5336,11 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.28: + resolution: {integrity: sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA==} + engines: {node: '>=6.0.0'} + hasBin: true + baseline-browser-mapping@2.9.14: resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} hasBin: true @@ -5384,6 +5398,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -5490,6 +5509,9 @@ packages: caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6257,6 +6279,9 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-to-chromium@1.5.352: + resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==} + electron-updater@6.7.3: resolution: {integrity: sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==} @@ -7111,6 +7136,9 @@ packages: resolution: {integrity: sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==} engines: {node: '>=20'} + glur@1.1.2: + resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -8352,6 +8380,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multimath@2.0.0: + resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -8450,6 +8481,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + nopt@8.1.0: resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} engines: {node: ^18.17.0 || >=20.5.0} @@ -8757,6 +8791,9 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} + pica@9.0.1: + resolution: {integrity: sha512-v0U4vY6Z3ztz9b4jBIhCD3WYoecGXCQeCsYep+sXRefViL+mVVoTL+wqzdPeE+GpBFsRUtQZb6dltvAt2UkMtQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -10948,6 +10985,9 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + webworkify@1.5.0: + resolution: {integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -13100,7 +13140,7 @@ snapshots: '@rollup/plugin-replace': 6.0.3(rollup@4.60.0) '@vitejs/plugin-vue': 6.0.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) '@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.1(@types/node@25.0.7)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3)) - autoprefixer: 10.4.27(postcss@8.5.8) + autoprefixer: 10.5.0(postcss@8.5.8) consola: 3.4.2 cssnano: 7.1.3(postcss@8.5.8) defu: 6.1.4 @@ -16245,10 +16285,10 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 - autoprefixer@10.4.27(postcss@8.5.8): + autoprefixer@10.5.0(postcss@8.5.8): dependencies: - browserslist: 4.28.1 - caniuse-lite: 1.0.30001781 + browserslist: 4.28.2 + caniuse-lite: 1.0.30001792 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.8 @@ -16318,6 +16358,8 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.10.28: {} + baseline-browser-mapping@2.9.14: {} bcrypt-pbkdf@1.0.2: @@ -16388,6 +16430,14 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.28 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.352 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-crc32@0.2.13: {} buffer-crc32@1.0.0: {} @@ -16535,6 +16585,8 @@ snapshots: caniuse-lite@1.0.30001781: {} + caniuse-lite@1.0.30001792: {} + ccount@2.0.1: {} chai@5.3.3: @@ -17324,6 +17376,8 @@ snapshots: electron-to-chromium@1.5.267: {} + electron-to-chromium@1.5.352: {} + electron-updater@6.7.3: dependencies: builder-util-runtime: 9.5.1 @@ -18633,6 +18687,8 @@ snapshots: slash: 5.1.0 unicorn-magic: 0.4.0 + glur@1.1.2: {} + gopd@1.2.0: {} got@11.8.6: @@ -20213,6 +20269,11 @@ snapshots: muggle-string@0.4.1: {} + multimath@2.0.0: + dependencies: + glur: 1.1.2 + object-assign: 4.1.1 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -20391,6 +20452,8 @@ snapshots: node-releases@2.0.27: {} + node-releases@2.0.38: {} + nopt@8.1.0: dependencies: abbrev: 3.0.1 @@ -20918,6 +20981,13 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 + pica@9.0.1: + dependencies: + glur: 1.1.2 + multimath: 2.0.0 + object-assign: 4.1.1 + webworkify: 1.5.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -22988,6 +23058,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + uqr@0.1.2: {} uri-js@4.4.1: @@ -23446,6 +23522,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} + webworkify@1.5.0: {} + whatwg-mimetype@3.0.0: {} whatwg-url@5.0.0: diff --git a/src/features/agent-attachments/contracts/index.ts b/src/features/agent-attachments/contracts/index.ts new file mode 100644 index 00000000..6689ca4b --- /dev/null +++ b/src/features/agent-attachments/contracts/index.ts @@ -0,0 +1,13 @@ +export type { + AgentAttachmentCapability, + AgentAttachmentCapabilityTarget, + AgentAttachmentErrorJson, + AgentAttachmentPayload, + AttachmentDeliveryFailureCode, + AttachmentValidationResult, + AttachmentWarning, + AttachmentWarningCode, + ImageOptimizationBudget, +} from '../core/domain'; + +export { AGENT_ATTACHMENT_SCHEMA_VERSION } from '../core/domain'; diff --git a/src/features/agent-attachments/core/domain/budgets.test.ts b/src/features/agent-attachments/core/domain/budgets.test.ts new file mode 100644 index 00000000..24a42a38 --- /dev/null +++ b/src/features/agent-attachments/core/domain/budgets.test.ts @@ -0,0 +1,39 @@ +import { allocateImageBudgets, planResizeDimensions, sortAttachmentsForDelivery } from './budgets'; + +describe('agent attachment budgets', () => { + it('does not upscale small images', () => { + expect(planResizeDimensions({ width: 320, height: 200 }, { maxEdge: 1600 })).toEqual({ + width: 320, + height: 200, + }); + }); + + it('downscales by longest edge', () => { + expect(planResizeDimensions({ width: 4000, height: 2000 }, { maxEdge: 2000 })).toEqual({ + width: 2000, + height: 1000, + }); + }); + + it('allocates fair per-image budgets within total cap', () => { + expect( + allocateImageBudgets({ + images: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + totalMaxBytes: 900, + perImageMaxBytes: 500, + }) + ).toEqual([ + { imageId: 'a', targetBytes: 300 }, + { imageId: 'b', targetBytes: 300 }, + { imageId: 'c', targetBytes: 300 }, + ]); + }); + + it('preserves explicit attachment order for delivery', () => { + const sorted = sortAttachmentsForDelivery([ + { id: 'b', order: 2 }, + { id: 'a', order: 1 }, + ]); + expect(sorted.map((item) => item.id)).toEqual(['a', 'b']); + }); +}); diff --git a/src/features/agent-attachments/core/domain/budgets.ts b/src/features/agent-attachments/core/domain/budgets.ts new file mode 100644 index 00000000..3280a7a0 --- /dev/null +++ b/src/features/agent-attachments/core/domain/budgets.ts @@ -0,0 +1,64 @@ +import type { ImageBudgetAllocation, ImageDimensions, ImageOptimizationBudget } from './types'; + +export const DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET: ImageOptimizationBudget = { + maxInputBytes: 20 * 1024 * 1024, + maxInputPixels: 32_000_000, + maxOutputBytesPerImage: 4 * 1024 * 1024, + maxOutputBytesTotal: 8 * 1024 * 1024, + maxOutputEdge: 2400, + jpegQualityAttempts: [0.86, 0.82, 0.78, 0.74, 0.72], +}; + +export function calculatePixelCount(dimensions: ImageDimensions): number { + return dimensions.width * dimensions.height; +} + +export function planResizeDimensions( + dimensions: ImageDimensions, + options: { maxEdge: number; allowUpscale?: boolean } +): ImageDimensions { + const width = Math.max(1, Math.floor(dimensions.width)); + const height = Math.max(1, Math.floor(dimensions.height)); + const maxEdge = Math.max(1, Math.floor(options.maxEdge)); + const longest = Math.max(width, height); + + if (longest <= maxEdge && options.allowUpscale !== true) { + return { width, height }; + } + + const scale = maxEdge / longest; + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }; +} + +export function allocateImageBudgets(input: { + images: { id: string }[]; + totalMaxBytes: number; + perImageMaxBytes: number; +}): ImageBudgetAllocation[] { + const count = Math.max(1, input.images.length); + const fairShare = Math.floor(input.totalMaxBytes / count); + const targetBytes = Math.max(1, Math.min(input.perImageMaxBytes, fairShare)); + + return input.images.map((image) => ({ imageId: image.id, targetBytes })); +} + +export function assertImageInputWithinBudget(input: { + sizeBytes: number; + dimensions: ImageDimensions; + budget?: ImageOptimizationBudget; +}): void { + const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET; + if (input.sizeBytes > budget.maxInputBytes) { + throw new Error('Image input exceeds byte budget'); + } + if (calculatePixelCount(input.dimensions) > budget.maxInputPixels) { + throw new Error('Image input exceeds pixel budget'); + } +} + +export function sortAttachmentsForDelivery(attachments: T[]): T[] { + return [...attachments].sort((left, right) => left.order - right.order); +} diff --git a/src/features/agent-attachments/core/domain/capabilities.ts b/src/features/agent-attachments/core/domain/capabilities.ts new file mode 100644 index 00000000..0e1f5792 --- /dev/null +++ b/src/features/agent-attachments/core/domain/capabilities.ts @@ -0,0 +1,89 @@ +import type { AgentAttachmentCapability, AgentAttachmentCapabilityTarget } from './types'; + +const DEFAULT_IMAGE_BYTES_PER_PROVIDER = 4 * 1024 * 1024; +const DEFAULT_IMAGE_BYTES_TOTAL = 8 * 1024 * 1024; + +function supported(displayText: string): AgentAttachmentCapability { + return { + supportsImages: true, + supportedImageMimeTypes: ['image/png', 'image/jpeg'], + maxImages: 5, + maxBytesPerImage: DEFAULT_IMAGE_BYTES_PER_PROVIDER, + maxBytesTotal: DEFAULT_IMAGE_BYTES_TOTAL, + reason: 'known_provider_support', + displayText, + }; +} + +function unsupported( + reason: AgentAttachmentCapability['reason'], + displayText: string +): AgentAttachmentCapability { + return { + supportsImages: false, + supportedImageMimeTypes: [], + maxImages: 0, + maxBytesPerImage: 0, + maxBytesTotal: 0, + reason, + displayText, + }; +} + +export function canonicalizeOpenCodeModel(input: { providerId: string; model?: string | null }): { + providerId: string; + model: string; +} { + const providerId = input.providerId.trim().toLowerCase(); + const model = (input.model ?? '') + .trim() + .toLowerCase() + .replace(/^openrouter\//, '') + .replace(/^openai\//, ''); + return { providerId, model }; +} + +export function resolveAgentAttachmentCapability( + target: AgentAttachmentCapabilityTarget +): AgentAttachmentCapability { + const providerId = target.providerId.trim().toLowerCase(); + + if (providerId === 'anthropic') { + return supported('Claude can receive image attachments through structured image blocks.'); + } + + if (providerId === 'codex') { + return supported('Codex can receive image attachments through the native image channel.'); + } + + if (providerId === 'opencode') { + const { model } = canonicalizeOpenCodeModel(target); + if (model === 'gpt-5.4-mini') { + return { + ...supported('OpenCode model openai/gpt-5.4-mini is verified for image attachments.'), + reason: 'known_vision_model', + }; + } + if (model === 'moonshotai/kimi-k2.6' || model === 'z-ai/glm-4.5v') { + return { + ...supported(`OpenCode model ${model} is verified for image attachments.`), + reason: 'known_vision_model', + }; + } + if (model === 'z-ai/glm-5.1') { + return unsupported( + 'known_non_vision_model', + 'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.' + ); + } + return unsupported( + 'unknown_model', + 'This OpenCode model has unknown image support. Image delivery is blocked for reliability.' + ); + } + + return unsupported( + 'unsupported_provider', + 'Selected provider does not support image attachments through this delivery path.' + ); +} diff --git a/src/features/agent-attachments/core/domain/errors.ts b/src/features/agent-attachments/core/domain/errors.ts new file mode 100644 index 00000000..e136e1f9 --- /dev/null +++ b/src/features/agent-attachments/core/domain/errors.ts @@ -0,0 +1,30 @@ +import type { AgentAttachmentErrorJson, AttachmentDeliveryFailureCode } from './types'; + +export class AgentAttachmentError extends Error { + constructor( + readonly code: AttachmentDeliveryFailureCode, + message: string, + readonly options: { + providerId?: string; + model?: string; + attachmentId?: string; + retryable?: boolean; + safeDetails?: Record; + } = {} + ) { + super(message); + this.name = 'AgentAttachmentError'; + } + + toJSON(): AgentAttachmentErrorJson { + return { + code: this.code, + message: this.message, + retryable: this.options.retryable ?? false, + ...(this.options.providerId ? { providerId: this.options.providerId } : {}), + ...(this.options.model ? { model: this.options.model } : {}), + ...(this.options.attachmentId ? { attachmentId: this.options.attachmentId } : {}), + ...(this.options.safeDetails ? { safeDetails: this.options.safeDetails } : {}), + }; + } +} diff --git a/src/features/agent-attachments/core/domain/index.ts b/src/features/agent-attachments/core/domain/index.ts new file mode 100644 index 00000000..cbe0326a --- /dev/null +++ b/src/features/agent-attachments/core/domain/index.ts @@ -0,0 +1,6 @@ +export * from './budgets'; +export * from './capabilities'; +export * from './errors'; +export * from './storageIds'; +export * from './types'; +export * from './validation'; diff --git a/src/features/agent-attachments/core/domain/storageIds.test.ts b/src/features/agent-attachments/core/domain/storageIds.test.ts new file mode 100644 index 00000000..eff4d3bc --- /dev/null +++ b/src/features/agent-attachments/core/domain/storageIds.test.ts @@ -0,0 +1,16 @@ +import { assertSafeAttachmentStorageId, isSafeAttachmentStorageId } from './storageIds'; + +describe('agent attachment storage ids', () => { + it('accepts compact stable ids', () => { + expect(isSafeAttachmentStorageId('msg_abc-123')).toBe(true); + }); + + it('rejects traversal-like ids', () => { + expect(() => assertSafeAttachmentStorageId('messageId', '../secret')).toThrow( + /Invalid messageId/ + ); + expect(() => assertSafeAttachmentStorageId('attachmentId', 'a/b')).toThrow( + /Invalid attachmentId/ + ); + }); +}); diff --git a/src/features/agent-attachments/core/domain/storageIds.ts b/src/features/agent-attachments/core/domain/storageIds.ts new file mode 100644 index 00000000..167bf35e --- /dev/null +++ b/src/features/agent-attachments/core/domain/storageIds.ts @@ -0,0 +1,11 @@ +const SAFE_ATTACHMENT_STORAGE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,120}$/; + +export function isSafeAttachmentStorageId(value: string): boolean { + return SAFE_ATTACHMENT_STORAGE_ID_RE.test(value); +} + +export function assertSafeAttachmentStorageId(label: string, value: string): void { + if (!isSafeAttachmentStorageId(value)) { + throw new Error(`Invalid ${label}`); + } +} diff --git a/src/features/agent-attachments/core/domain/types.ts b/src/features/agent-attachments/core/domain/types.ts new file mode 100644 index 00000000..190b05f5 --- /dev/null +++ b/src/features/agent-attachments/core/domain/types.ts @@ -0,0 +1,119 @@ +export const AGENT_ATTACHMENT_SCHEMA_VERSION = 1 as const; + +export type AgentAttachmentKind = 'image' | 'file' | 'unsupported'; + +export type AgentImageMimeType = 'image/png' | 'image/jpeg' | 'image/webp'; +export type ProviderImageMimeType = 'image/png' | 'image/jpeg'; + +export type AttachmentDeliveryFailureCode = + | 'attachment_too_large' + | 'attachment_type_unsupported' + | 'attachment_model_unsupported' + | 'attachment_optimization_failed' + | 'attachment_artifact_missing' + | 'attachment_artifact_path_unsafe' + | 'attachment_provider_rejected' + | 'attachment_runtime_transport_failed'; + +export type AttachmentWarningCode = + | 'image_was_resized' + | 'image_was_reencoded' + | 'image_quality_reduced' + | 'model_support_unknown' + | 'model_does_not_support_images' + | 'file_type_not_supported'; + +export interface AttachmentWarning { + code: AttachmentWarningCode; + message: string; + attachmentId?: string; +} + +export interface AgentAttachmentErrorJson { + code: AttachmentDeliveryFailureCode; + message: string; + providerId?: string; + model?: string; + attachmentId?: string; + retryable: boolean; + safeDetails?: Record; +} + +export interface AgentImageMetadata { + width?: number; + height?: number; + animated?: boolean; + optimizedWidth?: number; + optimizedHeight?: number; + optimization: 'none' | 'lossless' | 'resized' | 'jpeg-reencoded' | 'unsupported'; +} + +export interface AgentAttachmentStorageReference { + originalArtifactId?: string; + optimizedArtifactId?: string; + thumbnailArtifactId?: string; +} + +export interface AgentAttachmentPayload { + schemaVersion: typeof AGENT_ATTACHMENT_SCHEMA_VERSION; + id: string; + originalName: string; + mimeType: string; + sizeBytes: number; + kind: AgentAttachmentKind; + source: 'composer' | 'clipboard' | 'drag-drop' | 'task' | 'inbox'; + order: number; + storage: AgentAttachmentStorageReference; + image?: AgentImageMetadata; + warnings: AttachmentWarning[]; +} + +export interface ImageOptimizationBudget { + maxInputBytes: number; + maxInputPixels: number; + maxOutputBytesPerImage: number; + maxOutputBytesTotal: number; + maxOutputEdge: number; + jpegQualityAttempts: readonly number[]; +} + +export interface ImageDimensions { + width: number; + height: number; +} + +export interface ImageBudgetAllocation { + imageId: string; + targetBytes: number; +} + +export type AgentAttachmentProviderId = 'anthropic' | 'codex' | 'opencode' | 'unknown'; + +export interface AgentAttachmentCapabilityTarget { + providerId: AgentAttachmentProviderId | string; + model?: string | null; +} + +export interface AgentAttachmentCapability { + supportsImages: boolean; + supportedImageMimeTypes: ProviderImageMimeType[]; + maxImages: number; + maxBytesPerImage: number; + maxBytesTotal: number; + reason: + | 'known_provider_support' + | 'known_vision_model' + | 'known_non_vision_model' + | 'unknown_model' + | 'unsupported_provider'; + displayText: string; +} + +export type AttachmentValidationResult = + | { ok: true; warnings: AttachmentWarning[] } + | { + ok: false; + code: AttachmentDeliveryFailureCode; + message: string; + warnings: AttachmentWarning[]; + }; diff --git a/src/features/agent-attachments/core/domain/validation.test.ts b/src/features/agent-attachments/core/domain/validation.test.ts new file mode 100644 index 00000000..933a0f93 --- /dev/null +++ b/src/features/agent-attachments/core/domain/validation.test.ts @@ -0,0 +1,73 @@ +import { resolveAgentAttachmentCapability } from './capabilities'; +import { validateAttachmentForCapability, validateImageOptimizationInput } from './validation'; + +import type { AgentAttachmentPayload } from './types'; + +function fakeImageAttachment( + overrides: Partial = {} +): AgentAttachmentPayload { + return { + schemaVersion: 1, + id: 'att_1', + originalName: 'red-square.png', + mimeType: 'image/png', + sizeBytes: 1024, + kind: 'image', + source: 'composer', + order: 1, + storage: { originalArtifactId: 'art_original_1', optimizedArtifactId: 'art_optimized_1' }, + image: { width: 64, height: 64, optimizedWidth: 64, optimizedHeight: 64, optimization: 'none' }, + warnings: [], + ...overrides, + }; +} + +describe('agent attachment validation', () => { + it('accepts a small png optimization input', () => { + expect( + validateImageOptimizationInput({ + mimeType: 'image/png', + sizeBytes: 1000, + width: 64, + height: 64, + }) + ).toEqual({ ok: true, warnings: [] }); + }); + + it('rejects unsupported image optimization input', () => { + const result = validateImageOptimizationInput({ + mimeType: 'image/gif', + sizeBytes: 1000, + width: 64, + height: 64, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe('attachment_type_unsupported'); + }); + + it('blocks known non-vision OpenCode models', () => { + const capability = resolveAgentAttachmentCapability({ + providerId: 'opencode', + model: 'openrouter/z-ai/glm-5.1', + }); + const result = validateAttachmentForCapability({ + attachment: fakeImageAttachment(), + capability, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.code).toBe('attachment_model_unsupported'); + }); + + it('allows known vision OpenCode models', () => { + const capability = resolveAgentAttachmentCapability({ + providerId: 'opencode', + model: 'openrouter/moonshotai/kimi-k2.6', + }); + expect( + validateAttachmentForCapability({ attachment: fakeImageAttachment(), capability }) + ).toEqual({ + ok: true, + warnings: [], + }); + }); +}); diff --git a/src/features/agent-attachments/core/domain/validation.ts b/src/features/agent-attachments/core/domain/validation.ts new file mode 100644 index 00000000..3b737446 --- /dev/null +++ b/src/features/agent-attachments/core/domain/validation.ts @@ -0,0 +1,119 @@ +import { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from './budgets'; + +import type { + AgentAttachmentCapability, + AgentAttachmentKind, + AgentAttachmentPayload, + AgentImageMimeType, + AttachmentValidationResult, + ImageOptimizationBudget, + ProviderImageMimeType, +} from './types'; + +const AGENT_IMAGE_MIME_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', +]); + +const PROVIDER_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg']); + +export function isAgentImageMimeType(mimeType: string): mimeType is AgentImageMimeType { + return AGENT_IMAGE_MIME_TYPES.has(mimeType as AgentImageMimeType); +} + +export function isProviderImageMimeType(mimeType: string): mimeType is ProviderImageMimeType { + return PROVIDER_IMAGE_MIME_TYPES.has(mimeType as ProviderImageMimeType); +} + +export function classifyAttachmentMime(mimeType: string): AgentAttachmentKind { + if (isAgentImageMimeType(mimeType)) return 'image'; + if (mimeType === 'application/pdf' || mimeType === 'text/plain' || mimeType.startsWith('text/')) { + return 'file'; + } + return 'unsupported'; +} + +export function validateImageOptimizationInput(input: { + mimeType: string; + sizeBytes: number; + width: number; + height: number; + budget?: ImageOptimizationBudget; +}): AttachmentValidationResult { + const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET; + if (!isAgentImageMimeType(input.mimeType)) { + return { + ok: false, + code: 'attachment_type_unsupported', + message: 'This file type is not supported for agent image delivery.', + warnings: [], + }; + } + if (input.sizeBytes <= 0) { + return { + ok: false, + code: 'attachment_type_unsupported', + message: 'Image file is empty.', + warnings: [], + }; + } + if (input.sizeBytes > budget.maxInputBytes) { + return { + ok: false, + code: 'attachment_too_large', + message: 'Image is too large to prepare for sending.', + warnings: [], + }; + } + if (input.width * input.height > budget.maxInputPixels) { + return { + ok: false, + code: 'attachment_too_large', + message: 'Image dimensions are too large to prepare for sending.', + warnings: [], + }; + } + return { ok: true, warnings: [] }; +} + +export function validateAttachmentForCapability(input: { + attachment: AgentAttachmentPayload; + capability: AgentAttachmentCapability; +}): AttachmentValidationResult { + const { attachment, capability } = input; + const warnings = [...attachment.warnings]; + + if (attachment.kind !== 'image') { + return { ok: true, warnings }; + } + + if (!capability.supportsImages) { + return { + ok: false, + code: 'attachment_model_unsupported', + message: capability.displayText, + warnings, + }; + } + + if (!isProviderImageMimeType(attachment.mimeType)) { + return { + ok: false, + code: 'attachment_type_unsupported', + message: 'This image type is not supported by the selected provider.', + warnings, + }; + } + + if (attachment.sizeBytes > capability.maxBytesPerImage) { + return { + ok: false, + code: 'attachment_too_large', + message: 'Image is too large after optimization. Remove it or use a smaller image.', + warnings, + }; + } + + return { ok: true, warnings }; +} diff --git a/src/features/agent-attachments/index.ts b/src/features/agent-attachments/index.ts new file mode 100644 index 00000000..c7041c4c --- /dev/null +++ b/src/features/agent-attachments/index.ts @@ -0,0 +1 @@ +export * from './contracts'; diff --git a/src/features/agent-attachments/main/index.ts b/src/features/agent-attachments/main/index.ts new file mode 100644 index 00000000..1aed976d --- /dev/null +++ b/src/features/agent-attachments/main/index.ts @@ -0,0 +1 @@ +export * from './infrastructure/attachmentArtifactStore'; diff --git a/src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.test.ts b/src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.test.ts new file mode 100644 index 00000000..8dcec7d0 --- /dev/null +++ b/src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.test.ts @@ -0,0 +1,40 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { resolveAgentAttachmentArtifactPath, writeFileAtomic } from './attachmentArtifactStore'; + +describe('agent attachment artifact store helpers', () => { + it('resolves paths under the managed attachment directory', () => { + const root = path.join(os.tmpdir(), 'agent-attachments-test'); + const resolved = resolveAgentAttachmentArtifactPath({ + appDataPath: root, + teamName: 'team_1', + messageId: 'msg_1', + attachmentId: 'att_1', + fileName: 'optimized.png', + }); + expect(resolved).toBe( + path.join(root, 'attachments', 'team_1', 'msg_1', 'att_1', 'optimized.png') + ); + }); + + it('rejects unsafe ids before path construction', () => { + expect(() => + resolveAgentAttachmentArtifactPath({ + appDataPath: '/tmp/root', + teamName: 'team_1', + messageId: '../msg', + attachmentId: 'att_1', + fileName: 'optimized.png', + }) + ).toThrow(/Invalid messageId/); + }); + + it('writes files atomically', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-attachments-')); + const filePath = path.join(dir, 'file.txt'); + await writeFileAtomic(filePath, 'hello'); + await expect(fs.readFile(filePath, 'utf8')).resolves.toBe('hello'); + }); +}); diff --git a/src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.ts b/src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.ts new file mode 100644 index 00000000..9da34172 --- /dev/null +++ b/src/features/agent-attachments/main/infrastructure/attachmentArtifactStore.ts @@ -0,0 +1,55 @@ +import { getAppDataPath } from '@main/utils/pathDecoder'; +import { assertSafeAttachmentStorageId } from '@features/agent-attachments/core/domain'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +export type AgentAttachmentArtifactFileName = + | 'original.png' + | 'original.jpg' + | 'optimized.png' + | 'optimized.jpg' + | 'thumb.jpg' + | 'meta.json'; + +export interface ResolveAgentAttachmentArtifactPathInput { + teamName: string; + messageId: string; + attachmentId: string; + fileName: AgentAttachmentArtifactFileName; + appDataPath?: string; +} + +export function resolveAgentAttachmentArtifactPath( + input: ResolveAgentAttachmentArtifactPathInput +): string { + assertSafeAttachmentStorageId('teamName', input.teamName); + assertSafeAttachmentStorageId('messageId', input.messageId); + assertSafeAttachmentStorageId('attachmentId', input.attachmentId); + + const root = input.appDataPath ?? getAppDataPath(); + const base = path.resolve( + root, + 'attachments', + input.teamName, + input.messageId, + input.attachmentId + ); + const resolved = path.resolve(base, input.fileName); + if (!resolved.startsWith(base + path.sep)) { + throw new Error('Attachment artifact path escaped managed directory'); + } + return resolved; +} + +export async function writeFileAtomic(filePath: string, bytes: Buffer | string): Promise { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`); + try { + await fs.writeFile(tmpPath, bytes); + await fs.rename(tmpPath, filePath); + } catch (error) { + await fs.rm(tmpPath, { force: true }).catch(() => undefined); + throw error; + } +} diff --git a/src/features/agent-attachments/renderer/index.ts b/src/features/agent-attachments/renderer/index.ts new file mode 100644 index 00000000..5a48aa66 --- /dev/null +++ b/src/features/agent-attachments/renderer/index.ts @@ -0,0 +1 @@ +export * from './optimizeImageForAgent'; diff --git a/src/features/agent-attachments/renderer/optimizeImageForAgent.ts b/src/features/agent-attachments/renderer/optimizeImageForAgent.ts new file mode 100644 index 00000000..2541de20 --- /dev/null +++ b/src/features/agent-attachments/renderer/optimizeImageForAgent.ts @@ -0,0 +1,171 @@ +import createPica from 'pica'; + +import { + DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET, + planResizeDimensions, + validateImageOptimizationInput, + type AttachmentWarning, + type ImageDimensions, + type ImageOptimizationBudget, +} from '@features/agent-attachments/core/domain'; + +export interface OptimizeImageForAgentInput { + file: File; + budget?: ImageOptimizationBudget; +} + +export interface OptimizeImageForAgentResult { + original: { + blob: Blob; + mimeType: string; + sizeBytes: number; + width: number; + height: number; + }; + optimized: { + blob: Blob; + mimeType: 'image/png' | 'image/jpeg'; + sizeBytes: number; + width: number; + height: number; + }; + warnings: AttachmentWarning[]; +} + +function canvasToBlob( + canvas: HTMLCanvasElement, + mimeType: string, + quality?: number +): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (!blob) reject(new Error('Could not encode image canvas')); + else resolve(blob); + }, + mimeType, + quality + ); + }); +} + +async function drawBitmapToCanvas( + bitmap: ImageBitmap, + dimensions: ImageDimensions +): Promise { + const canvas = document.createElement('canvas'); + canvas.width = dimensions.width; + canvas.height = dimensions.height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Could not create image canvas context'); + context.drawImage(bitmap, 0, 0, dimensions.width, dimensions.height); + return canvas; +} + +async function resizeCanvas( + source: HTMLCanvasElement, + dimensions: ImageDimensions +): Promise { + const target = document.createElement('canvas'); + target.width = dimensions.width; + target.height = dimensions.height; + const pica = createPica(); + await pica.resize(source, target); + return target; +} + +async function encodeJpegWithinBudget( + canvas: HTMLCanvasElement, + budget: ImageOptimizationBudget, + targetBytes: number, + warnings: AttachmentWarning[] +): Promise { + for (const quality of budget.jpegQualityAttempts) { + const blob = await canvasToBlob(canvas, 'image/jpeg', quality); + if (blob.size <= targetBytes) { + if (quality < budget.jpegQualityAttempts[0]) { + warnings.push({ + code: 'image_quality_reduced', + message: 'Image quality was reduced to fit the provider budget.', + }); + } + return blob; + } + } + throw new Error('Image is too large after optimization. Remove it or use a smaller image.'); +} + +export async function optimizeImageForAgent( + input: OptimizeImageForAgentInput +): Promise { + const budget = input.budget ?? DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET; + const bitmap = await createImageBitmap(input.file); + const originalDimensions = { width: bitmap.width, height: bitmap.height }; + const validation = validateImageOptimizationInput({ + mimeType: input.file.type, + sizeBytes: input.file.size, + width: originalDimensions.width, + height: originalDimensions.height, + budget, + }); + if (!validation.ok) { + throw new Error(validation.message); + } + + const targetDimensions = planResizeDimensions(originalDimensions, { + maxEdge: budget.maxOutputEdge, + }); + const warnings: AttachmentWarning[] = [...validation.warnings]; + if ( + targetDimensions.width !== originalDimensions.width || + targetDimensions.height !== originalDimensions.height + ) { + warnings.push({ code: 'image_was_resized', message: 'Image was resized before sending.' }); + } + + const sourceCanvas = await drawBitmapToCanvas(bitmap, originalDimensions); + const outputCanvas = + targetDimensions.width === originalDimensions.width && + targetDimensions.height === originalDimensions.height + ? sourceCanvas + : await resizeCanvas(sourceCanvas, targetDimensions); + + let optimizedBlob: Blob; + let optimizedMimeType: 'image/png' | 'image/jpeg'; + if (input.file.type === 'image/png') { + optimizedBlob = await canvasToBlob(outputCanvas, 'image/png'); + optimizedMimeType = 'image/png'; + if (optimizedBlob.size > budget.maxOutputBytesPerImage) { + throw new Error( + 'PNG image is too large after optimization. Use a smaller screenshot or JPEG image.' + ); + } + } else { + optimizedBlob = await encodeJpegWithinBudget( + outputCanvas, + budget, + budget.maxOutputBytesPerImage, + warnings + ); + optimizedMimeType = 'image/jpeg'; + if (input.file.type !== 'image/jpeg') { + warnings.push({ code: 'image_was_reencoded', message: 'Image was converted to JPEG.' }); + } + } + + return { + original: { + blob: input.file, + mimeType: input.file.type, + sizeBytes: input.file.size, + ...originalDimensions, + }, + optimized: { + blob: optimizedBlob, + mimeType: optimizedMimeType, + sizeBytes: optimizedBlob.size, + ...targetDimensions, + }, + warnings, + }; +} diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index 675080e5..8b78943f 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -3,6 +3,8 @@ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; +import { atomicWriteAsync } from './atomicWrite'; + import type { AttachmentFileData, AttachmentPayload } from '@shared/types'; const logger = createLogger('Service:TeamAttachmentStore'); @@ -108,7 +110,7 @@ export class TeamAttachmentStore { const storedPath = this.getStoredFilePath(teamName, messageId, att.id, att.filename); try { - await fs.promises.writeFile(storedPath, buffer); + await atomicWriteAsync(storedPath, buffer); } catch (writeError) { logger.warn(`[${teamName}] Failed to write attachment ${att.id}: ${writeError}`); continue; @@ -125,7 +127,7 @@ export class TeamAttachmentStore { // Write metadata index for successful files (mimeType, original filename) if (indexEntries.length > 0) { const indexPath = this.getIndexPath(teamName, messageId); - await fs.promises.writeFile(indexPath, JSON.stringify(indexEntries, null, 2)); + await atomicWriteAsync(indexPath, JSON.stringify(indexEntries, null, 2)); } logger.debug( diff --git a/src/main/utils/atomicWrite.ts b/src/main/utils/atomicWrite.ts index 23d908f0..d42b4b80 100644 --- a/src/main/utils/atomicWrite.ts +++ b/src/main/utils/atomicWrite.ts @@ -34,13 +34,13 @@ async function renameWithRetry(src: string, dest: string): Promise { * Async atomic write: write tmp file then rename over target. * Uses best-effort fsync and EXDEV/EPERM fallback for safety. */ -export async function atomicWriteAsync(targetPath: string, data: string): Promise { +export async function atomicWriteAsync(targetPath: string, data: string | Buffer): Promise { const dir = path.dirname(targetPath); const tmpPath = path.join(dir, `.tmp.${randomUUID()}`); try { await fs.promises.mkdir(dir, { recursive: true }); - await fs.promises.writeFile(tmpPath, data, 'utf8'); + await fs.promises.writeFile(tmpPath, data, typeof data === 'string' ? 'utf8' : undefined); let fd: fs.promises.FileHandle | null = null; try { diff --git a/src/types/pica.d.ts b/src/types/pica.d.ts new file mode 100644 index 00000000..60f316f1 --- /dev/null +++ b/src/types/pica.d.ts @@ -0,0 +1,11 @@ +declare module 'pica' { + export interface PicaInstance { + resize( + from: HTMLCanvasElement, + to: HTMLCanvasElement, + options?: Record + ): Promise; + } + + export default function createPica(options?: Record): PicaInstance; +}