diff --git a/.env.example b/.env.example index 359eec7..9f7b3d0 100644 --- a/.env.example +++ b/.env.example @@ -9,20 +9,30 @@ POSTGRES_PASSWORD=growqr POSTGRES_DB=growqr # Clerk auth — get from dashboard.clerk.com → API Keys -CLERK_SECRET_KEY=sk_test_REPLACE_ME -CLERK_PUBLISHABLE_KEY=pk_test_REPLACE_ME +CLERK_SECRET_KEY=clerk_key +CLERK_PUBLISHABLE_KEY=clerk_publishable_key -# Anthropic — get from console.anthropic.com → API Keys -ANTHROPIC_API_KEY=sk-ant-REPLACE_ME -GROW_AGENT_MODEL=claude-opus-4-7 -SUB_AGENT_MODEL=claude-sonnet-4-6 +# OpenCode Zen LLM gateway — get from opencode.ai/auth +OPENCODE_API_KEY=sk-REPLACE_ME +LLM_PROVIDER=opencode +LLM_BASE_URL=https://opencode.ai/zen/v1 +LLM_MODEL=kimi-k2.6 +GROW_AGENT_MODEL=kimi-k2.6 +SUB_AGENT_MODEL=kimi-k2.6 MAX_AGENT_TOKENS=4096 # Shared secret for actor → backend service calls (rotate in prod) SERVICE_TOKEN=dev-service-token-REPLACE_ME +A2A_ALLOWED_KEY=dev-a2a-key # Rivet Kit engine (self-hosted in docker-compose) RIVET_ENDPOINT=http://localhost:6420 +RIVET_CLIENT_ENDPOINT=http://127.0.0.1:4000/api/rivet + +# Product microservice sub-agent URLs +INTERVIEW_SERVICE_URL=http://localhost:8007 +ROLEPLAY_SERVICE_URL=http://localhost:8008 +QSCORE_SERVICE_URL=http://localhost:8000 # Per-user container images GITEA_IMAGE=gitea/gitea:1.22 diff --git a/.gitignore b/.gitignore index e6749ef..420198d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules dist +.data .env .env.local *.log diff --git a/README.md b/README.md index 3652524..c38716a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A multi-agent platform where every user gets a private Grow Agent (Rivet Kit act - **Auth**: Clerk (frontend + backend JWT verification). - **DB**: Postgres + Drizzle (users, actor registry, container mappings, repos, OpenCode sessions, events). -- **Actors**: Rivet Kit — `growAgent` per user (master) and `subAgent` (worker), with a real Anthropic Claude tool-use loop. +- **Actors**: Rivet Kit — `growAgent` per user (master) and `subAgent` (worker), with a real OpenCode Zen / OpenAI-compatible tool-use loop. - **Per-user containers**: Gitea (memory repo) + OpenCode (workflow execution), spawned via `dockerode`, with admin user + access token bootstrap, ports allocated from a managed pool, lifecycle reconciled on backend boot. - **Frontend**: Next.js 16 with `@clerk/nextjs` for auth and `rivetkit/client` for direct actor connections + event streaming. - **Tool surface available to the Grow Agent**: `spawn_sub_agent`, `commit_memory`, `read_memory`, `list_memory`. @@ -16,7 +16,7 @@ A multi-agent platform where every user gets a private Grow Agent (Rivet Kit act You need three external accounts before running: 1. **Clerk** — create an app at https://dashboard.clerk.com → copy the publishable + secret keys. -2. **Anthropic** — create an API key at https://console.anthropic.com. +2. **OpenCode Zen** — create an API key at https://opencode.ai/auth. 3. **Docker** — Docker Desktop (or any daemon `dockerode` can reach via `/var/run/docker.sock`). Then: @@ -24,7 +24,7 @@ Then: ```bash # Backend env cp .env.example .env -# fill in CLERK_SECRET_KEY, CLERK_PUBLISHABLE_KEY, ANTHROPIC_API_KEY +# fill in CLERK_SECRET_KEY, CLERK_PUBLISHABLE_KEY, OPENCODE_API_KEY # Frontend env cd growqr-frontend @@ -73,7 +73,7 @@ Next.js (3000) ──fetch──▶ /users/bootstrap, /users/me (Hono on 40 ▼ Grow Agent actor (one per user) │ - ├─ Anthropic (Opus 4.7 + tool use) + ├─ OpenCode Zen (Kimi K2.6 + tool use) ├─ commit_memory ────▶ Gitea container (per user) └─ spawn_sub_agent ────▶ OpenCode container (per user) (multiplexed sessions) diff --git a/docker-compose.yml b/docker-compose.yml index 79843f4..ff3459c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,12 +54,20 @@ services: RIVET_ENDPOINT: http://rivet-engine:6420 CLERK_SECRET_KEY: ${CLERK_SECRET_KEY} CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY} - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} - GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-claude-opus-4-7} - SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-claude-sonnet-4-6} + OPENCODE_API_KEY: ${OPENCODE_API_KEY} + LLM_PROVIDER: ${LLM_PROVIDER:-opencode} + LLM_BASE_URL: ${LLM_BASE_URL:-https://opencode.ai/zen/v1} + LLM_MODEL: ${LLM_MODEL:-kimi-k2.6} + GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-kimi-k2.6} + SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-kimi-k2.6} SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token} + A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key} + RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet} GITEA_IMAGE: ${GITEA_IMAGE:-gitea/gitea:1.22} - OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/sst/opencode:latest} + OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode:latest} + INTERVIEW_SERVICE_URL: ${INTERVIEW_SERVICE_URL:-http://host.docker.internal:8007} + ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008} + QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000} USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal} USER_DATA_ROOT: /data/users USER_PORT_RANGE_START: 20000 diff --git a/docs/architecture.html b/docs/architecture.html index 069f739..9d4d857 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -366,7 +366,7 @@

Memory API

-

Three-layer memory surface exposed to Claude as tools (commit_memory, read_memory, list_memory). L1 in-actor state, L2 session in Postgres, L3 long-term in the user's Gitea repo.

+

Three-layer memory surface exposed to the configured OpenCode Zen model as tools (commit_memory, read_memory, list_memory). L1 in-actor state, L2 session in Postgres, L3 long-term in the user's Gitea repo.

diff --git a/package-lock.json b/package-lock.json index ec8e214..65a3da3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "growqr-backend", "version": "0.1.0", "dependencies": { - "@anthropic-ai/sdk": "^0.96.0", "@clerk/backend": "^1.21.0", "@hono/node-server": "^1.13.7", "dockerode": "^4.0.7", @@ -38,36 +37,6 @@ "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.96.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.96.0.tgz", - "integrity": "sha512-KlCsODtTyb17bLUVCSDC2HtSvAbJf60sEiPEax9dInF+aDF92vS4TZJ5XD7YCQXNb1/5icYaw8Y7wMjPlIV9Zg==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1", - "standardwebhooks": "^1.0.0" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@balena/dockerignore": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", @@ -244,7 +213,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -261,7 +229,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -278,7 +245,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -295,7 +261,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -312,7 +277,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -329,7 +293,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -346,7 +309,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -363,7 +325,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -380,7 +341,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -397,7 +357,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -414,7 +373,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -431,7 +389,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -448,7 +405,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -465,7 +421,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -482,7 +437,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -499,7 +453,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -516,7 +469,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -533,7 +485,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -550,7 +501,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -567,7 +517,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -584,7 +533,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -601,7 +549,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -668,7 +615,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -685,7 +631,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -702,7 +647,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -719,7 +663,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -736,7 +679,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -753,7 +695,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -770,7 +711,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -787,7 +727,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -804,7 +743,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -821,7 +759,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -838,7 +775,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -855,7 +791,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -872,7 +807,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -889,7 +823,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -906,7 +839,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -923,7 +855,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -940,7 +871,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -957,7 +887,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -974,7 +903,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -991,7 +919,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1008,7 +935,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1025,7 +951,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1042,7 +967,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1059,7 +983,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1076,7 +999,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1093,7 +1015,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1157,7 +1078,6 @@ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.14.1" }, @@ -2096,12 +2016,6 @@ "linux" ] }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" - }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -2305,7 +2219,6 @@ "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -2960,7 +2873,6 @@ "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz", "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", @@ -3014,7 +2926,6 @@ "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", @@ -3032,7 +2943,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3049,7 +2959,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3066,7 +2975,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3083,7 +2991,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3100,7 +3007,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3117,7 +3023,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3134,7 +3039,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3151,7 +3055,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3168,7 +3071,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3185,7 +3087,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3202,7 +3103,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3219,7 +3119,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3236,7 +3135,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3253,7 +3151,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3270,7 +3167,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3287,7 +3183,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3304,7 +3199,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3321,7 +3215,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3338,7 +3231,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3355,7 +3247,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3372,7 +3263,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3389,7 +3279,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3406,7 +3295,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3423,7 +3311,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3440,7 +3327,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3457,7 +3343,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3811,12 +3696,6 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, - "node_modules/fast-sha256": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" - }, "node_modules/fdb-tuple": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fdb-tuple/-/fdb-tuple-1.0.0.tgz", @@ -3870,7 +3749,6 @@ "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, @@ -3937,7 +3815,6 @@ "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16" }, @@ -4091,7 +3968,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4314,19 +4190,6 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4888,7 +4751,6 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", "license": "Unlicense", - "peer": true, "engines": { "node": ">=12" }, @@ -5380,7 +5242,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5774,16 +5635,6 @@ "nan": "^2.23.0" } }, - "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", - "license": "MIT", - "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0" - } - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -5972,12 +5823,6 @@ "node": ">= 0.4" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6307,7 +6152,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index d58bfc6..a3bf045 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "compose:down": "docker compose down" }, "dependencies": { - "@anthropic-ai/sdk": "^0.96.0", "@clerk/backend": "^1.21.0", "@hono/node-server": "^1.13.7", "dockerode": "^4.0.7", diff --git a/src/actors/grow-agent.ts b/src/actors/grow-agent.ts index 6ab564d..c28cd08 100644 --- a/src/actors/grow-agent.ts +++ b/src/actors/grow-agent.ts @@ -1,12 +1,13 @@ import { actor } from "rivetkit"; -import type Anthropic from "@anthropic-ai/sdk"; import { log } from "../log.js"; import { config } from "../config.js"; import { - anthropic, + createChatCompletion, GROW_AGENT_SYSTEM, growAgentTools, -} from "../lib/anthropic.js"; + type LlmMessage, + type LlmToolCall, +} from "../lib/llm.js"; import { provisionUserStack, getUserStack, @@ -18,9 +19,10 @@ import { db } from "../db/client.js"; import { actors as actorsTable, events as eventsTable } from "../db/schema.js"; type ChatTurn = { - role: "user" | "assistant"; - // Anthropic content blocks; "user" turns may also be plain strings. - content: string | Anthropic.ContentBlockParam[]; + role: "user" | "assistant" | "tool"; + content: string; + toolCallId?: string; + toolCalls?: LlmToolCall[]; }; type GrowAgentState = { @@ -73,7 +75,7 @@ export const growAgent = actor({ return stack; }, - // Main chat entry point. Runs the full agentic loop with Claude. + // Main chat entry point. Runs the full agentic loop through the configured LLM. receiveMessage: async (c, msg: { text: string }) => { if (!c.state.userId) { throw new Error("Grow Agent not initialized"); @@ -126,7 +128,8 @@ export const growAgent = actor({ }, }); -// The agentic loop. Keeps calling Claude with tools until stop_reason === "end_turn". +// The agentic loop. Keeps calling the configured LLM with tools until the model +// returns a normal assistant turn. async function runAgentLoop( c: { state: GrowAgentState; @@ -134,9 +137,9 @@ async function runAgentLoop( }, userId: string, ): Promise { - if (!config.anthropicApiKey) { + if (!config.llmApiKey) { const reply = - "ANTHROPIC_API_KEY is not configured on the backend — set it to enable the Grow Agent."; + "LLM_API_KEY or OPENCODE_API_KEY is not configured on the backend - set it to enable the Grow Agent."; c.state.history.push({ role: "assistant", content: reply }); c.broadcast("message", { role: "agent", text: reply }); return reply; @@ -148,78 +151,80 @@ async function runAgentLoop( let assistantTextOut = ""; for (let i = 0; i < MAX_ITERATIONS; i++) { - const response = await anthropic.messages.create({ + const response = await createChatCompletion({ model: config.growAgentModel, - max_tokens: config.maxAgentTokens, - system: [ - { - type: "text", - text: GROW_AGENT_SYSTEM, - cache_control: { type: "ephemeral" }, - }, - ], + maxTokens: config.maxAgentTokens, tools: growAgentTools, - thinking: { type: "adaptive" }, messages: messagesForApi(c.state.history), }); // Capture assistant text for streaming-style broadcast. - for (const block of response.content) { - if (block.type === "text" && block.text) { - assistantTextOut += (assistantTextOut ? "\n\n" : "") + block.text; - c.broadcast("message", { role: "agent", text: block.text }); - } + if (response.content) { + assistantTextOut += (assistantTextOut ? "\n\n" : "") + response.content; + c.broadcast("message", { role: "agent", text: response.content }); } - // Persist the full assistant turn (so subsequent loops keep tool_use blocks). + // Persist the assistant turn, including tool calls for the next tool result turn. c.state.history.push({ role: "assistant", - content: response.content as Anthropic.ContentBlockParam[], + content: response.content, + toolCalls: response.toolCalls, }); - if (response.stop_reason !== "tool_use") { + if (response.toolCalls.length === 0) { break; } - const toolUses = response.content.filter( - (b): b is Anthropic.ToolUseBlock => b.type === "tool_use", - ); - if (toolUses.length === 0) break; - - const toolResults: Anthropic.ToolResultBlockParam[] = []; - for (const call of toolUses) { + for (const call of response.toolCalls) { try { const result = await dispatchTool(c, userId, call); - toolResults.push({ - type: "tool_result", - tool_use_id: call.id, + c.state.history.push({ + role: "tool", + toolCallId: call.id, content: typeof result === "string" ? result : JSON.stringify(result), }); } catch (err) { log.error({ err, tool: call.name }, "tool dispatch failed"); - toolResults.push({ - type: "tool_result", - tool_use_id: call.id, + c.state.history.push({ + role: "tool", + toolCallId: call.id, content: `Error: ${err instanceof Error ? err.message : String(err)}`, - is_error: true, }); } } - - c.state.history.push({ role: "user", content: toolResults }); } c.broadcast("agent-thinking", { state: "idle" }); return assistantTextOut || "(no response)"; } -function messagesForApi( - history: ChatTurn[], -): Anthropic.MessageParam[] { - return history.map((t) => ({ - role: t.role, - content: t.content, - })) as Anthropic.MessageParam[]; +function messagesForApi(history: ChatTurn[]): LlmMessage[] { + const messages: LlmMessage[] = [ + { role: "system", content: GROW_AGENT_SYSTEM }, + ]; + for (const turn of history) { + if (turn.role === "tool") { + messages.push({ + role: "tool", + content: turn.content, + tool_call_id: turn.toolCallId, + }); + continue; + } + messages.push({ + role: turn.role, + content: turn.content, + tool_calls: turn.toolCalls?.map((call) => ({ + id: call.id, + type: "function", + function: { + name: call.name, + arguments: JSON.stringify(call.arguments), + }, + })), + }); + } + return messages; } async function dispatchTool( @@ -228,9 +233,9 @@ async function dispatchTool( state: GrowAgentState; }, userId: string, - call: Anthropic.ToolUseBlock, + call: LlmToolCall, ): Promise { - const input = call.input as Record; + const input = call.arguments; switch (call.name) { case "spawn_sub_agent": { const type = String(input.type ?? "generic"); diff --git a/src/actors/registry.ts b/src/actors/registry.ts index cfa8b81..e377828 100644 --- a/src/actors/registry.ts +++ b/src/actors/registry.ts @@ -1,11 +1,13 @@ import { setup } from "rivetkit"; import { growAgent } from "./grow-agent.js"; import { subAgent } from "./sub-agent.js"; +import { workflowJob } from "./workflow-job.js"; export const registry = setup({ use: { growAgent, subAgent, + workflowJob, }, }); diff --git a/src/actors/workflow-job.ts b/src/actors/workflow-job.ts new file mode 100644 index 0000000..90c7dab --- /dev/null +++ b/src/actors/workflow-job.ts @@ -0,0 +1,292 @@ +import { actor } from "rivetkit"; +import { + agentCatalog, + getAgentProfile, + jobApplicationAgentIds, + type AgentProfile, +} from "../agents/catalog.js"; +import { runServiceAgentProbe, type ServiceAgentResult } from "../services/service-agents.js"; + +type WorkflowStatus = "draft" | "running" | "paused" | "completed"; +type AgentStatus = "idle" | "running" | "blocked" | "done"; + +type AgentScorecard = { + id: string; + question: string; + answer: string; + score: number; + notes?: string; + createdAt: string; +}; + +type WorkflowAgentState = { + id: string; + name: string; + role: string; + kind: AgentProfile["kind"]; + service?: AgentProfile["service"]; + status: AgentStatus; + summary: string; + lastResult?: ServiceAgentResult; + scorecards: AgentScorecard[]; +}; + +type WorkflowEvent = { + id: string; + ts: string; + agentId: string; + agentName: string; + type: "workflow" | "agent" | "score"; + message: string; + payload?: unknown; +}; + +type WorkflowJobState = { + workflowId: string; + userId: string; + type: "job-application"; + status: WorkflowStatus; + goal: string; + agents: WorkflowAgentState[]; + timeline: WorkflowEvent[]; + createdAt: string; + updatedAt: string; +}; + +const now = () => new Date().toISOString(); + +const initialState: WorkflowJobState = { + workflowId: "", + userId: "", + type: "job-application", + status: "draft", + goal: "", + agents: [], + timeline: [], + createdAt: "", + updatedAt: "", +}; + +function eventId() { + return `evt_${Date.now()}_${Math.random().toString(16).slice(2)}`; +} + +function makeAgents(): WorkflowAgentState[] { + return jobApplicationAgentIds() + .map((id) => getAgentProfile(id)) + .filter((agent): agent is AgentProfile => Boolean(agent)) + .map((agent) => ({ + id: agent.id, + name: agent.name, + role: agent.role, + kind: agent.kind, + service: agent.service, + status: "idle", + summary: agent.description, + scorecards: [], + })); +} + +function appendEvent( + state: WorkflowJobState, + agent: Pick, + type: WorkflowEvent["type"], + message: string, + payload?: unknown, +) { + const ev: WorkflowEvent = { + id: eventId(), + ts: now(), + agentId: agent.id, + agentName: agent.name, + type, + message, + payload, + }; + state.timeline.unshift(ev); + state.timeline = state.timeline.slice(0, 100); + state.updatedAt = ev.ts; + return ev; +} + +function localAgentResult(agent: WorkflowAgentState, goal: string): ServiceAgentResult { + const goalText = goal || "job application workflow"; + switch (agent.id) { + case "grow": + return { + status: "local", + summary: `Grow Agent initialized the ${goalText} workflow and assigned specialist agents.`, + }; + case "resume": + return { + status: "local", + summary: `Resume Agent prepared a resume-improvement pass for ${goalText}.`, + }; + case "job-search": + return { + status: "local", + summary: `Job Search Agent created the opportunity discovery lane for ${goalText}.`, + }; + case "job-apply": + return { + status: "local", + summary: `Job Apply Agent prepared the application tracking lane for ${goalText}.`, + }; + default: + return { + status: "local", + summary: `${agent.name} completed a local workflow step.`, + }; + } +} + +export const workflowJob = actor({ + options: { + actionTimeout: 600_000, + noSleep: true, + }, + state: initialState, + actions: { + init: async ( + c, + input: { + userId: string; + goal?: string; + }, + ) => { + if (c.state.userId && c.state.userId !== input.userId) { + throw new Error("Workflow already belongs to another user"); + } + if (!c.state.workflowId) { + const ts = now(); + c.state.workflowId = `job-application:${input.userId}`; + c.state.userId = input.userId; + c.state.type = "job-application"; + c.state.goal = input.goal ?? "Find and apply to high-fit jobs"; + c.state.status = "draft"; + c.state.agents = makeAgents(); + c.state.createdAt = ts; + c.state.updatedAt = ts; + appendEvent( + c.state, + { id: "grow", name: "Grow Agent" }, + "workflow", + "Job application workflow created.", + { catalog: agentCatalog }, + ); + } else if (input.goal) { + c.state.goal = input.goal; + c.state.updatedAt = now(); + } + c.broadcast("workflow.updated", c.state); + return c.state; + }, + + start: async (c) => { + c.state.status = "running"; + appendEvent( + c.state, + { id: "grow", name: "Grow Agent" }, + "workflow", + "Workflow started.", + ); + c.broadcast("workflow.updated", c.state); + return c.state; + }, + + pause: async (c) => { + c.state.status = "paused"; + appendEvent( + c.state, + { id: "grow", name: "Grow Agent" }, + "workflow", + "Workflow paused.", + ); + c.broadcast("workflow.updated", c.state); + return c.state; + }, + + resume: async (c) => { + c.state.status = "running"; + appendEvent( + c.state, + { id: "grow", name: "Grow Agent" }, + "workflow", + "Workflow resumed.", + ); + c.broadcast("workflow.updated", c.state); + return c.state; + }, + + runAgent: async (c, input: { agentId: string }) => { + const agent = c.state.agents.find((item) => item.id === input.agentId); + if (!agent) throw new Error(`Unknown workflow agent: ${input.agentId}`); + + agent.status = "running"; + appendEvent(c.state, agent, "agent", `${agent.name} started.`); + c.broadcast("workflow.updated", c.state); + + const profile = getAgentProfile(agent.id); + if (profile?.service != null) { + const userId = c.state.userId; + const goal = c.state.goal; + c.waitUntil( + (async () => { + const result = await runServiceAgentProbe(profile, { + userId, + goal, + }); + agent.lastResult = result; + agent.status = result.status === "unavailable" ? "blocked" : "done"; + appendEvent(c.state, agent, "agent", result.summary, result.detail); + c.broadcast("workflow.updated", c.state); + await c.saveState({ immediate: true }); + })(), + ); + return c.state; + } + + const result = localAgentResult(agent, c.state.goal); + + agent.lastResult = result; + agent.status = result.status === "unavailable" ? "blocked" : "done"; + appendEvent(c.state, agent, "agent", result.summary, result.detail); + c.broadcast("workflow.updated", c.state); + return c.state; + }, + + recordQaScore: async ( + c, + input: { + agentId: string; + question: string; + answer: string; + score: number; + notes?: string; + }, + ) => { + const agent = c.state.agents.find((item) => item.id === input.agentId); + if (!agent) throw new Error(`Unknown workflow agent: ${input.agentId}`); + const card: AgentScorecard = { + id: `score_${Date.now()}`, + question: input.question, + answer: input.answer, + score: Math.max(0, Math.min(100, Number(input.score))), + notes: input.notes, + createdAt: now(), + }; + agent.scorecards.unshift(card); + appendEvent( + c.state, + agent, + "score", + `${agent.name} recorded Q&A score ${card.score}.`, + card, + ); + c.broadcast("workflow.updated", c.state); + return c.state; + }, + + getStatus: async (c) => c.state, + }, +}); diff --git a/src/agents/catalog.ts b/src/agents/catalog.ts new file mode 100644 index 0000000..350df5b --- /dev/null +++ b/src/agents/catalog.ts @@ -0,0 +1,94 @@ +export type AgentKind = + | "master" + | "workflow" + | "microservice" + | "score"; + +export type AgentProfile = { + id: string; + name: string; + role: string; + kind: AgentKind; + description: string; + service?: "interview-service" | "roleplay-service" | "qscore-service"; +}; + +export const agentCatalog = [ + { + id: "grow", + name: "Grow Agent", + role: "Master Orchestrator", + kind: "master", + description: + "Owns user context, routes work to sub-agents, commits durable memory, and tracks workflow progress.", + }, + { + id: "resume", + name: "Resume Agent", + role: "Resume Builder", + kind: "workflow", + description: + "Turns profile context, Q-Score gaps, and target roles into resume edits and application collateral.", + }, + { + id: "job-search", + name: "Job Search Agent", + role: "Opportunity Scout", + kind: "workflow", + description: + "Finds relevant jobs, ranks opportunities, and prepares a shortlist for the application workflow.", + }, + { + id: "job-apply", + name: "Job Apply Agent", + role: "Application Operator", + kind: "workflow", + description: + "Prepares tailored applications, tracks submissions, and records follow-up tasks.", + }, + { + id: "sara", + name: "Sara", + role: "Interview Agent", + kind: "microservice", + service: "interview-service", + description: + "Runs interview practice through the interview-service microservice and owns interview Q&A feedback.", + }, + { + id: "emily", + name: "Emily", + role: "Roleplay Agent", + kind: "microservice", + service: "roleplay-service", + description: + "Runs roleplay practice through the roleplay-service microservice and owns scenario feedback.", + }, + { + id: "qscore", + name: "Quinn", + role: "Q-Score Agent", + kind: "score", + service: "qscore-service", + description: + "Computes and explains Q-Score changes, then displays Q&A and scores under the owning agent.", + }, +] as const satisfies AgentProfile[]; + +export type AgentId = (typeof agentCatalog)[number]["id"]; + +export function getAgentProfile(id: string): AgentProfile | undefined { + return agentCatalog.find((agent) => agent.id === id); +} + +export function jobApplicationAgentIds(): AgentId[] { + return [ + "grow", + "resume", + "job-search", + "job-apply", + "sara", + "emily", + "qscore", + ]; +} diff --git a/src/auth/clerk.ts b/src/auth/clerk.ts index eb232d0..3f4d76a 100644 --- a/src/auth/clerk.ts +++ b/src/auth/clerk.ts @@ -34,7 +34,19 @@ export const requireUser = createMiddleware(async (c, next) => { c.req.header("x-growqr-user") ) { const userId = c.req.header("x-growqr-user")!; - const row = await db.query.users.findFirst({ where: eq(users.id, userId) }); + let row = await db.query.users.findFirst({ where: eq(users.id, userId) }); + if (!row) { + const inserted = await db + .insert(users) + .values({ + id: userId, + email: `${userId}@service.local`, + displayName: userId, + }) + .onConflictDoNothing() + .returning(); + row = inserted[0]; + } if (!row) { throw new HTTPException(401, { message: "service token references unknown user" }); } diff --git a/src/config.ts b/src/config.ts index b135d9d..a1fb960 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,21 +23,46 @@ export const config = { clerkPublishableKey: process.env.CLERK_PUBLISHABLE_KEY ?? "", // Optional: lock service-to-service calls (actor → backend). serviceToken: process.env.SERVICE_TOKEN ?? "", + a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key", - // Anthropic for Grow Agent + sub-agent LLM calls. - anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? "", + // LLM gateway for Grow Agent + sub-agent planning calls. + llmProvider: process.env.LLM_PROVIDER ?? "opencode", + llmApiKey: + process.env.LLM_API_KEY ?? + process.env.OPENCODE_API_KEY ?? + "", + llmBaseUrl: + process.env.LLM_BASE_URL ?? + process.env.OPENCODE_BASE_URL ?? + "https://opencode.ai/zen/v1", + opencodeApiKey: process.env.OPENCODE_API_KEY ?? "", growAgentModel: - process.env.GROW_AGENT_MODEL ?? "claude-opus-4-7", + process.env.GROW_AGENT_MODEL ?? + process.env.LLM_MODEL ?? + "kimi-k2.6", subAgentModel: - process.env.SUB_AGENT_MODEL ?? "claude-sonnet-4-6", + process.env.SUB_AGENT_MODEL ?? + process.env.LLM_MODEL ?? + "kimi-k2.6", // Rivet Kit engine endpoint (self-hosted in docker-compose). rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420", + rivetClientEndpoint: + process.env.RIVET_CLIENT_ENDPOINT ?? + `http://127.0.0.1:${Number(process.env.PORT ?? 4000)}/api/rivet`, + + // Product microservices exposed as sub-agent surfaces. + interviewServiceUrl: + process.env.INTERVIEW_SERVICE_URL ?? "http://localhost:8007", + roleplayServiceUrl: + process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008", + qscoreServiceUrl: + process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000", // Per-user container images. giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22", opencodeImage: - process.env.OPENCODE_IMAGE ?? "ghcr.io/sst/opencode:latest", + process.env.OPENCODE_IMAGE ?? "ghcr.io/anomalyco/opencode:latest", // Host that user containers expose ports on (the host running Docker). userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1", @@ -49,7 +74,7 @@ export const config = { frontendOrigin: process.env.FRONTEND_ORIGIN ?? "http://localhost:3000", - // Used by Anthropic SDK extended thinking / streaming budgets. + // Used by LLM requests. maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096), required, // exported so other modules can fail fast on boot diff --git a/src/docker/manager.ts b/src/docker/manager.ts index 6e1be5d..b9065c0 100644 --- a/src/docker/manager.ts +++ b/src/docker/manager.ts @@ -133,42 +133,95 @@ async function startGiteaContainer(opts: { return { id: container.id, name }; } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +async function execGiteaCli(containerId: string, args: string[]): Promise { + const container = docker.getContainer(containerId); + const command = [ + "gitea", + "--work-path", + "/data/gitea", + "--config", + "/data/gitea/conf/app.ini", + ...args, + ] + .map(shellQuote) + .join(" "); + const exec = await container.exec({ + Cmd: ["su", "git", "-c", command], + AttachStdout: true, + AttachStderr: true, + WorkingDir: "/data/gitea", + }); + const stream = await exec.start({ Detach: false, Tty: false }); + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk))); + await new Promise((resolve) => { + stream.on("end", () => resolve()); + stream.on("close", () => resolve()); + }); + const output = Buffer.concat(chunks).toString("utf8"); + const info = await exec.inspect(); + if (info.ExitCode && info.ExitCode !== 0) { + throw new Error(`gitea cli failed (${info.ExitCode}): ${output}`); + } + return output; +} + // Runs `gitea admin user create --admin ...` inside the container. -// Idempotent: returns existing creds if the user already exists. +// Idempotent: the CLI returns non-zero if the user already exists, which is fine. async function ensureGiteaAdmin(opts: { containerId: string; username: string; password: string; email: string; }): Promise { - const container = docker.getContainer(opts.containerId); - const exec = await container.exec({ - Cmd: [ - "su", - "git", - "-c", - `gitea admin user create --admin --username ${opts.username} --password '${opts.password.replace(/'/g, "'\\''")}' --email ${opts.email} --must-change-password=false`, - ], - AttachStdout: true, - AttachStderr: true, - WorkingDir: "/var/lib/gitea", - }); - const stream = await exec.start({ Detach: false, Tty: false }); - await new Promise((resolve) => { - stream.on("end", () => resolve()); - stream.on("close", () => resolve()); - stream.resume(); - }); - const info = await exec.inspect(); - if (info.ExitCode && info.ExitCode !== 0) { - // Most common non-zero: "user already exists" — that's fine. + try { + await execGiteaCli(opts.containerId, [ + "admin", + "user", + "create", + "--admin", + "--username", + opts.username, + "--password", + opts.password, + "--email", + opts.email, + "--must-change-password=false", + ]); + } catch (err) { log.debug( - { exitCode: info.ExitCode }, + { err }, "gitea admin user create returned non-zero (likely already exists)", ); } } +async function generateGiteaToken(opts: { + containerId: string; + username: string; + scopes: string[]; +}): Promise { + const output = await execGiteaCli(opts.containerId, [ + "admin", + "user", + "generate-access-token", + "--username", + opts.username, + "--token-name", + `growqr-backend-${Date.now()}`, + "--scopes", + opts.scopes.join(","), + "--raw", + ]); + const token = output.match(/[a-f0-9]{40}/i)?.[0]; + if (!token) throw new Error("gitea token generation returned an empty token"); + return token; +} + async function startOpencodeContainer(opts: { userId: string; httpPort: number; @@ -298,15 +351,11 @@ export async function provisionUserStack(userId: string): Promise { email: adminEmail, }); - // Mint a token using basic auth. - const basicClient = new GiteaClient(giteaBase, { - kind: "basic", + // Mint a token via Gitea's CLI so retries do not depend on a transient + // bootstrap password from a previous provisioning attempt. + const token = await generateGiteaToken({ + containerId: gitea.id, username: adminUsername, - password: adminPassword, - }); - const token = await basicClient.ensureAccessToken({ - username: adminUsername, - name: "growqr-backend", scopes: ["write:repository", "write:user", "write:issue"], }); diff --git a/src/index.ts b/src/index.ts index 009c4e3..d5ac02a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ import { actorRoutes } from "./routes/actors.js"; import { opencodeRoutes } from "./routes/opencode.js"; import { gitRoutes } from "./routes/git.js"; import { userRoutes } from "./routes/users.js"; +import { agentRoutes } from "./routes/agents.js"; +import { workflowRoutes } from "./routes/workflows.js"; import { db } from "./db/client.js"; import { hydratePortAllocator, reconcileOnBoot } from "./docker/manager.js"; @@ -58,10 +60,17 @@ async function main() { // PRD HTTP control plane (auth-gated). app.route("/users", userRoutes()); + app.route("/agents", agentRoutes()); + app.route("/workflows", workflowRoutes()); app.route("/actors", actorRoutes()); app.route("/opencode", opencodeRoutes()); app.route("/git", gitRoutes()); + if (process.env.RIVET_RUN_ENGINE === "1") { + delete process.env.RIVET_ENDPOINT; + } + registry.startRunner(); + serve({ fetch: app.fetch, port: config.port }, (info) => { log.info( { diff --git a/src/lib/anthropic.ts b/src/lib/anthropic.ts deleted file mode 100644 index 77903d5..0000000 --- a/src/lib/anthropic.ts +++ /dev/null @@ -1,104 +0,0 @@ -import Anthropic from "@anthropic-ai/sdk"; -import { config } from "../config.js"; - -export const anthropic = new Anthropic({ - apiKey: config.anthropicApiKey, -}); - -export const GROW_AGENT_SYSTEM = `You are a Grow Agent — a user's master AI orchestrator on the GrowQR platform. - -You own this user's long-running context, memory, and workspace. You coordinate specialized sub-agents (coding, repo, quest, product-flow, etc.), keep durable state in the user's Gitea memory repository, and execute workflows via the user's OpenCode sandbox. - -Operating principles: -- Be concise and direct. The user sees your messages in a Slack-like chat. -- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history. -- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container. -- Track active goals and quests. Surface progress proactively when the user returns. -- Prefer one small commit per meaningful state change over batching. -- Never invent tool names. Only use the tools provided. -`; - -export type GrowAgentTool = - | "spawn_sub_agent" - | "commit_memory" - | "read_memory" - | "list_memory"; - -export const growAgentTools: Anthropic.Tool[] = [ - { - name: "spawn_sub_agent", - description: - "Spawn a specialized sub-agent to run a bounded task through the user's OpenCode container. Use for anything that requires running code, editing files, or producing artifacts.", - input_schema: { - type: "object", - properties: { - type: { - type: "string", - description: - "Sub-agent type: 'coding', 'repo', 'migration', 'quest', 'product', 'backend', 'frontend', or another short identifier.", - }, - prompt: { - type: "string", - description: - "The full task prompt for the sub-agent. Include all context it needs — sub-agents do not see this conversation.", - }, - channelId: { - type: "string", - description: - "Optional channel/thread id the sub-agent should report into. Generated if omitted.", - }, - }, - required: ["type", "prompt"], - }, - }, - { - name: "commit_memory", - description: - "Write or update a file in the user's Gitea memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.", - input_schema: { - type: "object", - properties: { - path: { - type: "string", - description: - "Repo-relative path, e.g. 'goals/active.md' or 'decisions/2026-05-19-architecture.md'.", - }, - content: { - type: "string", - description: "Full UTF-8 file content to write.", - }, - message: { - type: "string", - description: "Commit message describing the change.", - }, - }, - required: ["path", "content", "message"], - }, - }, - { - name: "read_memory", - description: "Read a single file from the user's memory repo. Returns null if missing.", - input_schema: { - type: "object", - properties: { - path: { type: "string" }, - }, - required: ["path"], - }, - }, - { - name: "list_memory", - description: - "List files at a path prefix in the user's memory repo. Use to discover what context already exists.", - input_schema: { - type: "object", - properties: { - pathPrefix: { - type: "string", - description: "Repo-relative directory, e.g. 'goals' or '' for root.", - }, - }, - required: ["pathPrefix"], - }, - }, -]; diff --git a/src/lib/llm.ts b/src/lib/llm.ts new file mode 100644 index 0000000..2d6c917 --- /dev/null +++ b/src/lib/llm.ts @@ -0,0 +1,229 @@ +import { config } from "../config.js"; + +export const GROW_AGENT_SYSTEM = `You are a Grow Agent - a user's master AI orchestrator on the GrowQR platform. + +You own this user's long-running context, memory, and workspace. You coordinate specialized sub-agents (coding, repo, quest, product-flow, etc.), keep durable state in the user's Gitea memory repository, and execute workflows via the user's OpenCode sandbox. + +Operating principles: +- Be concise and direct. The user sees your messages in a Slack-like chat. +- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history. +- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container. +- Track active goals and quests. Surface progress proactively when the user returns. +- Prefer one small commit per meaningful state change over batching. +- Never invent tool names. Only use the tools provided. +`; + +export type GrowAgentTool = + | "spawn_sub_agent" + | "commit_memory" + | "read_memory" + | "list_memory"; + +export type LlmTool = { + type: "function"; + function: { + name: GrowAgentTool; + description: string; + parameters: Record; + }; +}; + +export type LlmToolCall = { + id: string; + name: string; + arguments: Record; +}; + +export type LlmMessage = { + role: "system" | "user" | "assistant" | "tool"; + content: string | null; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; + }>; +}; + +export const growAgentTools: LlmTool[] = [ + { + type: "function", + function: { + name: "spawn_sub_agent", + description: + "Spawn a specialized sub-agent to run a bounded task through the user's OpenCode container. Use for anything that requires running code, editing files, or producing artifacts.", + parameters: { + type: "object", + properties: { + type: { + type: "string", + description: + "Sub-agent type: 'coding', 'repo', 'migration', 'quest', 'product', 'backend', 'frontend', or another short identifier.", + }, + prompt: { + type: "string", + description: + "The full task prompt for the sub-agent. Include all context it needs - sub-agents do not see this conversation.", + }, + channelId: { + type: "string", + description: + "Optional channel/thread id the sub-agent should report into. Generated if omitted.", + }, + }, + required: ["type", "prompt"], + }, + }, + }, + { + type: "function", + function: { + name: "commit_memory", + description: + "Write or update a file in the user's Gitea memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: + "Repo-relative path, e.g. 'goals/active.md' or 'decisions/2026-05-19-architecture.md'.", + }, + content: { + type: "string", + description: "Full UTF-8 file content to write.", + }, + message: { + type: "string", + description: "Commit message describing the change.", + }, + }, + required: ["path", "content", "message"], + }, + }, + }, + { + type: "function", + function: { + name: "read_memory", + description: "Read a single file from the user's memory repo. Returns null if missing.", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + }, + required: ["path"], + }, + }, + }, + { + type: "function", + function: { + name: "list_memory", + description: + "List files at a path prefix in the user's memory repo. Use to discover what context already exists.", + parameters: { + type: "object", + properties: { + pathPrefix: { + type: "string", + description: "Repo-relative directory, e.g. 'goals' or '' for root.", + }, + }, + required: ["pathPrefix"], + }, + }, + }, +]; + +type ChatCompletionsResponse = { + choices?: Array<{ + finish_reason?: string; + message?: { + content?: string | null; + tool_calls?: Array<{ + id: string; + type: "function"; + function: { + name: string; + arguments?: string; + }; + }>; + }; + }>; + error?: { + message?: string; + }; +}; + +function normalizeModel(model: string): string { + if (config.llmProvider === "opencode" && model.startsWith("opencode/")) { + return model.slice("opencode/".length); + } + return model; +} + +function endpoint(path: string): string { + return `${config.llmBaseUrl.replace(/\/$/, "")}${path}`; +} + +function parseToolArguments(raw: string | undefined): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +export async function createChatCompletion(opts: { + model: string; + messages: LlmMessage[]; + tools: LlmTool[]; + maxTokens: number; +}): Promise<{ + content: string; + toolCalls: LlmToolCall[]; + finishReason?: string; +}> { + const res = await fetch(endpoint("/chat/completions"), { + method: "POST", + headers: { + authorization: `Bearer ${config.llmApiKey}`, + "content-type": "application/json", + accept: "application/json", + }, + body: JSON.stringify({ + model: normalizeModel(opts.model), + messages: opts.messages, + tools: opts.tools, + tool_choice: "auto", + max_tokens: opts.maxTokens, + }), + }); + + const json = (await res.json().catch(() => ({}))) as ChatCompletionsResponse; + if (!res.ok) { + const detail = json.error?.message ?? JSON.stringify(json); + throw new Error(`LLM request failed: ${res.status} ${res.statusText}: ${detail}`); + } + + const choice = json.choices?.[0]; + const message = choice?.message; + return { + content: message?.content ?? "", + finishReason: choice?.finish_reason, + toolCalls: + message?.tool_calls?.map((call) => ({ + id: call.id, + name: call.function.name, + arguments: parseToolArguments(call.function.arguments), + })) ?? [], + }; +} diff --git a/src/routes/agents.ts b/src/routes/agents.ts new file mode 100644 index 0000000..6c115aa --- /dev/null +++ b/src/routes/agents.ts @@ -0,0 +1,12 @@ +import { Hono } from "hono"; +import { agentCatalog } from "../agents/catalog.js"; +import { requireUser, type AuthContext } from "../auth/clerk.js"; + +export function agentRoutes() { + const app = new Hono(); + app.use("*", requireUser); + + app.get("/catalog", (c) => c.json({ agents: agentCatalog })); + + return app; +} diff --git a/src/routes/workflows.ts b/src/routes/workflows.ts new file mode 100644 index 0000000..8174699 --- /dev/null +++ b/src/routes/workflows.ts @@ -0,0 +1,74 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { createClient } from "rivetkit/client"; +import { config } from "../config.js"; +import { requireUser, type AuthContext } from "../auth/clerk.js"; +import type { Registry } from "../actors/registry.js"; + +const client = createClient(config.rivetEndpoint); + +function jobWorkflowFor(userId: string) { + return client.workflowJob.getOrCreate([userId, "job-application"]); +} + +export function workflowRoutes() { + const app = new Hono(); + app.use("*", requireUser); + + app.post("/job-application", async (c) => { + const userId = c.get("userId"); + const body = z + .object({ goal: z.string().min(1).optional() }) + .parse(await c.req.json().catch(() => ({}))); + const handle = jobWorkflowFor(userId); + const state = await handle.init({ userId, goal: body.goal }); + const started = await handle.start(); + return c.json({ workflow: started ?? state }); + }); + + app.get("/job-application", async (c) => { + const userId = c.get("userId"); + const handle = jobWorkflowFor(userId); + const state = await handle.init({ userId }); + return c.json({ workflow: state }); + }); + + app.post("/job-application/pause", async (c) => { + const userId = c.get("userId"); + const workflow = await jobWorkflowFor(userId).pause(); + return c.json({ workflow }); + }); + + app.post("/job-application/resume", async (c) => { + const userId = c.get("userId"); + const workflow = await jobWorkflowFor(userId).resume(); + return c.json({ workflow }); + }); + + app.post("/job-application/agents/:agentId/run", async (c) => { + const userId = c.get("userId"); + const agentId = c.req.param("agentId"); + const workflow = await jobWorkflowFor(userId).runAgent({ agentId }); + return c.json({ workflow }); + }); + + app.post("/job-application/agents/:agentId/score", async (c) => { + const userId = c.get("userId"); + const agentId = c.req.param("agentId"); + const body = z + .object({ + question: z.string().min(1), + answer: z.string().min(1), + score: z.number().min(0).max(100), + notes: z.string().optional(), + }) + .parse(await c.req.json()); + const workflow = await jobWorkflowFor(userId).recordQaScore({ + agentId, + ...body, + }); + return c.json({ workflow }); + }); + + return app; +} diff --git a/src/services/service-agents.ts b/src/services/service-agents.ts new file mode 100644 index 0000000..8c92d9e --- /dev/null +++ b/src/services/service-agents.ts @@ -0,0 +1,241 @@ +import { config } from "../config.js"; +import type { AgentProfile } from "../agents/catalog.js"; +import { createHash } from "node:crypto"; + +export type ServiceAgentResult = { + status: "ok" | "unavailable" | "local"; + summary: string; + detail?: unknown; +}; + +export type ServiceAgentContext = { + userId: string; + orgId?: string; + goal: string; +}; + +function stableUuid(input: string): string { + const hex = createHash("sha256").update(input).digest("hex").slice(0, 32); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + `4${hex.slice(13, 16)}`, + `8${hex.slice(17, 20)}`, + hex.slice(20, 32), + ].join("-"); +} + +async function serviceJson( + baseUrl: string, + path: string, + init?: RequestInit, +): Promise { + const res = await fetch(`${baseUrl.replace(/\/$/, "")}${path}`, { + ...init, + headers: { + "content-type": "application/json", + ...(config.a2aAllowedKey + ? { authorization: `Bearer ${config.a2aAllowedKey}` } + : {}), + ...(init?.headers ?? {}), + }, + }); + const body = await res.text(); + if (!res.ok) { + throw new Error(`${path} returned HTTP ${res.status}: ${body}`); + } + return (body ? JSON.parse(body) : {}) as T; +} + +async function healthCheck(baseUrl: string, label: string): Promise { + try { + const detail = await serviceJson(baseUrl, "/health", { method: "GET" }); + return { + status: "ok", + summary: `${label} is reachable and ready for workflow handoff.`, + detail, + }; + } catch (err) { + return { + status: "unavailable", + summary: `${label} is not reachable yet: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +async function runSaraInterview(ctx: ServiceAgentContext): Promise { + const payload = { + user_id: ctx.userId, + org_id: ctx.orgId ?? "growqr", + persona_id: "emma", + interview_type: "behavioral", + duration_minutes: 15, + context: { + target_role: ctx.goal, + company_name: "Target company", + difficulty: "medium", + source: "growqr-workflow", + }, + }; + const detail = await serviceJson>( + config.interviewServiceUrl, + "/api/v1/configure", + { + method: "POST", + body: JSON.stringify(payload), + }, + ); + return { + status: "ok", + summary: `Sara created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`, + detail, + }; +} + +async function runEmilyRoleplay(ctx: ServiceAgentContext): Promise { + const payload = { + user_id: ctx.userId, + org_id: ctx.orgId ?? "growqr", + persona_id: "emma", + duration_minutes: 15, + roleplay_type: "custom", + brief: `Practice a realistic job-application conversation for: ${ctx.goal}. Include objection handling, concise self-pitching, and a closing next step.`, + metadata: { + target_role: ctx.goal, + difficulty: "medium", + source: "growqr-workflow", + }, + qscore: { + q_score: 78, + profession: "student", + formula_version: "workflow-demo", + quotients: { + CQm: { score: 72, active: true }, + XQ: { score: 80, active: true }, + VQ: { score: 76, active: true }, + }, + }, + }; + const detail = await serviceJson>( + config.roleplayServiceUrl, + "/api/v1/roleplays/configure", + { + method: "POST", + body: JSON.stringify(payload), + }, + ); + return { + status: "ok", + summary: `Emily created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`, + detail, + }; +} + +async function runQuinnQScore(ctx: ServiceAgentContext): Promise { + const orgId = ctx.orgId ?? "growqr"; + const qscoreUserId = stableUuid(ctx.userId); + const signals = [ + { + signal_id: "resume.uploaded", + present: true, + score: 82, + raw: { source: "resume-agent", workflow_goal: ctx.goal }, + }, + { + signal_id: "resume.ats_compatibility", + present: true, + score: 76, + raw: { source: "resume-agent", workflow_goal: ctx.goal }, + }, + { + signal_id: "engagement.features_used", + present: true, + score: 88, + raw: { source: "grow-agent", workflow_goal: ctx.goal }, + }, + { + signal_id: "goals.goal_clarity", + present: true, + score: 90, + raw: { source: "grow-agent", workflow_goal: ctx.goal }, + }, + ]; + + const ingest = await serviceJson>( + config.qscoreServiceUrl, + "/v1/signals/ingest", + { + method: "POST", + body: JSON.stringify({ + org_id: orgId, + user_id: qscoreUserId, + profession: "student", + source: "growqr-workflow", + signals, + }), + }, + ); + + let compute: Record | undefined; + try { + compute = await serviceJson>( + config.qscoreServiceUrl, + "/v1/qscore/compute", + { + method: "POST", + body: JSON.stringify({ + org_id: orgId, + user_id: qscoreUserId, + }), + }, + ); + } catch (err) { + return { + status: "unavailable", + summary: + "Quinn ingested Q-Score signals, but computation is waiting for the QScore worker or formula store.", + detail: { + ingest, + compute_error: err instanceof Error ? err.message : String(err), + }, + }; + } + + return { + status: "ok", + summary: `Quinn computed Q-Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`, + detail: { ingest, compute, qscore_user_id: qscoreUserId }, + }; +} + +export async function runServiceAgentProbe( + agent: AgentProfile, + ctx?: ServiceAgentContext, +): Promise { + try { + switch (agent.service) { + case "interview-service": + return ctx + ? await runSaraInterview(ctx) + : healthCheck(config.interviewServiceUrl, "Sara / interview-service"); + case "roleplay-service": + return ctx + ? await runEmilyRoleplay(ctx) + : healthCheck(config.roleplayServiceUrl, "Emily / roleplay-service"); + case "qscore-service": + return ctx + ? await runQuinnQScore(ctx) + : healthCheck(config.qscoreServiceUrl, "Quinn / qscore-service"); + default: + return { + status: "local", + summary: `${agent.name} is a local workflow agent managed by Rivet.`, + }; + } + } catch (err) { + return { + status: "unavailable", + summary: `${agent.name} could not complete its service handoff: ${err instanceof Error ? err.message : String(err)}`, + }; + } +}