diff --git a/package-lock.json b/package-lock.json index bea2630..d338d0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,11 +7,6 @@ "": { "name": "vault-autopilot", "version": "0.1.0", - "dependencies": { - "@anthropic-ai/sdk": "^0.26.0", - "@google/generative-ai": "^0.15.0", - "openai": "^4.52.0" - }, "devDependencies": { "@types/jest": "^29.5.0", "@types/node": "^20.0.0", @@ -21,36 +16,6 @@ "typescript": "5.4.5" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.26.1.tgz", - "integrity": "sha512-HeMJP1bDFfQPQS3XTJAmfXkFBdZ88wvfkE05+vsoA9zGn5dHqEaHOPsqkazf/i0gXYg2XlLxxZrf6rUAarSqzw==", - "license": "MIT", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@anthropic-ai/sdk/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -573,31 +538,6 @@ "w3c-keyname": "^2.2.4" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1000,15 +940,6 @@ "node": ">=12" } }, - "node_modules/@google/generative-ai": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.15.0.tgz", - "integrity": "sha512-zs37judcTYFJf1U7tnuqnh7gdzF6dcWj9pNRxjA5JTONRoiQ0htrRdbefRFiewOIfXwhun5t9hbd2ray7812eQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2063,21 +1994,12 @@ "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2432,30 +2354,6 @@ "win32" ] }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2538,12 +2436,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/babel-jest": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", @@ -2751,19 +2643,6 @@ "dev": true, "license": "MIT" }, - "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/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2971,18 +2850,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3062,15 +2929,6 @@ "node": ">=0.10.0" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3091,20 +2949,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "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/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3149,51 +2993,6 @@ "is-arrayish": "^0.2.1" } }, - "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.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.3.tgz", @@ -3267,15 +3066,6 @@ "node": ">=4" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3395,41 +3185,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3452,15 +3207,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "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/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3481,30 +3227,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "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-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -3515,19 +3237,6 @@ "node": ">=8.0.0" } }, - "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/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -3563,18 +3272,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "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/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3614,45 +3311,6 @@ "node": ">=8" } }, - "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/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3670,15 +3328,6 @@ "node": ">=10.17.0" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -5407,15 +5056,6 @@ "tmpl": "1.0.5" } }, - "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/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -5450,27 +5090,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -5531,6 +5150,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/napi-postinstall": { @@ -5563,46 +5183,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5684,51 +5264,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openai": { - "version": "4.104.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", - "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - }, - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/openai/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/openai/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6416,12 +5951,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/ts-jest": { "version": "29.4.11", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", @@ -6565,6 +6094,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -6668,31 +6198,6 @@ "makeerror": "1.0.12" } }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 7244da0..04f540b 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,6 @@ "build": "tsc --noEmit --skipLibCheck && node esbuild.config.mjs production", "test": "jest" }, - "dependencies": { - "@anthropic-ai/sdk": "^0.26.0", - "@google/generative-ai": "^0.15.0", - "openai": "^4.52.0" - }, "devDependencies": { "@types/jest": "^29.5.0", "@types/node": "^20.0.0", diff --git a/src/clip-router.ts b/src/clip-router.ts index b20d768..c139812 100644 --- a/src/clip-router.ts +++ b/src/clip-router.ts @@ -1,6 +1,6 @@ import { ClipPayload, HookPayload, KeyframePayload, ScreenshotPayload, ThumbnailPayload } from './server'; -import { AIProvider, ClipRule, isMultiFrameProvider, MultiFrameRequest, PluginSettings, ScreenshotClipRule, ThumbnailClipRule } from './types'; -import { postProcessMarkdown, sanitize, buildVideoEmbed, extractVideoId, detectPlatform, videoKey } from './util'; +import { ClipRule, PluginSettings, ScreenshotClipRule, ThumbnailClipRule } from './types'; +import { sanitize, buildVideoEmbed, extractVideoId, detectPlatform, videoKey } from './util'; import { buildAnchor, mergeSection, coverSection, hookSection, keyframeSection, screenshotSection, VideoNoteMeta, NewSection } from './video-note'; export interface VaultOps { @@ -17,17 +17,16 @@ export interface VaultOps { export async function routeClip( payload: ClipPayload, - providers: Map, clipRules: PluginSettings['clipRules'], vaultOps: VaultOps, ): Promise<{ notePath?: string; notice?: string }> { - if (payload.mode === 'thumbnail') return handleThumbnail(payload, providers, clipRules.thumbnail, vaultOps); + if (payload.mode === 'thumbnail') return handleThumbnail(payload, clipRules.thumbnail, vaultOps); if (payload.mode === 'screenshot') { const normalized = normalizeScreenshot(payload); - return handleScreenshot(normalized, providers, clipRules.screenshot, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder); + return handleScreenshot(normalized, clipRules.screenshot, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder); } - if (payload.mode === 'hook') return handleMultiFrame(payload, providers, clipRules.hook, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder); - if (payload.mode === 'keyframe') return handleMultiFrame(payload, providers, clipRules.keyframe, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder); + if (payload.mode === 'hook') return handleMultiFrame(payload, clipRules.hook, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder); + if (payload.mode === 'keyframe') return handleMultiFrame(payload, clipRules.keyframe, vaultOps, clipRules.thumbnail.outputFolder, clipRules.thumbnail.thumbnailFolder); throw new Error('Unknown clip mode'); } @@ -115,7 +114,6 @@ function buildScreenshotTemplate(payload: ScreenshotPayload, imageNames: string[ async function handleScreenshot( payload: ScreenshotPayload, - providers: Map, rule: ScreenshotClipRule, vaultOps: VaultOps, searchFolder: string, @@ -146,43 +144,14 @@ async function handleScreenshot( const intoVideoNote = !!existing || extractVideoId(payload.url, undefined) != null; const meta: VideoNoteMeta = { platform: detectPlatform(payload.url), videoId: key, videoUrl: payload.url, title: payload.title }; - if (rule.processingMode === 'manual') { - const sopContent = readSopSafely(rule.sopPath, vaultOps); - if (intoVideoNote) { - const r = await upsertVideoNote(meta, screenshotSection(imageNames, sopContent), vaultOps, searchFolder); - await ensureCover(meta.videoId, payload.cover_url, vaultOps, assetFolder); - return r; - } - const template = buildScreenshotTemplate(payload, imageNames, sopContent); - await vaultOps.create(notePath, template); - return { notePath }; - } - - if (!rule.sopPath || !rule.outputFolder || !rule.providerId) { - throw new Error('Screenshot clip rule is not configured'); - } - const provider = providers.get(rule.providerId); - if (!provider) throw new Error(`Provider "${rule.providerId}" not found`); - if (!isMultiFrameProvider(provider)) { - throw new Error( - `Provider "${provider.name}" does not support multi-frame analysis. ` + - `Use an API provider (Anthropic, OpenAI-compatible, or Gemini).`, - ); - } - const frames = payload.images.map((img) => Buffer.from(img, 'base64')); - const sopContent = vaultOps.readFileSync(rule.sopPath); - const result = await provider.analyzeMultiFrame({ - frames, - sopContent, - meta: { url: payload.url }, - }); - const markdown = postProcessMarkdown(result); + const sopContent = readSopSafely(rule.sopPath, vaultOps); if (intoVideoNote) { - const r = await upsertVideoNote(meta, screenshotSection(imageNames, undefined, markdown), vaultOps, searchFolder); + const r = await upsertVideoNote(meta, screenshotSection(imageNames, sopContent), vaultOps, searchFolder); await ensureCover(meta.videoId, payload.cover_url, vaultOps, assetFolder); return r; } - await vaultOps.create(notePath, markdown); + const template = buildScreenshotTemplate(payload, imageNames, sopContent); + await vaultOps.create(notePath, template); return { notePath }; } @@ -207,7 +176,6 @@ async function findNoteByVideoId( async function handleThumbnail( payload: ThumbnailPayload, - providers: Map, rule: ThumbnailClipRule, vaultOps: VaultOps, ): Promise<{ notePath: string; notice?: string }> { @@ -223,19 +191,8 @@ async function handleThumbnail( const imgData = await vaultOps.downloadUrl(payload.thumbnail_url); await vaultOps.createBinary(thumbnailPath, imgData); - const sopContent = rule.processingMode === 'manual' - ? readSopSafely(rule.sopPath, vaultOps) - : undefined; - let aiResult: string | undefined; - if (rule.processingMode !== 'manual') { - if (!rule.sopPath || !rule.providerId) throw new Error('Thumbnail clip rule is not fully configured (sopPath / providerId missing)'); - const provider = providers.get(rule.providerId); - if (!provider) throw new Error(`Provider "${rule.providerId}" not found`); - if (!isMultiFrameProvider(provider)) throw new Error(`Provider "${provider.name}" does not support image analysis. Use an API provider.`); - const sop = vaultOps.readFileSync(rule.sopPath); - aiResult = postProcessMarkdown(await provider.analyzeMultiFrame({ frames: [Buffer.from(imgData)], sopContent: sop, meta: { video_title: payload.title, channel: payload.channel, url: payload.video_url } })); - } - const section = coverSection(thumbnailFile, aiResult ?? sopContent); + const sopContent = readSopSafely(rule.sopPath, vaultOps); + const section = coverSection(thumbnailFile, sopContent); const meta: VideoNoteMeta = { platform: payload.platform, videoId: videoKey(payload.video_url, payload.platform), @@ -248,7 +205,6 @@ async function handleThumbnail( async function handleMultiFrame( payload: HookPayload | KeyframePayload, - providers: Map, rule: ClipRule, vaultOps: VaultOps, searchFolder: string, @@ -271,70 +227,29 @@ async function handleMultiFrame( channel: payload.mode === 'hook' ? payload.channel : undefined, }; - if (rule.processingMode === 'manual') { - const framesDir = rule.framesFolder || rule.outputFolder; - await vaultOps.ensureFolder(framesDir); - const frameNames: string[] = []; - for (let i = 0; i < sampled.length; i++) { - const name = `${stem}-f${String(i + 1).padStart(2, '0')}.png`; - const bytes = Buffer.from(sampled[i], 'base64'); - await vaultOps.createBinary(`${framesDir}/${name}`, bytes.buffer as ArrayBuffer); - frameNames.push(name); - } - const sopContent = readSopSafely(rule.sopPath, vaultOps); - let section: NewSection; - if (payload.mode === 'hook') { - section = hookSection( - { url: payload.url, platform, endSeconds: payload.time_range?.end ?? 15, frameNames, transcript: payload.transcript, aiResult: undefined }, - sopContent, - ); - } else { - section = keyframeSection( - { url: payload.url, platform, start: payload.time_range.start, end: payload.time_range.end, frameNames, aiResult: undefined }, - sopContent, - ); - } - const result = await upsertVideoNote(meta, section, vaultOps, searchFolder); - await ensureCover(meta.videoId, payload.cover_url, vaultOps, assetFolder); - return result; + const framesDir = rule.framesFolder || rule.outputFolder; + await vaultOps.ensureFolder(framesDir); + const frameNames: string[] = []; + for (let i = 0; i < sampled.length; i++) { + const name = `${stem}-f${String(i + 1).padStart(2, '0')}.png`; + const bytes = Buffer.from(sampled[i], 'base64'); + await vaultOps.createBinary(`${framesDir}/${name}`, bytes.buffer as ArrayBuffer); + frameNames.push(name); } - - if (!rule.sopPath || !rule.outputFolder || !rule.providerId) { - throw new Error(`Clip rule for "${payload.mode}" is not configured`); - } - const provider = providers.get(rule.providerId); - if (!provider) throw new Error(`Provider "${rule.providerId}" not found`); - if (!isMultiFrameProvider(provider)) { - throw new Error( - `Provider "${payload.mode === 'hook' ? 'hook' : rule.providerId}" does not support multi-frame analysis. ` + - `Use an API provider (Anthropic, OpenAI-compatible, or Gemini).`, - ); - } - const frames = sampled.map((f) => Buffer.from(f, 'base64')); - const sopContent = vaultOps.readFileSync(rule.sopPath); - const metaReq: MultiFrameRequest['meta'] = { - video_title: payload.video_title, - url: payload.url, - captured_at: payload.captured_at, - ...(payload.mode === 'hook' - ? { channel: payload.channel, platform: payload.platform } - : { time_range: payload.time_range }), - }; - const transcript = payload.mode === 'hook' ? payload.transcript : undefined; - const result = await provider.analyzeMultiFrame({ frames, transcript, sopContent, meta: metaReq }); - const aiResult = postProcessMarkdown(result); - + const sopContent = readSopSafely(rule.sopPath, vaultOps); let section: NewSection; if (payload.mode === 'hook') { section = hookSection( - { url: payload.url, platform: payload.platform, endSeconds: payload.time_range?.end ?? 15, frameNames: [], transcript: payload.transcript, aiResult }, + { url: payload.url, platform, endSeconds: payload.time_range?.end ?? 15, frameNames, transcript: payload.transcript, aiResult: undefined }, sopContent, ); } else { section = keyframeSection( - { url: payload.url, platform: 'youtube', start: payload.time_range.start, end: payload.time_range.end, frameNames: [], aiResult }, + { url: payload.url, platform, start: payload.time_range.start, end: payload.time_range.end, frameNames, aiResult: undefined }, sopContent, ); } - return upsertVideoNote(meta, section, vaultOps, searchFolder); + const result = await upsertVideoNote(meta, section, vaultOps, searchFolder); + await ensureCover(meta.videoId, payload.cover_url, vaultOps, assetFolder); + return result; } diff --git a/src/main.ts b/src/main.ts index 7f577c9..236e8b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,26 +2,17 @@ import * as http from 'http'; import * as fs from 'fs'; import { Notice, Plugin, TFile, requestUrl } from 'obsidian'; import { DEFAULT_SETTINGS, VaultAutopilotSettingTab } from './settings'; -import { PluginSettings, AIProvider, ProviderConfig } from './types'; -import { createCLIProvider } from './providers/cli-base'; -import { createOpenAICompatProvider } from './providers/openai-compat'; -import { createAnthropicProvider } from './providers/anthropic'; -import { createGeminiAPIProvider } from './providers/gemini-api'; -import { detectBinaryPath } from './path-detector'; +import { PluginSettings } from './types'; import { createServer, ClipPayload } from './server'; import { routeClip, VaultOps } from './clip-router'; -import { runStartupChecks } from './startup-check'; export default class VaultAutopilotPlugin extends Plugin { settings: PluginSettings = DEFAULT_SETTINGS; private server: http.Server | null = null; - private providers = new Map(); async onload(): Promise { await this.loadSettings(); this.addSettingTab(new VaultAutopilotSettingTab(this.app, this)); - this.rebuildProviders(); - runStartupChecks(this.settings); if (this.settings.httpServer.enabled) this.startServer(); } @@ -42,13 +33,11 @@ export default class VaultAutopilotPlugin extends Plugin { hook: { ...DEFAULT_SETTINGS.clipRules.hook, ...(loaded?.clipRules?.hook ?? {}) }, keyframe: { ...DEFAULT_SETTINGS.clipRules.keyframe, ...(loaded?.clipRules?.keyframe ?? {}) }, }, - providers: loaded?.providers ?? DEFAULT_SETTINGS.providers, }; } async saveSettings(): Promise { await this.saveData(this.settings); - this.rebuildProviders(); } restartServer(): void { @@ -57,24 +46,6 @@ export default class VaultAutopilotPlugin extends Plugin { if (this.settings.httpServer.enabled) this.startServer(); } - private rebuildProviders(): void { - this.providers.clear(); - for (const config of this.settings.providers) { - this.providers.set(config.id, this.buildProvider(config)); - } - } - - private buildProvider(config: ProviderConfig): AIProvider { - if (config.type === 'cli') { - const resolvedBin = detectBinaryPath(config.cliType, config.bin); - return createCLIProvider({ ...config, bin: resolvedBin }); - } - if (config.type === 'openai-compat') return createOpenAICompatProvider(config); - if (config.type === 'anthropic') return createAnthropicProvider(config); - if (config.type === 'gemini-api') return createGeminiAPIProvider(config); - throw new Error(`Unknown provider type: ${(config as any).type}`); - } - private async ensureFolder(folderPath: string): Promise { const parts = folderPath.split('/'); let current = ''; @@ -122,7 +93,7 @@ export default class VaultAutopilotPlugin extends Plugin { this.server = createServer( port, async (payload) => { - const { notePath, notice } = await routeClip(payload, this.providers, this.settings.clipRules, vaultOps); + const { notePath, notice } = await routeClip(payload, this.settings.clipRules, vaultOps); const obsidianUrl = notePath ? `obsidian://open?vault=${encodeURIComponent(this.app.vault.getName())}&file=${encodeURIComponent(notePath)}` : undefined; diff --git a/src/path-detector.ts b/src/path-detector.ts deleted file mode 100644 index bd229b3..0000000 --- a/src/path-detector.ts +++ /dev/null @@ -1,50 +0,0 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -const HOME = os.homedir(); - -export const KNOWN_PATHS: Record = { - claude: [ - // Mac - official installer - path.join(HOME, '.claude', 'local', 'bin', 'claude'), - // Homebrew / manual - '/usr/local/bin/claude', - '/opt/homebrew/bin/claude', - path.join(HOME, '.local', 'bin', 'claude'), - // Windows - path.join(process.env['APPDATA'] || '', 'Claude', 'claude.exe'), - path.join(process.env['LOCALAPPDATA'] || '', 'Programs', 'Claude', 'claude.exe'), - ], - gemini: [ - // npm global - '/usr/local/bin/gemini', - '/opt/homebrew/bin/gemini', - path.join(HOME, '.npm-global', 'bin', 'gemini'), - path.join(HOME, 'node_modules', '.bin', 'gemini'), - // Windows npm global - path.join(process.env['APPDATA'] || '', 'npm', 'gemini.cmd'), - ], - codex: [ - '/usr/local/bin/codex', - '/opt/homebrew/bin/codex', - path.join(HOME, '.npm-global', 'bin', 'codex'), - // Windows - path.join(process.env['APPDATA'] || '', 'npm', 'codex.cmd'), - ], -}; - -/** - * Returns the resolved path for a CLI binary. - * Priority: configured path → known installation paths → bare name (rely on PATH at spawn time). - */ -export function detectBinaryPath(cliType: string, configured: string): string { - if (configured && fs.existsSync(configured)) return configured; - - const candidates = KNOWN_PATHS[cliType] || []; - for (const candidate of candidates) { - if (candidate && fs.existsSync(candidate)) return candidate; - } - - return cliType; // fall back to bare name; spawn will look in PATH -} diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts deleted file mode 100644 index 8210692..0000000 --- a/src/providers/anthropic.ts +++ /dev/null @@ -1,77 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; -import { AIProvider, AnalysisRequest, AnthropicProviderConfig, MultiFrameRequest, MultiFrameProvider } from '../types'; - -function imageMimeType(filePath: string): 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' { - const ext = filePath.split('.').pop()?.toLowerCase() || ''; - const map: Record = { - jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', - }; - return map[ext] ?? 'image/png'; -} - -export function createAnthropicProvider(config: AnthropicProviderConfig): MultiFrameProvider { - const client = new Anthropic({ apiKey: config.apiKey }); - - return { - id: config.id, - name: `Anthropic (${config.model})`, - - async analyze(req: AnalysisRequest): Promise { - const userContent: any[] = req.fileType === 'image' - ? [ - { type: 'image', source: { type: 'base64', media_type: imageMimeType(req.filePath), data: req.fileContent.toString('base64') } }, - { type: 'text', text: buildContext(req) }, - ] - : [{ type: 'text', text: `${buildContext(req)}\n\n${req.fileContent.toString('utf8')}` }]; - - const response = await client.messages.create({ - model: config.model, - max_tokens: 4096, - system: req.sopContent, - messages: [{ role: 'user', content: userContent }], - }); - - const textBlock = response.content.find((b: any) => b.type === 'text'); - if (!textBlock || textBlock.type !== 'text') throw new Error('Anthropic returned no text block'); - return (textBlock as any).text.trim(); - }, - - async analyzeMultiFrame(req: MultiFrameRequest): Promise { - const imageBlocks: any[] = req.frames.map((frame) => ({ - type: 'image', - source: { type: 'base64', media_type: 'image/png', data: frame.toString('base64') }, - })); - const response = await client.messages.create({ - model: config.model, - max_tokens: 4096, - system: req.sopContent, - messages: [{ - role: 'user', - content: [...imageBlocks, { type: 'text', text: buildMultiFrameContext(req) }], - }], - }); - const textBlock = response.content.find((b: any) => b.type === 'text'); - if (!textBlock || textBlock.type !== 'text') throw new Error('Anthropic returned no text block'); - return (textBlock as any).text.trim(); - }, - }; -} - -function buildContext(req: AnalysisRequest): string { - const parts: string[] = []; - if (req.meta.source_url) parts.push(`Source URL: ${req.meta.source_url}`); - if (req.meta.title) parts.push(`Title: ${req.meta.title}`); - return parts.join('\n') || 'Analyze the file.'; -} - -function buildMultiFrameContext(req: MultiFrameRequest): string { - const parts: string[] = []; - if (req.meta.video_title) parts.push(`Video: ${req.meta.video_title}`); - if (req.meta.channel) parts.push(`Channel: ${req.meta.channel}`); - if (req.meta.platform) parts.push(`Platform: ${req.meta.platform}`); - if (req.meta.url) parts.push(`URL: ${req.meta.url}`); - if (req.meta.time_range) parts.push(`Time range: ${req.meta.time_range.start}s–${req.meta.time_range.end}s`); - if (req.meta.captured_at) parts.push(`Captured: ${req.meta.captured_at}`); - if (req.transcript) parts.push(`\nTranscript:\n${req.transcript}`); - return parts.join('\n') || 'Analyze the frames.'; -} diff --git a/src/providers/cli-base.ts b/src/providers/cli-base.ts deleted file mode 100644 index c30cea0..0000000 --- a/src/providers/cli-base.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { spawn } from 'child_process'; -import { AIProvider, AnalysisRequest, CLIProviderConfig } from '../types'; - -// Each CLI provider has its own non-interactive flags. -// Prompt is passed via stdin to avoid special-character parsing issues with shell arguments. -// NOTE: gemini and codex non-interactive flags verified against their CLIs at time of writing. -// If a future CLI version changes flags, update CLI_FLAGS here. -const CLI_FLAGS: Record = { - claude: ['-p', '--allowedTools', 'Read'], - gemini: ['-p'], - codex: ['--quiet'], -}; - -function buildPrompt(req: AnalysisRequest): string { - const fileRef = req.fileType === 'image' - ? `Image file path: ${req.filePath}` - : `File content:\n\n${req.fileContent.toString('utf8')}`; - - return [ - '--- SOP / INSTRUCTIONS ---', - req.sopContent, - '--- FILE TO ANALYZE ---', - fileRef, - req.meta.source_url ? `Source URL: ${req.meta.source_url}` : '', - req.meta.title ? `Title hint: ${req.meta.title}` : '', - '--- OUTPUT INSTRUCTIONS ---', - 'Output the complete note as a markdown code block (```markdown ... ```).', - 'Do not add any explanation outside the code block.', - ].filter(Boolean).join('\n'); -} - -function extractMarkdown(stdout: string): string { - const match = stdout.match(/```markdown\r?\n([\s\S]*?)```/); - return match ? match[1].trim() : stdout.trim(); -} - -export function createCLIProvider(config: CLIProviderConfig): AIProvider { - const flags = CLI_FLAGS[config.cliType]; - if (!flags) throw new Error(`Unknown CLI type: ${config.cliType}`); - - return { - id: config.id, - name: `CLI: ${config.cliType} (${config.bin})`, - - analyze(req: AnalysisRequest): Promise { - return new Promise((resolve, reject) => { - const prompt = buildPrompt(req); - const proc = spawn(config.bin, flags, { timeout: 300_000 }); - - // Send prompt via stdin — avoids special-character parsing issues with -p argument - proc.stdin?.write(prompt, 'utf8'); - proc.stdin?.end(); - - let stdout = ''; - let stderr = ''; - proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); - proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); - proc.on('error', (err) => reject(err)); - proc.on('close', (code, signal) => { - if (signal) { - reject(new Error(`${config.cliType} timed out after 300s`)); - } else if (code !== 0) { - reject(new Error(stderr.trim() || `${config.cliType} exited with code ${code}`)); - } else { - resolve(extractMarkdown(stdout)); - } - }); - }); - }, - }; -} diff --git a/src/providers/gemini-api.ts b/src/providers/gemini-api.ts deleted file mode 100644 index f534a22..0000000 --- a/src/providers/gemini-api.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { GoogleGenerativeAI } from '@google/generative-ai'; -import { AIProvider, AnalysisRequest, GeminiAPIProviderConfig, MultiFrameRequest, MultiFrameProvider } from '../types'; - -function imageMimeType(filePath: string): string { - const ext = filePath.split('.').pop()?.toLowerCase() || ''; - const map: Record = { - jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', - }; - return map[ext] ?? 'image/png'; -} - -export function createGeminiAPIProvider(config: GeminiAPIProviderConfig): MultiFrameProvider { - const genAI = new GoogleGenerativeAI(config.apiKey); - - return { - id: config.id, - name: `Gemini API (${config.model})`, - - async analyze(req: AnalysisRequest): Promise { - const model = genAI.getGenerativeModel({ model: config.model }); - const parts: any[] = [{ text: req.sopContent + '\n\n' + buildContext(req) }]; - - if (req.fileType === 'image') { - parts.push({ inlineData: { mimeType: imageMimeType(req.filePath), data: req.fileContent.toString('base64') } }); - } else { - parts[0].text += `\n\nFile content:\n${req.fileContent.toString('utf8')}`; - } - - const result = await model.generateContent(parts); - return result.response.text().trim(); - }, - - async analyzeMultiFrame(req: MultiFrameRequest): Promise { - const model = genAI.getGenerativeModel({ model: config.model }); - const parts: any[] = [ - ...req.frames.map((frame) => ({ - inlineData: { mimeType: 'image/png', data: frame.toString('base64') }, - })), - { text: req.sopContent + '\n\n' + buildMultiFrameContext(req) }, - ]; - const result = await model.generateContent(parts); - return result.response.text().trim(); - }, - }; -} - -function buildContext(req: AnalysisRequest): string { - const parts: string[] = []; - if (req.meta.source_url) parts.push(`Source URL: ${req.meta.source_url}`); - if (req.meta.title) parts.push(`Title: ${req.meta.title}`); - return parts.join('\n'); -} - -function buildMultiFrameContext(req: MultiFrameRequest): string { - const parts: string[] = []; - if (req.meta.video_title) parts.push(`Video: ${req.meta.video_title}`); - if (req.meta.channel) parts.push(`Channel: ${req.meta.channel}`); - if (req.meta.platform) parts.push(`Platform: ${req.meta.platform}`); - if (req.meta.url) parts.push(`URL: ${req.meta.url}`); - if (req.meta.time_range) parts.push(`Time range: ${req.meta.time_range.start}s–${req.meta.time_range.end}s`); - if (req.meta.captured_at) parts.push(`Captured: ${req.meta.captured_at}`); - if (req.transcript) parts.push(`\nTranscript:\n${req.transcript}`); - return parts.join('\n') || 'Analyze the frames.'; -} diff --git a/src/providers/openai-compat.ts b/src/providers/openai-compat.ts deleted file mode 100644 index 4e89090..0000000 --- a/src/providers/openai-compat.ts +++ /dev/null @@ -1,83 +0,0 @@ -import OpenAI from 'openai'; -import { AIProvider, AnalysisRequest, OpenAICompatProviderConfig, MultiFrameRequest, MultiFrameProvider } from '../types'; - -function imageToDataUrl(buf: Buffer, filePath: string): string { - const ext = filePath.split('.').pop()?.toLowerCase() || 'png'; - const mime: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }; - return `data:${mime[ext] ?? 'image/png'};base64,${buf.toString('base64')}`; -} - -export function createOpenAICompatProvider(config: OpenAICompatProviderConfig): MultiFrameProvider { - const client = new OpenAI({ - apiKey: config.apiKey, - baseURL: config.baseUrl, - dangerouslyAllowBrowser: true, // required in Electron environment - }); - - return { - id: config.id, - name: `${config.label} (${config.model})`, - - async analyze(req: AnalysisRequest): Promise { - const systemPrompt = req.sopContent; - - const userContent = req.fileType === 'image' - ? [ - { type: 'text' as const, text: buildTextContext(req) }, - { type: 'image_url' as const, image_url: { url: imageToDataUrl(req.fileContent, req.filePath) } }, - ] - : buildTextContext(req); - - const response = await client.chat.completions.create({ - model: config.model, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userContent }, - ], - max_tokens: 4096, - }); - - const content = response.choices[0]?.message?.content; - if (!content) throw new Error('API returned no content'); - return content.trim(); - }, - - async analyzeMultiFrame(req: MultiFrameRequest): Promise { - const imageContent = req.frames.map((frame) => ({ - type: 'image_url' as const, - image_url: { url: `data:image/png;base64,${frame.toString('base64')}` }, - })); - const response = await client.chat.completions.create({ - model: config.model, - messages: [ - { role: 'system', content: req.sopContent }, - { role: 'user', content: [...imageContent, { type: 'text' as const, text: buildMultiFrameContext(req) }] }, - ], - max_tokens: 4096, - }); - const content = response.choices[0]?.message?.content; - if (!content) throw new Error('API returned no content'); - return content.trim(); - }, - }; -} - -function buildTextContext(req: AnalysisRequest): string { - const parts: string[] = []; - if (req.meta.source_url) parts.push(`Source URL: ${req.meta.source_url}`); - if (req.meta.title) parts.push(`Title: ${req.meta.title}`); - if (req.fileType === 'text') parts.push(`\nFile content:\n${req.fileContent.toString('utf8')}`); - return parts.join('\n') || 'Analyze the attached file.'; -} - -function buildMultiFrameContext(req: MultiFrameRequest): string { - const parts: string[] = []; - if (req.meta.video_title) parts.push(`Video: ${req.meta.video_title}`); - if (req.meta.channel) parts.push(`Channel: ${req.meta.channel}`); - if (req.meta.platform) parts.push(`Platform: ${req.meta.platform}`); - if (req.meta.url) parts.push(`URL: ${req.meta.url}`); - if (req.meta.time_range) parts.push(`Time range: ${req.meta.time_range.start}s–${req.meta.time_range.end}s`); - if (req.meta.captured_at) parts.push(`Captured: ${req.meta.captured_at}`); - if (req.transcript) parts.push(`\nTranscript:\n${req.transcript}`); - return parts.join('\n') || 'Analyze the frames.'; -} diff --git a/src/settings.ts b/src/settings.ts index d204b3e..9892cee 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,10 +1,8 @@ -import * as crypto from 'crypto'; -import { App, Notice, PluginSettingTab, Setting } from 'obsidian'; +import { App, PluginSettingTab, Setting } from 'obsidian'; import type VaultAutopilotPlugin from './main'; -import { PluginSettings, ProviderConfig, ScreenshotClipRule, ThumbnailClipRule } from './types'; +import { PluginSettings, ScreenshotClipRule, ThumbnailClipRule } from './types'; export const DEFAULT_SETTINGS: PluginSettings = { - providers: [], httpServer: { enabled: true, port: 27183, @@ -46,44 +44,6 @@ export class VaultAutopilotSettingTab extends PluginSettingTab { if (n > 1024 && n < 65536) { this.plugin.settings.httpServer.port = n; await this.plugin.saveSettings(); } })); - // ── Providers ────────────────────────────────────────────────────────────── - new Setting(containerEl).setName('AI Providers').setHeading(); - new Setting(containerEl) - .setName('Add provider') - .setDesc('Configure at least one provider before creating rules.') - .addDropdown(d => d - .addOption('cli-claude', 'CLI: claude (Claude Code subscription)') - .addOption('cli-gemini', 'CLI: gemini (Google account, free)') - .addOption('cli-codex', 'CLI: codex (ChatGPT Plus subscription)') - .addOption('openai-compat', 'API: OpenAI-compatible (OpenRouter / OpenAI / Grok / DeepSeek / Ollama)') - .addOption('anthropic', 'API: Anthropic (Claude API)') - .addOption('gemini-api', 'API: Google Gemini API') - ) - .addButton(b => b.setButtonText('Add').onClick(async () => { - const sel = containerEl.querySelector('select') as HTMLSelectElement; - if (!sel) return; - const type = sel.value; - const id = crypto.randomBytes(4).toString('hex'); - let newProvider: ProviderConfig; - if (type === 'openai-compat') { - newProvider = { id, type: 'openai-compat', label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', apiKey: '', model: '' }; - } else if (type === 'anthropic') { - newProvider = { id, type: 'anthropic', apiKey: '', model: 'claude-sonnet-4-6' }; - } else if (type === 'gemini-api') { - newProvider = { id, type: 'gemini-api', apiKey: '', model: 'gemini-1.5-flash' }; - } else { - const cliType = type.replace('cli-', '') as 'claude' | 'gemini' | 'codex'; - newProvider = { id, type: 'cli', cliType, bin: '' }; - } - this.plugin.settings.providers.push(newProvider); - await this.plugin.saveSettings(); - this.display(); - })); - - for (const [i, prov] of this.plugin.settings.providers.entries()) { - this.renderProvider(containerEl, prov, i); - } - // ── Clip Rules ────────────────────────────────────────────────────────────── new Setting(containerEl).setName('Clip Rules').setHeading(); @@ -96,18 +56,6 @@ export class VaultAutopilotSettingTab extends PluginSettingTab { for (const mode of ['hook', 'keyframe'] as const) { const label = mode === 'hook' ? 'Hook Analysis' : 'Keyframe Analysis'; new Setting(containerEl).setName(label).setHeading(); - new Setting(containerEl) - .setName('Processing mode') - .setDesc('Auto: vault-autopilot calls AI and writes the note. Manual: saves frames + template, you trigger analysis in Obsidian.') - .addDropdown(d => d - .addOption('manual', 'Manual (save frames + template)') - .addOption('auto', 'Auto (call AI provider)') - .setValue(this.plugin.settings.clipRules[mode].processingMode) - .onChange(async v => { - this.plugin.settings.clipRules[mode].processingMode = v as 'auto' | 'manual'; - await this.plugin.saveSettings(); - this.display(); - })); new Setting(containerEl) .setName('Max frames to save') .setDesc('How many frames to sample and save (1–20). Default: 5.') @@ -120,17 +68,15 @@ export class VaultAutopilotSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); } })); - if (this.plugin.settings.clipRules[mode].processingMode === 'manual') { - new Setting(containerEl) - .setName('Frames folder') - .setDesc('Vault-relative path where frame images are saved. Default: Assets/images') - .addText(t => t - .setValue(this.plugin.settings.clipRules[mode].framesFolder) - .onChange(async v => { - this.plugin.settings.clipRules[mode].framesFolder = v.trim(); - await this.plugin.saveSettings(); - })); - } + new Setting(containerEl) + .setName('Frames folder') + .setDesc('Vault-relative path where frame images are saved. Default: Assets/images') + .addText(t => t + .setValue(this.plugin.settings.clipRules[mode].framesFolder) + .onChange(async v => { + this.plugin.settings.clipRules[mode].framesFolder = v.trim(); + await this.plugin.saveSettings(); + })); new Setting(containerEl) .setName('SOP / prompt path') .setDesc('Absolute path to the markdown SOP file.') @@ -149,38 +95,11 @@ export class VaultAutopilotSettingTab extends PluginSettingTab { this.plugin.settings.clipRules[mode].outputFolder = v.trim(); await this.plugin.saveSettings(); })); - if (this.plugin.settings.clipRules[mode].processingMode === 'auto') { - new Setting(containerEl) - .setName('Provider') - .setDesc('Must be an API provider (Anthropic, OpenAI-compatible, or Gemini).') - .addDropdown(d => { - this.plugin.settings.providers.forEach(p => - d.addOption(p.id, p.type === 'cli' ? `CLI: ${(p as any).cliType}` : (p as any).label || p.type) - ); - d.setValue(this.plugin.settings.clipRules[mode].providerId) - .onChange(async v => { - this.plugin.settings.clipRules[mode].providerId = v; - await this.plugin.saveSettings(); - }); - }); - } } } private renderThumbnailClipRule(el: HTMLElement): void { const rule: ThumbnailClipRule = this.plugin.settings.clipRules.thumbnail; - new Setting(el) - .setName('Processing mode') - .setDesc('Auto: downloads thumbnail + calls AI to analyze cover. Manual: downloads thumbnail + generates note template.') - .addDropdown(d => d - .addOption('manual', 'Manual (download + template)') - .addOption('auto', 'Auto (download + call AI provider)') - .setValue(rule.processingMode) - .onChange(async v => { - this.plugin.settings.clipRules.thumbnail.processingMode = v as 'auto' | 'manual'; - await this.plugin.saveSettings(); - this.display(); - })); new Setting(el) .setName('Output folder') .setDesc('Vault-relative path for generated notes. Default: Content Creation/Great Videos') @@ -208,37 +127,10 @@ export class VaultAutopilotSettingTab extends PluginSettingTab { this.plugin.settings.clipRules.thumbnail.sopPath = v.trim(); await this.plugin.saveSettings(); })); - if (rule.processingMode === 'auto') { - new Setting(el) - .setName('Provider') - .setDesc('Must be an API provider (Anthropic, OpenAI-compatible, or Gemini).') - .addDropdown(d => { - this.plugin.settings.providers.forEach(p => - d.addOption(p.id, p.type === 'cli' ? `CLI: ${(p as any).cliType}` : (p as any).label || p.type) - ); - d.setValue(rule.providerId) - .onChange(async v => { - this.plugin.settings.clipRules.thumbnail.providerId = v; - await this.plugin.saveSettings(); - }); - }); - } } private renderScreenshotClipRule(el: HTMLElement): void { const rule: ScreenshotClipRule = this.plugin.settings.clipRules.screenshot; - new Setting(el) - .setName('Processing mode') - .setDesc('Auto: vault-autopilot calls AI and writes the note. Manual: saves images + template, you trigger analysis in Obsidian.') - .addDropdown(d => d - .addOption('manual', 'Manual (save images + template)') - .addOption('auto', 'Auto (call AI provider)') - .setValue(rule.processingMode) - .onChange(async v => { - this.plugin.settings.clipRules.screenshot.processingMode = v as 'auto' | 'manual'; - await this.plugin.saveSettings(); - this.display(); - })); new Setting(el) .setName('Frames folder') .setDesc('Vault-relative path where screenshot images are saved. Default: Assets/images') @@ -266,51 +158,5 @@ export class VaultAutopilotSettingTab extends PluginSettingTab { this.plugin.settings.clipRules.screenshot.outputFolder = v.trim(); await this.plugin.saveSettings(); })); - if (rule.processingMode === 'auto') { - new Setting(el) - .setName('Provider') - .setDesc('Must be an API provider (Anthropic, OpenAI-compatible, or Gemini).') - .addDropdown(d => { - this.plugin.settings.providers.forEach(p => - d.addOption(p.id, p.type === 'cli' ? `CLI: ${(p as any).cliType}` : (p as any).label || p.type) - ); - d.setValue(rule.providerId) - .onChange(async v => { - this.plugin.settings.clipRules.screenshot.providerId = v; - await this.plugin.saveSettings(); - }); - }); - } } - - private renderProvider(el: HTMLElement, prov: ProviderConfig, i: number): void { - const label = prov.type === 'cli' ? `CLI: ${prov.cliType}` : prov.type === 'openai-compat' ? `API: ${prov.label}` : `API: ${prov.type}`; - new Setting(el).setName(label).setHeading(); - - if (prov.type === 'cli') { - new Setting(el) - .setName('Binary path') - .setDesc(`Leave empty to auto-detect. Example: /usr/local/bin/${prov.cliType}`) - .addText(t => t.setValue(prov.bin).onChange(async v => { - (this.plugin.settings.providers[i] as any).bin = v.trim(); - await this.plugin.saveSettings(); - })); - } - if (prov.type === 'openai-compat') { - new Setting(el).setName('Label').addText(t => t.setValue(prov.label).onChange(async v => { (this.plugin.settings.providers[i] as any).label = v; await this.plugin.saveSettings(); })); - new Setting(el).setName('Base URL').setDesc('e.g. https://openrouter.ai/api/v1').addText(t => t.setValue(prov.baseUrl).onChange(async v => { (this.plugin.settings.providers[i] as any).baseUrl = v.trim(); await this.plugin.saveSettings(); })); - new Setting(el).setName('API Key').addText(t => t.setValue(prov.apiKey).onChange(async v => { (this.plugin.settings.providers[i] as any).apiKey = v.trim(); await this.plugin.saveSettings(); })); - new Setting(el).setName('Model').setDesc('Must support vision for image analysis. e.g. gpt-4o, openai/gpt-4o').addText(t => t.setValue(prov.model).onChange(async v => { (this.plugin.settings.providers[i] as any).model = v.trim(); await this.plugin.saveSettings(); })); - } - if (prov.type === 'anthropic' || prov.type === 'gemini-api') { - new Setting(el).setName('API Key').addText(t => t.setValue(prov.apiKey).onChange(async v => { (this.plugin.settings.providers[i] as any).apiKey = v.trim(); await this.plugin.saveSettings(); })); - new Setting(el).setName('Model').addText(t => t.setValue(prov.model).onChange(async v => { (this.plugin.settings.providers[i] as any).model = v.trim(); await this.plugin.saveSettings(); })); - } - new Setting(el).addButton(b => b.setButtonText('Remove provider').onClick(async () => { - this.plugin.settings.providers.splice(i, 1); - await this.plugin.saveSettings(); - this.display(); - })); - } - } diff --git a/src/startup-check.ts b/src/startup-check.ts deleted file mode 100644 index efa76a7..0000000 --- a/src/startup-check.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as fs from 'fs'; -import { Notice } from 'obsidian'; -import { detectBinaryPath } from './path-detector'; -import { PluginSettings } from './types'; - -/** - * Run on plugin load. Shows a Notice for each misconfiguration found. - * Returns list of problem strings (empty = all good). - */ -export function runStartupChecks(settings: PluginSettings): string[] { - const problems: string[] = []; - - // Check each provider - for (const prov of settings.providers) { - if (prov.type === 'cli') { - const resolved = detectBinaryPath(prov.cliType, prov.bin); - if (resolved === prov.cliType) { - // fell back to bare name — binary not found in known paths - problems.push(`CLI provider "${prov.cliType}": binary not found. Open plugin settings and set the full path.`); - } - } else if (prov.type === 'anthropic' || prov.type === 'gemini-api') { - if (!prov.apiKey) problems.push(`Provider "${prov.type}": API key is empty.`); - if (!prov.model) problems.push(`Provider "${prov.type}": model is empty.`); - } else if (prov.type === 'openai-compat') { - if (!prov.apiKey) problems.push(`Provider "${prov.label}": API key is empty.`); - if (!prov.model) problems.push(`Provider "${prov.label}": model is empty.`); - if (!prov.baseUrl) problems.push(`Provider "${prov.label}": base URL is empty.`); - } - } - - // Show as Notice (each problem gets its own so they don't stack-truncate) - for (const p of problems) { - new Notice(`Vault Autopilot: ${p}`, 10000); - } - - return problems; -} diff --git a/src/types.ts b/src/types.ts index d8bf86c..daca6d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,90 +1,9 @@ -export type FileType = 'image' | 'text'; - -export interface FileMeta { - source_url?: string; - title?: string; -} - -export interface AnalysisRequest { - filePath: string; // absolute path to the file on disk - fileType: FileType; - fileContent: Buffer; // raw bytes - sopContent: string; // contents of the SOP/prompt file - meta: FileMeta; -} - -export interface AIProvider { - id: string; - name: string; - analyze(request: AnalysisRequest): Promise; // returns markdown note content -} - -export interface MultiFrameRequest { - frames: Buffer[]; - transcript?: string; - sopContent: string; - meta: { - video_title?: string; - channel?: string; - platform?: string; - url?: string; - time_range?: { start: number; end: number }; - captured_at?: string; - }; -} - -export interface MultiFrameProvider extends AIProvider { - analyzeMultiFrame(req: MultiFrameRequest): Promise; -} - -export function isMultiFrameProvider(p: AIProvider): p is MultiFrameProvider { - return typeof (p as any).analyzeMultiFrame === 'function'; -} - -// ── Provider configs ────────────────────────────────────────────────────────── - -export interface CLIProviderConfig { - id: string; - type: 'cli'; - cliType: 'claude' | 'gemini' | 'codex'; - bin: string; // resolved absolute path or command name -} - -export interface OpenAICompatProviderConfig { - id: string; - type: 'openai-compat'; - label: string; // display name e.g. "OpenRouter", "Grok" - baseUrl: string; // e.g. https://openrouter.ai/api/v1 - apiKey: string; - model: string; // e.g. gpt-4o, meta-llama/llama-3.2-90b-vision -} - -export interface AnthropicProviderConfig { - id: string; - type: 'anthropic'; - apiKey: string; - model: string; // e.g. claude-sonnet-4-6 -} - -export interface GeminiAPIProviderConfig { - id: string; - type: 'gemini-api'; - apiKey: string; - model: string; // e.g. gemini-1.5-flash -} - -export type ProviderConfig = - | CLIProviderConfig - | OpenAICompatProviderConfig - | AnthropicProviderConfig - | GeminiAPIProviderConfig; - // ── Rules ───────────────────────────────────────────────────────────────────── export interface ClipRule { sopPath: string; // absolute path to SOP markdown file outputFolder: string; // vault-relative path - providerId: string; // must match a ProviderConfig.id + providerId: string; processingMode: 'auto' | 'manual'; maxFrames: number; framesFolder: string; // vault-relative path for frame PNGs (e.g. "Assets/images") @@ -114,7 +33,6 @@ export interface HttpServerSettings { } export interface PluginSettings { - providers: ProviderConfig[]; httpServer: HttpServerSettings; clipRules: { thumbnail: ThumbnailClipRule; diff --git a/tests/clip-router.test.ts b/tests/clip-router.test.ts index e30a877..eae6d5d 100644 --- a/tests/clip-router.test.ts +++ b/tests/clip-router.test.ts @@ -1,20 +1,7 @@ import { routeClip, VaultOps } from '../src/clip-router'; -import { AIProvider, ClipRule, ScreenshotClipRule, ThumbnailClipRule, isMultiFrameProvider } from '../src/types'; +import { ClipRule, ScreenshotClipRule, ThumbnailClipRule } from '../src/types'; import { ClipPayload } from '../src/server'; -function makeMultiFrameProvider(id = 'p1') { - return { - id, - name: 'Mock API Provider', - analyze: jest.fn(), - analyzeMultiFrame: jest.fn().mockResolvedValue('# Analysis\nContent'), - }; -} - -function makeSingleProvider(id = 'p2') { - return { id, name: 'Mock CLI', analyze: jest.fn() }; -} - function makeVaultOps(): jest.Mocked { return { ensureFolder: jest.fn().mockResolvedValue(undefined), @@ -39,16 +26,14 @@ const screenshotClipRule: ScreenshotClipRule = { }; const hookClipRule: ClipRule = { sopPath: '/hook-sop.md', outputFolder: 'Hooks', providerId: 'p1', - processingMode: 'auto', maxFrames: 5, framesFolder: 'Assets/images', + processingMode: 'manual', maxFrames: 5, framesFolder: 'Assets/images', }; const keyframeClipRule: ClipRule = { sopPath: '/kf-sop.md', outputFolder: 'Keyframes', providerId: 'p1', - processingMode: 'auto', maxFrames: 5, framesFolder: 'Assets/images', + processingMode: 'manual', maxFrames: 5, framesFolder: 'Assets/images', }; const clipRules = { thumbnail: thumbnailClipRule, screenshot: screenshotClipRule, hook: hookClipRule, keyframe: keyframeClipRule }; -// ── isMultiFrameProvider ────────────────────────────────────────────────────── - // ── thumbnail ───────────────────────────────────────────────────────────────── describe('routeClip — thumbnail', () => { @@ -67,7 +52,7 @@ describe('routeClip — thumbnail', () => { test('manual: downloads thumbnail and creates note with correct frontmatter', async () => { const vaultOps = makeVaultOps(); - await routeClip(payload, new Map(), clipRules, vaultOps); + await routeClip(payload, clipRules, vaultOps); expect(vaultOps.downloadUrl).toHaveBeenCalledWith(payload.thumbnail_url); expect(vaultOps.createBinary).toHaveBeenCalledWith( 'Assets/Great Videos/abc123.webp', @@ -86,25 +71,10 @@ describe('routeClip — thumbnail', () => { test('note filename omits the author segment when channel is empty', async () => { const vaultOps = makeVaultOps(); const noChannel = { ...payload, channel: '' } as ClipPayload; - const result = await routeClip(noChannel, new Map(), clipRules, vaultOps); + const result = await routeClip(noChannel, clipRules, vaultOps); expect(result.notePath).toBe('Content Creation/Great Videos/How to Get Rich on Easy Mode.md'); }); - test('auto: calls analyzeMultiFrame with thumbnail buffer and writes AI result', async () => { - const provider = makeMultiFrameProvider('p1'); - const providers = new Map([['p1', provider as any]]); - const vaultOps = makeVaultOps(); - const autoRule: ThumbnailClipRule = { ...thumbnailClipRule, processingMode: 'auto' }; - await routeClip(payload, providers, { ...clipRules, thumbnail: autoRule }, vaultOps); - expect(provider.analyzeMultiFrame).toHaveBeenCalledWith(expect.objectContaining({ - sopContent: '# SOP\nAnalyze this.', - meta: expect.objectContaining({ video_title: 'How to Get Rich on Easy Mode' }), - })); - const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0]; - expect(noteContent).toContain('## 🖼️ 封面标题'); - expect(noteContent).toContain('# Analysis'); - }); - test('non-YT/Bili platform: no youtube embed, no channel/views in frontmatter, image in cover', async () => { const vaultOps = makeVaultOps(); const generic: ClipPayload = { @@ -119,7 +89,7 @@ describe('routeClip — thumbnail', () => { views: null, captured_at: '2026-06-27T00:00:00Z', }; - await routeClip(generic, new Map(), clipRules, vaultOps); + await routeClip(generic, clipRules, vaultOps); const [, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0]; expect(noteContent).not.toContain('youtube.com/embed'); expect(noteContent).not.toContain('channel: "'); @@ -128,15 +98,6 @@ describe('routeClip — thumbnail', () => { }); }); -describe('isMultiFrameProvider', () => { - test('returns true when analyzeMultiFrame method is present', () => { - expect(isMultiFrameProvider(makeMultiFrameProvider())).toBe(true); - }); - test('returns false when analyzeMultiFrame is absent', () => { - expect(isMultiFrameProvider(makeSingleProvider())).toBe(false); - }); -}); - // ── screenshot ──────────────────────────────────────────────────────────────── describe('routeClip — screenshot', () => { @@ -148,7 +109,7 @@ describe('routeClip — screenshot', () => { url: 'https://x.com', title: 'My Screenshot', }; - const result = await routeClip(payload, new Map(), clipRules, vaultOps); + const result = await routeClip(payload, clipRules, vaultOps); expect(result.notePath).toMatch(/^Screenshots\/screenshot-.+\.md$/); expect(vaultOps.createBinary).toHaveBeenCalledTimes(2); expect(vaultOps.createBinary).toHaveBeenCalledWith( @@ -165,32 +126,6 @@ describe('routeClip — screenshot', () => { ); }); - test('auto: saves images then calls analyzeMultiFrame', async () => { - const provider = makeMultiFrameProvider('p1'); - const providers = new Map([['p1', provider as any]]); - const vaultOps = makeVaultOps(); - const autoRule: ScreenshotClipRule = { ...screenshotClipRule, processingMode: 'auto' }; - const payload: ClipPayload = { - mode: 'screenshot', - images: [Buffer.from('pixels').toString('base64')], - url: 'https://x.com', - title: 'Auto Shot', - }; - await routeClip(payload, providers, { ...clipRules, screenshot: autoRule }, vaultOps); - expect(vaultOps.createBinary).toHaveBeenCalledWith( - expect.stringContaining('Assets/images/'), - expect.any(ArrayBuffer), - ); - expect(provider.analyzeMultiFrame).toHaveBeenCalledWith(expect.objectContaining({ - frames: [Buffer.from('pixels')], - sopContent: '# SOP\nAnalyze this.', - })); - expect(vaultOps.create).toHaveBeenCalledWith( - expect.stringMatching(/Screenshots\/screenshot-.+\.md/), - expect.any(String), - ); - }); - test('backward compat: old image field is normalized to images array', async () => { const vaultOps = makeVaultOps(); const payload = { @@ -199,7 +134,7 @@ describe('routeClip — screenshot', () => { url: 'https://x.com', title: 'Old Format', } as unknown as ClipPayload; - await routeClip(payload, new Map(), clipRules, vaultOps); + await routeClip(payload, clipRules, vaultOps); expect(vaultOps.createBinary).toHaveBeenCalledTimes(1); expect(vaultOps.create).toHaveBeenCalledWith( expect.stringMatching(/Screenshots\/screenshot-.+\.md/), @@ -208,113 +143,6 @@ describe('routeClip — screenshot', () => { }); }); -// ── hook ────────────────────────────────────────────────────────────────────── - -describe('routeClip — hook', () => { - test('calls analyzeMultiFrame with decoded frames and transcript, creates note in Great Videos', async () => { - const provider = makeMultiFrameProvider('p1'); - const providers = new Map([['p1', provider as any]]); - const vaultOps = makeVaultOps(); - const payload: ClipPayload = { - mode: 'hook', - frames: [Buffer.from('frame1').toString('base64'), Buffer.from('frame2').toString('base64')], - transcript: 'Hello world', - video_title: 'My Hook', - url: 'https://yt.com', - captured_at: '2026-05-30T18:00:00Z', - }; - const result = await routeClip(payload, providers, clipRules, vaultOps); - expect(provider.analyzeMultiFrame).toHaveBeenCalledWith(expect.objectContaining({ - frames: [Buffer.from('frame1'), Buffer.from('frame2')], - transcript: 'Hello world', - sopContent: '# SOP\nAnalyze this.', - })); - // Hook now lands in the Great Videos (thumbnail outputFolder) as a merged note - expect(result.notePath).toMatch(/Content Creation\/Great Videos\/.+\.md/); - expect(vaultOps.create).toHaveBeenCalledWith( - expect.stringMatching(/Content Creation\/Great Videos\/.+\.md/), - expect.stringContaining('## 🎬 内容'), - ); - }); - - test('throws when hook clip rule has no sopPath configured', async () => { - const vaultOps = makeVaultOps(); - const emptyRules = { thumbnail: thumbnailClipRule, screenshot: screenshotClipRule, hook:{ sopPath: '', outputFolder: 'Hooks', providerId: 'p1', processingMode: 'auto' as const, maxFrames: 5, framesFolder: 'Assets/images' }, keyframe: keyframeClipRule }; - const payload: ClipPayload = { - mode: 'hook', frames: [Buffer.from('f').toString('base64')], - video_title: 'V', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z', - }; - await expect(routeClip(payload, new Map(), emptyRules, vaultOps)) - .rejects.toThrow('not configured'); - }); - - test('throws when provider id is not found in providers map', async () => { - const vaultOps = makeVaultOps(); - const payload: ClipPayload = { - mode: 'hook', frames: [Buffer.from('f').toString('base64')], - video_title: 'V', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z', - }; - // clipRules.hook.providerId is 'p1' but the map is empty - await expect(routeClip(payload, new Map(), clipRules, vaultOps)) - .rejects.toThrow('not found'); - }); - - test('throws when provider does not support multi-frame', async () => { - const singleProvider = makeSingleProvider('p1'); - const providers = new Map([['p1', singleProvider]]); - const vaultOps = makeVaultOps(); - const payload: ClipPayload = { - mode: 'hook', frames: [Buffer.from('f').toString('base64')], - video_title: 'V', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z', - }; - await expect(routeClip(payload, providers, clipRules, vaultOps)) - .rejects.toThrow('does not support multi-frame'); - }); - - test('21 raw frames are sampled down to maxFrames before AI call', async () => { - const provider = makeMultiFrameProvider('p1'); - const providers = new Map([['p1', provider as any]]); - const vaultOps = makeVaultOps(); - const frames = Array(21).fill(Buffer.from('f').toString('base64')); - const payload: ClipPayload = { - mode: 'hook', frames, video_title: 'V', - url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z', - }; - await routeClip(payload, providers, clipRules, vaultOps); - const { frames: sentFrames } = (provider.analyzeMultiFrame as jest.Mock).mock.calls[0][0]; - expect(sentFrames.length).toBeLessThanOrEqual(hookClipRule.maxFrames); - }); -}); - -// ── keyframe ────────────────────────────────────────────────────────────────── - -describe('routeClip — keyframe', () => { - test('calls analyzeMultiFrame without transcript and with time_range in meta, creates note in Great Videos', async () => { - const provider = makeMultiFrameProvider('p1'); - const providers = new Map([['p1', provider as any]]); - const vaultOps = makeVaultOps(); - const payload: ClipPayload = { - mode: 'keyframe', - frames: [Buffer.from('f1').toString('base64')], - video_title: 'My Video', - url: 'https://yt.com', - time_range: { start: 0, end: 15 }, - captured_at: '2026-05-30T18:00:00Z', - }; - const result = await routeClip(payload, providers, { thumbnail: thumbnailClipRule, screenshot: screenshotClipRule, hook:hookClipRule, keyframe: keyframeClipRule }, vaultOps); - expect(provider.analyzeMultiFrame).toHaveBeenCalledWith(expect.objectContaining({ - transcript: undefined, - meta: expect.objectContaining({ time_range: { start: 0, end: 15 } }), - })); - // Keyframe now lands in the Great Videos (thumbnail outputFolder) as a merged note - expect(result.notePath).toMatch(/Content Creation\/Great Videos\/.+\.md/); - expect(vaultOps.create).toHaveBeenCalledWith( - expect.stringMatching(/Content Creation\/Great Videos\/.+\.md/), - expect.stringContaining('## ✨ 动效'), - ); - }); -}); - // ── manual mode ─────────────────────────────────────────────────────────────── describe('routeClip — manual mode (hook)', () => { @@ -331,7 +159,7 @@ describe('routeClip — manual mode (hook)', () => { mode: 'hook', frames, video_title: 'Test Hook', url: 'https://youtube.com/watch?v=abc', captured_at: '2026-05-30T18:00:00Z', }; - await routeClip(payload, new Map(), manualClipRules, vaultOps); + await routeClip(payload, manualClipRules, vaultOps); expect(vaultOps.createBinary).toHaveBeenCalledTimes(3); expect(vaultOps.createBinary).toHaveBeenCalledWith( expect.stringMatching(/Assets\/images\/hook-.+-f01\.png/), @@ -347,7 +175,7 @@ describe('routeClip — manual mode (hook)', () => { video_title: 'My Hook', transcript: 'Hello world', url: 'https://youtube.com/watch?v=abc', captured_at: '2026-05-30T18:00:00Z', }; - await routeClip(payload, new Map(), manualClipRules, vaultOps); + await routeClip(payload, manualClipRules, vaultOps); const [notePath, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0]; // Hook now goes into the Great Videos (thumbnail outputFolder) as a merged note expect(notePath).toMatch(/Content Creation\/Great Videos\/.+\.md/); @@ -358,19 +186,6 @@ describe('routeClip — manual mode (hook)', () => { expect(noteContent).toContain('![['); expect(noteContent).toContain('Hello world'); }); - - test('does not call analyzeMultiFrame in manual mode', async () => { - const provider = makeMultiFrameProvider('p1'); - const providers = new Map([['p1', provider as any]]); - const vaultOps = makeVaultOps(); - const payload: ClipPayload = { - mode: 'hook', - frames: [Buffer.from('f').toString('base64')], - video_title: 'V', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z', - }; - await routeClip(payload, providers, manualClipRules, vaultOps); - expect(provider.analyzeMultiFrame).not.toHaveBeenCalled(); - }); }); describe('routeClip — manual mode (keyframe)', () => { @@ -388,7 +203,7 @@ describe('routeClip — manual mode (keyframe)', () => { video_title: 'My Video', url: 'https://youtube.com/watch?v=xyz', time_range: { start: 30, end: 45 }, captured_at: '2026-05-30T18:00:00Z', }; - await routeClip(payload, new Map(), manualClipRules, vaultOps); + await routeClip(payload, manualClipRules, vaultOps); const [notePath, noteContent] = (vaultOps.create as jest.Mock).mock.calls[0]; // Keyframe now goes into Great Videos as a merged note expect(notePath).toMatch(/Content Creation\/Great Videos\/.+\.md/); @@ -426,7 +241,7 @@ describe('routeClip — append to existing Great Videos note', () => { url: 'https://www.youtube.com/watch?v=abc123', captured_at: '2026-05-31T00:00:00Z', }; - await routeClip(payload, new Map(), { ...clipRules, hook: manualHookRule }, vaultOps); + await routeClip(payload, { ...clipRules, hook: manualHookRule }, vaultOps); expect(vaultOps.modify).toHaveBeenCalledWith( 'Content Creation/Great Videos/note.md', expect.stringContaining('## 🎬 内容'), @@ -450,7 +265,7 @@ describe('routeClip — append to existing Great Videos note', () => { time_range: { start: 10, end: 20 }, captured_at: '2026-05-31T00:00:00Z', }; - await routeClip(payload, new Map(), { ...clipRules, keyframe: manualKeyframeRule }, vaultOps); + await routeClip(payload, { ...clipRules, keyframe: manualKeyframeRule }, vaultOps); const [, modifiedContent] = (vaultOps.modify as jest.Mock).mock.calls[0]; expect(modifiedContent).toContain('## ✨ 动效'); expect(modifiedContent).toContain('dimensions: [封面标题, 动效]'); @@ -468,7 +283,7 @@ describe('routeClip — append to existing Great Videos note', () => { url: 'https://www.youtube.com/watch?v=abc123', captured_at: '2026-05-31T00:00:00Z', }; - const result = await routeClip(payload, new Map(), { ...clipRules, hook: manualHookRule }, vaultOps); + const result = await routeClip(payload, { ...clipRules, hook: manualHookRule }, vaultOps); expect(result.notePath).toBe('Content Creation/Great Videos/note.md'); }); @@ -483,7 +298,7 @@ describe('routeClip — append to existing Great Videos note', () => { url: 'https://www.youtube.com/watch?v=newvid', captured_at: '2026-05-31T00:00:00Z', }; - await routeClip(payload, new Map(), { ...clipRules, hook: manualHookRule }, vaultOps); + await routeClip(payload, { ...clipRules, hook: manualHookRule }, vaultOps); expect(vaultOps.modify).not.toHaveBeenCalled(); expect(vaultOps.create).toHaveBeenCalledWith( expect.stringContaining('Content Creation/Great Videos/'), @@ -515,8 +330,8 @@ describe('routeClip — unified video note (manual)', () => { test('hook then keyframe land in ONE note ordered 内容 before 动效', async () => { const { v, store } = vaultWithStore(); - await routeClip(hookPayload, new Map(), manual, v); - await routeClip(kfPayload(45, 52), new Map(), manual, v); + await routeClip(hookPayload, manual, v); + await routeClip(kfPayload(45, 52), manual, v); const paths = Object.keys(store); expect(paths.length).toBe(1); const note = store[paths[0]]; @@ -526,8 +341,8 @@ describe('routeClip — unified video note (manual)', () => { test('re-capturing hook returns a notice and does not duplicate', async () => { const { v, store } = vaultWithStore(); - await routeClip(hookPayload, new Map(), manual, v); - const r = await routeClip(hookPayload, new Map(), manual, v); + await routeClip(hookPayload, manual, v); + const r = await routeClip(hookPayload, manual, v); expect(r.notice).toContain('已存在'); const note = store[Object.keys(store)[0]]; expect((note.match(/## 🎬 内容/g) || []).length).toBe(1); @@ -536,8 +351,8 @@ describe('routeClip — unified video note (manual)', () => { test('Xiaohongshu hook + keyframe merge into ONE note (link fallback embed)', async () => { const { v, store } = vaultWithStore(); const url = 'https://www.xiaohongshu.com/explore/xhs123abc'; - await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'XHS', url, captured_at: '2026-06-28T00:00:00Z' }, new Map(), manual, v); - await routeClip({ mode: 'keyframe', frames: ['Zg=='], video_title: 'XHS', url, time_range: { start: 5, end: 9 }, captured_at: '2026-06-28T00:00:00Z' }, new Map(), manual, v); + await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'XHS', url, captured_at: '2026-06-28T00:00:00Z' }, manual, v); + await routeClip({ mode: 'keyframe', frames: ['Zg=='], video_title: 'XHS', url, time_range: { start: 5, end: 9 }, captured_at: '2026-06-28T00:00:00Z' }, manual, v); expect(Object.keys(store).length).toBe(1); const note = store[Object.keys(store)[0]]; expect(note).toContain('platform: xiaohongshu'); @@ -549,8 +364,8 @@ describe('routeClip — unified video note (manual)', () => { test('any platform merges by URL: Twitter hook + keyframe → one note', async () => { const { v, store } = vaultWithStore(); - await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Tweet', url: 'https://x.com/u/status/123?s=20', captured_at: '2026-06-28T00:00:00Z' }, new Map(), manual, v); - await routeClip({ mode: 'keyframe', frames: ['Zg=='], video_title: 'Tweet', url: 'https://x.com/u/status/123', time_range: { start: 5, end: 9 }, captured_at: '2026-06-28T00:00:00Z' }, new Map(), manual, v); + await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Tweet', url: 'https://x.com/u/status/123?s=20', captured_at: '2026-06-28T00:00:00Z' }, manual, v); + await routeClip({ mode: 'keyframe', frames: ['Zg=='], video_title: 'Tweet', url: 'https://x.com/u/status/123', time_range: { start: 5, end: 9 }, captured_at: '2026-06-28T00:00:00Z' }, manual, v); expect(Object.keys(store).length).toBe(1); const note = store[Object.keys(store)[0]]; expect(note).toContain('video_id: "https://x.com/u/status/123"'); @@ -559,45 +374,45 @@ describe('routeClip — unified video note (manual)', () => { test('hook with cover_url saves the gallery cover at .webp', async () => { const { v } = vaultWithStore(); - await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', cover_url: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg', captured_at: '2026-06-28T00:00:00Z' } as ClipPayload, new Map(), manual, v); + await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', cover_url: 'https://img.youtube.com/vi/abc123/maxresdefault.jpg', captured_at: '2026-06-28T00:00:00Z' } as ClipPayload, manual, v); expect(v.createBinary).toHaveBeenCalledWith('Assets/Great Videos/abc123.webp', expect.any(ArrayBuffer)); }); test('cover is not re-downloaded when it already exists', async () => { const { v } = vaultWithStore(); (v.fileExists as jest.Mock).mockReturnValue(true); - await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', cover_url: 'https://x/cover.jpg', captured_at: '2026-06-28T00:00:00Z' } as ClipPayload, new Map(), manual, v); + await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Bee', url: 'https://www.youtube.com/watch?v=abc123', cover_url: 'https://x/cover.jpg', captured_at: '2026-06-28T00:00:00Z' } as ClipPayload, manual, v); expect(v.createBinary).not.toHaveBeenCalledWith('Assets/Great Videos/abc123.webp', expect.anything()); }); test('screenshot folds into the existing video note; otherwise standalone', async () => { const { v, store } = vaultWithStore(); const url = 'https://www.youtube.com/watch?v=abc123'; - await routeClip(hookPayload, new Map(), manual, v); // creates the video note - await routeClip({ mode: 'screenshot', images: ['Zg=='], url, title: 'Bee' }, new Map(), manual, v); + await routeClip(hookPayload, manual, v); // creates the video note + await routeClip({ mode: 'screenshot', images: ['Zg=='], url, title: 'Bee' }, manual, v); expect(Object.keys(store).length).toBe(1); // merged, not a new note const note = store[Object.keys(store)[0]]; expect(note).toContain('## 📸 截图'); expect(note).toContain('dimensions: [内容, 截图]'); // a screenshot on an un-studied plain webpage stays standalone - await routeClip({ mode: 'screenshot', images: ['Zg=='], url: 'https://example.com/random', title: 'Rand' }, new Map(), manual, v); + await routeClip({ mode: 'screenshot', images: ['Zg=='], url: 'https://example.com/random', title: 'Rand' }, manual, v); expect(Object.keys(store).length).toBe(2); }); test('two different videos with the same author+title get distinct filenames', async () => { const { v, store } = vaultWithStore(); const base = { mode: 'hook' as const, frames: ['Zg=='], video_title: 'Same Title', channel: 'Ch', captured_at: '2026-06-28T00:00:00Z' }; - await routeClip({ ...base, url: 'https://www.youtube.com/watch?v=vid1' } as ClipPayload, new Map(), manual, v); - await routeClip({ ...base, url: 'https://www.youtube.com/watch?v=vid2' } as ClipPayload, new Map(), manual, v); + await routeClip({ ...base, url: 'https://www.youtube.com/watch?v=vid1' } as ClipPayload, manual, v); + await routeClip({ ...base, url: 'https://www.youtube.com/watch?v=vid2' } as ClipPayload, manual, v); expect(Object.keys(store).length).toBe(2); // no overwrite / no crash }); test('screenshot FIRST on a video page anchors the note (any order)', async () => { const { v, store } = vaultWithStore(); const url = 'https://www.youtube.com/watch?v=zzz999'; - await routeClip({ mode: 'screenshot', images: ['Zg=='], url, title: 'Vid - YouTube' }, new Map(), manual, v); - await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Vid', url, captured_at: '2026-06-28T00:00:00Z' }, new Map(), manual, v); + await routeClip({ mode: 'screenshot', images: ['Zg=='], url, title: 'Vid - YouTube' }, manual, v); + await routeClip({ mode: 'hook', frames: ['Zg=='], video_title: 'Vid', url, captured_at: '2026-06-28T00:00:00Z' }, manual, v); expect(Object.keys(store).length).toBe(1); // screenshot anchored it, hook merged in const note = store[Object.keys(store)[0]]; expect(note).toContain('## 📸 截图'); diff --git a/tests/path-detector.test.ts b/tests/path-detector.test.ts deleted file mode 100644 index 2eb7280..0000000 --- a/tests/path-detector.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as fs from 'fs'; -import { detectBinaryPath, KNOWN_PATHS } from '../src/path-detector'; - -jest.mock('fs'); -const mockExistsSync = fs.existsSync as jest.MockedFunction; - -describe('detectBinaryPath', () => { - beforeEach(() => { mockExistsSync.mockReturnValue(false); }); - - test('returns configured path if it exists', () => { - mockExistsSync.mockImplementation((p) => p === '/custom/claude'); - expect(detectBinaryPath('claude', '/custom/claude')).toBe('/custom/claude'); - }); - - test('returns first known path that exists when configured path not given', () => { - const knownPath = KNOWN_PATHS['claude'][0]; - mockExistsSync.mockImplementation((p) => p === knownPath); - expect(detectBinaryPath('claude', '')).toBe(knownPath); - }); - - test('returns binary name as fallback when nothing found', () => { - expect(detectBinaryPath('claude', '')).toBe('claude'); - }); - - test('KNOWN_PATHS has entries for claude, gemini, codex', () => { - expect(KNOWN_PATHS['claude'].length).toBeGreaterThan(0); - expect(KNOWN_PATHS['gemini'].length).toBeGreaterThan(0); - expect(KNOWN_PATHS['codex'].length).toBeGreaterThan(0); - }); -}); diff --git a/tests/providers/anthropic.test.ts b/tests/providers/anthropic.test.ts deleted file mode 100644 index 7bfc44b..0000000 --- a/tests/providers/anthropic.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { createAnthropicProvider } from '../../src/providers/anthropic'; -import { AnalysisRequest, MultiFrameRequest } from '../../src/types'; - -jest.mock('@anthropic-ai/sdk', () => ({ - __esModule: true, - default: jest.fn().mockImplementation(() => ({ - messages: { create: jest.fn() }, - })), -})); - -import Anthropic from '@anthropic-ai/sdk'; -const MockAnthropic = Anthropic as jest.MockedClass; - -const config = { id: 'a1', type: 'anthropic' as const, apiKey: 'sk-ant-test', model: 'claude-sonnet-4-6' }; - -const req: AnalysisRequest = { - filePath: '/tmp/test.png', fileType: 'image', - fileContent: Buffer.from([0x89, 0x50, 0x4e, 0x47]), - sopContent: 'Analyze the image.', meta: {}, -}; - -describe('analyzeMultiFrame — Anthropic', () => { - let mockCreate: jest.Mock; - - beforeEach(() => { - mockCreate = jest.fn().mockResolvedValue({ - content: [{ type: 'text', text: '# Hook Analysis\nContent' }], - }); - MockAnthropic.mockImplementation(() => ({ messages: { create: mockCreate } }) as any); - }); - - const multiFrameReq: MultiFrameRequest = { - frames: [Buffer.from('frame1'), Buffer.from('frame2')], - transcript: 'Hello world', - sopContent: 'Analyze the hook.', - meta: { video_title: 'My Hook', channel: 'TestChan', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z' }, - }; - - test('returns trimmed text from response', async () => { - const p = createAnthropicProvider(config); - const result = await (p as any).analyzeMultiFrame(multiFrameReq); - expect(result).toBe('# Hook Analysis\nContent'); - }); - - test('sends each frame as a separate image content block', async () => { - const p = createAnthropicProvider(config); - await (p as any).analyzeMultiFrame(multiFrameReq); - const call = mockCreate.mock.calls[0][0]; - const userContent = call.messages.find((m: any) => m.role === 'user').content; - const imageBlocks = userContent.filter((c: any) => c.type === 'image'); - expect(imageBlocks).toHaveLength(2); - expect(imageBlocks[0].source.data).toBe(Buffer.from('frame1').toString('base64')); - expect(imageBlocks[1].source.data).toBe(Buffer.from('frame2').toString('base64')); - }); - - test('includes transcript in context text block', async () => { - const p = createAnthropicProvider(config); - await (p as any).analyzeMultiFrame(multiFrameReq); - const call = mockCreate.mock.calls[0][0]; - const userContent = call.messages.find((m: any) => m.role === 'user').content; - const textBlock = userContent.find((c: any) => c.type === 'text'); - expect(textBlock.text).toContain('Hello world'); - }); - - test('uses sopContent as system prompt', async () => { - const p = createAnthropicProvider(config); - await (p as any).analyzeMultiFrame(multiFrameReq); - const call = mockCreate.mock.calls[0][0]; - expect(call.system).toBe('Analyze the hook.'); - }); - - test('throws when response has no text block', async () => { - mockCreate.mockResolvedValue({ content: [] }); - const p = createAnthropicProvider(config); - await expect((p as any).analyzeMultiFrame(multiFrameReq)).rejects.toThrow('no text'); - }); -}); - -describe('createAnthropicProvider', () => { - let mockCreate: jest.Mock; - - beforeEach(() => { - mockCreate = jest.fn().mockResolvedValue({ - content: [{ type: 'text', text: '# Note\nContent' }], - }); - MockAnthropic.mockImplementation(() => ({ messages: { create: mockCreate } }) as any); - }); - - test('returns text from response', async () => { - const p = createAnthropicProvider(config); - const result = await p.analyze(req); - expect(result).toBe('# Note\nContent'); - }); - - test('sends image as base64 source block', async () => { - const p = createAnthropicProvider(config); - await p.analyze(req); - const call = mockCreate.mock.calls[0][0]; - const userMsg = call.messages.find((m: any) => m.role === 'user'); - expect(userMsg.content.some((c: any) => c.type === 'image')).toBe(true); - }); - - test('throws when response has no text block', async () => { - mockCreate.mockResolvedValue({ content: [] }); - await expect(createAnthropicProvider(config).analyze(req)).rejects.toThrow('no text'); - }); -}); diff --git a/tests/providers/cli-base.test.ts b/tests/providers/cli-base.test.ts deleted file mode 100644 index 5bdef87..0000000 --- a/tests/providers/cli-base.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { spawn } from 'child_process'; -import { createCLIProvider } from '../../src/providers/cli-base'; -import { AnalysisRequest } from '../../src/types'; - -jest.mock('child_process'); -const mockSpawn = spawn as jest.MockedFunction; - -function makeMockProc(stdout: string, stderr: string, code: number | null, signal: string | null = null) { - const proc: any = { - stdin: { write: jest.fn(), end: jest.fn() }, - stdout: { on: jest.fn() }, - stderr: { on: jest.fn() }, - on: jest.fn(), - }; - proc.stdout.on.mockImplementation((ev: string, cb: (d: Buffer) => void) => { - if (ev === 'data') cb(Buffer.from(stdout)); - }); - proc.stderr.on.mockImplementation((ev: string, cb: (d: Buffer) => void) => { - if (ev === 'data') cb(Buffer.from(stderr)); - }); - proc.on.mockImplementation((ev: string, cb: (code: number | null, signal: string | null) => void) => { - if (ev === 'close') cb(code, signal); - }); - mockSpawn.mockReturnValue(proc); - return proc; -} - -const baseRequest: AnalysisRequest = { - filePath: '/tmp/test.png', - fileType: 'image', - fileContent: Buffer.from('fake'), - sopContent: '# SOP\nAnalyze the image.', - meta: { source_url: 'https://example.com', title: 'Test' }, -}; - -describe('createCLIProvider (claude)', () => { - const provider = createCLIProvider({ - id: 'p1', type: 'cli', cliType: 'claude', bin: 'claude', - }); - - beforeEach(() => mockSpawn.mockClear()); - - test('id and name are set', () => { - expect(provider.id).toBe('p1'); - expect(provider.name).toContain('claude'); - }); - - test('resolves with markdown on success', async () => { - makeMockProc('some preamble\n```markdown\n# Note\ncontent\n```', '', 0); - const result = await provider.analyze(baseRequest); - expect(result).toContain('# Note'); - expect(mockSpawn).toHaveBeenCalledWith( - 'claude', - ['-p', '--allowedTools', 'Read'], - expect.objectContaining({ timeout: 300_000 }), - ); - }); - - test('writes prompt to stdin', async () => { - const proc = makeMockProc('output', '', 0); - await provider.analyze(baseRequest); - expect(proc.stdin.write).toHaveBeenCalled(); - expect(proc.stdin.end).toHaveBeenCalled(); - }); - - test('resolves with raw stdout when no markdown block', async () => { - makeMockProc('plain output without code block', '', 0); - const result = await provider.analyze(baseRequest); - expect(result).toBe('plain output without code block'); - }); - - test('throws on non-zero exit', async () => { - makeMockProc('', 'auth error', 1); - await expect(provider.analyze(baseRequest)).rejects.toThrow('auth error'); - }); - - test('throws timeout message when killed by signal', async () => { - makeMockProc('', '', null, 'SIGTERM'); - await expect(provider.analyze(baseRequest)).rejects.toThrow('claude timed out after 300s'); - }); - - test('throws spawn error when binary not found', async () => { - const proc: any = { - stdin: { write: jest.fn(), end: jest.fn() }, - stdout: { on: jest.fn() }, - stderr: { on: jest.fn() }, - on: jest.fn(), - }; - proc.stdout.on.mockImplementation(() => {}); - proc.stderr.on.mockImplementation(() => {}); - proc.on.mockImplementation((ev: string, cb: (err: Error) => void) => { - if (ev === 'error') cb(new Error('spawn claude ENOENT')); - }); - mockSpawn.mockReturnValue(proc); - await expect(provider.analyze(baseRequest)).rejects.toThrow('spawn claude ENOENT'); - }); -}); - -describe('createCLIProvider (gemini)', () => { - const provider = createCLIProvider({ - id: 'p2', type: 'cli', cliType: 'gemini', bin: 'gemini', - }); - - beforeEach(() => mockSpawn.mockClear()); - - test('uses -p flag', async () => { - makeMockProc('gemini output', '', 0); - await provider.analyze(baseRequest); - const [bin, args] = mockSpawn.mock.calls[0]; - expect(bin).toBe('gemini'); - expect(args).toEqual(['-p']); - }); -}); - -describe('createCLIProvider (codex)', () => { - const provider = createCLIProvider({ - id: 'p3', type: 'cli', cliType: 'codex', bin: 'codex', - }); - - beforeEach(() => mockSpawn.mockClear()); - - test('uses --quiet flag', async () => { - makeMockProc('codex output', '', 0); - await provider.analyze(baseRequest); - const [bin, args] = mockSpawn.mock.calls[0]; - expect(bin).toBe('codex'); - expect(args).toEqual(['--quiet']); - }); -}); diff --git a/tests/providers/gemini-api.test.ts b/tests/providers/gemini-api.test.ts deleted file mode 100644 index 9a96ced..0000000 --- a/tests/providers/gemini-api.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { createGeminiAPIProvider } from '../../src/providers/gemini-api'; -import { AnalysisRequest, MultiFrameRequest } from '../../src/types'; - -jest.mock('@google/generative-ai', () => ({ - GoogleGenerativeAI: jest.fn().mockImplementation(() => ({ - getGenerativeModel: jest.fn().mockReturnValue({ - generateContent: jest.fn(), - }), - })), - HarmCategory: {}, - HarmBlockThreshold: {}, -})); - -import { GoogleGenerativeAI } from '@google/generative-ai'; -const MockGAI = GoogleGenerativeAI as jest.MockedClass; - -const config = { id: 'g1', type: 'gemini-api' as const, apiKey: 'gai-test', model: 'gemini-1.5-flash' }; - -const req: AnalysisRequest = { - filePath: '/tmp/img.png', fileType: 'image', - fileContent: Buffer.from([0x89, 0x50]), - sopContent: 'Analyze.', meta: {}, -}; - -describe('analyzeMultiFrame — Gemini', () => { - let mockGenerateContent: jest.Mock; - - beforeEach(() => { - mockGenerateContent = jest.fn().mockResolvedValue({ - response: { text: () => '# Keyframe Analysis\nContent' }, - }); - MockGAI.mockImplementation(() => ({ - getGenerativeModel: jest.fn().mockReturnValue({ generateContent: mockGenerateContent }), - }) as any); - }); - - const multiFrameReq: MultiFrameRequest = { - frames: [Buffer.from('frame1'), Buffer.from('frame2')], - sopContent: 'Analyze the keyframes.', - meta: { video_title: 'My Video', url: 'https://yt.com', time_range: { start: 0, end: 15 }, captured_at: '2026-05-30T18:00:00Z' }, - }; - - test('returns trimmed text from response', async () => { - const p = createGeminiAPIProvider(config); - const result = await (p as any).analyzeMultiFrame(multiFrameReq); - expect(result).toBe('# Keyframe Analysis\nContent'); - }); - - test('sends each frame as a separate inlineData part', async () => { - const p = createGeminiAPIProvider(config); - await (p as any).analyzeMultiFrame(multiFrameReq); - const parts = mockGenerateContent.mock.calls[0][0]; - const inlineDataParts = parts.filter((p: any) => p.inlineData); - expect(inlineDataParts).toHaveLength(2); - expect(inlineDataParts[0].inlineData.mimeType).toBe('image/png'); - expect(inlineDataParts[0].inlineData.data).toBe(Buffer.from('frame1').toString('base64')); - }); - - test('includes sop and context in the text part', async () => { - const p = createGeminiAPIProvider(config); - await (p as any).analyzeMultiFrame(multiFrameReq); - const parts = mockGenerateContent.mock.calls[0][0]; - const textPart = parts.find((p: any) => p.text); - expect(textPart.text).toContain('Analyze the keyframes.'); - expect(textPart.text).toContain('My Video'); - }); -}); - -describe('createGeminiAPIProvider', () => { - let mockGenerate: jest.Mock; - - beforeEach(() => { - mockGenerate = jest.fn().mockResolvedValue({ - response: { text: () => '# Gemini Note' }, - }); - MockGAI.mockImplementation(() => ({ - getGenerativeModel: jest.fn().mockReturnValue({ generateContent: mockGenerate }), - }) as any); - }); - - test('returns generated text', async () => { - const result = await createGeminiAPIProvider(config).analyze(req); - expect(result).toBe('# Gemini Note'); - }); - - test('sends image as inline data part', async () => { - await createGeminiAPIProvider(config).analyze(req); - const parts = mockGenerate.mock.calls[0][0]; - expect(parts.some((p: any) => p.inlineData)).toBe(true); - }); -}); diff --git a/tests/providers/openai-compat.test.ts b/tests/providers/openai-compat.test.ts deleted file mode 100644 index 2261714..0000000 --- a/tests/providers/openai-compat.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { createOpenAICompatProvider } from '../../src/providers/openai-compat'; -import { AnalysisRequest, MultiFrameRequest } from '../../src/types'; - -// Mock the openai module -jest.mock('openai', () => { - return { - __esModule: true, - default: jest.fn().mockImplementation(() => ({ - chat: { - completions: { - create: jest.fn(), - }, - }, - })), - }; -}); - -import OpenAI from 'openai'; -const MockOpenAI = OpenAI as jest.MockedClass; - -const config = { - id: 'p1', type: 'openai-compat' as const, - label: 'OpenRouter', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-or-test', - model: 'openai/gpt-4o', -}; - -const imageRequest: AnalysisRequest = { - filePath: '/tmp/test.png', - fileType: 'image', - fileContent: Buffer.from([0x89, 0x50, 0x4e, 0x47]), - sopContent: 'Analyze the image.', - meta: { source_url: 'https://example.com', title: 'Test' }, -}; - -const textRequest: AnalysisRequest = { - filePath: '/tmp/note.md', - fileType: 'text', - fileContent: Buffer.from('# My Note\nHello world'), - sopContent: 'Summarize the text.', - meta: {}, -}; - -describe('analyzeMultiFrame — OpenAI-compat', () => { - let mockCreate: jest.Mock; - - beforeEach(() => { - mockCreate = jest.fn().mockResolvedValue({ - choices: [{ message: { content: '# Analysis\nContent' } }], - }); - MockOpenAI.mockImplementation(() => ({ - chat: { completions: { create: mockCreate } }, - }) as any); - }); - - const multiFrameReq: MultiFrameRequest = { - frames: [Buffer.from('frame1'), Buffer.from('frame2')], - transcript: 'Hello', - sopContent: 'Analyze frames.', - meta: { video_title: 'My Video', url: 'https://yt.com', captured_at: '2026-05-30T18:00:00Z' }, - }; - - test('returns trimmed text from response', async () => { - const p = createOpenAICompatProvider(config); - const result = await (p as any).analyzeMultiFrame(multiFrameReq); - expect(result).toBe('# Analysis\nContent'); - }); - - test('sends each frame as a separate image_url content block', async () => { - const p = createOpenAICompatProvider(config); - await (p as any).analyzeMultiFrame(multiFrameReq); - const call = mockCreate.mock.calls[0][0]; - const userMsg = call.messages.find((m: any) => m.role === 'user'); - const imageBlocks = userMsg.content.filter((c: any) => c.type === 'image_url'); - expect(imageBlocks).toHaveLength(2); - expect(imageBlocks[0].image_url.url).toContain('data:image/png;base64,'); - expect(imageBlocks[0].image_url.url).toContain(Buffer.from('frame1').toString('base64')); - }); - - test('includes transcript in context text block', async () => { - const p = createOpenAICompatProvider(config); - await (p as any).analyzeMultiFrame(multiFrameReq); - const call = mockCreate.mock.calls[0][0]; - const userMsg = call.messages.find((m: any) => m.role === 'user'); - const textBlock = userMsg.content.find((c: any) => c.type === 'text'); - expect(textBlock.text).toContain('Hello'); - }); - - test('uses sopContent as system message', async () => { - const p = createOpenAICompatProvider(config); - await (p as any).analyzeMultiFrame(multiFrameReq); - const call = mockCreate.mock.calls[0][0]; - const sysMsg = call.messages.find((m: any) => m.role === 'system'); - expect(sysMsg.content).toBe('Analyze frames.'); - }); - - test('throws when response has no content', async () => { - mockCreate.mockResolvedValue({ choices: [{ message: { content: null } }] }); - const p = createOpenAICompatProvider(config); - await expect((p as any).analyzeMultiFrame(multiFrameReq)).rejects.toThrow('no content'); - }); -}); - -describe('createOpenAICompatProvider', () => { - let mockCreate: jest.Mock; - - beforeEach(() => { - mockCreate = jest.fn().mockResolvedValue({ - choices: [{ message: { content: '# Generated Note\nContent here' } }], - }); - MockOpenAI.mockImplementation(() => ({ chat: { completions: { create: mockCreate } } }) as any); - }); - - test('id and name are set', () => { - const p = createOpenAICompatProvider(config); - expect(p.id).toBe('p1'); - expect(p.name).toContain('OpenRouter'); - }); - - test('image request sends base64 image_url message', async () => { - const p = createOpenAICompatProvider(config); - const result = await p.analyze(imageRequest); - expect(result).toBe('# Generated Note\nContent here'); - const call = mockCreate.mock.calls[0][0]; - const userContent = call.messages.find((m: any) => m.role === 'user').content; - expect(Array.isArray(userContent)).toBe(true); - expect(userContent.some((c: any) => c.type === 'image_url')).toBe(true); - }); - - test('text request sends content as text', async () => { - const p = createOpenAICompatProvider(config); - await p.analyze(textRequest); - const call = mockCreate.mock.calls[0][0]; - const userContent = call.messages.find((m: any) => m.role === 'user').content; - expect(typeof userContent === 'string' || userContent.some((c: any) => c.type === 'text')).toBe(true); - }); - - test('throws when API returns no content', async () => { - mockCreate.mockResolvedValue({ choices: [{ message: { content: null } }] }); - const p = createOpenAICompatProvider(config); - await expect(p.analyze(imageRequest)).rejects.toThrow('no content'); - }); - - test('initialises OpenAI client with correct baseURL', () => { - createOpenAICompatProvider(config); - expect(MockOpenAI).toHaveBeenCalledWith(expect.objectContaining({ - baseURL: 'https://openrouter.ai/api/v1', - apiKey: 'sk-or-test', - })); - }); -});