diff --git a/backend/package-lock.json b/backend/package-lock.json index bd80b60..cabab19 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,7 +22,8 @@ "@types/node-telegram-bot-api": "^0.64.7", "@types/uuid": "^10.0.0", "tsx": "^4.19.2", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.1.1" } }, "node_modules/@cypress/request": { @@ -112,6 +113,40 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -554,6 +589,320 @@ "node": ">=18" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", + "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", + "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", + "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", + "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", + "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", + "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", + "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", + "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -572,6 +921,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -592,6 +952,20 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -705,7 +1079,120 @@ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", "dev": true, - "license": "MIT" + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.1", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, "node_modules/accepts": { "version": "1.3.8", @@ -814,6 +1301,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -970,6 +1467,16 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "license": "Apache-2.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1003,6 +1510,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1175,6 +1689,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -1321,6 +1845,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1425,6 +1956,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1440,6 +1981,16 @@ "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -1513,6 +2064,24 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-type": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", @@ -2248,77 +2817,338 @@ "call-bound": "^1.0.3" }, "engines": { - "node": ">= 0.4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/jsprim": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", + "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "license": "MIT" - }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "license": "MIT" - }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" - }, - "node_modules/jsprim": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", - "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", - "engines": [ - "node >=0.6.0" + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" ], - "license": "MIT", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lodash": { @@ -2327,6 +3157,16 @@ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2402,6 +3242,25 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2505,6 +3364,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2558,12 +3428,40 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -2573,6 +3471,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -2870,6 +3797,40 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", + "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.11" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", + "@rolldown/binding-darwin-x64": "1.0.0-rc.11", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" + } + }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -3129,6 +4090,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sql.js": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", @@ -3160,6 +4138,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3169,6 +4154,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stealthy-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", @@ -3262,6 +4254,50 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -3301,12 +4337,21 @@ "node": ">=16" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3559,6 +4604,167 @@ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "license": "MIT" }, + "node_modules/vite": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", + "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.11", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", @@ -3650,6 +4856,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index e46b6c9..02b8ee9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,14 +7,16 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "seed": "tsx src/seed.ts" + "seed": "tsx src/seed.ts", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { - "sql.js": "^1.12.0", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", "node-telegram-bot-api": "^0.66.0", + "sql.js": "^1.12.0", "uuid": "^11.1.0" }, "devDependencies": { @@ -24,6 +26,7 @@ "@types/node-telegram-bot-api": "^0.64.7", "@types/uuid": "^10.0.0", "tsx": "^4.19.2", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.1.1" } } diff --git a/backend/src/__tests__/modelClassifier.test.ts b/backend/src/__tests__/modelClassifier.test.ts new file mode 100644 index 0000000..58485bd --- /dev/null +++ b/backend/src/__tests__/modelClassifier.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from "vitest"; +import { classifySession, classifySessionSummary } from "../modelClassifier"; +import type { SessionDetail, SessionSummary } from "../sessions"; + +function makeSessionDetail(overrides: Partial = {}): SessionDetail { + return { + id: "test-session", + agentId: "test-agent", + profile: "default", + title: "Test Session", + status: "completed", + costUsd: 0.05, + tokenCount: 5000, + messageCount: 10, + model: "anthropic/claude-sonnet-4-20250514", + startedAt: "2025-03-20T10:00:00Z", + lastActivityAt: "2025-03-20T10:30:00Z", + duration: 30 * 60 * 1000, + costByModel: [{ model: "anthropic/claude-sonnet-4-20250514", costUsd: 0.05, tokenCount: 5000 }], + tokenBreakdown: { input: 3000, output: 2000, cacheRead: 0, cacheWrite: 0 }, + messages: [], + ...overrides, + }; +} + +function makeSessionSummary(overrides: Partial = {}): SessionSummary { + return { + id: "test-session", + agentId: "test-agent", + profile: "default", + title: "Test Session", + status: "active", + costUsd: 0.05, + tokenCount: 5000, + messageCount: 5, + model: "anthropic/claude-sonnet-4-20250514", + startedAt: "2025-03-20T10:00:00Z", + lastActivityAt: "2025-03-20T10:30:00Z", + duration: 10 * 60 * 1000, + costByModel: [{ model: "anthropic/claude-sonnet-4-20250514", costUsd: 0.05, tokenCount: 5000 }], + ...overrides, + }; +} + +describe("classifySession", () => { + it("classifies a session with no messages as simple", () => { + const session = makeSessionDetail({ messages: [] }); + const result = classifySession(session); + expect(result.complexity).toBe("simple"); + expect(result.confidence).toBeGreaterThan(0); + expect(result.reasons.length).toBeGreaterThan(0); + }); + + it("classifies a session with short user messages and no tools as simple", () => { + const session = makeSessionDetail({ + messages: [ + { id: "1", role: "user", timestamp: "2025-03-20T10:00:00Z", content: "What is 2+2?" }, + { id: "2", role: "assistant", timestamp: "2025-03-20T10:00:01Z", content: "4" }, + { id: "3", role: "user", timestamp: "2025-03-20T10:00:02Z", content: "Thanks!" }, + ], + }); + const result = classifySession(session); + expect(result.complexity).toBe("simple"); + expect(result.metrics.avgMessageLength).toBeLessThan(100); + expect(result.metrics.toolCallsPerMessage).toBe(0); + }); + + it("classifies a session with heavy tool usage as complex", () => { + const session = makeSessionDetail({ + messages: [ + { + id: "1", + role: "user", + timestamp: "2025-03-20T10:00:00Z", + content: + "Please refactor the authentication module to use JWT tokens instead of session cookies. Also add rate limiting and update all the integration tests to work with the new auth flow.", + }, + { + id: "2", + role: "assistant", + timestamp: "2025-03-20T10:00:01Z", + content: "Reading auth module...", + toolName: "read", + }, + { + id: "3", + role: "assistant", + timestamp: "2025-03-20T10:00:02Z", + content: "Editing auth module...", + toolName: "edit", + }, + { + id: "4", + role: "assistant", + timestamp: "2025-03-20T10:00:03Z", + content: "Running tests...", + toolName: "exec", + }, + { + id: "5", + role: "assistant", + timestamp: "2025-03-20T10:00:04Z", + content: "Spawning sub-agent...", + toolName: "sessions_spawn", + }, + ], + }); + const result = classifySession(session); + expect(result.complexity).toBe("complex"); + expect(result.metrics.hasFileOperations).toBe(true); + expect(result.metrics.hasCodeExecution).toBe(true); + expect(result.metrics.hasSubAgents).toBe(true); + }); + + it("classifies a moderate session correctly", () => { + const session = makeSessionDetail({ + messages: [ + { + id: "1", + role: "user", + timestamp: "2025-03-20T10:00:00Z", + content: + "Can you check the weather for Tel Aviv and then create a summary of the forecast for the next 3 days?", + }, + { + id: "2", + role: "assistant", + timestamp: "2025-03-20T10:00:01Z", + content: "Checking weather...", + toolName: "exec", + }, + { id: "3", role: "assistant", timestamp: "2025-03-20T10:00:02Z", content: "Here is the forecast..." }, + ], + }); + const result = classifySession(session); + expect(["simple", "moderate"]).toContain(result.complexity); + }); + + it("returns confidence between 0 and 1", () => { + const session = makeSessionDetail({ messages: [] }); + const result = classifySession(session); + expect(result.confidence).toBeGreaterThanOrEqual(0); + expect(result.confidence).toBeLessThanOrEqual(1); + }); +}); + +describe("classifySessionSummary", () => { + it("classifies a short cheap session as simple", () => { + const session = makeSessionSummary({ + messageCount: 3, + model: "anthropic/claude-haiku-3", + duration: 2 * 60 * 1000, + }); + expect(classifySessionSummary(session)).toBe("simple"); + }); + + it("classifies a high-message-count session as complex", () => { + const session = makeSessionSummary({ messageCount: 50 }); + expect(classifySessionSummary(session)).toBe("complex"); + }); + + it("classifies a long-duration session as complex", () => { + const session = makeSessionSummary({ duration: 60 * 60 * 1000 }); + expect(classifySessionSummary(session)).toBe("complex"); + }); + + it("classifies a multi-model session as complex", () => { + const session = makeSessionSummary({ + costByModel: [ + { model: "claude-sonnet", costUsd: 0.03, tokenCount: 3000 }, + { model: "claude-haiku", costUsd: 0.01, tokenCount: 2000 }, + ], + }); + expect(classifySessionSummary(session)).toBe("complex"); + }); + + it("classifies an opus session with moderate messages as moderate", () => { + const session = makeSessionSummary({ + messageCount: 8, + model: "anthropic/claude-opus-4-20250514", + }); + expect(classifySessionSummary(session)).toBe("moderate"); + }); +}); diff --git a/backend/src/__tests__/modelRecommendations.test.ts b/backend/src/__tests__/modelRecommendations.test.ts new file mode 100644 index 0000000..69963ed --- /dev/null +++ b/backend/src/__tests__/modelRecommendations.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect } from "vitest"; +import { recommendModelForSession, recommendForRecentSessions } from "../modelRecommendations"; +import type { SessionDetail, SessionSummary } from "../sessions"; + +function makeSessionDetail(overrides: Partial = {}): SessionDetail { + return { + id: "test-session", + agentId: "test-agent", + profile: "default", + title: "Test Session", + status: "completed", + costUsd: 0.50, + tokenCount: 50000, + messageCount: 10, + model: "anthropic/claude-opus-4-20250514", + startedAt: "2025-03-20T10:00:00Z", + lastActivityAt: "2025-03-20T10:30:00Z", + duration: 30 * 60 * 1000, + costByModel: [{ model: "anthropic/claude-opus-4-20250514", costUsd: 0.50, tokenCount: 50000 }], + tokenBreakdown: { input: 30000, output: 20000, cacheRead: 0, cacheWrite: 0 }, + messages: [], + ...overrides, + }; +} + +function makeSessionSummary(overrides: Partial = {}): SessionSummary { + return { + id: "test-session", + agentId: "test-agent", + profile: "default", + title: "Test Session", + status: "active", + costUsd: 0.50, + tokenCount: 50000, + messageCount: 5, + model: "anthropic/claude-opus-4-20250514", + startedAt: "2025-03-20T10:00:00Z", + lastActivityAt: new Date().toISOString(), // recent for "active" status + duration: 10 * 60 * 1000, + costByModel: [{ model: "anthropic/claude-opus-4-20250514", costUsd: 0.50, tokenCount: 50000 }], + ...overrides, + }; +} + +describe("recommendModelForSession", () => { + it("recommends cheaper model for simple Opus sessions", () => { + const session = makeSessionDetail({ + model: "anthropic/claude-opus-4-20250514", + messages: [ + { id: "1", role: "user", timestamp: "2025-03-20T10:00:00Z", content: "Hi there" }, + { id: "2", role: "assistant", timestamp: "2025-03-20T10:00:01Z", content: "Hello!" }, + ], + }); + const result = recommendModelForSession(session); + expect(result.recommendedModel).toBe("claude-haiku"); + expect(result.potentialSavings.costUsd).toBeGreaterThan(0); + expect(result.potentialSavings.percentage).toBeGreaterThan(0); + }); + + it("recommends Sonnet for moderate Opus sessions", () => { + const session = makeSessionDetail({ + model: "anthropic/claude-opus-4-20250514", + messages: [ + { + id: "1", + role: "user", + timestamp: "2025-03-20T10:00:00Z", + content: + "Can you analyze the performance metrics from last week and create a summary report with visualizations for the dashboard? Include trends and anomalies.", + }, + { + id: "2", + role: "assistant", + timestamp: "2025-03-20T10:00:01Z", + content: "Checking metrics...", + toolName: "exec", + }, + { + id: "3", + role: "assistant", + timestamp: "2025-03-20T10:00:02Z", + content: "Reading data file...", + toolName: "read", + }, + { id: "4", role: "assistant", timestamp: "2025-03-20T10:00:03Z", content: "Here's the report..." }, + ], + }); + const result = recommendModelForSession(session); + expect(["claude-sonnet", "claude-haiku"]).toContain(result.recommendedModel); + expect(result.potentialSavings.costUsd).toBeGreaterThan(0); + }); + + it("keeps current model for complex sessions", () => { + const session = makeSessionDetail({ + model: "anthropic/claude-opus-4-20250514", + messages: [ + { + id: "1", + role: "user", + timestamp: "2025-03-20T10:00:00Z", + content: + "Refactor the entire authentication system from session-based to JWT, update all 15 middleware files, run the integration test suite, and spawn sub-agents for parallel testing across microservices.", + }, + { id: "2", role: "assistant", timestamp: "2025-03-20T10:00:01Z", content: "Reading...", toolName: "read" }, + { id: "3", role: "assistant", timestamp: "2025-03-20T10:00:02Z", content: "Editing...", toolName: "edit" }, + { id: "4", role: "assistant", timestamp: "2025-03-20T10:00:03Z", content: "Running...", toolName: "exec" }, + { + id: "5", + role: "assistant", + timestamp: "2025-03-20T10:00:04Z", + content: "Spawning...", + toolName: "sessions_spawn", + }, + { id: "6", role: "assistant", timestamp: "2025-03-20T10:00:05Z", content: "Writing...", toolName: "write" }, + ], + }); + const result = recommendModelForSession(session); + expect(result.complexity).toBe("complex"); + // Should keep the current model or the same tier + expect(result.recommendedModel).toContain("opus"); + }); + + // --- Edge cases --- + + it("handles zero-cost sessions gracefully", () => { + const session = makeSessionDetail({ + costUsd: 0, + tokenCount: 0, + messages: [ + { id: "1", role: "user", timestamp: "2025-03-20T10:00:00Z", content: "Test" }, + ], + }); + const result = recommendModelForSession(session); + expect(result.potentialSavings.costUsd).toBe(0); + expect(result.potentialSavings.percentage).toBe(0); + }); + + it("handles empty model string", () => { + const session = makeSessionDetail({ model: "", messages: [] }); + const result = recommendModelForSession(session); + expect(result.currentModel).toBeDefined(); + expect(result.confidence).toBe(0); + expect(result.reasons).toContain("Insufficient data to analyze this session"); + }); + + it("handles sessions with no messages", () => { + const session = makeSessionDetail({ messageCount: 0, messages: [] }); + const result = recommendModelForSession(session); + expect(result.confidence).toBe(0); + }); + + it("handles unknown model names", () => { + const session = makeSessionDetail({ + model: "some-custom-local-model-v3", + messages: [ + { id: "1", role: "user", timestamp: "2025-03-20T10:00:00Z", content: "Hello" }, + { id: "2", role: "assistant", timestamp: "2025-03-20T10:00:01Z", content: "Hi!" }, + ], + }); + const result = recommendModelForSession(session); + expect(result).toBeDefined(); + expect(result.currentModel).toBe("some-custom-local-model-v3"); + // Should not crash + }); + + it("rounds savings to 4 decimal places", () => { + const session = makeSessionDetail({ + costUsd: 1.23456789, + tokenCount: 100000, + model: "anthropic/claude-opus-4-20250514", + messages: [ + { id: "1", role: "user", timestamp: "2025-03-20T10:00:00Z", content: "Short question" }, + { id: "2", role: "assistant", timestamp: "2025-03-20T10:00:01Z", content: "Short answer" }, + ], + }); + const result = recommendModelForSession(session); + const decimalPlaces = result.potentialSavings.costUsd.toString().split(".")[1]?.length || 0; + expect(decimalPlaces).toBeLessThanOrEqual(4); + }); +}); + +describe("recommendForRecentSessions", () => { + it("returns savings summary across multiple sessions", () => { + const sessions: SessionSummary[] = [ + makeSessionSummary({ id: "s1", model: "anthropic/claude-opus-4-20250514", costUsd: 0.50, tokenCount: 50000, messageCount: 3 }), + makeSessionSummary({ id: "s2", model: "anthropic/claude-opus-4-20250514", costUsd: 0.30, tokenCount: 30000, messageCount: 4 }), + makeSessionSummary({ id: "s3", model: "anthropic/claude-haiku-3", costUsd: 0.01, tokenCount: 5000, messageCount: 2 }), + ]; + const result = recommendForRecentSessions(sessions); + expect(result.totalSessions).toBe(3); + expect(result.recommendations.length).toBeGreaterThan(0); + expect(result.potentialTotalSavings).toBeGreaterThanOrEqual(0); + }); + + it("filters out completed sessions", () => { + const sessions: SessionSummary[] = [ + makeSessionSummary({ id: "s1", status: "completed" }), + makeSessionSummary({ id: "s2", status: "active" }), + ]; + const result = recommendForRecentSessions(sessions); + expect(result.totalSessions).toBe(1); // Only active/idle sessions + }); + + it("respects the limit parameter", () => { + const sessions: SessionSummary[] = Array.from({ length: 50 }, (_, i) => + makeSessionSummary({ id: `s${i}` }) + ); + const result = recommendForRecentSessions(sessions, 5); + expect(result.totalSessions).toBeLessThanOrEqual(5); + }); + + it("handles empty session list", () => { + const result = recommendForRecentSessions([]); + expect(result.totalSessions).toBe(0); + expect(result.recommendations).toEqual([]); + expect(result.potentialTotalSavings).toBe(0); + }); + + it("aggregates across different agents", () => { + const sessions: SessionSummary[] = [ + makeSessionSummary({ id: "s1", agentId: "agent-a", model: "anthropic/claude-opus-4-20250514" }), + makeSessionSummary({ id: "s2", agentId: "agent-b", model: "anthropic/claude-opus-4-20250514" }), + makeSessionSummary({ id: "s3", agentId: "agent-c", model: "gpt-4" }), + ]; + const result = recommendForRecentSessions(sessions); + // Should include sessions from all agents + expect(result.totalSessions).toBe(3); + }); +}); diff --git a/backend/src/modelClassifier.ts b/backend/src/modelClassifier.ts new file mode 100644 index 0000000..8273563 --- /dev/null +++ b/backend/src/modelClassifier.ts @@ -0,0 +1,148 @@ +import { SessionDetail, SessionSummary } from "./sessions.js"; + +/** + * Session complexity classification + */ +export type SessionComplexity = "simple" | "moderate" | "complex"; + +export interface SessionClassification { + complexity: SessionComplexity; + confidence: number; // 0-1 + reasons: string[]; + metrics: { + avgMessageLength: number; + toolCallsPerMessage: number; + userTurnCount: number; + hasFileOperations: boolean; + hasCodeExecution: boolean; + hasSubAgents: boolean; + }; +} + +/** + * Classify a session's complexity based on heuristics + */ +export function classifySession(session: SessionDetail): SessionClassification { + const metrics = analyzeSessionMetrics(session); + const { complexity, confidence, reasons } = determineComplexity(metrics); + + return { + complexity, + confidence, + reasons, + metrics, + }; +} + +function analyzeSessionMetrics(session: SessionDetail) { + const messages = session.messages; + const userMessages = messages.filter((m) => m.role === "user"); + const assistantMessages = messages.filter((m) => m.role === "assistant"); + + // Calculate average message length + const totalChars = userMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0); + const avgMessageLength = userMessages.length > 0 ? totalChars / userMessages.length : 0; + + // Count tool calls + const toolCalls = assistantMessages.filter((m) => m.toolName).length; + const toolCallsPerMessage = assistantMessages.length > 0 ? toolCalls / assistantMessages.length : 0; + + // Check for specific tool types + const toolNames = new Set(messages.filter((m) => m.toolName).map((m) => m.toolName!)); + const hasFileOperations = ["read", "write", "edit", "Read", "Write", "Edit"].some((t) => toolNames.has(t)); + const hasCodeExecution = toolNames.has("exec"); + const hasSubAgents = toolNames.has("sessions_spawn") || toolNames.has("subagents"); + + return { + avgMessageLength, + toolCallsPerMessage, + userTurnCount: userMessages.length, + hasFileOperations, + hasCodeExecution, + hasSubAgents, + }; +} + +function determineComplexity( + metrics: ReturnType +): { complexity: SessionComplexity; confidence: number; reasons: string[] } { + const reasons: string[] = []; + let complexityScore = 0; // 0-10 scale + + // Factor 1: Message complexity (0-3 points) + if (metrics.avgMessageLength < 100) { + complexityScore += 0; + reasons.push("Short, simple messages"); + } else if (metrics.avgMessageLength < 300) { + complexityScore += 1.5; + reasons.push("Moderate message length"); + } else { + complexityScore += 3; + reasons.push("Detailed, complex messages"); + } + + // Factor 2: Tool usage (0-3 points) + if (metrics.toolCallsPerMessage < 0.3) { + complexityScore += 0; + reasons.push("Minimal tool usage"); + } else if (metrics.toolCallsPerMessage < 1.0) { + complexityScore += 1.5; + reasons.push("Moderate tool usage"); + } else { + complexityScore += 3; + reasons.push("Heavy tool usage"); + } + + // Factor 3: Advanced features (0-4 points) + if (metrics.hasSubAgents) { + complexityScore += 2; + reasons.push("Uses sub-agent orchestration"); + } + if (metrics.hasCodeExecution) { + complexityScore += 1; + reasons.push("Executes code/commands"); + } + if (metrics.hasFileOperations) { + complexityScore += 1; + reasons.push("File operations"); + } + + // Determine complexity based on score + let complexity: SessionComplexity; + let confidence: number; + + if (complexityScore <= 3) { + complexity = "simple"; + confidence = 1 - complexityScore / 6; // Higher confidence for very simple tasks + } else if (complexityScore <= 6) { + complexity = "moderate"; + confidence = 0.7; // Moderate confidence in the middle range + } else { + complexity = "complex"; + confidence = 0.5 + (complexityScore - 6) / 8; // Higher confidence for very complex tasks + } + + return { complexity, confidence: Math.min(confidence, 0.95), reasons }; +} + +/** + * Quick classification from summary (no message details needed) + * Less accurate but faster for bulk analysis + */ +export function classifySessionSummary(session: SessionSummary): SessionComplexity { + // Heuristic based on available summary data + const hasMultipleModels = session.costByModel.length > 1; + const highMessageCount = session.messageCount > 20; + const longDuration = session.duration > 30 * 60 * 1000; // > 30 min + const expensiveModel = session.model.includes("opus") || session.model.includes("gpt-4"); + + if (hasMultipleModels || highMessageCount || longDuration) { + return "complex"; + } + + if (session.messageCount > 5 || expensiveModel) { + return "moderate"; + } + + return "simple"; +} diff --git a/backend/src/modelRecommendations.ts b/backend/src/modelRecommendations.ts new file mode 100644 index 0000000..fc90f60 --- /dev/null +++ b/backend/src/modelRecommendations.ts @@ -0,0 +1,215 @@ +import { SessionDetail, SessionSummary } from "./sessions.js"; +import { classifySession, classifySessionSummary, SessionComplexity } from "./modelClassifier.js"; + +export interface ModelRecommendation { + currentModel: string; + recommendedModel: string; + complexity: SessionComplexity; + confidence: number; + reasons: string[]; + potentialSavings: { + costUsd: number; + percentage: number; + }; +} + +export interface BulkRecommendationSummary { + totalSessions: number; + potentialTotalSavings: number; + recommendations: Array<{ + sessionId: string; + title: string; + recommendation: ModelRecommendation; + }>; +} + +/** + * Model cost estimates (rough estimates per 1M tokens) + * Based on typical pricing as of March 2024 + */ +const MODEL_COSTS = { + // Anthropic + "claude-opus": { input: 15.0, output: 75.0 }, + "claude-sonnet": { input: 3.0, output: 15.0 }, + "claude-haiku": { input: 0.25, output: 1.25 }, + + // OpenAI + "gpt-4": { input: 30.0, output: 60.0 }, + "gpt-4-turbo": { input: 10.0, output: 30.0 }, + "gpt-3.5-turbo": { input: 0.5, output: 1.5 }, + + // Gemini + "gemini-pro": { input: 0.5, output: 1.5 }, + "gemini-flash": { input: 0.075, output: 0.3 }, + + // Default fallback + unknown: { input: 5.0, output: 15.0 }, +}; + +function normalizeModelName(model: string): keyof typeof MODEL_COSTS { + const lower = model.toLowerCase(); + if (lower.includes("opus")) return "claude-opus"; + if (lower.includes("sonnet")) return "claude-sonnet"; + if (lower.includes("haiku")) return "claude-haiku"; + if (lower.includes("gpt-4-turbo") || lower.includes("gpt-4o")) return "gpt-4-turbo"; + if (lower.includes("gpt-4")) return "gpt-4"; + if (lower.includes("gpt-3.5")) return "gpt-3.5-turbo"; + if (lower.includes("gemini") && lower.includes("flash")) return "gemini-flash"; + if (lower.includes("gemini")) return "gemini-pro"; + return "unknown"; +} + +function estimateSessionCost(tokenCount: number, model: string): number { + const normalized = normalizeModelName(model); + const costs = MODEL_COSTS[normalized]; + // Rough estimate: assume 50/50 input/output split + const avgCost = (costs.input + costs.output) / 2; + return (tokenCount / 1_000_000) * avgCost; +} + +function mapComplexityToModel( + complexity: SessionComplexity, + currentModel: string +): { model: string; reason: string } { + const normalized = normalizeModelName(currentModel); + + switch (complexity) { + case "simple": + // Recommend cheapest tier + if (normalized === "claude-opus" || normalized === "claude-sonnet") { + return { model: "claude-haiku", reason: "Simple tasks work great with Haiku" }; + } + if (normalized === "gpt-4" || normalized === "gpt-4-turbo") { + return { model: "gpt-3.5-turbo", reason: "GPT-3.5 handles simple queries well" }; + } + if (normalized === "gemini-pro") { + return { model: "gemini-flash", reason: "Gemini Flash is fast and cheap for simple tasks" }; + } + break; + + case "moderate": + // Recommend mid-tier + if (normalized === "claude-opus") { + return { model: "claude-sonnet", reason: "Sonnet balances cost and capability" }; + } + if (normalized === "gpt-4") { + return { model: "gpt-4-turbo", reason: "GPT-4 Turbo is faster and cheaper" }; + } + if (normalized === "claude-haiku") { + return { model: "claude-haiku", reason: "Already using an efficient model" }; + } + if (normalized === "gpt-3.5-turbo") { + return { model: "gpt-3.5-turbo", reason: "Already cost-optimized" }; + } + break; + + case "complex": + // Keep current model or recommend upgrade if on cheapest tier + if (normalized === "claude-haiku" || normalized === "gpt-3.5-turbo") { + return { model: currentModel, reason: "Consider upgrading for complex tasks, but testing recommended" }; + } + return { model: currentModel, reason: "Complex task — current model is appropriate" }; + } + + // Default: no change + return { model: currentModel, reason: "Current model matches task complexity" }; +} + +/** + * Generate recommendation for a session (requires full detail) + */ +export function recommendModelForSession(session: SessionDetail): ModelRecommendation { + // Edge case: no messages or no model info + if (!session.model || session.messageCount === 0) { + return { + currentModel: session.model || "unknown", + recommendedModel: session.model || "unknown", + complexity: "simple", + confidence: 0, + reasons: ["Insufficient data to analyze this session"], + potentialSavings: { costUsd: 0, percentage: 0 }, + }; + } + + const classification = classifySession(session); + const recommendation = mapComplexityToModel(classification.complexity, session.model); + + // Edge case: zero cost session — no savings to compute + if (session.costUsd === 0 || session.tokenCount === 0) { + return { + currentModel: session.model, + recommendedModel: recommendation.model, + complexity: classification.complexity, + confidence: classification.confidence, + reasons: [...classification.reasons, recommendation.reason], + potentialSavings: { costUsd: 0, percentage: 0 }, + }; + } + + // Calculate potential savings + const currentCost = session.costUsd; + const estimatedNewCost = estimateSessionCost(session.tokenCount, recommendation.model); + const savings = Math.max(0, currentCost - estimatedNewCost); + const savingsPercentage = currentCost > 0 ? (savings / currentCost) * 100 : 0; + + return { + currentModel: session.model, + recommendedModel: recommendation.model, + complexity: classification.complexity, + confidence: classification.confidence, + reasons: [...classification.reasons, recommendation.reason], + potentialSavings: { + costUsd: Math.round(savings * 10000) / 10000, // Round to 4 decimal places + percentage: Math.round(savingsPercentage * 10) / 10, + }, + }; +} + +/** + * Generate recommendations for recent sessions (uses summary data only) + * Faster but less accurate than full detail analysis + */ +export function recommendForRecentSessions( + sessions: SessionSummary[], + limit: number = 20 +): BulkRecommendationSummary { + const recentSessions = sessions + .filter((s) => s.status === "active" || s.status === "idle") + .slice(0, limit); + + const recommendations = recentSessions + .map((session) => { + const complexity = classifySessionSummary(session); + const recommendation = mapComplexityToModel(complexity, session.model); + + const currentCost = session.costUsd; + const estimatedNewCost = estimateSessionCost(session.tokenCount, recommendation.model); + const savings = Math.max(0, currentCost - estimatedNewCost); + const savingsPercentage = currentCost > 0 ? (savings / currentCost) * 100 : 0; + + return { + sessionId: session.id, + title: session.title, + recommendation: { + currentModel: session.model, + recommendedModel: recommendation.model, + complexity, + confidence: 0.6, // Lower confidence without full detail + reasons: [recommendation.reason], + potentialSavings: { + costUsd: savings, + percentage: savingsPercentage, + }, + }, + }; + }) + .filter((r) => r.recommendation.potentialSavings.costUsd > 0.001); // Filter out negligible savings + + const totalSavings = recommendations.reduce((sum, r) => sum + r.recommendation.potentialSavings.costUsd, 0); + + return { + totalSessions: recentSessions.length, + potentialTotalSavings: totalSavings, + recommendations, + }; +} diff --git a/backend/src/routes.ts b/backend/src/routes.ts index ce9ff94..ff96611 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -16,6 +16,7 @@ import { getSessionProjects, bulkGetSessionProjects, } from "./projects"; +import { recommendModelForSession, recommendForRecentSessions } from "./modelRecommendations"; const router = Router(); @@ -858,33 +859,71 @@ router.get("/analytics", async (req: Request, res: Response) => { const agentMap = new Map>(); const projectMap = new Map }>(); - for (const s of sessions) { - const key = getBucketKey(s.startedAt); + /** + * For hourly grouping, distribute a session's cost/tokens proportionally + * across all hours between startedAt and lastActivityAt. + * This fixes #58 — sessions no longer spike at their start time. + * For daily/weekly, we still bucket by startedAt (sessions rarely span days). + */ + function getSessionBucketKeys(s: { startedAt: string; lastActivityAt: string }): string[] { + if (groupBy !== "hour") return [getBucketKey(s.startedAt)]; + + const start = new Date(s.startedAt); + const end = new Date(s.lastActivityAt); + // If start == end or invalid, just use startedAt + if (isNaN(start.getTime()) || isNaN(end.getTime()) || end <= start) { + return [getBucketKey(s.startedAt)]; + } - // Total - const tb = totalMap.get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 }; - tb.costUsd += s.costUsd; - tb.tokenCount += s.tokenCount; - tb.sessionCount += 1; - totalMap.set(key, tb); + const keys: string[] = []; + const cursor = new Date(start); + // Truncate to the hour + cursor.setUTCMinutes(0, 0, 0); + const endKey = getBucketKey(s.lastActivityAt); + while (cursor.toISOString().slice(0, 13) + ":00" <= endKey) { + keys.push(cursor.toISOString().slice(0, 13) + ":00"); + cursor.setUTCHours(cursor.getUTCHours() + 1); + // Safety: cap at 168 hours (1 week) to avoid runaway loops + if (keys.length > 168) break; + } + return keys.length > 0 ? keys : [getBucketKey(s.startedAt)]; + } - // By agent - if (!agentMap.has(s.agentId)) agentMap.set(s.agentId, new Map()); - const ab = agentMap.get(s.agentId)!.get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 }; - ab.costUsd += s.costUsd; - ab.tokenCount += s.tokenCount; - ab.sessionCount += 1; - agentMap.get(s.agentId)!.set(key, ab); - - // By project (session can belong to multiple projects) - const projects = projectTags.get(s.id) || []; - for (const p of projects) { - if (!projectMap.has(p.id)) projectMap.set(p.id, { name: p.name, buckets: new Map() }); - const pb = projectMap.get(p.id)!.buckets.get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 }; - pb.costUsd += s.costUsd; - pb.tokenCount += s.tokenCount; - pb.sessionCount += 1; - projectMap.get(p.id)!.buckets.set(key, pb); + for (const s of sessions) { + const bucketKeys = getSessionBucketKeys(s); + const numBuckets = bucketKeys.length; + + // Distribute cost/tokens evenly across active hours + const costPerBucket = s.costUsd / numBuckets; + const tokensPerBucket = Math.round(s.tokenCount / numBuckets); + + for (const key of bucketKeys) { + // Total + const tb = totalMap.get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 }; + tb.costUsd += costPerBucket; + tb.tokenCount += tokensPerBucket; + // Count session only in the first bucket (start hour) + if (key === bucketKeys[0]) tb.sessionCount += 1; + totalMap.set(key, tb); + + // By agent + if (!agentMap.has(s.agentId)) agentMap.set(s.agentId, new Map()); + const ab = agentMap.get(s.agentId)!.get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 }; + ab.costUsd += costPerBucket; + ab.tokenCount += tokensPerBucket; + if (key === bucketKeys[0]) ab.sessionCount += 1; + agentMap.get(s.agentId)!.set(key, ab); + + // By project (session can belong to multiple projects) + const projects = projectTags.get(s.id) || []; + for (const p of projects) { + if (!projectMap.has(p.id)) projectMap.set(p.id, { name: p.name, buckets: new Map() }); + const pb = projectMap.get(p.id)!.buckets.get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 }; + pb.costUsd += costPerBucket; + pb.tokenCount += tokensPerBucket; + if (key === bucketKeys[0]) pb.sessionCount += 1; + projectMap.get(p.id)!.buckets.set(key, pb); + } } } @@ -1087,4 +1126,50 @@ router.get("/spend", async (req: Request, res: Response) => { } }); +// ---------- Model Recommendations ---------- + +/** + * GET /api/recommendations/summary + * Get overview of potential savings across recent sessions. + * NOTE: must be defined BEFORE /:sessionId to avoid "summary" matching as a param. + */ +router.get("/recommendations/summary", async (req: Request, res: Response) => { + try { + const profile = req.query.profile as string | undefined; + const limit = parseInt(req.query.limit as string) || 20; + + const sessions = await listSessions(profile); + const summary = recommendForRecentSessions(sessions, limit); + + res.json(summary); + } catch (err: any) { + res.status(500).json({ error: err.message || "Failed to generate summary" }); + } +}); + +/** + * GET /api/recommendations/:sessionId + * Get AI-powered model recommendation for a specific session + */ +router.get("/recommendations/:sessionId", async (req: Request, res: Response) => { + try { + const sessionId = req.params.sessionId as string; + const profile = req.query.profile as string | undefined; + + const session = await getSessionDetail(sessionId, profile); + if (!session) { + res.status(404).json({ error: "Session not found" }); + return; + } + + const recommendation = recommendModelForSession(session); + res.json({ + sessionId, + recommendation, + }); + } catch (err: any) { + res.status(500).json({ error: err.message || "Failed to generate recommendation" }); + } +}); + export default router;