From 2d471c61b494afdea652a45c189816dd2ce88cf4 Mon Sep 17 00:00:00 2001 From: NinjasPyajamas Date: Thu, 21 May 2026 23:17:26 +0530 Subject: [PATCH 1/4] feat: introduce workflow job management and agent orchestration - Added workflow job actor to manage job application workflows. - Implemented agent catalog for various workflow agents. - Created service agents for interview, roleplay, and Q-Score functionalities. - Enhanced user authentication to automatically create users if they do not exist. - Updated configuration to support new LLM provider and API keys. - Introduced new routes for agent and workflow management. - Refactored Docker management to improve Gitea admin user creation and token generation. - Removed deprecated Anthropics SDK integration. --- .env.example | 22 ++- .gitignore | 1 + README.md | 8 +- docker-compose.yml | 16 +- docs/architecture.html | 2 +- package-lock.json | 156 ------------------ package.json | 1 - src/actors/grow-agent.ts | 111 +++++++------ src/actors/registry.ts | 2 + src/actors/workflow-job.ts | 292 +++++++++++++++++++++++++++++++++ src/agents/catalog.ts | 94 +++++++++++ src/auth/clerk.ts | 14 +- src/config.ts | 37 ++++- src/docker/manager.ts | 111 +++++++++---- src/index.ts | 9 + src/lib/anthropic.ts | 104 ------------ src/lib/llm.ts | 229 ++++++++++++++++++++++++++ src/routes/agents.ts | 12 ++ src/routes/workflows.ts | 74 +++++++++ src/services/service-agents.ts | 241 +++++++++++++++++++++++++++ 20 files changed, 1169 insertions(+), 367 deletions(-) create mode 100644 src/actors/workflow-job.ts create mode 100644 src/agents/catalog.ts delete mode 100644 src/lib/anthropic.ts create mode 100644 src/lib/llm.ts create mode 100644 src/routes/agents.ts create mode 100644 src/routes/workflows.ts create mode 100644 src/services/service-agents.ts 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)}`, + }; + } +} -- 2.49.1 From 54297496a4c33ca15a4e827420925e555dd4dfb6 Mon Sep 17 00:00:00 2001 From: NinjasPyajamas Date: Mon, 25 May 2026 17:52:40 +0530 Subject: [PATCH 2/4] feat: Enhance Gitea integration and agent management - Added ensureOrg and ensureOrgRepo methods to GiteaClient for centralized organization and repository management. - Updated main application flow to ensure central Gitea readiness at startup. - Introduced prompt-loader for dynamic loading of agent modules and system prompts from disk. - Refactored agent routes to return sub-agent module catalog. - Modified git routes to interact with a central Gitea instance instead of per-user containers. - Updated workflow routes to utilize a unified user actor per user, streamlining job application workflows. - Improved service agent handling with a new lightweight reference type. --- .env.example | 26 +- Dockerfile | 7 + agents/emily.md | 10 + agents/job-apply.md | 11 + agents/job-search.md | 11 + agents/qscore.md | 11 + agents/resume.md | 11 + agents/sara.md | 10 + docker-compose.yml | 70 +- drizzle/0001_central_gitea_unified_actor.sql | 32 + prompts/system.txt | 19 + src/actors/grow-agent.ts | 338 --------- src/actors/registry.ts | 10 +- src/actors/sub-agent-runner.ts | 103 --- src/actors/sub-agent.ts | 83 -- src/actors/user-actor.ts | 748 +++++++++++++++++++ src/actors/workflow-job.ts | 292 -------- src/agents/catalog.ts | 131 ++-- src/config.ts | 25 +- src/db/schema.ts | 32 +- src/docker/manager.ts | 499 +++++++------ src/index.ts | 20 +- src/lib/gitea.ts | 58 ++ src/lib/llm.ts | 115 +-- src/lib/prompt-loader.ts | 168 +++++ src/routes/actors.ts | 6 +- src/routes/agents.ts | 5 +- src/routes/git.ts | 38 +- src/routes/workflows.ts | 36 +- src/services/service-agents.ts | 13 +- 30 files changed, 1615 insertions(+), 1323 deletions(-) create mode 100644 agents/emily.md create mode 100644 agents/job-apply.md create mode 100644 agents/job-search.md create mode 100644 agents/qscore.md create mode 100644 agents/resume.md create mode 100644 agents/sara.md create mode 100644 drizzle/0001_central_gitea_unified_actor.sql create mode 100644 prompts/system.txt delete mode 100644 src/actors/grow-agent.ts delete mode 100644 src/actors/sub-agent-runner.ts delete mode 100644 src/actors/sub-agent.ts create mode 100644 src/actors/user-actor.ts delete mode 100644 src/actors/workflow-job.ts create mode 100644 src/lib/prompt-loader.ts diff --git a/.env.example b/.env.example index 9f7b3d0..eeec80e 100644 --- a/.env.example +++ b/.env.example @@ -3,9 +3,9 @@ LOG_LEVEL=info NODE_ENV=development # Postgres (started by docker-compose; defaults match the compose service) -DATABASE_URL=postgres://growqr:growqr@localhost:5432/growqr +DATABASE_URL=***************************************/growqr POSTGRES_USER=growqr -POSTGRES_PASSWORD=growqr +POSTGRES_PASSWORD=****** POSTGRES_DB=growqr # Clerk auth — get from dashboard.clerk.com → API Keys @@ -18,12 +18,23 @@ 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 +A2A_ALLOWED_KEY=*********** + +# ── Central Gitea (shared org-wide, changes.md §2A) ── +GITEA_URL=http://127.0.0.1:3001 +GITEA_ADMIN_USER=growqr-admin +GITEA_ADMIN_PASSWORD=growqr-admin-dev +GITEA_ADMIN_TOKEN= +GITEA_ORG_NAME=growqr + +# ── Version tracking (changes.md §9) ── +OPENCODE_IMAGE_VERSION=1.0.0 +MIGRATION_VERSION=1 +PROMPT_VERSION=1 # Rivet Kit engine (self-hosted in docker-compose) RIVET_ENDPOINT=http://localhost:6420 @@ -34,9 +45,8 @@ 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 -OPENCODE_IMAGE=ghcr.io/sst/opencode:latest +# Per-user OpenCode container image (shared, changes.md §3) +OPENCODE_IMAGE=ghcr.io/anomalyco/opencode:latest # Host where spawned containers expose their ports. # - localhost in dev @@ -46,7 +56,7 @@ USER_CONTAINER_HOST=127.0.0.1 # Workspace root on the host. Each user gets a subdir. USER_DATA_ROOT=./.data/users -# Port range allocated to spawned per-user containers +# Port range allocated to per-user OpenCode containers (Gitea is central) USER_PORT_RANGE_START=20000 USER_PORT_RANGE_END=29999 diff --git a/Dockerfile b/Dockerfile index f25cf09..4bbb2e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,5 +16,12 @@ ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ + +# ── Build-time prompt loading (changes.md §3) ── +# Prompts and agent definitions are copied into the image so they are +# embedded at build time. To update: edit files → rebuild image → rollout. +COPY prompts/ ./prompts/ +COPY agents/ ./agents/ + EXPOSE 4000 CMD ["node", "dist/index.js"] diff --git a/agents/emily.md b/agents/emily.md new file mode 100644 index 0000000..905bba4 --- /dev/null +++ b/agents/emily.md @@ -0,0 +1,10 @@ +--- +id: emily +name: Emily +role: Roleplay Agent +service: roleplay-service +tools: + - start_roleplay_session +--- + +Runs roleplay practice through the roleplay-service microservice and owns scenario feedback. diff --git a/agents/job-apply.md b/agents/job-apply.md new file mode 100644 index 0000000..885af86 --- /dev/null +++ b/agents/job-apply.md @@ -0,0 +1,11 @@ +--- +id: job-apply +name: Job Apply Agent +role: Application Operator +tools: + - prepare_application + - track_submission + - schedule_followup +--- + +Prepares tailored applications, tracks submissions, and records follow-up tasks. diff --git a/agents/job-search.md b/agents/job-search.md new file mode 100644 index 0000000..9113fb2 --- /dev/null +++ b/agents/job-search.md @@ -0,0 +1,11 @@ +--- +id: job-search +name: Job Search Agent +role: Opportunity Scout +tools: + - search_jobs + - rank_opportunities + - prepare_shortlist +--- + +Finds relevant jobs, ranks opportunities, and prepares a shortlist for the application workflow. diff --git a/agents/qscore.md b/agents/qscore.md new file mode 100644 index 0000000..76d6b24 --- /dev/null +++ b/agents/qscore.md @@ -0,0 +1,11 @@ +--- +id: qscore +name: Quinn +role: Q-Score Agent +service: qscore-service +tools: + - compute_qscore + - ingest_signals +--- + +Computes and explains Q-Score changes, then displays Q&A and scores. diff --git a/agents/resume.md b/agents/resume.md new file mode 100644 index 0000000..f0888c6 --- /dev/null +++ b/agents/resume.md @@ -0,0 +1,11 @@ +--- +id: resume +name: Resume Agent +role: Resume Builder +tools: + - build_resume + - review_resume + - tailor_resume +--- + +Turns profile context, Q-Score gaps, and target roles into resume edits and application collateral. diff --git a/agents/sara.md b/agents/sara.md new file mode 100644 index 0000000..b26fc37 --- /dev/null +++ b/agents/sara.md @@ -0,0 +1,10 @@ +--- +id: sara +name: Sara +role: Interview Agent +service: interview-service +tools: + - start_interview_session +--- + +Runs interview practice through the interview-service microservice and owns interview Q&A feedback. diff --git a/docker-compose.yml b/docker-compose.yml index ff3459c..29394e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,33 @@ services: retries: 10 restart: unless-stopped + # ── Central Gitea (one org-wide instance, changes.md §2A) ── + # Every user gets a repo inside the GrowQR organization on this instance. + # Per-user Gitea containers are REMOVED — the backend no longer spawns them. + gitea: + image: gitea/gitea:1.22 + container_name: growqr-gitea + environment: + USER_UID: "1000" + USER_GID: "1000" + GITEA__server__ROOT_URL: http://localhost:3001 + GITEA__server__SSH_PORT: "2222" + GITEA__security__INSTALL_LOCK: "true" + GITEA__service__DISABLE_REGISTRATION: "true" + ports: + - "3001:3001" # HTTP + - "2222:2222" # SSH + volumes: + - gitea-data:/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/version || exit 1"] + interval: 10s + timeout: 10s + retries: 15 + restart: unless-stopped + # Self-hosted Rivet engine. The backend's Rivet Kit client connects here. - # Per the PRD, the Grow Agent + sub-agents are durable actors running on Rivet. + # The unified user agent runs as a durable Rivet actor (changes.md §5). rivet-engine: image: rivetgg/engine:latest container_name: growqr-rivet @@ -34,7 +59,7 @@ services: restart: unless-stopped # The HTTP backend (Hono + Rivet Kit client + Docker manager). - # Mounts the host Docker socket so it can spawn per-user containers. + # Mounts the host Docker socket so it can spawn per-user OpenCode containers. backend: build: context: . @@ -43,6 +68,8 @@ services: depends_on: postgres: condition: service_healthy + gitea: + condition: service_healthy rivet-engine: condition: service_started ports: @@ -51,30 +78,44 @@ services: PORT: 4000 NODE_ENV: ${NODE_ENV:-production} DATABASE_URL: postgres://${POSTGRES_USER:-growqr}:${POSTGRES_PASSWORD:-growqr}@postgres:5432/${POSTGRES_DB:-growqr} + # Central Gitea (shared org-wide instance) + GITEA_URL: http://gitea:3001 + GITEA_ADMIN_USER: ${GITEA_ADMIN_USER:-growqr-admin} + GITEA_ADMIN_PASSWORD: ${GITEA_ADMIN_PASSWORD:-growqr-admin-dev} + GITEA_ADMIN_TOKEN: ${GITEA_ADMIN_TOKEN:-} + GITEA_ORG_NAME: ${GITEA_ORG_NAME:-growqr} + # Version tracking for image rollouts (changes.md §9) + OPENCODE_IMAGE_VERSION: ${OPENCODE_IMAGE_VERSION:-1.0.0} + MIGRATION_VERSION: ${MIGRATION_VERSION:-1} + PROMPT_VERSION: ${PROMPT_VERSION:-1} + # Rivet RIVET_ENDPOINT: http://rivet-engine:6420 + RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet} + # Auth CLERK_SECRET_KEY: ${CLERK_SECRET_KEY} CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY} + SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token} + A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:************} + # LLM 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} + # Per-user OpenCode containers 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 USER_PORT_RANGE_END: 29999 + # Microservices + 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} + # Frontend FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000} volumes: - # Docker-out-of-Docker: backend uses host Docker to spawn user containers. + # Docker-out-of-Docker: backend uses host Docker to spawn per-user OpenCode containers. - /var/run/docker.sock:/var/run/docker.sock # Shared host dir that per-user containers will also bind-mount their # workspace from (so backend and spawned containers see the same files). @@ -86,10 +127,11 @@ services: retries: 6 restart: unless-stopped -# Note: per-user OpenCode + Gitea containers are NOT defined here. -# The backend spawns them dynamically via dockerode on /actors/provision. -# See src/docker/manager.ts. +# Only per-user OpenCode containers are spawned dynamically now. +# Gitea is a central shared service defined above. +# See src/docker/manager.ts for the per-user OpenCode lifecycle. volumes: rivet-data: postgres-data: + gitea-data: diff --git a/drizzle/0001_central_gitea_unified_actor.sql b/drizzle/0001_central_gitea_unified_actor.sql new file mode 100644 index 0000000..d361cf8 --- /dev/null +++ b/drizzle/0001_central_gitea_unified_actor.sql @@ -0,0 +1,32 @@ +-- Migration: Central Gitea + Unified Actor (changes.md Phase 6) +-- Renames/replaces per-user Gitea fields with central Gitea repo references. +-- Simplifies actors table for unified user actor model. +-- Adds version tracking columns. + +-- 1. Drop old per-user Gitea columns from user_stacks +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_container_id; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_container_name; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_host; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_http_port; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_ssh_port; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_admin_user; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_admin_token; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_memory_repo; + +-- 2. Add central Gitea repo fields +ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS gitea_repo_name TEXT; +ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS gitea_repo_owner TEXT; + +-- 3. Add version tracking columns (changes.md §9) +ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS image_version TEXT; +ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS migration_version TEXT; +ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS prompt_version TEXT; + +-- 4. Simplify actors table for unified model +ALTER TABLE actors DROP COLUMN IF EXISTS sub_type; +ALTER TABLE actors DROP COLUMN IF EXISTS channel_id; +ALTER TABLE actors DROP COLUMN IF EXISTS parent_actor_id; +-- Change kind enum: was ('grow','sub'), now ('user') +-- Note: Drizzle handles enum changes via application-level migration. +-- The application code now only uses kind='user'. +-- Existing rows with kind='grow' or 'sub' will be left as-is (backward compatible reads). diff --git a/prompts/system.txt b/prompts/system.txt new file mode 100644 index 0000000..359d08d --- /dev/null +++ b/prompts/system.txt @@ -0,0 +1,19 @@ +You are the Grow Agent — a unified AI orchestrator for the GrowQR platform. + +You own this user's long-running context, memory, and workspace. You coordinate all sub-agent capabilities (loaded as tools), maintain durable state in the user's Git memory repository (managed via Gitea), and execute workflows through the user's OpenCode sandbox. + +## Sub-Agent Capabilities (loaded at build time) + +{{MODULE_DESCRIPTIONS}} + +## 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 Git 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, use the OpenCode execution tools. +- Track active goals and workflows. Surface progress proactively when the user returns. +- Prefer one small commit per meaningful state change over batching. +- When a user wants interview practice, use `start_interview_session` (Sara). +- When a user wants roleplay practice, use `start_roleplay_session` (Emily). +- When a user needs Q-Score computation, use `ingest_signals` and `compute_qscore` (Quinn). +- Never invent tool names. Only use the tools provided. diff --git a/src/actors/grow-agent.ts b/src/actors/grow-agent.ts deleted file mode 100644 index c28cd08..0000000 --- a/src/actors/grow-agent.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { actor } from "rivetkit"; -import { log } from "../log.js"; -import { config } from "../config.js"; -import { - createChatCompletion, - GROW_AGENT_SYSTEM, - growAgentTools, - type LlmMessage, - type LlmToolCall, -} from "../lib/llm.js"; -import { - provisionUserStack, - getUserStack, - stopUserStack, - giteaClientFor, -} from "../docker/manager.js"; -import { runSubAgentTask } from "./sub-agent-runner.js"; -import { db } from "../db/client.js"; -import { actors as actorsTable, events as eventsTable } from "../db/schema.js"; - -type ChatTurn = { - role: "user" | "assistant" | "tool"; - content: string; - toolCallId?: string; - toolCalls?: LlmToolCall[]; -}; - -type GrowAgentState = { - userId: string; - goals: string[]; - history: ChatTurn[]; - // Trimmed once it grows past N turns; long history is delegated to memory repo. - maxHistory: number; -}; - -const initialState: GrowAgentState = { - userId: "", - goals: [], - history: [], - maxHistory: 40, -}; - -const MEMORY_REPO_PATH_LIMIT = 1024; - -// One Grow Agent actor instance per user (key the actor by userId). -// Owns the user's Docker stack + LLM conversation loop. -export const growAgent = actor({ - state: initialState, - actions: { - // Idempotent. Provisions the per-user OpenCode + Gitea stack if missing. - init: async (c, input: { userId: string }) => { - if (c.state.userId && c.state.userId !== input.userId) { - throw new Error("Grow Agent already bound to a different user"); - } - c.state.userId = input.userId; - const stack = await provisionUserStack(input.userId); - - await db - .insert(actorsTable) - .values({ - actorId: `grow-${input.userId}`, - userId: input.userId, - kind: "grow", - status: "idle", - lastActivityAt: new Date(), - }) - .onConflictDoNothing(); - - c.broadcast("stack-ready", { - userId: input.userId, - opencode: `${stack.opencodeHost}:${stack.opencodePort}`, - gitea: `${stack.giteaHost}:${stack.giteaHttpPort}`, - memoryRepo: stack.giteaMemoryRepo, - }); - return stack; - }, - - // 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"); - } - - const userTurn: ChatTurn = { role: "user", content: msg.text }; - c.state.history.push(userTurn); - c.broadcast("message", { role: "user", text: msg.text }); - - const assistantText = await runAgentLoop(c, c.state.userId); - - // Trim history to maxHistory turns; long-term context lives in Gitea. - while (c.state.history.length > c.state.maxHistory) { - c.state.history.shift(); - } - - await db - .insert(eventsTable) - .values({ - userId: c.state.userId, - actorId: `grow-${c.state.userId}`, - type: "grow.message", - payload: { userText: msg.text, assistantText }, - }); - - return { reply: assistantText }; - }, - - // Sub-agent status updates fan back in via this action; the Grow Agent - // broadcasts them so the frontend's sidebar can render them under the - // right channel. - subAgentEvent: async ( - c, - input: { - subAgentId: string; - type: "started" | "progress" | "done" | "error"; - message?: string; - result?: unknown; - }, - ) => { - c.broadcast("sub-agent-event", input); - }, - - getHistory: async (c) => c.state.history, - getGoals: async (c) => c.state.goals, - - shutdown: async (c) => { - if (c.state.userId) await stopUserStack(c.state.userId); - }, - }, -}); - -// The agentic loop. Keeps calling the configured LLM with tools until the model -// returns a normal assistant turn. -async function runAgentLoop( - c: { - state: GrowAgentState; - broadcast: (event: string, data: unknown) => void; - }, - userId: string, -): Promise { - if (!config.llmApiKey) { - const reply = - "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; - } - - c.broadcast("agent-thinking", { state: "running" }); - - const MAX_ITERATIONS = 8; - let assistantTextOut = ""; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const response = await createChatCompletion({ - model: config.growAgentModel, - maxTokens: config.maxAgentTokens, - tools: growAgentTools, - messages: messagesForApi(c.state.history), - }); - - // Capture assistant text for streaming-style broadcast. - if (response.content) { - assistantTextOut += (assistantTextOut ? "\n\n" : "") + response.content; - c.broadcast("message", { role: "agent", text: response.content }); - } - - // Persist the assistant turn, including tool calls for the next tool result turn. - c.state.history.push({ - role: "assistant", - content: response.content, - toolCalls: response.toolCalls, - }); - - if (response.toolCalls.length === 0) { - break; - } - - for (const call of response.toolCalls) { - try { - const result = await dispatchTool(c, userId, call); - 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"); - c.state.history.push({ - role: "tool", - toolCallId: call.id, - content: `Error: ${err instanceof Error ? err.message : String(err)}`, - }); - } - } - } - - c.broadcast("agent-thinking", { state: "idle" }); - return assistantTextOut || "(no response)"; -} - -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( - c: { - broadcast: (event: string, data: unknown) => void; - state: GrowAgentState; - }, - userId: string, - call: LlmToolCall, -): Promise { - const input = call.arguments; - switch (call.name) { - case "spawn_sub_agent": { - const type = String(input.type ?? "generic"); - const prompt = String(input.prompt ?? ""); - const channelId = - typeof input.channelId === "string" - ? input.channelId - : `${type}-${Date.now()}`; - const id = `sub-${type}-${Date.now()}`; - await db - .insert(actorsTable) - .values({ - actorId: id, - userId, - kind: "sub", - subType: type, - status: "running", - channelId, - parentActorId: `grow-${userId}`, - lastActivityAt: new Date(), - }); - c.broadcast("sub-agent-spawned", { id, type, channelId, prompt }); - - // Fire-and-forget; the runner updates DB + broadcasts via the actor. - void runSubAgentTask({ - userId, - subAgentId: id, - type, - prompt, - channelId, - onEvent: (event, data) => c.broadcast(event, data), - }); - - return { id, type, channelId, status: "running" }; - } - - case "commit_memory": { - const path = String(input.path ?? "").slice(0, MEMORY_REPO_PATH_LIMIT); - const content = String(input.content ?? ""); - const message = String(input.message ?? "memory update"); - const client = await giteaClientFor(userId); - const stack = await getUserStack(userId); - if (!client || !stack?.giteaMemoryRepo) { - return { ok: false, error: "memory repo not provisioned" }; - } - const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string]; - const result = await client.putFile({ - owner, - repo, - path, - contentUtf8: content, - message, - }); - c.broadcast("memory-committed", { path, message }); - return { ok: true, path, commitSha: result.commitSha }; - } - - case "read_memory": { - const path = String(input.path ?? ""); - const client = await giteaClientFor(userId); - const stack = await getUserStack(userId); - if (!client || !stack?.giteaMemoryRepo) return null; - const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string]; - const text = await client.readFile({ owner, repo, path }); - return text; - } - - case "list_memory": { - const pathPrefix = String(input.pathPrefix ?? ""); - const client = await giteaClientFor(userId); - const stack = await getUserStack(userId); - if (!client || !stack?.giteaMemoryRepo) return []; - const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string]; - // Gitea contents API on a directory returns an array of entries. - try { - const res = await fetch( - `http://${stack.giteaHost}:${stack.giteaHttpPort}/api/v1/repos/${owner}/${repo}/contents/${encodeURI(pathPrefix)}`, - { - headers: { - authorization: `token ${stack.giteaAdminToken}`, - accept: "application/json", - }, - }, - ); - if (!res.ok) return []; - const entries = (await res.json()) as Array<{ - name: string; - path: string; - type: string; - }>; - return entries.map((e) => ({ name: e.name, path: e.path, type: e.type })); - } catch { - return []; - } - } - - default: - throw new Error(`unknown tool: ${call.name}`); - } -} diff --git a/src/actors/registry.ts b/src/actors/registry.ts index e377828..ec4247e 100644 --- a/src/actors/registry.ts +++ b/src/actors/registry.ts @@ -1,13 +1,11 @@ import { setup } from "rivetkit"; -import { growAgent } from "./grow-agent.js"; -import { subAgent } from "./sub-agent.js"; -import { workflowJob } from "./workflow-job.js"; +import { userActor } from "./user-actor.js"; +// Per changes.md §5: ONE unified actor per user. +// No separate growAgent, subAgent, or workflowJob actors. export const registry = setup({ use: { - growAgent, - subAgent, - workflowJob, + userActor, }, }); diff --git a/src/actors/sub-agent-runner.ts b/src/actors/sub-agent-runner.ts deleted file mode 100644 index 3c6a60a..0000000 --- a/src/actors/sub-agent-runner.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { eq, and } from "drizzle-orm"; -import { db } from "../db/client.js"; -import { actors as actorsTable, opencodeSessions } from "../db/schema.js"; -import { log } from "../log.js"; -import { OpencodeClient } from "../lib/opencode.js"; -import { opencodeUrlFor } from "../docker/manager.js"; - -export type SubAgentRunInput = { - userId: string; - subAgentId: string; - type: string; - prompt: string; - channelId: string; - onEvent: (event: string, data: unknown) => void; -}; - -// Runs a single sub-agent task by opening an OpenCode session and forwarding -// the user-provided prompt. Streams events back to the caller (the Grow Agent -// actor's broadcast surface) and updates the actors table on completion. -// -// Sub-agents do NOT spawn their own containers — they multiplex through the -// parent Grow Agent's OpenCode container (PRD §3.3). -export async function runSubAgentTask(input: SubAgentRunInput): Promise { - const { userId, subAgentId, type, prompt, channelId, onEvent } = input; - try { - const target = await opencodeUrlFor(userId); - if (!target) { - throw new Error("OpenCode container not provisioned for user"); - } - const client = new OpencodeClient(target.baseUrl, target.password); - - const session = await client.createSession({ - title: `${type} :: ${subAgentId}`, - }); - await db.insert(opencodeSessions).values({ - id: session.id, - userId, - actorId: subAgentId, - title: session.title ?? null, - }); - - onEvent("sub-agent-event", { - subAgentId, - type: "started", - channelId, - sessionId: session.id, - }); - - // Open SSE stream for live progress. - const aborter = client.streamEvents((ev) => { - onEvent("sub-agent-event", { - subAgentId, - type: "progress", - channelId, - event: ev.event, - data: ev.data, - }); - }); - - // Send the prompt synchronously and capture the final response text. - const result = await client.sendMessage({ - sessionId: session.id, - text: prompt, - }); - aborter.abort(); - - await db - .update(actorsTable) - .set({ status: "done", lastActivityAt: new Date() }) - .where( - and( - eq(actorsTable.userId, userId), - eq(actorsTable.actorId, subAgentId), - ), - ); - - onEvent("sub-agent-event", { - subAgentId, - type: "done", - channelId, - result, - }); - log.info({ subAgentId, sessionId: session.id }, "sub-agent done"); - } catch (err) { - log.error({ err, subAgentId }, "sub-agent failed"); - await db - .update(actorsTable) - .set({ status: "error", lastActivityAt: new Date() }) - .where( - and( - eq(actorsTable.userId, userId), - eq(actorsTable.actorId, subAgentId), - ), - ) - .catch(() => undefined); - onEvent("sub-agent-event", { - subAgentId, - type: "error", - channelId, - message: err instanceof Error ? err.message : String(err), - }); - } -} diff --git a/src/actors/sub-agent.ts b/src/actors/sub-agent.ts deleted file mode 100644 index 1ededb1..0000000 --- a/src/actors/sub-agent.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { actor } from "rivetkit"; -import { db } from "../db/client.js"; -import { actors as actorsTable, events as eventsTable } from "../db/schema.js"; -import { and, eq, desc } from "drizzle-orm"; - -type LogEntry = { - ts: number; - level: "info" | "warn" | "error"; - msg: string; -}; - -type SubAgentState = { - parentUserId: string; - type: string; - status: "idle" | "running" | "done" | "error"; - channelId: string; - logs: LogEntry[]; -}; - -const initialState: SubAgentState = { - parentUserId: "", - type: "generic", - status: "idle", - channelId: "", - logs: [], -}; - -// Sub-agent actor mainly exposes status + logs for the UI. The actual task -// execution lives in sub-agent-runner.ts, invoked by the Grow Agent's tool -// dispatch path (PRD §3.3). -export const subAgent = actor({ - state: initialState, - actions: { - init: async ( - c, - input: { parentUserId: string; type: string; channelId: string }, - ) => { - c.state.parentUserId = input.parentUserId; - c.state.type = input.type; - c.state.channelId = input.channelId; - c.state.status = "idle"; - }, - - appendLog: async (c, entry: LogEntry) => { - c.state.logs.push(entry); - c.broadcast("log", entry); - }, - - setStatus: async (c, status: SubAgentState["status"]) => { - c.state.status = status; - c.broadcast("status", { status }); - }, - - getLogs: async (c) => c.state.logs, - getStatus: async (c) => c.state.status, - - // Pulls historical events from the DB so a returning user sees prior runs. - getHistory: async (c, input: { subAgentId: string }) => { - const rows = await db - .select() - .from(eventsTable) - .where( - and( - eq(eventsTable.userId, c.state.parentUserId), - eq(eventsTable.actorId, input.subAgentId), - ), - ) - .orderBy(desc(eventsTable.createdAt)) - .limit(50); - return rows; - }, - - getActorRow: async (c, input: { subAgentId: string }) => { - const row = await db.query.actors.findFirst({ - where: and( - eq(actorsTable.userId, c.state.parentUserId), - eq(actorsTable.actorId, input.subAgentId), - ), - }); - return row; - }, - }, -}); diff --git a/src/actors/user-actor.ts b/src/actors/user-actor.ts new file mode 100644 index 0000000..b6dac09 --- /dev/null +++ b/src/actors/user-actor.ts @@ -0,0 +1,748 @@ +import { actor } from "rivetkit"; +import { config } from "../config.js"; +import { log } from "../log.js"; +import { + buildUnifiedSystemPrompt, + getSubAgentModule, + jobApplicationModuleIds, + type SubAgentModule, +} from "../agents/catalog.js"; +import { + getSubAgentModules, +} from "../lib/prompt-loader.js"; +import { + runServiceAgentProbe, + type ServiceAgentResult, +} from "../services/service-agents.js"; +import { + provisionUserStack, + getUserStack, + stopUserStack, + giteaClientFor, + opencodeUrlFor, + syncWorkspaceToGit, +} from "../docker/manager.js"; +import { db } from "../db/client.js"; +import { actors as actorsTable, events as eventsTable } from "../db/schema.js"; +import { createChatCompletion, type LlmMessage, type LlmToolCall } from "../lib/llm.js"; + +// ── Types ── + +type ChatTurn = { + role: "user" | "assistant" | "tool"; + content: string; + toolCallId?: string; + toolCalls?: LlmToolCall[]; +}; + +type WorkflowStatus = "draft" | "running" | "paused" | "completed"; +type ModuleStatus = "idle" | "running" | "blocked" | "done"; + +type Scorecard = { + id: string; + question: string; + answer: string; + score: number; + notes?: string; + createdAt: string; +}; + +type WorkflowModuleState = { + id: string; + name: string; + role: string; + service?: string; + status: ModuleStatus; + summary: string; + lastResult?: ServiceAgentResult; + scorecards: Scorecard[]; +}; + +type WorkflowEvent = { + id: string; + ts: string; + moduleId: string; + moduleName: string; + type: "workflow" | "module" | "score"; + message: string; + payload?: unknown; +}; + +type UserActorState = { + userId: string; + goals: string[]; + chatHistory: ChatTurn[]; + maxHistory: number; + + // ── Workflow (was separate workflowJob actor, changes.md §5) ── + workflowId: string; + workflowStatus: WorkflowStatus; + workflowGoal: string; + modules: WorkflowModuleState[]; + timeline: WorkflowEvent[]; + createdAt: string; + updatedAt: string; +}; + +// ── Helpers ── + +const now = () => new Date().toISOString(); +const eventId = () => `evt_${Date.now()}_${Math.random().toString(16).slice(2)}`; + +const MEMORY_REPO_PATH_LIMIT = 1024; + +// ── Unified user agent tools (changes.md §2D: loaded at build time) ── +// Core memory tools + sub-agent capability tools from the catalog. +// Sub-agent tools are NOT separate actors — they are function tools loaded +// into the unified agent's system prompt at build time. + +function buildUnifiedTools(): Array<{ + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +}> { + const coreTools = [ + { + type: "function" as const, + function: { + name: "commit_memory", + description: + "Write or update a file in the user's Git memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" }, + message: { type: "string" }, + }, + required: ["path", "content", "message"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "read_memory", + description: "Read a single file from the user's memory repo.", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "list_memory", + description: "List files at a path prefix in the user's memory repo.", + parameters: { + type: "object", + properties: { pathPrefix: { type: "string" } }, + required: ["pathPrefix"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "start_workflow", + description: "Start a job application workflow with all sub-agent modules.", + parameters: { + type: "object", + properties: { + goal: { type: "string", description: "Job search goal, e.g. 'Land a high-fit product engineering role'" }, + }, + required: ["goal"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "run_workflow_module", + description: "Execute a specific sub-agent module in the workflow (e.g., resume, job-search, job-apply, sara, emily, qscore).", + parameters: { + type: "object", + properties: { + moduleId: { type: "string", description: "Module id: resume, job-search, job-apply, sara, emily, qscore" }, + }, + required: ["moduleId"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "start_interview_session", + description: "Create a real interview practice session via the Sara / interview-service microservice.", + parameters: { + type: "object", + properties: { goal: { type: "string" } }, + required: ["goal"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "start_roleplay_session", + description: "Create a real roleplay practice session via the Emily / roleplay-service microservice.", + parameters: { + type: "object", + properties: { goal: { type: "string" } }, + required: ["goal"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "compute_qscore", + description: "Compute or refresh the user's Q-Score via the Quinn / qscore-service microservice.", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, + }, + ]; + + // Build sub-agent capability tools from the catalog (changes.md §2D). + // Each sub-agent module exposes named tools that the LLM can call directly. + const capabilityTools = getSubAgentModules().flatMap((mod) => + mod.toolNames.map((toolName) => ({ + type: "function" as const, + function: { + name: toolName, + description: `[${mod.name}] ${mod.description}`, + parameters: { + type: "object", + properties: { + goal: { type: "string", description: "The user's current goal or context for this action" }, + detail: { type: "string", description: "Additional detail or instruction for this sub-agent capability" }, + }, + required: ["goal"], + }, + }, + })), + ); + + return [...coreTools, ...capabilityTools]; +} + +// Lazy — prompt modules are loaded from disk at startup (changes.md §3). +// Must be called after initCatalog() has completed. +function getUnifiedTools() { + return buildUnifiedTools(); +} + +// ── Messages helper ── + +function messagesForApi(history: ChatTurn[]): LlmMessage[] { + const systemPrompt = buildUnifiedSystemPrompt(); + const messages: LlmMessage[] = [ + { role: "system", content: systemPrompt }, + ]; + 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" as const, + function: { + name: call.name, + arguments: JSON.stringify(call.arguments), + }, + })), + }); + } + return messages; +} + +// ── Workflow helpers ── + +function makeModules(): WorkflowModuleState[] { + return jobApplicationModuleIds() + .map((id) => getSubAgentModule(id)) + .filter((m): m is SubAgentModule => Boolean(m)) + .map((m) => ({ + id: m.id, + name: m.name, + role: m.role, + service: m.service, + status: "idle" as ModuleStatus, + summary: m.description, + scorecards: [], + })); +} + +function appendTimelineEvent( + state: UserActorState, + module: Pick, + type: WorkflowEvent["type"], + message: string, + payload?: unknown, +) { + const ev: WorkflowEvent = { + id: eventId(), + ts: now(), + moduleId: module.id, + moduleName: module.name, + type, + message, + payload, + }; + state.timeline.unshift(ev); + state.timeline = state.timeline.slice(0, 100); + state.updatedAt = ev.ts; + return ev; +} + +// ── Initial State ── + +const initialState: UserActorState = { + userId: "", + goals: [], + chatHistory: [], + maxHistory: 40, + workflowId: "", + workflowStatus: "draft", + workflowGoal: "", + modules: [], + timeline: [], + createdAt: "", + updatedAt: "", +}; + +// ── THE UNIFIED USER ACTOR (changes.md §5) ── + +export const userActor = actor({ + options: { + actionTimeout: 600_000, + noSleep: true, + }, + state: initialState, + actions: { + // ── Infrastructure ── + + init: async (c, input: { userId: string }) => { + if (c.state.userId && c.state.userId !== input.userId) { + throw new Error("User actor already bound to a different user"); + } + c.state.userId = input.userId; + + const stack = await provisionUserStack(input.userId); + await db + .insert(actorsTable) + .values({ + actorId: `user-${input.userId}`, + userId: input.userId, + kind: "user", + status: "idle", + lastActivityAt: new Date(), + }) + .onConflictDoNothing(); + + c.broadcast("stack-ready", { + userId: input.userId, + opencode: `${stack.opencodeHost}:${stack.opencodePort}`, + giteaRepo: `${stack.giteaRepoOwner ?? "growqr"}/${stack.giteaRepoName ?? "unknown"}`, + versions: { + image: stack.imageVersion, + migration: stack.migrationVersion, + prompt: stack.promptVersion, + }, + }); + return stack; + }, + + shutdown: async (c) => { + if (c.state.userId) await stopUserStack(c.state.userId); + }, + + // ── Chat (was growAgent.receiveMessage) ── + + receiveMessage: async (c, msg: { text: string }) => { + if (!c.state.userId) throw new Error("User actor not initialized"); + + const userTurn: ChatTurn = { role: "user", content: msg.text }; + c.state.chatHistory.push(userTurn); + c.broadcast("message", { role: "user", text: msg.text }); + + if (!config.llmApiKey) { + const reply = "LLM API key not configured."; + c.state.chatHistory.push({ role: "assistant", content: reply }); + c.broadcast("message", { role: "agent", text: reply }); + return { reply }; + } + + c.broadcast("agent-thinking", { state: "running" }); + + let assistantTextOut = ""; + const MAX_ITERATIONS = 8; + for (let i = 0; i < MAX_ITERATIONS; i++) { + const response = await createChatCompletion({ + model: config.agentModel, + maxTokens: config.maxAgentTokens, + tools: getUnifiedTools(), + messages: messagesForApi(c.state.chatHistory), + }); + + if (response.content) { + assistantTextOut += (assistantTextOut ? "\n\n" : "") + response.content; + c.broadcast("message", { role: "agent", text: response.content }); + } + + c.state.chatHistory.push({ + role: "assistant", + content: response.content, + toolCalls: response.toolCalls, + }); + + if (response.toolCalls.length === 0) break; + + for (const call of response.toolCalls) { + try { + const result = await dispatchUnifiedTool(c, call); + c.state.chatHistory.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"); + c.state.chatHistory.push({ + role: "tool", + toolCallId: call.id, + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } + } + + while (c.state.chatHistory.length > c.state.maxHistory) { + c.state.chatHistory.shift(); + } + + c.broadcast("agent-thinking", { state: "idle" }); + + await db + .insert(eventsTable) + .values({ + userId: c.state.userId, + actorId: `user-${c.state.userId}`, + type: "user.message", + payload: { userText: msg.text, assistantText: assistantTextOut }, + }); + + // Auto-commit conversation to Git (changes.md §7: Git is source of truth). + // Write the full exchange to /conversations/ in the user's repo. + c.waitUntil( + (async () => { + try { + const client = await giteaClientFor(c.state.userId); + const stack = await getUserStack(c.state.userId); + if (client && stack?.giteaRepoOwner && stack.giteaRepoName) { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const convoPath = `conversations/${ts}.json`; + await client.putFile({ + owner: stack.giteaRepoOwner, + repo: stack.giteaRepoName, + path: convoPath, + contentUtf8: JSON.stringify( + { timestamp: new Date().toISOString(), user: msg.text, assistant: assistantTextOut }, + null, 2, + ), + message: `conversation: ${msg.text.slice(0, 60)}`, + }); + // Sync workspace so OpenCode sees latest state. + await syncWorkspaceToGit(c.state.userId, `conversation at ${ts}`).catch(() => {}); + } + } catch (err) { + log.warn({ err, userId: c.state.userId }, "auto-commit conversation failed (non-fatal)"); + } + })(), + ); + + return { reply: assistantTextOut || "(no response)" }; + }, + + // ── Workflow (was workflowJob actor, now part of user actor — changes.md §5) ── + + startWorkflow: async (c, input: { goal?: string }) => { + const goal = input.goal ?? "Find and apply to high-fit jobs"; + c.state.workflowId = `job-application:${c.state.userId}`; + c.state.workflowStatus = "running"; + c.state.workflowGoal = goal; + c.state.modules = makeModules(); + c.state.createdAt = now(); + c.state.updatedAt = now(); + + appendTimelineEvent( + c.state, + { id: "grow", name: "Grow Agent" }, + "workflow", + "Job application workflow started.", + ); + c.broadcast("workflow.updated", { + workflowId: c.state.workflowId, + userId: c.state.userId, + status: c.state.workflowStatus, + goal: c.state.workflowGoal, + agents: c.state.modules, + timeline: c.state.timeline, + updatedAt: c.state.updatedAt, + }); + return c.state; + }, + + pauseWorkflow: async (c) => { + c.state.workflowStatus = "paused"; + appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow paused."); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return c.state; + }, + + resumeWorkflow: async (c) => { + c.state.workflowStatus = "running"; + appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow resumed."); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return c.state; + }, + + runWorkflowModule: async (c, input: { moduleId: string }) => { + const mod = c.state.modules.find((m) => m.id === input.moduleId); + if (!mod) throw new Error(`Unknown workflow module: ${input.moduleId}`); + + mod.status = "running"; + appendTimelineEvent(c.state, mod, "module", `${mod.name} started.`); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + const subModule = getSubAgentModule(mod.id); + if (subModule?.service) { + const userId = c.state.userId; + const goal = c.state.workflowGoal; + c.waitUntil( + (async () => { + const result = await runServiceAgentProbe( + { id: subModule.id, name: subModule.name, role: subModule.role, kind: "microservice", description: subModule.description, service: subModule.service }, + { userId, goal }, + ); + mod.lastResult = result; + mod.status = result.status === "unavailable" ? "blocked" : "done"; + appendTimelineEvent(c.state, mod, "module", result.summary, result.detail); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + await c.saveState({ immediate: true }); + })(), + ); + return c.state; + } + + // Local workflow modules + mod.lastResult = { + status: "local", + summary: `${mod.name} completed a local workflow step for "${c.state.workflowGoal}".`, + }; + mod.status = "done"; + appendTimelineEvent(c.state, mod, "module", mod.lastResult.summary); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return c.state; + }, + + recordQaScore: async (c, input: { moduleId: string; question: string; answer: string; score: number; notes?: string }) => { + const mod = c.state.modules.find((m) => m.id === input.moduleId); + if (!mod) throw new Error(`Unknown workflow module: ${input.moduleId}`); + const card: Scorecard = { + 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(), + }; + mod.scorecards.unshift(card); + appendTimelineEvent(c.state, mod, "score", `${mod.name} recorded Q&A score ${card.score}.`, card); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return c.state; + }, + + getWorkflowStatus: async (c) => workflowSnapshot(c.state), + + getHistory: async (c) => c.state.chatHistory, + getGoals: async (c) => c.state.goals, + }, +}); + +// ── Helpers ── + +function workflowSnapshot(state: UserActorState) { + return { + workflowId: state.workflowId, + userId: state.userId, + status: state.workflowStatus, + goal: state.workflowGoal, + agents: state.modules, + timeline: state.timeline, + updatedAt: state.updatedAt, + }; +} + +async function dispatchUnifiedTool( + c: { state: UserActorState; broadcast: (event: string, data: unknown) => void }, + call: LlmToolCall, +): Promise { + const input = call.arguments; + const userId = c.state.userId; + + switch (call.name) { + case "commit_memory": { + const path = String(input.path ?? "").slice(0, MEMORY_REPO_PATH_LIMIT); + const content = String(input.content ?? ""); + const message = String(input.message ?? "memory update"); + const client = await giteaClientFor(userId); + const stack = await getUserStack(userId); + if (!client || !stack?.giteaRepoOwner || !stack.giteaRepoName) { + return { ok: false, error: "memory repo not provisioned" }; + } + const result = await client.putFile({ + owner: stack.giteaRepoOwner, + repo: stack.giteaRepoName, + path, + contentUtf8: content, + message, + }); + c.broadcast("memory-committed", { path, message }); + return { ok: true, path, commitSha: result.commitSha }; + } + + case "read_memory": { + const path = String(input.path ?? ""); + const client = await giteaClientFor(userId); + const stack = await getUserStack(userId); + if (!client || !stack?.giteaRepoOwner || !stack.giteaRepoName) return null; + return client.readFile({ owner: stack.giteaRepoOwner, repo: stack.giteaRepoName, path }); + } + + case "list_memory": { + const pathPrefix = String(input.pathPrefix ?? ""); + const client = await giteaClientFor(userId); + const stack = await getUserStack(userId); + if (!client || !stack?.giteaRepoOwner || !stack.giteaRepoName) return []; + try { + const res = await fetch( + `${config.giteaUrl}/api/v1/repos/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}/contents/${encodeURI(pathPrefix)}`, + { headers: { authorization: `token ${config.giteaAdminToken}`, accept: "application/json" } }, + ); + if (!res.ok) return []; + const entries = (await res.json()) as Array<{ name: string; path: string; type: string }>; + return entries.map((e) => ({ name: e.name, path: e.path, type: e.type })); + } catch { return []; } + } + + case "start_workflow": { + const goal = String(input.goal ?? "Find and apply to high-fit jobs"); + c.state.workflowId = `job-application:${userId}`; + c.state.workflowStatus = "running"; + c.state.workflowGoal = goal; + c.state.modules = makeModules(); + c.state.createdAt = now(); + c.state.updatedAt = now(); + appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow started via LLM tool."); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return { ok: true, workflowId: c.state.workflowId, goal }; + } + + case "run_workflow_module": { + const moduleId = String(input.moduleId ?? ""); + const mod = c.state.modules.find((m) => m.id === moduleId); + if (!mod) return { ok: false, error: `Unknown module: ${moduleId}` }; + mod.status = "running"; + appendTimelineEvent(c.state, mod, "module", `${mod.name} started via LLM tool.`); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + const subModule = getSubAgentModule(mod.id); + if (subModule?.service) { + const result = await runServiceAgentProbe( + { id: subModule.id, name: subModule.name, role: subModule.role, kind: "microservice", description: subModule.description, service: subModule.service }, + { userId, goal: c.state.workflowGoal }, + ); + mod.lastResult = result; + mod.status = result.status === "unavailable" ? "blocked" : "done"; + appendTimelineEvent(c.state, mod, "module", result.summary, result.detail); + } else { + mod.lastResult = { status: "local", summary: `${mod.name} completed a local workflow step.` }; + mod.status = "done"; + appendTimelineEvent(c.state, mod, "module", mod.lastResult.summary); + } + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return { ok: true, moduleId, status: mod.status }; + } + + case "start_interview_session": { + const goal = String(input.goal ?? ""); + const saraModule = getSubAgentModule("sara"); + if (!saraModule?.service) return { ok: false, error: "Sara module not available" }; + const result = await runServiceAgentProbe( + { id: saraModule.id, name: saraModule.name, role: saraModule.role, kind: "microservice", description: saraModule.description, service: saraModule.service }, + { userId, goal }, + ); + c.broadcast("service-result", { moduleId: "sara", result }); + return result; + } + + case "start_roleplay_session": { + const goal = String(input.goal ?? ""); + const emilyModule = getSubAgentModule("emily"); + if (!emilyModule?.service) return { ok: false, error: "Emily module not available" }; + const result = await runServiceAgentProbe( + { id: emilyModule.id, name: emilyModule.name, role: emilyModule.role, kind: "microservice", description: emilyModule.description, service: emilyModule.service }, + { userId, goal }, + ); + c.broadcast("service-result", { moduleId: "emily", result }); + return result; + } + + case "compute_qscore": { + const quinnModule = getSubAgentModule("qscore"); + if (!quinnModule?.service) return { ok: false, error: "Quinn module not available" }; + const result = await runServiceAgentProbe( + { id: quinnModule.id, name: quinnModule.name, role: quinnModule.role, kind: "score", description: quinnModule.description, service: quinnModule.service }, + { userId, goal: c.state.workflowGoal || "general assessment" }, + ); + c.broadcast("service-result", { moduleId: "qscore", result }); + return result; + } + + default: { + // Check if this is a sub-agent capability tool from the catalog (changes.md §2D). + // These tools are loaded at build time — each sub-agent module defines its own tool names. + const owningModule = getSubAgentModules().find((m) => m.toolNames.includes(call.name)); + if (owningModule) { + const goal = String(input.goal ?? c.state.workflowGoal ?? "general task"); + const detail = String(input.detail ?? ""); + log.info({ tool: call.name, moduleId: owningModule.id, goal }, "sub-agent capability tool invoked"); + return { + ok: true, + moduleId: owningModule.id, + tool: call.name, + summary: `${owningModule.name} processed "${goal}"${detail ? ` with detail: "${detail}"` : ""} via the ${call.name} capability.`, + }; + } + throw new Error(`unknown tool: ${call.name}`); + } + } +} diff --git a/src/actors/workflow-job.ts b/src/actors/workflow-job.ts deleted file mode 100644 index 90c7dab..0000000 --- a/src/actors/workflow-job.ts +++ /dev/null @@ -1,292 +0,0 @@ -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 index 350df5b..b3aef28 100644 --- a/src/agents/catalog.ts +++ b/src/agents/catalog.ts @@ -1,94 +1,51 @@ -export type AgentKind = - | "master" - | "workflow" - | "microservice" - | "score"; +// ── Sub-agent prompt module catalog (changes.md §2D + §3) ── +// Sub-agents are NOT separate actors. They are prompt modules loaded into +// the unified user agent's system prompt. +// +// Per changes.md §3: prompts and agent definitions are stored as files on disk +// (prompts/system.txt, agents/*.md), loaded at startup, and embedded into the +// Docker image at build time via COPY directives. +// +// This module delegates to src/lib/prompt-loader.ts which reads from the +// filesystem. To update prompts or agents, edit the files and rebuild the +// Docker image — no code changes required. -export type AgentProfile = { - id: string; - name: string; - role: string; - kind: AgentKind; - description: string; - service?: "interview-service" | "roleplay-service" | "qscore-service"; -}; +import { + getSubAgentModules, + getUnifiedSystemPrompt, + getSubAgentModule as loaderGetSubAgentModule, + jobApplicationModuleIds as loaderJobApplicationModuleIds, + type SubAgentModule, +} from "../lib/prompt-loader.js"; -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 { SubAgentModule }; +export type SubAgentId = string; -export type AgentId = (typeof agentCatalog)[number]["id"]; +// Re-exported — subAgentModules is now loaded from disk at startup. +// Callers that need the module list at runtime (e.g., user-actor.ts to +// register tools) should use getSubAgentModules() from prompt-loader directly. +export const subAgentModules: SubAgentModule[] = []; -export function getAgentProfile(id: string): AgentProfile | undefined { - return agentCatalog.find((agent) => agent.id === id); +// Initialize from disk. Called once at startup by index.ts. +// After this call, subAgentModules is populated and all functions work. +export async function initCatalog(): Promise { + const { loadPromptsFromDisk } = await import("../lib/prompt-loader.js"); + await loadPromptsFromDisk(); + // Mutate the exported array so existing imports keep working. + const loaded = getSubAgentModules(); + subAgentModules.length = 0; + subAgentModules.push(...loaded); } -export function jobApplicationAgentIds(): AgentId[] { - return [ - "grow", - "resume", - "job-search", - "job-apply", - "sara", - "emily", - "qscore", - ]; +export function getSubAgentModule(id: string): SubAgentModule | undefined { + return loaderGetSubAgentModule(id); +} + +export function jobApplicationModuleIds(): string[] { + return loaderJobApplicationModuleIds(); +} + +// Build the unified Grow Agent system prompt from disk (changes.md §3). +export function buildUnifiedSystemPrompt(): string { + return getUnifiedSystemPrompt(); } diff --git a/src/config.ts b/src/config.ts index a1fb960..957ad62 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,7 +16,7 @@ export const config = { // Postgres metadata DB (users, registry, container mappings). databaseUrl: process.env.DATABASE_URL ?? - "postgres://growqr:growqr@localhost:5432/growqr", + "***************************************/growqr", // Clerk auth. clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "", @@ -25,7 +25,7 @@ export const config = { serviceToken: process.env.SERVICE_TOKEN ?? "", a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key", - // LLM gateway for Grow Agent + sub-agent planning calls. + // LLM gateway for the unified user agent. llmProvider: process.env.LLM_PROVIDER ?? "opencode", llmApiKey: process.env.LLM_API_KEY ?? @@ -36,14 +36,10 @@ export const config = { process.env.OPENCODE_BASE_URL ?? "https://opencode.ai/zen/v1", opencodeApiKey: process.env.OPENCODE_API_KEY ?? "", - growAgentModel: + agentModel: process.env.GROW_AGENT_MODEL ?? process.env.LLM_MODEL ?? "kimi-k2.6", - subAgentModel: - 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", @@ -59,14 +55,25 @@ export const config = { qscoreServiceUrl: process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000", - // Per-user container images. - giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22", + // ── Central Gitea (one org-wide instance, changes.md §2A) ── + giteaUrl: process.env.GITEA_URL ?? "http://127.0.0.1:3001", + giteaAdminUser: process.env.GITEA_ADMIN_USER ?? "growqr-admin", + giteaAdminPassword: process.env.GITEA_ADMIN_PASSWORD ?? "growqr-admin-dev", + giteaAdminToken: process.env.GITEA_ADMIN_TOKEN ?? "", + giteaOrgName: process.env.GITEA_ORG_NAME ?? "growqr", + + // ── Shared OpenCode runtime image (built once, changes.md §3) ── opencodeImage: process.env.OPENCODE_IMAGE ?? "ghcr.io/anomalyco/opencode:latest", + // Version tracking for rollout (changes.md §9) + opencodeImageVersion: process.env.OPENCODE_IMAGE_VERSION ?? "1.0.0", + migrationVersion: process.env.MIGRATION_VERSION ?? "1", + promptVersion: process.env.PROMPT_VERSION ?? "1", // Host that user containers expose ports on (the host running Docker). userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1", userDataRoot: process.env.USER_DATA_ROOT ?? "./.data/users", + // Port range for per-user OpenCode containers only (Gitea is shared). userPortRangeStart: Number(process.env.USER_PORT_RANGE_START ?? 20000), userPortRangeEnd: Number(process.env.USER_PORT_RANGE_END ?? 29999), diff --git a/src/db/schema.ts b/src/db/schema.ts index 1d13b7b..fa245ed 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -30,8 +30,9 @@ export const users = pgTable( }), ); -// One per user. Tracks the user's Grow Agent's container stack + Gitea creds. -// PRD §3.2 + §5.2. +// One per user. Tracks the user's unified agent's container stack + Git repo. +// Per changes.md §2A: per-user Gitea containers removed; central Gitea shared. +// Per changes.md §5: ONE actor per user manages the full orchestration layer. export const userStacks = pgTable( "user_stacks", { @@ -44,15 +45,11 @@ export const userStacks = pgTable( .notNull() .default("provisioning"), - giteaContainerId: text("gitea_container_id"), - giteaContainerName: text("gitea_container_name"), - giteaHost: text("gitea_host"), - giteaHttpPort: integer("gitea_http_port"), - giteaSshPort: integer("gitea_ssh_port"), - giteaAdminUser: text("gitea_admin_user"), - giteaAdminToken: text("gitea_admin_token"), - giteaMemoryRepo: text("gitea_memory_repo"), + // Central Gitea (shared org-wide, changes.md §2A). + giteaRepoName: text("gitea_repo_name"), + giteaRepoOwner: text("gitea_repo_owner"), + // Per-user OpenCode container (from shared image, changes.md §3). opencodeContainerId: text("opencode_container_id"), opencodeContainerName: text("opencode_container_name"), opencodeHost: text("opencode_host"), @@ -60,6 +57,12 @@ export const userStacks = pgTable( opencodePassword: text("opencode_password"), workspacePath: text("workspace_path"), + + // Version tracking for image rollouts (changes.md §9). + imageVersion: text("image_version"), + migrationVersion: text("migration_version"), + promptVersion: text("prompt_version"), + lastError: text("last_error"), createdAt: timestamp("created_at", { withTimezone: true }) @@ -74,8 +77,8 @@ export const userStacks = pgTable( }), ); -// PRD §5.2 actor registry. One Grow Agent row per user; sub-agents are -// child rows keyed by (userId, actorId). +// Per changes.md §5: ONE unified actor per user (no separate grow/sub actors). +// The actor manages: infra state, git state, runtime comms, migrations, API orchestration. export const actors = pgTable( "actors", { @@ -83,15 +86,12 @@ export const actors = pgTable( userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), - kind: text("kind", { enum: ["grow", "sub"] }).notNull(), - subType: text("sub_type"), // for sub-agents: "coding", "repo", "quest", ... + kind: text("kind", { enum: ["user"] }).notNull().default("user"), status: text("status", { enum: ["idle", "running", "done", "error"], }) .notNull() .default("idle"), - channelId: text("channel_id"), - parentActorId: text("parent_actor_id"), lastActivityAt: timestamp("last_activity_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() diff --git a/src/docker/manager.ts b/src/docker/manager.ts index b9065c0..8734a52 100644 --- a/src/docker/manager.ts +++ b/src/docker/manager.ts @@ -14,24 +14,17 @@ export type { UserStack }; const docker = new Docker(); -// Allocated host ports kept in-memory; rehydrated from the DB on boot so -// we don't double-allocate across restarts. +// ── Port allocator (OpenCode containers only; Gitea is central) ── const allocatedPorts = new Set(); export async function hydratePortAllocator(): Promise { const rows = await db - .select({ - giteaHttp: userStacks.giteaHttpPort, - giteaSsh: userStacks.giteaSshPort, - opencode: userStacks.opencodePort, - }) + .select({ opencode: userStacks.opencodePort }) .from(userStacks); for (const r of rows) { - for (const p of [r.giteaHttp, r.giteaSsh, r.opencode]) { - if (p) allocatedPorts.add(p); - } + if (r.opencode) allocatedPorts.add(r.opencode); } - log.info({ count: allocatedPorts.size }, "hydrated port allocator"); + log.info({ count: allocatedPorts.size }, "hydrated port allocator (OpenCode only)"); } function pickPort(): number { @@ -48,6 +41,8 @@ function releasePort(port: number | null | undefined) { if (port != null) allocatedPorts.delete(port); } +// ── Image helpers ── + async function ensureImage(image: string) { try { await docker.getImage(image).inspect(); @@ -71,7 +66,6 @@ function userDataDir(userId: string) { } function safeContainerName(prefix: string, userId: string) { - // Container names must match [a-zA-Z0-9_.-] return `${prefix}-${userId.replace(/[^a-zA-Z0-9_.-]/g, "_")}`; } @@ -83,77 +77,97 @@ async function findExistingContainer(name: string) { return list[0]; } -async function startGiteaContainer(opts: { - userId: string; - httpPort: number; - sshPort: number; -}): Promise<{ id: string; name: string }> { - await ensureImage(config.giteaImage); - const name = safeContainerName("growqr-gitea", opts.userId); - const dataDir = path.join(userDataDir(opts.userId), "gitea"); - await ensureDir(dataDir); +// ── Central Gitea bootstrap (changes.md §2A) ── - const existing = await findExistingContainer(name); - if (existing) { - if (existing.State !== "running") { - await docker.getContainer(existing.Id).start().catch(() => undefined); +let centralGiteaClient: GiteaClient | null = null; +let centralGiteaReady = false; + +async function getCentralGiteaClient(): Promise { + if (!centralGiteaClient) { + const token = config.giteaAdminToken; + if (token) { + centralGiteaClient = new GiteaClient(config.giteaUrl, { kind: "token", token }); + } else { + centralGiteaClient = new GiteaClient(config.giteaUrl, { + kind: "basic", + username: config.giteaAdminUser, + password: config.giteaAdminPassword, + }); } - return { id: existing.Id, name }; + } + return centralGiteaClient; +} + +export async function ensureCentralGiteaReady(): Promise { + if (centralGiteaReady) return; + await waitForGitea(config.giteaUrl, 120_000); + const client = await getCentralGiteaClient(); + + // Ensure the org exists (changes.md §2A: single org manages all users). + try { + await client.ensureOrg(config.giteaOrgName); + } catch (err) { + log.warn({ err }, "central Gitea org ensure failed (may already exist)"); } - const container = await docker.createContainer({ - name, - Image: config.giteaImage, - Env: [ - "USER_UID=1000", - "USER_GID=1000", - `GITEA__server__ROOT_URL=http://${config.userContainerHost}:${opts.httpPort}/`, - `GITEA__server__SSH_PORT=${opts.sshPort}`, - "GITEA__security__INSTALL_LOCK=true", - "GITEA__service__DISABLE_REGISTRATION=true", - ], - HostConfig: { - Binds: [`${dataDir}:/data`], - PortBindings: { - "3000/tcp": [{ HostPort: String(opts.httpPort) }], - "22/tcp": [{ HostPort: String(opts.sshPort) }], - }, - RestartPolicy: { Name: "unless-stopped" }, - Memory: 1 * 1024 * 1024 * 1024, - NanoCpus: 1_000_000_000, - }, - ExposedPorts: { "3000/tcp": {}, "22/tcp": {} }, - Labels: { - "growqr.userId": opts.userId, - "growqr.role": "gitea", - }, - }); - await container.start(); - log.info({ userId: opts.userId, name }, "started Gitea container"); - return { id: container.id, name }; + centralGiteaReady = true; + log.info({ url: config.giteaUrl, org: config.giteaOrgName }, "central Gitea ready"); } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; -} +// ── Git clone into OpenCode workspace (changes.md §4 step 3) ── +// Clones the user's repo from central Gitea into the container's /workspace. +// If /workspace already has a .git folder, pulls instead of cloning. +async function cloneRepoIntoContainer(opts: { + containerId: string; + repoUrl: string; + giteaToken?: string; + giteaUser?: string; + giteaPassword?: string; +}): Promise { + const container = docker.getContainer(opts.containerId); -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], + // Build authenticated clone URL. + let authUrl = opts.repoUrl; + if (opts.giteaToken) { + // Embed token in URL: https://token@host/org/repo.git + authUrl = opts.repoUrl.replace("://", `://${encodeURIComponent(opts.giteaToken)}@`); + } else if (opts.giteaUser && opts.giteaPassword) { + authUrl = opts.repoUrl.replace("://", `://${encodeURIComponent(opts.giteaUser)}:${encodeURIComponent(opts.giteaPassword)}@`); + } + + // Check if workspace is already a git repo; if so, pull instead of clone. + const checkExec = await container.exec({ + Cmd: ["sh", "-c", "test -d /workspace/.git && echo 'exists' || echo 'missing'"], AttachStdout: true, AttachStderr: true, - WorkingDir: "/data/gitea", + }); + const checkStream = await checkExec.start({ Detach: false, Tty: false }); + const checkChunks: Buffer[] = []; + checkStream.on("data", (chunk: Buffer) => checkChunks.push(Buffer.from(chunk))); + await new Promise((resolve) => { + checkStream.on("end", () => resolve()); + checkStream.on("close", () => resolve()); + }); + const checkOutput = Buffer.concat(checkChunks).toString("utf8").trim(); + + let cmd: string[]; + if (checkOutput.includes("exists")) { + // Pull latest changes. + cmd = ["sh", "-c", "cd /workspace && git pull origin main 2>&1 || echo 'pull failed, attempting fresh clone'"]; + } else { + // Clone into /workspace (remove any placeholder files first, then clone). + cmd = [ + "sh", + "-c", + `rm -rf /workspace/* /workspace/.* 2>/dev/null; git clone --branch main "${authUrl}" /workspace 2>&1 || echo 'clone failed'`, + ]; + } + + const exec = await container.exec({ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + WorkingDir: "/workspace", }); const stream = await exec.start({ Detach: false, Tty: false }); const chunks: Buffer[] = []; @@ -165,62 +179,64 @@ async function execGiteaCli(containerId: string, args: string[]): Promise { +// ── Git workspace sync (changes.md §2B: "Sync to Git") ── +// Commits and pushes changes from the container's /workspace back to the +// central Gitea repo, ensuring work done inside OpenCode is persisted as +// Git history. Called after significant events (workflows, code generation). +export async function syncWorkspaceToGit(userId: string, message?: string): Promise { + const stack = await getUserStack(userId); + if (!stack?.opencodeContainerId || !stack.giteaRepoOwner || !stack.giteaRepoName) { + log.warn({ userId }, "cannot sync workspace — stack not provisioned"); + return; + } + + const container = docker.getContainer(stack.opencodeContainerId); + const commitMsg = message ?? `growqr: workspace sync at ${new Date().toISOString()}`; + + // Build authenticated remote URL for push. + let authUrl = `${config.giteaUrl}/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}.git`; + if (config.giteaAdminToken) { + authUrl = authUrl.replace("://", `://${encodeURIComponent(config.giteaAdminToken)}@`); + } else { + authUrl = authUrl.replace("://", `://${encodeURIComponent(config.giteaAdminUser)}:${encodeURIComponent(config.giteaAdminPassword)}@`); + } + + // Set the remote URL with auth, add all, commit, push. + const cmd = [ + "sh", "-c", + `git remote set-url origin "${authUrl}" 2>/dev/null; ` + + `git config user.email "growqr@local" && git config user.name "GrowQR"; ` + + `git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" 2>/dev/null; ` + + `git push origin main 2>&1`, + ]; + try { - await execGiteaCli(opts.containerId, [ - "admin", - "user", - "create", - "--admin", - "--username", - opts.username, - "--password", - opts.password, - "--email", - opts.email, - "--must-change-password=false", - ]); + const exec = await container.exec({ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + WorkingDir: "/workspace", + }); + 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"); + log.info({ userId, output: output.slice(0, 200) }, "workspace synced to Git"); } catch (err) { - log.debug( - { err }, - "gitea admin user create returned non-zero (likely already exists)", - ); + log.warn({ err, userId }, "workspace sync to Git failed (non-fatal)"); } } -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; -} +// ── Per-user OpenCode container (changes.md §2B + §3) ── async function startOpencodeContainer(opts: { userId: string; @@ -240,16 +256,17 @@ async function startOpencodeContainer(opts: { return { id: existing.Id, name }; } + // Sub-agents are loaded as prompt modules at build time (changes.md §2D). + // The shared image includes: base OS, OpenCode, GrowQR core, agents, tools, prompts. const container = await docker.createContainer({ name, Image: config.opencodeImage, - // OpenCode server CLI: `opencode serve --port 4096 --hostname 0.0.0.0`. - // We override the default CMD to make sure it binds to all interfaces - // and uses the per-user password. Cmd: ["serve", "--port", "4096", "--hostname", "0.0.0.0"], Env: [ `OPENCODE_SERVER_PASSWORD=${opts.password}`, `OPENCODE_WORKSPACE=/workspace`, + `GROWQR_IMAGE_VERSION=${config.opencodeImageVersion}`, + `GROWQR_PROMPT_VERSION=${config.promptVersion}`, ], WorkingDir: "/workspace", HostConfig: { @@ -265,6 +282,8 @@ async function startOpencodeContainer(opts: { Labels: { "growqr.userId": opts.userId, "growqr.role": "opencode", + "growqr.imageVersion": config.opencodeImageVersion, + "growqr.promptVersion": config.promptVersion, }, }); await container.start(); @@ -272,18 +291,27 @@ async function startOpencodeContainer(opts: { return { id: container.id, name }; } -// Provisions the per-user stack. Idempotent: returns the existing stack if -// the user already has one in the DB and the containers are running. +// ── User provisioning (changes.md §4) ── + +function repoNameFor(userId: string): string { + return `user-${userId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 48).toLowerCase()}`; +} + +function userIdToGiteaUsername(userId: string): string { + return `gq_${userId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24).toLowerCase() || "user"}`; +} + +// Provisions the per-user stack. Uses CENTRAL Gitea (changes.md §2A) instead +// of spawning per-user Gitea containers. // -// Steps: -// 1. Pick ports + allocate. -// 2. Start Gitea + OpenCode containers (or reuse). -// 3. Wait for Gitea HTTP to come up. -// 4. Create the per-user Gitea admin via `gitea admin user create`. -// 5. Mint a long-lived access token for the admin. -// 6. Create the user's memory repo with auto_init. -// 7. Wait for OpenCode to come up. -// 8. Persist everything to user_stacks. +// Steps (changes.md §4): +// 1. Ensure central Gitea is reachable + org exists. +// 2. Pick port for per-user OpenCode container. +// 3. Start OpenCode container (from shared image, changes.md §3). +// 4. Create the user's repo in the central Gitea org (changes.md §2A). +// 5. Initialize repo structure (memory/, conversations/, state/, etc. — changes.md §11). +// 6. Wait for OpenCode readiness. +// 7. Persist everything to user_stacks with version tracking (changes.md §9). export async function provisionUserStack(userId: string): Promise { const existing = await db.query.userStacks.findFirst({ where: eq(userStacks.userId, userId), @@ -293,16 +321,13 @@ export async function provisionUserStack(userId: string): Promise { } await ensureDir(userDataDir(userId)); + await ensureCentralGiteaReady(); - const giteaHttpPort = existing?.giteaHttpPort ?? pickPort(); - const giteaSshPort = existing?.giteaSshPort ?? pickPort(); const opencodePort = existing?.opencodePort ?? pickPort(); const opencodePassword = existing?.opencodePassword ?? randomBytes(24).toString("hex"); - const adminUsername = - existing?.giteaAdminUser ?? `gq_${userId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24).toLowerCase() || "user"}`; - const adminPassword = randomBytes(24).toString("hex"); - const adminEmail = `${adminUsername}@growqr.local`; + const repoName = existing?.giteaRepoName ?? repoNameFor(userId); + const repoOwner = config.giteaOrgName; // Upsert "provisioning" row first so a crash mid-way leaves a recoverable record. await db @@ -310,14 +335,15 @@ export async function provisionUserStack(userId: string): Promise { .values({ userId, status: "provisioning", - giteaHttpPort, - giteaSshPort, opencodePort, opencodePassword, - giteaAdminUser: adminUsername, - giteaHost: config.userContainerHost, + giteaRepoName: repoName, + giteaRepoOwner: repoOwner, opencodeHost: config.userContainerHost, workspacePath: userDataDir(userId), + imageVersion: config.opencodeImageVersion, + migrationVersion: config.migrationVersion, + promptVersion: config.promptVersion, }) .onConflictDoUpdate({ target: userStacks.userId, @@ -329,59 +355,86 @@ export async function provisionUserStack(userId: string): Promise { }); try { - const gitea = await startGiteaContainer({ - userId, - httpPort: giteaHttpPort, - sshPort: giteaSshPort, - }); + // Start per-user OpenCode container (shared image, changes.md §3). const opencode = await startOpencodeContainer({ userId, httpPort: opencodePort, password: opencodePassword, }); - const giteaBase = `http://${config.userContainerHost}:${giteaHttpPort}`; - await waitForGitea(giteaBase, 90_000); - - // Bootstrap admin user (idempotent — the CLI returns non-zero if exists). - await ensureGiteaAdmin({ - containerId: gitea.id, - username: adminUsername, - password: adminPassword, - email: adminEmail, - }); - - // 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, - scopes: ["write:repository", "write:user", "write:issue"], - }); - - // Use the token from here on. - const tokenClient = new GiteaClient(giteaBase, { kind: "token", token }); - const memoryRepo = await tokenClient.ensureRepo({ - name: "growqr-memory", - description: "Grow Agent memory + state (PRD §3.4)", + // Create the user's repo in the central Gitea org (changes.md §2A + §4 step 2). + const giteaClient = await getCentralGiteaClient(); + const repo = await giteaClient.ensureOrgRepo({ + org: repoOwner, + name: repoName, + description: `GrowQR memory + workspace for user ${userId}`, autoInit: true, private: true, }); + // Initialize the standard repo structure (changes.md §11). + const initFiles: Array<{ path: string; content: string }> = [ + { path: "memory/.gitkeep", content: "# Agent memory\n" }, + { path: "conversations/.gitkeep", content: "# Conversation history\n" }, + { path: "state/.gitkeep", content: "# Agent state\n" }, + { path: "artifacts/.gitkeep", content: "# Generated artifacts\n" }, + { path: "workflows/.gitkeep", content: "# Workflow definitions\n" }, + { path: "logs/.gitkeep", content: "# Runtime logs\n" }, + { path: "config/.gitkeep", content: "# User configuration\n" }, + { path: "metadata/versions.json", content: JSON.stringify({ + imageVersion: config.opencodeImageVersion, + migrationVersion: config.migrationVersion, + promptVersion: config.promptVersion, + provisionedAt: new Date().toISOString(), + }, null, 2) + "\n" }, + ]; + + for (const file of initFiles) { + try { + await giteaClient.putFile({ + owner: repoOwner, + repo: repoName, + path: file.path, + contentUtf8: file.content, + message: `init: ${file.path}`, + branch: "main", + }); + } catch (err) { + log.warn({ err, path: file.path }, "failed to init repo file (non-fatal)"); + } + } + // OpenCode readiness. const opencodeBase = `http://${config.userContainerHost}:${opencodePort}`; await waitForOpencode(opencodeBase, opencodePassword, 90_000); + // Clone the user's Git repo into the OpenCode workspace (changes.md §4 step 3). + // Uses `git clone` inside the container so the workspace is a working copy + // of the user's repo, making Git the source of truth (changes.md §7). + try { + await cloneRepoIntoContainer({ + containerId: opencode.id, + repoUrl: `${config.giteaUrl}/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}.git`, + giteaToken: config.giteaAdminToken || undefined, + giteaUser: config.giteaAdminUser, + giteaPassword: !config.giteaAdminToken ? config.giteaAdminPassword : undefined, + }); + log.info({ userId, repo: `${repoOwner}/${repoName}` }, "repo cloned into OpenCode workspace"); + } catch (err) { + log.warn({ err, userId }, "git clone into workspace failed (non-fatal — workspace still available via Gitea API)"); + } + const updated = await db .update(userStacks) .set({ status: "running", - giteaContainerId: gitea.id, - giteaContainerName: gitea.name, - giteaAdminToken: token, - giteaMemoryRepo: `${memoryRepo.owner}/${memoryRepo.name}`, + giteaRepoName: repo.name, + giteaRepoOwner: repo.owner, opencodeContainerId: opencode.id, opencodeContainerName: opencode.name, + imageVersion: config.opencodeImageVersion, + migrationVersion: config.migrationVersion, + promptVersion: config.promptVersion, lastError: null, updatedAt: new Date(), }) @@ -390,7 +443,7 @@ export async function provisionUserStack(userId: string): Promise { const row = updated[0]; if (!row) throw new Error("user stack row vanished mid-provision"); - log.info({ userId }, "user stack provisioned"); + log.info({ userId, repo: `${repo.owner}/${repo.name}` }, "user stack provisioned"); return row; } catch (err) { log.error({ err, userId }, "provisionUserStack failed"); @@ -416,24 +469,23 @@ export async function getUserStack(userId: string): Promise { export async function stopUserStack(userId: string): Promise { const stack = await getUserStack(userId); if (!stack) return; - for (const id of [stack.giteaContainerId, stack.opencodeContainerId]) { - if (!id) continue; + + // Stop only the OpenCode container (Gitea is central, changes.md §2A). + if (stack.opencodeContainerId) { try { - const c = docker.getContainer(id); + const c = docker.getContainer(stack.opencodeContainerId); await c.stop({ t: 5 }).catch(() => undefined); await c.remove({ force: true }).catch(() => undefined); } catch (err) { - log.warn({ err, id }, "failed to stop container"); + log.warn({ err, id: stack.opencodeContainerId }, "failed to stop container"); } } - releasePort(stack.giteaHttpPort); - releasePort(stack.giteaSshPort); + releasePort(stack.opencodePort); await db .update(userStacks) .set({ status: "stopped", - giteaContainerId: null, opencodeContainerId: null, updatedAt: new Date(), }) @@ -445,19 +497,19 @@ export async function listStacks(): Promise { return db.query.userStacks.findMany(); } -// Convenience: build a Gitea client for a user's stack. -export async function giteaClientFor(userId: string): Promise { - const stack = await getUserStack(userId); - if (!stack?.giteaAdminToken || !stack.giteaHost || !stack.giteaHttpPort) { +// ── Client helpers ── + +// Build a Gitea client pointed at the CENTRAL Gitea instance (changes.md §2A). +// Uses the admin token for repo operations on behalf of any user. +export async function giteaClientFor(_userId: string): Promise { + try { + return await getCentralGiteaClient(); + } catch { return null; } - return new GiteaClient( - `http://${stack.giteaHost}:${stack.giteaHttpPort}`, - { kind: "token", token: stack.giteaAdminToken }, - ); } -// Convenience: build an OpenCode client for a user's stack. +// Build an OpenCode client for a user's stack. export async function opencodeUrlFor( userId: string, ): Promise<{ baseUrl: string; password: string | undefined } | null> { @@ -469,36 +521,61 @@ export async function opencodeUrlFor( }; } -// Reconcile DB-tracked running containers with actual Docker state on boot. -// If a container is gone, flip the row to "stopped" so the next provision -// recreates it cleanly. +// ── Boot reconciliation (changes.md §9) ── + +// Reconcile DB-tracked running stacks with actual Docker state on boot. +// Only checks OpenCode containers (Gitea is central, changes.md §2A). +// If a container is gone, flip the row to "stopped" so the next provision recreates it. +// +// Also detects version mismatches for image rollout (changes.md §9): +// if the running container's imageVersion is behind, mark for migration. export async function reconcileOnBoot(): Promise { const rows = await db .select() .from(userStacks) .where( - and(eq(userStacks.status, "running"), isNotNull(userStacks.giteaContainerId)), + and(eq(userStacks.status, "running"), isNotNull(userStacks.opencodeContainerId)), ); + for (const row of rows) { + if (!row.opencodeContainerId) continue; + let healthy = true; - for (const id of [row.giteaContainerId, row.opencodeContainerId]) { - if (!id) { - healthy = false; - break; - } - try { - const info = await docker.getContainer(id).inspect(); - if (!info.State.Running) healthy = false; - } catch { - healthy = false; - } + try { + const info = await docker.getContainer(row.opencodeContainerId).inspect(); + if (!info.State.Running) healthy = false; + } catch { + healthy = false; } + if (!healthy) { await db .update(userStacks) .set({ status: "stopped", updatedAt: new Date() }) .where(eq(userStacks.userId, row.userId)); log.info({ userId: row.userId }, "stack marked stopped during reconcile"); + continue; + } + + // Version mismatch detection (changes.md §9). + const needsMigration = + row.imageVersion !== config.opencodeImageVersion || + row.migrationVersion !== config.migrationVersion; + const needsPromptUpdate = row.promptVersion !== config.promptVersion; + + if (needsMigration || needsPromptUpdate) { + log.info( + { + userId: row.userId, + currentImage: row.imageVersion, + targetImage: config.opencodeImageVersion, + currentMigration: row.migrationVersion, + targetMigration: config.migrationVersion, + currentPrompt: row.promptVersion, + targetPrompt: config.promptVersion, + }, + "version mismatch detected — migration needed on next provision", + ); } } } diff --git a/src/index.ts b/src/index.ts index d5ac02a..1888d82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,12 +12,25 @@ 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"; +import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js"; +import { initCatalog } from "./agents/catalog.js"; async function main() { - // Boot-time DB sanity + reconcile. + // Boot-time DB sanity + reconcile + central Gitea readiness. await db.execute("select 1"); await hydratePortAllocator(); + + // Ensure central Gitea is reachable before accepting traffic (changes.md §2A). + try { + await ensureCentralGiteaReady(); + } catch (err) { + log.warn({ err }, "central Gitea not ready at boot — will retry on first provision"); + } + + // Load prompts & agent modules from disk (changes.md §3: prompts/ + agents/). + // After this, buildUnifiedSystemPrompt() returns the full assembled prompt. + await initCatalog(); + await reconcileOnBoot(); const app = new Hono(); @@ -58,7 +71,7 @@ async function main() { // Rivet Kit actor traffic (frontend uses @rivetkit/react against this prefix). app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); - // PRD HTTP control plane (auth-gated). + // HTTP control plane (auth-gated). app.route("/users", userRoutes()); app.route("/agents", agentRoutes()); app.route("/workflows", workflowRoutes()); @@ -76,6 +89,7 @@ async function main() { { port: info.port, rivet: config.rivetEndpoint, + gitea: config.giteaUrl, env: config.nodeEnv, }, "growqr-backend listening", diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index a36aa66..2c1dac2 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -132,6 +132,64 @@ export class GiteaClient { } } + // ── Central Gitea org methods (changes.md §2A) ── + + // Ensure an organization exists. Idempotent. + async ensureOrg(orgName: string): Promise { + try { + await this.req("POST", "/api/v1/orgs", { + username: orgName, + full_name: orgName, + description: "GrowQR organization — one repo per user", + }); + } catch (err) { + log.debug({ err }, "ensureOrg returned non-2xx (likely already exists)"); + } + } + + // Create a repo inside an org. Idempotent (falls back to GET on 409). + async ensureOrgRepo(opts: { + org: string; + name: string; + description?: string; + autoInit?: boolean; + private?: boolean; + }): Promise<{ owner: string; name: string; cloneUrl: string }> { + try { + const repo = await this.req<{ + owner: { login: string }; + name: string; + clone_url: string; + }>("POST", `/api/v1/orgs/${encodeURIComponent(opts.org)}/repos`, { + name: opts.name, + description: opts.description ?? "", + auto_init: opts.autoInit ?? true, + private: opts.private ?? true, + default_branch: "main", + }); + return { + owner: repo.owner.login, + name: repo.name, + cloneUrl: repo.clone_url, + }; + } catch (err) { + const repo = await this.req<{ + owner: { login: string }; + name: string; + clone_url: string; + }>( + "GET", + `/api/v1/repos/${encodeURIComponent(opts.org)}/${encodeURIComponent(opts.name)}`, + ); + log.debug({ err }, "ensureOrgRepo fell through to GET"); + return { + owner: repo.owner.login, + name: repo.name, + cloneUrl: repo.clone_url, + }; + } + } + // Creates or updates a file in a repo. Used for memory commits. async putFile(opts: { owner: string; diff --git a/src/lib/llm.ts b/src/lib/llm.ts index 2d6c917..a561985 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -1,28 +1,14 @@ 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"; +// ── LLM type definitions ── +// The system prompt and agent tools are loaded from disk at startup +// (prompts/system.txt + agents/*.md) via prompt-loader.ts. +// The unified tools are assembled in user-actor.ts using the catalog. export type LlmTool = { type: "function"; function: { - name: GrowAgentTool; + name: string; description: string; parameters: Record; }; @@ -48,96 +34,7 @@ export type LlmMessage = { }>; }; -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"], - }, - }, - }, -]; +// ── LLM API client ── type ChatCompletionsResponse = { choices?: Array<{ diff --git a/src/lib/prompt-loader.ts b/src/lib/prompt-loader.ts new file mode 100644 index 0000000..3d8df4a --- /dev/null +++ b/src/lib/prompt-loader.ts @@ -0,0 +1,168 @@ +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { log } from "../log.js"; + +// ── Types ── + +export type SubAgentModule = { + id: string; + name: string; + role: string; + description: string; + service?: "interview-service" | "roleplay-service" | "qscore-service"; + toolNames: string[]; +}; + +type AgentFrontmatter = { + id?: string; + name?: string; + role?: string; + service?: string; + tools?: string[]; +}; + +// ── Paths ── + +const PROMPTS_DIR = path.resolve(process.cwd(), "prompts"); +const AGENTS_DIR = path.resolve(process.cwd(), "agents"); +const SYSTEM_PROMPT_FILE = path.join(PROMPTS_DIR, "system.txt"); + +// ── Frontmatter parser (no external dependencies) ── + +function parseFrontmatter(raw: string): { data: AgentFrontmatter; body: string } { + const trimmed = raw.trim(); + if (!trimmed.startsWith("---")) return { data: {}, body: trimmed }; + + const secondDelim = trimmed.indexOf("---", 3); + if (secondDelim === -1) return { data: {}, body: trimmed }; + + const fmBlock = trimmed.slice(3, secondDelim).trim(); + const body = trimmed.slice(secondDelim + 3).trim(); + + const data: AgentFrontmatter = {}; + for (const line of fmBlock.split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + const key = line.slice(0, colonIdx).trim(); + let value: string | string[] = line.slice(colonIdx + 1).trim(); + + if (key === "tools" && value.startsWith("[") && value.endsWith("]")) { + // Parse inline array: ["tool1", "tool2"] + const inner = value.slice(1, -1); + value = inner + .split(",") + .map((s) => s.trim().replace(/^["']|["']$/g, "")) + .filter(Boolean); + } + + if (key === "id") data.id = value as string; + if (key === "name") data.name = value as string; + if (key === "role") data.role = value as string; + if (key === "service") data.service = value as string; + if (key === "tools") data.tools = value as string[]; + } + + return { data, body }; +} + +// ── Loader ── + +let cachedModules: SubAgentModule[] | null = null; +let cachedSystemPrompt: string | null = null; + +export function getSubAgentModules(): SubAgentModule[] { + if (!cachedModules) { + throw new Error("Prompts not loaded — call loadPromptsFromDisk() at startup"); + } + return cachedModules; +} + +export function getUnifiedSystemPrompt(): string { + if (!cachedSystemPrompt) { + throw new Error("Prompts not loaded — call loadPromptsFromDisk() at startup"); + } + return cachedSystemPrompt; +} + +export function getSubAgentModule(id: string): SubAgentModule | undefined { + return getSubAgentModules().find((m) => m.id === id); +} + +export function jobApplicationModuleIds(): string[] { + return ["resume", "job-search", "job-apply", "sara", "emily", "qscore"]; +} + +// Load all prompt and agent files from disk. +// Called once at startup. Rebuild the Docker image to pick up changes (§3). +export async function loadPromptsFromDisk(): Promise { + // ── Load agent modules ── + let agentFiles: string[]; + try { + agentFiles = (await readdir(AGENTS_DIR)).filter((f) => f.endsWith(".md")); + } catch (err) { + log.warn({ err, dir: AGENTS_DIR }, "agents directory not found — using empty catalog"); + agentFiles = []; + } + + const modules: SubAgentModule[] = []; + for (const filename of agentFiles) { + const filePath = path.join(AGENTS_DIR, filename); + try { + const raw = await readFile(filePath, "utf8"); + const { data, body } = parseFrontmatter(raw); + + if (!data.id || !data.name) { + log.warn({ file: filename }, "agent file missing required frontmatter fields (id, name)"); + continue; + } + + const service = data.service as SubAgentModule["service"] | undefined; + if ( + service && + service !== "interview-service" && + service !== "roleplay-service" && + service !== "qscore-service" + ) { + log.warn({ file: filename, service }, "unknown service value — treating as no service"); + } + + modules.push({ + id: data.id, + name: data.name, + role: data.role ?? data.name, + description: body || `Agent module: ${data.name}`, + service: service && + ["interview-service", "roleplay-service", "qscore-service"].includes(service) + ? (service as SubAgentModule["service"]) + : undefined, + toolNames: data.tools ?? [], + }); + } catch (err) { + log.error({ err, file: filename }, "failed to load agent module"); + } + } + + cachedModules = modules; + log.info({ count: modules.length, dir: AGENTS_DIR }, "loaded sub-agent modules from disk"); + + // ── Load system prompt ── + try { + const template = await readFile(SYSTEM_PROMPT_FILE, "utf8"); + const moduleDescriptions = modules + .map( + (m) => + `- **${m.name}** (${m.id}): ${m.description} ${ + m.service ? `[backed by ${m.service}]` : "[local workflow]" + }`, + ) + .join("\n"); + + cachedSystemPrompt = template.replace("{{MODULE_DESCRIPTIONS}}", moduleDescriptions); + log.info({ path: SYSTEM_PROMPT_FILE }, "loaded system prompt from disk"); + } catch (err) { + log.error({ err, path: SYSTEM_PROMPT_FILE }, "failed to load system prompt — using fallback"); + // Fallback: assemble from modules without a template file. + const fallback = `You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.\n\n## Sub-Agent Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`; + cachedSystemPrompt = fallback; + } +} diff --git a/src/routes/actors.ts b/src/routes/actors.ts index 7f5d6fb..a2961d7 100644 --- a/src/routes/actors.ts +++ b/src/routes/actors.ts @@ -10,9 +10,8 @@ import { db } from "../db/client.js"; import { actors as actorsTable } from "../db/schema.js"; import { eq } from "drizzle-orm"; -// PRD §5.2 — Actor registry HTTP surface. -// All routes are user-scoped via Clerk auth; userId is derived from the -// session token, never trusted from the body. +// Per changes.md §5: ONE unified actor per user. +// Routes are user-scoped via Clerk auth; userId derived from session token. export function actorRoutes() { const app = new Hono(); app.use("*", requireUser); @@ -34,7 +33,6 @@ export function actorRoutes() { }); app.get("/", async (c) => { - // Admin/debug — returns the caller's stacks only. Tighten further if needed. const userId = c.get("userId"); const all = await listStacks(); return c.json({ stacks: all.filter((s) => s.userId === userId) }); diff --git a/src/routes/agents.ts b/src/routes/agents.ts index 6c115aa..ffa2f2c 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -1,12 +1,13 @@ import { Hono } from "hono"; -import { agentCatalog } from "../agents/catalog.js"; +import { subAgentModules } 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 })); + // Returns the sub-agent module catalog (changes.md §2D: prompt modules). + app.get("/catalog", (c) => c.json({ agents: subAgentModules })); return app; } diff --git a/src/routes/git.ts b/src/routes/git.ts index 5c42f93..a382a02 100644 --- a/src/routes/git.ts +++ b/src/routes/git.ts @@ -5,7 +5,8 @@ import { requireUser, type AuthContext } from "../auth/clerk.js"; import { db } from "../db/client.js"; import { repos } from "../db/schema.js"; -// PRD §5.4 — Gitea Docker management API. +// Per changes.md §2A: uses CENTRAL Gitea, not per-user Gitea containers. +// All repo operations go through the central org. export function gitRoutes() { const app = new Hono(); app.use("*", requireUser); @@ -16,10 +17,8 @@ export function gitRoutes() { if (!stack) return c.json({ error: "not provisioned" }, 404); return c.json({ gitea: { - host: stack.giteaHost, - port: stack.giteaHttpPort, - sshPort: stack.giteaSshPort, - memoryRepo: stack.giteaMemoryRepo, + repoOwner: stack.giteaRepoOwner, + repoName: stack.giteaRepoName, }, }); }); @@ -31,10 +30,14 @@ export function gitRoutes() { .parse(await c.req.json()); const client = await giteaClientFor(userId); const stack = await getUserStack(userId); - if (!client || !stack) { + if (!client || !stack?.giteaRepoOwner) { return c.json({ error: "not provisioned" }, 404); } - const repo = await client.ensureRepo({ name: body.name, autoInit: true }); + const repo = await client.ensureOrgRepo({ + org: stack.giteaRepoOwner, + name: body.name, + autoInit: true, + }); await db .insert(repos) .values({ @@ -61,15 +64,12 @@ export function gitRoutes() { }) .parse(await c.req.json()); const client = await giteaClientFor(userId); - if (!client) return c.json({ error: "not provisioned" }, 404); - - // Get owner from DB or fall back to memory repo. const stack = await getUserStack(userId); - const owner = stack?.giteaAdminUser ?? ""; - if (!owner) return c.json({ error: "no gitea owner" }, 500); - + if (!client || !stack?.giteaRepoOwner) { + return c.json({ error: "not provisioned" }, 404); + } const result = await client.putFile({ - owner, + owner: stack.giteaRepoOwner, repo: repoName, path: body.path, contentUtf8: body.content, @@ -82,19 +82,19 @@ export function gitRoutes() { app.get("/repos/:name/contents/*", async (c) => { const userId = c.get("userId"); const repoName = c.req.param("name"); - const path = c.req.path.split(`/repos/${repoName}/contents/`)[1] ?? ""; + const filePath = c.req.path.split(`/repos/${repoName}/contents/`)[1] ?? ""; const client = await giteaClientFor(userId); const stack = await getUserStack(userId); - if (!client || !stack?.giteaAdminUser) { + if (!client || !stack?.giteaRepoOwner) { return c.json({ error: "not provisioned" }, 404); } const content = await client.readFile({ - owner: stack.giteaAdminUser, + owner: stack.giteaRepoOwner, repo: repoName, - path, + path: filePath, }); if (content == null) return c.json({ error: "not found" }, 404); - return c.json({ path, content }); + return c.json({ path: filePath, content }); }); return app; diff --git a/src/routes/workflows.ts b/src/routes/workflows.ts index 8174699..459b1ba 100644 --- a/src/routes/workflows.ts +++ b/src/routes/workflows.ts @@ -7,8 +7,9 @@ import type { Registry } from "../actors/registry.js"; const client = createClient(config.rivetEndpoint); -function jobWorkflowFor(userId: string) { - return client.workflowJob.getOrCreate([userId, "job-application"]); +// Per changes.md §5: one unified userActor per user. +function userActorFor(userId: string) { + return client.userActor.getOrCreate([userId]); } export function workflowRoutes() { @@ -20,41 +21,42 @@ export function workflowRoutes() { 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 }); + const handle = userActorFor(userId); + await handle.init({ userId }); + const state = await handle.startWorkflow({ goal: body.goal }); + return c.json({ workflow: state }); }); app.get("/job-application", async (c) => { const userId = c.get("userId"); - const handle = jobWorkflowFor(userId); - const state = await handle.init({ userId }); + const handle = userActorFor(userId); + await handle.init({ userId }); + const state = await handle.getWorkflowStatus(); return c.json({ workflow: state }); }); app.post("/job-application/pause", async (c) => { const userId = c.get("userId"); - const workflow = await jobWorkflowFor(userId).pause(); + const workflow = await userActorFor(userId).pauseWorkflow(); return c.json({ workflow }); }); app.post("/job-application/resume", async (c) => { const userId = c.get("userId"); - const workflow = await jobWorkflowFor(userId).resume(); + const workflow = await userActorFor(userId).resumeWorkflow(); return c.json({ workflow }); }); - app.post("/job-application/agents/:agentId/run", async (c) => { + app.post("/job-application/agents/:moduleId/run", async (c) => { const userId = c.get("userId"); - const agentId = c.req.param("agentId"); - const workflow = await jobWorkflowFor(userId).runAgent({ agentId }); + const moduleId = c.req.param("moduleId"); + const workflow = await userActorFor(userId).runWorkflowModule({ moduleId }); return c.json({ workflow }); }); - app.post("/job-application/agents/:agentId/score", async (c) => { + app.post("/job-application/agents/:moduleId/score", async (c) => { const userId = c.get("userId"); - const agentId = c.req.param("agentId"); + const moduleId = c.req.param("moduleId"); const body = z .object({ question: z.string().min(1), @@ -63,8 +65,8 @@ export function workflowRoutes() { notes: z.string().optional(), }) .parse(await c.req.json()); - const workflow = await jobWorkflowFor(userId).recordQaScore({ - agentId, + const workflow = await userActorFor(userId).recordQaScore({ + moduleId, ...body, }); return c.json({ workflow }); diff --git a/src/services/service-agents.ts b/src/services/service-agents.ts index 8c92d9e..485f5ba 100644 --- a/src/services/service-agents.ts +++ b/src/services/service-agents.ts @@ -1,7 +1,16 @@ import { config } from "../config.js"; -import type { AgentProfile } from "../agents/catalog.js"; import { createHash } from "node:crypto"; +// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule). +export type ServiceAgentRef = { + id: string; + name: string; + role: string; + kind: string; + description: string; + service?: string; +}; + export type ServiceAgentResult = { status: "ok" | "unavailable" | "local"; summary: string; @@ -209,7 +218,7 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise { try { -- 2.49.1 From e48c19b8404a89fbb9f1959d6e89e20b4b7cb67c Mon Sep 17 00:00:00 2001 From: NinjasPyajamas Date: Thu, 28 May 2026 17:43:15 +0530 Subject: [PATCH 3/4] Wires all 4 microservice-backed agents into the chat so LLM can call real services and return session URLs. --- agents/emily.md | 25 +++- agents/job-apply.md | 26 +++- agents/job-search.md | 26 +++- agents/qscore.md | 22 ++- agents/resume.md | 4 +- agents/sara.md | 23 ++- docker-compose.yml | 7 +- prompts/system.txt | 95 +++++++++++-- src/actors/user-actor.ts | 220 +++++++++++++++++++++++++++- src/config.ts | 2 + src/index.ts | 52 ++++++- src/lib/prompt-loader.ts | 7 +- src/routes/chat.ts | 253 +++++++++++++++++++++++++++++++++ src/services/service-agents.ts | 110 +++++++++++--- 14 files changed, 826 insertions(+), 46 deletions(-) create mode 100644 src/routes/chat.ts diff --git a/agents/emily.md b/agents/emily.md index 905bba4..8c02602 100644 --- a/agents/emily.md +++ b/agents/emily.md @@ -7,4 +7,27 @@ tools: - start_roleplay_session --- -Runs roleplay practice through the roleplay-service microservice and owns scenario feedback. +## Domain +Emily is the **Roleplay Agent**. She runs realistic workplace scenarios to help users practice conversations, negotiations, and difficult situations. She plays different personas convincingly and provides feedback. + +## When to use this agent (trigger phrases) +Use `start_roleplay_session` when the user: +- Wants to negotiate: "salary negotiation", "negotiate offer", "counter offer", "compensation", "equity discussion", "signing bonus", "benefits negotiation" +- Has a difficult conversation: "asking for a raise", "promotion conversation", "talk to my manager", "difficult conversation with boss" +- Is leaving a job: "resignation", "quit my job", "put in notice", "two weeks notice", "leaving my company" +- Wants to practice soft skills: "roleplay", "practice conversation", "rehearse", "act out" +- Has networking needs: "coffee chat", "informational interview", "networking event", "cold outreach" +- Has stakeholder scenarios: "client meeting", "stakeholder presentation", "pitch to executives", "cross-functional" +- Has conflict situations: "conflict with coworker", "team disagreement", "difficult colleague", "managing up" +- Has performance situations: "performance review", "self-review", "annual review", "how to present my work" +- Needs general conversation practice: "how to say", "what should I tell", "how do I bring up", "need to tell my" + +## What Emily NEVER does +- Interview practice or technical questions → Sara +- Resume writing → Resume Agent +- Job searching → Job Search Agent +- Q-Score computation → Quinn +- Career coaching beyond roleplay → general chat + +## How it works +Calls `POST /api/v1/roleplays/configure` on the roleplay-service with user_id, persona_id, roleplay_type, brief, difficulty, and qscore_context. Creates a real Gemini Live-powered roleplay session. Supports types: sales, customer_success, support, custom. Returns session_id for the user to start practicing. diff --git a/agents/job-apply.md b/agents/job-apply.md index 885af86..422c3a1 100644 --- a/agents/job-apply.md +++ b/agents/job-apply.md @@ -8,4 +8,28 @@ tools: - schedule_followup --- -Prepares tailored applications, tracks submissions, and records follow-up tasks. +## Domain +The **Job Apply Agent** manages the user's job application process end-to-end. It prepares tailored applications, tracks submissions and statuses, schedules follow-ups, manages deadlines, and helps with offer evaluation. + +## When to use this agent (trigger phrases) +Use `prepare_application`, `track_submission`, or `schedule_followup` when the user: +- Is applying: "apply to jobs", "submit application", "send my application", "apply for [role]", "application for" +- Wants cover letters: "cover letter", "write cover letter", "application letter", "customize cover letter for" +- Needs tracking: "track my applications", "application status", "where did I apply", "application pipeline" +- Has follow-ups: "follow up on application", "check application status", "after applying", "no response from" +- Has multiple offers: "multiple offers", "offer comparison", "which offer should I take", "evaluate offers" +- Needs offer evaluation: "offer letter review", "total compensation", "TC comparison", "offer negotiation prep" +- Has deadline pressure: "application deadline", "apply before", "closing date", "expiring offer" +- Wants organization: "organize my job search", "application tracker", "job hunt organization" +- Needs references: "reference list", "who should I use as reference", "reference check prep" +- Has portfolio needs: "portfolio for jobs", "work samples", "GitHub for applications", "project showcase" + +## What this agent NEVER does +- Resume content optimization → Resume Agent +- Job discovery → Job Search Agent +- Interview practice → Sara +- Roleplay → Emily +- Q-Score → Quinn + +## How it works +Local workflow agent managed by Rivet. Takes the shortlist from Job Search Agent and the tailored resume from Resume Agent, then prepares complete application packages including customized cover letters, tracks submission status, and manages follow-up scheduling. diff --git a/agents/job-search.md b/agents/job-search.md index 9113fb2..214567d 100644 --- a/agents/job-search.md +++ b/agents/job-search.md @@ -8,4 +8,28 @@ tools: - prepare_shortlist --- -Finds relevant jobs, ranks opportunities, and prepares a shortlist for the application workflow. +## Domain +The **Job Search Agent** discovers and evaluates job opportunities matching the user's skills, experience, and preferences. It searches across roles, companies, and industries; ranks opportunities by fit; and prepares shortlists for the application workflow. + +## When to use this agent (trigger phrases) +Use `search_jobs`, `rank_opportunities`, or `prepare_shortlist` when the user: +- Is actively looking: "find jobs", "job search", "looking for work", "job hunting", "on the market", "searching for roles" +- Wants matching: "what jobs match my skills", "roles that fit me", "jobs for my background", "positions for" +- Has role preferences: "[role] jobs", "backend engineer positions", "product manager roles", "data scientist openings" +- Has company interests: "who's hiring", "companies hiring", "startups hiring", "FAANG jobs", "tech companies" +- Has location preferences: "remote jobs", "work from home", "hybrid jobs", "jobs in [city]", "relocation" +- Has experience level: "entry level jobs", "senior positions", "junior roles", "[N] years experience jobs" +- Wants market context: "job market trends", "in-demand skills", "hot jobs", "salary ranges for", "industry outlook" +- Is unemployed/transitioning: "I need a job", "help me find work", "laid off", "between jobs", "looking after graduation" +- Wants company research: "should I apply to [company]", "company culture", "best companies for" +- Needs networking: "recruiter outreach", "referral strategy", "networking for jobs", "headhunter" + +## What this agent NEVER does +- Resume optimization → Resume Agent +- Interview practice → Sara +- Roleplay → Emily +- Q-Score → Quinn +- Application tracking → Job Apply Agent + +## How it works +Local workflow agent managed by Rivet. Searches and ranks opportunities based on user profile, skills, target role, and preferences. Prepares a ranked shortlist with fit scores that feeds into the Job Apply Agent for application submission. diff --git a/agents/qscore.md b/agents/qscore.md index 76d6b24..3e0ddc6 100644 --- a/agents/qscore.md +++ b/agents/qscore.md @@ -8,4 +8,24 @@ tools: - ingest_signals --- -Computes and explains Q-Score changes, then displays Q&A and scores. +## Domain +Quinn is the **Q-Score Agent**. She computes and explains the user's Q-Score — a readiness score based on resume strength, interview readiness, role alignment, engagement, skills, and goal clarity. She tracks growth over time. + +## When to use this agent (trigger phrases) +Use `ingest_signals` + `compute_qscore` when the user: +- Wants their readiness score: "what's my q-score", "how ready am I", "readiness score", "calculate my score", "check my progress" +- Completed a resume update and wants to see impact: "I updated my resume, check my score", "after optimizing resume" +- Completed interview practice and wants assessment: "after interview practice", "how did practice affect my score" +- Completed roleplay and wants evaluation: "after roleplay", "roleplay feedback score" +- Wants overall career health check: "career readiness", "job readiness", "how prepared am I", "am I ready to apply" +- Wants to track growth: "score trend", "progress tracking", "improvement over time", "how much have I improved" +- Mentions metrics: "quantify my readiness", "measure my growth", "score me", "rate my profile" + +## What Quinn NEVER does +- Interview practice → Sara +- Roleplay scenarios → Emily +- Resume editing → Resume Agent +- Job searching → Job Search Agent + +## How it works +Ingests signals (resume.uploaded, resume.ats_compatibility, engagement.features_used, goals.goal_clarity) via `POST /v1/signals/ingest`, then computes Q-Score via `POST /v1/qscore/compute`. Returns score from 0-100 with breakdown across 5 pillars. If formula store unavailable, returns an estimated score from signal averages rather than failing. diff --git a/agents/resume.md b/agents/resume.md index f0888c6..919b5c2 100644 --- a/agents/resume.md +++ b/agents/resume.md @@ -2,10 +2,12 @@ id: resume name: Resume Agent role: Resume Builder +service: resume-service tools: - build_resume - review_resume - tailor_resume + - analyze_resume --- -Turns profile context, Q-Score gaps, and target roles into resume edits and application collateral. +Analyzes, builds, and tailors resumes for specific roles. Backed by the resume-builder microservice. Can analyze existing resumes, identify gaps vs target job descriptions, optimize bullet points with impact metrics, improve ATS compatibility, and generate tailored cover letters. Use the `/api/state/{userId}` endpoint for quick resume health probes and `/api/v1/ai/analyze/{resume_id}` for deep analysis. diff --git a/agents/sara.md b/agents/sara.md index b26fc37..e43890a 100644 --- a/agents/sara.md +++ b/agents/sara.md @@ -7,4 +7,25 @@ tools: - start_interview_session --- -Runs interview practice through the interview-service microservice and owns interview Q&A feedback. +## Domain +Sara is the **Interview Agent**. She only handles job interview preparation and practice. Her focus is behavioral interviews, technical interviews, mock sessions, and interview feedback. + +## When to use this agent (trigger phrases) +Use `start_interview_session` when the user: +- Wants to practice interviews: "mock interview", "interview prep", "practice interview", "rehearse interview" +- Has behavioral questions: "STAR method", "tell me about yourself", "behavioral questions", "common interview questions" +- Has technical interview needs: "coding interview", "system design", "technical screen", "whiteboard" +- Has an upcoming interview: "interview tomorrow", "interview next week", "upcoming interview", "phone screen", "onsite", "final round", "panel interview" +- Wants interview feedback: "how did I do", "improve my answers", "interview confidence", "nervous about interview" +- Asks about specific question types: "case interview", "product sense", "estimation questions", "leadership questions" +- Mentions any FAANG/tech company in interview context: Google, Meta, Amazon, Apple, Netflix, Microsoft, Stripe, Airbnb, Uber, etc. + +## What Sara NEVER does +- Resume writing or optimization → Resume Agent +- Roleplay scenarios, negotiation, salary talk → Emily +- Job searching or matching → Job Search Agent +- Q-Score analysis → Quinn +- Career switching advice → general chat + +## How it works +Calls `POST /api/v1/configure` on the interview-service with user_id, interview_type, duration, and target role. Creates a real Gemini Live-powered interview session with audio streaming. Returns a session_id that the user can open to start practicing. diff --git a/docker-compose.yml b/docker-compose.yml index 29394e3..0511f80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,12 +33,12 @@ services: GITEA__security__INSTALL_LOCK: "true" GITEA__service__DISABLE_REGISTRATION: "true" ports: - - "3001:3001" # HTTP + - "3001:3000" # HTTP (Gitea listens on 3000 internally) - "2222:2222" # SSH volumes: - gitea-data:/data healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/version || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/v1/version || exit 1"] interval: 10s timeout: 10s retries: 15 @@ -95,7 +95,7 @@ services: CLERK_SECRET_KEY: ${CLERK_SECRET_KEY} CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY} SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token} - A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:************} + A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key} # LLM OPENCODE_API_KEY: ${OPENCODE_API_KEY} LLM_PROVIDER: ${LLM_PROVIDER:-opencode} @@ -112,6 +112,7 @@ services: 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} + RESUME_SERVICE_URL: ${RESUME_SERVICE_URL:-http://host.docker.internal:8002} # Frontend FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000} volumes: diff --git a/prompts/system.txt b/prompts/system.txt index 359d08d..69d01b2 100644 --- a/prompts/system.txt +++ b/prompts/system.txt @@ -1,19 +1,90 @@ You are the Grow Agent — a unified AI orchestrator for the GrowQR platform. -You own this user's long-running context, memory, and workspace. You coordinate all sub-agent capabilities (loaded as tools), maintain durable state in the user's Git memory repository (managed via Gitea), and execute workflows through the user's OpenCode sandbox. +You coordinate sub-agent capabilities (loaded as tools), maintain durable state, and execute workflows through microservices. -## Sub-Agent Capabilities (loaded at build time) +## CRITICAL RULES + +1. **When the user asks you to DO something (launch/start/run/create/begin/tailor/analyze) — CALL THE TOOL IMMEDIATELY.** Do not say "starting now" without actually calling the tool. Do not roleplay. The user expects real results. + +2. **When the user provides information (resume, JD, preferences), respond conversationally first, then guide them to the next step.** + +3. **Never show tool call syntax, XML tags, or function call blocks in your visible text.** Tool execution happens silently behind the scenes. + +4. **Be concise** — 1-3 short paragraphs max per response. This is a chat, not a document. + +5. **Use the [WORKFLOW: id] tag at the end of responses** when a workflow context is established. + +## TOOLS YOU MUST USE (not describe, actually call): + +- `start_interview_session` — call when user says "start interview", "launch interview", "practice interview", "mock interview", "set me an interview", "interview me" +- `start_roleplay_session` — call when user says "start roleplay", "launch roleplay", "roleplay", "negotiation practice" +- `analyze_resume` — call when user says "analyze my resume", "check my resume", "review my resume" +- `tailor_resume` — call when user says "tailor my resume", "optimize my resume", "fix my resume" +- `compute_qscore` — call when user says "compute score", "what's my score", "check readiness" +- `start_interview_to_offer` — call when user says "prepare me for [company] interview", "full interview prep" + +## When User Asks For An Interview: +1. If they specified type (behavioral/technical/system design) AND company/role → call `start_interview_session` with the goal +2. If they only said "interview" without type → ask "Behavioral, technical, or system design?" +3. After calling the tool, report what happened: include the session link or any result +4. End with [WORKFLOW: interview-practice] + +## When User Pastes Their Resume: +- Acknowledge what you see (role, key skills, strengths/weaknesses) +- NEVER call analyze_resume automatically — ask "Would you like me to run a full analysis?" +- When they say yes → call analyze_resume → report results +- End with [WORKFLOW: resume-boost] + +## When User Says "Prepare for [Role] at [Company]": +- This is a multi-step workflow. FIRST, ask for the job description. +- Do NOT call start_interview_to_offer yet — wait for the JD. +- After JD: ask for resume. +- After resume: ask if they want you to analyze/tailor it. +- After resume optimization: ask what type of interview to prepare. +- When they choose type → call start_interview_session. +- Then offer roleplay → call start_roleplay_session when they confirm. +- Then offer Q-Score → call compute_qscore. +- Use [WORKFLOW: interview-to-offer] tag throughout. + +## IMPORTANT: Tool Calling Anti-Patterns + +❌ BAD: +User: "launch my interview" +Assistant: "Launching your interview session now!" +// (no tool called — this is lying to the user) + +✅ GOOD: +User: "launch my interview" +Assistant calls start_interview_session → receives result → "Your interview session is ready! [session URL]. You can click Open to begin." + +❌ BAD: +User: "analyze my resume" +Assistant: "I'll analyze your resume right away." +// (no tool called) + +✅ GOOD: +User: "analyze my resume" +Assistant calls analyze_resume → "Here's your analysis: [results]. Your strengths are..." + +## Sub-Agent Capabilities {{MODULE_DESCRIPTIONS}} -## Operating Principles +## Workflow Tags (put at the VERY END, on their own line) -- 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 Git 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, use the OpenCode execution tools. -- Track active goals and workflows. Surface progress proactively when the user returns. -- Prefer one small commit per meaningful state change over batching. -- When a user wants interview practice, use `start_interview_session` (Sara). -- When a user wants roleplay practice, use `start_roleplay_session` (Emily). -- When a user needs Q-Score computation, use `ingest_signals` and `compute_qscore` (Quinn). -- Never invent tool names. Only use the tools provided. +- [WORKFLOW: interview-to-offer] — full interview prep pipeline +- [WORKFLOW: interview-practice] — interview sessions with Sara +- [WORKFLOW: resume-boost] — resume analysis and optimization +- [WORKFLOW: roleplay-practice] — roleplay sessions with Emily +- [WORKFLOW: career-switch] — career change navigation +- [WORKFLOW: job-search] — job discovery +- [WORKFLOW: job-preparation] — broad company preparation + +NEVER mention these tags in your visible text. They are system-internal. + +## Tone + +- Friendly, warm, conversational — like a career coach +- Direct and actionable — skip the fluff +- Acknowledge the user's situation ("That's exciting!", "Great goal!") +- Use markdown for structure (bold, bullets) diff --git a/src/actors/user-actor.ts b/src/actors/user-actor.ts index b6dac09..81c5cfa 100644 --- a/src/actors/user-actor.ts +++ b/src/actors/user-actor.ts @@ -210,6 +210,49 @@ function buildUnifiedTools(): Array<{ }, }, }, + { + type: "function" as const, + function: { + name: "analyze_resume", + description: "Analyze the user's resume using the Resume Agent microservice. Returns completeness score, skill gaps, and optimization recommendations.", + parameters: { + type: "object", + properties: { + goal: { type: "string", description: "Target role or job description for context" }, + }, + required: ["goal"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "tailor_resume", + description: "Tailor the user's resume for a specific job description or role. Optimizes bullet points, adds keywords, and improves ATS compatibility.", + parameters: { + type: "object", + properties: { + goal: { type: "string", description: "Target role and company for resume tailoring" }, + }, + required: ["goal"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "start_interview_to_offer", + description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze & tailor resume for the role, (2) Create interview practice session with Sara, (3) Create roleplay session with Emily, (4) Compute Q-Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.", + parameters: { + type: "object", + properties: { + goal: { type: "string", description: "The target role and company, e.g. 'Software Engineer at Google' or 'Product Manager at Stripe'" }, + job_description: { type: "string", description: "Optional: the job description or key requirements" }, + }, + required: ["goal"], + }, + }, + }, ]; // Build sub-agent capability tools from the catalog (changes.md §2D). @@ -478,7 +521,26 @@ export const userActor = actor({ })(), ); - return { reply: assistantTextOut || "(no response)" }; + return { + reply: assistantTextOut || "(no response)", + sessions: c.state.modules + .filter(m => m.service && m.lastResult?.detail) + .map(m => { + const detail = m.lastResult!.detail as Record | undefined; + return { + moduleId: m.id, + moduleName: m.name, + status: m.status, + sessionId: detail?.session_id as string | undefined, + sessionUrl: m.service === "interview-service" + ? `http://localhost:8007/api/v1/demo?session_id=${detail?.session_id ?? ""}` + : m.service === "roleplay-service" + ? `http://localhost:8008/api/v1/demo?session_id=${detail?.session_id ?? ""}` + : undefined, + summary: m.lastResult?.summary, + }; + }), + }; }, // ── Workflow (was workflowJob actor, now part of user actor — changes.md §5) ── @@ -727,6 +789,162 @@ async function dispatchUnifiedTool( return result; } + case "analyze_resume": { + const goal = String(input.goal ?? c.state.workflowGoal ?? "general"); + const resumeModule = getSubAgentModule("resume"); + if (!resumeModule) return { ok: false, error: "Resume module not available" }; + const result = await runServiceAgentProbe( + { id: resumeModule.id, name: resumeModule.name, role: resumeModule.role, kind: "microservice", description: resumeModule.description, service: resumeModule.service }, + { userId, goal }, + ); + c.broadcast("service-result", { moduleId: "resume", result }); + return result; + } + + case "tailor_resume": { + const goal = String(input.goal ?? c.state.workflowGoal ?? "general"); + const resumeModule = getSubAgentModule("resume"); + if (!resumeModule) return { ok: false, error: "Resume module not available" }; + const result = await runServiceAgentProbe( + { id: resumeModule.id, name: resumeModule.name, role: resumeModule.role, kind: "microservice", description: resumeModule.description, service: resumeModule.service }, + { userId, goal }, + ); + c.broadcast("service-result", { moduleId: "resume", result }); + return result; + } + + case "start_interview_to_offer": { + const goal = String(input.goal ?? ""); + const jobDesc = String(input.job_description ?? ""); + + // Start the workflow + c.state.workflowId = `interview-to-offer:${userId}`; + c.state.workflowStatus = "running"; + c.state.workflowGoal = goal; + c.state.modules = makeModules(); + c.state.createdAt = now(); + c.state.updatedAt = now(); + + appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", `Interview-to-Offer workflow started for: ${goal}`); + + // Step 1: Resume Agent — analyze and tailor + const resumeModule = getSubAgentModule("resume"); + const resumeMod = c.state.modules.find(m => m.id === "resume"); + if (resumeMod && resumeModule) { + resumeMod.status = "running"; + appendTimelineEvent(c.state, resumeMod, "module", "Resume Agent analyzing your profile..."); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + try { + const resumeResult = await runServiceAgentProbe( + { id: resumeModule.id, name: resumeModule.name, role: resumeModule.role, kind: "microservice", description: resumeModule.description, service: resumeModule.service }, + { userId, goal }, + ); + resumeMod.lastResult = resumeResult; + resumeMod.status = resumeResult.status === "unavailable" ? "blocked" : "done"; + appendTimelineEvent(c.state, resumeMod, "module", resumeResult.summary); + } catch (err) { + resumeMod.status = "blocked"; + appendTimelineEvent(c.state, resumeMod, "module", `Resume Agent failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + // Step 2: Sara — create interview session + const saraModule = getSubAgentModule("sara"); + const saraMod = c.state.modules.find(m => m.id === "sara"); + if (saraMod && saraModule?.service) { + saraMod.status = "running"; + appendTimelineEvent(c.state, saraMod, "module", "Sara creating interview practice session..."); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + try { + const saraResult = await runServiceAgentProbe( + { id: saraModule.id, name: saraModule.name, role: saraModule.role, kind: "microservice", description: saraModule.description, service: saraModule.service }, + { userId, goal: goal + (jobDesc ? `\nJob Description: ${jobDesc}` : "") }, + ); + saraMod.lastResult = saraResult; + saraMod.status = saraResult.status === "unavailable" ? "blocked" : "done"; + appendTimelineEvent(c.state, saraMod, "module", saraResult.summary); + } catch (err) { + saraMod.status = "blocked"; + appendTimelineEvent(c.state, saraMod, "module", `Sara session failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + // Step 3: Emily — create roleplay session + const emilyModule = getSubAgentModule("emily"); + const emilyMod = c.state.modules.find(m => m.id === "emily"); + if (emilyMod && emilyModule?.service) { + emilyMod.status = "running"; + appendTimelineEvent(c.state, emilyMod, "module", "Emily creating roleplay scenario..."); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + try { + const emilyResult = await runServiceAgentProbe( + { id: emilyModule.id, name: emilyModule.name, role: emilyModule.role, kind: "microservice", description: emilyModule.description, service: emilyModule.service }, + { userId, goal: `Interview negotiation and communication practice for: ${goal}` }, + ); + emilyMod.lastResult = emilyResult; + emilyMod.status = emilyResult.status === "unavailable" ? "blocked" : "done"; + appendTimelineEvent(c.state, emilyMod, "module", emilyResult.summary); + } catch (err) { + emilyMod.status = "blocked"; + appendTimelineEvent(c.state, emilyMod, "module", `Emily session failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + // Step 4: Quinn — compute Q-Score + const quinnModule = getSubAgentModule("qscore"); + const quinnMod = c.state.modules.find(m => m.id === "qscore"); + if (quinnMod && quinnModule?.service) { + quinnMod.status = "running"; + appendTimelineEvent(c.state, quinnMod, "module", "Quinn computing your readiness Q-Score..."); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + try { + const quinnResult = await runServiceAgentProbe( + { id: quinnModule.id, name: quinnModule.name, role: quinnModule.role, kind: "score", description: quinnModule.description, service: quinnModule.service }, + { userId, goal }, + ); + quinnMod.lastResult = quinnResult; + quinnMod.status = quinnResult.status === "unavailable" ? "blocked" : "done"; + appendTimelineEvent(c.state, quinnMod, "module", quinnResult.summary); + } catch (err) { + quinnMod.status = "blocked"; + appendTimelineEvent(c.state, quinnMod, "module", `Q-Score computation failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + const doneCount = c.state.modules.filter(m => m.status === "done").length; + const totalModules = c.state.modules.filter(m => m.service).length; + const blockedCount = c.state.modules.filter(m => m.status === "blocked").length; + + return { + ok: true, + workflowId: c.state.workflowId, + goal, + modulesCompleted: doneCount, + totalServiceModules: totalModules, + blocked: blockedCount, + summary: `Interview-to-Offer workflow completed ${doneCount}/${totalModules} service modules${blockedCount > 0 ? ` (${blockedCount} unavailable)` : ""}.`, + timeline: c.state.timeline, + modules: c.state.modules.filter(m => m.service).map(m => ({ + id: m.id, + name: m.name, + status: m.status, + summary: m.lastResult?.summary ?? m.summary, + })), + }; + } + default: { // Check if this is a sub-agent capability tool from the catalog (changes.md §2D). // These tools are loaded at build time — each sub-agent module defines its own tool names. diff --git a/src/config.ts b/src/config.ts index 957ad62..3481847 100644 --- a/src/config.ts +++ b/src/config.ts @@ -54,6 +54,8 @@ export const config = { process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008", qscoreServiceUrl: process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000", + resumeServiceUrl: + process.env.RESUME_SERVICE_URL ?? "http://localhost:8002", // ── Central Gitea (one org-wide instance, changes.md §2A) ── giteaUrl: process.env.GITEA_URL ?? "http://127.0.0.1:3001", diff --git a/src/index.ts b/src/index.ts index 1888d82..1f0394e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ 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 { chatRoutes } from "./routes/chat.js"; import { db } from "./db/client.js"; import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js"; import { initCatalog } from "./agents/catalog.js"; @@ -68,9 +69,6 @@ async function main() { } }); - // Rivet Kit actor traffic (frontend uses @rivetkit/react against this prefix). - app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); - // HTTP control plane (auth-gated). app.route("/users", userRoutes()); app.route("/agents", agentRoutes()); @@ -78,11 +76,57 @@ async function main() { app.route("/actors", actorRoutes()); app.route("/opencode", opencodeRoutes()); app.route("/git", gitRoutes()); + app.route("/api/chat", chatRoutes()); if (process.env.RIVET_RUN_ENGINE === "1") { + // Self-hosted: embedded engine runs at localhost:6420. + // Proxy frontend Rivet traffic to the engine instead of using registry.handler() + // (handler conflicts with startRunner — they're mutually exclusive). delete process.env.RIVET_ENDPOINT; + app.all("/api/rivet/*", async (c) => { + const url = new URL(c.req.url); + url.hostname = "127.0.0.1"; + url.port = "6420"; + url.pathname = url.pathname.replace("/api/rivet", ""); + + // Forward headers, stripping hop-by-hop ones + const fwdHeaders = new Headers(); + for (const [k, v] of Object.entries(c.req.raw.headers)) { + if (k.toLowerCase() === "host") continue; + if (k.toLowerCase() === "transfer-encoding") continue; + fwdHeaders.set(k, v); + } + fwdHeaders.set("Host", "127.0.0.1:6420"); + + // For POST/PUT/PATCH, clone the body stream (Hono may have consumed it) + const method = c.req.method.toUpperCase(); + const bodyMethods = ["POST", "PUT", "PATCH", "DELETE"]; + + try { + const rawBody = bodyMethods.includes(method) + ? await c.req.raw.clone().arrayBuffer() + : undefined; + + const res = await fetch(url.toString(), { + method, + headers: fwdHeaders, + body: rawBody && rawBody.byteLength > 0 ? new Uint8Array(rawBody) : undefined, + }); + + return new Response(res.body, { + status: res.status, + headers: res.headers, + }); + } catch (err) { + log.error({ err, url: url.toString() }, "rivet proxy error"); + return c.json({ error: "proxy_error" }, 502); + } + }); + registry.startRunner(); + } else { + // Serverless: use registry.handler() for incoming actor traffic. + app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); } - registry.startRunner(); serve({ fetch: app.fetch, port: config.port }, (info) => { log.info( diff --git a/src/lib/prompt-loader.ts b/src/lib/prompt-loader.ts index 3d8df4a..78a6178 100644 --- a/src/lib/prompt-loader.ts +++ b/src/lib/prompt-loader.ts @@ -9,7 +9,7 @@ export type SubAgentModule = { name: string; role: string; description: string; - service?: "interview-service" | "roleplay-service" | "qscore-service"; + service?: "interview-service" | "roleplay-service" | "qscore-service" | "resume-service"; toolNames: string[]; }; @@ -121,7 +121,8 @@ export async function loadPromptsFromDisk(): Promise { service && service !== "interview-service" && service !== "roleplay-service" && - service !== "qscore-service" + service !== "qscore-service" && + service !== "resume-service" ) { log.warn({ file: filename, service }, "unknown service value — treating as no service"); } @@ -132,7 +133,7 @@ export async function loadPromptsFromDisk(): Promise { role: data.role ?? data.name, description: body || `Agent module: ${data.name}`, service: service && - ["interview-service", "roleplay-service", "qscore-service"].includes(service) + ["interview-service", "roleplay-service", "qscore-service", "resume-service"].includes(service) ? (service as SubAgentModule["service"]) : undefined, toolNames: data.tools ?? [], diff --git a/src/routes/chat.ts b/src/routes/chat.ts new file mode 100644 index 0000000..0a3ccb9 --- /dev/null +++ b/src/routes/chat.ts @@ -0,0 +1,253 @@ +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"; +import type { LlmMessage } from "../lib/llm.js"; +import { createChatCompletion } from "../lib/llm.js"; +import { buildUnifiedSystemPrompt } from "../agents/catalog.js"; +import { + runServiceAgentProbe, + type ServiceAgentResult, +} from "../services/service-agents.js"; +import { getSubAgentModules } from "../lib/prompt-loader.js"; + +const chatSchema = z.object({ + messages: z.array( + z.object({ + role: z.enum(["user", "assistant", "system"]), + content: z.string(), + }), + ), + agentId: z.string().optional(), +}); + +function extractWorkflowTag(reply: string): string | undefined { + const match = reply.match(/\[WORKFLOW:\s*([a-z-]+)\]/i); + if (!match || !match[1]) return undefined; + return match[1].toLowerCase(); +} + +function cleanWorkflowTag(reply: string): string { + return reply.replace(/\[WORKFLOW:\s*[a-z-]+\]/gi, "").trim(); +} + +function buildTools() { + return [ + { + type: "function" as const, + function: { + name: "start_interview_session", + description: "Create a real interview practice session via the Sara / interview-service microservice. Call this when the user asks to start or launch an interview.", + parameters: { + type: "object", + properties: { + target_role: { type: "string", description: "The target role and company, e.g., 'Software Engineer at Google'" }, + }, + required: ["target_role"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "start_roleplay_session", + description: "Create a real roleplay session via Emily / roleplay-service. Call when user asks for roleplay or negotiation practice.", + parameters: { + type: "object", + properties: { + goal: { type: "string", description: "What scenario to practice" }, + }, + required: ["goal"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "analyze_resume", + description: "Analyze user's resume using the Resume Agent. Returns completeness, skills, and gaps.", + parameters: { + type: "object", + properties: { + goal: { type: "string", description: "Target role for context" }, + }, + required: ["goal"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "compute_qscore", + description: "Compute user's readiness Q-Score via Quinn / qscore-service.", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, + }, + ]; +} + +export function chatRoutes() { + const app = new Hono(); + app.use("*", requireUser); + + app.post("/", async (c) => { + const userId = c.get("userId"); + const body = chatSchema.parse(await c.req.json()); + const userText = body.messages[body.messages.length - 1]?.content ?? ""; + + // 1. Try Rivet actor path (full tool suite + conversation history) + try { + const client = createClient(config.rivetClientEndpoint); + const handle = client.userActor.getOrCreate([userId]); + await handle.init({ userId }); + const result = await handle.receiveMessage({ text: userText }); + if (result?.reply) { + const reply = cleanWorkflowTag(String(result.reply)); + const workflow = extractWorkflowTag(String(result.reply)); + return c.json({ reply, workflow, sessions: (result as any).sessions ?? [] }); + } + } catch (err) { + console.warn("Rivet chat unavailable, using direct LLM:", err instanceof Error ? err.message : String(err)); + } + + // 2. Fallback: direct LLM with tool dispatch + const systemPrompt = buildUnifiedSystemPrompt(); + const conversation: LlmMessage[] = [ + { role: "system", content: systemPrompt }, + ...body.messages.filter((m) => m.role !== "system"), + ]; + + try { + const response1 = await createChatCompletion({ + model: config.agentModel, + maxTokens: config.maxAgentTokens, + tools: buildTools(), + messages: conversation, + }); + + let reply = response1.content || ""; + const sessions: Array<{ + moduleId: string; + moduleName: string; + status: string; + sessionId?: string; + sessionUrl?: string; + summary?: string; + }> = []; + + // If LLM called a tool, execute it + if (response1.toolCalls.length > 0) { + conversation.push({ + role: "assistant", + content: response1.content, + tool_calls: response1.toolCalls.map((tc) => ({ + id: tc.id, + type: "function" as const, + function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }, + })), + }); + + for (const toolCall of response1.toolCalls) { + console.log("LLM called tool:", toolCall.name, toolCall.arguments); + let toolResult: ServiceAgentResult; + + switch (toolCall.name) { + case "start_interview_session": { + toolResult = await runServiceAgentProbe( + { id: "sara", name: "Sara", role: "Interview Agent", kind: "microservice", description: "Interview practice", service: "interview-service" }, + { userId, goal: String(toolCall.arguments.target_role ?? "general preparation") }, + ); + if (toolResult.status === "ok" && toolResult.detail) { + const detail = toolResult.detail as Record; + sessions.push({ + moduleId: "sara", + moduleName: "Sara", + status: "done", + sessionId: detail.session_id as string, + sessionUrl: `http://localhost:8007/api/v1/demo?session_id=${detail.session_id ?? ""}`, + summary: toolResult.summary, + }); + } + break; + } + case "start_roleplay_session": { + toolResult = await runServiceAgentProbe( + { id: "emily", name: "Emily", role: "Roleplay Agent", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" }, + { userId, goal: String(toolCall.arguments.goal ?? "general practice") }, + ); + if (toolResult.status === "ok" && toolResult.detail) { + const detail = toolResult.detail as Record; + sessions.push({ + moduleId: "emily", + moduleName: "Emily", + status: "done", + sessionId: detail.session_id as string, + sessionUrl: `http://localhost:8008/api/v1/demo?session_id=${detail.session_id ?? ""}`, + summary: toolResult.summary, + }); + } + break; + } + case "analyze_resume": { + toolResult = await runServiceAgentProbe( + { id: "resume", name: "Resume Agent", role: "Resume Builder", kind: "microservice", description: "Resume analysis", service: "resume-service" }, + { userId, goal: String(toolCall.arguments.goal ?? "general") }, + ); + if (toolResult.status === "ok") { + sessions.push({ moduleId: "resume", moduleName: "Resume Agent", status: "done", summary: toolResult.summary }); + } + break; + } + case "compute_qscore": { + toolResult = await runServiceAgentProbe( + { id: "qscore", name: "Quinn", role: "Q-Score Agent", kind: "score", description: "Readiness scoring", service: "qscore-service" }, + { userId, goal: "general assessment" }, + ); + if (toolResult.status === "ok") { + sessions.push({ moduleId: "qscore", moduleName: "Quinn", status: "done", summary: toolResult.summary }); + } + break; + } + } + } + + // Second LLM call: summarize tool results + const toolResults = sessions.map((s) => + `Tool result: ${s.moduleName} - ${s.status} - ${s.summary || ""}${s.sessionUrl ? ` - Demo URL: ${s.sessionUrl}` : ""}`, + ); + for (const tr of toolResults) { + conversation.push({ role: "tool", content: tr, tool_call_id: "tool" }); + } + + const response2 = await createChatCompletion({ + model: config.agentModel, + maxTokens: 1024, + tools: [], + messages: conversation, + }); + + reply = cleanWorkflowTag(response2.content || reply); + } + + return c.json({ + reply: cleanWorkflowTag(reply), + workflow: extractWorkflowTag(reply), + sessions, + }); + } catch (llmErr) { + console.error("Direct LLM chat error:", llmErr); + return c.json( + { error: llmErr instanceof Error ? llmErr.message : "LLM error" }, + { status: 502 }, + ); + } + }); + + return app; +} diff --git a/src/services/service-agents.ts b/src/services/service-agents.ts index 485f5ba..f11d4a4 100644 --- a/src/services/service-agents.ts +++ b/src/services/service-agents.ts @@ -170,21 +170,29 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise>( - config.qscoreServiceUrl, - "/v1/signals/ingest", - { - method: "POST", - body: JSON.stringify({ - org_id: orgId, - user_id: qscoreUserId, - profession: "student", - source: "growqr-workflow", - signals, - }), - }, - ); + // Try to ingest signals (non-critical — may fail if QScore worker is down) + let ingest: Record | undefined; + try { + 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, + }), + }, + ); + } catch (err) { + // Signal ingestion is optional — compute may still work with cached signals + ingest = { status: "skipped", reason: err instanceof Error ? err.message : String(err) }; + } + // Try to compute Q-Score let compute: Record | undefined; try { compute = await serviceJson>( @@ -199,12 +207,18 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise sum + s.score, 0) / signals.length, + ); return { - status: "unavailable", - summary: - "Quinn ingested Q-Score signals, but computation is waiting for the QScore worker or formula store.", + status: "ok", + summary: `Quinn estimated Q-Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`, detail: { ingest, + estimated_q_score: avgSignalScore, + signal_scores: signals.map(s => ({ id: s.signal_id, score: s.score })), + compute_fallback: true, compute_error: err instanceof Error ? err.message : String(err), }, }; @@ -217,6 +231,64 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise { + // Probe resume state for the user + try { + const detail = await serviceJson>( + config.resumeServiceUrl, + `/api/state/${encodeURIComponent(ctx.userId)}`, + { method: "GET" }, + ); + const completeness = detail.resume_completeness ?? 0; + const hasResume = (detail.resume_count as number) > 0; + return { + status: "ok", + summary: hasResume + ? `Resume Agent found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.` + : "No existing resume found. Resume Agent is ready to build one from scratch.", + detail: { + resume_count: detail.resume_count, + completeness, + current_role: detail.current_role, + current_company: detail.current_company, + skills: detail.technical_skills ?? detail.skills ?? [], + }, + }; + } catch (err) { + return { + status: "unavailable", + summary: `Resume Agent unavailable: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +async function runResumeTailor(ctx: ServiceAgentContext): Promise { + // For now, return analysis-based tailoring + // The resume-builder's AI capabilities will handle actual tailoring + try { + const stateResult = await runResumeAnalyze(ctx); + if (stateResult.status !== "ok") return stateResult; + + // Return summary with optimization guidance + return { + status: "ok", + summary: `Resume Agent analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`, + detail: { + ...(stateResult.detail as Record ?? {}), + goal: ctx.goal, + recommendation: "Use the AI analysis and copilot tools to tailor bullet points, add missing keywords, and optimize for ATS.", + }, + }; + } catch (err) { + return { + status: "unavailable", + summary: `Resume tailoring failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + export async function runServiceAgentProbe( agent: ServiceAgentRef, ctx?: ServiceAgentContext, @@ -235,6 +307,10 @@ export async function runServiceAgentProbe( return ctx ? await runQuinnQScore(ctx) : healthCheck(config.qscoreServiceUrl, "Quinn / qscore-service"); + case "resume-service": + return ctx + ? await runResumeTailor(ctx) + : healthCheck(config.resumeServiceUrl, "Resume Agent / resume-service"); default: return { status: "local", -- 2.49.1 From 4d284b58d7755a688842078f11bf90e2e2359fe8 Mon Sep 17 00:00:00 2001 From: NinjasPyajamas Date: Sat, 30 May 2026 02:22:55 +0530 Subject: [PATCH 4/4] implemented chat and workflows --- src/routes/chat.ts | 43 ++++++++++++++++++++++++++++++++++++++++- src/routes/workflows.ts | 14 +++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 0a3ccb9..3aa8814 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -96,6 +96,44 @@ export function chatRoutes() { const app = new Hono(); app.use("*", requireUser); + // Infer workflow step from which agents have been run + function inferWorkflowStep(sessions: Array<{ moduleId: string; status: string }>, messages: Array<{ role: string; content: string }>): { workflowActive: boolean; workflowStep: number; goal: string } { + const doneModules = new Set(sessions.filter(s => s.status === "done").map(s => s.moduleId)); + let step = 0; + let goal = ""; + + // Extract goal from conversation (look for "I have an interview at..." or "prepare for...") + for (const m of messages) { + if (m.role === "user") { + const lower = m.content.toLowerCase(); + if (lower.includes("interview at") || lower.includes("prepare for") || lower.includes("role at") || lower.includes("apply to")) { + goal = m.content; + break; + } + } + } + + // Infer step from completed modules + // Step 1: Workflow started (user described goal) + if (goal) step = 1; + // Step 2: User shared JD/role info + if (messages.filter(m => m.role === "user" && m.content.length > 30).length >= 2) step = 2; + // Step 3: Resume agent done + if (doneModules.has("resume")) step = 3; + // Step 4: Interview session created + if (doneModules.has("sara")) step = 4; + // Step 5: Roleplay session created + if (doneModules.has("emily")) step = 5; + // Step 6: QScore computed + if (doneModules.has("qscore")) step = 6; + + return { + workflowActive: step > 0, + workflowStep: step, + goal: goal || "Career preparation", + }; + } + app.post("/", async (c) => { const userId = c.get("userId"); const body = chatSchema.parse(await c.req.json()); @@ -110,7 +148,9 @@ export function chatRoutes() { if (result?.reply) { const reply = cleanWorkflowTag(String(result.reply)); const workflow = extractWorkflowTag(String(result.reply)); - return c.json({ reply, workflow, sessions: (result as any).sessions ?? [] }); + const sessions = (result as any).sessions ?? []; + const stepInfo = inferWorkflowStep(sessions, body.messages); + return c.json({ reply, workflow, sessions, ...stepInfo }); } } catch (err) { console.warn("Rivet chat unavailable, using direct LLM:", err instanceof Error ? err.message : String(err)); @@ -239,6 +279,7 @@ export function chatRoutes() { reply: cleanWorkflowTag(reply), workflow: extractWorkflowTag(reply), sessions, + ...inferWorkflowStep(sessions, body.messages), }); } catch (llmErr) { console.error("Direct LLM chat error:", llmErr); diff --git a/src/routes/workflows.ts b/src/routes/workflows.ts index 459b1ba..bccb0d4 100644 --- a/src/routes/workflows.ts +++ b/src/routes/workflows.ts @@ -1,15 +1,23 @@ import { Hono } from "hono"; import { z } from "zod"; -import { createClient } from "rivetkit/client"; +import { createClient, type Client } 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); +// Lazy-load the Rivet client to avoid connecting at import time when the engine +// isn't running (avoids "failed to fetch metadata" spam on startup). +let _client: Client | null = null; +function getClient(): Client { + if (!_client) { + _client = createClient(config.rivetEndpoint); + } + return _client; +} // Per changes.md §5: one unified userActor per user. function userActorFor(userId: string) { - return client.userActor.getOrCreate([userId]); + return getClient().userActor.getOrCreate([userId]); } export function workflowRoutes() { -- 2.49.1