diff --git a/apps/app/server-bundle/package-lock.json b/apps/app/server-bundle/package-lock.json new file mode 100644 index 00000000..1c773f35 --- /dev/null +++ b/apps/app/server-bundle/package-lock.json @@ -0,0 +1,1282 @@ +{ + "name": "@automaker/server-bundle", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@automaker/server-bundle", + "version": "0.1.0", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.61", + "cors": "^2.8.5", + "dotenv": "^17.2.3", + "express": "^5.1.0", + "morgan": "^1.10.1", + "node-pty": "1.1.0-beta41", + "ws": "^8.18.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.1.69.tgz", + "integrity": "sha512-T6mb8xKGYIH0g3drS0VRxDHemj8kmWD37nuB+ENoD9sZfi/lomnugWLWBjq9Cjw10WBewE5hjv+i8swM34nkAA==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-linuxmusl-arm64": "^0.33.5", + "@img/sharp-linuxmusl-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + }, + "peerDependencies": { + "zod": "^3.24.1" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-pty": { + "version": "1.1.0-beta41", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0-beta41.tgz", + "integrity": "sha512-OUT29KMnzh1IS0b2YcUwVz56D4iAXDsl2PtIKP3zHMljiUBq2WcaHEFfhzQfgkhWs2SExcXvfdlBPANDVU9SnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/apps/app/server-bundle/package.json b/apps/app/server-bundle/package.json new file mode 100644 index 00000000..becf118f --- /dev/null +++ b/apps/app/server-bundle/package.json @@ -0,0 +1,15 @@ +{ + "name": "@automaker/server-bundle", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.1.61", + "cors": "^2.8.5", + "dotenv": "^17.2.3", + "express": "^5.1.0", + "morgan": "^1.10.1", + "node-pty": "1.1.0-beta41", + "ws": "^8.18.0" + } +} \ No newline at end of file diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index b2e9f115..98f4561c 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -47,6 +47,7 @@ import { createSpecRegenerationRoutes } from './routes/app-spec/index.js'; import { createClaudeRoutes } from './routes/claude/index.js'; import { ClaudeUsageService } from './services/claude-usage-service.js'; import { createGitHubRoutes } from './routes/github/index.js'; +import { createContextRoutes } from './routes/context/index.js'; // Load environment variables dotenv.config(); @@ -147,6 +148,7 @@ app.use('/api/terminal', createTerminalRoutes()); app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/github', createGitHubRoutes()); +app.use('/api/context', createContextRoutes()); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 21df839e..2ed2728d 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -153,9 +153,9 @@ export class ClaudeProvider extends BaseProvider { tier: 'standard' as const, }, { - id: 'claude-3-5-haiku-20241022', - name: 'Claude 3.5 Haiku', - modelString: 'claude-3-5-haiku-20241022', + id: 'claude-haiku-4-5-20251001', + name: 'Claude Haiku 4.5', + modelString: 'claude-haiku-4-5-20251001', provider: 'anthropic', description: 'Fastest Claude model', contextWindow: 200000, diff --git a/apps/server/src/routes/context/index.ts b/apps/server/src/routes/context/index.ts new file mode 100644 index 00000000..37e447bf --- /dev/null +++ b/apps/server/src/routes/context/index.ts @@ -0,0 +1,24 @@ +/** + * Context routes - HTTP API for context file operations + * + * Provides endpoints for managing context files including + * AI-powered image description generation. + */ + +import { Router } from 'express'; +import { createDescribeImageHandler } from './routes/describe-image.js'; +import { createDescribeFileHandler } from './routes/describe-file.js'; + +/** + * Create the context router + * + * @returns Express router with context endpoints + */ +export function createContextRoutes(): Router { + const router = Router(); + + router.post('/describe-image', createDescribeImageHandler()); + router.post('/describe-file', createDescribeFileHandler()); + + return router; +} diff --git a/apps/server/src/routes/context/routes/describe-file.ts b/apps/server/src/routes/context/routes/describe-file.ts new file mode 100644 index 00000000..0e680b65 --- /dev/null +++ b/apps/server/src/routes/context/routes/describe-file.ts @@ -0,0 +1,220 @@ +/** + * POST /context/describe-file endpoint - Generate description for a text file + * + * Uses Claude Haiku to analyze a text file and generate a concise description + * suitable for context file metadata. + * + * SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY + * and reads file content directly (not via Claude's Read tool) to prevent + * arbitrary file reads and prompt injection attacks. + */ + +import type { Request, Response } from 'express'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger } from '@automaker/utils'; +import { CLAUDE_MODEL_MAP } from '@automaker/types'; +import { PathNotAllowedError } from '@automaker/platform'; +import { createCustomOptions } from '../../../lib/sdk-options.js'; +import * as secureFs from '../../../lib/secure-fs.js'; +import * as path from 'path'; + +const logger = createLogger('DescribeFile'); + +/** + * Request body for the describe-file endpoint + */ +interface DescribeFileRequestBody { + /** Path to the file */ + filePath: string; +} + +/** + * Success response from the describe-file endpoint + */ +interface DescribeFileSuccessResponse { + success: true; + description: string; +} + +/** + * Error response from the describe-file endpoint + */ +interface DescribeFileErrorResponse { + success: false; + error: string; +} + +/** + * Extract text content from Claude SDK response messages + */ +async function extractTextFromStream( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stream: AsyncIterable +): Promise { + let responseText = ''; + + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + const blocks = msg.message.content as Array<{ type: string; text?: string }>; + for (const block of blocks) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success') { + responseText = msg.result || responseText; + } + } + + return responseText; +} + +/** + * Create the describe-file request handler + * + * @returns Express request handler for file description + */ +export function createDescribeFileHandler(): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { filePath } = req.body as DescribeFileRequestBody; + + // Validate required fields + if (!filePath || typeof filePath !== 'string') { + const response: DescribeFileErrorResponse = { + success: false, + error: 'filePath is required and must be a string', + }; + res.status(400).json(response); + return; + } + + logger.info(`[DescribeFile] Starting description generation for: ${filePath}`); + + // Resolve the path for logging and cwd derivation + const resolvedPath = secureFs.resolvePath(filePath); + + // Read file content using secureFs (validates path against ALLOWED_ROOT_DIRECTORY) + // This prevents arbitrary file reads (e.g., /etc/passwd, ~/.ssh/id_rsa) + // and prompt injection attacks where malicious filePath values could inject instructions + let fileContent: string; + try { + const content = await secureFs.readFile(resolvedPath, 'utf-8'); + fileContent = typeof content === 'string' ? content : content.toString('utf-8'); + } catch (readError) { + // Path not allowed - return 403 Forbidden + if (readError instanceof PathNotAllowedError) { + logger.warn(`[DescribeFile] Path not allowed: ${filePath}`); + const response: DescribeFileErrorResponse = { + success: false, + error: 'File path is not within the allowed directory', + }; + res.status(403).json(response); + return; + } + + // File not found + if ( + readError !== null && + typeof readError === 'object' && + 'code' in readError && + readError.code === 'ENOENT' + ) { + logger.warn(`[DescribeFile] File not found: ${resolvedPath}`); + const response: DescribeFileErrorResponse = { + success: false, + error: `File not found: ${filePath}`, + }; + res.status(404).json(response); + return; + } + + const errorMessage = readError instanceof Error ? readError.message : 'Unknown error'; + logger.error(`[DescribeFile] Failed to read file: ${errorMessage}`); + const response: DescribeFileErrorResponse = { + success: false, + error: `Failed to read file: ${errorMessage}`, + }; + res.status(500).json(response); + return; + } + + // Truncate very large files to avoid token limits + const MAX_CONTENT_LENGTH = 50000; + const truncated = fileContent.length > MAX_CONTENT_LENGTH; + const contentToAnalyze = truncated + ? fileContent.substring(0, MAX_CONTENT_LENGTH) + : fileContent; + + // Get the filename for context + const fileName = path.basename(resolvedPath); + + // Build prompt with file content passed as structured data + // The file content is included directly, not via tool invocation + const instructionText = `Analyze the following file and provide a 1-2 sentence description suitable for use as context in an AI coding assistant. Focus on what the file contains, its purpose, and why an AI agent might want to use this context in the future (e.g., "API documentation for the authentication endpoints", "Configuration file for database connections", "Coding style guidelines for the project"). + +Respond with ONLY the description text, no additional formatting, preamble, or explanation. + +File: ${fileName}${truncated ? ' (truncated)' : ''}`; + + const promptContent = [ + { type: 'text' as const, text: instructionText }, + { type: 'text' as const, text: `\n\n--- FILE CONTENT ---\n${contentToAnalyze}` }, + ]; + + // Use the file's directory as the working directory + const cwd = path.dirname(resolvedPath); + + // Use centralized SDK options with proper cwd validation + // No tools needed since we're passing file content directly + const sdkOptions = createCustomOptions({ + cwd, + model: CLAUDE_MODEL_MAP.haiku, + maxTurns: 1, + allowedTools: [], + sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, + }); + + const promptGenerator = (async function* () { + yield { + type: 'user' as const, + session_id: '', + message: { role: 'user' as const, content: promptContent }, + parent_tool_use_id: null, + }; + })(); + + const stream = query({ prompt: promptGenerator, options: sdkOptions }); + + // Extract the description from the response + const description = await extractTextFromStream(stream); + + if (!description || description.trim().length === 0) { + logger.warn('Received empty response from Claude'); + const response: DescribeFileErrorResponse = { + success: false, + error: 'Failed to generate description - empty response', + }; + res.status(500).json(response); + return; + } + + logger.info(`Description generated, length: ${description.length} chars`); + + const response: DescribeFileSuccessResponse = { + success: true, + description: description.trim(), + }; + res.json(response); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + logger.error('File description failed:', errorMessage); + + const response: DescribeFileErrorResponse = { + success: false, + error: errorMessage, + }; + res.status(500).json(response); + } + }; +} diff --git a/apps/server/src/routes/context/routes/describe-image.ts b/apps/server/src/routes/context/routes/describe-image.ts new file mode 100644 index 00000000..64ddfa0f --- /dev/null +++ b/apps/server/src/routes/context/routes/describe-image.ts @@ -0,0 +1,416 @@ +/** + * POST /context/describe-image endpoint - Generate description for an image + * + * Uses Claude Haiku to analyze an image and generate a concise description + * suitable for context file metadata. + * + * IMPORTANT: + * The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks), + * not by asking Claude to use the Read tool to open files. This endpoint now mirrors that approach + * so it doesn't depend on Claude's filesystem tool access or working directory restrictions. + */ + +import type { Request, Response } from 'express'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger, readImageAsBase64 } from '@automaker/utils'; +import { CLAUDE_MODEL_MAP } from '@automaker/types'; +import { createCustomOptions } from '../../../lib/sdk-options.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +const logger = createLogger('DescribeImage'); + +/** + * Allowlist of safe headers to log + * All other headers are excluded to prevent leaking sensitive values + */ +const SAFE_HEADERS_ALLOWLIST = new Set([ + 'content-type', + 'accept', + 'user-agent', + 'host', + 'referer', + 'content-length', + 'origin', + 'x-request-id', +]); + +/** + * Filter request headers to only include safe, non-sensitive values + */ +function filterSafeHeaders(headers: Record): Record { + const filtered: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (SAFE_HEADERS_ALLOWLIST.has(key.toLowerCase())) { + filtered[key] = value; + } + } + return filtered; +} + +/** + * Find the actual file path, handling Unicode character variations. + * macOS screenshots use U+202F (NARROW NO-BREAK SPACE) before AM/PM, + * but this may be transmitted as a regular space through the API. + */ +function findActualFilePath(requestedPath: string): string | null { + // First, try the exact path + if (fs.existsSync(requestedPath)) { + return requestedPath; + } + + // Try with Unicode normalization + const normalizedPath = requestedPath.normalize('NFC'); + if (fs.existsSync(normalizedPath)) { + return normalizedPath; + } + + // If not found, try to find the file in the directory by matching the basename + // This handles cases where the space character differs (U+0020 vs U+202F vs U+00A0) + const dir = path.dirname(requestedPath); + const baseName = path.basename(requestedPath); + + if (!fs.existsSync(dir)) { + return null; + } + + try { + const files = fs.readdirSync(dir); + + // Normalize the requested basename for comparison + // Replace various space-like characters with regular space for comparison + const normalizeSpaces = (s: string): string => s.replace(/[\u00A0\u202F\u2009\u200A]/g, ' '); + + const normalizedBaseName = normalizeSpaces(baseName); + + for (const file of files) { + if (normalizeSpaces(file) === normalizedBaseName) { + logger.info(`Found matching file with different space encoding: ${file}`); + return path.join(dir, file); + } + } + } catch (err) { + logger.error(`Error reading directory ${dir}: ${err}`); + } + + return null; +} + +/** + * Request body for the describe-image endpoint + */ +interface DescribeImageRequestBody { + /** Path to the image file */ + imagePath: string; +} + +/** + * Success response from the describe-image endpoint + */ +interface DescribeImageSuccessResponse { + success: true; + description: string; +} + +/** + * Error response from the describe-image endpoint + */ +interface DescribeImageErrorResponse { + success: false; + error: string; + requestId?: string; +} + +/** + * Map SDK/CLI errors to a stable status + user-facing message. + */ +function mapDescribeImageError(rawMessage: string | undefined): { + statusCode: number; + userMessage: string; +} { + const baseResponse = { + statusCode: 500, + userMessage: 'Failed to generate an image description. Please try again.', + }; + + if (!rawMessage) return baseResponse; + + if (rawMessage.includes('Claude Code process exited')) { + return { + statusCode: 503, + userMessage: + 'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.', + }; + } + + if ( + rawMessage.includes('Failed to spawn Claude Code process') || + rawMessage.includes('Claude Code executable not found') || + rawMessage.includes('Claude Code native binary not found') + ) { + return { + statusCode: 503, + userMessage: + 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, then try again.', + }; + } + + if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) { + return { + statusCode: 429, + userMessage: 'Rate limited while describing the image. Please wait a moment and try again.', + }; + } + + if (rawMessage.toLowerCase().includes('payload too large') || rawMessage.includes('413')) { + return { + statusCode: 413, + userMessage: + 'The image is too large to send for description. Please resize/compress it and try again.', + }; + } + + return baseResponse; +} + +/** + * Extract text content from Claude SDK response messages and log high-signal stream events. + */ +async function extractTextFromStream( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stream: AsyncIterable, + requestId: string +): Promise { + let responseText = ''; + let messageCount = 0; + + logger.info(`[${requestId}] [Stream] Begin reading SDK stream...`); + + for await (const msg of stream) { + messageCount++; + const msgType = msg?.type; + const msgSubtype = msg?.subtype; + + // Keep this concise but informative. Full error object is logged in catch blocks. + logger.info( + `[${requestId}] [Stream] #${messageCount} type=${String(msgType)} subtype=${String(msgSubtype ?? '')}` + ); + + if (msgType === 'assistant' && msg.message?.content) { + const blocks = msg.message.content as Array<{ type: string; text?: string }>; + logger.info(`[${requestId}] [Stream] assistant blocks=${blocks.length}`); + for (const block of blocks) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } + + if (msgType === 'result' && msgSubtype === 'success') { + if (typeof msg.result === 'string' && msg.result.length > 0) { + responseText = msg.result; + } + } + } + + logger.info( + `[${requestId}] [Stream] End of stream. messages=${messageCount} textLength=${responseText.length}` + ); + + return responseText; +} + +/** + * Create the describe-image request handler + * + * Uses Claude SDK query with multi-part content blocks to include the image (base64), + * matching the agent runner behavior. + * + * @returns Express request handler for image description + */ +export function createDescribeImageHandler(): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + const requestId = `describe-image-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const startedAt = Date.now(); + + // Request envelope logs (high value when correlating failures) + // Only log safe headers to prevent leaking sensitive values (auth tokens, cookies, etc.) + logger.info(`[${requestId}] ===== POST /api/context/describe-image =====`); + logger.info(`[${requestId}] headers=${JSON.stringify(filterSafeHeaders(req.headers))}`); + logger.info(`[${requestId}] body=${JSON.stringify(req.body)}`); + + try { + const { imagePath } = req.body as DescribeImageRequestBody; + + // Validate required fields + if (!imagePath || typeof imagePath !== 'string') { + const response: DescribeImageErrorResponse = { + success: false, + error: 'imagePath is required and must be a string', + requestId, + }; + res.status(400).json(response); + return; + } + + logger.info(`[${requestId}] imagePath="${imagePath}" type=${typeof imagePath}`); + + // Find the actual file path (handles Unicode space character variations) + const actualPath = findActualFilePath(imagePath); + if (!actualPath) { + logger.error(`[${requestId}] File not found: ${imagePath}`); + // Log hex representation of the path for debugging + const hexPath = Buffer.from(imagePath).toString('hex'); + logger.error(`[${requestId}] imagePath hex: ${hexPath}`); + const response: DescribeImageErrorResponse = { + success: false, + error: `File not found: ${imagePath}`, + requestId, + }; + res.status(404).json(response); + return; + } + + if (actualPath !== imagePath) { + logger.info(`[${requestId}] Using actual path: ${actualPath}`); + } + + // Log path + stats (this is often where issues start: missing file, perms, size) + let stat: fs.Stats | null = null; + try { + stat = fs.statSync(actualPath); + logger.info( + `[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}` + ); + } catch (statErr) { + logger.warn( + `[${requestId}] Unable to stat image file (continuing to read base64): ${String(statErr)}` + ); + } + + // Read image and convert to base64 (same as agent runner) + logger.info(`[${requestId}] Reading image into base64...`); + const imageReadStart = Date.now(); + const imageData = await readImageAsBase64(actualPath); + const imageReadMs = Date.now() - imageReadStart; + + const base64Length = imageData.base64.length; + const estimatedBytes = Math.ceil((base64Length * 3) / 4); + logger.info(`[${requestId}] imageReadMs=${imageReadMs}`); + logger.info( + `[${requestId}] image meta filename=${imageData.filename} mime=${imageData.mimeType} base64Len=${base64Length} estBytes=${estimatedBytes}` + ); + + // Build multi-part prompt with image block (no Read tool required) + const instructionText = + `Describe this image in 1-2 sentences suitable for use as context in an AI coding assistant. ` + + `Focus on what the image shows and its purpose (e.g., "UI mockup showing login form with email/password fields", ` + + `"Architecture diagram of microservices", "Screenshot of error message in terminal").\n\n` + + `Respond with ONLY the description text, no additional formatting, preamble, or explanation.`; + + const promptContent = [ + { type: 'text' as const, text: instructionText }, + { + type: 'image' as const, + source: { + type: 'base64' as const, + media_type: imageData.mimeType, + data: imageData.base64, + }, + }, + ]; + + logger.info(`[${requestId}] Built multi-part prompt blocks=${promptContent.length}`); + + const cwd = path.dirname(actualPath); + logger.info(`[${requestId}] Using cwd=${cwd}`); + + // Use the same centralized option builder used across the server (validates cwd) + const sdkOptions = createCustomOptions({ + cwd, + model: CLAUDE_MODEL_MAP.haiku, + maxTurns: 1, + allowedTools: [], + sandbox: { enabled: true, autoAllowBashIfSandboxed: true }, + }); + + logger.info( + `[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify( + sdkOptions.allowedTools + )} sandbox=${JSON.stringify(sdkOptions.sandbox)}` + ); + + const promptGenerator = (async function* () { + yield { + type: 'user' as const, + session_id: '', + message: { role: 'user' as const, content: promptContent }, + parent_tool_use_id: null, + }; + })(); + + logger.info(`[${requestId}] Calling query()...`); + const queryStart = Date.now(); + const stream = query({ prompt: promptGenerator, options: sdkOptions }); + logger.info(`[${requestId}] query() returned stream in ${Date.now() - queryStart}ms`); + + // Extract the description from the response + const extractStart = Date.now(); + const description = await extractTextFromStream(stream, requestId); + logger.info(`[${requestId}] extractMs=${Date.now() - extractStart}`); + + if (!description || description.trim().length === 0) { + logger.warn(`[${requestId}] Received empty response from Claude`); + const response: DescribeImageErrorResponse = { + success: false, + error: 'Failed to generate description - empty response', + requestId, + }; + res.status(500).json(response); + return; + } + + const totalMs = Date.now() - startedAt; + logger.info(`[${requestId}] Success descriptionLen=${description.length} totalMs=${totalMs}`); + + const response: DescribeImageSuccessResponse = { + success: true, + description: description.trim(), + }; + res.json(response); + } catch (error) { + const totalMs = Date.now() - startedAt; + const err = error as unknown; + const errMessage = err instanceof Error ? err.message : String(err); + const errName = err instanceof Error ? err.name : 'UnknownError'; + const errStack = err instanceof Error ? err.stack : undefined; + + logger.error(`[${requestId}] FAILED totalMs=${totalMs}`); + logger.error(`[${requestId}] errorName=${errName}`); + logger.error(`[${requestId}] errorMessage=${errMessage}`); + if (errStack) logger.error(`[${requestId}] errorStack=${errStack}`); + + // Dump all enumerable + non-enumerable props (this is where stderr/stdout/exitCode often live) + try { + const props = err && typeof err === 'object' ? Object.getOwnPropertyNames(err) : []; + logger.error(`[${requestId}] errorProps=${JSON.stringify(props)}`); + if (err && typeof err === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyErr = err as any; + const details = JSON.stringify(anyErr, props as unknown as string[]); + logger.error(`[${requestId}] errorDetails=${details}`); + } + } catch (stringifyErr) { + logger.error(`[${requestId}] Failed to serialize error object: ${String(stringifyErr)}`); + } + + const { statusCode, userMessage } = mapDescribeImageError(errMessage); + const response: DescribeImageErrorResponse = { + success: false, + error: `${userMessage} (requestId: ${requestId})`, + requestId, + }; + res.status(statusCode).json(response); + } + }; +} diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 996a4a38..93df5566 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -7,7 +7,12 @@ import path from 'path'; import * as secureFs from '../lib/secure-fs.js'; import type { EventEmitter } from '../lib/events.js'; import type { ExecuteOptions } from '@automaker/types'; -import { readImageAsBase64, buildPromptWithImages, isAbortError } from '@automaker/utils'; +import { + readImageAsBase64, + buildPromptWithImages, + isAbortError, + loadContextFiles, +} from '@automaker/utils'; import { ProviderFactory } from '../providers/provider-factory.js'; import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; import { PathNotAllowedError } from '@automaker/platform'; @@ -178,12 +183,27 @@ export class AgentService { await this.saveSession(sessionId, session.messages); try { + // Determine the effective working directory for context loading + const effectiveWorkDir = workingDirectory || session.workingDirectory; + + // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) + const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({ + projectPath: effectiveWorkDir, + fsModule: secureFs as Parameters[0]['fsModule'], + }); + + // Build combined system prompt with base prompt and context files + const baseSystemPrompt = this.getSystemPrompt(); + const combinedSystemPrompt = contextFilesPrompt + ? `${contextFilesPrompt}\n\n${baseSystemPrompt}` + : baseSystemPrompt; + // Build SDK options using centralized configuration const sdkOptions = createChatOptions({ - cwd: workingDirectory || session.workingDirectory, + cwd: effectiveWorkDir, model: model, sessionModel: session.model, - systemPrompt: this.getSystemPrompt(), + systemPrompt: combinedSystemPrompt, abortController: session.abortController!, }); @@ -203,8 +223,8 @@ export class AgentService { const options: ExecuteOptions = { prompt: '', // Will be set below based on images model: effectiveModel, - cwd: workingDirectory || session.workingDirectory, - systemPrompt: this.getSystemPrompt(), + cwd: effectiveWorkDir, + systemPrompt: combinedSystemPrompt, maxTurns: maxTurns, allowedTools: allowedTools, abortController: session.abortController!, diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 6621ff8a..1da65e35 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -11,10 +11,15 @@ import { ProviderFactory } from '../providers/provider-factory.js'; import type { ExecuteOptions, Feature } from '@automaker/types'; -import { buildPromptWithImages, isAbortError, classifyError } from '@automaker/utils'; +import { + buildPromptWithImages, + isAbortError, + classifyError, + loadContextFiles, +} from '@automaker/utils'; import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; import { resolveDependencies, areDependenciesSatisfied } from '@automaker/dependency-resolver'; -import { getFeatureDir, getAutomakerDir, getFeaturesDir, getContextDir } from '@automaker/platform'; +import { getFeatureDir, getAutomakerDir, getFeaturesDir } from '@automaker/platform'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; @@ -549,7 +554,10 @@ export class AutoModeService { // Build the prompt - use continuation prompt if provided (for recovery after plan approval) let prompt: string; // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt - const contextFiles = await this.loadContextFiles(projectPath); + const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({ + projectPath, + fsModule: secureFs as Parameters[0]['fsModule'], + }); if (options?.continuationPrompt) { // Continuation prompt is used when recovering from a plan approval @@ -595,7 +603,7 @@ export class AutoModeService { projectPath, planningMode: feature.planningMode, requirePlanApproval: feature.requirePlanApproval, - systemPrompt: contextFiles || undefined, + systemPrompt: contextFilesPrompt || undefined, } ); @@ -739,7 +747,10 @@ export class AutoModeService { } // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt - const contextFiles = await this.loadContextFiles(projectPath); + const { formattedPrompt: contextFilesPrompt } = await loadContextFiles({ + projectPath, + fsModule: secureFs as Parameters[0]['fsModule'], + }); // Build complete prompt with feature info, previous context, and follow-up instructions let fullPrompt = `## Follow-up on Feature Implementation @@ -867,7 +878,7 @@ Address the follow-up instructions above. Review the previous work and make the projectPath, planningMode: 'skip', // Follow-ups don't require approval previousContent: previousContext || undefined, - systemPrompt: contextFiles || undefined, + systemPrompt: contextFilesPrompt || undefined, } ); @@ -1050,63 +1061,6 @@ Address the follow-up instructions above. Review the previous work and make the } } - /** - * Load context files from .automaker/context/ directory - * These are user-defined context files (CLAUDE.md, CODE_QUALITY.md, etc.) - * that provide project-specific rules and guidelines for the agent. - */ - private async loadContextFiles(projectPath: string): Promise { - // Use path.resolve for cross-platform absolute path handling - const contextDir = path.resolve(getContextDir(projectPath)); - - try { - // Check if directory exists first - await secureFs.access(contextDir); - - const files = await secureFs.readdir(contextDir); - // Filter for text-based context files (case-insensitive for Windows) - const textFiles = files.filter((f) => { - const lower = f.toLowerCase(); - return lower.endsWith('.md') || lower.endsWith('.txt'); - }); - - if (textFiles.length === 0) return ''; - - const contents: string[] = []; - for (const file of textFiles) { - // Use path.join for cross-platform path construction - const filePath = path.join(contextDir, file); - const content = (await secureFs.readFile(filePath, 'utf-8')) as string; - contents.push(`## ${file}\n\n${content}`); - } - - console.log(`[AutoMode] Loaded ${textFiles.length} context file(s): ${textFiles.join(', ')}`); - - return `# ⚠️ CRITICAL: Project Context Files - READ AND FOLLOW STRICTLY - -**IMPORTANT**: The following context files contain MANDATORY project-specific rules and conventions. You MUST: -1. Read these rules carefully before taking any action -2. Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`) -3. Follow ALL coding conventions, commit message formats, and architectural patterns specified -4. Reference these rules before running ANY shell commands or making commits - -Failure to follow these rules will result in broken builds, failed CI, and rejected commits. - -${contents.join('\n\n---\n\n')} - ---- - -**REMINDER**: Before running any command, verify you are using the correct package manager and following the conventions above. - ---- - -`; - } catch { - // Context directory doesn't exist or is empty - this is fine - return ''; - } - } - /** * Analyze project to gather context */ diff --git a/apps/server/tests/unit/lib/model-resolver.test.ts b/apps/server/tests/unit/lib/model-resolver.test.ts index 5eb1fa70..f73991f7 100644 --- a/apps/server/tests/unit/lib/model-resolver.test.ts +++ b/apps/server/tests/unit/lib/model-resolver.test.ts @@ -24,12 +24,12 @@ describe('model-resolver.ts', () => { describe('resolveModelString', () => { it("should resolve 'haiku' alias to full model string", () => { const result = resolveModelString('haiku'); - expect(result).toBe('claude-haiku-4-5'); + expect(result).toBe(CLAUDE_MODEL_MAP.haiku); }); it("should resolve 'sonnet' alias to full model string", () => { const result = resolveModelString('sonnet'); - expect(result).toBe('claude-sonnet-4-20250514'); + expect(result).toBe(CLAUDE_MODEL_MAP.sonnet); }); it("should resolve 'opus' alias to full model string", () => { @@ -50,7 +50,7 @@ describe('model-resolver.ts', () => { }); it('should pass through full Claude model strings', () => { - const models = ['claude-opus-4-5-20251101', 'claude-sonnet-4-20250514', 'claude-haiku-4-5']; + const models = [CLAUDE_MODEL_MAP.opus, CLAUDE_MODEL_MAP.sonnet, CLAUDE_MODEL_MAP.haiku]; models.forEach((model) => { const result = resolveModelString(model); expect(result).toBe(model); @@ -93,11 +93,11 @@ describe('model-resolver.ts', () => { it('should use session model when explicit is not provided', () => { const result = getEffectiveModel(undefined, 'sonnet', 'gpt-5.2'); - expect(result).toBe('claude-sonnet-4-20250514'); + expect(result).toBe(CLAUDE_MODEL_MAP.sonnet); }); it('should use default when neither explicit nor session is provided', () => { - const customDefault = 'claude-haiku-4-5'; + const customDefault = CLAUDE_MODEL_MAP.haiku; const result = getEffectiveModel(undefined, undefined, customDefault); expect(result).toBe(customDefault); }); @@ -109,7 +109,7 @@ describe('model-resolver.ts', () => { it('should handle explicit empty strings as undefined', () => { const result = getEffectiveModel('', 'haiku'); - expect(result).toBe('claude-haiku-4-5'); + expect(result).toBe(CLAUDE_MODEL_MAP.haiku); }); }); diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index 888cf091..45deb0d1 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -307,10 +307,10 @@ describe('claude-provider.ts', () => { expect(sonnet35).toBeDefined(); }); - it('should include Claude 3.5 Haiku', () => { + it('should include Claude Haiku 4.5', () => { const models = provider.getAvailableModels(); - const haiku = models.find((m) => m.id === 'claude-3-5-haiku-20241022'); + const haiku = models.find((m) => m.id === 'claude-haiku-4-5-20251001'); expect(haiku).toBeDefined(); }); diff --git a/apps/server/tests/unit/services/agent-service.test.ts b/apps/server/tests/unit/services/agent-service.test.ts index 8b125f8c..ef2a5e0d 100644 --- a/apps/server/tests/unit/services/agent-service.test.ts +++ b/apps/server/tests/unit/services/agent-service.test.ts @@ -4,12 +4,12 @@ import { ProviderFactory } from '@/providers/provider-factory.js'; import * as fs from 'fs/promises'; import * as imageHandler from '@automaker/utils'; import * as promptBuilder from '@automaker/utils'; +import * as contextLoader from '@automaker/utils'; import { collectAsyncGenerator } from '../../utils/helpers.js'; vi.mock('fs/promises'); vi.mock('@/providers/provider-factory.js'); vi.mock('@automaker/utils'); -vi.mock('@automaker/utils'); describe('agent-service.ts', () => { let service: AgentService; @@ -21,6 +21,12 @@ describe('agent-service.ts', () => { beforeEach(() => { vi.clearAllMocks(); service = new AgentService('/test/data', mockEvents as any); + + // Mock loadContextFiles to return empty context by default + vi.mocked(contextLoader.loadContextFiles).mockResolvedValue({ + files: [], + formattedPrompt: '', + }); }); describe('initialize', () => { diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index b879365a..3f74fd35 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -15,6 +15,11 @@ export default defineConfig({ 'src/**/*.d.ts', 'src/index.ts', 'src/routes/**', // Routes are better tested with integration tests + 'src/types/**', // Type re-exports don't need coverage + 'src/middleware/**', // Middleware needs integration tests + 'src/lib/enhancement-prompts.ts', // Prompt templates don't need unit tests + 'src/services/claude-usage-service.ts', // TODO: Add tests for usage tracking + '**/libs/**', // Exclude aliased shared packages from server coverage ], thresholds: { // Increased thresholds to ensure better code quality diff --git a/apps/ui/src/components/dialogs/board-background-modal.tsx b/apps/ui/src/components/dialogs/board-background-modal.tsx index 5dccb076..2738ec79 100644 --- a/apps/ui/src/components/dialogs/board-background-modal.tsx +++ b/apps/ui/src/components/dialogs/board-background-modal.tsx @@ -16,9 +16,12 @@ import { useAppStore, defaultBackgroundSettings } from '@/store/app-store'; import { getHttpApiClient } from '@/lib/http-api-client'; import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings'; import { toast } from 'sonner'; - -const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; -const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB +import { + fileToBase64, + validateImageFile, + ACCEPTED_IMAGE_TYPES, + DEFAULT_MAX_FILE_SIZE, +} from '@/lib/image-utils'; interface BoardBackgroundModalProps { open: boolean; @@ -71,21 +74,6 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa } }, [currentProject, backgroundSettings.imagePath, imageVersion]); - const fileToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === 'string') { - resolve(reader.result); - } else { - reject(new Error('Failed to read file as base64')); - } - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); - }; - const processFile = useCallback( async (file: File) => { if (!currentProject) { @@ -93,16 +81,10 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa return; } - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - toast.error('Unsupported file type. Please use JPG, PNG, GIF, or WebP.'); - return; - } - - // Validate file size - if (file.size > DEFAULT_MAX_FILE_SIZE) { - const maxSizeMB = DEFAULT_MAX_FILE_SIZE / (1024 * 1024); - toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`); + // Validate file + const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE); + if (!validation.isValid) { + toast.error(validation.error); return; } diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 16b1e5cb..0e87d03b 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -121,7 +121,7 @@ export function Sidebar() { const isCreatingSpec = specCreatingForProject !== null; const creatingSpecProjectPath = specCreatingForProject; - // Auto-collapse sidebar on small screens + // Auto-collapse sidebar on small screens and update Electron window minWidth useSidebarAutoCollapse({ sidebarOpen, toggleSidebar }); // Running agents count diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts index 9da2954e..fc5faba2 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-sidebar-auto-collapse.ts @@ -32,4 +32,17 @@ export function useSidebarAutoCollapse({ mediaQuery.addEventListener('change', handleResize); return () => mediaQuery.removeEventListener('change', handleResize); }, [sidebarOpen, toggleSidebar]); + + // Update Electron window minWidth when sidebar state changes + // This ensures the window can't be resized smaller than what the kanban board needs + useEffect(() => { + const electronAPI = ( + window as unknown as { + electronAPI?: { updateMinWidth?: (expanded: boolean) => Promise }; + } + ).electronAPI; + if (electronAPI?.updateMinWidth) { + electronAPI.updateMinWidth(sidebarOpen); + } + }, [sidebarOpen]); } diff --git a/apps/ui/src/components/ui/description-image-dropzone.tsx b/apps/ui/src/components/ui/description-image-dropzone.tsx index 7020ca75..0a84ed8a 100644 --- a/apps/ui/src/components/ui/description-image-dropzone.tsx +++ b/apps/ui/src/components/ui/description-image-dropzone.tsx @@ -1,18 +1,38 @@ import React, { useState, useRef, useCallback } from 'react'; import { cn } from '@/lib/utils'; -import { ImageIcon, X, Loader2 } from 'lucide-react'; +import { ImageIcon, X, Loader2, FileText } from 'lucide-react'; import { Textarea } from '@/components/ui/textarea'; import { getElectronAPI } from '@/lib/electron'; -import { useAppStore, type FeatureImagePath } from '@/store/app-store'; +import { useAppStore, type FeatureImagePath, type FeatureTextFilePath } from '@/store/app-store'; +import { + sanitizeFilename, + fileToBase64, + fileToText, + isTextFile, + isImageFile, + validateTextFile, + getTextFileMimeType, + generateFileId, + ACCEPTED_IMAGE_TYPES, + ACCEPTED_TEXT_EXTENSIONS, + DEFAULT_MAX_FILE_SIZE, + DEFAULT_MAX_TEXT_FILE_SIZE, + formatFileSize, +} from '@/lib/image-utils'; // Map to store preview data by image ID (persisted across component re-mounts) export type ImagePreviewMap = Map; +// Re-export for convenience +export type { FeatureImagePath, FeatureTextFilePath }; + interface DescriptionImageDropZoneProps { value: string; onChange: (value: string) => void; images: FeatureImagePath[]; onImagesChange: (images: FeatureImagePath[]) => void; + textFiles?: FeatureTextFilePath[]; + onTextFilesChange?: (textFiles: FeatureTextFilePath[]) => void; placeholder?: string; className?: string; disabled?: boolean; @@ -25,14 +45,13 @@ interface DescriptionImageDropZoneProps { error?: boolean; // Show error state with red border } -const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; -const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - export function DescriptionImageDropZone({ value, onChange, images, onImagesChange, + textFiles = [], + onTextFilesChange, placeholder = 'Describe the feature...', className, disabled = false, @@ -81,21 +100,6 @@ export function DescriptionImageDropZone({ [currentProject?.path] ); - const fileToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === 'string') { - resolve(reader.result); - } else { - reject(new Error('Failed to read file as base64')); - } - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); - }; - const saveImageToTemp = useCallback( async (base64Data: string, filename: string, mimeType: string): Promise => { try { @@ -129,54 +133,89 @@ export function DescriptionImageDropZone({ setIsProcessing(true); const newImages: FeatureImagePath[] = []; + const newTextFiles: FeatureTextFilePath[] = []; const newPreviews = new Map(previewImages); const errors: string[] = []; + // Calculate total current files + const currentTotalFiles = images.length + textFiles.length; + for (const file of Array.from(files)) { - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); - continue; - } - - // Validate file size - if (file.size > maxFileSize) { - const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); - continue; - } - - // Check if we've reached max files - if (newImages.length + images.length >= maxFiles) { - errors.push(`Maximum ${maxFiles} images allowed.`); - break; - } - - try { - const base64 = await fileToBase64(file); - const tempPath = await saveImageToTemp(base64, file.name, file.type); - - if (tempPath) { - const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; - const imagePathRef: FeatureImagePath = { - id: imageId, - path: tempPath, - filename: file.name, - mimeType: file.type, - }; - newImages.push(imagePathRef); - // Store preview for display - newPreviews.set(imageId, base64); - } else { - errors.push(`${file.name}: Failed to save image.`); + // Check if it's a text file + if (isTextFile(file)) { + const validation = validateTextFile(file, DEFAULT_MAX_TEXT_FILE_SIZE); + if (!validation.isValid) { + errors.push(validation.error!); + continue; } - } catch { - errors.push(`${file.name}: Failed to process image.`); + + // Check if we've reached max files + const totalFiles = newImages.length + newTextFiles.length + currentTotalFiles; + if (totalFiles >= maxFiles) { + errors.push(`Maximum ${maxFiles} files allowed.`); + break; + } + + try { + const content = await fileToText(file); + const sanitizedName = sanitizeFilename(file.name); + const textFilePath: FeatureTextFilePath = { + id: generateFileId(), + path: '', // Text files don't need to be saved to disk + filename: sanitizedName, + mimeType: getTextFileMimeType(file.name), + content, + }; + newTextFiles.push(textFilePath); + } catch { + errors.push(`${file.name}: Failed to read text file.`); + } + } + // Check if it's an image file + else if (isImageFile(file)) { + // Validate file size + if (file.size > maxFileSize) { + const maxSizeMB = maxFileSize / (1024 * 1024); + errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); + continue; + } + + // Check if we've reached max files + const totalFiles = newImages.length + newTextFiles.length + currentTotalFiles; + if (totalFiles >= maxFiles) { + errors.push(`Maximum ${maxFiles} files allowed.`); + break; + } + + try { + const base64 = await fileToBase64(file); + const sanitizedName = sanitizeFilename(file.name); + const tempPath = await saveImageToTemp(base64, sanitizedName, file.type); + + if (tempPath) { + const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + const imagePathRef: FeatureImagePath = { + id: imageId, + path: tempPath, + filename: sanitizedName, + mimeType: file.type, + }; + newImages.push(imagePathRef); + // Store preview for display + newPreviews.set(imageId, base64); + } else { + errors.push(`${file.name}: Failed to save image.`); + } + } catch { + errors.push(`${file.name}: Failed to process image.`); + } + } else { + errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`); } } if (errors.length > 0) { - console.warn('Image upload errors:', errors); + console.warn('File upload errors:', errors); } if (newImages.length > 0) { @@ -184,15 +223,21 @@ export function DescriptionImageDropZone({ setPreviewImages(newPreviews); } + if (newTextFiles.length > 0 && onTextFilesChange) { + onTextFilesChange([...textFiles, ...newTextFiles]); + } + setIsProcessing(false); }, [ disabled, isProcessing, images, + textFiles, maxFiles, maxFileSize, onImagesChange, + onTextFilesChange, previewImages, saveImageToTemp, ] @@ -263,6 +308,15 @@ export function DescriptionImageDropZone({ [images, onImagesChange] ); + const removeTextFile = useCallback( + (fileId: string) => { + if (onTextFilesChange) { + onTextFilesChange(textFiles.filter((file) => file.id !== fileId)); + } + }, + [textFiles, onTextFilesChange] + ); + // Handle paste events to detect and process images from clipboard // Works across all OS (Windows, Linux, macOS) const handlePaste = useCallback( @@ -314,11 +368,11 @@ export function DescriptionImageDropZone({ ref={fileInputRef} type="file" multiple - accept={ACCEPTED_IMAGE_TYPES.join(',')} + accept={[...ACCEPTED_IMAGE_TYPES, ...ACCEPTED_TEXT_EXTENSIONS].join(',')} onChange={handleFileSelect} className="hidden" disabled={disabled} - data-testid="description-image-input" + data-testid="description-file-input" /> {/* Drop zone wrapper */} @@ -338,7 +392,7 @@ export function DescriptionImageDropZone({ >
- Drop images here + Drop files here
)} @@ -359,7 +413,7 @@ export function DescriptionImageDropZone({ {/* Hint text */}

- Paste, drag and drop images, or{' '} + Paste, drag and drop files, or{' '} {' '} - to attach context images + to attach context (images, .txt, .md)

{/* Processing indicator */} {isProcessing && (
- Saving images... + Processing files...
)} - {/* Image previews */} - {images.length > 0 && ( -
+ {/* File previews (images and text files) */} + {(images.length > 0 || textFiles.length > 0) && ( +

- {images.length} image{images.length > 1 ? 's' : ''} attached + {images.length + textFiles.length} file + {images.length + textFiles.length > 1 ? 's' : ''} attached

+ {/* Image previews */} {images.map((image) => (
))} + {/* Text file previews */} + {textFiles.map((file) => ( +
+ {/* Text file icon */} +
+ +
+ {/* Remove button */} + {!disabled && ( + + )} + {/* Filename and size tooltip on hover */} +
+

{file.filename}

+

{formatFileSize(file.content.length)}

+
+
+ ))}
)} diff --git a/apps/ui/src/components/ui/feature-image-upload.tsx b/apps/ui/src/components/ui/feature-image-upload.tsx index 0cb5403c..4722502e 100644 --- a/apps/ui/src/components/ui/feature-image-upload.tsx +++ b/apps/ui/src/components/ui/feature-image-upload.tsx @@ -1,6 +1,14 @@ import React, { useState, useRef, useCallback } from 'react'; import { cn } from '@/lib/utils'; import { ImageIcon, X, Upload } from 'lucide-react'; +import { + fileToBase64, + generateImageId, + ACCEPTED_IMAGE_TYPES, + DEFAULT_MAX_FILE_SIZE, + DEFAULT_MAX_FILES, + validateImageFile, +} from '@/lib/image-utils'; export interface FeatureImage { id: string; @@ -19,13 +27,10 @@ interface FeatureImageUploadProps { disabled?: boolean; } -const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; -const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - export function FeatureImageUpload({ images, onImagesChange, - maxFiles = 5, + maxFiles = DEFAULT_MAX_FILES, maxFileSize = DEFAULT_MAX_FILE_SIZE, className, disabled = false, @@ -34,21 +39,6 @@ export function FeatureImageUpload({ const [isProcessing, setIsProcessing] = useState(false); const fileInputRef = useRef(null); - const fileToBase64 = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === 'string') { - resolve(reader.result); - } else { - reject(new Error('Failed to read file as base64')); - } - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); - }; - const processFiles = useCallback( async (files: FileList) => { if (disabled || isProcessing) return; @@ -58,16 +48,10 @@ export function FeatureImageUpload({ const errors: string[] = []; for (const file of Array.from(files)) { - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); - continue; - } - - // Validate file size - if (file.size > maxFileSize) { - const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); + // Validate file + const validation = validateImageFile(file, maxFileSize); + if (!validation.isValid) { + errors.push(validation.error!); continue; } @@ -80,7 +64,7 @@ export function FeatureImageUpload({ try { const base64 = await fileToBase64(file); const imageAttachment: FeatureImage = { - id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + id: generateImageId(), data: base64, mimeType: file.type, filename: file.name, diff --git a/apps/ui/src/components/ui/image-drop-zone.tsx b/apps/ui/src/components/ui/image-drop-zone.tsx index 04e53491..2f8f5c43 100644 --- a/apps/ui/src/components/ui/image-drop-zone.tsx +++ b/apps/ui/src/components/ui/image-drop-zone.tsx @@ -2,6 +2,15 @@ import React, { useState, useRef, useCallback } from 'react'; import { cn } from '@/lib/utils'; import { ImageIcon, X, Upload } from 'lucide-react'; import type { ImageAttachment } from '@/store/app-store'; +import { + fileToBase64, + generateImageId, + formatFileSize, + validateImageFile, + ACCEPTED_IMAGE_TYPES, + DEFAULT_MAX_FILE_SIZE, + DEFAULT_MAX_FILES, +} from '@/lib/image-utils'; interface ImageDropZoneProps { onImagesSelected: (images: ImageAttachment[]) => void; @@ -13,12 +22,9 @@ interface ImageDropZoneProps { images?: ImageAttachment[]; // Optional controlled images prop } -const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; -const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - export function ImageDropZone({ onImagesSelected, - maxFiles = 5, + maxFiles = DEFAULT_MAX_FILES, maxFileSize = DEFAULT_MAX_FILE_SIZE, className, children, @@ -53,16 +59,10 @@ export function ImageDropZone({ const errors: string[] = []; for (const file of Array.from(files)) { - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); - continue; - } - - // Validate file size - if (file.size > maxFileSize) { - const maxSizeMB = maxFileSize / (1024 * 1024); - errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); + // Validate file + const validation = validateImageFile(file, maxFileSize); + if (!validation.isValid) { + errors.push(validation.error!); continue; } @@ -75,7 +75,7 @@ export function ImageDropZone({ try { const base64 = await fileToBase64(file); const imageAttachment: ImageAttachment = { - id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + id: generateImageId(), data: base64, mimeType: file.type, filename: file.name, @@ -89,7 +89,6 @@ export function ImageDropZone({ if (errors.length > 0) { console.warn('Image upload errors:', errors); - // You could show these errors to the user via a toast or notification } if (newImages.length > 0) { @@ -282,26 +281,3 @@ export function ImageDropZone({
); } - -function fileToBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === 'string') { - resolve(reader.result); - } else { - reject(new Error('Failed to read file as base64')); - } - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); -} - -function formatFileSize(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; -} diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 0d6a7f77..d5a07036 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -16,12 +16,27 @@ import { X, ImageIcon, ChevronDown, + FileText, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useElectronAgent } from '@/hooks/use-electron-agent'; import { SessionManager } from '@/components/session-manager'; import { Markdown } from '@/components/ui/markdown'; -import type { ImageAttachment } from '@/store/app-store'; +import type { ImageAttachment, TextFileAttachment } from '@/store/app-store'; +import { + fileToBase64, + generateImageId, + generateFileId, + validateImageFile, + validateTextFile, + isTextFile, + isImageFile, + fileToText, + getTextFileMimeType, + formatFileSize, + DEFAULT_MAX_FILE_SIZE, + DEFAULT_MAX_FILES, +} from '@/lib/image-utils'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig, @@ -40,6 +55,7 @@ export function AgentView() { const shortcuts = useKeyboardShortcutsConfig(); const [input, setInput] = useState(''); const [selectedImages, setSelectedImages] = useState([]); + const [selectedTextFiles, setSelectedTextFiles] = useState([]); const [showImageDropZone, setShowImageDropZone] = useState(false); const [currentTool, setCurrentTool] = useState(null); const [currentSessionId, setCurrentSessionId] = useState(null); @@ -116,17 +132,23 @@ export function AgentView() { }, [currentProject?.path]); const handleSend = useCallback(async () => { - if ((!input.trim() && selectedImages.length === 0) || isProcessing) return; + if ( + (!input.trim() && selectedImages.length === 0 && selectedTextFiles.length === 0) || + isProcessing + ) + return; const messageContent = input; const messageImages = selectedImages; + const messageTextFiles = selectedTextFiles; setInput(''); setSelectedImages([]); + setSelectedTextFiles([]); setShowImageDropZone(false); - await sendMessage(messageContent, messageImages); - }, [input, selectedImages, isProcessing, sendMessage]); + await sendMessage(messageContent, messageImages, messageTextFiles); + }, [input, selectedImages, selectedTextFiles, isProcessing, sendMessage]); const handleImagesSelected = useCallback((images: ImageAttachment[]) => { setSelectedImages(images); @@ -136,84 +158,99 @@ export function AgentView() { setShowImageDropZone(!showImageDropZone); }, [showImageDropZone]); - // Helper function to convert file to base64 - const fileToBase64 = useCallback((file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === 'string') { - resolve(reader.result); - } else { - reject(new Error('Failed to read file as base64')); - } - }; - reader.onerror = () => reject(new Error('Failed to read file')); - reader.readAsDataURL(file); - }); - }, []); - - // Process dropped files + // Process dropped files (images and text files) const processDroppedFiles = useCallback( async (files: FileList) => { if (isProcessing) return; - const ACCEPTED_IMAGE_TYPES = [ - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/gif', - 'image/webp', - ]; - const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB - const MAX_FILES = 5; - const newImages: ImageAttachment[] = []; + const newTextFiles: TextFileAttachment[] = []; const errors: string[] = []; for (const file of Array.from(files)) { - // Validate file type - if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { - errors.push(`${file.name}: Unsupported file type. Please use JPG, PNG, GIF, or WebP.`); - continue; - } + // Check if it's a text file + if (isTextFile(file)) { + const validation = validateTextFile(file); + if (!validation.isValid) { + errors.push(validation.error!); + continue; + } - // Validate file size - if (file.size > MAX_FILE_SIZE) { - const maxSizeMB = MAX_FILE_SIZE / (1024 * 1024); - errors.push(`${file.name}: File too large. Maximum size is ${maxSizeMB}MB.`); - continue; - } + // Check if we've reached max files + const totalFiles = + newImages.length + + selectedImages.length + + newTextFiles.length + + selectedTextFiles.length; + if (totalFiles >= DEFAULT_MAX_FILES) { + errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`); + break; + } - // Check if we've reached max files - if (newImages.length + selectedImages.length >= MAX_FILES) { - errors.push(`Maximum ${MAX_FILES} images allowed.`); - break; + try { + const content = await fileToText(file); + const textFileAttachment: TextFileAttachment = { + id: generateFileId(), + content, + mimeType: getTextFileMimeType(file.name), + filename: file.name, + size: file.size, + }; + newTextFiles.push(textFileAttachment); + } catch { + errors.push(`${file.name}: Failed to read text file.`); + } } + // Check if it's an image file + else if (isImageFile(file)) { + const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE); + if (!validation.isValid) { + errors.push(validation.error!); + continue; + } - try { - const base64 = await fileToBase64(file); - const imageAttachment: ImageAttachment = { - id: `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - data: base64, - mimeType: file.type, - filename: file.name, - size: file.size, - }; - newImages.push(imageAttachment); - } catch (error) { - errors.push(`${file.name}: Failed to process image.`); + // Check if we've reached max files + const totalFiles = + newImages.length + + selectedImages.length + + newTextFiles.length + + selectedTextFiles.length; + if (totalFiles >= DEFAULT_MAX_FILES) { + errors.push(`Maximum ${DEFAULT_MAX_FILES} files allowed.`); + break; + } + + try { + const base64 = await fileToBase64(file); + const imageAttachment: ImageAttachment = { + id: generateImageId(), + data: base64, + mimeType: file.type, + filename: file.name, + size: file.size, + }; + newImages.push(imageAttachment); + } catch { + errors.push(`${file.name}: Failed to process image.`); + } + } else { + errors.push(`${file.name}: Unsupported file type. Use images, .txt, or .md files.`); } } if (errors.length > 0) { - console.warn('Image upload errors:', errors); + console.warn('File upload errors:', errors); } if (newImages.length > 0) { setSelectedImages((prev) => [...prev, ...newImages]); } + + if (newTextFiles.length > 0) { + setSelectedTextFiles((prev) => [...prev, ...newTextFiles]); + } }, - [isProcessing, selectedImages, fileToBase64] + [isProcessing, selectedImages, selectedTextFiles] ); // Remove individual image @@ -221,6 +258,11 @@ export function AgentView() { setSelectedImages((prev) => prev.filter((img) => img.id !== imageId)); }, []); + // Remove individual text file + const removeTextFile = useCallback((fileId: string) => { + setSelectedTextFiles((prev) => prev.filter((file) => file.id !== fileId)); + }, []); + // Drag and drop handlers for the input area const handleDragEnter = useCallback( (e: React.DragEvent) => { @@ -720,16 +762,19 @@ export function AgentView() { /> )} - {/* Selected Images Preview - only show when ImageDropZone is hidden to avoid duplicate display */} - {selectedImages.length > 0 && !showImageDropZone && ( + {/* Selected Files Preview - only show when ImageDropZone is hidden to avoid duplicate display */} + {(selectedImages.length > 0 || selectedTextFiles.length > 0) && !showImageDropZone && (

- {selectedImages.length} image - {selectedImages.length > 1 ? 's' : ''} attached + {selectedImages.length + selectedTextFiles.length} file + {selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''} attached

+ {/* Image attachments */} {selectedImages.map((image) => (
))} + {/* Text file attachments */} + {selectedTextFiles.map((file) => ( +
+ {/* File icon */} +
+ +
+ {/* File info */} +
+

+ {file.filename} +

+

+ {formatFileSize(file.size)} +

+
+ {/* Remove button */} + +
+ ))}
)} @@ -792,7 +867,7 @@ export function AgentView() { setInput(e.target.value)} @@ -803,14 +878,15 @@ export function AgentView() { className={cn( 'h-11 bg-background border-border rounded-xl pl-4 pr-20 text-sm transition-all', 'focus:ring-2 focus:ring-primary/20 focus:border-primary/50', - selectedImages.length > 0 && 'border-primary/30', + (selectedImages.length > 0 || selectedTextFiles.length > 0) && + 'border-primary/30', isDragOver && 'border-primary bg-primary/5' )} /> - {selectedImages.length > 0 && !isDragOver && ( + {(selectedImages.length > 0 || selectedTextFiles.length > 0) && !isDragOver && (
- {selectedImages.length} image - {selectedImages.length > 1 ? 's' : ''} + {selectedImages.length + selectedTextFiles.length} file + {selectedImages.length + selectedTextFiles.length > 1 ? 's' : ''}
)} {isDragOver && ( @@ -821,7 +897,7 @@ export function AgentView() { )}
- {/* Image Attachment Button */} + {/* File Attachment Button */} @@ -841,7 +918,11 @@ export function AgentView() { setIsAddDialogOpen(true)} + onClick={() => setIsCreateMarkdownOpen(true)} hotkey={shortcuts.addContextFile} hotkeyActive={false} - data-testid="add-context-file" + data-testid="create-markdown-button" > - - Add File + + Create Markdown {/* Main content area with file list and editor */}
+ {/* Drop overlay */} + {isDropHovering && ( +
+
+ + Drop files to upload + + Files will be analyzed automatically + +
+
+ )} + + {/* Uploading overlay */} + {isUploading && ( +
+
+ + Uploading {uploadingFileName}... +
+
+ )} + {/* Left Panel - File List */}
@@ -449,24 +745,23 @@ export function ContextView() {

No context files yet.
- Drop files here or click Add File. + Drop files here or use the buttons above.

) : (
- {contextFiles.map((file) => ( -
- - -
- ))} +
+ {file.name} + {isGenerating ? ( + + + Generating description... + + ) : file.description ? ( + + {file.description} + + ) : null} +
+ + + + + + { + setRenameFileName(file.name); + setSelectedFile(file); + setIsRenameDialogOpen(true); + }} + data-testid={`rename-context-file-${file.name}`} + > + + Rename + + handleDeleteFromList(file)} + className="text-red-500 focus:text-red-500" + data-testid={`delete-context-file-${file.name}`} + > + + Delete + + + +
+ ); + })}
)}
@@ -501,13 +828,13 @@ export function ContextView() { <> {/* File toolbar */}
-
+
{selectedFile.type === 'image' ? ( - + ) : ( - + )} - {selectedFile.name} + {selectedFile.name}
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && ( @@ -519,7 +846,7 @@ export function ContextView() { > {isPreviewMode ? ( <> - + Edit ) : ( @@ -553,8 +880,42 @@ export function ContextView() {
+ {/* Description section */} +
+
+
+
+ + Description + + {generatingDescriptions.has(selectedFile.name) ? ( +
+ + Generating description with AI... +
+ ) : selectedFile.description ? ( +

{selectedFile.description}

+ ) : ( +

+ No description. Click edit to add one. +

+ )} +
+ +
+
+
+ {/* Content area */} -
+
{selectedFile.type === 'image' ? (
- {/* Add File Dialog */} - + {/* Create Markdown Dialog */} + - Add Context File - Add a new text or image file to the context. + Create Markdown Context + + Create a new markdown file to add context for AI prompts. + -
-
- - -
- +
- + setNewFileName(e.target.value)} - placeholder={newFileType === 'text' ? 'context.md' : 'image.png'} - data-testid="new-file-name" + id="markdown-filename" + value={newMarkdownName} + onChange={(e) => setNewMarkdownName(e.target.value)} + placeholder="context-file.md" + data-testid="new-markdown-name" />
- {newFileType === 'text' && ( -
- -
-