diff --git a/.gitignore b/.gitignore index a28e9e8..6ca778f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,8 @@ # regenerable, not for VCS. The embedded engine mounts cph-render directly. render/vendor/local-packages/ +# Node (hub/ TS workspace and any future JS package) +node_modules/ + # OS / editor .DS_Store diff --git a/hub/.gitignore b/hub/.gitignore new file mode 100644 index 0000000..12419b1 --- /dev/null +++ b/hub/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.env +.env.* +!.env.example diff --git a/hub/package-lock.json b/hub/package-lock.json new file mode 100644 index 0000000..0cbd247 --- /dev/null +++ b/hub/package-lock.json @@ -0,0 +1,1005 @@ +{ + "name": "@paradigm/hub", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@paradigm/hub", + "version": "0.0.0", + "dependencies": { + "openai": "^4.77.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "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/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/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/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/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/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/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/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.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "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/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "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/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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/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/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/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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/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/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/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "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" + } + } + } +} diff --git a/hub/package.json b/hub/package.json new file mode 100644 index 0000000..a707b17 --- /dev/null +++ b/hub/package.json @@ -0,0 +1,22 @@ +{ + "name": "@paradigm/hub", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "dependencies": { + "openai": "^4.77.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "description": "Curriculum Project Hub — Feishu-group collaboration + provider-agnostic agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js", + "check": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/hub/src/agent/models.ts b/hub/src/agent/models.ts new file mode 100644 index 0000000..4bae3bc --- /dev/null +++ b/hub/src/agent/models.ts @@ -0,0 +1,64 @@ +/** + * Per-run model selection & role-based routing. + * + * ADR-0017 consequence: role-based model routing (different run kinds default + * to different models) is a **product/admin configuration** concern, not a spec + * invariant. This registry is the seam for that config; the concrete policy + * (which models are admin-enabled, which role maps to which default) is `OPEN` + * here — decided by admin settings + ADR, not hard-coded. + */ + +/** Coarse run role, used to pick a default model. Open set: add as product grows. */ +export type RunRole = "draft" | "review" | "triage"; + +/** A model the admin has enabled for use by the Hub. */ +export interface ModelEntry { + readonly id: string; + /** Human label for the teacher-side switcher UX. */ + readonly label: string; + /** True when the model is known to support tool use reliably. */ + readonly toolCapable: boolean; +} + +export interface ModelRegistry { + /** All models admin-enabled for this Hub instance. */ + list(): readonly ModelEntry[]; + /** Default model id for a given run role, if a routing rule exists. */ + defaultFor(role: RunRole): string | undefined; +} + +/** + * A simple in-memory registry. Real wiring reads from admin settings / DB; this + * is the skeleton seam. The policy (role→model map) is intentionally pluggable. + */ +export class InMemoryModelRegistry implements ModelRegistry { + private readonly models: readonly ModelEntry[]; + private readonly defaults: Map; + + constructor(models: readonly ModelEntry[], defaults: Partial> = {}) { + this.models = models; + this.defaults = new Map( + (Object.entries(defaults) as [RunRole, string][]).filter(([, v]) => v !== undefined), + ); + } + + list(): readonly ModelEntry[] { + return this.models; + } + + defaultFor(role: RunRole): string | undefined { + return this.defaults.get(role); + } + + /** Resolve a model id, falling back to the role default, then the first enabled. */ + resolve(requested: string | undefined, role: RunRole): string { + if (requested !== undefined && this.models.some((m) => m.id === requested)) { + return requested; + } + const d = this.defaultFor(role); + if (d !== undefined) return d; + const first = this.models[0]; + if (first === undefined) throw new Error("no models enabled for this Hub"); + return first.id; + } +} diff --git a/hub/src/agent/openrouter-provider.ts b/hub/src/agent/openrouter-provider.ts new file mode 100644 index 0000000..911bcab --- /dev/null +++ b/hub/src/agent/openrouter-provider.ts @@ -0,0 +1,157 @@ +/** + * OpenRouter provider adapter. + * + * Points an OpenAI-compatible client at OpenRouter (or any compatible baseURL). + * A single API key + one baseURL routes to any model id OpenRouter serves; the + * model is chosen per run (ADR-0017: role-based routing, run carries its model). + * + * This adapter only translates between our {@link Message} shape and the `openai` + * SDK's `ChatCompletionMessageParam` union. The agent loop itself never imports + * the SDK — swapping providers means implementing {@link AgentProvider}, not + * rewriting the loop. + */ +import OpenAI from "openai"; +import type { + AgentProvider, + ChatRequest, + ChatResponse, + Message, + MessagePart, + ToolSpec, +} from "./provider.js"; + +export interface OpenRouterOptions { + readonly apiKey: string; + /** Defaults to OpenRouter. Override for direct vendor APIs or local gateways. */ + readonly baseURL?: string; + readonly id?: string; + /** Extra headers (e.g. `HTTP-Referer`, `X-Title` for OpenRouter rankings). */ + readonly defaultHeaders?: Record; +} + +export class OpenRouterProvider implements AgentProvider { + readonly id: string; + private readonly client: OpenAI; + + constructor(opts: OpenRouterOptions) { + this.id = opts.id ?? "openrouter"; + const params: ConstructorParameters[0] = { + apiKey: opts.apiKey, + baseURL: opts.baseURL ?? "https://openrouter.ai/api/v1", + }; + if (opts.defaultHeaders !== undefined) { + params.defaultHeaders = opts.defaultHeaders; + } + this.client = new OpenAI(params); + } + + async chat(req: ChatRequest): Promise { + const completion = (await this.client.chat.completions.create({ + model: req.model, + messages: req.messages.map(toSDKMessage), + tools: req.tools.map(toSDKTool), + })) as OpenAI.Chat.Completions.ChatCompletion; + + const choice = completion.choices[0]; + if (choice === undefined) { + throw new Error(`${this.id}: provider returned no choices`); + } + const base: Pick = { + finishReason: normalizeFinish(choice.finish_reason), + message: fromSDKMessage(choice.message), + }; + if (completion.usage !== undefined) { + return { + ...base, + usage: { + inputTokens: completion.usage.prompt_tokens, + outputTokens: completion.usage.completion_tokens, + }, + }; + } + return base; + } +} + +function normalizeFinish(r: string | null): ChatResponse["finishReason"] { + switch (r) { + case "stop": + return "stop"; + case "tool_calls": + return "tool_calls"; + case "length": + return "length"; + default: + return "error"; + } +} + +function toSDKMessage(m: Message): OpenAI.Chat.Completions.ChatCompletionMessageParam { + switch (m.role) { + case "system": + return { role: "system", content: joinText(m.parts) }; + case "user": + return { role: "user", content: joinText(m.parts) }; + case "assistant": { + const text = joinText(m.parts.filter(isText)); + const calls = m.parts.filter(isToolCall).map((c) => ({ + id: c.id, + type: "function" as const, + function: { name: c.name, arguments: c.arguments }, + })); + if (calls.length > 0) { + return { role: "assistant", content: text, tool_calls: calls }; + } + return { role: "assistant", content: text }; + } + case "tool": { + const result = m.parts.find(isToolResult); + if (result === undefined) { + throw new Error("tool message without a tool_result part"); + } + return { + role: "tool", + tool_call_id: result.tool_call_id, + content: result.content, + }; + } + } +} + +function fromSDKMessage(m: OpenAI.Chat.Completions.ChatCompletion.Choice["message"]): Message { + const parts: MessagePart[] = []; + if (typeof m.content === "string" && m.content.length > 0) { + parts.push({ type: "text", text: m.content }); + } + if (m.tool_calls !== undefined) { + for (const c of m.tool_calls) { + parts.push({ + type: "tool_call", + id: c.id, + name: c.function.name, + arguments: c.function.arguments, + }); + } + } + return { role: "assistant", parts }; +} + +function toSDKTool(t: ToolSpec): OpenAI.Chat.Completions.ChatCompletionTool { + return { + type: "function", + function: { name: t.name, description: t.description, parameters: t.parameters as object }, + } as OpenAI.Chat.Completions.ChatCompletionTool; +} + +function joinText(parts: readonly MessagePart[]): string { + return parts.filter(isText).map((p) => p.text).join(""); +} +function isText(p: MessagePart): p is Extract { + return p.type === "text"; +} +function isToolCall(p: MessagePart): p is Extract { + return p.type === "tool_call"; +} +function isToolResult(p: MessagePart): p is Extract { + return p.type === "tool_result"; +} diff --git a/hub/src/agent/provider.ts b/hub/src/agent/provider.ts new file mode 100644 index 0000000..9032509 --- /dev/null +++ b/hub/src/agent/provider.ts @@ -0,0 +1,88 @@ +/** + * Provider-agnostic agent seam. + * + * The Hub's agent loop talks to models through this interface, not through any + * vendor SDK. The request/response shape follows the OpenAI chat-completions + * de-facto standard (messages + tools + tool_calls) because that is the common + * surface OpenRouter and most OpenAI-compatible providers expose; it is the + * lingua franca, not an OpenAI commitment. + * + * ADR-0017: the agent layer is provider-agnostic. `@Claude` is a trigger brand, + * not a provider contract. Switching model = new session (provider-bound), so + * the seam never assumes cross-provider session continuity. + * + * This file defines the contract; see {@link OpenRouterProvider} for the + * concrete adapter that points an OpenAI-compatible client at OpenRouter. + */ + +/** Why a run terminated. Mirrors the common provider surface. */ +export type FinishReason = "stop" | "tool_calls" | "length" | "error"; + +/** A contiguous text span in a message. */ +export interface TextPart { + readonly type: "text"; + readonly text: string; +} + +/** A tool call the model wants the runner to execute. `arguments` is a JSON string. */ +export interface ToolCallPart { + readonly type: "tool_call"; + readonly id: string; + readonly name: string; + readonly arguments: string; +} + +/** The result of a tool execution, addressed back to the originating call. */ +export interface ToolResultPart { + readonly type: "tool_result"; + readonly tool_call_id: string; + /** Free-form string content; structured payloads are JSON-encoded here. */ + readonly content: string; +} + +export type MessagePart = TextPart | ToolCallPart | ToolResultPart; + +export type Role = "system" | "user" | "assistant" | "tool"; + +/** A single chat message: a role plus an ordered list of parts. */ +export interface Message { + readonly role: Role; + readonly parts: readonly MessagePart[]; +} + +/** JSON-schema description of a tool the model may call. */ +export interface ToolSpec { + readonly name: string; + readonly description: string; + /** A JSON Schema object describing the tool's parameters. */ + readonly parameters: unknown; +} + +export interface ChatRequest { + readonly model: string; + readonly messages: readonly Message[]; + readonly tools: readonly ToolSpec[]; +} + +export interface ChatResponse { + readonly finishReason: FinishReason; + /** The assistant message produced. May contain tool_calls. */ + readonly message: Message; + readonly usage?: { readonly inputTokens: number; readonly outputTokens: number }; +} + +/** + * The seam a model provider implements. Implementations are expected to be + * OpenAI-compatible HTTP clients (OpenRouter, direct vendor APIs, local + * gateways); the Hub does not hard-depend on any single-vendor agent SDK. + */ +export interface AgentProvider { + readonly id: string; + chat(req: ChatRequest): Promise; +} + +/** Extract tool calls from an assistant message, if any. */ +export function toolCallsOf(msg: Message): readonly ToolCallPart[] { + if (msg.role !== "assistant") return []; + return msg.parts.filter((p): p is ToolCallPart => p.type === "tool_call"); +} diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts new file mode 100644 index 0000000..cd32d96 --- /dev/null +++ b/hub/src/agent/runner.ts @@ -0,0 +1,139 @@ +/** + * Provider-agnostic agent loop. + * + * The loop is ours (ADR-0017): dispatch tools, fold results, continue until the + * model stops, a budget is exhausted, or a tool fails unrecoverably. It depends + * only on {@link AgentProvider} + {@link ToolRegistry}, never on a vendor SDK. + * + * Session/provider binding (ADR-0017): a run is bound to one model. Switching + * model is a *new* run + new session; cross-run continuity is carried by project + * memory/anchors (ADR-0003), not by this loop. + */ +import type { AgentProvider, ChatResponse, Message, ToolCallPart } from "./provider.js"; +import type { ToolContext, ToolRegistry } from "./tools.js"; +import type { RunRole } from "./models.js"; + +/** What the runner needs about the project to seed and bound a run. */ +export interface ProjectContext { + readonly projectId: string; + /** ADR-0001 binding — the chat this project is bound to. */ + readonly boundChatId: string; + /** Absolute path to the curriculum engineering-file workspace. */ + readonly workspaceDir: string; +} + +export interface RunRequest { + readonly prompt: string; + /** Resolved model id (already chosen by the caller per ADR-0017). */ + readonly model: string; + readonly role: RunRole; + readonly project: ProjectContext; + readonly systemPrompt?: string; + /** Iteration cap before the run is returned as `length`. Default 25. */ + readonly maxIterations?: number; +} + +export type RunStatus = "completed" | "length" | "failed"; + +export interface RunResult { + readonly status: RunStatus; + /** Full transcript of the run, for audit (ADR Audit, content OPEN). */ + readonly messages: readonly Message[]; + readonly usage: { inputTokens: number; outputTokens: number }; + readonly error?: string; +} + +const DEFAULT_MAX_ITERATIONS = 25; + +/** + * Run one agent task to completion (or budget exhaustion). + * + * @throws if the provider call itself fails before any tool runs. + */ +export async function runAgent( + provider: AgentProvider, + tools: ToolRegistry, + req: RunRequest, +): Promise { + const ctx: ToolContext = { + runId: cryptoRandomId(), + projectId: req.project.projectId, + boundChatId: req.project.boundChatId, + workspaceDir: req.project.workspaceDir, + }; + + const messages: Message[] = []; + if (req.systemPrompt !== undefined) { + messages.push({ role: "system", parts: [{ type: "text", text: req.systemPrompt }] }); + } + messages.push({ role: "user", parts: [{ type: "text", text: req.prompt }] }); + + const cap = req.maxIterations ?? DEFAULT_MAX_ITERATIONS; + let inputTokens = 0; + let outputTokens = 0; + + for (let i = 0; i < cap; i++) { + let res: ChatResponse; + try { + res = await provider.chat({ + model: req.model, + messages, + tools: tools.specs(), + }); + } catch (e) { + return { + status: "failed", + messages, + usage: { inputTokens, outputTokens }, + error: e instanceof Error ? e.message : String(e), + }; + } + + messages.push(res.message); + if (res.usage !== undefined) { + inputTokens += res.usage.inputTokens; + outputTokens += res.usage.outputTokens; + } + + if (res.finishReason === "stop") { + return { status: "completed", messages, usage: { inputTokens, outputTokens } }; + } + if (res.finishReason !== "tool_calls") { + return { status: "length", messages, usage: { inputTokens, outputTokens } }; + } + + const calls = toolCallsOfAssistant(res.message); + for (const call of calls) { + const args = safeParseArgs(call); + const content = await tools.execute(call.name, args, ctx); + messages.push({ + role: "tool", + parts: [{ type: "tool_result", tool_call_id: call.id, content }], + }); + } + } + + return { + status: "length", + messages, + usage: { inputTokens, outputTokens }, + error: `exceeded maxIterations=${cap}`, + }; +} + +function toolCallsOfAssistant(msg: Message): readonly ToolCallPart[] { + if (msg.role !== "assistant") return []; + return msg.parts.filter((p): p is ToolCallPart => p.type === "tool_call"); +} + +function safeParseArgs(call: ToolCallPart): unknown { + try { + return JSON.parse(call.arguments); + } catch { + return {}; + } +} + +function cryptoRandomId(): string { + return globalThis.crypto.randomUUID(); +} diff --git a/hub/src/agent/tools.ts b/hub/src/agent/tools.ts new file mode 100644 index 0000000..eed6198 --- /dev/null +++ b/hub/src/agent/tools.ts @@ -0,0 +1,128 @@ +/** + * Tool registry — the agent's narrow, project-scoped tool surface. + * + * Unlike a general "Claude Code", the Hub agent operates on a curriculum + * engineering-file tree (ADR-0007) and reads Feishu context on demand + * (ADR-0003). Its tools are therefore bounded: + * + * - file-tree read/write against the project workspace dir, + * - `cph check` / `cph build` subprocess calls (the Courseware-side checker, + * coupled only via the stable CLI contract ADR-0016), + * - Feishu context reads, which must honor ADR-0003's authorization invariant. + * + * That last invariant is encoded here: a Feishu context tool handler rejects any + * chat id that is not the run's project's bound chat (ADR-0001 binding). This is + * `McpReadRequest.Authorized` from `spec/System/Memory.lean` landing in code. + */ +import type { ToolSpec } from "./provider.js"; + +/** Per-run execution context handed to every tool handler. */ +export interface ToolContext { + readonly runId: string; + readonly projectId: string; + /** ADR-0001: the chat this project is bound to. Feishu reads must stay in it. */ + readonly boundChatId: string; + /** Absolute path to the project's curriculum engineering-file workspace. */ + readonly workspaceDir: string; +} + +/** A tool handler: receives parsed args + run context, returns content for the model. */ +export type ToolHandler = (args: unknown, ctx: ToolContext) => Promise; + +export interface RegisteredTool { + readonly spec: ToolSpec; + readonly execute: ToolHandler; +} + +export class ToolRegistry { + private readonly byName = new Map(); + + register(tool: RegisteredTool): void { + if (this.byName.has(tool.spec.name)) { + throw new Error(`duplicate tool: ${tool.spec.name}`); + } + this.byName.set(tool.spec.name, tool); + } + + specs(): readonly ToolSpec[] { + return [...this.byName.values()].map((t) => t.spec); + } + + has(name: string): boolean { + return this.byName.has(name); + } + + /** Execute a tool by name. Throws on unknown tool or authorization failure. */ + async execute(name: string, args: unknown, ctx: ToolContext): Promise { + const tool = this.byName.get(name); + if (tool === undefined) { + return JSON.stringify({ error: `unknown tool: ${name}` }); + } + return tool.execute(args, ctx); + } +} + +/** + * Thrown when a Feishu context read targets a chat other than the run's project's + * bound chat. This is the runtime enforcement of ADR-0003's + * `McpReadRequest.Authorized`: "Claude cannot pass arbitrary chat ids". + */ +export class UnauthorizedChatRead extends Error { + constructor( + readonly requestedChatId: string, + readonly boundChatId: string, + ) { + super( + `refused Feishu context read: requested chat ${requestedChatId} is not the run's project's bound chat ${boundChatId} (ADR-0003)`, + ); + this.name = "UnauthorizedChatRead"; + } +} + +/** Schema for the Feishu context read tool's arguments. */ +export interface FeishuContextArgs { + readonly chat_id: string; + readonly anchor: "trigger_message" | "status_card" | "reply" | "thread"; + readonly id: string; +} + +/** + * Build the Feishu context-read tool. The handler enforces ADR-0003's invariant + * before any API call: the requested `chat_id` must equal `ctx.boundChatId`. + * + * The actual Feishu API call (read message / reply chain / thread / recent + * group messages) is injected as `read`, so the invariant check is separable + * from the transport. In tests `read` can be a stub; in production it wraps + * `@larksuiteoapi/node-sdk`. + */ +export function feishuContextTool( + read: (args: FeishuContextArgs, ctx: ToolContext) => Promise, +): RegisteredTool { + return { + spec: { + name: "feishu_read_context", + description: + "Read on-demand Feishu context (a trigger message, status card, reply, or thread) for the current project's bound chat. The chat id must match the project binding.", + parameters: { + type: "object", + properties: { + chat_id: { type: "string", description: "The Feishu chat id to read from." }, + anchor: { + type: "string", + enum: ["trigger_message", "status_card", "reply", "thread"], + description: "Which kind of anchor to read.", + }, + id: { type: "string", description: "The anchor id (message id or run id)." }, + }, + required: ["chat_id", "anchor", "id"], + }, + }, + async execute(args, ctx): Promise { + const a = args as FeishuContextArgs; + if (a.chat_id !== ctx.boundChatId) { + throw new UnauthorizedChatRead(a.chat_id, ctx.boundChatId); + } + return read(a, ctx); + }, + }; +} diff --git a/hub/src/server.ts b/hub/src/server.ts new file mode 100644 index 0000000..6951271 --- /dev/null +++ b/hub/src/server.ts @@ -0,0 +1,60 @@ +/** + * Hub entry — skeleton. + * + * Wires the provider-agnostic agent seam end-to-end with a no-op Feishu read, so + * the invariant path is exercised without live credentials. Real wiring (Fastify + * server, Feishu OAuth/event callbacks, Prisma, admin-web) lands incrementally; + * this file proves the seam compiles and the pieces connect. + */ +import { OpenRouterProvider } from "./agent/openrouter-provider.js"; +import { InMemoryModelRegistry } from "./agent/models.js"; +import { ToolRegistry, feishuContextTool } from "./agent/tools.js"; +import { runAgent } from "./agent/runner.js"; + +function main(): void { + const apiKey = process.env["OPENROUTER_API_KEY"]; + if (apiKey === undefined || apiKey === "") { + console.error("[hub] OPENROUTER_API_KEY not set; nothing to run. Exiting."); + process.exit(1); + } + + const provider = new OpenRouterProvider({ apiKey }); + const models = new InMemoryModelRegistry( + [ + { id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true }, + { id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true }, + ], + { draft: "z-ai/glm-4.6", review: "anthropic/claude-3.5-sonnet" }, + ); + + const tools = new ToolRegistry(); + tools.register( + feishuContextTool(async (args) => { + // Skeleton: no live Feishu calls. Real impl wraps @larksuiteoapi/node-sdk. + return JSON.stringify({ stub: true, anchor: args.anchor, id: args.id }); + }), + ); + + const model = models.resolve(undefined, "draft"); + + runAgent(provider, tools, { + prompt: "ping", + model, + role: "draft", + project: { + projectId: "proj-skeleton", + boundChatId: "chat-skeleton", + workspaceDir: "/tmp/cph-skeleton", + }, + }) + .then((r) => { + console.log("[hub] run finished", { status: r.status, model, usage: r.usage }); + if (r.error !== undefined) console.error("[hub] error:", r.error); + }) + .catch((e) => { + console.error("[hub] run threw:", e); + process.exit(1); + }); +} + +main(); diff --git a/hub/tsconfig.json b/hub/tsconfig.json new file mode 100644 index 0000000..a868bb5 --- /dev/null +++ b/hub/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"] +}