Compare commits

...

10 Commits

Author SHA1 Message Date
Jake Shore
b80ffee3f7 Compass mock mode - runs locally without WorkOS auth
Some checks failed
Tests / E2E Web (chromium) (push) Has been cancelled
Tests / Coverage (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Build (push) Has been cancelled
Tests / Unit Tests (push) Has been cancelled
Tests / E2E Web (firefox) (push) Has been cancelled
Tests / E2E Web (webkit) (push) Has been cancelled
Tests / E2E Desktop (macos-latest) (push) Has been cancelled
Tests / E2E Desktop (ubuntu-latest) (push) Has been cancelled
Tests / E2E Desktop (windows-latest) (push) Has been cancelled
- Middleware bypasses WorkOS when API keys not configured
- AuthWrapper conditionally loads AuthKitProvider
- getCurrentUser() returns mock dev user when no auth
- Demo mode (/demo) sets cookie for dashboard access
- Memory DB provider fallback when D1 unavailable
- CF dev proxy gated behind ENABLE_CF_DEV=1
- All changes conditional - real auth works if keys provided
2026-02-18 21:52:50 -05:00
0198898979 fix(auth): noopener returns null popup reference
window.open with noopener returns null, so the subsequent
location.href assignment was a no-op. Open without noopener,
then null out opener manually before navigating.
2026-02-16 23:51:07 -07:00
7d7eb72ea8 fix(auth): open OAuth popup before async PKCE
Browsers block window.open after an await because the user
gesture is lost. Open a blank tab synchronously first, then
set the URL after PKCE generation completes.
2026-02-16 23:45:26 -07:00
c53f3a5fac feat(auth): add Anthropic OAuth with PKCE
Browser-based OAuth flow using Anthropic's hosted callback.
Users authorize on claude.ai, paste the code back, and tokens
are encrypted and stored in D1. Includes auto-refresh, Bearer
auth via custom fetch wrapper, and mcp_ tool name prefixing
required by the OAuth endpoint.

Also fixes provider-config save bug that required encryption
key unconditionally — now only checks when API key is present.
2026-02-16 22:05:01 -07:00
3f8d273986 feat(agent): MCP-based tool architecture
Extract agent-core as shared package with agentic loop,
tool definitions, and MCP integration. Compass tools
wrapped as MCP server using low-level Server API. Client
manager connects multiple MCP servers (in-memory, stdio,
HTTP) with unified tool routing. External MCP server
configs stored in DB with CRUD actions. Both Workers and
Bun runtimes use the new MCP client manager.
2026-02-16 20:14:57 -07:00
7ee5304176 feat(auth): add organization invite links
Shareable invite codes (e.g. hps-k7m2x9) let anyone
join an org after authenticating. Admins create/revoke
links from Settings > Team. Public /join/[code] route
handles acceptance with expiry and max-use limits.
2026-02-16 20:08:07 -07:00
4fc952cddd fix(auth): pass userId through signup verification flow
Signup redirect now includes userId in the query string so the
verify-email endpoint receives it. After successful verification,
the endpoint creates a local DB user via ensureUserExists() so
the account is ready for login.
2026-02-16 19:46:48 -07:00
feb7dc2643 refactor(settings): unify agent tabs into single Agent section
Consolidate AI Model, Skills, Code Bridge, and Identity into
one Agent tab with pill-style sub-navigation. Reduces top-level
settings from 8 tabs to 5 (page) / 6 (modal).
2026-02-16 18:54:45 -07:00
7f5efb84e2 feat(agent): migrate to Anthropic Agents SDK
Replace Vercel AI SDK with Anthropic Claude Agent SDK.
Add standalone agent server (packages/agent-server/)
with MCP tools, JWT auth, and SSE streaming. Introduce
bridge API routes (src/app/api/compass/) and custom
SSE hooks (use-agent, use-compass-chat) replacing
useChat. Remove provider.ts, tools.ts, system-prompt.ts,
github-tools.ts, usage.ts, and old agent route.
2026-02-16 18:37:26 -07:00
50c7d1d1e4 feat(desktop): add UI zoom scaling
Add zoom controls to Theme settings (slider 50-200%)
with Tauri native webview zoom and font-size fallback.
Keyboard shortcuts (Ctrl+=/Ctrl+-/Ctrl+0) wired in
desktop shell. Zoom level persists in localStorage.
2026-02-16 18:35:56 -07:00
116 changed files with 25729 additions and 4342 deletions

1
.gitignore vendored
View File

@ -22,6 +22,7 @@ dist/
# misc # misc
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
*.png
# dev tools # dev tools
.playwright-mcp .playwright-mcp

View File

@ -5,7 +5,6 @@
"": { "": {
"name": "dashboard-app-template", "name": "dashboard-app-template",
"dependencies": { "dependencies": {
"@ai-sdk/react": "^3.0.74",
"@capacitor/android": "^8.0.2", "@capacitor/android": "^8.0.2",
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
"@capacitor/camera": "^8.0.0", "@capacitor/camera": "^8.0.0",
@ -31,8 +30,8 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@json-render/core": "^0.4.0", "@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0", "@json-render/react": "^0.4.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@opennextjs/cloudflare": "^1.14.4", "@opennextjs/cloudflare": "^1.14.4",
"@openrouter/ai-sdk-provider": "^2.1.1",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-aspect-ratio": "^1.1.8",
@ -76,7 +75,7 @@
"@workos-inc/authkit-nextjs": "^2.13.0", "@workos-inc/authkit-nextjs": "^2.13.0",
"@workos-inc/node": "^8.1.0", "@workos-inc/node": "^8.1.0",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"ai": "^6.0.73", "agent-core": "file:packages/agent-core",
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -89,6 +88,7 @@
"frappe-gantt": "^1.0.4", "frappe-gantt": "^1.0.4",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"isomorphic-dompurify": "^3.0.0-rc.2", "isomorphic-dompurify": "^3.0.0-rc.2",
"jose": "^6.1.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"marked": "^17.0.2", "marked": "^17.0.2",
"motion": "^12.33.0", "motion": "^12.33.0",
@ -143,16 +143,10 @@
"packages": { "packages": {
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.36", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2r1Q6azvqMYxQ1hqfWZmWg4+8MajoldD/ty65XdhCaCoBfvDu7trcvxXDfTSU+3/wZ1JIDky46SWYFOHnTbsBw=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og=="],
"@ai-sdk/react": ["@ai-sdk/react@3.0.74", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.13", "ai": "6.0.72", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-L8N9HNM9Vt3rxORhX6+KCrsYRI6ZXGz1q8o/ysw6+Sx3MC0pqSZLiaKYifIYe2TSWgLP5mWcGlA5hHPuq5Jdfw=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="], "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.8", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.5" } }, "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ=="], "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.8", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.5" } }, "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ=="],
@ -463,6 +457,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@ -563,6 +559,8 @@
"@json-render/react": ["@json-render/react@0.4.0", "", { "dependencies": { "@json-render/core": "0.4.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-OAXdWdOrAXHEFzpEF7xV+84D00JEmLMKlt5u0wc+C/P+q4q6TnpAWx2j28PBpDB2mpidkW9VnTUM+SCH9J8Lrw=="], "@json-render/react": ["@json-render/react@0.4.0", "", { "dependencies": { "@json-render/core": "0.4.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-OAXdWdOrAXHEFzpEF7xV+84D00JEmLMKlt5u0wc+C/P+q4q6TnpAWx2j28PBpDB2mpidkW9VnTUM+SCH9J8Lrw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next/env": ["@next/env@15.5.9", "", {}, "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg=="], "@next/env": ["@next/env@15.5.9", "", {}, "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg=="],
@ -609,8 +607,6 @@
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.15.0", "", { "dependencies": { "@ast-grep/napi": "0.40.0", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.11", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "^14.2.35 || ~15.0.7 || ~15.1.11 || ~15.2.8 || ~15.3.8 || ~15.4.10 || ~15.5.9 || ^16.0.10", "wrangler": "^4.59.2" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-AZPaqk25XUBxtdkfjUZQBbY3ovifVLC4GgSRHuejqsIWfv8KjTRNFVdaCaaPmbLkrgymqxNhkbfJS5sD28AK/g=="], "@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.15.0", "", { "dependencies": { "@ast-grep/napi": "0.40.0", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.11", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "^14.2.35 || ~15.0.7 || ~15.1.11 || ~15.2.8 || ~15.3.8 || ~15.4.10 || ~15.5.9 || ^16.0.10", "wrangler": "^4.59.2" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-AZPaqk25XUBxtdkfjUZQBbY3ovifVLC4GgSRHuejqsIWfv8KjTRNFVdaCaaPmbLkrgymqxNhkbfJS5sD28AK/g=="],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.1.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-UypPbVnSExxmG/4Zg0usRiit3auvQVrjUXSyEhm0sZ9GQnW/d8p/bKgCk2neh1W5YyRSo7PNQvCrAEBHZnqQkQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="], "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="],
@ -1253,8 +1249,6 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
"@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="],
@ -1289,12 +1283,14 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"agent-core": ["agent-core@file:packages/agent-core", { "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "zod": "^3.24.1" }, "devDependencies": { "bun-types": "^1.3.9" } }],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@6.0.73", "", { "dependencies": { "@ai-sdk/gateway": "3.0.36", "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-p2/ICXIjAM4+bIFHEkAB+l58zq+aTmxAkotsb6doNt/CEms72zt6gxv2ky1fQDwU4ecMOcmMh78VJUSEKECzlg=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@ -1379,6 +1375,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
@ -1439,10 +1437,12 @@
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@ -1649,6 +1649,8 @@
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
@ -1659,6 +1661,8 @@
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@ -1671,6 +1675,8 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-parser": ["fast-xml-parser@4.2.5", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g=="], "fast-xml-parser": ["fast-xml-parser@4.2.5", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
@ -1791,6 +1797,8 @@
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
@ -1833,6 +1841,8 @@
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="], "iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
@ -1925,7 +1935,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
@ -1935,10 +1945,12 @@
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
@ -2243,6 +2255,8 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
@ -2525,8 +2539,6 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"swr": ["swr@2.4.0", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
@ -2543,8 +2555,6 @@
"terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="], "terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
@ -2581,6 +2591,8 @@
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"ts-tqdm": ["ts-tqdm@0.8.6", "", {}, "sha512-3X3M1PZcHtgQbnwizL+xU8CAgbYbeLHrrDwL9xxcZZrV5J+e7loJm1XrXozHjSkl44J0Zg0SgA8rXbh83kCkcQ=="], "ts-tqdm": ["ts-tqdm@0.8.6", "", {}, "sha512-3X3M1PZcHtgQbnwizL+xU8CAgbYbeLHrrDwL9xxcZZrV5J+e7loJm1XrXozHjSkl44J0Zg0SgA8rXbh83kCkcQ=="],
@ -2743,12 +2755,12 @@
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ai-sdk/react/ai": ["ai@6.0.72", "", { "dependencies": { "@ai-sdk/gateway": "3.0.35", "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-D3TzDX6LzYL8qwi1A0rLnmuUexqDcCu4LSg77hcDHsqNRkaGspGItkz1U3RnN3ojv31XQYI9VmoWpkj44uvIUA=="],
"@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], "@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.972.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug=="], "@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.972.0", "", { "dependencies": { "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug=="],
@ -3275,10 +3287,14 @@
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"@node-minify/core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "@node-minify/core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"@opennextjs/aws/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "@opennextjs/aws/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@opennextjs/aws/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@ -3401,7 +3417,11 @@
"@workos-inc/authkit-nextjs/@workos-inc/node": ["@workos-inc/node@7.82.0", "", { "dependencies": { "iron-session": "~6.3.1", "jose": "~5.6.3", "leb": "^1.0.0", "qs": "6.14.1" } }, "sha512-8h6XjIJf8nqNGYQMkWsjZ72WXMtzqrb4Azz39schXWoSRmwoK6tK+GpeAviJ9slddiJdOcp0Ht9/r+L6pGQMCg=="], "@workos-inc/authkit-nextjs/@workos-inc/node": ["@workos-inc/node@7.82.0", "", { "dependencies": { "iron-session": "~6.3.1", "jose": "~5.6.3", "leb": "^1.0.0", "qs": "6.14.1" } }, "sha512-8h6XjIJf8nqNGYQMkWsjZ72WXMtzqrb4Azz39schXWoSRmwoK6tK+GpeAviJ9slddiJdOcp0Ht9/r+L6pGQMCg=="],
"@workos-inc/node/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "@workos-inc/authkit-nextjs/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"agent-core/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
@ -3431,8 +3451,6 @@
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@ -3441,8 +3459,6 @@
"glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"iron-session/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"iron-session/iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], "iron-session/iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="],
"jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], "jsdom/parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="],
@ -3515,7 +3531,7 @@
"yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"@ai-sdk/react/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@ai-sdk/provider-utils": "4.0.13", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9aRTVM1P1u4yUIjBpco/WCF1WXr/DgWKuDYgLLHdENS8kiEuxDOPJuGbc/6+7EwQ6ZqSh0UOgeqvHfGJfU23Qg=="], "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], "@aws-crypto/crc32/@aws-sdk/types/@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
@ -3979,6 +3995,8 @@
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@node-minify/core/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="], "@node-minify/core/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="],
"@node-minify/core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], "@node-minify/core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
@ -4017,6 +4035,8 @@
"@workos-inc/authkit-nextjs/@workos-inc/node/jose": ["jose@5.6.3", "", {}, "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g=="], "@workos-inc/authkit-nextjs/@workos-inc/node/jose": ["jose@5.6.3", "", {}, "sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g=="],
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "cliui/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],

View File

@ -0,0 +1,10 @@
CREATE TABLE `user_provider_config` (
`user_id` text PRIMARY KEY NOT NULL,
`provider_type` text NOT NULL,
`api_key` text,
`base_url` text,
`model_overrides` text,
`is_active` integer DEFAULT 1 NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);

View File

@ -0,0 +1,33 @@
CREATE TABLE `organization_invites` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`code` text NOT NULL,
`role` text DEFAULT 'office' NOT NULL,
`max_uses` integer,
`use_count` integer DEFAULT 0 NOT NULL,
`expires_at` text,
`created_by` text NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `organization_invites_code_unique` ON `organization_invites` (`code`);--> statement-breakpoint
CREATE TABLE `mcp_servers` (
`id` text PRIMARY KEY NOT NULL,
`org_id` text NOT NULL,
`name` text NOT NULL,
`slug` text NOT NULL,
`transport` text NOT NULL,
`command` text,
`args` text,
`env` text,
`url` text,
`headers` text,
`is_enabled` integer DEFAULT true NOT NULL,
`created_at` text NOT NULL,
`created_by` text NOT NULL,
FOREIGN KEY (`org_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);

View File

@ -0,0 +1,8 @@
CREATE TABLE `anthropic_oauth_tokens` (
`user_id` text PRIMARY KEY NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`expires_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -190,6 +190,27 @@
"when": 1771215013379, "when": 1771215013379,
"tag": "0026_easy_professor_monster", "tag": "0026_easy_professor_monster",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1771282232152,
"tag": "0027_flowery_hulk",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1771295883108,
"tag": "0028_small_old_lace",
"breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1771303716734,
"tag": "0029_fantastic_mach_iv",
"breakpoints": true
} }
] ]
} }

View File

@ -1,6 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
transpilePackages: ["agent-core"],
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
@ -26,8 +27,19 @@ export default nextConfig;
// Enable calling `getCloudflareContext()` in `next dev`. // Enable calling `getCloudflareContext()` in `next dev`.
// See https://opennext.js.org/cloudflare/bindings#local-access-to-bindings. // See https://opennext.js.org/cloudflare/bindings#local-access-to-bindings.
// Only init in dev -- build and lint don't need the wrangler proxy. // Only init in dev -- build and lint don't need the wrangler proxy.
if (process.env.NODE_ENV === "development") { // Disabled for local dev without Cloudflare account access:
if (process.env.NODE_ENV === "development" && process.env.ENABLE_CF_DEV === "1") {
import("@opennextjs/cloudflare").then((mod) => import("@opennextjs/cloudflare").then((mod) =>
mod.initOpenNextCloudflareForDev() mod.initOpenNextCloudflareForDev()
); );
} else if (process.env.NODE_ENV === "development") {
// When Cloudflare dev proxy is not enabled, set a mock context so
// getCloudflareContext() doesn't throw. Actions check `env?.DB` and
// gracefully return empty data when it's missing.
const sym = Symbol.for("__cloudflare-context__");
(globalThis as Record<symbol, unknown>)[sym] = {
env: {}, // no DB binding — actions will short-circuit
cf: {},
ctx: { waitUntil: () => {}, passThroughOnException: () => {} },
};
} }

View File

@ -32,7 +32,6 @@
"test:e2e:desktop": "TAURI=true playwright test --project=desktop-chromium" "test:e2e:desktop": "TAURI=true playwright test --project=desktop-chromium"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^3.0.74",
"@capacitor/android": "^8.0.2", "@capacitor/android": "^8.0.2",
"@capacitor/app": "^8.0.0", "@capacitor/app": "^8.0.0",
"@capacitor/camera": "^8.0.0", "@capacitor/camera": "^8.0.0",
@ -58,8 +57,8 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@json-render/core": "^0.4.0", "@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0", "@json-render/react": "^0.4.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"@opennextjs/cloudflare": "^1.14.4", "@opennextjs/cloudflare": "^1.14.4",
"@openrouter/ai-sdk-provider": "^2.1.1",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-aspect-ratio": "^1.1.8",
@ -103,7 +102,7 @@
"@workos-inc/authkit-nextjs": "^2.13.0", "@workos-inc/authkit-nextjs": "^2.13.0",
"@workos-inc/node": "^8.1.0", "@workos-inc/node": "^8.1.0",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"ai": "^6.0.73", "agent-core": "file:packages/agent-core",
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -116,6 +115,7 @@
"frappe-gantt": "^1.0.4", "frappe-gantt": "^1.0.4",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"isomorphic-dompurify": "^3.0.0-rc.2", "isomorphic-dompurify": "^3.0.0-rc.2",
"jose": "^6.1.3",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"marked": "^17.0.2", "marked": "^17.0.2",
"motion": "^12.33.0", "motion": "^12.33.0",

View File

@ -0,0 +1,214 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "agent-core",
"dependencies": {
"@anthropic-ai/sdk": "^0.74.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"zod": "^3.24.1",
},
"devDependencies": {
"bun-types": "^1.3.9",
},
},
},
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="],
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
}
}

View File

@ -0,0 +1,14 @@
{
"name": "agent-core",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"dependencies": {
"@anthropic-ai/sdk": "^0.74.0",
"@modelcontextprotocol/sdk": "^1.26.0",
"zod": "^3.24.1"
},
"devDependencies": {
"bun-types": "^1.3.9"
}
}

View File

@ -0,0 +1,57 @@
import Anthropic from "@anthropic-ai/sdk"
import type { ProviderConfig } from "./types"
export function createClient(provider: ProviderConfig): Anthropic {
switch (provider.type) {
case "anthropic": {
// OAuth tokens use Bearer auth instead of x-api-key
if (provider.apiKey?.startsWith("sk-ant-oat")) {
const oauthToken = provider.apiKey
const wrappedFetch: typeof globalThis.fetch = (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url
const betaUrl = url.includes("?")
? `${url}&beta=true`
: `${url}?beta=true`
const existingHeaders =
(init?.headers as Record<string, string> | undefined) ?? {}
// Remove x-api-key if SDK sets it, add Bearer
const { "x-api-key": _dropped, ...rest } = existingHeaders
return globalThis.fetch(betaUrl, {
...init,
headers: {
...rest,
authorization: `Bearer ${oauthToken}`,
"anthropic-beta":
"oauth-2025-04-20,interleaved-thinking-2025-05-14",
},
})
}
return new Anthropic({ apiKey: "unused", fetch: wrappedFetch })
}
return new Anthropic({
apiKey: provider.apiKey,
...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
})
}
case "openrouter":
return new Anthropic({
apiKey: provider.apiKey ?? "",
baseURL: "https://openrouter.ai/api",
})
case "ollama":
return new Anthropic({
apiKey: "ollama",
baseURL: provider.baseUrl ?? "http://localhost:11434",
})
case "custom":
return new Anthropic({
apiKey: provider.apiKey ?? "",
...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
})
}
}

View File

@ -0,0 +1,27 @@
export { createClient } from "./client"
export { runAgent } from "./loop"
export { createTools } from "./tools"
export type { ToolDef } from "./tools"
export { zodToJsonSchema } from "./tools"
export { buildSystemPrompt } from "./system-prompt"
export { sseEncode, createSSEStream } from "./stream-helpers"
export {
createCompassServer,
createClientManager,
} from "./mcp"
export type {
McpServerConfig,
McpClientManager,
} from "./mcp"
export type {
ProviderConfig,
AgentContext,
DataSource,
SSEData,
} from "./types"
export {
generatePKCE,
buildAuthUrl,
exchangeCode,
refreshAccessToken,
} from "./oauth"

View File

@ -0,0 +1,248 @@
import type Anthropic from "@anthropic-ai/sdk"
import type { Tool } from "@anthropic-ai/sdk/resources/messages/messages"
import { createClient } from "./client"
import type { ProviderConfig, SSEData } from "./types"
import type { ToolDef } from "./tools"
import type { McpClientManager } from "./mcp/types"
interface AgentOptions {
readonly provider: ProviderConfig
readonly model: string
readonly systemPrompt: string
readonly messages: ReadonlyArray<{
role: "user" | "assistant"
content: string
}>
readonly tools?: readonly ToolDef[]
readonly mcpClientManager?: McpClientManager
readonly maxTurns?: number
readonly isOAuth?: boolean
}
export async function* runAgent(
opts: AgentOptions
): AsyncGenerator<SSEData> {
const client = createClient(opts.provider)
const maxTurns = opts.maxTurns ?? 25
// Mutable messages array for the agentic loop
const messages: Anthropic.MessageParam[] = opts.messages.map(
(m) => ({
role: m.role,
content: m.content,
})
)
// Build tool map for execution and API tool definitions
const toolMap = new Map<
string,
(input: unknown) => Promise<string> | string
>()
const apiTools: Tool[] = []
// Register direct tools first (they take priority)
if (opts.tools) {
for (const tool of opts.tools) {
toolMap.set(tool.name, tool.run)
apiTools.push({
name: tool.name,
description: tool.description,
input_schema:
tool.input_schema as Tool.InputSchema,
})
}
}
// Register MCP tools (skip if already provided as direct)
const mcpManager = opts.mcpClientManager
if (mcpManager) {
for (const tool of mcpManager.listTools()) {
if (toolMap.has(tool.name)) continue
apiTools.push({
name: tool.name,
description: tool.description,
input_schema:
tool.input_schema as Tool.InputSchema,
})
}
}
// OAuth endpoint requires mcp_ prefix on tool names
const effectiveTools: Tool[] = opts.isOAuth
? apiTools.map((t) => ({ ...t, name: `mcp_${t.name}` }))
: apiTools
let turn = 0
while (turn < maxTurns) {
turn++
try {
const stream = client.messages.stream({
model: opts.model,
max_tokens: 8192,
system: opts.systemPrompt,
messages,
tools: effectiveTools,
})
// Stream text deltas and tool_use starts to the caller
for await (const event of stream) {
if (event.type === "content_block_start") {
const block = event.content_block
if (block.type === "tool_use") {
const displayName =
opts.isOAuth && block.name.startsWith("mcp_")
? block.name.slice(4)
: block.name
yield {
type: "tool_use",
name: displayName,
toolCallId: block.id,
input: {},
}
}
}
if (event.type === "content_block_delta") {
const delta = event.delta
if (
"text" in delta &&
delta.type === "text_delta"
) {
yield { type: "text", content: delta.text }
}
}
}
const message = await stream.finalMessage()
// No tool calls — we're done
if (message.stop_reason !== "tool_use") {
yield {
type: "result",
subtype: "success",
result: message.content
.filter(
(b): b is Anthropic.TextBlock =>
b.type === "text"
)
.map((b) => b.text)
.join(""),
usage: message.usage
? {
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
totalCostUsd: 0,
}
: undefined,
}
return
}
// Execute tool calls and continue the loop
messages.push({
role: "assistant",
content: message.content,
})
const toolResults: Anthropic.ToolResultBlockParam[] = []
for (const block of message.content) {
if (block.type !== "tool_use") continue
// Strip mcp_ prefix from OAuth tool calls for local dispatch
const toolName =
opts.isOAuth && block.name.startsWith("mcp_")
? block.name.slice(4)
: block.name
const runFn = toolMap.get(toolName)
// Route: direct tool -> MCP manager -> unknown
if (!runFn && !mcpManager) {
const errorResult = JSON.stringify({
error: `Unknown tool: ${toolName}`,
})
yield {
type: "tool_result",
toolCallId: block.id,
output: errorResult,
}
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: errorResult,
is_error: true,
})
continue
}
try {
let result: string
if (runFn) {
result = await runFn(block.input)
} else if (mcpManager) {
result = await mcpManager.callTool(
toolName,
block.input
)
} else {
result = JSON.stringify({
error: `Unknown tool: ${toolName}`,
})
}
let parsed: unknown
try {
parsed = JSON.parse(result)
} catch {
parsed = result
}
yield {
type: "tool_result",
toolCallId: block.id,
output: parsed,
}
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result,
})
} catch (err) {
const errorMsg =
err instanceof Error
? err.message
: String(err)
yield {
type: "tool_result",
toolCallId: block.id,
output: { error: errorMsg },
}
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: JSON.stringify({ error: errorMsg }),
is_error: true,
})
}
}
messages.push({ role: "user", content: toolResults })
} catch (err) {
yield {
type: "error",
error:
err instanceof Error ? err.message : String(err),
}
return
}
}
// Max turns reached
yield {
type: "result",
subtype: "success",
result: "Max turns reached",
usage: undefined,
}
}

View File

@ -0,0 +1,228 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import {
StreamableHTTPClientTransport,
} from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import type { Server } from "@modelcontextprotocol/sdk/server/index.js"
import type { McpServerConfig, McpClientManager } from "./types"
interface ConnectedServer {
readonly client: Client
readonly toolNames: ReadonlySet<string>
}
interface ToolEntry {
readonly name: string
readonly description: string
readonly input_schema: Record<string, unknown>
readonly serverName: string
readonly originalName: string
}
const COMPASS_SERVER_NAME = "compass"
export function createClientManager(
compassServer?: Server
): McpClientManager {
const servers = new Map<string, ConnectedServer>()
const toolIndex = new Map<string, { serverName: string; originalName: string }>()
let cachedTools: ToolEntry[] = []
async function connectServer(
config: McpServerConfig
): Promise<void> {
const client = new Client({
name: `compass-client-${config.name}`,
version: "1.0.0",
})
const transport = config.transport
if (transport.type === "in-memory") {
if (!compassServer) {
console.error(
"in-memory transport requires compassServer"
)
return
}
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair()
await compassServer.connect(serverTransport)
await client.connect(clientTransport)
} else if (transport.type === "stdio") {
const stdioTransport = new StdioClientTransport({
command: transport.command,
args: transport.args ? [...transport.args] : undefined,
env: transport.env
? { ...transport.env }
: undefined,
})
await client.connect(stdioTransport)
} else if (transport.type === "http") {
const httpTransport = new StreamableHTTPClientTransport(
new URL(transport.url),
transport.headers
? {
requestInit: {
headers: { ...transport.headers },
},
}
: undefined
)
await client.connect(httpTransport)
}
const result = await client.listTools()
const toolNames = new Set<string>()
for (const tool of result.tools) {
toolNames.add(tool.name)
}
servers.set(config.name, { client, toolNames })
}
function buildToolIndex(): void {
toolIndex.clear()
cachedTools = []
for (const [serverName, server] of servers) {
const isCompass = serverName === COMPASS_SERVER_NAME
for (const toolName of server.toolNames) {
const publicName = isCompass
? toolName
: `${serverName}__${toolName}`
toolIndex.set(publicName, {
serverName,
originalName: toolName,
})
}
}
}
return {
async connect(
configs: readonly McpServerConfig[]
): Promise<void> {
for (const config of configs) {
if (!config.enabled) continue
try {
await connectServer(config)
} catch (err) {
console.error(
`Failed to connect MCP server "${config.name}":`,
err
)
}
}
buildToolIndex()
// Fetch full tool metadata after index is built
for (const [serverName, server] of servers) {
const isCompass = serverName === COMPASS_SERVER_NAME
try {
const result = await server.client.listTools()
for (const tool of result.tools) {
const publicName = isCompass
? tool.name
: `${serverName}__${tool.name}`
cachedTools.push({
name: publicName,
description: tool.description ?? "",
input_schema:
(tool.inputSchema as Record<string, unknown>) ??
{},
serverName,
originalName: tool.name,
})
}
} catch (err) {
console.error(
`Failed to list tools from "${serverName}":`,
err
)
}
}
},
listTools(): Array<{
name: string
description: string
input_schema: Record<string, unknown>
serverName: string
}> {
return cachedTools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.input_schema,
serverName: t.serverName,
}))
},
async callTool(
name: string,
input: unknown
): Promise<string> {
const entry = toolIndex.get(name)
if (!entry) {
return JSON.stringify({
error: `Unknown MCP tool: ${name}`,
})
}
const server = servers.get(entry.serverName)
if (!server) {
return JSON.stringify({
error: `MCP server "${entry.serverName}" not connected`,
})
}
const result = await server.client.callTool({
name: entry.originalName,
arguments: input as Record<string, unknown>,
})
// Extract text content from MCP result
const contents = result.content
if (!Array.isArray(contents) || contents.length === 0) {
return ""
}
const textParts: string[] = []
for (const part of contents) {
if (
typeof part === "object" &&
part !== null &&
"type" in part &&
part.type === "text" &&
"text" in part &&
typeof part.text === "string"
) {
textParts.push(part.text)
}
}
return textParts.join("")
},
async disconnect(): Promise<void> {
for (const [, server] of servers) {
try {
await server.client.close()
} catch {
// ignore close errors
}
}
servers.clear()
toolIndex.clear()
cachedTools = []
},
}
}

View File

@ -0,0 +1,73 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import {
ListToolsRequestSchema,
CallToolRequestSchema,
type ListToolsResult,
type CallToolResult,
} from "@modelcontextprotocol/sdk/types.js"
import { createTools } from "../tools"
import type { DataSource } from "../types"
// McpServer (high-level API) requires Zod schemas for tool registration.
// Our tools use JSON Schema (Record<string, unknown>), so we use the
// low-level Server with manual request handlers instead.
// Type predicates to convert our JSON Schema shape into the
// MCP-expected inputSchema type without using `as` assertions.
function isObjectRecord(val: unknown): val is Record<string, object> {
return typeof val === "object" && val !== null && !Array.isArray(val)
}
function isStringArray(val: unknown): val is string[] {
return Array.isArray(val) && val.every((v) => typeof v === "string")
}
function toInputSchema(
raw: Record<string, unknown>,
): { type: "object"; properties?: Record<string, object>; required?: string[] } {
const props = raw["properties"]
const reqs = raw["required"]
return {
type: "object",
...(isObjectRecord(props) && { properties: props }),
...(isStringArray(reqs) && { required: reqs }),
}
}
export function createCompassServer(dataSource: DataSource): Server {
const server = new Server(
{ name: "compass", version: "1.0.0" },
{ capabilities: { tools: {} } },
)
const tools = createTools(dataSource)
const toolMap = new Map(tools.map((t) => [t.name, t]))
server.setRequestHandler(
ListToolsRequestSchema,
(): ListToolsResult => ({
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: toInputSchema(t.input_schema),
})),
}),
)
server.setRequestHandler(
CallToolRequestSchema,
async (req): Promise<CallToolResult> => {
const tool = toolMap.get(req.params.name)
if (tool === undefined) {
return {
content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }],
isError: true,
}
}
const result = await tool.run(req.params.arguments ?? {})
return { content: [{ type: "text", text: result }] }
},
)
return server
}

View File

@ -0,0 +1,6 @@
export { createCompassServer } from "./compass-server"
export { createClientManager } from "./client-manager"
export type {
McpServerConfig,
McpClientManager,
} from "./types"

View File

@ -0,0 +1,32 @@
export interface McpServerConfig {
readonly name: string
readonly transport:
| { readonly type: "in-memory" }
| {
readonly type: "stdio"
readonly command: string
readonly args?: readonly string[]
readonly env?: Readonly<Record<string, string>>
}
| {
readonly type: "http"
readonly url: string
readonly headers?: Readonly<Record<string, string>>
}
readonly enabled: boolean
}
export interface McpClientManager {
connect(configs: readonly McpServerConfig[]): Promise<void>
listTools(): Array<{
name: string
description: string
input_schema: Record<string, unknown>
serverName: string
}>
callTool(name: string, input: unknown): Promise<string>
disconnect(): Promise<void>
}

View File

@ -0,0 +1,118 @@
// Anthropic OAuth constants (same as Claude Code / pi-ai)
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
const SCOPES = "org:create_api_key user:profile user:inference"
interface OAuthTokenResponse {
readonly access_token: string
readonly refresh_token: string
readonly expires_in: number
readonly token_type: string
}
function base64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ""
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
export async function generatePKCE(): Promise<{
verifier: string
challenge: string
}> {
// 32 random bytes -> 43 base64url chars, well within 43-128 range
const verifierBytes = crypto.getRandomValues(new Uint8Array(32))
const verifier = base64url(verifierBytes.buffer)
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const hash = await crypto.subtle.digest("SHA-256", data)
const challenge = base64url(hash)
return { verifier, challenge }
}
export function buildAuthUrl(challenge: string): string {
const params = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPES,
code_challenge: challenge,
code_challenge_method: "S256",
})
return `${AUTHORIZE_URL}?${params.toString()}`
}
function parseTokenResponse(raw: unknown): {
access: string
refresh: string
expiresAt: number
} {
if (
typeof raw !== "object" ||
raw === null ||
!("access_token" in raw) ||
!("refresh_token" in raw) ||
!("expires_in" in raw)
) {
throw new Error("Unexpected token response shape")
}
const resp = raw as OAuthTokenResponse
return {
access: resp.access_token,
refresh: resp.refresh_token,
expiresAt: Date.now() + resp.expires_in * 1000,
}
}
async function postToken(body: Record<string, string>): Promise<{
access: string
refresh: string
expiresAt: number
}> {
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Token request failed (${res.status}): ${text}`)
}
const json: unknown = await res.json()
return parseTokenResponse(json)
}
export async function exchangeCode(
code: string,
state: string,
verifier: string,
): Promise<{ access: string; refresh: string; expiresAt: number }> {
return postToken({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
state,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
})
}
export async function refreshAccessToken(
refreshToken: string,
): Promise<{ access: string; refresh: string; expiresAt: number }> {
return postToken({
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
})
}

View File

@ -0,0 +1,34 @@
import type { SSEData } from "./types"
export function sseEncode(data: SSEData): string {
return `data: ${JSON.stringify(data)}\n\n`
}
export function createSSEStream(
generator: AsyncGenerator<SSEData>
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
return new ReadableStream({
async start(controller) {
try {
for await (const event of generator) {
controller.enqueue(encoder.encode(sseEncode(event)))
}
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
controller.close()
} catch (err) {
const errorEvent: SSEData = {
type: "error",
error:
err instanceof Error ? err.message : String(err),
}
controller.enqueue(
encoder.encode(sseEncode(errorEvent))
)
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
controller.close()
}
},
})
}

View File

@ -0,0 +1,90 @@
import type { AgentContext } from "./types"
interface Message {
readonly role: "user" | "assistant"
readonly content: string
}
interface McpToolInfo {
readonly serverName: string
readonly name: string
}
interface SystemPromptOptions {
readonly context: AgentContext
readonly messages: readonly Message[]
readonly externalMcpTools?: readonly McpToolInfo[]
}
export function buildSystemPrompt(
contextOrOpts: AgentContext | SystemPromptOptions,
messages?: readonly Message[]
): string {
// Support both old and new call signatures
const opts: SystemPromptOptions =
"context" in contextOrOpts
? contextOrOpts
: { context: contextOrOpts, messages: messages ?? [] }
const ctx = opts.context
const history = buildConversationHistory(opts.messages)
const externalTools = buildExternalToolsSection(
opts.externalMcpTools
)
return `You are Compass AI, an intelligent assistant for project \
management and collaboration.
Current context:
- User ID: ${ctx.userId}
- Organization: ${ctx.orgId}
- Role: ${ctx.role}
- Current page: ${ctx.currentPage}
- Timezone: ${ctx.timezone}
You have tools for querying data, navigating the UI, managing \
schedules, themes, memories, skills, dashboards, and GitHub \
integration.
When a tool returns an "action" field in its result, that action \
will be forwarded to the client for execution (navigation, toasts, \
UI generation, theme changes, etc.). You don't need to do anything \
extra \u2014 just call the tool and the action dispatches \
automatically.${externalTools}${history}`
}
function buildExternalToolsSection(
tools?: readonly McpToolInfo[]
): string {
if (!tools || tools.length === 0) return ""
const byServer = new Map<string, string[]>()
for (const tool of tools) {
const list = byServer.get(tool.serverName) ?? []
list.push(tool.name)
byServer.set(tool.serverName, list)
}
const sections: string[] = []
for (const [server, names] of byServer) {
sections.push(
`- ${server}: ${names.join(", ")}`
)
}
return `\n\nExternal MCP servers connected:\n${sections.join("\n")}`
}
function buildConversationHistory(
messages: readonly Message[]
): string {
const history = messages.slice(0, -1)
if (history.length === 0) return ""
const lines = history.map((m) => {
const role = m.role === "user" ? "User" : "Assistant"
return `${role}: ${m.content}`
})
return `\n\nConversation so far:\n${lines.join("\n")}\n\nContinue the conversation naturally.`
}

View File

@ -0,0 +1,110 @@
import { z } from "zod"
import type { DataSource } from "../types"
import type { ToolDef } from "./data"
import { zodToJsonSchema } from "./data"
const saveSchema = z.object({
name: z.string().describe("Dashboard display name"),
description: z
.string()
.optional()
.describe("Brief description of the dashboard"),
dashboardId: z
.string()
.optional()
.describe("Existing dashboard ID to update (for edits)"),
})
const listSchema = z.object({})
const editSchema = z.object({
dashboardId: z
.string()
.describe("ID of the dashboard to edit"),
editPrompt: z
.string()
.optional()
.describe("Description of changes to make"),
})
const deleteSchema = z.object({
dashboardId: z
.string()
.describe("ID of the dashboard to delete"),
})
export function dashboardTools(
dataSource: DataSource
): ToolDef[] {
return [
{
name: "saveDashboard",
description:
"Save the currently rendered UI as a named dashboard. " +
"The client captures the current spec and data context " +
"automatically. Returns an action for the client to " +
"handle the save.",
input_schema: zodToJsonSchema(saveSchema),
run: async (input: unknown): Promise<string> => {
const args = saveSchema.parse(input)
return JSON.stringify({
action: "save_dashboard",
name: args.name,
description: args.description ?? "",
dashboardId: args.dashboardId,
})
},
},
{
name: "listDashboards",
description: "List the user's saved custom dashboards.",
input_schema: zodToJsonSchema(listSchema),
run: async (): Promise<string> => {
const result = await dataSource.fetch(
"/api/compass/dashboards/list"
)
return JSON.stringify(result)
},
},
{
name: "editDashboard",
description:
"Load a saved dashboard for editing. The client " +
"injects the spec into the render context and " +
"navigates to /dashboard. Optionally pass an " +
"editPrompt to trigger immediate re-generation.",
input_schema: zodToJsonSchema(editSchema),
run: async (input: unknown): Promise<string> => {
const args = editSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/dashboards/get",
{ dashboardId: args.dashboardId }
)
return JSON.stringify({
action: "load_dashboard",
dashboardId: args.dashboardId,
spec: result,
editPrompt: args.editPrompt,
})
},
},
{
name: "deleteDashboard",
description:
"Delete a saved dashboard. Always confirm with the " +
"user before deleting.",
input_schema: zodToJsonSchema(deleteSchema),
run: async (input: unknown): Promise<string> => {
const args = deleteSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/dashboards/delete",
args
)
return JSON.stringify(result)
},
},
]
}

View File

@ -0,0 +1,164 @@
import { z } from "zod"
import type { DataSource } from "../types"
export interface ToolDef {
readonly name: string
readonly description: string
readonly input_schema: Record<string, unknown>
readonly run: (input: unknown) => Promise<string>
}
const queryDataSchema = z.object({
queryType: z.enum([
"customers",
"vendors",
"projects",
"invoices",
"vendor_bills",
"schedule_tasks",
"project_detail",
"customer_detail",
"vendor_detail",
]),
id: z.string().optional().describe("Record ID for detail queries"),
search: z
.string()
.optional()
.describe("Search term to filter results"),
limit: z
.number()
.optional()
.describe("Max results to return (default 20)"),
})
type QueryDataInput = z.infer<typeof queryDataSchema>
export function dataTools(dataSource: DataSource): ToolDef[] {
return [
{
name: "queryData",
description:
"Query the application database. Describe what data " +
"you need in natural language and provide a query type.",
input_schema: zodToJsonSchema(queryDataSchema),
run: async (input: unknown): Promise<string> => {
const args = queryDataSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/query",
args
)
return JSON.stringify(result)
},
},
]
}
/** Minimal zod-to-JSON-schema converter for our tool schemas */
export function zodToJsonSchema(
schema: z.ZodObject<z.ZodRawShape>
): Record<string, unknown> {
const jsonSchema = zodObjectToProperties(schema)
return {
type: "object",
properties: jsonSchema.properties,
required: jsonSchema.required,
}
}
function zodObjectToProperties(schema: z.ZodObject<z.ZodRawShape>): {
properties: Record<string, unknown>
required: string[]
} {
const shape = schema.shape
const properties: Record<string, unknown> = {}
const required: string[] = []
for (const [key, value] of Object.entries(shape)) {
const { schema: propSchema, isOptional } = unwrapOptional(
value as z.ZodType
)
properties[key] = zodTypeToJsonSchema(propSchema)
if (!isOptional) {
required.push(key)
}
}
return { properties, required }
}
function unwrapOptional(
schema: z.ZodType
): { schema: z.ZodType; isOptional: boolean } {
if (schema instanceof z.ZodOptional) {
return { schema: schema.unwrap(), isOptional: true }
}
if (schema instanceof z.ZodNullable) {
return { schema: schema.unwrap(), isOptional: true }
}
return { schema, isOptional: false }
}
function zodTypeToJsonSchema(
schema: z.ZodType
): Record<string, unknown> {
const desc = schema.description
if (schema instanceof z.ZodString) {
return { type: "string", ...(desc ? { description: desc } : {}) }
}
if (schema instanceof z.ZodNumber) {
const result: Record<string, unknown> = {
type: "number",
...(desc ? { description: desc } : {}),
}
const checks = (schema as z.ZodNumber)._def.checks
for (const check of checks) {
if (check.kind === "min") result.minimum = check.value
if (check.kind === "max") result.maximum = check.value
}
return result
}
if (schema instanceof z.ZodBoolean) {
return { type: "boolean", ...(desc ? { description: desc } : {}) }
}
if (schema instanceof z.ZodEnum) {
return {
type: "string",
enum: schema.options,
...(desc ? { description: desc } : {}),
}
}
if (schema instanceof z.ZodArray) {
return {
type: "array",
items: zodTypeToJsonSchema(schema.element),
...(desc ? { description: desc } : {}),
}
}
if (schema instanceof z.ZodObject) {
const inner = zodObjectToProperties(schema)
return {
type: "object",
properties: inner.properties,
required: inner.required,
...(desc ? { description: desc } : {}),
}
}
if (schema instanceof z.ZodRecord) {
return {
type: "object",
additionalProperties: zodTypeToJsonSchema(schema.element),
...(desc ? { description: desc } : {}),
}
}
if (schema instanceof z.ZodNullable) {
const inner = zodTypeToJsonSchema(schema.unwrap())
return { ...inner, nullable: true }
}
if (schema instanceof z.ZodOptional) {
return zodTypeToJsonSchema(schema.unwrap())
}
// Fallback
return { ...(desc ? { description: desc } : {}) }
}

View File

@ -0,0 +1,132 @@
import { z } from "zod"
import type { DataSource } from "../types"
import type { ToolDef } from "./data"
import { zodToJsonSchema } from "./data"
const querySchema = z.object({
queryType: z
.enum([
"commits",
"commit_diff",
"pull_requests",
"issues",
"contributors",
"milestones",
"repo_stats",
])
.describe("Type of GitHub data to query"),
sha: z
.string()
.optional()
.describe("Commit SHA for commit_diff queries"),
state: z
.enum(["open", "closed", "all"])
.optional()
.describe("State filter for PRs, issues, milestones"),
labels: z
.string()
.optional()
.describe("Comma-separated labels to filter issues"),
limit: z
.number()
.optional()
.describe("Max results to return (default 10)"),
})
const createIssueSchema = z.object({
title: z.string().describe("Issue title"),
body: z.string().describe("Issue body in markdown"),
labels: z
.array(z.string())
.optional()
.describe("Labels to apply"),
assignee: z
.string()
.optional()
.describe("GitHub username to assign"),
milestone: z
.number()
.optional()
.describe("Milestone number to attach to"),
})
const interviewSchema = z.object({
responses: z
.array(
z.object({
question: z.string(),
answer: z.string(),
})
)
.describe(
"Array of question/answer pairs from the interview"
),
summary: z
.string()
.describe("Brief summary of the interview findings"),
painPoints: z
.array(z.string())
.optional()
.describe("Key pain points identified"),
featureRequests: z
.array(z.string())
.optional()
.describe("Feature requests from the user"),
overallSentiment: z
.enum(["positive", "neutral", "negative", "mixed"])
.describe("Overall sentiment of the feedback"),
})
export function githubTools(dataSource: DataSource): ToolDef[] {
return [
{
name: "queryGitHub",
description:
"Query GitHub repository data: commits, pull requests, " +
"issues, contributors, milestones, or repo stats.",
input_schema: zodToJsonSchema(querySchema),
run: async (input: unknown): Promise<string> => {
const args = querySchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/github/query",
args
)
return JSON.stringify(result)
},
},
{
name: "createGitHubIssue",
description:
"Create a new GitHub issue in the Compass repository. " +
"Always confirm with the user before creating.",
input_schema: zodToJsonSchema(createIssueSchema),
run: async (input: unknown): Promise<string> => {
const args = createIssueSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/github/create-issue",
args
)
return JSON.stringify(result)
},
},
{
name: "saveInterviewFeedback",
description:
"Save the results of a UX interview. Call this after " +
"completing an interview with the user. Saves to the " +
"database and creates a GitHub issue tagged " +
"user-feedback.",
input_schema: zodToJsonSchema(interviewSchema),
run: async (input: unknown): Promise<string> => {
const args = interviewSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/github/save-interview",
args
)
return JSON.stringify(result)
},
},
]
}

View File

@ -0,0 +1,26 @@
import type { DataSource } from "../types"
import type { ToolDef } from "./data"
import { dataTools } from "./data"
import { uiTools } from "./ui"
import { scheduleTools } from "./schedule"
import { themeTools } from "./theme"
import { memoryTools } from "./memory"
import { skillTools } from "./skills"
import { githubTools } from "./github"
import { dashboardTools } from "./dashboards"
export type { ToolDef } from "./data"
export { zodToJsonSchema } from "./data"
export function createTools(dataSource: DataSource): ToolDef[] {
return [
...dataTools(dataSource),
...uiTools(),
...scheduleTools(dataSource),
...themeTools(dataSource),
...memoryTools(dataSource),
...skillTools(dataSource),
...githubTools(dataSource),
...dashboardTools(dataSource),
]
}

View File

@ -0,0 +1,74 @@
import { z } from "zod"
import type { DataSource } from "../types"
import type { ToolDef } from "./data"
import { zodToJsonSchema } from "./data"
const rememberSchema = z.object({
content: z
.string()
.describe(
"What to remember (a preference, decision, fact, " +
"or workflow)"
),
memoryType: z
.enum(["preference", "workflow", "fact", "decision"])
.describe("Category of memory"),
tags: z
.string()
.optional()
.describe("Comma-separated tags for categorization"),
importance: z
.number()
.min(0.3)
.max(1.0)
.optional()
.describe("Importance weight 0.3-1.0 (default 0.7)"),
})
const recallSchema = z.object({
query: z
.string()
.describe("What to search for in memories"),
limit: z
.number()
.optional()
.describe("Max results (default 5)"),
})
export function memoryTools(dataSource: DataSource): ToolDef[] {
return [
{
name: "rememberContext",
description:
"Save something to persistent memory. Use when the " +
"user shares a preference, makes a decision, or " +
"mentions a fact worth remembering across sessions.",
input_schema: zodToJsonSchema(rememberSchema),
run: async (input: unknown): Promise<string> => {
const args = rememberSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/memory/save",
args
)
return JSON.stringify(result)
},
},
{
name: "recallMemory",
description:
"Search persistent memories for this user. Use when " +
"the user asks if you remember something or when you " +
"need to look up a past preference or decision.",
input_schema: zodToJsonSchema(recallSchema),
run: async (input: unknown): Promise<string> => {
const args = recallSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/memory/recall",
args
)
return JSON.stringify(result)
},
},
]
}

View File

@ -0,0 +1,270 @@
import { z } from "zod"
import type { DataSource } from "../types"
import type { ToolDef } from "./data"
import { zodToJsonSchema } from "./data"
const getScheduleSchema = z.object({
projectId: z.string().describe("The project UUID"),
})
const createTaskSchema = z.object({
projectId: z.string().describe("The project UUID"),
title: z.string().describe("Task title"),
startDate: z
.string()
.describe("Start date in YYYY-MM-DD format"),
workdays: z.number().describe("Duration in working days"),
phase: z
.string()
.describe(
"Construction phase (preconstruction, sitework, " +
"foundation, framing, roofing, electrical, plumbing, " +
"hvac, insulation, drywall, finish, landscaping, " +
"closeout)"
),
isMilestone: z
.boolean()
.optional()
.describe("Whether this is a milestone (0 workdays)"),
percentComplete: z
.number()
.min(0)
.max(100)
.optional()
.describe("Initial percent complete (0-100)"),
assignedTo: z
.string()
.optional()
.describe("Name of the person assigned"),
})
const updateTaskSchema = z.object({
taskId: z.string().describe("The task UUID"),
title: z.string().optional().describe("New title"),
startDate: z
.string()
.optional()
.describe("New start date (YYYY-MM-DD)"),
workdays: z
.number()
.optional()
.describe("New duration in working days"),
phase: z.string().optional().describe("New phase"),
status: z
.enum(["PENDING", "IN_PROGRESS", "COMPLETE", "BLOCKED"])
.optional()
.describe("New status"),
isMilestone: z
.boolean()
.optional()
.describe("Set milestone flag"),
percentComplete: z
.number()
.min(0)
.max(100)
.optional()
.describe("New percent complete (0-100)"),
assignedTo: z
.string()
.nullable()
.optional()
.describe("New assignee (null to unassign)"),
})
const deleteTaskSchema = z.object({
taskId: z.string().describe("The task UUID to delete"),
})
const createDepSchema = z.object({
projectId: z.string().describe("The project UUID"),
predecessorId: z
.string()
.describe("UUID of the predecessor task"),
successorId: z
.string()
.describe("UUID of the successor task"),
type: z
.enum(["FS", "SS", "FF", "SF"])
.describe(
"Dependency type: FS (finish-to-start), " +
"SS (start-to-start), FF (finish-to-finish), " +
"SF (start-to-finish)"
),
lagDays: z
.number()
.optional()
.describe("Lag in working days (default 0)"),
})
const deleteDepSchema = z.object({
dependencyId: z
.string()
.describe("The dependency UUID to delete"),
projectId: z
.string()
.describe("The project UUID (for revalidation)"),
})
const addExceptionSchema = z.object({
projectId: z.string().describe("The project UUID"),
date: z
.string()
.describe("Exception date in YYYY-MM-DD format"),
category: z
.enum(["holiday", "non_working", "extra_working"])
.describe("Exception category"),
recurrence: z
.enum(["none", "annual", "weekly"])
.optional()
.describe("Recurrence pattern (default none)"),
description: z
.string()
.optional()
.describe("Description of the exception"),
})
const removeExceptionSchema = z.object({
exceptionId: z
.string()
.describe("The exception UUID to remove"),
projectId: z.string().describe("The project UUID"),
})
export function scheduleTools(
dataSource: DataSource
): ToolDef[] {
return [
{
name: "getProjectSchedule",
description:
"Get the full schedule for a project including tasks, " +
"dependencies, workday exceptions, and a computed " +
"summary (counts, overall %, critical path). Always " +
"call this before making schedule mutations to resolve " +
"task names to IDs.",
input_schema: zodToJsonSchema(getScheduleSchema),
run: async (input: unknown): Promise<string> => {
const args = getScheduleSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/schedule/get",
args
)
return JSON.stringify(result)
},
},
{
name: "createScheduleTask",
description:
"Create a new task on a project schedule. Returns a " +
"toast confirmation. Dates are ISO format (YYYY-MM-DD).",
input_schema: zodToJsonSchema(createTaskSchema),
run: async (input: unknown): Promise<string> => {
const args = createTaskSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/schedule/create",
args
)
return JSON.stringify(result)
},
},
{
name: "updateScheduleTask",
description:
"Update an existing schedule task. Provide only the " +
"fields to change. Use getProjectSchedule first to " +
"resolve task names to IDs.",
input_schema: zodToJsonSchema(updateTaskSchema),
run: async (input: unknown): Promise<string> => {
const args = updateTaskSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/schedule/update",
args
)
return JSON.stringify(result)
},
},
{
name: "deleteScheduleTask",
description:
"Delete a schedule task. Always confirm with the user " +
"before deleting. This also removes any dependencies " +
"involving the task.",
input_schema: zodToJsonSchema(deleteTaskSchema),
run: async (input: unknown): Promise<string> => {
const args = deleteTaskSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/schedule/delete",
args
)
return JSON.stringify(result)
},
},
{
name: "createScheduleDependency",
description:
"Create a dependency between two tasks. Has built-in " +
"cycle detection. Use getProjectSchedule first to " +
"resolve task names to IDs.",
input_schema: zodToJsonSchema(createDepSchema),
run: async (input: unknown): Promise<string> => {
const args = createDepSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/schedule/create-dependency",
args
)
return JSON.stringify(result)
},
},
{
name: "deleteScheduleDependency",
description:
"Delete a dependency between tasks. Use " +
"getProjectSchedule first to find the dependency ID.",
input_schema: zodToJsonSchema(deleteDepSchema),
run: async (input: unknown): Promise<string> => {
const args = deleteDepSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/schedule/delete-dependency",
args
)
return JSON.stringify(result)
},
},
{
name: "addWorkdayException",
description:
"Add a workday exception to a project (holiday, " +
"non-working day, or extra working day).",
input_schema: zodToJsonSchema(addExceptionSchema),
run: async (input: unknown): Promise<string> => {
const args = addExceptionSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/schedule/add-exception",
args
)
return JSON.stringify(result)
},
},
{
name: "removeWorkdayException",
description:
"Remove a workday exception from a project.",
input_schema: zodToJsonSchema(removeExceptionSchema),
run: async (input: unknown): Promise<string> => {
const args = removeExceptionSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/schedule/remove-exception",
args
)
return JSON.stringify(result)
},
},
]
}

View File

@ -0,0 +1,94 @@
import { z } from "zod"
import type { DataSource } from "../types"
import type { ToolDef } from "./data"
import { zodToJsonSchema } from "./data"
const installSchema = z.object({
source: z
.string()
.describe(
"GitHub source path, e.g. 'cloudflare/skills/wrangler'"
),
})
const listSchema = z.object({})
const toggleSchema = z.object({
pluginId: z
.string()
.describe("The plugin ID of the skill"),
enabled: z
.boolean()
.describe("true to enable, false to disable"),
})
const uninstallSchema = z.object({
pluginId: z
.string()
.describe("The plugin ID of the skill"),
})
export function skillTools(dataSource: DataSource): ToolDef[] {
return [
{
name: "installSkill",
description:
"Install a skill from GitHub (skills.sh format). " +
"Source format: owner/repo or owner/repo/skill-name. " +
"Requires admin role. Always confirm with the user " +
"what skill they want before installing.",
input_schema: zodToJsonSchema(installSchema),
run: async (input: unknown): Promise<string> => {
const args = installSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/skills/install",
args
)
return JSON.stringify(result)
},
},
{
name: "listInstalledSkills",
description:
"List all installed agent skills with their status.",
input_schema: zodToJsonSchema(listSchema),
run: async (): Promise<string> => {
const result = await dataSource.fetch(
"/api/compass/skills/list"
)
return JSON.stringify(result)
},
},
{
name: "toggleInstalledSkill",
description: "Enable or disable an installed skill.",
input_schema: zodToJsonSchema(toggleSchema),
run: async (input: unknown): Promise<string> => {
const args = toggleSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/skills/toggle",
args
)
return JSON.stringify(result)
},
},
{
name: "uninstallSkill",
description:
"Remove an installed skill permanently. Requires " +
"admin role. Always confirm before uninstalling.",
input_schema: zodToJsonSchema(uninstallSchema),
run: async (input: unknown): Promise<string> => {
const args = uninstallSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/skills/uninstall",
args
)
return JSON.stringify(result)
},
},
]
}

View File

@ -0,0 +1,155 @@
import { z } from "zod"
import type { DataSource } from "../types"
import type { ToolDef } from "./data"
import { zodToJsonSchema } from "./data"
const listThemesSchema = z.object({})
const setThemeSchema = z.object({
themeId: z.string().describe("The theme ID to activate"),
})
const generateThemeSchema = z.object({
name: z.string().describe("Theme display name"),
description: z.string().describe("Brief theme description"),
light: z
.record(z.string(), z.string())
.describe(
"Light mode color map with all 32 ThemeColorKey entries"
),
dark: z
.record(z.string(), z.string())
.describe(
"Dark mode color map with all 32 ThemeColorKey entries"
),
fonts: z
.object({
sans: z.string(),
serif: z.string(),
mono: z.string(),
})
.describe("CSS font-family strings"),
googleFonts: z
.array(z.string())
.optional()
.describe("Google Font names to load (case-sensitive)"),
radius: z
.string()
.optional()
.describe("Border radius (e.g. '0.5rem')"),
spacing: z
.string()
.optional()
.describe("Base spacing (e.g. '0.25rem')"),
})
const editThemeSchema = z.object({
themeId: z
.string()
.describe("ID of existing custom theme to edit"),
name: z.string().optional().describe("New display name"),
description: z
.string()
.optional()
.describe("New description"),
light: z
.record(z.string(), z.string())
.optional()
.describe("Partial light color overrides (only changed keys)"),
dark: z
.record(z.string(), z.string())
.optional()
.describe("Partial dark color overrides (only changed keys)"),
fonts: z
.object({
sans: z.string().optional(),
serif: z.string().optional(),
mono: z.string().optional(),
})
.optional()
.describe("Partial font overrides"),
googleFonts: z
.array(z.string())
.optional()
.describe("Replace Google Font list"),
radius: z
.string()
.optional()
.describe("New border radius"),
spacing: z
.string()
.optional()
.describe("New base spacing"),
})
export function themeTools(dataSource: DataSource): ToolDef[] {
return [
{
name: "listThemes",
description:
"List available visual themes (presets + user custom " +
"themes).",
input_schema: zodToJsonSchema(listThemesSchema),
run: async (): Promise<string> => {
const result = await dataSource.fetch(
"/api/compass/themes/list"
)
return JSON.stringify(result)
},
},
{
name: "setTheme",
description:
"Switch the user's visual theme. Use a preset ID " +
"(native-compass, corpo, notebook, doom-64, " +
"bubblegum, developers-choice, anslopics-clood, " +
"violet-bloom, soy, mocha) or a custom theme UUID.",
input_schema: zodToJsonSchema(setThemeSchema),
run: async (input: unknown): Promise<string> => {
const args = setThemeSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/themes/set",
args
)
return JSON.stringify(result)
},
},
{
name: "generateTheme",
description:
"Generate and save a custom visual theme. Provide " +
"complete light and dark color maps (all 32 keys), " +
"fonts, optional Google Font names, and design tokens. " +
"All colors must be in oklch() format.",
input_schema: zodToJsonSchema(generateThemeSchema),
run: async (input: unknown): Promise<string> => {
const args = generateThemeSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/themes/generate",
args
)
return JSON.stringify(result)
},
},
{
name: "editTheme",
description:
"Edit an existing custom theme. Provide the theme ID " +
"and only the properties you want to change. " +
"Unspecified properties are preserved from the existing " +
"theme. Only works on custom themes (not presets).",
input_schema: zodToJsonSchema(editThemeSchema),
run: async (input: unknown): Promise<string> => {
const args = editThemeSchema.parse(input)
const result = await dataSource.fetch(
"/api/compass/themes/edit",
args
)
return JSON.stringify(result)
},
},
]
}

View File

@ -0,0 +1,144 @@
import { z } from "zod"
import type { ToolDef } from "./data"
import { zodToJsonSchema } from "./data"
const VALID_ROUTES: ReadonlyArray<RegExp> = [
/^\/dashboard$/,
/^\/dashboard\/contacts$/,
/^\/dashboard\/customers$/,
/^\/dashboard\/vendors$/,
/^\/dashboard\/projects$/,
/^\/dashboard\/projects\/[^/]+$/,
/^\/dashboard\/projects\/[^/]+\/schedule$/,
/^\/dashboard\/financials$/,
/^\/dashboard\/people$/,
/^\/dashboard\/files$/,
/^\/dashboard\/files\/.+$/,
/^\/dashboard\/boards\/[^/]+$/,
/^\/dashboard\/conversations$/,
/^\/dashboard\/settings$/,
]
function isValidRoute(path: string): boolean {
return VALID_ROUTES.some((r) => r.test(path))
}
function normalizePath(path: string): string {
let p = path.trim()
if (!p.startsWith("/dashboard")) {
p = p.startsWith("/") ? `/dashboard${p}` : `/dashboard/${p}`
}
return p
}
const navigateSchema = z.object({
path: z
.string()
.describe(
"Page path, e.g. 'projects' or '/dashboard/projects'"
),
reason: z
.string()
.optional()
.describe("Brief explanation of why navigating"),
})
const notificationSchema = z.object({
message: z.string().describe("The notification message"),
type: z
.enum(["default", "success", "error"])
.optional()
.describe("Notification style"),
})
const generateUISchema = z.object({
description: z
.string()
.describe(
"Layout and content description for the dashboard to " +
"generate. Be specific about what components and data " +
"to display."
),
dataContext: z
.record(z.string(), z.unknown())
.optional()
.describe(
"Data to include in the rendered UI. Pass query results here."
),
})
type NavigateInput = z.infer<typeof navigateSchema>
type NotificationInput = z.infer<typeof notificationSchema>
type GenerateUIInput = z.infer<typeof generateUISchema>
export function uiTools(): ToolDef[] {
return [
{
name: "navigateTo",
description:
"Navigate the user to a page. Paths are relative to " +
"/dashboard \u2014 e.g. pass 'projects' or " +
"'/dashboard/projects'. Valid pages: projects, " +
"projects/{id}, projects/{id}/schedule, contacts, " +
"customers, vendors, financials, people, files, " +
"files/{path}, boards/{id}, conversations, settings.",
input_schema: zodToJsonSchema(navigateSchema),
run: async (input: unknown): Promise<string> => {
const args = navigateSchema.parse(input) as NavigateInput
const resolved = normalizePath(args.path)
if (!isValidRoute(resolved)) {
return JSON.stringify({
error:
`"${args.path}" (resolved to "${resolved}") ` +
"is not a valid page. Valid: projects, " +
"projects/{id}, contacts, financials, people, " +
"files, boards/{id}",
})
}
return JSON.stringify({
action: "navigate",
path: resolved,
reason: args.reason ?? null,
})
},
},
{
name: "showNotification",
description:
"Show a toast notification to the user. Use for " +
"confirmations or important alerts.",
input_schema: zodToJsonSchema(notificationSchema),
run: async (input: unknown): Promise<string> => {
const args = notificationSchema.parse(
input
) as NotificationInput
return JSON.stringify({
action: "toast",
message: args.message,
type: args.type ?? "default",
})
},
},
{
name: "generateUI",
description:
"Generate a rich interactive UI dashboard. Use when " +
"the user wants to see structured data (tables, charts, " +
"stats, forms). Always fetch data with queryData first, " +
"then pass it here as dataContext.",
input_schema: zodToJsonSchema(generateUISchema),
run: async (input: unknown): Promise<string> => {
const args = generateUISchema.parse(
input
) as GenerateUIInput
return JSON.stringify({
action: "generateUI",
renderPrompt: args.description,
dataContext: args.dataContext ?? {},
})
},
},
]
}

View File

@ -0,0 +1,46 @@
export interface ProviderConfig {
readonly type: "anthropic" | "openrouter" | "ollama" | "custom"
readonly apiKey?: string
readonly baseUrl?: string
readonly modelOverrides?: Readonly<Record<string, string>>
}
export interface AgentContext {
readonly userId: string
readonly orgId: string
readonly role: string
readonly isDemoUser: boolean
readonly currentPage: string
readonly timezone: string
}
/** Abstracts HTTP calls vs direct DB access for tools */
export interface DataSource {
fetch(path: string, body?: unknown): Promise<unknown>
}
/** SSE events emitted by the agentic loop */
export type SSEData =
| { readonly type: "text"; readonly content: string }
| {
readonly type: "tool_use"
readonly name: string
readonly toolCallId: string
readonly input: unknown
}
| {
readonly type: "tool_result"
readonly toolCallId: string
readonly output: unknown
}
| {
readonly type: "result"
readonly subtype: string
readonly result: string
readonly usage?: {
readonly inputTokens: number
readonly outputTokens: number
readonly totalCostUsd: number
}
}
| { readonly type: "error"; readonly error: string }

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true,
"types": ["bun-types"]
},
"include": ["src"]
}

View File

@ -0,0 +1,14 @@
# Required
AGENT_AUTH_SECRET=your-secret-key-here
# Optional - Anthropic API (can be overridden by user BYOK)
ANTHROPIC_API_KEY=sk-ant-...
# Optional - OpenRouter mode
# ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
# Optional - CORS configuration
ALLOWED_ORIGINS=http://localhost:3000
# Optional - Server port
PORT=3001

6
packages/agent-server/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
.env
.env.local
*.log
.DS_Store

View File

@ -0,0 +1,103 @@
# Agent Server
Standalone Node.js agent server wrapping the Anthropic Agent SDK for Compass.
## Overview
This server provides an SSE (Server-Sent Events) endpoint that streams AI agent responses using the Anthropic Agent SDK. It handles authentication, session management, and tool execution via MCP (Model Context Protocol) servers.
## Environment Variables
Required:
- `AGENT_AUTH_SECRET` - HS256 JWT signing secret for authentication
Optional:
- `ANTHROPIC_API_KEY` - Direct Anthropic API key (can be overridden by user BYOK)
- `ANTHROPIC_BASE_URL` - Set to OpenRouter URL for OpenRouter mode
- `ALLOWED_ORIGINS` - Comma-separated list of allowed CORS origins (default: `http://localhost:3000`)
- `PORT` - Server port (default: `3001`)
## API Endpoints
### POST /agent/chat
Stream AI agent responses via SSE.
**Headers:**
- `Authorization: Bearer <jwt>` - Required JWT token
- `x-session-id: <uuid>` - Session identifier (optional, auto-generated if missing)
- `x-current-page: <path>` - Current page path for context
- `x-timezone: <tz>` - User timezone
- `x-user-api-key: <key>` - User's own API key (BYOK, takes priority over server key)
**Request Body:**
```json
{
"messages": [
{ "role": "user", "content": "Hello" },
{ "role": "assistant", "content": "Hi there!" },
{ "role": "user", "content": "What can you do?" }
]
}
```
**Response:** SSE stream with the following event types:
```
data: {"type":"text","content":"..."}
data: {"type":"tool_use","name":"queryData","input":{...}}
data: {"type":"tool_result","name":"queryData","output":{...}}
data: {"type":"result","subtype":"success","result":"..."}
data: [DONE]
```
### GET /health
Health check endpoint.
**Response:**
```json
{
"status": "ok",
"version": "0.1.0"
}
```
## JWT Token Format
The JWT must be signed with HS256 and include the following claims:
```json
{
"userId": "user-uuid",
"orgId": "org-uuid",
"role": "admin|member|viewer",
"isDemoUser": false
}
```
## Running
Development:
```bash
bun run dev
```
Production:
```bash
bun run start
```
Build:
```bash
bun run build
```
## Architecture
- `src/index.ts` - HTTP server entry point (Bun.serve)
- `src/stream.ts` - Wraps SDK query() → SSE response
- `src/auth.ts` - JWT validation (HS256)
- `src/config.ts` - Environment configuration
- `src/sessions.ts` - In-memory session store
- `src/mcp/compass-server.ts` - MCP server for Compass tools

View File

@ -0,0 +1,40 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "agent-server",
"dependencies": {
"agent-core": "file:../agent-core",
"jose": "^5.9.6",
"zod": "^3.24.1",
},
"devDependencies": {
"@types/bun": "latest",
},
},
},
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
"agent-core": ["agent-core@file:../agent-core", { "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "zod": "^3.24.1" }, "devDependencies": { "bun-types": "^1.3.9" } }],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
}
}

View File

@ -0,0 +1,22 @@
{
"name": "agent-server",
"version": "0.1.0",
"description": "Standalone agent server using agent-core",
"type": "module",
"bin": {
"agent-server": "./src/index.ts"
},
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun --hot run src/index.ts",
"build": "bun build src/index.ts --target=bun --outdir=dist --minify"
},
"dependencies": {
"agent-core": "file:../agent-core",
"jose": "^5.9.6",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bun": "latest"
}
}

View File

@ -0,0 +1,27 @@
/**
* Shared HTTP client for calling the Compass Workers API.
* All MCP tools use this to interact with the Compass backend.
*/
export async function compassApi<T>(
baseUrl: string,
path: string,
authToken: string,
body?: unknown
): Promise<T> {
const res = await fetch(`${baseUrl}${path}`, {
method: body ? "POST" : "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${authToken}`,
},
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error ?? `API error ${res.status}`)
}
return res.json() as Promise<T>
}

View File

@ -0,0 +1,61 @@
/**
* JWT authentication for agent server
* Validates tokens signed with HS256 and extracts user context
*/
import { jwtVerify } from "jose"
import { config } from "./config"
export interface ProviderConfig {
type: string // anthropic-oauth | anthropic-key | openrouter | ollama | custom
apiKey?: string
baseUrl?: string
modelOverrides?: Record<string, string> // { sonnet?: string, opus?: string, haiku?: string }
}
export interface AuthContext {
userId: string
orgId: string
role: string
isDemoUser: boolean
provider?: ProviderConfig
}
export async function validateAuth(
request: Request
): Promise<AuthContext | { error: string }> {
const authHeader = request.headers.get("authorization")
if (!authHeader?.startsWith("Bearer ")) {
return { error: "Missing or invalid Authorization header" }
}
const token = authHeader.slice(7)
try {
const secret = new TextEncoder().encode(config.agentAuthSecret)
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
})
// Extract claims from JWT payload
const userId = (payload.sub ?? payload.userId) as string | undefined
const orgId = payload.orgId as string | undefined
const role = payload.role as string | undefined
const isDemoUser = payload.isDemoUser as boolean | undefined
const provider = payload.provider as ProviderConfig | undefined
if (!userId || !orgId || !role) {
return { error: "Invalid token payload: missing required claims" }
}
return {
userId,
orgId,
role,
isDemoUser: isDemoUser ?? false,
provider,
}
} catch (err) {
return { error: `JWT verification failed: ${err}` }
}
}

View File

@ -0,0 +1,43 @@
/**
* Configuration for agent server
* Reads from environment variables
*/
export interface Config {
anthropicApiKey: string | undefined
anthropicBaseUrl: string | undefined
compassApiBaseUrl: string
agentAuthSecret: string
allowedOrigins: string[]
port: number
}
function parseOrigins(originsStr: string | undefined): string[] {
if (!originsStr) {
return ["http://localhost:3000"]
}
return originsStr.split(",").map(o => o.trim())
}
export function loadConfig(): Config {
const agentAuthSecret = process.env.AGENT_AUTH_SECRET
if (!agentAuthSecret) {
throw new Error("AGENT_AUTH_SECRET environment variable is required")
}
const compassApiBaseUrl = process.env.COMPASS_API_BASE_URL
if (!compassApiBaseUrl) {
throw new Error("COMPASS_API_BASE_URL environment variable is required")
}
return {
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL,
compassApiBaseUrl,
agentAuthSecret,
allowedOrigins: parseOrigins(process.env.ALLOWED_ORIGINS),
port: parseInt(process.env.PORT || "3001", 10),
}
}
export const config = loadConfig()

View File

@ -0,0 +1,151 @@
#!/usr/bin/env bun
/**
* Compass Agent Server
* Standalone server using agent-core for the agentic loop
*/
import { config } from "./config"
import { validateAuth } from "./auth"
import { createAgentStream } from "./stream"
interface ChatRequest {
messages: Array<{
role: "user" | "assistant"
content: string
}>
}
/**
* Handle POST /agent/chat
*/
async function handleChat(request: Request): Promise<Response> {
// Validate auth
const authResult = await validateAuth(request)
if ("error" in authResult) {
return new Response(
JSON.stringify({ error: authResult.error }),
{ status: 401, headers: { "Content-Type": "application/json" } }
)
}
// Extract JWT token from Authorization header
const authHeader = request.headers.get("authorization")
const authToken = authHeader?.slice(7) || "" // Remove "Bearer " prefix
// Extract headers
const sessionId = request.headers.get("x-session-id") || crypto.randomUUID()
const currentPage = request.headers.get("x-current-page") || "/"
const timezone = request.headers.get("x-timezone") || "UTC"
const model = request.headers.get("x-model") || "sonnet"
// Parse body
let body: ChatRequest
try {
body = await request.json()
} catch {
return new Response(
JSON.stringify({ error: "Invalid JSON body" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
)
}
if (!Array.isArray(body.messages) || body.messages.length === 0) {
return new Response(
JSON.stringify({ error: "messages array is required and cannot be empty" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
)
}
// Create SSE stream
const stream = await createAgentStream(body.messages, {
auth: authResult,
authToken,
sessionId,
currentPage,
timezone,
provider: authResult.provider,
model,
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": request.headers.get("origin") || "*",
"Access-Control-Allow-Credentials": "true",
},
})
}
/**
* Handle GET /health
*/
function handleHealth(): Response {
return new Response(
JSON.stringify({ status: "ok", version: "0.1.0" }),
{ headers: { "Content-Type": "application/json" } }
)
}
/**
* CORS preflight handler
*/
function handlePreflight(request: Request): Response {
const origin = request.headers.get("origin") || ""
const allowed = config.allowedOrigins.includes(origin) || config.allowedOrigins.includes("*")
if (!allowed) {
return new Response(null, { status: 403 })
}
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type, x-session-id, x-current-page, x-timezone, x-model",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400",
},
})
}
/**
* Main request router
*/
async function handleRequest(request: Request): Promise<Response> {
const url = new URL(request.url)
// Handle CORS preflight
if (request.method === "OPTIONS") {
return handlePreflight(request)
}
// Route requests
if (url.pathname === "/health" && request.method === "GET") {
return handleHealth()
}
if (url.pathname === "/agent/chat" && request.method === "POST") {
return handleChat(request)
}
return new Response(
JSON.stringify({ error: "Not found" }),
{ status: 404, headers: { "Content-Type": "application/json" } }
)
}
/**
* Start the server
*/
const server = Bun.serve({
port: config.port,
async fetch(request) {
return handleRequest(request)
},
})
console.log(`Agent server running on http://localhost:${server.port}`)
console.log(`Allowed origins: ${config.allowedOrigins.join(", ")}`)

View File

@ -0,0 +1,220 @@
/**
* SSE streaming using agent-core's agentic loop with MCP-based
* tool routing. Supports both in-process compass tools and
* external MCP servers (stdio + HTTP).
*/
import {
runAgent,
buildSystemPrompt,
createSSEStream,
createCompassServer,
createClientManager,
} from "agent-core"
import type {
DataSource,
ProviderConfig,
McpServerConfig,
} from "agent-core"
import type {
AuthContext,
ProviderConfig as JWTProvider,
} from "./auth"
import { compassApi } from "./api-client"
import { config } from "./config"
interface Message {
role: "user" | "assistant"
content: string
}
interface ExternalServerConfig {
readonly name: string
readonly slug: string
readonly transport: "stdio" | "http"
readonly command?: string
readonly args?: string
readonly env?: string
readonly url?: string
readonly headers?: string
}
interface StreamContext {
auth: AuthContext
authToken: string
sessionId: string
currentPage: string
timezone: string
provider?: JWTProvider
model: string
externalServers?: readonly ExternalServerConfig[]
}
function mapProvider(context: StreamContext): ProviderConfig {
if (!context.provider) {
return {
type: "anthropic",
apiKey: config.anthropicApiKey,
baseUrl: config.anthropicBaseUrl,
}
}
switch (context.provider.type) {
case "anthropic-key":
case "anthropic-oauth":
return {
type: "anthropic",
apiKey: context.provider.apiKey,
baseUrl: context.provider.baseUrl,
}
case "openrouter":
return {
type: "openrouter",
apiKey: context.provider.apiKey,
}
case "ollama":
return {
type: "ollama",
baseUrl: context.provider.baseUrl,
}
default:
return {
type: "custom",
apiKey: context.provider.apiKey,
baseUrl: context.provider.baseUrl,
}
}
}
function buildExternalConfigs(
servers?: readonly ExternalServerConfig[]
): McpServerConfig[] {
if (!servers) return []
const configs: McpServerConfig[] = []
for (const s of servers) {
if (s.transport === "stdio" && s.command) {
const args = s.args
? (JSON.parse(s.args) as string[])
: undefined
const env = s.env
? (JSON.parse(s.env) as Record<string, string>)
: undefined
configs.push({
name: s.slug,
transport: {
type: "stdio",
command: s.command,
args,
env,
},
enabled: true,
})
} else if (s.transport === "http" && s.url) {
const headers = s.headers
? (JSON.parse(s.headers) as Record<string, string>)
: undefined
configs.push({
name: s.slug,
transport: {
type: "http",
url: s.url,
headers,
},
enabled: true,
})
}
}
return configs
}
export async function createAgentStream(
messages: Message[],
context: StreamContext
): Promise<ReadableStream<Uint8Array>> {
const provider = mapProvider(context)
const dataSource: DataSource = {
async fetch(
path: string,
body?: unknown
): Promise<unknown> {
return compassApi(
config.compassApiBaseUrl,
path,
context.authToken,
body
)
},
}
// Set up MCP-based tool routing
const compassServer = createCompassServer(dataSource)
const manager = createClientManager(compassServer)
const mcpConfigs: McpServerConfig[] = [
{
name: "compass",
transport: { type: "in-memory" },
enabled: true,
},
...buildExternalConfigs(context.externalServers),
]
await manager.connect(mcpConfigs)
// Identify external tools for system prompt
const allTools = manager.listTools()
const externalMcpTools = allTools
.filter((t) => t.serverName !== "compass")
.map((t) => ({
serverName: t.serverName,
name: t.name,
}))
const systemPrompt = buildSystemPrompt({
context: {
userId: context.auth.userId,
orgId: context.auth.orgId,
role: context.auth.role,
isDemoUser: context.auth.isDemoUser,
currentPage: context.currentPage,
timezone: context.timezone,
},
messages,
externalMcpTools:
externalMcpTools.length > 0
? externalMcpTools
: undefined,
})
const isOAuth =
provider.apiKey?.startsWith("sk-ant-oat") ?? false
const agentStream = runAgent({
provider,
model: context.model,
systemPrompt,
messages,
mcpClientManager: manager,
isOAuth,
})
// Wrap to disconnect MCP after stream ends
const sseStream = createSSEStream(agentStream)
return new ReadableStream<Uint8Array>({
async start(controller) {
const reader = sseStream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
controller.enqueue(value)
}
} finally {
controller.close()
await manager.disconnect()
}
},
})
}

View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2024",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"types": ["bun-types"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2a.9.9 0 0 1 .84.58l2.32 5.94a4.5 4.5 0 0 0 2.6 2.6l5.94 2.32a.9.9 0 0 1 0 1.67l-5.94 2.32a4.5 4.5 0 0 0-2.6 2.6l-2.32 5.94a.9.9 0 0 1-1.68 0l-2.32-5.94a4.5 4.5 0 0 0-2.6-2.6L.3 15.11a.9.9 0 0 1 0-1.67l5.94-2.32a4.5 4.5 0 0 0 2.6-2.6L11.16 2.58A.9.9 0 0 1 12 2Z" fill="#D97757"/>
</svg>

After

Width:  |  Height:  |  Size: 398 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2C9 2 7 4.5 7 7.5c0 1.5.5 2.8 1.3 3.8C7.5 12.3 7 13.6 7 15c0 3 2.2 5 5 5s5-2 5-5c0-1.4-.5-2.7-1.3-3.7.8-1 1.3-2.3 1.3-3.8C17 4.5 15 2 12 2z"/>
<circle cx="10" cy="7" r="1"/>
<circle cx="14" cy="7" r="1"/>
<path d="M10 11c.7.7 1.3 1 2 1s1.3-.3 2-1"/>
</svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@ -7,6 +7,7 @@
"core:app:default", "core:app:default",
"core:window:default", "core:window:default",
"core:webview:default", "core:webview:default",
"core:webview:allow-set-webview-zoom",
"shell:allow-open", "shell:allow-open",
"sql:default", "sql:default",
"sql:allow-load", "sql:allow-load",

View File

@ -0,0 +1,39 @@
import { getCurrentUser } from "@/lib/auth"
import { getInviteByCode } from "@/app/actions/invites"
import { JoinForm } from "@/components/auth/join-form"
interface Props {
params: Promise<{ code: string }>
}
export default async function JoinPage({ params }: Props) {
const { code } = await params
const result = await getInviteByCode(code)
if (!result.success || !result.data) {
return (
<div className="space-y-4 text-center">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
Invalid invite
</h2>
<p className="text-sm text-muted-foreground">
This invite link is invalid or has expired.
</p>
</div>
</div>
)
}
const currentUser = await getCurrentUser()
return (
<JoinForm
code={code}
orgName={result.data.organizationName}
role={result.data.role}
isAuthenticated={currentUser !== null}
/>
)
}

View File

@ -0,0 +1,56 @@
"use server"
import { SignJWT } from "jose"
import { getCurrentUser } from "@/lib/auth"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { isDemoUser } from "@/lib/demo"
import { getProviderConfigForJwt } from "./provider-config"
const ORG_DEFAULT_USER_ID = "org_default"
/**
* Generate a JWT for the browser to use when connecting to the agent server
* Token includes user identity, org context, and role for authorization
*/
export async function getAgentToken(): Promise<
{ token: string } | { error: string }
> {
const user = await getCurrentUser()
if (!user) {
return { error: "Not authenticated" }
}
const { env } = await getCloudflareContext()
const secret = (env as unknown as Record<string, string>)
.AGENT_AUTH_SECRET
if (!secret) {
return { error: "Agent auth not configured" }
}
try {
let providerConfig = await getProviderConfigForJwt(user.id)
if (!providerConfig) {
providerConfig = await getProviderConfigForJwt(ORG_DEFAULT_USER_ID)
}
const token = await new SignJWT({
sub: user.id,
orgId: user.organizationId,
role: user.role,
isDemoUser: isDemoUser(user.id),
provider: providerConfig ?? undefined,
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(new TextEncoder().encode(secret))
return { token }
} catch (err) {
return {
error: err instanceof Error ? err.message : "Failed to generate token",
}
}
}

View File

@ -518,9 +518,7 @@ export async function updateModelPolicy(
.where(eq(agentConfig.id, "global")) .where(eq(agentConfig.id, "global"))
.run() .run()
} else { } else {
const { const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
DEFAULT_MODEL_ID,
} = await import("@/lib/agent/provider")
await db await db
.insert(agentConfig) .insert(agentConfig)
.values({ .values({

View File

@ -0,0 +1,238 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { getDb } from "@/db"
import {
anthropicOauthTokens,
userProviderConfig,
} from "@/db/schema-ai-config"
import { getCurrentUser } from "@/lib/auth"
import { encrypt, decrypt } from "@/lib/crypto"
import { isDemoUser } from "@/lib/demo"
import {
exchangeCode as exchangeOAuthCode_,
refreshAccessToken as refreshToken_,
} from "agent-core"
export async function exchangeOAuthCode(
code: string,
state: string,
verifier: string
): Promise<{ success: true } | { success: false; error: string }> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const tokens = await exchangeOAuthCode_(code, state, verifier)
const { env } = await getCloudflareContext()
const encryptionKey = (
env as unknown as Record<string, string>
).PROVIDER_KEY_ENCRYPTION_KEY
if (!encryptionKey) {
return {
success: false,
error:
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
}
}
const encryptedAccess = await encrypt(
tokens.access,
encryptionKey,
user.id
)
const encryptedRefresh = await encrypt(
tokens.refresh,
encryptionKey,
user.id
)
const db = getDb(env.DB)
const now = new Date().toISOString()
const expiresAt = new Date(tokens.expiresAt).toISOString()
await db
.insert(anthropicOauthTokens)
.values({
userId: user.id,
accessToken: encryptedAccess,
refreshToken: encryptedRefresh,
expiresAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: anthropicOauthTokens.userId,
set: {
accessToken: encryptedAccess,
refreshToken: encryptedRefresh,
expiresAt,
updatedAt: now,
},
})
.run()
await db
.insert(userProviderConfig)
.values({
userId: user.id,
providerType: "anthropic-oauth",
apiKey: null,
baseUrl: null,
modelOverrides: null,
isActive: 1,
updatedAt: now,
})
.onConflictDoUpdate({
target: userProviderConfig.userId,
set: {
providerType: "anthropic-oauth",
apiKey: null,
baseUrl: null,
isActive: 1,
updatedAt: now,
},
})
.run()
revalidatePath("/dashboard")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to exchange OAuth code",
}
}
}
// Returns a valid access token for the given userId, refreshing if needed.
// Returns null if no token exists or on any error.
export async function getOAuthAccessToken(
userId: string
): Promise<string | null> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const row = await db
.select()
.from(anthropicOauthTokens)
.where(eq(anthropicOauthTokens.userId, userId))
.get()
if (!row) return null
const encryptionKey = (
env as unknown as Record<string, string>
).PROVIDER_KEY_ENCRYPTION_KEY
if (!encryptionKey) return null
const isExpired =
Date.now() >
new Date(row.expiresAt).getTime() - 5 * 60 * 1000
if (!isExpired) {
return await decrypt(row.accessToken, encryptionKey, userId)
}
// Token expired — refresh
const refreshToken = await decrypt(
row.refreshToken,
encryptionKey,
userId
)
const fresh = await refreshToken_(refreshToken)
const encryptedAccess = await encrypt(
fresh.access,
encryptionKey,
userId
)
const encryptedRefresh = await encrypt(
fresh.refresh,
encryptionKey,
userId
)
const now = new Date().toISOString()
const expiresAt = new Date(fresh.expiresAt).toISOString()
await db
.update(anthropicOauthTokens)
.set({
accessToken: encryptedAccess,
refreshToken: encryptedRefresh,
expiresAt,
updatedAt: now,
})
.where(eq(anthropicOauthTokens.userId, userId))
.run()
return fresh.access
} catch {
return null
}
}
export async function disconnectOAuth(): Promise<{
success: true
}> {
const user = await getCurrentUser()
if (!user) {
// Still return success shape — nothing to disconnect
return { success: true }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db
.delete(anthropicOauthTokens)
.where(eq(anthropicOauthTokens.userId, user.id))
.run()
await db
.delete(userProviderConfig)
.where(eq(userProviderConfig.userId, user.id))
.run()
revalidatePath("/dashboard")
return { success: true }
}
export async function getOAuthStatus(): Promise<{
connected: boolean
expiresAt?: string
}> {
try {
const user = await getCurrentUser()
if (!user) return { connected: false }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const row = await db
.select()
.from(anthropicOauthTokens)
.where(eq(anthropicOauthTokens.userId, user.id))
.get()
if (!row) return { connected: false }
return { connected: true, expiresAt: row.expiresAt }
} catch {
return { connected: false }
}
}

View File

@ -37,6 +37,7 @@ export async function getCustomDashboards(): Promise<
if (!user) return { success: false, error: "not authenticated" } if (!user) return { success: false, error: "not authenticated" }
const { env } = await getCloudflareContext() const { env } = await getCloudflareContext()
if (!env?.DB) return { success: true, data: [] }
const db = getDb(env.DB) const db = getDb(env.DB)
const dashboards = await db.query.customDashboards.findMany({ const dashboards = await db.query.customDashboards.findMany({

359
src/app/actions/invites.ts Normal file
View File

@ -0,0 +1,359 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import {
organizationInvites,
organizationMembers,
organizations,
users,
type NewOrganizationInvite,
} from "@/db/schema"
import { getCurrentUser } from "@/lib/auth"
import { requirePermission } from "@/lib/permissions"
import { isDemoUser } from "@/lib/demo"
import { eq, and, desc } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { cookies } from "next/headers"
// unambiguous charset — no 0/O/1/I/l
const CODE_CHARS = "23456789abcdefghjkmnpqrstuvwxyz"
function generateInviteCode(orgSlug: string): string {
const prefix = orgSlug.replace(/[^a-z0-9]/g, "").slice(0, 3)
const suffix = Array.from(
{ length: 6 },
() => CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]
).join("")
return `${prefix}-${suffix}`
}
// --- createInvite ---
export async function createInvite(
role: string,
maxUses?: number,
expiresAt?: string
): Promise<{ success: boolean; error?: string; data?: { code: string; url: string } }> {
try {
const currentUser = await getCurrentUser()
if (!currentUser) return { success: false, error: "Unauthorized" }
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
requirePermission(currentUser, "organization", "create")
if (!currentUser.organizationId) {
return { success: false, error: "No active organization" }
}
const { env } = await getCloudflareContext()
if (!env?.DB) return { success: false, error: "Database not available" }
const db = getDb(env.DB)
const org = await db
.select({ slug: organizations.slug })
.from(organizations)
.where(eq(organizations.id, currentUser.organizationId))
.get()
if (!org) return { success: false, error: "Organization not found" }
const code = generateInviteCode(org.slug)
const now = new Date().toISOString()
const invite: NewOrganizationInvite = {
id: crypto.randomUUID(),
organizationId: currentUser.organizationId,
code,
role,
maxUses: maxUses ?? null,
useCount: 0,
expiresAt: expiresAt ?? null,
createdBy: currentUser.id,
isActive: true,
createdAt: now,
}
await db.insert(organizationInvites).values(invite).run()
revalidatePath("/dashboard/settings")
return {
success: true,
data: {
code,
url: `/join/${code}`,
},
}
} catch (error) {
console.error("Error creating invite:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
// --- getOrgInvites ---
export async function getOrgInvites(): Promise<{
success: boolean
error?: string
data?: ReadonlyArray<{
readonly id: string
readonly code: string
readonly role: string
readonly maxUses: number | null
readonly useCount: number
readonly expiresAt: string | null
readonly isActive: boolean
readonly createdAt: string
readonly createdByName: string | null
}>
}> {
try {
const currentUser = await getCurrentUser()
if (!currentUser) return { success: false, error: "Unauthorized" }
requirePermission(currentUser, "organization", "read")
if (!currentUser.organizationId) {
return { success: false, error: "No active organization" }
}
const { env } = await getCloudflareContext()
if (!env?.DB) return { success: false, error: "Database not available" }
const db = getDb(env.DB)
const invites = await db
.select({
id: organizationInvites.id,
code: organizationInvites.code,
role: organizationInvites.role,
maxUses: organizationInvites.maxUses,
useCount: organizationInvites.useCount,
expiresAt: organizationInvites.expiresAt,
isActive: organizationInvites.isActive,
createdAt: organizationInvites.createdAt,
createdByName: users.displayName,
})
.from(organizationInvites)
.leftJoin(users, eq(organizationInvites.createdBy, users.id))
.where(eq(organizationInvites.organizationId, currentUser.organizationId))
.orderBy(desc(organizationInvites.createdAt))
return { success: true, data: invites }
} catch (error) {
console.error("Error fetching org invites:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
// --- revokeInvite ---
export async function revokeInvite(
inviteId: string
): Promise<{ success: boolean; error?: string }> {
try {
const currentUser = await getCurrentUser()
if (!currentUser) return { success: false, error: "Unauthorized" }
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
requirePermission(currentUser, "organization", "update")
if (!currentUser.organizationId) {
return { success: false, error: "No active organization" }
}
const { env } = await getCloudflareContext()
if (!env?.DB) return { success: false, error: "Database not available" }
const db = getDb(env.DB)
// verify invite belongs to this org before revoking
const invite = await db
.select({ id: organizationInvites.id })
.from(organizationInvites)
.where(
and(
eq(organizationInvites.id, inviteId),
eq(organizationInvites.organizationId, currentUser.organizationId)
)
)
.get()
if (!invite) return { success: false, error: "Invite not found" }
await db
.update(organizationInvites)
.set({ isActive: false })
.where(eq(organizationInvites.id, inviteId))
.run()
revalidatePath("/dashboard/settings")
return { success: true }
} catch (error) {
console.error("Error revoking invite:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
// --- getInviteByCode (public — no auth) ---
export async function getInviteByCode(code: string): Promise<{
success: boolean
error?: string
data?: {
readonly organizationName: string
readonly role: string
}
}> {
const INVALID = "This invite link is invalid or has expired"
try {
const { env } = await getCloudflareContext()
if (!env?.DB) return { success: false, error: INVALID }
const db = getDb(env.DB)
const row = await db
.select({
id: organizationInvites.id,
role: organizationInvites.role,
maxUses: organizationInvites.maxUses,
useCount: organizationInvites.useCount,
expiresAt: organizationInvites.expiresAt,
isActive: organizationInvites.isActive,
organizationName: organizations.name,
})
.from(organizationInvites)
.innerJoin(organizations, eq(organizationInvites.organizationId, organizations.id))
.where(eq(organizationInvites.code, code))
.get()
if (!row || !row.isActive) return { success: false, error: INVALID }
if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
return { success: false, error: INVALID }
}
if (row.maxUses !== null && row.useCount >= row.maxUses) {
return { success: false, error: INVALID }
}
return { success: true, data: { organizationName: row.organizationName, role: row.role } }
} catch (error) {
console.error("Error looking up invite:", error)
return { success: false, error: INVALID }
}
}
// --- acceptInvite ---
export async function acceptInvite(code: string): Promise<{
success: boolean
error?: string
data?: { organizationId: string; organizationName: string }
}> {
const INVALID = "This invite link is invalid or has expired"
try {
const currentUser = await getCurrentUser()
if (!currentUser) return { success: false, error: "Unauthorized" }
if (isDemoUser(currentUser.id)) return { success: false, error: "DEMO_READ_ONLY" }
const { env } = await getCloudflareContext()
if (!env?.DB) return { success: false, error: "Database not available" }
const db = getDb(env.DB)
const invite = await db
.select({
id: organizationInvites.id,
organizationId: organizationInvites.organizationId,
role: organizationInvites.role,
maxUses: organizationInvites.maxUses,
useCount: organizationInvites.useCount,
expiresAt: organizationInvites.expiresAt,
isActive: organizationInvites.isActive,
organizationName: organizations.name,
})
.from(organizationInvites)
.innerJoin(organizations, eq(organizationInvites.organizationId, organizations.id))
.where(eq(organizationInvites.code, code))
.get()
if (!invite || !invite.isActive) return { success: false, error: INVALID }
if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) {
return { success: false, error: INVALID }
}
if (invite.maxUses !== null && invite.useCount >= invite.maxUses) {
return { success: false, error: INVALID }
}
// check user is not already a member
const existing = await db
.select({ id: organizationMembers.id })
.from(organizationMembers)
.where(
and(
eq(organizationMembers.organizationId, invite.organizationId),
eq(organizationMembers.userId, currentUser.id)
)
)
.get()
if (existing) {
return { success: false, error: "You are already a member of this organization" }
}
const now = new Date().toISOString()
await db
.insert(organizationMembers)
.values({
id: crypto.randomUUID(),
organizationId: invite.organizationId,
userId: currentUser.id,
role: invite.role,
joinedAt: now,
})
.run()
const newUseCount = invite.useCount + 1
const exhausted = invite.maxUses !== null && newUseCount >= invite.maxUses
await db
.update(organizationInvites)
.set({
useCount: newUseCount,
...(exhausted ? { isActive: false } : {}),
})
.where(eq(organizationInvites.id, invite.id))
.run()
const cookieStore = await cookies()
cookieStore.set("compass-active-org", invite.organizationId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 365,
})
revalidatePath("/dashboard")
return {
success: true,
data: {
organizationId: invite.organizationId,
organizationName: invite.organizationName,
},
}
} catch (error) {
console.error("Error accepting invite:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}

View File

@ -0,0 +1,192 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { mcpServers } from "@/db/schema-mcp"
import type { McpServer } from "@/db/schema-mcp"
import { eq, and } from "drizzle-orm"
import { getCurrentUser } from "@/lib/auth"
import { revalidatePath } from "next/cache"
type CreateMcpServerData = {
readonly name: string
readonly slug: string
readonly transport: string
readonly command?: string
readonly args?: string
readonly env?: string
readonly url?: string
readonly headers?: string
}
type UpdateMcpServerData = Partial<CreateMcpServerData>
export async function getMcpServers(): Promise<
| { success: true; data: ReadonlyArray<McpServer> }
| { success: false; error: string }
> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Unauthorized" }
if (!user.organizationId) return { success: false, error: "No organization" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const rows = await db
.select()
.from(mcpServers)
.where(eq(mcpServers.orgId, user.organizationId))
.all()
return { success: true, data: rows }
} catch (error) {
console.error("getMcpServers failed:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
export async function createMcpServer(
data: CreateMcpServerData
): Promise<{ success: true; data: McpServer } | { success: false; error: string }> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Unauthorized" }
if (!user.organizationId) return { success: false, error: "No organization" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const row: McpServer = {
id: crypto.randomUUID(),
orgId: user.organizationId,
name: data.name,
slug: data.slug,
transport: data.transport,
command: data.command ?? null,
args: data.args ?? null,
env: data.env ?? null,
url: data.url ?? null,
headers: data.headers ?? null,
isEnabled: true,
createdAt: new Date().toISOString(),
createdBy: user.id,
}
await db.insert(mcpServers).values(row).run()
revalidatePath("/dashboard/settings")
return { success: true, data: row }
} catch (error) {
console.error("createMcpServer failed:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
export async function updateMcpServer(
id: string,
data: UpdateMcpServerData
): Promise<{ success: true } | { success: false; error: string }> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Unauthorized" }
if (!user.organizationId) return { success: false, error: "No organization" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const existing = await db
.select({ id: mcpServers.id })
.from(mcpServers)
.where(and(eq(mcpServers.id, id), eq(mcpServers.orgId, user.organizationId)))
.get()
if (!existing) return { success: false, error: "Server not found" }
await db.update(mcpServers).set(data).where(eq(mcpServers.id, id)).run()
revalidatePath("/dashboard/settings")
return { success: true }
} catch (error) {
console.error("updateMcpServer failed:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
export async function deleteMcpServer(
id: string
): Promise<{ success: true } | { success: false; error: string }> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Unauthorized" }
if (!user.organizationId) return { success: false, error: "No organization" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const existing = await db
.select({ id: mcpServers.id })
.from(mcpServers)
.where(and(eq(mcpServers.id, id), eq(mcpServers.orgId, user.organizationId)))
.get()
if (!existing) return { success: false, error: "Server not found" }
await db.delete(mcpServers).where(eq(mcpServers.id, id)).run()
revalidatePath("/dashboard/settings")
return { success: true }
} catch (error) {
console.error("deleteMcpServer failed:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}
export async function toggleMcpServer(
id: string,
enabled: boolean
): Promise<{ success: true } | { success: false; error: string }> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Unauthorized" }
if (!user.organizationId) return { success: false, error: "No organization" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const existing = await db
.select({ id: mcpServers.id })
.from(mcpServers)
.where(and(eq(mcpServers.id, id), eq(mcpServers.orgId, user.organizationId)))
.get()
if (!existing) return { success: false, error: "Server not found" }
await db
.update(mcpServers)
.set({ isEnabled: enabled })
.where(eq(mcpServers.id, id))
.run()
revalidatePath("/dashboard/settings")
return { success: true }
} catch (error) {
console.error("toggleMcpServer failed:", error)
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
}
}
}

View File

@ -0,0 +1,435 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { getDb } from "@/db"
import {
userProviderConfig,
agentConfig,
} from "@/db/schema-ai-config"
import { getCurrentUser } from "@/lib/auth"
import { can } from "@/lib/permissions"
import { encrypt, decrypt } from "@/lib/crypto"
import { isDemoUser } from "@/lib/demo"
// --- constants ---
const ORG_DEFAULT_USER_ID = "org_default"
// --- types ---
interface ProviderConfigData {
readonly providerType: string
readonly hasApiKey: boolean
readonly baseUrl: string | null
readonly modelOverrides: Record<string, string> | null
readonly isActive: boolean
}
interface ProviderConfigForJwt {
readonly type: string
readonly apiKey: string | null
readonly baseUrl: string | null
readonly modelOverrides: Record<string, string> | null
}
// --- actions ---
export async function getUserProviderConfig(): Promise<
| { success: true; data: ProviderConfigData | null }
| { success: false; error: string }
> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const config = await db
.select()
.from(userProviderConfig)
.where(eq(userProviderConfig.userId, user.id))
.get()
if (!config) {
return { success: true, data: null }
}
let modelOverrides: Record<string, string> | null = null
if (config.modelOverrides) {
try {
modelOverrides = JSON.parse(
config.modelOverrides
) as Record<string, string>
} catch {
modelOverrides = null
}
}
return {
success: true,
data: {
providerType: config.providerType,
hasApiKey: config.apiKey !== null,
baseUrl: config.baseUrl,
modelOverrides,
isActive: config.isActive === 1,
},
}
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to get provider config",
}
}
}
export async function getProviderConfigForJwt(
userId: string
): Promise<ProviderConfigForJwt | null> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const config = await db
.select()
.from(userProviderConfig)
.where(eq(userProviderConfig.userId, userId))
.get()
if (!config) {
return null
}
const encryptionKey = (
env as unknown as Record<string, string>
).PROVIDER_KEY_ENCRYPTION_KEY
let decryptedApiKey: string | null = null
if (config.apiKey) {
if (!encryptionKey) {
// Can't decrypt, but still return the config without a key
decryptedApiKey = null
} else {
try {
decryptedApiKey = await decrypt(
config.apiKey,
encryptionKey,
userId
)
} catch {
decryptedApiKey = null
}
}
}
let modelOverrides: Record<string, string> | null = null
if (config.modelOverrides) {
try {
modelOverrides = JSON.parse(
config.modelOverrides
) as Record<string, string>
} catch {
modelOverrides = null
}
}
return {
type: config.providerType,
apiKey: decryptedApiKey,
baseUrl: config.baseUrl,
modelOverrides,
}
} catch {
return null
}
}
export async function setUserProviderConfig(
providerType: string,
apiKey?: string,
baseUrl?: string,
modelOverrides?: Record<string, string>
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const config = await db
.select()
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
const isAdmin = can(user, "agent", "update")
if (
!isAdmin &&
config &&
config.allowUserSelection !== 1
) {
return {
success: false,
error: "User provider selection is disabled",
}
}
let encryptedApiKey: string | null = null
if (apiKey) {
const encryptionKey = (
env as unknown as Record<string, string>
).PROVIDER_KEY_ENCRYPTION_KEY
if (!encryptionKey) {
return {
success: false,
error:
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
}
}
encryptedApiKey = await encrypt(
apiKey,
encryptionKey,
user.id
)
}
const now = new Date().toISOString()
const modelOverridesJson = modelOverrides
? JSON.stringify(modelOverrides)
: null
await db
.insert(userProviderConfig)
.values({
userId: user.id,
providerType,
apiKey: encryptedApiKey,
baseUrl: baseUrl ?? null,
modelOverrides: modelOverridesJson,
isActive: 1,
updatedAt: now,
})
.onConflictDoUpdate({
target: userProviderConfig.userId,
set: {
providerType,
apiKey: encryptedApiKey,
baseUrl: baseUrl ?? null,
modelOverrides: modelOverridesJson,
isActive: 1,
updatedAt: now,
},
})
.run()
revalidatePath("/dashboard")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to set provider config",
}
}
}
export async function clearUserProviderConfig(): Promise<{
success: boolean
error?: string
}> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db
.delete(userProviderConfig)
.where(eq(userProviderConfig.userId, user.id))
.run()
revalidatePath("/dashboard")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to clear provider config",
}
}
}
export async function getOrgProviderConfig(): Promise<
| { success: true; data: ProviderConfigData | null }
| { success: false; error: string }
> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (!can(user, "agent", "update")) {
return {
success: false,
error: "Permission denied",
}
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const config = await db
.select()
.from(userProviderConfig)
.where(
eq(userProviderConfig.userId, ORG_DEFAULT_USER_ID)
)
.get()
if (!config) {
return { success: true, data: null }
}
let modelOverrides: Record<string, string> | null = null
if (config.modelOverrides) {
try {
modelOverrides = JSON.parse(
config.modelOverrides
) as Record<string, string>
} catch {
modelOverrides = null
}
}
return {
success: true,
data: {
providerType: config.providerType,
hasApiKey: config.apiKey !== null,
baseUrl: config.baseUrl,
modelOverrides,
isActive: config.isActive === 1,
},
}
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to get org provider config",
}
}
}
export async function setOrgProviderConfig(
providerType: string,
apiKey?: string,
baseUrl?: string
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (!can(user, "agent", "update")) {
return {
success: false,
error: "Permission denied",
}
}
if (isDemoUser(user.id)) {
return { success: false, error: "DEMO_READ_ONLY" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
let encryptedApiKey: string | null = null
if (apiKey) {
const encryptionKey = (
env as unknown as Record<string, string>
).PROVIDER_KEY_ENCRYPTION_KEY
if (!encryptionKey) {
return {
success: false,
error:
"Encryption key not configured (PROVIDER_KEY_ENCRYPTION_KEY)",
}
}
encryptedApiKey = await encrypt(
apiKey,
encryptionKey,
ORG_DEFAULT_USER_ID
)
}
const now = new Date().toISOString()
await db
.insert(userProviderConfig)
.values({
userId: ORG_DEFAULT_USER_ID,
providerType,
apiKey: encryptedApiKey,
baseUrl: baseUrl ?? null,
modelOverrides: null,
isActive: 1,
updatedAt: now,
})
.onConflictDoUpdate({
target: userProviderConfig.userId,
set: {
providerType,
apiKey: encryptedApiKey,
baseUrl: baseUrl ?? null,
isActive: 1,
updatedAt: now,
},
})
.run()
revalidatePath("/dashboard")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to set org provider config",
}
}
}

View File

@ -1,7 +1,11 @@
import { streamText } from "ai" import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getAgentModel } from "@/lib/agent/provider"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
import { compassCatalog } from "@/lib/agent/render/catalog" import { compassCatalog } from "@/lib/agent/render/catalog"
import { getDb } from "@/db"
import { agentConfig } from "@/db/schema-ai-config"
import { eq } from "drizzle-orm"
const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
const SYSTEM_PROMPT = compassCatalog.prompt({ const SYSTEM_PROMPT = compassCatalog.prompt({
customRules: [ customRules: [
@ -53,6 +57,31 @@ const SYSTEM_PROMPT = compassCatalog.prompt({
const MAX_PROMPT_LENGTH = 2000 const MAX_PROMPT_LENGTH = 2000
async function getModelConfig(): Promise<{
apiKey: string
modelId: string
}> {
const { env } = await getCloudflareContext()
const apiKey = (env as unknown as Record<string, string>)
.OPENROUTER_API_KEY
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY not configured")
}
const db = getDb(env.DB)
const config = await db
.select({ modelId: agentConfig.modelId })
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
return {
apiKey,
modelId: config?.modelId ?? DEFAULT_MODEL_ID,
}
}
export async function POST( export async function POST(
req: Request req: Request
): Promise<Response> { ): Promise<Response> {
@ -61,7 +90,10 @@ export async function POST(
return new Response("Unauthorized", { status: 401 }) return new Response("Unauthorized", { status: 401 })
} }
let body: { prompt?: string; context?: Record<string, unknown> } let body: {
prompt?: string
context?: Record<string, unknown>
}
try { try {
body = (await req.json()) as { body = (await req.json()) as {
prompt?: string prompt?: string
@ -70,14 +102,20 @@ export async function POST(
} catch { } catch {
return new Response( return new Response(
JSON.stringify({ error: "Invalid JSON body" }), JSON.stringify({ error: "Invalid JSON body" }),
{ status: 400, headers: { "Content-Type": "application/json" } } {
status: 400,
headers: { "Content-Type": "application/json" },
}
) )
} }
const { prompt, context } = body const { prompt, context } = body
const previousSpec = context?.previousSpec as const previousSpec = context?.previousSpec as
| { root?: string; elements?: Record<string, unknown> } | {
root?: string
elements?: Record<string, unknown>
}
| undefined | undefined
const sanitizedPrompt = String(prompt || "").slice( const sanitizedPrompt = String(prompt || "").slice(
@ -87,15 +125,15 @@ export async function POST(
let userPrompt = sanitizedPrompt let userPrompt = sanitizedPrompt
// include data context if provided
const dataContext = context?.dataContext as const dataContext = context?.dataContext as
| Record<string, unknown> | Record<string, unknown>
| undefined | undefined
if (dataContext && Object.keys(dataContext).length > 0) { if (dataContext && Object.keys(dataContext).length > 0) {
userPrompt += `\n\nAVAILABLE DATA:\n${JSON.stringify(dataContext, null, 2)}` userPrompt +=
`\n\nAVAILABLE DATA:\n` +
JSON.stringify(dataContext, null, 2)
} }
// include previous spec for iterative updates
if ( if (
previousSpec?.root && previousSpec?.root &&
previousSpec.elements && previousSpec.elements &&
@ -115,14 +153,89 @@ IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to m
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.` DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`
} }
const model = await getAgentModel() const { apiKey, modelId } = await getModelConfig()
const result = streamText({ // call OpenRouter directly (OpenAI-compatible streaming)
model, const response = await fetch(
system: SYSTEM_PROMPT, "https://openrouter.ai/api/v1/chat/completions",
prompt: userPrompt, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://compass.build",
"X-Title": "Compass",
},
body: JSON.stringify({
model: modelId,
stream: true,
temperature: 0.7, temperature: 0.7,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
}),
}
)
if (!response.ok || !response.body) {
return new Response("Model API error", {
status: 502,
})
}
// transform OpenAI SSE stream into plain text stream
// that useUIStream from @json-render/react expects
const reader = response.body.getReader()
const decoder = new TextDecoder()
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) {
controller.close()
return
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const data = line.slice(6).trim()
if (data === "[DONE]") {
controller.close()
return
}
try {
const parsed = JSON.parse(data) as {
choices?: ReadonlyArray<{
delta?: { content?: string }
}>
}
const content =
parsed.choices?.[0]?.delta?.content
if (content) {
controller.enqueue(encoder.encode(content))
}
} catch {
// skip malformed chunks
}
}
}
},
}) })
return result.toTextStreamResponse() return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
},
})
} }

424
src/app/api/agent/route.ts Executable file → Normal file
View File

@ -1,42 +1,154 @@
import { /**
streamText, * Cloud-mode agent API route.
stepCountIs, * Runs on Cloudflare Workers via OpenNext. Uses agent-core
convertToModelMessages, * for the agentic loop with MCP-based tool routing.
RetryError, */
type UIMessage,
} from "ai"
import { APICallError } from "@ai-sdk/provider"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import {
resolveModelForUser,
createModelFromId,
DEFAULT_MODEL_ID,
} from "@/lib/agent/provider"
import { agentTools } from "@/lib/agent/tools"
import { githubTools } from "@/lib/agent/github-tools"
import { buildSystemPrompt } from "@/lib/agent/system-prompt"
import { loadMemoriesForPrompt } from "@/lib/agent/memory"
import { getRegistry } from "@/lib/agent/plugins/registry"
import { saveStreamUsage } from "@/lib/agent/usage"
import { getCurrentUser } from "@/lib/auth"
import { getDb } from "@/db"
import { isDemoUser } from "@/lib/demo"
export async function POST(req: Request): Promise<Response> { import { getCurrentUser } from "@/lib/auth"
const user = await getCurrentUser() import { getProviderConfigForJwt } from "@/app/actions/provider-config"
if (!user) { import { getOAuthAccessToken } from "@/app/actions/anthropic-oauth"
return new Response("Unauthorized", { status: 401 }) import { generateAgentToken } from "@/lib/agent/api-auth"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { mcpServers } from "@/db/schema-mcp"
import { eq } from "drizzle-orm"
import {
runAgent,
buildSystemPrompt,
createSSEStream,
createCompassServer,
createClientManager,
} from "agent-core"
import type {
DataSource,
ProviderConfig,
McpServerConfig,
} from "agent-core"
interface ChatRequest {
readonly messages: ReadonlyArray<{
readonly role: "user" | "assistant"
readonly content: string
}>
} }
const { env, ctx } = await getCloudflareContext() function mapProviderType(
const db = getDb(env.DB) type: string
const envRecord = env as unknown as Record<string, string> ): ProviderConfig["type"] {
switch (type) {
case "anthropic-key":
case "anthropic-oauth":
return "anthropic"
case "openrouter":
return "openrouter"
case "ollama":
return "ollama"
default:
return "custom"
}
}
const apiKey = envRecord.OPENROUTER_API_KEY export async function POST(
if (!apiKey) { request: Request
): Promise<Response> {
const user = await getCurrentUser()
if (!user) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
)
}
let body: ChatRequest
try {
body = await request.json()
} catch {
return new Response(
JSON.stringify({ error: "Invalid JSON body" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
if (
!Array.isArray(body.messages) ||
body.messages.length === 0
) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "OPENROUTER_API_KEY not configured", error:
"messages array is required and cannot be empty",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const model =
request.headers.get("x-model") ?? "sonnet"
const currentPage =
request.headers.get("x-current-page") ?? "/dashboard"
const timezone =
request.headers.get("x-timezone") ?? "UTC"
// Resolve provider config from DB
let providerConfig = await getProviderConfigForJwt(
user.id
)
if (!providerConfig) {
providerConfig = await getProviderConfigForJwt(
"org_default"
)
}
let provider: ProviderConfig = providerConfig
? {
type: mapProviderType(providerConfig.type),
apiKey: providerConfig.apiKey ?? undefined,
baseUrl: providerConfig.baseUrl ?? undefined,
modelOverrides:
providerConfig.modelOverrides ?? undefined,
}
: { type: "anthropic" }
// Resolve OAuth access token if needed
if (providerConfig?.type === "anthropic-oauth") {
const accessToken = await getOAuthAccessToken(user.id)
if (!accessToken) {
return new Response(
JSON.stringify({
error: "Anthropic OAuth not connected or token expired",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
)
}
provider = {
type: "anthropic",
apiKey: accessToken,
}
}
// Generate JWT for bridge route auth
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<
string,
string
>
const agentSecret = envRecord.AGENT_AUTH_SECRET
if (!agentSecret) {
return new Response(
JSON.stringify({
error: "AGENT_AUTH_SECRET not configured",
}), }),
{ {
status: 500, status: 500,
@ -45,130 +157,166 @@ export async function POST(req: Request): Promise<Response> {
) )
} }
const { getCustomDashboards } = await import( const token = await generateAgentToken(
"@/app/actions/dashboards" agentSecret,
user.id,
user.organizationId ?? "",
user.role,
false
) )
const [memories, registry, dashboardResult] = const baseUrl =
await Promise.all([ envRecord.COMPASS_API_BASE_URL ??
loadMemoriesForPrompt(db, user.id), request.headers.get("origin") ??
getRegistry(db, envRecord), ""
getCustomDashboards(),
])
const pluginSections = registry.getPromptSections() const dataSource: DataSource = {
const pluginTools = registry.getTools() async fetch(
path: string,
fetchBody?: unknown
): Promise<unknown> {
const res = await fetch(`${baseUrl}${path}`, {
method: fetchBody ? "POST" : "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: fetchBody
? JSON.stringify(fetchBody)
: undefined,
})
if (!res.ok) {
const err = await res
.json()
.catch(() => ({ error: res.statusText }))
const errObj = err as { error?: string }
throw new Error(
errObj.error ?? `API error ${res.status}`
)
}
return res.json()
},
}
let body: { messages: UIMessage[] } // Set up MCP-based tool routing
const compassServer = createCompassServer(dataSource)
const manager = createClientManager(compassServer)
// Load external MCP servers from DB (HTTP only on Workers)
const mcpConfigs: McpServerConfig[] = [
{
name: "compass",
transport: { type: "in-memory" },
enabled: true,
},
]
if (user.organizationId) {
try { try {
body = (await req.json()) as { messages: UIMessage[] } const db = getDb(env.DB)
} catch { const rows = await db
return new Response( .select()
JSON.stringify({ error: "Invalid JSON body" }), .from(mcpServers)
{ status: 400, headers: { "Content-Type": "application/json" } } .where(eq(mcpServers.orgId, user.organizationId))
) .all()
for (const row of rows) {
if (!row.isEnabled) continue
// Workers can't spawn processes — skip stdio
if (row.transport === "stdio") continue
if (row.transport === "http" && row.url) {
const headers = row.headers
? (JSON.parse(row.headers) as Record<
string,
string
>)
: undefined
mcpConfigs.push({
name: row.slug,
transport: {
type: "http",
url: row.url,
headers,
},
enabled: true,
})
} }
}
const currentPage = } catch (err) {
req.headers.get("x-current-page") ?? undefined
const timezone =
req.headers.get("x-timezone") ?? undefined
const conversationId =
req.headers.get("x-conversation-id") ||
crypto.randomUUID()
let modelId = await resolveModelForUser(db, user.id)
if (!modelId || !modelId.includes("/")) {
console.error( console.error(
`Invalid model ID resolved: "${modelId}",` + "Failed to load external MCP servers:",
` falling back to default` err
) )
modelId = DEFAULT_MODEL_ID }
} }
const model = createModelFromId(apiKey, modelId) await manager.connect(mcpConfigs)
// detect demo mode // Identify external tools for system prompt
const isDemo = isDemoUser(user.id) const allTools = manager.listTools()
const externalMcpTools = allTools
.filter((t) => t.serverName !== "compass")
.map((t) => ({
serverName: t.serverName,
name: t.name,
}))
const result = streamText({ const msgs = body.messages as Array<{
model, role: "user" | "assistant"
system: buildSystemPrompt({ content: string
userName: user.displayName ?? user.email, }>
userRole: user.role,
const systemPrompt = buildSystemPrompt({
context: {
userId: user.id,
orgId: user.organizationId ?? "",
role: user.role,
isDemoUser: false,
currentPage, currentPage,
timezone, timezone,
memories,
pluginSections,
dashboards: dashboardResult.success
? dashboardResult.data
: [],
mode: isDemo ? "demo" : "full",
}),
messages: await convertToModelMessages(
body.messages
),
tools: {
...agentTools,
...githubTools,
...pluginTools,
}, },
toolChoice: "auto", messages: msgs,
stopWhen: stepCountIs(10), externalMcpTools:
onError({ error }) { externalMcpTools.length > 0
const apiErr = unwrapAPICallError(error) ? externalMcpTools
if (apiErr) { : undefined,
console.error( })
`Agent API error [model=${modelId}]`,
`status=${apiErr.statusCode}`, const isOAuth =
`body=${apiErr.responseBody}` provider.apiKey?.startsWith("sk-ant-oat") ?? false
)
} else { const stream = runAgent({
const msg = provider,
error instanceof Error model,
? error.message systemPrompt,
: String(error) messages: msgs,
console.error( mcpClientManager: manager,
`Agent error [model=${modelId}]:`, isOAuth,
msg })
)
// Wrap stream to disconnect MCP after completion
const sseStream = createSSEStream(stream)
const wrappedStream = new ReadableStream<Uint8Array>({
async start(controller) {
const reader = sseStream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
controller.enqueue(value)
}
} finally {
controller.close()
await manager.disconnect()
} }
}, },
}) })
ctx.waitUntil( return new Response(wrappedStream, {
saveStreamUsage( headers: {
db, "Content-Type": "text/event-stream",
conversationId, "Cache-Control": "no-cache",
user.id, Connection: "keep-alive",
modelId,
result
)
)
return result.toUIMessageStreamResponse({
onError(error) {
const apiErr = unwrapAPICallError(error)
if (apiErr) {
return (
apiErr.responseBody ??
`Provider error (${apiErr.statusCode})`
)
}
return error instanceof Error
? error.message
: "Unknown error"
}, },
}) })
} }
function unwrapAPICallError(
error: unknown
): APICallError | undefined {
if (APICallError.isInstance(error)) return error
if (RetryError.isInstance(error)) {
const last: unknown = error.lastError
if (APICallError.isInstance(last)) return last
}
return undefined
}

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getWorkOS } from "@workos-inc/authkit-nextjs" import { getWorkOS } from "@workos-inc/authkit-nextjs"
import { z } from "zod" import { z } from "zod"
import { ensureUserExists } from "@/lib/auth"
const verifyEmailSchema = z.object({ const verifyEmailSchema = z.object({
code: z.string().min(1, "Verification code is required"), code: z.string().min(1, "Verification code is required"),
@ -51,6 +52,15 @@ export async function POST(request: NextRequest) {
const workos = getWorkOS() const workos = getWorkOS()
await workos.userManagement.verifyEmail({ userId, code }) await workos.userManagement.verifyEmail({ userId, code })
const user = await workos.userManagement.getUser(userId)
await ensureUserExists({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
profilePictureUrl: user.profilePictureUrl,
})
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: "Email verified successfully", message: "Email verified successfully",

View File

@ -0,0 +1,148 @@
import { validateAgentAuth } from "@/lib/agent/api-auth"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import {
getCustomDashboards,
getCustomDashboardById,
deleteCustomDashboard,
} from "@/app/actions/dashboards"
type DashboardAction = "list" | "get" | "delete"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
let body: { action: DashboardAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
try {
switch (body.action) {
case "list": {
const result = await getCustomDashboards()
if (!result.success) {
return new Response(
JSON.stringify({ error: result.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
dashboards: result.data,
count: result.data.length,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "get": {
const dashboardId = body.dashboardId as string
if (!dashboardId) {
return new Response(
JSON.stringify({ error: "dashboardId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await getCustomDashboardById(dashboardId)
if (!result.success) {
return new Response(
JSON.stringify({ error: result.error }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
dashboard: result.data,
spec: JSON.parse(result.data.specData),
queries: result.data.queries,
renderPrompt: result.data.renderPrompt,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "delete": {
const dashboardId = body.dashboardId as string
if (!dashboardId) {
return new Response(
JSON.stringify({ error: "dashboardId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await deleteCustomDashboard(dashboardId)
if (!result.success) {
return new Response(
JSON.stringify({ error: result.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
success: true,
message: "Dashboard deleted",
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Dashboards endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,290 @@
import { validateAgentAuth } from "@/lib/agent/api-auth"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import {
getGitHubConfig,
fetchCommits,
fetchCommitDiff,
fetchPullRequests,
fetchIssues,
fetchContributors,
fetchMilestones,
fetchRepoStats,
createIssue,
} from "@/lib/github/client"
type GitHubAction = "query" | "createIssue"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
let body: { action: GitHubAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
const cfgResult = await getGitHubConfig()
if (!cfgResult.success) {
return new Response(
JSON.stringify({ error: cfgResult.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const cfg = cfgResult.config
try {
switch (body.action) {
case "query": {
const queryType = body.queryType as string
const sha = body.sha as string | undefined
const state = (body.state as "open" | "closed" | "all") ?? "open"
const labels = body.labels as string | undefined
const limit = (body.limit as number) ?? 10
if (!queryType) {
return new Response(
JSON.stringify({ error: "queryType required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
switch (queryType) {
case "commits": {
const res = await fetchCommits(cfg, limit)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data, count: res.data.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "commit_diff": {
if (!sha) {
return new Response(
JSON.stringify({
error: "sha is required for commit_diff",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const res = await fetchCommitDiff(cfg, sha)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "pull_requests": {
const res = await fetchPullRequests(cfg, state, limit)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data, count: res.data.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "issues": {
const res = await fetchIssues(cfg, state, labels, limit)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data, count: res.data.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "contributors": {
const res = await fetchContributors(cfg)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data, count: res.data.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "milestones": {
const res = await fetchMilestones(cfg, state)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data, count: res.data.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "repo_stats": {
const res = await fetchRepoStats(cfg)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({ data: res.data }),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown query type" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
}
case "createIssue": {
const title = body.title as string
const bodyText = body.body as string
const labels = body.labels as string[] | undefined
const assignee = body.assignee as string | undefined
const milestone = body.milestone as number | undefined
if (!title || !bodyText) {
return new Response(
JSON.stringify({ error: "title and body required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const res = await createIssue(
cfg,
title,
bodyText,
labels,
assignee,
milestone,
)
if (!res.success) {
return new Response(
JSON.stringify({ error: res.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
success: true,
data: {
issueNumber: res.data.number,
issueUrl: res.data.url,
title: res.data.title,
},
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("GitHub endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,132 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import { saveMemory, searchMemories } from "@/lib/agent/memory"
type MemoryAction = "save" | "search"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
const db = getDb(env.DB)
let body: { action: MemoryAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
try {
switch (body.action) {
case "save": {
const content = body.content as string
const memoryType = body.memoryType as
| "preference"
| "workflow"
| "fact"
| "decision"
const tags = (body.tags as string) ?? undefined
const importance = (body.importance as number) ?? undefined
if (!content || !memoryType) {
return new Response(
JSON.stringify({
error: "content and memoryType required",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const id = await saveMemory(
db,
auth.userId,
content,
memoryType,
tags,
importance,
)
return new Response(
JSON.stringify({
success: true,
id,
content,
memoryType,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "search": {
const query = body.query as string
const limit = (body.limit as number) ?? 5
if (!query) {
return new Response(
JSON.stringify({ error: "query required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const results = await searchMemories(
db,
auth.userId,
query,
limit,
)
return new Response(
JSON.stringify({
success: true,
results,
count: results.length,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Memory endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,170 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import {
getUserProviderConfig,
setUserProviderConfig,
clearUserProviderConfig,
} from "@/app/actions/provider-config"
export async function GET(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
try {
const result = await getUserProviderConfig()
if (!result.success) {
return new Response(JSON.stringify({ error: result.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
return new Response(
JSON.stringify({
success: true,
data: result.data,
}),
{
headers: { "Content-Type": "application/json" },
}
)
} catch (error) {
console.error("Provider config GET error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
let body: {
type: string
apiKey?: string
baseUrl?: string
modelOverrides?: Record<string, string>
}
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
const { type, apiKey, baseUrl, modelOverrides } = body
if (!type) {
return new Response(
JSON.stringify({ error: "type field is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
try {
const result = await setUserProviderConfig(
type,
apiKey,
baseUrl,
modelOverrides
)
if (!result.success) {
return new Response(JSON.stringify({ error: result.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
return new Response(
JSON.stringify({
success: true,
}),
{
headers: { "Content-Type": "application/json" },
}
)
} catch (error) {
console.error("Provider config POST error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}
export async function DELETE(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
try {
const result = await clearUserProviderConfig()
if (!result.success) {
return new Response(JSON.stringify({ error: result.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
return new Response(
JSON.stringify({
success: true,
}),
{
headers: { "Content-Type": "application/json" },
}
)
} catch (error) {
console.error("Provider config DELETE error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,306 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import { projects, scheduleTasks } from "@/db/schema"
import { invoices, vendorBills } from "@/db/schema-netsuite"
import { eq, and, like } from "drizzle-orm"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
const db = getDb(env.DB)
let body: {
queryType: string
id?: string
search?: string
limit?: number
}
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
const orgId = auth.orgId
const cap = body.limit ?? 20
try {
switch (body.queryType) {
case "customers": {
const rows = await db.query.customers.findMany({
limit: cap,
where: (c, { eq: eqFunc, like: likeFunc, and: andFunc }) => {
const conditions = [eqFunc(c.organizationId, orgId)]
if (body.search) {
conditions.push(likeFunc(c.name, `%${body.search}%`))
}
return conditions.length > 1
? andFunc(...conditions)
: conditions[0]
},
})
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "vendors": {
const rows = await db.query.vendors.findMany({
limit: cap,
where: (v, { eq: eqFunc, like: likeFunc, and: andFunc }) => {
const conditions = [eqFunc(v.organizationId, orgId)]
if (body.search) {
conditions.push(likeFunc(v.name, `%${body.search}%`))
}
return conditions.length > 1
? andFunc(...conditions)
: conditions[0]
},
})
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "projects": {
const rows = await db.query.projects.findMany({
limit: cap,
where: (p, { eq: eqFunc, like: likeFunc, and: andFunc }) => {
const conditions = [eqFunc(p.organizationId, orgId)]
if (body.search) {
conditions.push(likeFunc(p.name, `%${body.search}%`))
}
return conditions.length > 1
? andFunc(...conditions)
: conditions[0]
},
})
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "invoices": {
const rows = await db
.select({
id: invoices.id,
netsuiteId: invoices.netsuiteId,
customerId: invoices.customerId,
projectId: invoices.projectId,
invoiceNumber: invoices.invoiceNumber,
status: invoices.status,
issueDate: invoices.issueDate,
dueDate: invoices.dueDate,
subtotal: invoices.subtotal,
tax: invoices.tax,
total: invoices.total,
amountPaid: invoices.amountPaid,
amountDue: invoices.amountDue,
memo: invoices.memo,
lineItems: invoices.lineItems,
createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt,
})
.from(invoices)
.innerJoin(projects, eq(invoices.projectId, projects.id))
.where(eq(projects.organizationId, orgId))
.limit(cap)
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "vendor_bills": {
const rows = await db
.select({
id: vendorBills.id,
netsuiteId: vendorBills.netsuiteId,
vendorId: vendorBills.vendorId,
projectId: vendorBills.projectId,
billNumber: vendorBills.billNumber,
status: vendorBills.status,
billDate: vendorBills.billDate,
dueDate: vendorBills.dueDate,
subtotal: vendorBills.subtotal,
tax: vendorBills.tax,
total: vendorBills.total,
amountPaid: vendorBills.amountPaid,
amountDue: vendorBills.amountDue,
memo: vendorBills.memo,
lineItems: vendorBills.lineItems,
createdAt: vendorBills.createdAt,
updatedAt: vendorBills.updatedAt,
})
.from(vendorBills)
.innerJoin(projects, eq(vendorBills.projectId, projects.id))
.where(eq(projects.organizationId, orgId))
.limit(cap)
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "schedule_tasks": {
const whereConditions = [eq(projects.organizationId, orgId)]
if (body.search) {
whereConditions.push(like(scheduleTasks.title, `%${body.search}%`))
}
const rows = await db
.select({
id: scheduleTasks.id,
projectId: scheduleTasks.projectId,
title: scheduleTasks.title,
startDate: scheduleTasks.startDate,
workdays: scheduleTasks.workdays,
endDateCalculated: scheduleTasks.endDateCalculated,
phase: scheduleTasks.phase,
status: scheduleTasks.status,
isCriticalPath: scheduleTasks.isCriticalPath,
isMilestone: scheduleTasks.isMilestone,
percentComplete: scheduleTasks.percentComplete,
assignedTo: scheduleTasks.assignedTo,
sortOrder: scheduleTasks.sortOrder,
createdAt: scheduleTasks.createdAt,
updatedAt: scheduleTasks.updatedAt,
})
.from(scheduleTasks)
.innerJoin(projects, eq(scheduleTasks.projectId, projects.id))
.where(
whereConditions.length > 1
? and(...whereConditions)
: whereConditions[0]
)
.limit(cap)
return new Response(
JSON.stringify({ data: rows, count: rows.length }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "project_detail": {
if (!body.id) {
return new Response(
JSON.stringify({ error: "id required for detail query" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const row = await db.query.projects.findFirst({
where: (p, { eq: eqFunc, and: andFunc }) =>
andFunc(eqFunc(p.id, body.id!), eqFunc(p.organizationId, orgId)),
})
if (!row) {
return new Response(JSON.stringify({ error: "not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
}
return new Response(JSON.stringify({ data: row }), {
headers: { "Content-Type": "application/json" },
})
}
case "customer_detail": {
if (!body.id) {
return new Response(
JSON.stringify({ error: "id required for detail query" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const row = await db.query.customers.findFirst({
where: (c, { eq: eqFunc, and: andFunc }) =>
andFunc(eqFunc(c.id, body.id!), eqFunc(c.organizationId, orgId)),
})
if (!row) {
return new Response(JSON.stringify({ error: "not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
}
return new Response(JSON.stringify({ data: row }), {
headers: { "Content-Type": "application/json" },
})
}
case "vendor_detail": {
if (!body.id) {
return new Response(
JSON.stringify({ error: "id required for detail query" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const row = await db.query.vendors.findFirst({
where: (v, { eq: eqFunc, and: andFunc }) =>
andFunc(eqFunc(v.id, body.id!), eqFunc(v.organizationId, orgId)),
})
if (!row) {
return new Response(JSON.stringify({ error: "not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
}
return new Response(JSON.stringify({ data: row }), {
headers: { "Content-Type": "application/json" },
})
}
default:
return new Response(
JSON.stringify({ error: "unknown query type" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Query endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,686 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import { projects, scheduleTasks, taskDependencies, workdayExceptions } from "@/db/schema"
import { eq, asc, and } from "drizzle-orm"
import { calculateEndDate } from "@/lib/schedule/business-days"
import { findCriticalPath } from "@/lib/schedule/critical-path"
import { wouldCreateCycle } from "@/lib/schedule/dependency-validation"
import { propagateDates } from "@/lib/schedule/propagate-dates"
import { revalidatePath } from "next/cache"
import type {
TaskStatus,
DependencyType,
ExceptionCategory,
ExceptionRecurrence,
WorkdayExceptionData,
} from "@/lib/schedule/types"
type ScheduleAction =
| "getSchedule"
| "createTask"
| "updateTask"
| "deleteTask"
| "createDependency"
| "deleteDependency"
| "addException"
| "removeException"
async function fetchProjectExceptions(
db: ReturnType<typeof getDb>,
projectId: string,
): Promise<WorkdayExceptionData[]> {
const rows = await db
.select()
.from(workdayExceptions)
.where(eq(workdayExceptions.projectId, projectId))
return rows.map((r) => ({
...r,
category: r.category as ExceptionCategory,
recurrence: r.recurrence as ExceptionRecurrence,
}))
}
async function fetchProjectDeps(
db: ReturnType<typeof getDb>,
projectId: string,
) {
const tasks = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, projectId))
const allDeps = await db.select().from(taskDependencies)
const taskIdSet = new Set(tasks.map((t) => t.id))
const deps = allDeps.filter(
(d) =>
taskIdSet.has(d.predecessorId) && taskIdSet.has(d.successorId),
)
return {
tasks: tasks.map((t) => ({
...t,
status: t.status as TaskStatus,
})),
deps: deps.map((d) => ({
...d,
type: d.type as DependencyType,
})),
}
}
async function recalcCriticalPathDirect(
db: ReturnType<typeof getDb>,
projectId: string,
): Promise<void> {
const tasks = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, projectId))
const allDeps = await db.select().from(taskDependencies)
const taskIdSet = new Set(tasks.map((t) => t.id))
const projectDeps = allDeps.filter(
(d) =>
taskIdSet.has(d.predecessorId) && taskIdSet.has(d.successorId),
)
const criticalSet = findCriticalPath(
tasks.map((t) => ({
...t,
status: t.status as TaskStatus,
})),
projectDeps.map((d) => ({
...d,
type: d.type as DependencyType,
})),
)
for (const task of tasks) {
const isCritical = criticalSet.has(task.id)
if (task.isCriticalPath !== isCritical) {
await db
.update(scheduleTasks)
.set({ isCriticalPath: isCritical })
.where(eq(scheduleTasks.id, task.id))
}
}
}
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
const db = getDb(env.DB)
let body: { action: ScheduleAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
try {
switch (body.action) {
case "getSchedule": {
const projectId = body.projectId as string
if (!projectId) {
return new Response(
JSON.stringify({ error: "projectId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const [project] = await db
.select({ id: projects.id })
.from(projects)
.where(
and(
eq(projects.id, projectId),
eq(projects.organizationId, auth.orgId)
)
)
.limit(1)
if (!project) {
return new Response(
JSON.stringify({ error: "Project not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
const { tasks: typedTasks, deps: typedDeps } =
await fetchProjectDeps(db, projectId)
const exceptions = await fetchProjectExceptions(db, projectId)
const total = typedTasks.length
const completed = typedTasks.filter(
(t) => t.status === "COMPLETE",
).length
const inProgress = typedTasks.filter(
(t) => t.status === "IN_PROGRESS",
).length
const blocked = typedTasks.filter(
(t) => t.status === "BLOCKED",
).length
const overallPercent =
total > 0
? Math.round(
typedTasks.reduce(
(sum, t) => sum + t.percentComplete,
0,
) / total,
)
: 0
const criticalPath = typedTasks
.filter((t) => t.isCriticalPath)
.map((t) => ({
id: t.id,
title: t.title,
status: t.status,
startDate: t.startDate,
endDate: t.endDateCalculated,
}))
return new Response(
JSON.stringify({
tasks: typedTasks,
dependencies: typedDeps,
exceptions,
summary: {
total,
completed,
inProgress,
blocked,
pending: total - completed - inProgress - blocked,
overallPercent,
criticalPath,
},
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "createTask": {
if (auth.isDemoUser) {
return new Response(
JSON.stringify({ error: "DEMO_READ_ONLY" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const projectId = body.projectId as string
const title = body.title as string
const startDate = body.startDate as string
const workdays = body.workdays as number
const phase = body.phase as string
const isMilestone = (body.isMilestone as boolean) ?? false
const percentComplete = (body.percentComplete as number) ?? 0
const assignedTo = (body.assignedTo as string | undefined) ?? null
if (!projectId || !title || !startDate || workdays === undefined || !phase) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const [project] = await db
.select({ id: projects.id })
.from(projects)
.where(
and(
eq(projects.id, projectId),
eq(projects.organizationId, auth.orgId)
)
)
.limit(1)
if (!project) {
return new Response(
JSON.stringify({ error: "Project not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
const exceptions = await fetchProjectExceptions(db, projectId)
const endDate = calculateEndDate(startDate, workdays, exceptions)
const now = new Date().toISOString()
const existing = await db
.select({ sortOrder: scheduleTasks.sortOrder })
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, projectId))
.orderBy(asc(scheduleTasks.sortOrder))
const nextOrder =
existing.length > 0
? existing[existing.length - 1].sortOrder + 1
: 0
const id = crypto.randomUUID()
await db.insert(scheduleTasks).values({
id,
projectId,
title,
startDate,
workdays,
endDateCalculated: endDate,
phase,
status: "PENDING",
isCriticalPath: false,
isMilestone,
percentComplete,
assignedTo,
sortOrder: nextOrder,
createdAt: now,
updatedAt: now,
})
await recalcCriticalPathDirect(db, projectId)
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
return new Response(
JSON.stringify({
success: true,
message: `Task "${title}" created`,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "updateTask": {
if (auth.isDemoUser) {
return new Response(
JSON.stringify({ error: "DEMO_READ_ONLY" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const taskId = body.taskId as string
if (!taskId) {
return new Response(
JSON.stringify({ error: "taskId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const [task] = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.id, taskId))
.limit(1)
if (!task) {
return new Response(
JSON.stringify({ error: "Task not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
const { action: _action, taskId: _taskId, status, ...fields } = body
const hasFields = Object.keys(fields).length > 0
if (hasFields) {
const exceptions = await fetchProjectExceptions(db, task.projectId)
const startDate = (fields.startDate as string) ?? task.startDate
const workdays = (fields.workdays as number) ?? task.workdays
const endDate = calculateEndDate(startDate, workdays, exceptions)
await db
.update(scheduleTasks)
.set({
...(fields.title ? { title: fields.title as string } : {}),
startDate,
workdays,
endDateCalculated: endDate,
...(fields.phase ? { phase: fields.phase as string } : {}),
...(fields.isMilestone !== undefined
? { isMilestone: fields.isMilestone as boolean }
: {}),
...(fields.percentComplete !== undefined
? { percentComplete: fields.percentComplete as number }
: {}),
...(fields.assignedTo !== undefined
? { assignedTo: fields.assignedTo as string | null }
: {}),
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, taskId))
// propagate date changes
const allTasks = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.projectId, task.projectId))
const allDeps = await db.select().from(taskDependencies)
const taskIdSet = new Set(allTasks.map((t) => t.id))
const projectDeps = allDeps
.filter(
(d) =>
taskIdSet.has(d.predecessorId) &&
taskIdSet.has(d.successorId),
)
.map((d) => ({
...d,
type: d.type as DependencyType,
}))
const updatedTask = {
...task,
status: task.status as TaskStatus,
startDate,
workdays,
endDateCalculated: endDate,
}
const typedAll = allTasks.map((t) =>
t.id === taskId
? updatedTask
: { ...t, status: t.status as TaskStatus },
)
const { updatedTasks } = propagateDates(
taskId,
typedAll,
projectDeps,
exceptions,
)
for (const [id, dates] of updatedTasks) {
await db
.update(scheduleTasks)
.set({
startDate: dates.startDate,
endDateCalculated: dates.endDateCalculated,
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, id))
}
}
if (status) {
await db
.update(scheduleTasks)
.set({
status: status as TaskStatus,
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, taskId))
}
if (!hasFields && !status) {
return new Response(
JSON.stringify({ error: "No fields provided to update" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
await recalcCriticalPathDirect(db, task.projectId)
revalidatePath(`/dashboard/projects/${task.projectId}/schedule`)
return new Response(
JSON.stringify({
success: true,
message: "Task updated",
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "deleteTask": {
if (auth.isDemoUser) {
return new Response(
JSON.stringify({ error: "DEMO_READ_ONLY" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const taskId = body.taskId as string
if (!taskId) {
return new Response(
JSON.stringify({ error: "taskId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const [task] = await db
.select()
.from(scheduleTasks)
.where(eq(scheduleTasks.id, taskId))
.limit(1)
if (!task) {
return new Response(
JSON.stringify({ error: "Task not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
await db
.delete(scheduleTasks)
.where(eq(scheduleTasks.id, taskId))
await recalcCriticalPathDirect(db, task.projectId)
revalidatePath(`/dashboard/projects/${task.projectId}/schedule`)
return new Response(
JSON.stringify({
success: true,
message: "Task deleted",
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "createDependency": {
if (auth.isDemoUser) {
return new Response(
JSON.stringify({ error: "DEMO_READ_ONLY" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const projectId = body.projectId as string
const predecessorId = body.predecessorId as string
const successorId = body.successorId as string
const type = body.type as DependencyType
const lagDays = (body.lagDays as number) ?? 0
if (!projectId || !predecessorId || !successorId || !type) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const { deps: existingDeps } = await fetchProjectDeps(db, projectId)
if (
wouldCreateCycle(existingDeps, predecessorId, successorId)
) {
return new Response(
JSON.stringify({
error: "This dependency would create a cycle",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const depId = crypto.randomUUID()
await db.insert(taskDependencies).values({
id: depId,
predecessorId,
successorId,
type,
lagDays,
})
const exceptions = await fetchProjectExceptions(db, projectId)
const { tasks: typedTasks } = await fetchProjectDeps(db, projectId)
const updatedDeps = [
...existingDeps,
{
id: depId,
predecessorId,
successorId,
type,
lagDays,
},
]
const { updatedTasks } = propagateDates(
predecessorId,
typedTasks,
updatedDeps,
exceptions,
)
for (const [id, dates] of updatedTasks) {
await db
.update(scheduleTasks)
.set({
startDate: dates.startDate,
endDateCalculated: dates.endDateCalculated,
updatedAt: new Date().toISOString(),
})
.where(eq(scheduleTasks.id, id))
}
await recalcCriticalPathDirect(db, projectId)
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
return new Response(
JSON.stringify({
success: true,
message: "Dependency created",
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "deleteDependency": {
if (auth.isDemoUser) {
return new Response(
JSON.stringify({ error: "DEMO_READ_ONLY" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const dependencyId = body.dependencyId as string
const projectId = body.projectId as string
if (!dependencyId || !projectId) {
return new Response(
JSON.stringify({ error: "dependencyId and projectId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
await db
.delete(taskDependencies)
.where(eq(taskDependencies.id, dependencyId))
await recalcCriticalPathDirect(db, projectId)
revalidatePath(`/dashboard/projects/${projectId}/schedule`)
return new Response(
JSON.stringify({
success: true,
message: "Dependency removed",
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Schedule endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,153 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import {
installSkill as installSkillAction,
uninstallSkill as uninstallSkillAction,
toggleSkill as toggleSkillAction,
getInstalledSkills as getInstalledSkillsAction,
} from "@/app/actions/plugins"
type SkillAction = "list" | "install" | "toggle" | "uninstall"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
let body: { action: SkillAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
try {
switch (body.action) {
case "list": {
const result = await getInstalledSkillsAction()
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
})
}
case "install": {
if (auth.role !== "admin") {
return new Response(
JSON.stringify({
error: "admin role required to install skills",
}),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const source = body.source as string
if (!source) {
return new Response(
JSON.stringify({ error: "source required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await installSkillAction(source)
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
})
}
case "toggle": {
if (auth.role !== "admin") {
return new Response(
JSON.stringify({ error: "admin role required" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const pluginId = body.pluginId as string
const enabled = body.enabled as boolean
if (!pluginId || enabled === undefined) {
return new Response(
JSON.stringify({
error: "pluginId and enabled required",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await toggleSkillAction(pluginId, enabled)
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
})
}
case "uninstall": {
if (auth.role !== "admin") {
return new Response(
JSON.stringify({ error: "admin role required" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
)
}
const pluginId = body.pluginId as string
if (!pluginId) {
return new Response(
JSON.stringify({ error: "pluginId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await uninstallSkillAction(pluginId)
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
})
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Skills endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -0,0 +1,337 @@
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { validateAgentAuth } from "@/lib/agent/api-auth"
import {
getCustomThemes,
setUserThemePreference,
saveCustomTheme,
getCustomThemeById,
} from "@/app/actions/themes"
import { THEME_PRESETS, findPreset } from "@/lib/theme/presets"
import type {
ThemeDefinition,
ColorMap,
ThemeFonts,
ThemeTokens,
ThemeShadows,
} from "@/lib/theme/types"
type ThemeAction = "list" | "set" | "generate" | "edit"
export async function POST(req: Request): Promise<Response> {
const { env } = await getCloudflareContext()
const envRecord = env as unknown as Record<string, string>
const auth = await validateAgentAuth(req, envRecord)
if (!auth.valid) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
let body: { action: ThemeAction; [key: string]: unknown }
try {
body = await req.json()
} catch {
return new Response(JSON.stringify({ error: "Invalid JSON body" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
try {
switch (body.action) {
case "list": {
const presets = THEME_PRESETS.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
isPreset: true,
}))
const customResult = await getCustomThemes()
const customs = customResult.success
? customResult.data.map((c) => ({
id: c.id,
name: c.name,
description: c.description,
isPreset: false,
}))
: []
return new Response(
JSON.stringify({ themes: [...presets, ...customs] }),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "set": {
const themeId = body.themeId as string
if (!themeId) {
return new Response(
JSON.stringify({ error: "themeId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const result = await setUserThemePreference(themeId)
if (!result.success) {
return new Response(
JSON.stringify({ error: result.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
success: true,
themeId,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "generate": {
const name = body.name as string
const description = body.description as string
const light = body.light as Record<string, string>
const dark = body.dark as Record<string, string>
const fonts = body.fonts as { sans: string; serif: string; mono: string }
const googleFonts = (body.googleFonts as string[]) ?? []
const radius = (body.radius as string) ?? "0.5rem"
const spacing = (body.spacing as string) ?? "0.25rem"
if (!name || !description || !light || !dark || !fonts) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const nativePreset = findPreset("native-compass")
if (!nativePreset) {
return new Response(
JSON.stringify({ error: "Internal error" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
const tokens: ThemeTokens = {
radius,
spacing,
trackingNormal: "0em",
shadowColor: "#000000",
shadowOpacity: "0.1",
shadowBlur: "3px",
shadowSpread: "0px",
shadowOffsetX: "0",
shadowOffsetY: "1px",
}
const defaultShadows: ThemeShadows = {
"2xs": "0 1px 3px 0px hsl(0 0% 0% / 0.05)",
xs: "0 1px 3px 0px hsl(0 0% 0% / 0.05)",
sm: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)",
default:
"0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)",
md: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10)",
lg: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10)",
xl: "0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10)",
"2xl": "0 1px 3px 0px hsl(0 0% 0% / 0.25)",
}
const theme: ThemeDefinition = {
id: "",
name,
description,
light: light as unknown as ColorMap,
dark: dark as unknown as ColorMap,
fonts: fonts as ThemeFonts,
fontSources: {
googleFonts,
},
tokens,
shadows: { light: defaultShadows, dark: defaultShadows },
isPreset: false,
previewColors: {
primary: light["primary"] ?? "oklch(0.5 0.1 200)",
background: light["background"] ?? "oklch(0.97 0 0)",
foreground: light["foreground"] ?? "oklch(0.2 0 0)",
},
}
const saveResult = await saveCustomTheme(
name,
description,
JSON.stringify(theme),
)
if (!saveResult.success) {
return new Response(
JSON.stringify({ error: saveResult.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const savedTheme = { ...theme, id: saveResult.id }
return new Response(
JSON.stringify({
success: true,
themeId: saveResult.id,
themeData: savedTheme,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
case "edit": {
const themeId = body.themeId as string
if (!themeId) {
return new Response(
JSON.stringify({ error: "themeId required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
const existing = await getCustomThemeById(themeId)
if (!existing.success) {
return new Response(
JSON.stringify({ error: existing.error }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
)
}
const prev = JSON.parse(
existing.data.themeData,
) as ThemeDefinition
const mergedLight = body.light
? ({
...prev.light,
...(body.light as Record<string, string>),
} as unknown as ColorMap)
: prev.light
const mergedDark = body.dark
? ({
...prev.dark,
...(body.dark as Record<string, string>),
} as unknown as ColorMap)
: prev.dark
const mergedFonts: ThemeFonts = body.fonts
? {
sans:
(body.fonts as { sans?: string }).sans ?? prev.fonts.sans,
serif:
(body.fonts as { serif?: string }).serif ??
prev.fonts.serif,
mono:
(body.fonts as { mono?: string }).mono ?? prev.fonts.mono,
}
: prev.fonts
const mergedTokens: ThemeTokens = {
...prev.tokens,
...(body.radius ? { radius: body.radius as string } : {}),
...(body.spacing ? { spacing: body.spacing as string } : {}),
}
const mergedFontSources = body.googleFonts
? { googleFonts: body.googleFonts as string[] }
: prev.fontSources
const name = (body.name as string) ?? existing.data.name
const description =
(body.description as string) ?? existing.data.description
const merged: ThemeDefinition = {
...prev,
id: themeId,
name,
description,
light: mergedLight,
dark: mergedDark,
fonts: mergedFonts,
fontSources: mergedFontSources,
tokens: mergedTokens,
previewColors: {
primary: mergedLight.primary,
background: mergedLight.background,
foreground: mergedLight.foreground,
},
}
const saveResult = await saveCustomTheme(
name,
description,
JSON.stringify(merged),
themeId,
)
if (!saveResult.success) {
return new Response(
JSON.stringify({ error: saveResult.error }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
return new Response(
JSON.stringify({
success: true,
themeId,
themeData: merged,
}),
{
headers: { "Content-Type": "application/json" },
}
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
)
}
} catch (error) {
console.error("Themes endpoint error:", error)
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Internal error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
)
}
}

View File

@ -3,20 +3,13 @@
import * as React from "react" import * as React from "react"
import { import {
IconAdjustments, IconAdjustments,
IconBrain,
IconPalette, IconPalette,
IconPlug, IconPlug,
IconPuzzle,
IconRobot, IconRobot,
IconTerminal2,
IconUsers, IconUsers,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { import {
Select, Select,
SelectContent, SelectContent,
@ -24,14 +17,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PreferencesTab } from "@/components/settings/preferences-tab" import { PreferencesTab } from "@/components/settings/preferences-tab"
import { AppearanceTab } from "@/components/settings/appearance-tab" import { AppearanceTab } from "@/components/settings/appearance-tab"
import { TeamTab } from "@/components/settings/team-tab" import { TeamTab } from "@/components/settings/team-tab"
import { AIModelTab } from "@/components/settings/ai-model-tab" import { AgentTab } from "@/components/settings/agent-tab"
import { SkillsTab } from "@/components/settings/skills-tab"
import { ClaudeCodeTab } from "@/components/settings/claude-code-tab"
import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status" import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status"
import { SyncControls } from "@/components/netsuite/sync-controls" import { SyncControls } from "@/components/netsuite/sync-controls"
import { GoogleDriveConnectionStatus } from "@/components/google/connection-status" import { GoogleDriveConnectionStatus } from "@/components/google/connection-status"
@ -40,46 +32,17 @@ const SETTINGS_TABS = [
{ value: "preferences", label: "Preferences", icon: IconAdjustments }, { value: "preferences", label: "Preferences", icon: IconAdjustments },
{ value: "appearance", label: "Theme", icon: IconPalette }, { value: "appearance", label: "Theme", icon: IconPalette },
{ value: "team", label: "Team", icon: IconUsers }, { value: "team", label: "Team", icon: IconUsers },
{ value: "ai-model", label: "AI Model", icon: IconBrain },
{ value: "agent", label: "Agent", icon: IconRobot }, { value: "agent", label: "Agent", icon: IconRobot },
{ value: "skills", label: "Skills", icon: IconPuzzle },
{ value: "integrations", label: "Integrations", icon: IconPlug }, { value: "integrations", label: "Integrations", icon: IconPlug },
{ value: "claude-code", label: "Code Bridge", icon: IconTerminal2 },
] as const ] as const
type SectionValue = (typeof SETTINGS_TABS)[number]["value"] type SectionValue = (typeof SETTINGS_TABS)[number]["value"]
// wide sections get unconstrained width for tables/complex layouts // wide sections get unconstrained width for tables/complex layouts
const WIDE_SECTIONS = new Set<string>([ const WIDE_SECTIONS = new Set<string>([
"appearance", "team", "ai-model", "claude-code", "appearance", "team", "agent",
]) ])
function AgentSection() {
const [signetId, setSignetId] = React.useState("")
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="signet-id" className="text-xs">
Signet ID (ETH)
</Label>
<Input
id="signet-id"
value={signetId}
onChange={(e) => setSignetId(e.target.value)}
placeholder="0x..."
className="h-9 max-w-sm font-mono"
type="password"
/>
</div>
<Separator />
<Button className="w-full max-w-sm">
Configure your agent
</Button>
</div>
)
}
function IntegrationsSection() { function IntegrationsSection() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -106,16 +69,10 @@ export default function SettingsPage() {
return <AppearanceTab /> return <AppearanceTab />
case "team": case "team":
return <TeamTab /> return <TeamTab />
case "ai-model":
return <AIModelTab />
case "agent": case "agent":
return <AgentSection /> return <AgentTab />
case "skills":
return <SkillsTab />
case "integrations": case "integrations":
return <IntegrationsSection /> return <IntegrationsSection />
case "claude-code":
return <ClaudeCodeTab />
default: default:
return null return null
} }

View File

@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Sora, IBM_Plex_Mono, Playfair_Display } from "next/font/google"; import { Sora, IBM_Plex_Mono, Playfair_Display } from "next/font/google";
import { AuthKitProvider } from "@workos-inc/authkit-nextjs/components";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { AuthWrapper } from "@/components/auth-wrapper";
import "./globals.css"; import "./globals.css";
const sora = Sora({ const sora = Sora({
@ -44,11 +44,11 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${sora.variable} ${ibmPlexMono.variable} ${playfair.variable} font-sans antialiased`}> <body className={`${sora.variable} ${ibmPlexMono.variable} ${playfair.variable} font-sans antialiased`}>
<AuthKitProvider> <AuthWrapper>
<ThemeProvider> <ThemeProvider>
{children} {children}
</ThemeProvider> </ThemeProvider>
</AuthKitProvider> </AuthWrapper>
</body> </body>
</html> </html>
); );

View File

@ -1,7 +1,6 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { type UIMessage } from "ai"
import { useUIStream, type Spec } from "@json-render/react" import { useUIStream, type Spec } from "@json-render/react"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { import {
@ -11,8 +10,8 @@ import {
} from "@/app/actions/agent" } from "@/app/actions/agent"
import { getTextFromParts } from "@/lib/agent/chat-adapter" import { getTextFromParts } from "@/lib/agent/chat-adapter"
import { useCompassChat } from "@/hooks/use-compass-chat" import { useCompassChat } from "@/hooks/use-compass-chat"
import type { AgentMessage } from "@/lib/agent/message-types"
import { import {
WebSocketChatTransport,
detectBridge, detectBridge,
} from "@/lib/agent/ws-transport" } from "@/lib/agent/ws-transport"
import { import {
@ -47,11 +46,11 @@ export function useChatPanel(): PanelContextValue {
// --- Chat state context --- // --- Chat state context ---
interface ChatStateValue { interface ChatStateValue {
readonly messages: ReadonlyArray<UIMessage> readonly messages: ReadonlyArray<AgentMessage>
setMessages: ( setMessages: (
messages: messages:
| UIMessage[] | AgentMessage[]
| ((prev: UIMessage[]) => UIMessage[]) | ((prev: AgentMessage[]) => AgentMessage[])
) => void ) => void
sendMessage: (params: { text: string }) => void sendMessage: (params: { text: string }) => void
regenerate: () => void regenerate: () => void
@ -148,7 +147,7 @@ export function useAgentOptional(): PanelContextValue | null {
// --- Helper: extract generateUI output from parts --- // --- Helper: extract generateUI output from parts ---
function findGenerateUIOutput( function findGenerateUIOutput(
parts: ReadonlyArray<unknown>, parts: ReadonlyArray<AgentMessage["parts"][number]>,
dispatched: Set<string> dispatched: Set<string>
): { ): {
renderPrompt: string renderPrompt: string
@ -156,24 +155,13 @@ function findGenerateUIOutput(
callId: string callId: string
} | null { } | null {
for (const part of parts) { for (const part of parts) {
const p = part as Record<string, unknown> // only check tool-result parts
const pType = p.type as string | undefined if (part.type !== "tool-result") continue
// handle both static tool parts (tool-<name>) const callId = part.toolCallId
// and dynamic tool parts (dynamic-tool) if (dispatched.has(callId)) continue
const isToolPart =
typeof pType === "string" &&
(pType.startsWith("tool-") ||
pType === "dynamic-tool")
if (!isToolPart) continue
const state = p.state as string | undefined const output = part.result as
if (state !== "output-available") continue
const callId = p.toolCallId as string | undefined
if (!callId || dispatched.has(callId)) continue
const output = p.output as
| Record<string, unknown> | Record<string, unknown>
| undefined | undefined
if (output?.action !== "generateUI") continue if (output?.action !== "generateUI") continue
@ -239,10 +227,11 @@ export function ChatProvider({
} }
}, []) }, [])
// TODO: Re-implement bridge transport for new agent architecture
const bridgeTransport = React.useMemo(() => { const bridgeTransport = React.useMemo(() => {
if (bridge.bridgeConnected && bridge.bridgeEnabled) { // if (bridge.bridgeConnected && bridge.bridgeEnabled) {
return new WebSocketChatTransport() // return new WebSocketChatTransport()
} // }
return null return null
}, [bridge.bridgeConnected, bridge.bridgeEnabled]) }, [bridge.bridgeConnected, bridge.bridgeEnabled])
@ -256,14 +245,9 @@ export function ChatProvider({
const serialized = finalMessages.map((m) => ({ const serialized = finalMessages.map((m) => ({
id: m.id, id: m.id,
role: m.role, role: m.role,
content: getTextFromParts( content: getTextFromParts(m.parts),
m.parts as ReadonlyArray<{
type: string
text?: string
}>
),
parts: m.parts, parts: m.parts,
createdAt: new Date().toISOString(), createdAt: m.createdAt.toISOString(),
})) }))
await saveConversation(conversationId, serialized) await saveConversation(conversationId, serialized)
@ -344,7 +328,7 @@ export function ChatProvider({
if (!lastMsg || lastMsg.role !== "assistant") return if (!lastMsg || lastMsg.role !== "assistant") return
const result = findGenerateUIOutput( const result = findGenerateUIOutput(
lastMsg.parts as ReadonlyArray<unknown>, lastMsg.parts,
renderDispatchedRef.current renderDispatchedRef.current
) )
if (!result) return if (!result) return
@ -525,14 +509,15 @@ export function ChatProvider({
setConversationId(lastConv.id) setConversationId(lastConv.id)
const restored: UIMessage[] = msgResult.data.map( const restored: AgentMessage[] = msgResult.data.map(
(m) => ({ (m) => ({
id: m.id, id: m.id,
role: m.role as "user" | "assistant", role: m.role as "user" | "assistant",
parts: parts:
(m.parts as UIMessage["parts"]) ?? [ (m.parts as AgentMessage["parts"]) ?? [
{ type: "text" as const, text: m.content }, { type: "text" as const, text: m.content },
], ],
createdAt: m.createdAt ? new Date(m.createdAt) : new Date(),
}) })
) )
@ -541,15 +526,14 @@ export function ChatProvider({
// renders or navigate to /dashboard on resume // renders or navigate to /dashboard on resume
for (const m of restored) { for (const m of restored) {
if (m.role !== "assistant") continue if (m.role !== "assistant") continue
const parts = m.parts as ReadonlyArray<unknown>
let result = findGenerateUIOutput( let result = findGenerateUIOutput(
parts, m.parts,
renderDispatchedRef.current renderDispatchedRef.current
) )
while (result) { while (result) {
renderDispatchedRef.current.add(result.callId) renderDispatchedRef.current.add(result.callId)
result = findGenerateUIOutput( result = findGenerateUIOutput(
parts, m.parts,
renderDispatchedRef.current renderDispatchedRef.current
) )
} }

View File

@ -20,14 +20,7 @@ import {
IconAlertCircle, IconAlertCircle,
IconEye, IconEye,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { import type { AgentMessage } from "@/lib/agent/message-types"
isTextUIPart,
isToolUIPart,
isReasoningUIPart,
type UIMessage,
type ToolUIPart,
type DynamicToolUIPart,
} from "ai"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
Reasoning, Reasoning,
@ -176,20 +169,18 @@ function friendlyToolName(raw: string): string {
} }
interface ChatMessageProps { interface ChatMessageProps {
readonly msg: UIMessage readonly msg: AgentMessage
readonly copiedId: string | null readonly copiedId: string | null
readonly onCopy: (id: string, text: string) => void readonly onCopy: (id: string, text: string) => void
readonly onRegenerate: () => void readonly onRegenerate: () => void
readonly isStreaming?: boolean readonly isStreaming?: boolean
} }
type AnyToolPart = ToolUIPart | DynamicToolUIPart function extractToolName(part: AgentMessage["parts"][number]): string {
if (part.type === "tool-call") {
function extractToolName(part: AnyToolPart): string { return part.toolName
if (part.type === "dynamic-tool") {
return part.toolName ?? ""
} }
return part.type.slice(5) return ""
} }
// renders parts in their natural order from the AI SDK // renders parts in their natural order from the AI SDK
@ -203,8 +194,8 @@ const ChatMessage = memo(
}: ChatMessageProps) { }: ChatMessageProps) {
if (msg.role === "user") { if (msg.role === "user") {
const text = msg.parts const text = msg.parts
.filter(isTextUIPart) .filter((p) => p.type === "text")
.map((p) => p.text) .map((p) => (p as Extract<typeof p, { type: "text" }>).text)
.join("") .join("")
return ( return (
<Message from="user"> <Message from="user">
@ -264,19 +255,19 @@ const ChatMessage = memo(
for (let i = 0; i < msg.parts.length; i++) { for (let i = 0; i < msg.parts.length; i++) {
const part = msg.parts[i] const part = msg.parts[i]
if (isReasoningUIPart(part)) { if (part.type === "reasoning") {
pendingReasoning += part.text pendingReasoning += part.text
reasoningStreaming = part.state === "streaming" reasoningStreaming = part.state === "streaming"
continue continue
} }
if (isTextUIPart(part)) { if (part.type === "text") {
pendingText += part.text pendingText += part.text
allText += part.text allText += part.text
continue continue
} }
if (isToolUIPart(part)) { if (part.type === "tool-call") {
sawToolPart = true sawToolPart = true
// flush reasoning accumulated before this tool // flush reasoning accumulated before this tool
flushThinking(pendingReasoning, i, reasoningStreaming) flushThinking(pendingReasoning, i, reasoningStreaming)
@ -284,24 +275,45 @@ const ChatMessage = memo(
reasoningStreaming = false reasoningStreaming = false
// flush text as thinking (not final) // flush text as thinking (not final)
flushText(i, false) flushText(i, false)
const tp = part as AnyToolPart const rawName = part.toolName
const rawName = extractToolName(tp)
// map our state to the expected Tool component state
const toolState =
part.state === "partial-call"
? "partial-call"
: part.state === "call"
? "call"
: "result"
// find matching result for this tool call
const resultPart = msg.parts.find(
(p) =>
p.type === "tool-result" &&
p.toolCallId === part.toolCallId
) as Extract<
AgentMessage["parts"][number],
{ type: "tool-result" }
> | undefined
elements.push( elements.push(
<Tool key={tp.toolCallId}> <Tool key={`tool-${part.toolCallId}`}>
<ToolHeader <ToolHeader
title={ title={
friendlyToolName(rawName) || "Working" friendlyToolName(rawName) || "Working"
} }
type={tp.type as ToolUIPart["type"]} type={"tool-call" as const}
state={tp.state} state={toolState}
/> />
<ToolContent> <ToolContent>
<ToolInput input={tp.input} /> <ToolInput input={part.args} />
{(tp.state === "output-available" || {resultPart && (
tp.state === "output-error") && (
<ToolOutput <ToolOutput
output={tp.output} output={resultPart.result}
errorText={tp.errorText} errorText={
resultPart.isError
? String(resultPart.result)
: undefined
}
/> />
)} )}
</ToolContent> </ToolContent>
@ -317,20 +329,6 @@ const ChatMessage = memo(
reasoningStreaming reasoningStreaming
) )
// while streaming, if no tool calls have arrived yet
// and text is substantial, it's likely chain-of-thought
// that'll be reclassified as thinking once tools come in.
// render it collapsed so it doesn't flood the screen.
const COT_THRESHOLD = 500
if (
msgStreaming &&
!sawToolPart &&
pendingText.length > COT_THRESHOLD
) {
flushThinking(pendingText, msg.parts.length, true)
pendingText = ""
}
// flush remaining text as the final response // flush remaining text as the final response
flushText(msg.parts.length, true) flushText(msg.parts.length, true)

View File

@ -1,439 +1,282 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { import { ChevronDown, Check } from "lucide-react"
ChevronDown,
Check,
Search,
Loader2,
Zap,
} from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover" } from "@/components/ui/popover"
import { import { Input } from "@/components/ui/input"
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ProviderIcon, hasLogo } from "./provider-icon"
import { useBridgeState } from "./chat-provider"
import {
getActiveModel,
getModelList,
getUserModelPreference,
setUserModelPreference,
} from "@/app/actions/ai-config"
const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next" // ============================================================================
const DEFAULT_MODEL_NAME = "Qwen3 Coder" // Inline Claude sparkle — rendered directly here to avoid stale HMR
const DEFAULT_PROVIDER = "Alibaba (Qwen)" // from provider-icon.tsx. This is the ONLY icon the model dropdown needs.
// ============================================================================
// anthropic models available through the bridge function ClaudeSparkle({ size = 14 }: { size?: number }): React.JSX.Element {
const BRIDGE_MODELS = [ return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<path
d="M12 2a.9.9 0 0 1 .84.58l2.32 5.94a4.5 4.5 0 0 0 2.6 2.6l5.94 2.32a.9.9 0 0 1 0 1.67l-5.94 2.32a4.5 4.5 0 0 0-2.6 2.6l-2.32 5.94a.9.9 0 0 1-1.68 0l-2.32-5.94a4.5 4.5 0 0 0-2.6-2.6L.3 15.11a.9.9 0 0 1 0-1.67l5.94-2.32a4.5 4.5 0 0 0 2.6-2.6L11.16 2.58A.9.9 0 0 1 12 2Z"
fill="#D97757"
/>
</svg>
)
}
// ============================================================================
// Types
// ============================================================================
const PROVIDER_TYPES = [
"anthropic-oauth",
"anthropic-key",
"openrouter",
"ollama",
"custom",
] as const
type ProviderType = (typeof PROVIDER_TYPES)[number]
const AGENT_MODELS = [
{ {
id: "claude-sonnet-4-5-20250929", id: "sonnet",
name: "Claude Sonnet 4.5", name: "Sonnet",
provider: "Anthropic", description: "Fast and capable",
}, },
{ {
id: "claude-opus-4-6", id: "opus",
name: "Claude Opus 4.6", name: "Opus",
provider: "Anthropic", description: "Most intelligent",
}, },
{ {
id: "claude-haiku-4-5-20251001", id: "haiku",
name: "Claude Haiku 4.5", name: "Haiku",
provider: "Anthropic", description: "Quick and lightweight",
}, },
] as const ] as const
const DEFAULT_BRIDGE_MODEL = BRIDGE_MODELS[0] type AgentModel = (typeof AGENT_MODELS)[number]
// --- shared state so all instances stay in sync --- interface ProviderState {
providerType: ProviderType
interface SharedState { model: AgentModel
readonly display: { customModelId: string
readonly id: string
readonly name: string
readonly provider: string
}
readonly global: {
readonly id: string
readonly name: string
readonly provider: string
}
readonly bridgeModel: {
readonly id: string
readonly name: string
readonly provider: string
}
readonly allowUserSelection: boolean
readonly isAdmin: boolean
readonly maxCostPerMillion: string | null
readonly configLoaded: boolean
} }
let shared: SharedState = { // ============================================================================
display: { // Provider display helpers
id: DEFAULT_MODEL_ID, // ============================================================================
name: DEFAULT_MODEL_NAME,
provider: DEFAULT_PROVIDER, function providerUsesModelPicker(type: ProviderType): boolean {
}, return (
global: { type === "anthropic-oauth" ||
id: DEFAULT_MODEL_ID, type === "anthropic-key" ||
name: DEFAULT_MODEL_NAME, type === "openrouter"
provider: DEFAULT_PROVIDER, )
},
bridgeModel: {
id: DEFAULT_BRIDGE_MODEL.id,
name: DEFAULT_BRIDGE_MODEL.name,
provider: DEFAULT_BRIDGE_MODEL.provider,
},
allowUserSelection: true,
isAdmin: false,
maxCostPerMillion: null,
configLoaded: false,
} }
// ============================================================================
// External store (shared across components)
// ============================================================================
const STORAGE_KEY = "compass-agent-model"
const PROVIDER_STORAGE_KEY = "compass-agent-provider"
function loadState(): ProviderState {
if (typeof window === "undefined") {
return defaultState()
}
try {
const raw = localStorage.getItem(PROVIDER_STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Record<string, unknown>
const providerType = (
PROVIDER_TYPES.includes(parsed.providerType as ProviderType)
? parsed.providerType
: "anthropic-oauth"
) as ProviderType
const modelObj = parsed.model as
| { id?: string }
| undefined
const model =
AGENT_MODELS.find((m) => m.id === modelObj?.id) ??
AGENT_MODELS[0]
return {
providerType,
model,
customModelId:
typeof parsed.customModelId === "string"
? parsed.customModelId
: "",
}
}
} catch {
// fall through
}
// legacy: migrate from old model-only storage
const legacyModel = localStorage.getItem(STORAGE_KEY)
if (legacyModel) {
const model =
AGENT_MODELS.find((m) => m.id === legacyModel) ??
AGENT_MODELS[0]
return { ...defaultState(), model }
}
return defaultState()
}
function defaultState(): ProviderState {
return {
providerType: "anthropic-oauth",
model: AGENT_MODELS[0],
customModelId: "",
}
}
let state: ProviderState = defaultState()
const listeners = new Set<() => void>() const listeners = new Set<() => void>()
function getSnapshot(): SharedState { function subscribe(listener: () => void): () => void {
return shared
}
function setShared(
next: Partial<SharedState>
): void {
shared = { ...shared, ...next }
for (const l of listeners) l()
}
function subscribe(
listener: () => void
): () => void {
listeners.add(listener) listeners.add(listener)
return () => { return () => {
listeners.delete(listener) listeners.delete(listener)
} }
} }
interface ModelInfo { function getSnapshot(): ProviderState {
readonly id: string return state
readonly name: string
readonly provider: string
readonly contextLength: number
readonly promptCost: string
readonly completionCost: string
} }
interface ProviderGroup { function getServerSnapshot(): ProviderState {
readonly provider: string return defaultState()
readonly models: ReadonlyArray<ModelInfo>
} }
function outputCostPerMillion( function emit(): void {
completionCost: string for (const l of listeners) l()
): number {
return parseFloat(completionCost) * 1_000_000
} }
function formatOutputCost( function persistState(): void {
completionCost: string try {
): string { localStorage.setItem(
const cost = outputCostPerMillion(completionCost) PROVIDER_STORAGE_KEY,
if (cost === 0) return "free" JSON.stringify(state)
if (cost < 0.01) return "<$0.01/M" )
return `$${cost.toFixed(2)}/M` localStorage.setItem(STORAGE_KEY, state.model.id)
} catch {
// storage full or unavailable
} }
}
function updateState(patch: Partial<ProviderState>): void {
state = { ...state, ...patch }
persistState()
emit()
}
/**
* Update provider type from settings page.
* Called by ai-model-tab when the user changes provider.
*/
export function setProviderType(type: ProviderType): void {
updateState({ providerType: type })
}
// ============================================================================
// Public API for use-agent.ts
// ============================================================================
/** Returns the model ID to send to the agent server */
export function getAgentModelId(): string {
if (
state.providerType === "ollama" ||
state.providerType === "custom"
) {
return state.customModelId || state.model.id
}
return state.model.id
}
/** Returns the provider type for context */
export function getAgentProviderType(): ProviderType {
return state.providerType
}
// ============================================================================
// Component
// ============================================================================
export function ModelDropdown(): React.JSX.Element { export function ModelDropdown(): React.JSX.Element {
const [open, setOpen] = React.useState(false) const [open, setOpen] = React.useState(false)
const state = React.useSyncExternalStore( const current = React.useSyncExternalStore(
subscribe, subscribe,
getSnapshot, getSnapshot,
getSnapshot getServerSnapshot
) )
const [groups, setGroups] = React.useState<
ReadonlyArray<ProviderGroup>
>([])
const [loading, setLoading] = React.useState(false)
const [search, setSearch] = React.useState("")
const [saving, setSaving] = React.useState<
string | null
>(null)
const [listLoaded, setListLoaded] =
React.useState(false)
const [activeProvider, setActiveProvider] =
React.useState<string | null>(null)
const bridge = useBridgeState()
const bridgeActive =
bridge.bridgeConnected && bridge.bridgeEnabled
// restore from localStorage on mount
React.useEffect(() => { React.useEffect(() => {
if (state.configLoaded) return const stored = loadState()
setShared({ configLoaded: true })
// restore bridge model preference from localStorage
const storedBridge = localStorage.getItem(
"compass-bridge-model"
)
if (storedBridge) {
const found = BRIDGE_MODELS.find(
(m) => m.id === storedBridge
)
if (found) {
setShared({
bridgeModel: {
id: found.id,
name: found.name,
provider: found.provider,
},
})
}
}
Promise.all([
getActiveModel(),
getUserModelPreference(),
]).then(([configResult, prefResult]) => {
let gModelId = DEFAULT_MODEL_ID
let gModelName = DEFAULT_MODEL_NAME
let gProvider = DEFAULT_PROVIDER
let canSelect = true
let ceiling: string | null = null
let admin = false
if (configResult.success && configResult.data) {
gModelId = configResult.data.modelId
gModelName = configResult.data.modelName
gProvider = configResult.data.provider
canSelect =
configResult.data.allowUserSelection
ceiling =
configResult.data.maxCostPerMillion
admin = configResult.data.isAdmin
}
const base: Partial<SharedState> = {
global: {
id: gModelId,
name: gModelName,
provider: gProvider,
},
allowUserSelection: canSelect,
isAdmin: admin,
maxCostPerMillion: ceiling,
}
if ( if (
canSelect && stored.providerType !== state.providerType ||
prefResult.success && stored.model.id !== state.model.id
prefResult.data
) { ) {
const prefValid = state = stored
ceiling === null || emit()
outputCostPerMillion( }
prefResult.data.completionCost }, [])
) <= parseFloat(ceiling)
if (prefValid) { // hydrate provider type from D1 on mount
const slashIdx = React.useEffect(() => {
prefResult.data.modelId.indexOf("/") import("@/app/actions/provider-config")
setShared({ .then(({ getUserProviderConfig }) => {
...base, getUserProviderConfig()
display: { .then((result) => {
id: prefResult.data.modelId, if (!("success" in result) || !result.success)
name:
slashIdx > 0
? prefResult.data.modelId.slice(
slashIdx + 1
)
: prefResult.data.modelId,
provider: "",
},
})
return return
} if (!result.data) return
}
setShared({ const d = result.data
...base, const providerType = (
display: { PROVIDER_TYPES.includes(
id: gModelId, d.providerType as ProviderType
name: gModelName, )
provider: gProvider, ? d.providerType
}, : state.providerType
) as ProviderType
if (providerType !== state.providerType) {
updateState({ providerType })
}
}) })
.catch(() => {})
}) })
}, [state.configLoaded]) .catch(() => {})
}, [])
React.useEffect(() => { const usesModelPicker = providerUsesModelPicker(
if (!open || listLoaded || bridgeActive) return current.providerType
setLoading(true)
getModelList().then((result) => {
if (result.success) {
const sorted = [...result.data]
.sort((a, b) => {
const aHas = hasLogo(a.provider) ? 0 : 1
const bHas = hasLogo(b.provider) ? 0 : 1
if (aHas !== bHas) return aHas - bHas
return a.provider.localeCompare(
b.provider
) )
})
.map((g) => ({
...g,
models: [...g.models].sort(
(a, b) =>
outputCostPerMillion(
a.completionCost
) -
outputCostPerMillion(
b.completionCost
)
),
}))
setGroups(sorted)
}
setListLoaded(true)
setLoading(false)
})
}, [open, listLoaded, bridgeActive])
// reset provider filter when popover closes const displayName = usesModelPicker
React.useEffect(() => { ? current.model.name
if (!open) { : current.customModelId || "Custom"
setActiveProvider(null)
setSearch("")
}
}, [open])
const query = search.toLowerCase() const handleModelSelect = (m: AgentModel): void => {
const ceiling = state.maxCostPerMillion updateState({ model: m })
? parseFloat(state.maxCostPerMillion)
: null
const filtered = React.useMemo(() => {
return groups
.map((g) => ({
...g,
models: g.models.filter((m) => {
if (ceiling !== null) {
if (
outputCostPerMillion(
m.completionCost
) > ceiling
)
return false
}
if (
activeProvider &&
g.provider !== activeProvider
) {
return false
}
if (!query) return true
return (
m.name.toLowerCase().includes(query) ||
m.id.toLowerCase().includes(query)
)
}),
}))
.filter((g) => g.models.length > 0)
}, [groups, query, ceiling, activeProvider])
const totalFiltered = React.useMemo(() => {
let count = 0
for (const g of groups) {
for (const m of g.models) {
if (
ceiling === null ||
outputCostPerMillion(m.completionCost) <=
ceiling
) {
count++
}
}
}
return count
}, [groups, ceiling])
// sorted groups for provider sidebar (cost-filtered)
const sortedGroups = React.useMemo(() => {
return groups
.map((g) => ({
...g,
models: g.models.filter((m) => {
if (ceiling === null) return true
return (
outputCostPerMillion(
m.completionCost
) <= ceiling
)
}),
}))
.filter((g) => g.models.length > 0)
}, [groups, ceiling])
const handleSelect = async (
model: ModelInfo
): Promise<void> => {
if (model.id === state.display.id) {
setOpen(false)
return
}
setSaving(model.id)
const result = await setUserModelPreference(
model.id,
model.promptCost,
model.completionCost
)
setSaving(null)
if (result.success) {
setShared({
display: {
id: model.id,
name: model.name,
provider: model.provider,
},
})
toast.success(`Switched to ${model.name}`)
setOpen(false)
} else {
toast.error(result.error ?? "Failed to switch")
}
}
const handleBridgeModelSelect = (
model: typeof BRIDGE_MODELS[number]
): void => {
setShared({
bridgeModel: {
id: model.id,
name: model.name,
provider: model.provider,
},
})
localStorage.setItem(
"compass-bridge-model",
model.id
)
toast.success(`Bridge model: ${model.name}`)
setOpen(false) setOpen(false)
} }
// bridge active: show bridge model selector const handleCustomModelChange = (v: string): void => {
if (bridgeActive) { updateState({ customModelId: v })
}
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -442,13 +285,13 @@ export function ModelDropdown(): React.JSX.Element {
className={cn( className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs", "flex items-center gap-1.5 rounded-md px-2 py-1 text-xs",
"hover:bg-muted hover:text-foreground transition-colors", "hover:bg-muted hover:text-foreground transition-colors",
"text-emerald-600 dark:text-emerald-400", "text-muted-foreground",
open && "bg-muted text-foreground" open && "bg-muted text-foreground"
)} )}
> >
<Zap className="h-3 w-3" /> <ClaudeSparkle size={14} />
<span className="max-w-32 truncate"> <span className="max-w-36 truncate">
{state.bridgeModel.name} {displayName}
</span> </span>
<ChevronDown className="h-3 w-3 opacity-50" /> <ChevronDown className="h-3 w-3 opacity-50" />
</button> </button>
@ -456,254 +299,57 @@ export function ModelDropdown(): React.JSX.Element {
<PopoverContent <PopoverContent
align="start" align="start"
side="top" side="top"
className="w-64 p-1" className="w-56 p-1"
> >
<div className="px-2 py-1.5 mb-1"> {usesModelPicker ? (
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider"> <div role="radiogroup" aria-label="Model">
Claude Code Bridge {AGENT_MODELS.map((m) => {
</p> const isActive = m.id === current.model.id
</div>
{BRIDGE_MODELS.map((model) => {
const isActive =
model.id === state.bridgeModel.id
return ( return (
<button <button
key={model.id} key={m.id}
type="button" type="button"
onClick={() => role="radio"
handleBridgeModelSelect(model) aria-checked={isActive}
} onClick={() => handleModelSelect(m)}
className={cn( className={cn(
"w-full text-left rounded-lg px-2.5 py-2 flex items-center gap-2.5 transition-all", "w-full text-left rounded-md px-2.5 py-2 flex items-center gap-2.5 transition-all",
isActive isActive
? "bg-primary/10 ring-1 ring-primary/30" ? "bg-primary/10 ring-1 ring-primary/30"
: "hover:bg-muted/70" : "hover:bg-muted/70"
)} )}
> >
<ProviderIcon <div className="flex-1 min-w-0">
provider="Anthropic" <span className="text-xs font-medium">
size={20} {m.name}
className="shrink-0"
/>
<span className="text-xs font-medium flex-1">
{model.name}
</span> </span>
<p className="text-[10px] text-muted-foreground">
{m.description}
</p>
</div>
{isActive && ( {isActive && (
<Check className="h-3 w-3 text-primary shrink-0" /> <Check className="h-3 w-3 text-primary shrink-0" />
)} )}
</button> </button>
) )
})} })}
</PopoverContent>
</Popover>
)
}
if (!state.allowUserSelection && !state.isAdmin) {
return (
<div className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground">
<ProviderIcon
provider={state.global.provider}
size={14}
/>
<span className="max-w-28 truncate">
{state.global.name}
</span>
</div>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground",
"hover:bg-muted hover:text-foreground transition-colors",
open && "bg-muted text-foreground"
)}
>
<ProviderIcon
provider={state.display.provider}
size={14}
/>
<span className="max-w-28 truncate">
{state.display.name}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="w-96 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<TooltipProvider delayDuration={200}>
{/* search */}
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) =>
setSearch(e.target.value)
}
placeholder="Search models..."
className="h-8 pl-8 text-xs"
/>
</div>
</div>
{/* two-panel layout */}
<div className="flex h-72">
{/* provider sidebar */}
<div className="w-11 shrink-0 overflow-y-auto flex flex-col items-center gap-0.5 py-1 border-r">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
setActiveProvider(null)
}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-[10px] font-semibold transition-all shrink-0",
activeProvider === null
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-muted"
)}
>
All
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">
All providers
</p>
</TooltipContent>
</Tooltip>
{sortedGroups.map((group) => (
<Tooltip key={group.provider}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
setActiveProvider(
activeProvider ===
group.provider
? null
: group.provider
)
}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center transition-all shrink-0",
activeProvider ===
group.provider
? "bg-primary/15 scale-110"
: "hover:bg-muted"
)}
>
<ProviderIcon
provider={group.provider}
size={18}
/>
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">
{group.provider} (
{group.models.length})
</p>
</TooltipContent>
</Tooltip>
))}
</div>
{/* model list */}
<div className="flex-1 overflow-y-auto p-1">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
No models found.
</div> </div>
) : ( ) : (
<div className="space-y-1"> <div className="p-1.5">
{filtered.map((group) => <label className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1 block">
group.models.map((model) => { Model ID
const isActive = </label>
model.id === state.display.id <Input
const isSaving = type="text"
saving === model.id value={current.customModelId}
onChange={(e) =>
return ( handleCustomModelChange(e.target.value)
<button
key={model.id}
type="button"
disabled={
isSaving ||
saving !== null
} }
onClick={() => placeholder="llama3.2"
handleSelect(model) className="h-7 text-xs"
}
className={cn(
"w-full text-left rounded-lg px-2.5 py-2 flex items-center gap-2.5 transition-all",
isActive
? "bg-primary/10 ring-1 ring-primary/30"
: "hover:bg-muted/70",
isSaving && "opacity-50"
)}
>
<ProviderIcon
provider={
model.provider
}
size={20}
className="shrink-0"
/> />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium truncate">
{model.name}
</span>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin shrink-0" />
) : isActive ? (
<Check className="h-3 w-3 text-primary shrink-0" />
) : null}
</div>
<Badge
variant="secondary"
className="text-[10px] px-1 py-0 h-3.5 mt-0.5 font-normal"
>
{formatOutputCost(
model.completionCost
)}
</Badge>
</div>
</button>
)
})
)}
</div> </div>
)} )}
</div>
</div>
{/* budget footer */}
{ceiling !== null && listLoaded && (
<div className="border-t px-3 py-1.5">
<p className="text-[10px] text-muted-foreground">
{totalFiltered} models within $
{state.maxCostPerMillion}/M budget
</p>
</div>
)}
</TooltipProvider>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )

View File

@ -2,9 +2,31 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Inline SVG for Claude sparkle — avoids static file caching issues
function ClaudeIcon({ size, className }: { size: number; className?: string }): React.JSX.Element {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className={className}
>
<path
d="M12 2a.9.9 0 0 1 .84.58l2.32 5.94a4.5 4.5 0 0 0 2.6 2.6l5.94 2.32a.9.9 0 0 1 0 1.67l-5.94 2.32a4.5 4.5 0 0 0-2.6 2.6l-2.32 5.94a.9.9 0 0 1-1.68 0l-2.32-5.94a4.5 4.5 0 0 0-2.6-2.6L.3 15.11a.9.9 0 0 1 0-1.67l5.94-2.32a4.5 4.5 0 0 0 2.6-2.6L11.16 2.58A.9.9 0 0 1 12 2Z"
fill="#D97757"
/>
</svg>
)
}
// providers with inline SVG components (preferred) or file-based logos
const INLINE_ICONS: Record<string, (size: number, className?: string) => React.JSX.Element> = {
Anthropic: (size, className) => <ClaudeIcon size={size} className={className} />,
}
// provider logo files in /public/providers/ // provider logo files in /public/providers/
export const PROVIDER_LOGO: Record<string, string> = { export const PROVIDER_LOGO: Record<string, string> = {
Anthropic: "anthropic",
OpenAI: "openai", OpenAI: "openai",
Google: "google", Google: "google",
Meta: "meta", Meta: "meta",
@ -15,6 +37,8 @@ export const PROVIDER_LOGO: Record<string, string> = {
Microsoft: "microsoft", Microsoft: "microsoft",
Amazon: "amazon", Amazon: "amazon",
Perplexity: "perplexity", Perplexity: "perplexity",
Ollama: "ollama",
OpenRouter: "openai",
} }
const PROVIDER_ABBR: Record<string, string> = { const PROVIDER_ABBR: Record<string, string> = {
@ -31,7 +55,7 @@ function getProviderAbbr(name: string): string {
} }
export function hasLogo(provider: string): boolean { export function hasLogo(provider: string): boolean {
return provider in PROVIDER_LOGO return provider in PROVIDER_LOGO || provider in INLINE_ICONS
} }
export function ProviderIcon({ export function ProviderIcon({
@ -43,8 +67,13 @@ export function ProviderIcon({
readonly size?: number readonly size?: number
readonly className?: string readonly className?: string
}): React.JSX.Element { }): React.JSX.Element {
const logo = PROVIDER_LOGO[provider] // Prefer inline SVG components
const inlineIcon = INLINE_ICONS[provider]
if (inlineIcon) {
return inlineIcon(size, className)
}
const logo = PROVIDER_LOGO[provider]
if (logo) { if (logo) {
return ( return (
<img <img

View File

@ -1,6 +1,6 @@
"use client" "use client"
import type { UIMessage } from "ai" import type { UIMessage } from "./types"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react" import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
import type { ComponentProps, HTMLAttributes, ReactElement } from "react" import type { ComponentProps, HTMLAttributes, ReactElement } from "react"
import { createContext, useContext, useEffect, useState } from "react" import { createContext, useContext, useEffect, useState } from "react"

View File

@ -1,6 +1,6 @@
"use client" "use client"
import type { ToolUIPart } from "ai" import type { ToolUIPart } from "./types"
import { CheckIcon, XIcon } from "lucide-react" import { CheckIcon, XIcon } from "lucide-react"
import { type ComponentProps, createContext, type ReactNode, useContext } from "react" import { type ComponentProps, createContext, type ReactNode, useContext } from "react"
import { Alert, AlertDescription } from "@/components/ui/alert" import { Alert, AlertDescription } from "@/components/ui/alert"

View File

@ -1,6 +1,6 @@
"use client" "use client"
import type { LanguageModelUsage } from "ai" import type { LanguageModelUsage } from "./types"
import { type ComponentProps, createContext, useContext } from "react" import { type ComponentProps, createContext, useContext } from "react"
import { getUsage } from "tokenlens" import { getUsage } from "tokenlens"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"

View File

@ -1,7 +1,7 @@
import type { Experimental_GeneratedImage } from "ai" import type { GeneratedImage } from "./types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export type ImageProps = Experimental_GeneratedImage & { export type ImageProps = GeneratedImage & {
className?: string className?: string
alt?: string alt?: string
} }

View File

@ -1,6 +1,6 @@
"use client" "use client"
import type { FileUIPart, UIMessage } from "ai" import type { FileUIPart, UIMessage } from "./types"
import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from "lucide-react" import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from "lucide-react"
import type { ComponentProps, HTMLAttributes, ReactElement } from "react" import type { ComponentProps, HTMLAttributes, ReactElement } from "react"
import { createContext, memo, useContext, useEffect, useState } from "react" import { createContext, memo, useContext, useEffect, useState } from "react"

View File

@ -1,6 +1,6 @@
"use client" "use client"
import type { ChatStatus, FileUIPart } from "ai" import type { ChatStatus, FileUIPart } from "./types"
import { import {
CornerDownLeftIcon, CornerDownLeftIcon,
ImageIcon, ImageIcon,

View File

@ -1,6 +1,15 @@
"use client" "use client"
import type { ToolUIPart } from "ai" // Tool state types (no longer dependent on AI SDK)
type ToolState =
| "input-streaming"
| "input-available"
| "output-available"
| "output-error"
| "output-denied"
| "partial-call"
| "call"
| "result"
import { import {
CheckCircleIcon, CheckCircleIcon,
ChevronDownIcon, ChevronDownIcon,
@ -21,12 +30,12 @@ export const Tool = ({ className, ...props }: ToolProps) => (
export interface ToolHeaderProps { export interface ToolHeaderProps {
title?: string title?: string
type: ToolUIPart["type"] type: string
state: ToolUIPart["state"] state: ToolState
className?: string className?: string
} }
const getStatusIcon = (status: ToolUIPart["state"]): ReactNode => { const getStatusIcon = (status: ToolState): ReactNode => {
switch (status) { switch (status) {
case "input-streaming": case "input-streaming":
case "input-available": case "input-available":
@ -41,8 +50,11 @@ const getStatusIcon = (status: ToolUIPart["state"]): ReactNode => {
} }
} }
const isInProgress = (status: ToolUIPart["state"]): boolean => const isInProgress = (status: ToolState): boolean =>
status === "input-streaming" || status === "input-available" status === "input-streaming" ||
status === "input-available" ||
status === "partial-call" ||
status === "call"
export const ToolHeader = ({ className, title, type, state, ...props }: ToolHeaderProps) => ( export const ToolHeader = ({ className, title, type, state, ...props }: ToolHeaderProps) => (
<CollapsibleTrigger <CollapsibleTrigger
@ -74,7 +86,7 @@ export const ToolContent = ({ className, ...props }: ToolContentProps) => (
) )
export type ToolInputProps = ComponentProps<"div"> & { export type ToolInputProps = ComponentProps<"div"> & {
input: ToolUIPart["input"] input: unknown
} }
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => ( export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
@ -89,8 +101,8 @@ export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
) )
export type ToolOutputProps = ComponentProps<"div"> & { export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolUIPart["output"] output: unknown
errorText: ToolUIPart["errorText"] errorText?: string
} }
export const ToolOutput = ({ className, output, errorText, ...props }: ToolOutputProps) => { export const ToolOutput = ({ className, output, errorText, ...props }: ToolOutputProps) => {

View File

@ -0,0 +1,57 @@
/**
* Local type definitions that replace AI SDK type imports.
* These match the shapes the shadcn AI components actually use.
*/
export type ChatStatus =
| "streaming"
| "submitted"
| "ready"
| "error"
export interface LanguageModelUsage {
readonly inputTokens?: number
readonly outputTokens?: number
readonly totalTokens?: number
readonly cachedInputTokens?: number
readonly reasoningTokens?: number
readonly inputTokenDetails?: {
readonly noCacheTokens?: number
readonly cacheReadTokens?: number
readonly cacheWriteTokens?: number
}
readonly outputTokenDetails?: {
readonly textTokens?: number
readonly reasoningTokens?: number
}
}
export interface FileUIPart {
readonly type: "file"
readonly name?: string
readonly filename?: string
readonly mediaType: string
readonly url: string
}
export interface ToolUIPart {
readonly type: string
readonly toolCallId: string
readonly toolName: string
readonly args: unknown
readonly state: string
readonly result?: unknown
}
export interface UIMessage {
readonly id: string
readonly role: "user" | "assistant" | "system"
readonly parts: ReadonlyArray<Record<string, unknown>>
readonly createdAt?: Date
}
export interface GeneratedImage {
readonly base64?: string
readonly uint8Array?: Uint8Array
readonly mediaType?: string
}

View File

@ -0,0 +1,26 @@
import type { ReactNode } from "react"
/**
* Server component that conditionally renders the real AuthKitProvider
* (when WorkOS is configured) or a simple passthrough (demo/local mode).
*
* This avoids importing @workos-inc/authkit-nextjs/components when
* WORKOS_API_KEY is empty, which would throw NoApiKeyProvidedException.
*/
const isWorkOSConfigured =
!!process.env.WORKOS_API_KEY &&
!!process.env.WORKOS_CLIENT_ID &&
!process.env.WORKOS_API_KEY.includes("placeholder")
export async function AuthWrapper({ children }: { children: ReactNode }) {
if (isWorkOSConfigured) {
// Dynamic import so the module is never loaded when WorkOS is absent
const { AuthKitProvider } = await import(
"@workos-inc/authkit-nextjs/components"
)
return <AuthKitProvider>{children}</AuthKitProvider>
}
return <>{children}</>
}

View File

@ -0,0 +1,74 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { IconLoader } from "@tabler/icons-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { acceptInvite } from "@/app/actions/invites"
interface Props {
code: string
orgName: string
role: string
isAuthenticated: boolean
}
export function JoinForm({ code, orgName, role, isAuthenticated }: Props) {
const router = useRouter()
const [isPending, setIsPending] = React.useState(false)
async function handleJoin() {
setIsPending(true)
try {
const result = await acceptInvite(code)
if (!result.success) {
toast.error(result.error ?? "Failed to join organization")
return
}
router.push("/dashboard")
} finally {
setIsPending(false)
}
}
function handleSignIn() {
router.push(`/login?from=/join/${code}`)
}
return (
<div className="space-y-6">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
You&apos;ve been invited
</h2>
<p className="text-sm text-muted-foreground">
Join <span className="font-medium text-foreground">{orgName}</span> as{" "}
<span className="font-medium text-foreground capitalize">{role}</span>
</p>
</div>
{isAuthenticated ? (
<Button
className="w-full"
onClick={handleJoin}
disabled={isPending}
>
{isPending && (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
)}
Join {orgName}
</Button>
) : (
<div className="space-y-3">
<Button className="w-full" onClick={handleSignIn}>
Sign in to join
</Button>
<p className="text-center text-xs text-muted-foreground">
You&apos;ll be redirected back after signing in.
</p>
</div>
)}
</div>
)
}

View File

@ -45,11 +45,14 @@ export function SignupForm() {
success: boolean success: boolean
message?: string message?: string
error?: string error?: string
userId?: string
} }
if (result.success) { if (result.success) {
toast.success(result.message || "Account created!") toast.success(result.message || "Account created!")
router.push("/verify-email?email=" + encodeURIComponent(data.email)) router.push(
`/verify-email?email=${encodeURIComponent(data.email)}&userId=${encodeURIComponent(result.userId ?? "")}`
)
} else { } else {
toast.error(result.error || "Signup failed") toast.error(result.error || "Signup failed")
} }

View File

@ -116,7 +116,21 @@ export function DesktopShell({ children }: DesktopShellProps) {
const { registerShortcuts } = await import( const { registerShortcuts } = await import(
"@/lib/desktop/shortcuts" "@/lib/desktop/shortcuts"
) )
unregister = await registerShortcuts({ triggerSync }) const { WindowManager } = await import("@/lib/desktop/window-manager")
unregister = await registerShortcuts({
triggerSync,
onZoomIn: () => {
const current = WindowManager.getZoom()
WindowManager.setZoom(Math.round((current + 0.1) * 10) / 10)
},
onZoomOut: () => {
const current = WindowManager.getZoom()
WindowManager.setZoom(Math.round((current - 0.1) * 10) / 10)
},
onZoomReset: () => {
WindowManager.setZoom(1.0)
},
})
} catch (error) { } catch (error) {
console.error("Failed to register desktop shortcuts:", error) console.error("Failed to register desktop shortcuts:", error)
} }

View File

@ -29,10 +29,8 @@ import { Textarea } from "@/components/ui/textarea"
import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status" import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status"
import { SyncControls } from "@/components/netsuite/sync-controls" import { SyncControls } from "@/components/netsuite/sync-controls"
import { GoogleDriveConnectionStatus } from "@/components/google/connection-status" import { GoogleDriveConnectionStatus } from "@/components/google/connection-status"
import { SkillsTab } from "@/components/settings/skills-tab"
import { AIModelTab } from "@/components/settings/ai-model-tab"
import { AppearanceTab } from "@/components/settings/appearance-tab" import { AppearanceTab } from "@/components/settings/appearance-tab"
import { ClaudeCodeTab } from "@/components/settings/claude-code-tab" import { AgentTab } from "@/components/settings/agent-tab"
import { TeamTab } from "@/components/settings/team-tab" import { TeamTab } from "@/components/settings/team-tab"
import { useNative } from "@/hooks/use-native" import { useNative } from "@/hooks/use-native"
import { useBiometricAuth } from "@/hooks/use-biometric-auth" import { useBiometricAuth } from "@/hooks/use-biometric-auth"
@ -44,9 +42,7 @@ const SETTINGS_TABS = [
{ value: "notifications", label: "Notifications" }, { value: "notifications", label: "Notifications" },
{ value: "appearance", label: "Theme" }, { value: "appearance", label: "Theme" },
{ value: "integrations", label: "Integrations" }, { value: "integrations", label: "Integrations" },
{ value: "ai-model", label: "AI Model" },
{ value: "agent", label: "Agent" }, { value: "agent", label: "Agent" },
{ value: "skills", label: "Skills" },
] as const ] as const
const CREATE_SETTING_TAB = { const CREATE_SETTING_TAB = {
@ -91,7 +87,6 @@ export function SettingsModal({
const [pushNotifs, setPushNotifs] = React.useState(true) const [pushNotifs, setPushNotifs] = React.useState(true)
const [weeklyDigest, setWeeklyDigest] = React.useState(false) const [weeklyDigest, setWeeklyDigest] = React.useState(false)
const [timezone, setTimezone] = React.useState("America/New_York") const [timezone, setTimezone] = React.useState("America/New_York")
const [signetId, setSignetId] = React.useState("")
const [customTabs, setCustomTabs] = React.useState<ReadonlyArray<CustomSettingTab>>([]) const [customTabs, setCustomTabs] = React.useState<ReadonlyArray<CustomSettingTab>>([])
const [activeTab, setActiveTab] = React.useState<string>("general") const [activeTab, setActiveTab] = React.useState<string>("general")
const [newSettingName, setNewSettingName] = React.useState("") const [newSettingName, setNewSettingName] = React.useState("")
@ -263,41 +258,11 @@ export function SettingsModal({
<Separator /> <Separator />
<NetSuiteConnectionStatus /> <NetSuiteConnectionStatus />
<SyncControls /> <SyncControls />
<Separator />
<ClaudeCodeTab />
</div> </div>
) )
case "ai-model":
return <div className="pt-2"><AIModelTab /></div>
case "agent": case "agent":
return ( return <div className="flex min-h-0 flex-1 pt-2"><AgentTab /></div>
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="signet-id" className="text-xs">
Signet ID (ETH)
</Label>
<Input
id="signet-id"
value={signetId}
onChange={(e) => setSignetId(e.target.value)}
placeholder="0x..."
className="h-9 font-mono"
type="password"
/>
</div>
<Separator />
<Button className="w-full">
Configure your agent
</Button>
</div>
)
case "skills":
return <div className="pt-2"><SkillsTab /></div>
case CREATE_SETTING_TAB.value: case CREATE_SETTING_TAB.value:
return ( return (

View File

@ -0,0 +1,96 @@
"use client"
import * as React from "react"
import {
BrainCircuit,
Blocks,
Cable,
Fingerprint,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { AIModelTab } from "@/components/settings/ai-model-tab"
import { SkillsTab } from "@/components/settings/skills-tab"
import { ClaudeCodeTab } from "@/components/settings/claude-code-tab"
import { cn } from "@/lib/utils"
const SUB_TABS = [
{ value: "model", label: "Model", icon: BrainCircuit },
{ value: "skills", label: "Skills", icon: Blocks },
{ value: "bridge", label: "Bridge", icon: Cable },
{ value: "identity", label: "Identity", icon: Fingerprint },
] as const
type SubTab = (typeof SUB_TABS)[number]["value"]
export function AgentTab(): React.ReactElement {
const [activeSubTab, setActiveSubTab] =
React.useState<SubTab>("model")
const [signetId, setSignetId] = React.useState("")
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div
className="flex gap-1.5"
role="radiogroup"
aria-label="Agent settings"
>
{SUB_TABS.map((tab) => {
const isActive = activeSubTab === tab.value
const Icon = tab.icon
return (
<button
key={tab.value}
type="button"
role="radio"
aria-checked={isActive}
onClick={() => setActiveSubTab(tab.value)}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5",
"text-xs font-medium transition-all",
isActive
? "bg-primary/10 text-primary ring-1 ring-primary/20"
: "text-muted-foreground hover:bg-muted/70"
)}
>
<Icon className="size-3.5" />
<span>{tab.label}</span>
</button>
)
})}
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{activeSubTab === "model" && <AIModelTab />}
{activeSubTab === "skills" && <SkillsTab />}
{activeSubTab === "bridge" && <ClaudeCodeTab />}
{activeSubTab === "identity" && (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="signet-id" className="text-xs">
Signet ID (ETH)
</Label>
<Input
id="signet-id"
value={signetId}
onChange={(e) => setSignetId(e.target.value)}
placeholder="0x..."
className="h-9 font-mono"
type="password"
/>
</div>
<Separator />
<Button className="w-full">
Configure your agent
</Button>
</div>
)}
</div>
</div>
)
}

View File

@ -3,8 +3,12 @@
import * as React from "react" import * as React from "react"
import { import {
Check, Check,
ExternalLink,
Loader2, Loader2,
Search, Search,
Eye,
EyeOff,
X,
} from "lucide-react" } from "lucide-react"
import { import {
Bar, Bar,
@ -39,6 +43,20 @@ import {
getUsageMetrics, getUsageMetrics,
updateModelPolicy, updateModelPolicy,
} from "@/app/actions/ai-config" } from "@/app/actions/ai-config"
import {
getUserProviderConfig,
setUserProviderConfig,
clearUserProviderConfig,
} from "@/app/actions/provider-config"
import {
exchangeOAuthCode,
disconnectOAuth,
getOAuthStatus,
} from "@/app/actions/anthropic-oauth"
import {
generatePKCE,
buildAuthUrl,
} from "@/lib/anthropic-oauth-client"
import { Slider } from "@/components/ui/slider" import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -46,6 +64,11 @@ import {
ProviderIcon, ProviderIcon,
hasLogo, hasLogo,
} from "@/components/agent/provider-icon" } from "@/components/agent/provider-icon"
import { setProviderType } from "@/components/agent/model-dropdown"
// ============================================================================
// Types
// ============================================================================
const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next" const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
@ -93,6 +116,79 @@ interface UsageMetrics {
}> }>
} }
// ============================================================================
// Provider types
// ============================================================================
const PROVIDER_TYPES = [
"anthropic-oauth",
"anthropic-key",
"openrouter",
"ollama",
"custom",
] as const
type ProviderType = (typeof PROVIDER_TYPES)[number]
interface ProviderInfo {
readonly type: ProviderType
readonly label: string
readonly icon: string
readonly description: string
readonly needsApiKey: boolean
readonly needsBaseUrl: boolean
readonly defaultBaseUrl?: string
}
const PROVIDERS: ReadonlyArray<ProviderInfo> = [
{
type: "anthropic-oauth",
label: "Anthropic",
icon: "Anthropic",
description: "Uses CLI OAuth credentials",
needsApiKey: false,
needsBaseUrl: false,
},
{
type: "anthropic-key",
label: "Anthropic (API Key)",
icon: "Anthropic",
description: "Direct API access with your key",
needsApiKey: true,
needsBaseUrl: false,
},
{
type: "openrouter",
label: "OpenRouter",
icon: "OpenRouter",
description: "Multi-provider routing",
needsApiKey: true,
needsBaseUrl: false,
defaultBaseUrl: "https://openrouter.ai/api",
},
{
type: "ollama",
label: "Ollama",
icon: "Ollama",
description: "Local inference",
needsApiKey: false,
needsBaseUrl: true,
defaultBaseUrl: "http://localhost:11434",
},
{
type: "custom",
label: "Custom",
icon: "Custom",
description: "Any OpenAI-compatible endpoint",
needsApiKey: true,
needsBaseUrl: true,
},
]
// ============================================================================
// Helpers
// ============================================================================
function formatCost(costPerToken: string): string { function formatCost(costPerToken: string): string {
const perMillion = const perMillion =
parseFloat(costPerToken) * 1_000_000 parseFloat(costPerToken) * 1_000_000
@ -134,7 +230,437 @@ function outputCostPerMillion(
return parseFloat(completionCost) * 1_000_000 return parseFloat(completionCost) * 1_000_000
} }
// --- two-panel model picker --- // ============================================================================
// Provider Configuration Section
// ============================================================================
type OAuthState =
| { step: "idle" }
| { step: "connecting"; verifier: string }
| { step: "connected"; expiresAt?: string }
function ProviderConfigSection({
onProviderChanged,
}: {
readonly onProviderChanged: () => void
}): React.JSX.Element {
const [activeType, setActiveType] =
React.useState<ProviderType>("anthropic-oauth")
const [apiKey, setApiKey] = React.useState("")
const [baseUrl, setBaseUrl] = React.useState("")
const [showKey, setShowKey] = React.useState(false)
const [saving, setSaving] = React.useState(false)
const [loading, setLoading] = React.useState(true)
const [hasStoredKey, setHasStoredKey] =
React.useState(false)
// OAuth state
const [oauth, setOAuth] = React.useState<OAuthState>({
step: "idle",
})
const [oauthCode, setOAuthCode] = React.useState("")
// load current config + OAuth status from D1
React.useEffect(() => {
Promise.all([
getUserProviderConfig(),
getOAuthStatus(),
])
.then(([configResult, oauthStatus]) => {
if (
"success" in configResult &&
configResult.success &&
configResult.data
) {
const d = configResult.data
const type = (
PROVIDER_TYPES.includes(
d.providerType as ProviderType
)
? d.providerType
: "anthropic-oauth"
) as ProviderType
setActiveType(type)
setBaseUrl(d.baseUrl ?? "")
setHasStoredKey(d.hasApiKey)
}
if (oauthStatus.connected) {
setOAuth({
step: "connected",
expiresAt: oauthStatus.expiresAt,
})
}
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const info = PROVIDERS.find(
(p) => p.type === activeType
) ?? PROVIDERS[0]
const handleProviderSelect = (
type: ProviderType
): void => {
setActiveType(type)
setApiKey("")
setShowKey(false)
setOAuthCode("")
if (type !== "anthropic-oauth") {
setOAuth({ step: "idle" })
}
const newInfo = PROVIDERS.find(
(p) => p.type === type
)
setBaseUrl(newInfo?.defaultBaseUrl ?? "")
setHasStoredKey(false)
}
const handleOAuthConnect = async (): Promise<void> => {
// Open window immediately in the click handler to avoid
// popup blockers (async gap kills the user gesture).
// Can't use noopener here — it makes window.open return null.
const popup = window.open("about:blank", "_blank")
const { verifier, challenge } = await generatePKCE()
const url = buildAuthUrl(challenge)
if (popup) {
popup.opener = null
popup.location.href = url
}
setOAuth({ step: "connecting", verifier })
}
const handleOAuthSubmit = async (): Promise<void> => {
if (oauth.step !== "connecting") return
const trimmed = oauthCode.trim()
if (!trimmed) return
// Parse "code#state" or just "code"
const hashIdx = trimmed.indexOf("#")
const code =
hashIdx >= 0 ? trimmed.slice(0, hashIdx) : trimmed
const state =
hashIdx >= 0 ? trimmed.slice(hashIdx + 1) : ""
setSaving(true)
const result = await exchangeOAuthCode(
code,
state,
oauth.verifier
)
setSaving(false)
if (result.success) {
toast.success("Connected to Anthropic")
setOAuth({ step: "connected" })
setOAuthCode("")
setProviderType("anthropic-oauth")
onProviderChanged()
} else {
toast.error(result.error ?? "OAuth failed")
}
}
const handleOAuthDisconnect =
async (): Promise<void> => {
setSaving(true)
await disconnectOAuth()
setSaving(false)
setOAuth({ step: "idle" })
toast.success("Disconnected")
onProviderChanged()
}
const handleSave = async (): Promise<void> => {
setSaving(true)
const result = await setUserProviderConfig(
activeType,
apiKey || undefined,
baseUrl || undefined
)
setSaving(false)
if (result.success) {
toast.success("Provider updated")
setProviderType(activeType)
setHasStoredKey(Boolean(apiKey))
setApiKey("")
onProviderChanged()
} else {
toast.error(result.error ?? "Failed to save")
}
}
const handleClear = async (): Promise<void> => {
setSaving(true)
const result = await clearUserProviderConfig()
setSaving(false)
if (result.success) {
toast.success("Reverted to default")
setActiveType("anthropic-oauth")
setApiKey("")
setBaseUrl("")
setHasStoredKey(false)
setProviderType("anthropic-oauth")
onProviderChanged()
} else {
toast.error(result.error ?? "Failed to clear")
}
}
if (loading) {
return (
<div className="space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-9 w-full" />
</div>
)
}
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">AI Provider</Label>
<p className="text-muted-foreground text-xs">
Choose where your AI inference runs.
</p>
</div>
{/* provider pills */}
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label="AI Provider"
>
{PROVIDERS.map((p) => {
const isActive = p.type === activeType
return (
<button
key={p.type}
type="button"
role="radio"
aria-checked={isActive}
onClick={() =>
handleProviderSelect(p.type)
}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all",
isActive
? "bg-primary/10 text-primary ring-1 ring-primary/20"
: "text-muted-foreground hover:bg-muted/70"
)}
>
<ProviderIcon
provider={p.icon}
size={14}
/>
<span>{p.label}</span>
</button>
)
})}
</div>
{/* description */}
<p className="text-[11px] text-muted-foreground">
{info.description}
</p>
{/* OAuth flow for anthropic-oauth */}
{activeType === "anthropic-oauth" && (
<div className="space-y-2">
{oauth.step === "connected" && (
<div className="flex items-center justify-between rounded-md border border-green-500/20 bg-green-500/5 px-3 py-2">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-xs font-medium text-green-700 dark:text-green-400">
Connected
</span>
{oauth.expiresAt && (
<span className="text-[10px] text-muted-foreground">
expires{" "}
{new Date(
oauth.expiresAt
).toLocaleDateString()}
</span>
)}
</div>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={handleOAuthDisconnect}
disabled={saving}
>
Disconnect
</Button>
</div>
)}
{oauth.step === "idle" && (
<Button
size="sm"
className="h-8"
onClick={handleOAuthConnect}
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
Connect with Anthropic
</Button>
)}
{oauth.step === "connecting" && (
<div className="space-y-2">
<p className="text-[11px] text-muted-foreground">
Authorize in the browser tab that opened,
then paste the code below.
</p>
<div className="flex gap-2">
<Input
type="text"
value={oauthCode}
onChange={(e) =>
setOAuthCode(e.target.value)
}
placeholder="Paste authorization code here"
className="h-8 text-xs font-mono"
autoFocus
/>
<Button
size="sm"
className="h-8 shrink-0"
onClick={handleOAuthSubmit}
disabled={saving || !oauthCode.trim()}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
"Submit"
)}
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 shrink-0"
onClick={() => {
setOAuth({ step: "idle" })
setOAuthCode("")
}}
>
Cancel
</Button>
</div>
</div>
)}
</div>
)}
{/* credential inputs (non-OAuth providers) */}
{activeType !== "anthropic-oauth" &&
(info.needsApiKey || info.needsBaseUrl) && (
<div className="space-y-2">
{info.needsApiKey && (
<div className="space-y-1">
<Label className="text-[11px]">
API Key
</Label>
<div className="relative">
<Input
type={showKey ? "text" : "password"}
value={apiKey}
onChange={(e) =>
setApiKey(e.target.value)
}
placeholder={
hasStoredKey
? "Key saved (enter new to replace)"
: activeType === "openrouter"
? "sk-or-..."
: activeType === "anthropic-key"
? "sk-ant-..."
: "API key"
}
className="h-8 pr-10 text-xs"
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-1">
<button
type="button"
onClick={() =>
setShowKey((v) => !v)
}
className="rounded p-1 text-muted-foreground hover:text-foreground"
aria-label={
showKey
? "Hide key"
: "Show key"
}
>
{showKey ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
</div>
)}
{info.needsBaseUrl && (
<div className="space-y-1">
<Label className="text-[11px]">
Base URL
</Label>
<Input
type="text"
value={
baseUrl ||
info.defaultBaseUrl ||
""
}
onChange={(e) =>
setBaseUrl(e.target.value)
}
placeholder={
info.defaultBaseUrl ?? "https://..."
}
className="h-8 text-xs"
/>
</div>
)}
</div>
)}
{/* actions (non-OAuth providers) */}
{activeType !== "anthropic-oauth" && (
<div className="flex items-center gap-2">
<Button
size="sm"
className="h-8"
onClick={handleSave}
disabled={saving}
>
{saving && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
Save Provider
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 text-xs"
onClick={handleClear}
disabled={saving}
>
<X className="mr-1 h-3 w-3" />
Reset to default
</Button>
</div>
)}
</div>
)
}
// ============================================================================
// Two-panel model picker (existing, for OpenRouter admin)
// ============================================================================
function ModelPicker({ function ModelPicker({
groups, groups,
@ -159,7 +685,6 @@ function ModelPicker({
const query = search.toLowerCase() const query = search.toLowerCase()
// sort: providers with logos first, then alphabetical
const sortedGroups = React.useMemo(() => { const sortedGroups = React.useMemo(() => {
return [...groups].sort((a, b) => { return [...groups].sort((a, b) => {
const aHas = hasLogo(a.provider) ? 0 : 1 const aHas = hasLogo(a.provider) ? 0 : 1
@ -169,7 +694,6 @@ function ModelPicker({
}) })
}, [groups]) }, [groups])
// filter models by search + active provider + cost ceiling
const filteredGroups = React.useMemo(() => { const filteredGroups = React.useMemo(() => {
return sortedGroups return sortedGroups
.map((group) => { .map((group) => {
@ -240,7 +764,6 @@ function ModelPicker({
return ( return (
<TooltipProvider delayDuration={200}> <TooltipProvider delayDuration={200}>
<div className="space-y-3"> <div className="space-y-3">
{/* search bar */}
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
@ -251,9 +774,7 @@ function ModelPicker({
/> />
</div> </div>
{/* two-panel layout - no outer border */}
<div className="flex gap-2 h-80"> <div className="flex gap-2 h-80">
{/* provider sidebar */}
<div className="w-12 shrink-0 overflow-y-auto flex flex-col items-center gap-1 py-0.5"> <div className="w-12 shrink-0 overflow-y-auto flex flex-col items-center gap-1 py-0.5">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -315,7 +836,6 @@ function ModelPicker({
))} ))}
</div> </div>
{/* model list */}
<div className="flex-1 overflow-y-auto pr-1"> <div className="flex-1 overflow-y-auto pr-1">
{filteredGroups.length === 0 ? ( {filteredGroups.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs"> <div className="flex items-center justify-center h-full text-muted-foreground text-xs">
@ -380,7 +900,6 @@ function ModelPicker({
</div> </div>
</div> </div>
{/* save bar */}
{isDirty && ( {isDirty && (
<div className="flex items-center justify-between pt-1"> <div className="flex items-center justify-between pt-1">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@ -407,7 +926,9 @@ function ModelPicker({
) )
} }
// --- usage metrics --- // ============================================================================
// Usage section
// ============================================================================
const chartConfig = { const chartConfig = {
tokens: { tokens: {
@ -527,7 +1048,9 @@ function UsageSection({
) )
} }
// --- main tab --- // ============================================================================
// Main tab
// ============================================================================
export function AIModelTab() { export function AIModelTab() {
const [loading, setLoading] = React.useState(true) const [loading, setLoading] = React.useState(true)
@ -631,6 +1154,13 @@ export function AIModelTab() {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Provider configuration — always visible */}
<ProviderConfigSection
onProviderChanged={loadData}
/>
<Separator />
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs">Active Model</Label> <Label className="text-xs">Active Model</Label>
{activeConfig ? ( {activeConfig ? (

View File

@ -2,9 +2,10 @@
import * as React from "react" import * as React from "react"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { Check, Moon, Sparkles, Sun, Trash2 } from "lucide-react" import { Check, Moon, RotateCcw, Sparkles, Sun, Trash2 } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Slider } from "@/components/ui/slider"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useCompassTheme } from "@/components/theme-provider" import { useCompassTheme } from "@/components/theme-provider"
import { useAgentOptional } from "@/components/agent/chat-provider" import { useAgentOptional } from "@/components/agent/chat-provider"
@ -172,6 +173,59 @@ export function AppearanceTab() {
const isDark = resolvedTheme === "dark" const isDark = resolvedTheme === "dark"
const [zoomLevel, setZoomLevel] = React.useState(1.0)
// Load persisted zoom level on mount
React.useEffect(() => {
try {
const stored = localStorage.getItem("compass-zoom-level")
if (stored) {
const level = parseFloat(stored)
if (!isNaN(level) && level >= 0.5 && level <= 2.0) {
setZoomLevel(level)
}
}
} catch {
// localStorage not available
}
}, [])
async function applyZoom(level: number): Promise<void> {
const clamped = Math.min(2.0, Math.max(0.5, level))
try {
localStorage.setItem("compass-zoom-level", String(clamped))
} catch {
// localStorage not available
}
// Use Tauri native webview zoom (true browser-level zoom)
try {
const { invoke } = await import("@tauri-apps/api/core")
await invoke("plugin:webview|set_webview_zoom", {
label: "main",
scaleFactor: clamped,
})
// Clear any CSS fallback
document.documentElement.style.fontSize = ""
return
} catch {
// Not in Tauri or permission denied — CSS fallback
}
// Fallback: scale root font-size (slightly thicker icons but functional)
document.documentElement.style.fontSize = `${clamped * 16}px`
}
function handleZoomChange(value: number[]): void {
const level = value[0]
if (level === undefined) return
setZoomLevel(level)
void applyZoom(level)
}
function handleZoomReset(): void {
setZoomLevel(1.0)
void applyZoom(1.0)
}
const allThemes = React.useMemo<ReadonlyArray<ThemeDefinition>>( const allThemes = React.useMemo<ReadonlyArray<ThemeDefinition>>(
() => [...THEME_PRESETS, ...customThemes], () => [...THEME_PRESETS, ...customThemes],
[customThemes], [customThemes],
@ -246,6 +300,40 @@ export function AppearanceTab() {
</div> </div>
</div> </div>
{/* ui scale */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">UI Scale</p>
<div className="flex items-center gap-2">
<span className="text-sm tabular-nums text-muted-foreground">
{Math.round(zoomLevel * 100)}%
</span>
{zoomLevel !== 1.0 && (
<button
type="button"
onClick={handleZoomReset}
className="flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RotateCcw className="size-3" />
Reset
</button>
)}
</div>
</div>
<Slider
value={[zoomLevel]}
onValueChange={handleZoomChange}
min={0.5}
max={2.0}
step={0.1}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>50%</span>
<span>200%</span>
</div>
</div>
{/* theme grid */} {/* theme grid */}
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-medium">Theme</p> <p className="text-sm font-medium">Theme</p>

View File

@ -0,0 +1,192 @@
"use client"
import * as React from "react"
import { IconCopy } from "@tabler/icons-react"
import { toast } from "sonner"
import { createInvite } from "@/app/actions/invites"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const EXPIRY_PRESETS = [
{ label: "Never", value: "never" },
{ label: "1 hour", value: "1h" },
{ label: "1 day", value: "1d" },
{ label: "7 days", value: "7d" },
{ label: "30 days", value: "30d" },
] as const
function getExpiryDate(preset: string): string | undefined {
const now = Date.now()
switch (preset) {
case "1h":
return new Date(now + 60 * 60 * 1000).toISOString()
case "1d":
return new Date(now + 24 * 60 * 60 * 1000).toISOString()
case "7d":
return new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString()
case "30d":
return new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString()
default:
return undefined
}
}
interface CreateInviteDialogProps {
readonly open: boolean
readonly onOpenChange: (open: boolean) => void
readonly onCreated: () => void
}
export function CreateInviteDialog({
open,
onOpenChange,
onCreated,
}: CreateInviteDialogProps) {
const [role, setRole] = React.useState("office")
const [maxUses, setMaxUses] = React.useState("")
const [expiry, setExpiry] = React.useState("never")
const [loading, setLoading] = React.useState(false)
const [createdUrl, setCreatedUrl] = React.useState<string | null>(null)
const handleCreate = async () => {
setLoading(true)
try {
const result = await createInvite(
role,
maxUses ? parseInt(maxUses, 10) : undefined,
getExpiryDate(expiry)
)
if (result.success && result.data) {
const fullUrl = `${window.location.origin}${result.data.url}`
setCreatedUrl(fullUrl)
onCreated()
} else {
toast.error(result.error ?? "Failed to create invite")
}
} catch {
toast.error("Something went wrong")
} finally {
setLoading(false)
}
}
const handleCopy = () => {
if (createdUrl) {
navigator.clipboard.writeText(createdUrl)
toast.success("Invite link copied")
}
}
const handleClose = (isOpen: boolean) => {
if (!isOpen) {
setCreatedUrl(null)
setRole("office")
setMaxUses("")
setExpiry("never")
}
onOpenChange(isOpen)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{createdUrl ? "Invite Link Created" : "Create Invite Link"}
</DialogTitle>
<DialogDescription>
{createdUrl
? "Share this link with people you want to invite."
: "Create a shareable link for your organization."}
</DialogDescription>
</DialogHeader>
{createdUrl ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
value={createdUrl}
readOnly
className="font-mono text-sm"
/>
<Button size="icon" variant="outline" onClick={handleCopy}>
<IconCopy className="size-4" />
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label>Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="office">Office</SelectItem>
<SelectItem value="field">Field</SelectItem>
<SelectItem value="client">Client</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Max uses (optional)</Label>
<Input
type="number"
min={1}
placeholder="Unlimited"
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Expires</Label>
<Select value={expiry} onValueChange={setExpiry}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<DialogFooter>
{createdUrl ? (
<Button onClick={() => handleClose(false)}>Done</Button>
) : (
<Button onClick={handleCreate} disabled={loading}>
{loading ? "Creating..." : "Create Link"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,207 @@
"use client"
import * as React from "react"
import { IconCopy, IconTrash, IconPlus } from "@tabler/icons-react"
import { toast } from "sonner"
import { getOrgInvites, revokeInvite } from "@/app/actions/invites"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { CreateInviteDialog } from "./create-invite-dialog"
type InviteRow = {
readonly id: string
readonly code: string
readonly role: string
readonly maxUses: number | null
readonly useCount: number
readonly expiresAt: string | null
readonly isActive: boolean
readonly createdAt: string
readonly createdByName: string | null
}
function isExpired(expiresAt: string | null): boolean {
if (!expiresAt) return false
return new Date(expiresAt) < new Date()
}
function isExhausted(invite: InviteRow): boolean {
return invite.maxUses !== null && invite.useCount >= invite.maxUses
}
function formatExpiry(expiresAt: string | null): string {
if (!expiresAt) return "Never"
const date = new Date(expiresAt)
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
})
}
export function InviteLinksSection() {
const [invites, setInvites] = React.useState<InviteRow[]>([])
const [loading, setLoading] = React.useState(true)
const [createOpen, setCreateOpen] = React.useState(false)
const loadInvites = React.useCallback(async () => {
try {
const result = await getOrgInvites()
if (result.success && result.data) {
setInvites(result.data as InviteRow[])
}
} catch (error) {
console.error("Failed to load invites:", error)
} finally {
setLoading(false)
}
}, [])
React.useEffect(() => {
loadInvites()
}, [loadInvites])
const handleCopyLink = (code: string) => {
const url = `${window.location.origin}/join/${code}`
navigator.clipboard.writeText(url)
toast.success("Invite link copied")
}
const handleRevoke = async (inviteId: string) => {
const result = await revokeInvite(inviteId)
if (result.success) {
toast.success("Invite revoked")
await loadInvites()
} else {
toast.error(result.error ?? "Failed to revoke invite")
}
}
const handleCreated = () => {
loadInvites()
}
if (loading) {
return (
<div className="rounded-md border p-8 text-center text-muted-foreground">
Loading...
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium">Invite Links</h3>
<p className="text-sm text-muted-foreground">
Shareable links that let anyone join your organization
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<IconPlus className="mr-2 size-4" />
Create Link
</Button>
</div>
{invites.length === 0 ? (
<div className="rounded-md border p-6 text-center text-sm text-muted-foreground">
No invite links yet
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Role</TableHead>
<TableHead>Uses</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Created by</TableHead>
<TableHead className="w-[100px]" />
</TableRow>
</TableHeader>
<TableBody>
{invites.map((invite) => {
const expired = isExpired(invite.expiresAt)
const exhausted = isExhausted(invite)
const dimmed = !invite.isActive || expired || exhausted
return (
<TableRow
key={invite.id}
className={cn(dimmed && "opacity-50")}
>
<TableCell className="font-mono text-sm">
{invite.code}
</TableCell>
<TableCell>
<Badge variant="secondary" className="capitalize">
{invite.role}
</Badge>
</TableCell>
<TableCell>
{invite.useCount} / {invite.maxUses ?? "∞"}
</TableCell>
<TableCell className="text-sm">
{expired ? (
<span className="text-destructive">Expired</span>
) : (
formatExpiry(invite.expiresAt)
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{invite.createdByName ?? "Unknown"}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{!dimmed && (
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => handleCopyLink(invite.code)}
>
<IconCopy className="size-4" />
</Button>
)}
{invite.isActive && !expired && !exhausted && (
<Button
variant="ghost"
size="icon"
className="size-8 text-destructive"
onClick={() => handleRevoke(invite.id)}
>
<IconTrash className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
<CreateInviteDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={handleCreated}
/>
</div>
)
}

View File

@ -6,9 +6,11 @@ import { toast } from "sonner"
import { getUsers, deactivateUser, type UserWithRelations } from "@/app/actions/users" import { getUsers, deactivateUser, type UserWithRelations } from "@/app/actions/users"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { PeopleTable } from "@/components/people-table" import { PeopleTable } from "@/components/people-table"
import { UserDrawer } from "@/components/people/user-drawer" import { UserDrawer } from "@/components/people/user-drawer"
import { InviteDialog } from "@/components/people/invite-dialog" import { InviteDialog } from "@/components/people/invite-dialog"
import { InviteLinksSection } from "@/components/settings/invite-links-section"
export function TeamTab() { export function TeamTab() {
const [users, setUsers] = React.useState<UserWithRelations[]>([]) const [users, setUsers] = React.useState<UserWithRelations[]>([])
@ -101,6 +103,9 @@ export function TeamTab() {
)} )}
</div> </div>
<Separator className="my-6" />
<InviteLinksSection />
<UserDrawer <UserDrawer
user={selectedUser} user={selectedUser}
open={drawerOpen} open={drawerOpen}

View File

@ -23,9 +23,36 @@ const allSchemas = {
...conversationsSchema, ...conversationsSchema,
} }
/**
* Null-safe stub returned when no D1 binding is available (local dev without CF).
* Every property access returns a chainable proxy that resolves to empty results,
* so server actions that forget to check `env?.DB` won't crash.
*/
function createNullDb(): ReturnType<typeof drizzle> {
const handler: ProxyHandler<object> = {
get(_target, prop) {
// .then — make the proxy non-thenable so `await proxy` returns the proxy itself
if (prop === "then") return undefined
// Common drizzle terminal methods — resolve to empty/noop
if (prop === "all" || prop === "values") return async () => []
if (prop === "get") return async () => undefined
if (prop === "run") return async () => ({ changes: 0 })
if (prop === "execute") return async () => []
// findMany / findFirst on the relational query builder
if (prop === "findMany") return async () => []
if (prop === "findFirst") return async () => undefined
// Everything else returns another proxy so chaining works:
// db.select().from(t).where(...) etc.
return new Proxy((..._args: unknown[]) => new Proxy({}, handler), handler)
},
}
return new Proxy({}, handler) as ReturnType<typeof drizzle>
}
// Legacy function - kept for backwards compatibility // Legacy function - kept for backwards compatibility
// Prefer using the provider interface from ./provider for new code // Prefer using the provider interface from ./provider for new code
export function getDb(d1: D1Database) { export function getDb(d1: D1Database) {
if (!d1) return createNullDb()
return drizzle(d1, { schema: allSchemas }) return drizzle(d1, { schema: allSchemas })
} }

View File

@ -38,6 +38,22 @@ export const userModelPreference = sqliteTable(
} }
) )
// per-user provider configuration
export const userProviderConfig = sqliteTable(
"user_provider_config",
{
userId: text("user_id")
.primaryKey()
.references(() => users.id),
providerType: text("provider_type").notNull(), // anthropic-oauth | anthropic-key | openrouter | ollama | custom
apiKey: text("api_key"), // encrypted, nullable
baseUrl: text("base_url"), // nullable
modelOverrides: text("model_overrides"), // JSON, nullable
isActive: integer("is_active").notNull().default(1),
updatedAt: text("updated_at").notNull(),
}
)
// one row per streamText invocation // one row per streamText invocation
export const agentUsage = sqliteTable("agent_usage", { export const agentUsage = sqliteTable("agent_usage", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@ -63,11 +79,32 @@ export const agentUsage = sqliteTable("agent_usage", {
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
}) })
// per-user Anthropic OAuth tokens (separate from provider config
// because OAuth needs refresh token + expiry tracking)
export const anthropicOauthTokens = sqliteTable(
"anthropic_oauth_tokens",
{
userId: text("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
accessToken: text("access_token").notNull(),
refreshToken: text("refresh_token").notNull(),
expiresAt: text("expires_at").notNull(),
updatedAt: text("updated_at").notNull(),
}
)
export type AgentConfig = typeof agentConfig.$inferSelect export type AgentConfig = typeof agentConfig.$inferSelect
export type NewAgentConfig = typeof agentConfig.$inferInsert export type NewAgentConfig = typeof agentConfig.$inferInsert
export type UserProviderConfig = typeof userProviderConfig.$inferSelect
export type NewUserProviderConfig = typeof userProviderConfig.$inferInsert
export type AgentUsage = typeof agentUsage.$inferSelect export type AgentUsage = typeof agentUsage.$inferSelect
export type NewAgentUsage = typeof agentUsage.$inferInsert export type NewAgentUsage = typeof agentUsage.$inferInsert
export type UserModelPreference = export type UserModelPreference =
typeof userModelPreference.$inferSelect typeof userModelPreference.$inferSelect
export type NewUserModelPreference = export type NewUserModelPreference =
typeof userModelPreference.$inferInsert typeof userModelPreference.$inferInsert
export type AnthropicOauthToken =
typeof anthropicOauthTokens.$inferSelect
export type NewAnthropicOauthToken =
typeof anthropicOauthTokens.$inferInsert

View File

@ -3,7 +3,7 @@ import {
text, text,
integer, integer,
} from "drizzle-orm/sqlite-core" } from "drizzle-orm/sqlite-core"
import { users } from "./schema" import { users, organizations } from "./schema"
export const mcpApiKeys = sqliteTable("mcp_api_keys", { export const mcpApiKeys = sqliteTable("mcp_api_keys", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@ -41,3 +41,28 @@ export type McpApiKey = typeof mcpApiKeys.$inferSelect
export type NewMcpApiKey = typeof mcpApiKeys.$inferInsert export type NewMcpApiKey = typeof mcpApiKeys.$inferInsert
export type McpUsage = typeof mcpUsage.$inferSelect export type McpUsage = typeof mcpUsage.$inferSelect
export type NewMcpUsage = typeof mcpUsage.$inferInsert export type NewMcpUsage = typeof mcpUsage.$inferInsert
export const mcpServers = sqliteTable("mcp_servers", {
id: text("id").primaryKey(),
orgId: text("org_id")
.notNull()
.references(() => organizations.id, { onDelete: "cascade" }),
name: text("name").notNull(),
slug: text("slug").notNull(), // used as tool name prefix
transport: text("transport").notNull(), // "stdio" | "http"
command: text("command"), // stdio only
args: text("args"), // stdio: JSON array
env: text("env"), // stdio: JSON env vars
url: text("url"), // http only
headers: text("headers"), // http: JSON headers
isEnabled: integer("is_enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: text("created_at").notNull(),
createdBy: text("created_by")
.notNull()
.references(() => users.id),
})
export type McpServer = typeof mcpServers.$inferSelect
export type NewMcpServer = typeof mcpServers.$inferInsert

View File

@ -44,6 +44,23 @@ export const organizationMembers = sqliteTable("organization_members", {
joinedAt: text("joined_at").notNull(), joinedAt: text("joined_at").notNull(),
}) })
export const organizationInvites = sqliteTable("organization_invites", {
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organizations.id, { onDelete: "cascade" }),
code: text("code").notNull().unique(),
role: text("role").notNull().default("office"),
maxUses: integer("max_uses"),
useCount: integer("use_count").notNull().default(0),
expiresAt: text("expires_at"),
createdBy: text("created_by")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull(),
})
export const teams = sqliteTable("teams", { export const teams = sqliteTable("teams", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
organizationId: text("organization_id") organizationId: text("organization_id")
@ -238,6 +255,8 @@ export type Organization = typeof organizations.$inferSelect
export type NewOrganization = typeof organizations.$inferInsert export type NewOrganization = typeof organizations.$inferInsert
export type OrganizationMember = typeof organizationMembers.$inferSelect export type OrganizationMember = typeof organizationMembers.$inferSelect
export type NewOrganizationMember = typeof organizationMembers.$inferInsert export type NewOrganizationMember = typeof organizationMembers.$inferInsert
export type OrganizationInvite = typeof organizationInvites.$inferSelect
export type NewOrganizationInvite = typeof organizationInvites.$inferInsert
export type Team = typeof teams.$inferSelect export type Team = typeof teams.$inferSelect
export type NewTeam = typeof teams.$inferInsert export type NewTeam = typeof teams.$inferInsert
export type TeamMember = typeof teamMembers.$inferSelect export type TeamMember = typeof teamMembers.$inferSelect

385
src/hooks/use-agent.ts Normal file
View File

@ -0,0 +1,385 @@
"use client"
import { useState, useRef, useCallback } from "react"
import type { AgentMessage, SSEEvent } from "@/lib/agent/message-types"
import { dispatchToolActions } from "@/lib/agent/chat-adapter"
import { getAgentModelId } from "@/components/agent/model-dropdown"
export interface UseAgentOptions {
readonly agentServerUrl?: string
readonly sessionId?: string
readonly currentPage?: string
readonly timezone?: string
readonly onFinish?: (messages: ReadonlyArray<AgentMessage>) => void | Promise<void>
}
export interface UseAgentReturn {
readonly messages: ReadonlyArray<AgentMessage>
setMessages: (
msgs: AgentMessage[] | ((prev: AgentMessage[]) => AgentMessage[])
) => void
sendMessage: (params: { text: string }) => void
stop: () => void
regenerate: () => void
readonly status: "ready" | "streaming" | "error"
readonly error: string | null
}
/**
* Core SSE consumer hook for the agent server
* Handles streaming, parsing, and message accumulation
*/
export function useAgent(options: UseAgentOptions = {}): UseAgentReturn {
const {
agentServerUrl = "",
sessionId = crypto.randomUUID(),
currentPage = "/dashboard",
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
onFinish,
} = options
const [messages, setMessages] = useState<AgentMessage[]>([])
const [status, setStatus] = useState<"ready" | "streaming" | "error">("ready")
const [error, setError] = useState<string | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const dispatchedRef = useRef(new Set<string>())
const sendMessage = useCallback(
async (params: { text: string }) => {
if (status === "streaming") return
if (!params.text.trim()) return
// add user message
const userMessage: AgentMessage = {
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: params.text }],
createdAt: new Date(),
}
setMessages((prev) => [...prev, userMessage])
setStatus("streaming")
setError(null)
// create assistant message stub
const assistantId = crypto.randomUUID()
const assistantMessage: AgentMessage = {
id: assistantId,
role: "assistant",
parts: [],
createdAt: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
// prepare request
const controller = new AbortController()
abortControllerRef.current = controller
try {
// Determine endpoint based on mode
const isStandalone = agentServerUrl !== ""
const endpoint = isStandalone
? `${agentServerUrl}/agent/chat`
: `/api/agent`
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-session-id": sessionId,
"x-current-page": currentPage,
"x-timezone": timezone,
"x-model": getAgentModelId(),
}
// Standalone mode: JWT auth via server action
// Cloud mode: WorkOS session cookie (same-origin, automatic)
if (isStandalone) {
const { getAgentToken } = await import("@/app/actions/agent-auth")
const tokenResult = await getAgentToken()
if ("error" in tokenResult) {
throw new Error(tokenResult.error)
}
headers["Authorization"] = `Bearer ${tokenResult.token}`
}
const allMessages = [...messages, userMessage]
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
messages: allMessages.map((m) => ({
role: m.role,
content: m.parts
.filter((p) => p.type === "text")
.map((p) => (p as { text: string }).text)
.join(""),
})),
}),
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`Agent server error: ${response.status}`)
}
if (!response.body) {
throw new Error("No response body")
}
// parse SSE stream
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
// accumulate parts for current assistant message
const parts: Array<
AgentMessage["parts"][number]
> = []
// track active tool calls for state updates
const toolCallMap = new Map<
string,
{ name: string; args: unknown; state: "partial-call" | "call" | "result" }
>()
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// split on SSE boundaries
const lines = buffer.split("\n\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const data = line.slice(6).trim()
if (data === "[DONE]") {
// stream complete
setStatus("ready")
const finalMessages = [
...messages,
userMessage,
{ ...assistantMessage, parts },
]
setMessages(finalMessages)
if (onFinish) {
await onFinish(finalMessages)
}
return
}
try {
const event = JSON.parse(data) as SSEEvent
switch (event.type) {
case "text": {
// streaming text delta — append to last text part or create new
const lastPart = parts[parts.length - 1]
if (lastPart?.type === "text") {
parts[parts.length - 1] = {
type: "text",
text: lastPart.text + event.content,
}
} else {
parts.push({ type: "text", text: event.content })
}
break
}
case "tool_use": {
// create tool-call part
toolCallMap.set(event.toolCallId, {
name: event.name,
args: event.input,
state: "call",
})
parts.push({
type: "tool-call",
toolName: event.name,
toolCallId: event.toolCallId,
args: event.input,
state: "call",
})
break
}
case "tool_result": {
// update tool-call state to "result"
const toolCall = toolCallMap.get(event.toolCallId)
if (toolCall) {
toolCall.state = "result"
// find and update the tool-call part
const idx = parts.findIndex(
(p) =>
p.type === "tool-call" &&
p.toolCallId === event.toolCallId
)
if (idx !== -1) {
const existingPart = parts[idx]
if (existingPart.type === "tool-call") {
parts[idx] = { ...existingPart, state: "result" }
}
}
}
// add tool-result part
parts.push({
type: "tool-result",
toolCallId: event.toolCallId,
result: event.output,
isError: false,
})
// check for action dispatch
const output = event.output as Record<string, unknown> | null
if (output?.action && !dispatchedRef.current.has(event.toolCallId)) {
dispatchedRef.current.add(event.toolCallId)
// dispatch as if it were a tool part with output
dispatchToolActions(
[
{
type: "tool-result",
toolCallId: event.toolCallId,
state: "output-available",
output: event.output,
},
],
dispatchedRef.current
)
}
break
}
case "tool_progress": {
// optional progress event — could be used for loading states
// for now, just log it (or could update tool-call state)
console.log(
`[agent] tool progress: ${event.toolName} (${event.elapsedSeconds}s)`
)
break
}
case "result": {
// Result text is the full response — already streamed
// via text deltas, so don't append it again.
if (event.usage) {
console.log("[agent] usage:", {
input: event.usage.inputTokens,
output: event.usage.outputTokens,
cost: `$${event.usage.totalCostUsd.toFixed(4)}`,
})
}
break
}
case "error": {
setError(event.error)
setStatus("error")
return
}
}
// update UI with accumulated parts
setMessages((prev) => {
const updated = [...prev]
const lastIdx = updated.length - 1
if (
lastIdx >= 0 &&
updated[lastIdx].role === "assistant"
) {
updated[lastIdx] = {
...updated[lastIdx],
parts: [...parts],
}
}
return updated
})
} catch (parseErr) {
console.error("Failed to parse SSE event:", parseErr)
}
}
}
// if we exit the loop without [DONE], treat as complete
setStatus("ready")
const finalMessages = [
...messages,
userMessage,
{ ...assistantMessage, parts },
]
setMessages(finalMessages)
if (onFinish) {
await onFinish(finalMessages)
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
setStatus("ready")
} else {
const errMsg =
err instanceof Error ? err.message : "Unknown error"
setError(errMsg)
setStatus("error")
}
} finally {
abortControllerRef.current = null
}
},
[
messages,
status,
agentServerUrl,
sessionId,
currentPage,
timezone,
onFinish,
]
)
const stop = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
}
}, [])
const regenerate = useCallback(() => {
// remove last assistant message and resend
setMessages((prev) => {
const filtered = prev.filter(
(m, i) => i !== prev.length - 1 || m.role !== "assistant"
)
const lastUser = [...filtered].reverse().find((m) => m.role === "user")
if (lastUser) {
// extract text from last user message
const text = lastUser.parts
.filter((p) => p.type === "text")
.map((p) => (p as { text: string }).text)
.join("")
// re-send (but keep the filtered messages first)
setTimeout(() => sendMessage({ text }), 0)
return filtered
}
return filtered
})
}, [sendMessage])
return {
messages,
setMessages,
sendMessage,
stop,
regenerate,
status,
error,
}
}

View File

@ -2,12 +2,6 @@
import { useEffect, useMemo, useRef } from "react" import { useEffect, useMemo, useRef } from "react"
import { usePathname, useRouter } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { useChat } from "@ai-sdk/react"
import {
DefaultChatTransport,
type ChatTransport,
type UIMessage,
} from "ai"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
initializeActionHandlers, initializeActionHandlers,
@ -15,40 +9,16 @@ import {
dispatchToolActions, dispatchToolActions,
ALL_HANDLER_TYPES, ALL_HANDLER_TYPES,
} from "@/lib/agent/chat-adapter" } from "@/lib/agent/chat-adapter"
import { useAgent } from "@/hooks/use-agent"
import type { AgentMessage } from "@/lib/agent/message-types"
interface UseCompassChatOptions { interface UseCompassChatOptions {
readonly conversationId?: string | null readonly conversationId?: string | null
readonly onFinish?: (params: { readonly onFinish?: (params: {
messages: ReadonlyArray<UIMessage> messages: ReadonlyArray<AgentMessage>
}) => void | Promise<void> }) => void | Promise<void>
readonly openPanel?: () => void readonly openPanel?: () => void
readonly bridgeTransport?: readonly bridgeTransport?: unknown | null // placeholder for future bridge integration
| ChatTransport<UIMessage>
| null
}
// useChat captures transport at init -- this wrapper
// delegates at send-time so bridge/default swaps work
class DynamicTransport
implements ChatTransport<UIMessage>
{
private resolve: () => ChatTransport<UIMessage>
constructor(
resolve: () => ChatTransport<UIMessage>
) {
this.resolve = resolve
}
sendMessages: ChatTransport<UIMessage>["sendMessages"] =
(options) => {
return this.resolve().sendMessages(options)
}
reconnectToStream: ChatTransport<UIMessage>["reconnectToStream"] =
async (options) => {
return this.resolve().reconnectToStream(options)
}
} }
export function useCompassChat(options?: UseCompassChatOptions) { export function useCompassChat(options?: UseCompassChatOptions) {
@ -62,68 +32,50 @@ export function useCompassChat(options?: UseCompassChatOptions) {
const dispatchedRef = useRef(new Set<string>()) const dispatchedRef = useRef(new Set<string>())
const bridgeRef = useRef(options?.bridgeTransport) // use the new agent hook
bridgeRef.current = options?.bridgeTransport const agent = useAgent({
agentServerUrl:
const defaultTransport = useMemo( typeof window !== "undefined" &&
() => "__TAURI__" in window
new DefaultChatTransport({ ? "http://localhost:3001"
api: "/api/agent", : "",
headers: { sessionId: options?.conversationId ?? undefined,
"x-current-page": pathname, currentPage: pathname,
"x-timezone": timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
Intl.DateTimeFormat().resolvedOptions() onFinish: options?.onFinish
.timeZone, ? (messages) => options.onFinish?.({ messages })
"x-conversation-id": : undefined,
options?.conversationId ?? "",
},
}),
[pathname, options?.conversationId]
)
const defaultRef = useRef(defaultTransport)
defaultRef.current = defaultTransport
// stable transport -- delegates at send-time
const transport = useMemo(
() =>
new DynamicTransport(() => {
if (bridgeRef.current) {
console.log(
"[chat] routing → bridge transport"
)
return bridgeRef.current
}
console.log(
"[chat] routing → default transport"
)
return defaultRef.current
}),
[]
)
const chatState = useChat({
transport,
onFinish: options?.onFinish,
onError: (err) => {
toast.error(err.message)
},
}) })
const isGenerating = const isGenerating =
chatState.status === "streaming" || agent.status === "streaming"
chatState.status === "submitted"
// dispatch tool-based client actions on new messages // dispatch tool-based client actions on new messages
useEffect(() => { useEffect(() => {
const last = chatState.messages.at(-1) const last = agent.messages.at(-1)
if (last?.role !== "assistant") return if (last?.role !== "assistant") return
// convert AgentPart[] to the format expected by dispatchToolActions
const toolParts = last.parts
.filter((p) => p.type === "tool-result")
.map((p) => {
const toolResult = p as Extract<
(typeof last.parts)[number],
{ type: "tool-result" }
>
return {
type: "tool-result",
toolCallId: toolResult.toolCallId,
state: "output-available",
output: toolResult.result,
}
})
dispatchToolActions( dispatchToolActions(
last.parts as ReadonlyArray<Record<string, unknown>>, toolParts as ReadonlyArray<Record<string, unknown>>,
dispatchedRef.current dispatchedRef.current
) )
}, [chatState.messages]) }, [agent.messages])
// initialize action handlers // initialize action handlers
useEffect(() => { useEffect(() => {
@ -159,13 +111,13 @@ export function useCompassChat(options?: UseCompassChatOptions) {
}, []) }, [])
return { return {
messages: chatState.messages, messages: agent.messages,
setMessages: chatState.setMessages, setMessages: agent.setMessages,
sendMessage: chatState.sendMessage, sendMessage: agent.sendMessage,
regenerate: chatState.regenerate, regenerate: agent.regenerate,
stop: chatState.stop, stop: agent.stop,
status: chatState.status, status: agent.status,
error: chatState.error, error: agent.error,
isGenerating, isGenerating,
pathname, pathname,
} }

View File

@ -1,77 +1,23 @@
import { describe, it, expect, vi, beforeEach } from "vitest" import { describe, it, expect, vi, beforeEach } from "vitest"
// the ws-transport module is "use client" and relies on describe("ws-transport", () => {
// browser globals (WebSocket, localStorage, window).
// we test what we can: the detectBridge timeout logic
// and the constructor / getApiKey behavior via mocks.
describe("WebSocketChatTransport", () => {
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
it("can be imported without throwing", async () => { it("exports BRIDGE_PORT and detectBridge", async () => {
// mock WebSocket globally so the module loads
vi.stubGlobal( vi.stubGlobal(
"WebSocket", "WebSocket",
class { class {
static OPEN = 1
readyState = 0
close = vi.fn() close = vi.fn()
send = vi.fn()
onopen: (() => void) | null = null onopen: (() => void) | null = null
onmessage: ((e: unknown) => void) | null = null
onerror: (() => void) | null = null onerror: (() => void) | null = null
onclose: (() => void) | null = null
addEventListener = vi.fn()
removeEventListener = vi.fn()
}, },
) )
const mod = await import("../ws-transport") const mod = await import("../ws-transport")
expect(mod.WebSocketChatTransport).toBeDefined()
expect(mod.BRIDGE_PORT).toBe(18789) expect(mod.BRIDGE_PORT).toBe(18789)
}) expect(typeof mod.detectBridge).toBe("function")
it("getApiKey returns null when window is undefined", { timeout: 15000 }, async () => {
// simulate server-side: no window
const originalWindow = globalThis.window
// @ts-expect-error intentionally removing window
delete globalThis.window
vi.stubGlobal(
"WebSocket",
class {
static OPEN = 1
readyState = 0
close = vi.fn()
send = vi.fn()
onopen: (() => void) | null = null
onmessage: ((e: unknown) => void) | null = null
onerror: (() => void) | null = null
onclose: (() => void) | null = null
addEventListener = vi.fn()
removeEventListener = vi.fn()
},
)
// re-import fresh
vi.resetModules()
const { WebSocketChatTransport } = await import(
"../ws-transport"
)
const transport = new WebSocketChatTransport()
// ensureConnected should reject because getApiKey
// returns null (or times out trying to connect)
await expect(
(transport as unknown as {
ensureConnected: () => Promise<void>
}).ensureConnected(),
).rejects.toThrow()
// restore window
globalThis.window = originalWindow
}) })
}) })
@ -89,7 +35,6 @@ describe("detectBridge", () => {
onerror: (() => void) | null = null onerror: (() => void) | null = null
onopen: (() => void) | null = null onopen: (() => void) | null = null
constructor() { constructor() {
// fire error on next tick
setTimeout(() => { setTimeout(() => {
if (this.onerror) this.onerror() if (this.onerror) this.onerror()
}, 0) }, 0)
@ -141,7 +86,6 @@ describe("detectBridge", () => {
close = vi.fn() close = vi.fn()
onerror: (() => void) | null = null onerror: (() => void) | null = null
onopen: (() => void) | null = null onopen: (() => void) | null = null
// never fires onopen or onerror
}, },
) )
@ -149,7 +93,6 @@ describe("detectBridge", () => {
const { detectBridge } = await import("../ws-transport") const { detectBridge } = await import("../ws-transport")
const promise = detectBridge("ws://localhost:18789") const promise = detectBridge("ws://localhost:18789")
// advance past the 3000ms CONNECT_TIMEOUT
await vi.advanceTimersByTimeAsync(3500) await vi.advanceTimersByTimeAsync(3500)
const result = await promise const result = await promise
expect(result).toBe(false) expect(result).toBe(false)

View File

@ -0,0 +1,64 @@
"use client"
import type { AgentMessage } from "@/lib/agent/message-types"
/**
* Transport abstraction for agent communication
* Supports both SSE (web/mobile) and WebSocket (desktop bridge)
*/
export interface AgentTransport {
sendMessages(options: {
messages: ReadonlyArray<AgentMessage>
headers?: Record<string, string>
signal?: AbortSignal
}): Promise<ReadableStream<Uint8Array>>
}
/**
* SSE transport for agent server
*/
export class AgentSSETransport implements AgentTransport {
constructor(
private readonly config: {
url: string
getAuthToken: () => Promise<string>
}
) {}
async sendMessages(options: {
messages: ReadonlyArray<AgentMessage>
headers?: Record<string, string>
signal?: AbortSignal
}): Promise<ReadableStream<Uint8Array>> {
const token = await this.config.getAuthToken()
const response = await fetch(`${this.config.url}/agent/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...options.headers,
},
body: JSON.stringify({
messages: options.messages.map((m) => ({
role: m.role,
content: m.parts
.filter((p) => p.type === "text")
.map((p) => (p as { text: string }).text)
.join(""),
})),
}),
signal: options.signal,
})
if (!response.ok) {
throw new Error(`Agent server error: ${response.status}`)
}
if (!response.body) {
throw new Error("No response body")
}
return response.body
}
}

Some files were not shown because too many files have changed in this diff Show More